Skip to content

web: join-by-address — one input, no second machine (phone join without NIP-07 + canonical-origin signing) - #7311

Open
loviswaternakamoto wants to merge 7 commits into
block:mainfrom
skaists:join-by-address
Open

web: join-by-address — one input, no second machine (phone join without NIP-07 + canonical-origin signing)#7311
loviswaternakamoto wants to merge 7 commits into
block:mainfrom
skaists:join-by-address

Conversation

@loviswaternakamoto

Copy link
Copy Markdown

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).
  • Persistent browser-local identity (shared/lib/local-identity): generated once, stored locally, exportable as nsec, honestly labeled in the UI. signNostrEvent gains an optional secretKey path, so durable membership no longer requires an extension — requireNip07 flows 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.)
  • Canonical-origin signing law (bug-grade for alias-host deployments). NIP-98 u tags and NIP-42 relay tags must name the community's canonical origin as the relay declares it (NIP-11 /infopush.origin), while transport rides the road the user was given. On a deployment behind an alias host this is the difference between a 401 URL mismatch and a working join — the same mismatch will bite any client that signs against the alias it dialed.

The join.json convention (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-relay 0.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.devskaists.buzz, host-rewritten at the edge):

  • fresh key generated on the phone → POST /api/invites/claim signed with the canonical origin → 200 {status:"joined", role:"member"}
  • NIP-42 AUTH (relay tag = canonical origin) → OK trueREQ {kinds:[9], "#h":[…], limit} → history rendered, EOSE, live
  • message sent from the phone and received back through the subscription
  • an address whose origin publishes no material → refused in plain words, nothing guessed

Checks: 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/join that 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.

loviswaternakamoto and others added 3 commits August 7, 2026 22:34
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
@loviswaternakamoto
loviswaternakamoto requested a review from a team as a code owner September 4, 2026 01:05
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is f038cbbb0d4092a72ffd93f17916f84d2b39bb43...5dc78307cc6892c8b12acd3bc45ac357984fc6bd.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 5dc78307cc6892c8b12acd3bc45ac357984fc6bd to authorize a new review.
Any previous review applies only to its recorded range.

loviswaternakamoto added a commit to beehive-nature/beehive-nature that referenced this pull request Sep 4, 2026
… 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.
@loviswaternakamoto

Copy link
Copy Markdown
Author

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.

  • Relay side (defense in depth): kind 9 content carrying a real-shape secret token is refused before ingest, in plain words. The match is the token shape (prefix + ≥15 bech32 digits — a real nsec is ~63), so ordinary words that merely contain the prefix pass. 3 unit tests; proven live end-to-end on a throwaway stack (fresh owner mints → fresh member claims → NIP-42 → the nsec post returns OK false with the refusal; GUARD_VERDICT=PROVEN).
  • Client side: the composer refuses the same shape before signing, keeps the draft with a clear-the-draft action, and the substring trap sends normally (precision proven on a live hive). The copy-the-secret control moved off the composer into its own sheet (opened from the header) with the never-paste warning beside the copy button.

2. Room switcher for the phone viewjoin.json gains an optional operator-curated rooms: [{id, name}]; malformed rooms refuse the whole material (fail-closed). The phone view renders rooms as chips under the header; switching resets the pane and re-opens the live socket on that channel; the join lands on the default room. Proven live: history read in two rooms.

Receipts at 390 px: nsec paste refused + draft kept; switcher reading two rooms; the key sheet.

loviswaternakamoto added a commit to beehive-nature/beehive-nature that referenced this pull request Sep 4, 2026
…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
z2.1 seat added 2 commits September 5, 2026 03:24
…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
loviswaternakamoto added a commit to beehive-nature/beehive-nature that referenced this pull request Sep 5, 2026
…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.
@loviswaternakamoto

Copy link
Copy Markdown
Author

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.
@loviswaternakamoto

Copy link
Copy Markdown
Author

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.

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