web: join-by-address — one input, no second machine (phone joins by address alone) [DCO rebuild] - #7673
Open
loviswaternakamoto wants to merge 5 commits into
Open
web: join-by-address — one input, no second machine (phone joins by address alone) [DCO rebuild]#7673loviswaternakamoto wants to merge 5 commits into
loviswaternakamoto wants to merge 5 commits into
Conversation
…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 (cherry picked from commit 7605d1e) Signed-off-by: loVis waTer <loviswater44@gmail.com> Co-authored-by: zCode <zcode@skaists.dev>
…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.
(cherry picked from commit 256b4b4)
Signed-off-by: loVis waTer <loviswater44@gmail.com>
Co-authored-by: zCode <zcode@skaists.dev>
…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. (cherry picked from commit 088a677) Signed-off-by: loVis waTer <loviswater44@gmail.com> Co-authored-by: zCode <zcode@skaists.dev>
…iew phase change) — the e2e asserts body[data-join-source]=event (cherry picked from commit eeb2522) Signed-off-by: loVis waTer <loviswater44@gmail.com> Co-authored-by: zCode <zcode@skaists.dev>
…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. (cherry picked from commit 5dc7830) Signed-off-by: loVis waTer <loviswater44@gmail.com> Co-authored-by: zCode <zcode@skaists.dev>
🔐 Codex Security Review
|
This was referenced Sep 16, 2026
Open
Author
|
Note for reviewers — the security-review pipeline skips outside contributions until triggered. To run it over this exact range, one line from a block-org member: (head sha for this PR: |
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.
Rebuild of #7311 (DCO-fixed)
Supersedes #7311 — same changes, rebuilt as a clean branch directly off current
mainwith a signed commit range (the old branch carried an unsigned fork merge commit that failed the DCO check; history preserved on the superseded branch, no force-push). Cherry-picked with--no-commit+ recommit so every commit is author-signed; each message carries its(cherry picked from commit …)trace.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.