web: join-by-address — one input, no second machine (phone join without NIP-07 + canonical-origin signing) - #7311
web: join-by-address — one input, no second machine (phone join without NIP-07 + canonical-origin signing)#7311loviswaternakamoto wants to merge 7 commits into
Conversation
Mirror of block/buzz, pinned at upstream commit 02f640b. License: Apache-2.0 (LICENSE carried; upstream has no NOTICE, so Apache §4(d) is N/A).
…a phone and the relay address lands in the room - web /join — paste the relay address; the community's pairing material (join.json, origin-relative, operator-gated: absent = plain refusal, never a guess) resolves the standing invite; a PERSISTENT browser-local key claims it (NIP-07 preferred whenever present, per the existing durable-membership gating); a phone-first room view opens on the live wire (NIP-42 AUTH → NIP-29 shape: kind 9, #h = channel id), history + composer, relay verdicts rendered verbatim, fail-closed throughout - canonical-origin signing law: NIP-98 u tags and NIP-42 relay tags must name the community's CANONICAL origin as the relay declares it (NIP-11 /info push.origin), while transport rides the road the user was given — proven against an alias-host deployment (relay.skaists.dev behind skaists.buzz) where signing the alias is refused 401 URL-mismatch - signer: optional persistent secretKey path (shared/lib/local-identity — generated once, stored locally, exportable as nsec, honestly labeled), so durable membership no longer REQUIRES an extension on phones that have none; requireNip07 flows unchanged - checks: pnpm check (biome + file-size + pubkey-truncation) green, tsc clean, vite build clean - proven LIVE on the estate hive (skaists.buzz / relay.skaists.dev, buzz 0.2.1 stock server): cold 390px phone context, no extension, address typed by hand → key made on the phone → standing invite claimed (role member) → NIP-42 ok on the alias road → room live, history rendered, message sent AND received; a relay with no published material refused in plain words
🔐 Codex Security Review
|
… stranger with ONLY a phone and the relay address ends up in the room, one input wide, zero configuration/tools/VPN/toggles. CLIENT WORK in the monorepo (fork skaists/buzz branch join-by-address @7605d1e, pushed; PR upstream block/buzz#7311 per SPEC-BUZZ-MULTIRELAY-1 §roads): persistent browser-local identity (closes the stranded-membership gap that made NIP-07 mandatory — NIP-07 stays preferred, requireNip07 flows unchanged), the /join address resolver (origin join.json pairing material, operator-gated by construction, absent = plain refusal = fail-closed held), a phone-first room view on the live NIP-42/NIP-29 wire (kind 9 #h, history + composer + relay verdicts verbatim); THE CANONICAL-ORIGIN SIGNING LAW found live and fixed client-side: NIP-98 u and NIP-42 relay tags must name the community's canonical origin from NIP-11 push.origin while transport rides the road given — signing the alias is 401 URL-mismatch, riding the canonical road dies on SNI filters, both proven. ESTATE DEPLOY (fork-to-prove): compose.join.yml + Caddyfile join blocks in both skaists sites (backups kept), /join/ + CORS-open join.json live on relay.skaists.dev AND skaists.buzz, door now phone-first. RECEIPTS: e2e/shots-buzz/join-390-address-only.png (the one line typed) → join-390-composing → join-390-in-the-room.png (THE ORDER'S RECEIPT: #welcome-everyone · skaists.buzz · live, history, the phone's OWN message delivered, key-export warning) → join-390-fail-closed.png (relay2, no material, refused in plain words); e2e/join-by-address-shot.mjs 8/8 PASS twice; e2e/join-by-address-phone-truth.mjs = the headless server-side truth (fresh key → claim 200 joined → AUTH ok → EOSE) that found both protocol laws. Standing-invite ledger in the dispatch (alive, TTL ≤30d/10k uses — re-mint is an owner ritual; test member rows listed for revocation). Monorepo checks green (pnpm check + tsc + vite build ×2 bases); estate CI carries docs+e2e artifacts only
…dings
RELAY GUARD (defense in depth): kind 9 (channel message) content carrying a bech32 SECRET KEY is refused before ingest — content_leaks_secret matches the token shape (nsec1 + >=15 bech32 digits, a real nsec is ~63) so ordinary words containing nsec1 inside them pass and only a real key shape trips it; plain-words refusal: "invalid: that looks like a private key (nsec1...) -- never send a secret into a room"; 3 unit tests (real token refused, normal + substring-trap + short-token + other-kind pass). FORK-TO-PROVEN LIVE on the throwaway stack (rotate-test.local, our debug binary, fresh owner+member): mint 200 -> claim 200 joined -> NIP-42 AUTH ok -> nsec post OK FALSE with the guard refusal -> GUARD_VERDICT=PROVEN.
CLIENT: the composer refuses the same token shape BEFORE signing, plain words, draft kept with a clear-the-draft action (a broadcast cannot be unsent); the substring trap sends normally (precision proven live on the estate hive); the copy-the-secret control moved OFF the composer into its own bottom sheet opened from the header (key), carrying the never-paste warning beside the copy button — the control that handles the secret is never next to the control that broadcasts.
ROOM SWITCHER: join.json gains optional rooms [{id,name}] (operator-curated; malformed rooms refuse the whole material — fail-closed); the phone view renders the rooms as chips under the header, switching resets the pane and re-opens the live socket on the chosen channel; the join lands on default_channel. Rooms proven live: welcome-everyone · general · huddle · PLUR, a message read in two rooms.
checks: cargo test -p buzz-relay --lib secret_guard 2/2, cargo build -p buzz-relay green (a [cfg(test)] attribute placement bug in the first cut was caught by cargo build vs cargo test divergence — the helper must sit OUTSIDE the tests mod); pnpm check + tsc + vite build green.
|
Pushed two founder-phone findings to this branch (256b4b4): 1. NEVER SEND A SECRET — a channel message is a broadcast, and a bech32 secret cannot be unsent.
2. Room switcher for the phone view — Receipts at 390 px: nsec paste refused + draft kept; switcher reading two rooms; the key sheet. |
…ndings landed, receipted 16/16 at 390px on the live hive: the composer refuses a real-shape nsec paste in plain words (draft kept, clear action), the substring trap PROVEN to send (no false positive, an actual send+receive); the copy-the-secret control moved OFF the composer into its own bottom sheet with the never-paste warning; rooms chips (welcome-everyone · general · huddle · PLUR from operator join.json) with a message read in TWO rooms; relay-side guard FORK-TO-PROVEN live on the throwaway stack (fresh owner mints → fresh member claims → AUTH ok → nsec post OK FALSE with the guard refusal, GUARD_VERDICT=PROVEN; token-shape match ≥15 bech32 digits after my own false-positive test caught the bare-substring cut; [cfg(test)] placement bug caught by build-vs-test divergence); PR block/buzz#7311 updated @256b4b45; ⚠ THE FINDING THE SHOT CAUGHT: db1ccc71… pasted a real-looking nsec into welcome-everyone ~7h before the guard existed — owner actions named in docs/dispatches/2026-09-04-never-send-a-secret.md
…vent (kind 34550) + phone URL join RELAY: KIND_COMMUNITY_JOIN_MATERIAL = 34550 (NIP-29's community-definition kind reused as the carrier) — admin-scoped to publish (Scope::AdminChannels: the community owner's key signs it, a member key cannot forge it), community-global, parameterized-replaceable by d tag. req.rs: the ONE unauthenticated read — a REQ whose every filter pins exactly kinds:[34550] with no other scoping and a small limit is served pre-AUTH (fail-closed shape guard, unit-tested: kindless/mixed/tagged/oversized/multi-filter all refuse); the query is pinned to global rows of that kind so no member data can appear on the path. This is what lets a stranger holding nothing but the wss:// URL join: no desktop, no QR, no join.json. WEB: join-event.ts fetches the event off the wire (unauthenticated REQ, timeout, fail-closed parse with the same well-formedness law as join.json); JoinPage tries the WIRE FIRST and keeps join.json as the fallback for relays that have not published the event; data-join-source records which path served. MOBILE: pairing 'Add Community' now accepts a wss:// URL (pair() dispatch) — join_by_address.dart ports the web protocol to the phone: fetch the event over the raw socket → mint the key IN-POCKET (nostr.Keys.generate — never asked 'do you have a key') → claim the standing invite over HTTP with the NIP-98 u tag naming the CANONICAL url while transporting on the pasted road (sign-the-identity, ride-the-road) → NIP-42 AUTH with the canonical origin in the relay tag must return OK true → Community stored against the canonical origin. Every refusal surfaces verbatim.
…iew phase change) — the e2e asserts body[data-join-source]=event
…erved by the relay wire; receipt docs/dispatches/2026-09-05-order-d-join-event.md. RELAY (fork @eeb252286, PR block/buzz#7311): KIND_COMMUNITY_JOIN_MATERIAL=34550 (NIP-29 community-definition kind as carrier) — admin-scoped publish, community-global, d-tag replaceable; the ONE unauthenticated read (fail-closed shape guard, unit-tested 1/1 on the box) serves exactly kinds:[34550] from global rows — a client knowing only the wss:// URL fetches the event, mints in-pocket, claims the invite, AUTHs, lives. DEPLOYED TO PROD (buzz-prod-relay-1 volume-mounted fork binary; host-glibc crash rolled back in a minute, rebuilt in rust:1.95-bookworm; zero new hosted dependency). WEB: join-event.ts wire-first with join.json fallback; door redeployed (base /join/; the bind-mount inode trap bit + healed, banked). MOBILE: pair() takes wss:// → join_by_address.dart (full protocol, key minted in-pocket, canonical-origin signing); flutter CI gates the PR — no flutter on this seat (FLAG). RECEIPTS: throwaway stack LIVE — publish AUTHed OK/publish OK + an UNAUTHENTICATED REQ returned the event; 390px browser e2e ALL PASS with body[data-join-source]=event, send+receive, zero errors (shots e2e/shots-buzz/join-event-390-*); prod regression 7/8 (the identity-panel FAIL pre-existing door drift). ops/join-event-publish.mjs staged as the founder gesture (BUZZ_OWNER_SEC env, never printed — the owner key is the founder's custody alone); until that run the prod door rides its proven json fallback. The founder's phone (caffeine) not driven from this seat — no adb/flutter (FLAG); phone-browser path regression-proven.
|
Join by address, take two — the relay now SERVES the join material itself. New in this push: (1) relay: kind 34550 (NIP-29 community-definition) as the owner-signed join-material event — admin-scoped to publish, community-global, d-tag replaceable — and the one unauthenticated read: a REQ pinned to exactly kinds:[34550] is served pre-AUTH behind a fail-closed shape guard (unit-tested), so a client holding nothing but the wss:// URL can fetch it and join. (2) web: the join page fetches the event off the wire first, keeping join.json as the fallback for relays that have not published it (body[data-join-source] records the path). (3) mobile: the Add Community field now accepts a wss:// URL — join_by_address.dart fetches the event, mints the key in-pocket, claims the standing invite (NIP-98 u tag on the canonical url), and proves membership with a NIP-42 AUTH OK. Proven live end-to-end on our rehearsal stack: unauthenticated REQ → event → join → send+receive (390px e2e, all pass). |
…ld (rider to the order) The relay's web /join view already joins a room by address (the same view watch.html embeds verbatim in an iframe — same relay, same origin, same localStorage identity: join once, every surface is the same member). So the phone-app change is a LAUNCHER: pair() dispatches a pasted wss:// URL to https://<host>/join/ via url_launcher (external browser — the identity the flow mints lives at the relay origin, shared with watch.html and the door); the /join view self-prefills when served by the relay, fetches the owner-signed join material (kind 34550) off the wire, mints the key on-device, and joins. The Dart protocol port (join_by_address.dart) is REMOVED — superseded by the proven web flow it duplicated.
|
Correction on the mobile half (and it got simpler): the relay's web /join view already joins a room by address — it is the same flow our watch surface embeds verbatim (same relay, same origin, same localStorage identity — join once, every surface is the same member). So the phone-app change is now a LAUNCHER, not a client rebuild: a pasted wss:// URL opens https:///join/ in the browser; that view self-prefills when served by the relay, fetches the owner-signed kind-34550 join material off the wire, mints the key on-device, and joins. The Dart protocol port from the previous push is removed (5dc7830) — the web flow it duplicated is the one flow. |
What
A stranger with only a phone and the relay address joins the community in the browser — no app install, no NIP-07 extension, no desktop to pair against, no invite code handed over by a member.
/join— the one-input join page. Paste the relay address → the community's pairing material is read from the relay's own origin (join.json, see below) → the standing invite is claimed with a key made on the phone → a phone-first room view opens on the live wire (NIP-42 AUTH → NIP-29 shape: kind 9,#h= channel id): history, composer, relay verdicts rendered verbatim. No material published → a plain refusal, never a guess (fail-closed).shared/lib/local-identity): generated once, stored locally, exportable as nsec, honestly labeled in the UI.signNostrEventgains an optionalsecretKeypath, so durable membership no longer requires an extension —requireNip07flows are unchanged and NIP-07 stays preferred whenever present. (The extension gate existed because the old fallback key was page-lifetime; a persistent local key closes that gap without weakening anything.)utags and NIP-42relaytags must name the community's canonical origin as the relay declares it (NIP-11/info→push.origin), while transport rides the road the user was given. On a deployment behind an alias host this is the difference between a 401URL mismatchand a working join — the same mismatch will bite any client that signs against the alias it dialed.The
join.jsonconvention (operator-gated by construction)Communities that want join-by-address serve, from the relay's origin:
{ "v": 1, "community": { "host": "example.com", "name": "Example" }, "invite_url": "/invite/v2.…", "default_channel": { "id": "channel-uuid", "name": "general" }, "note": "optional human line" }An operator who has not published it simply has none — the client fails closed. The file may be served by the relay or by any static file server on the origin (we serve it from the edge proxy);
Access-Control-Allow-Origin: *lets any hosted join page resolve any relay address. Minting rules (TTL ≤ 30 days, ≤ 10 000 uses) are unchanged — a standing invite is an operator's choice to spend them publicly.Proof (live, stock
buzz-relay0.2.1, no server changes)Cold 390 px phone context, no extension, address typed by hand against a members-only community behind an alias host (
relay.skaists.dev⇄skaists.buzz, host-rewritten at the edge):POST /api/invites/claimsigned with the canonical origin →200 {status:"joined", role:"member"}AUTH(relay tag = canonical origin) →OK true→REQ {kinds:[9], "#h":[…], limit}→ history rendered,EOSE, liveChecks:
pnpm check(biome + file-size + pubkey-truncation),tsc --noEmit,vite build— all green.Scope notes
Server-side intentionally untouched: this is client capability plus a convention. A relay-native
/api/jointhat mints/exposes the standing invite would remove the static-file step and make a nice follow-up; the client already prefers whatever the origin serves.