feat(web): reconcile the two chat implementations - #12
Merged
Conversation
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>
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two chats were written in parallel into the same directory without knowing about each other — PR #7 on
main, andfeat/nuri-passkey-walletwhich is what production has been serving. This was the blocking task for everything else.The resolution
Kept
main'sChatPage. 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
/admindepends on it and the chat does not:shared/lib/relay-socket.ts— persistent NIP-42 socket withOK-confirmed publishingshared/lib/use-relay-connection.ts— one socket per page, 2s reconnectfeatures/chat/channels.ts,features/chat/use-channels.tsDeleted 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
main's chat keepsqueryEvents/subscribeEvents;/adminuses 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.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,typecheckandbuildclean.Note:
pnpm check:file-sizesneedsCHECK_FILE_SIZES_BASE=nuri/mainlocally; it defaults toorigin/mainand this clone has nooriginremote.🤖 Generated with Claude Code