Skip to content

feat(web): reconcile the two chat implementations - #12

Merged
eminogrande merged 11 commits into
mainfrom
chore/reconcile-chat
Jul 30, 2026
Merged

feat(web): reconcile the two chat implementations#12
eminogrande merged 11 commits into
mainfrom
chore/reconcile-chat

Conversation

@eminogrande

Copy link
Copy Markdown

Two chats were written in parallel into the same directory without knowing about each other — PR #7 on main, and feat/nuri-passkey-wallet which is what production has been serving. This was the blocking task for everything else.

The resolution

Kept main's ChatPage. It is the more complete one — markdown rendering and an explicit kind:9021 join flow — and it went through review, while the branch's version was written from an older base by someone who had not seen it.

Kept the branch's infrastructure, because /admin depends on it and the chat does not:

  • shared/lib/relay-socket.ts — persistent NIP-42 socket with OK-confirmed publishing
  • shared/lib/use-relay-connection.ts — one socket per page, 2s reconnect
  • features/chat/channels.ts, features/chat/use-channels.ts

Deleted the branch's now-orphaned use-chat.ts.

Also brings in everything else from the branch: /admin (channels, members, invite links), the signing-identity display, the Connect return-path fix, and the WebAuthn focus fix.

Deliberate trade-offs

  • The two transports coexist. main's chat keeps queryEvents / subscribeEvents; /admin uses the persistent socket. Collapsing them is worth doing, but it is not a merge-conflict decision, and doing it here would have hidden a UI swap inside a reconciliation commit.
  • Production's chat UI changes on the next deploy, from the branch's plainer timeline to main's markdown one. A reviewed, more featureful implementation should not lose a tie-break to an unreviewed one just because it happened to be deployed first. Rollback is one file.

Checks

16 unit tests green — both suites now run together. biome, typecheck and build clean.

Note: pnpm check:file-sizes needs CHECK_FILE_SIZES_BASE=nuri/main locally; it defaults to origin/main and this clone has no origin remote.

🤖 Generated with Claude Code

eminogrande and others added 11 commits July 28, 2026 12:21
support.nuri.com only ever shipped a repo browser, so a visitor who
finished the Nuri passkey flow landed on "This community is empty" — the
login was fine, the chat simply did not exist in the web client.

The relay already allows what we need: open channels are readable *and*
writable by any authenticated relay member without an explicit join
(handlers/ingest.rs check_channel_membership). So no relay change — the
missing half was purely client-side.

- relay-socket.ts: persistent NIP-42-authed socket. queryEvents() is
  one-shot (closes at EOSE); chat needs the subscription left open so the
  relay keeps pushing, plus an outbound EVENT path with OK confirmation.
- features/chat: kind:39000 discovery -> channel list, live kind:9/40002
  per channel, publish kind:9 with an h tag. Auto-reconnect after 2s.
- Chat takes "/", the repo browser moves back to "/repos". Both keep the
  Nuri passkey gate.
- currentSignerPubkey() so the timeline can mark your own messages.

Deliberately not in this step: profile names (pubkeys are truncated),
threads, reactions, unread state, channel creation from the web.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emin asked for channel administration in a web frontend instead of
`buzz channels create`.

Deliberately NOT hung off the existing admin dashboard: its only
authorization is a Host-header match against BUZZ_ADMIN_HOST
(api/admin/auth.rs:13) — no login, no signature. Acceptable for a
read-only reports board, a hole for writes. Adding channel mutations
there would mean anyone who can reach that host may reshape the
community.

Instead reuse what the relay already authenticates: kind:9007 (create
group) and kind:9002 (edit metadata, ["visibility","open"|"private"]).
Those are authorized per kind against the signing pubkey, so /admin only
builds and publishes — a non-owner gets OK false and the UI says so. No
relay change, no new endpoint, no second auth path to keep honest.

Also fixes a filter bug from the previous commit: the chat dropped every
channel tagged `private`. Wrong — kind:39000 is stored channel-scoped, so
a private channel in the relay's response is one this member belongs to
and must see. Only `hidden` (DMs) is filtered now.

Relay socket + channel discovery moved to shared hooks so chat and admin
run one connection each instead of duplicating the reconnect logic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records why the passkey login was never the problem, why open channels
made a relay change unnecessary, and why channel admin does not hang off
the host-header-gated admin dashboard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emin asked how admins get designated. Answer: the relay already has the
whole system, nothing to invent.

- relay_members carries owner | admin | member.
- kind:9030 add, 9031 remove, 9032 change role, with the matrix in
  handlers/relay_admin.rs:6 — admins may add/remove members, only the
  owner grants admin, and the owner role itself is immovable by event.
- Ownership changes only through RELAY_OWNER_PUBKEY, deliberately, so a
  compromised admin cannot lock the owner out.
- POST /api/invites mints a join link; owner/admin only.

So this is purely UI over what exists: member list from the relay-signed
kind:13534 snapshot, add by pubkey, promote/demote, remove, and an invite
link button. The owner row shows no buttons — the relay refuses every
role change on it, and rendering controls that always fail would be a
lie.

Invite links grant `member`, not admin: invite_token.rs:135 hardcodes the
role. Promotion is a second step via kind:9032, and the UI says so
instead of implying the link can carry admin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without this the admin page is unusable in practice: the browser signs
with the Nuri passkey key, which is a *different* key from the one the
desktop app holds. RELAY_OWNER_PUBKEY on support.nuri.com points at the
desktop key, so every privileged action from the browser gets OK false
and there was no way to see why — or to find out which key to authorize.

Shows both npub (for human comparison) and hex (what RELAY_OWNER_PUBKEY
and kind:9030 expect), and says outright that the two identities differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Logging in on /admin dumped the user into the chat. startConnectFlow built
the Connect callback from window.location.origin, dropping the path, so
every deep link came back as "/" — the gate then rendered whatever lives
at the root.

Keep the pathname. Query and hash are stripped so a stale nuri_connect
from an abandoned flow cannot ride along and collide with the fresh tab
nonce.

Pulled the URL construction into connectReturnUrl() so the rule is a
tested pure function rather than an inline expression that reads fine and
silently loses the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two separate failures from the same round trip through Connect.

1. "The document is not focused." — Chrome rejects
   navigator.credentials.get() when the document lacks focus, and the gate
   reaches for the passkey the moment the Connect redirect lands, before
   the tab is focused again. Wait for the focus event first, and say
   "click anywhere in this window" instead of appearing stuck.

2. The member list showed the *previous* owner as owner and the current
   one as member. bootstrap_owner (main.rs:294) rotates ownership in a
   plain SQL transaction at startup and never republishes the kind:13534
   snapshot the client renders, so the list is stale until some 9030/9031
   triggers a republish. Hiding the row actions by the role that snapshot
   claims then locked the real owner out of removing the demoted one.

   Hide actions only for your own row — the one case we know for certain
   from the signing key, and the one the relay refuses regardless of role.
   Every other row keeps its buttons and lets the relay decide.

The relay-side half of 2 (publish the snapshot after bootstrap_owner) is
not fixed here: it needs a relay image rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Session-closing documentation so the next person can resume without
re-deriving anything.

- docs/nuri-support-portal.md — the handover document: architecture, the
  exact deploy commands and server paths, both authorization layers with
  file references, how to rotate the owner without asking anyone to copy
  a 64-char key, the ranked issue list, and what was deliberately left
  out so it does not get re-discovered as a gap.
- CHANGELOG.md — an Unreleased section for the fork covering the seven
  commits shipped today, each with the reason it exists.
- README.md — the Nuri section claimed the relay runs closed with
  BUZZ_REQUIRE_RELAY_MEMBERSHIP=true. Production runs it false: the relay
  is open, any Nostr key can participate, and the passkey is not enforced
  as an entry requirement. Corrected with a callout rather than a quiet
  edit, because anyone who read the old text drew the wrong security
  conclusion.
- SESSION_LOG.md — decisions, the three problems nobody had on their
  radar, my own three mistakes and how they were caught, and a next-steps
  list ordered by what blocks what.

The single most important item: the relay image build path for
support.nuri.com is unknown, so no Rust change can ship. It blocks three
of the open fixes and is why this session stayed entirely in web/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch carried an earlier draft of the handover docs that main has
since superseded, including a claim that turned out to be wrong: that the
relay build path was unknown and blocked every Rust change. It is not
unknown — ci.yml uploads buzz-relay binaries, docker.yml publishes a GHCR
image via vars.GHCR_IMAGE, and server-deploy-artifact.yml extracts the
binary from that image. What is actually open is narrower: whether the
running container came from that path.

Taking main's versions of README, CHANGELOG and the handover doc so the
branch cannot overwrite the corrected text when it eventually merges, and
correcting the same claim in the session log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two chats were written in parallel into the same directory without
knowing about each other: PR #7 on main, and feat/nuri-passkey-wallet
which is what production has been serving. Merging them was the blocking
task for everything else.

Kept main's ChatPage. It is the more complete one — markdown rendering
and an explicit kind:9021 join flow — and it went through review, while
the branch's version was written from an older base by someone who had
not seen it.

Kept the branch's infrastructure, because /admin depends on it and the
chat does not: relay-socket.ts (persistent NIP-42 socket with
OK-confirmed publishing), use-relay-connection.ts, chat/channels.ts and
chat/use-channels.ts. Deleted the branch's now-orphaned use-chat.ts.

The two transports coexist deliberately: main's chat keeps queryEvents /
subscribeEvents, /admin uses the persistent socket. Collapsing them is
worth doing but is not a merge-conflict decision, and doing it here would
have hidden a UI swap inside a reconciliation commit.

What this costs: production's chat UI changes on the next deploy, from
the branch's plainer timeline to main's markdown one. Deliberate — a
reviewed, more featureful implementation should not lose a tie-break to
an unreviewed one just because it happened to be deployed first. Rollback
is one file.

Checks: 16 unit tests green (both suites), biome, typecheck and build
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Emin Mahrt <emin@emin.de>
The handover described a world with two chat implementations, a branch
ahead of main, and three overlapping PRs. All of that is resolved in this
PR, so the docs would have shipped stale in the same merge that made them
wrong.

Now records: main is the source of truth, how the chat reconciliation
went and what it deliberately left coexisting, that the deployed bundle
predates it, and that PR #8 needs a rebase before review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Emin Mahrt <emin@emin.de>
@eminogrande
eminogrande merged commit 0f767e2 into main Jul 30, 2026
26 of 27 checks passed
@eminogrande
eminogrande deleted the chore/reconcile-chat branch July 30, 2026 00:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant