From b6984dda59ce972466a6fe229d52b77c4bb448f3 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Mon, 15 Jun 2026 01:00:11 -0400 Subject: [PATCH] docs: redesign W7 around member-issued/multi-bootstrap tickets Pivot the contacts plan away from the heavyweight control-plane design (always-on listener + persistent reachable identity + n0 DNS presence beacon) toward a serverless, no-beacon spine: member-issued, multi-bootstrap tickets. Any current member can mint a fresh ticket from their own live address + the room's existing topic_id (both already in hand; join() already takes extra_bootstrap: Vec), so rooms outlive their creator for ~no cost. Layered optional add-ons: one-click invite sharing (peerspeak:// deep links + QR + .peerspeak files, delegating delivery to Signal/email/OS), persistent identity (save-and-return), name-derived rooms, favorite-room bookmarks, and a privacy-bounded silent occupancy peek. The old heavyweight path is retained at the bottom of the doc as 'decided against', with the reasoning. Co-Authored-By: Claude Opus 4.8 --- docs/contacts-plan.md | 273 +++++++++++++++++++++++++----------------- 1 file changed, 162 insertions(+), 111 deletions(-) diff --git a/docs/contacts-plan.md b/docs/contacts-plan.md index f36d657..08856d5 100644 --- a/docs/contacts-plan.md +++ b/docs/contacts-plan.md @@ -1,138 +1,189 @@ -# Contacts list + room-invite notifications (W7) — plan / scope contract +# Durable rooms + frictionless invites (W7, redesigned) — plan / scope contract -**Status:** SCOPED, not started (assessment 2026-06-14 by the senior). This is the -scope contract; update it as phases land. Source: wishlist item **W7** in +**Status:** SCOPED, not started. **Redesigned 2026-06-15** (brainstorm with the +user) away from the original "port pixelpass's friends/control-plane" approach. +This is the scope contract; update it as phases land. Source: wishlist **W7** in `~/Documents/handoff-docs/Gemini/peerspeak/wishlist-handoff.md`. -## Goal +> **This doc supersedes the original W7 design.** The first cut (an always-on +> control-plane endpoint + persistent reachable identity + n0 DNS discovery) is +> recorded at the bottom under "Heavyweight path (decided against)" with the +> reasons. The redesign below delivers the same *user-visible* goals — save/reuse +> rooms, share an invite in one click — **without** an always-on listener, a +> presence beacon, or any phone-home, per [[user-security-preferences]] / +> [[user-telemetry-preference]]. -Save known peers and make joining their rooms frictionless: -- A **contacts list** — save known peers (by stable id + name) so you don't - re-exchange tickets every time. -- A **notification drawer** where incoming **room invites** appear out-of-room. -- Each invite carries the room **ticket in a button**; clicking it **auto-fills - the join-room text box** (one-click join — never copy/paste a ticket). +## Goals (user-visible) -## The decisive architectural finding (read this first) +1. **Reuse a room** without re-exchanging a fresh ticket every time. +2. **Share an invite in one click** through tools people already trust (Signal, + Telegram, email, SMS) — peerspeak makes the invite trivially shareable and + clickable; it does NOT try to be a messenger. +3. **Optionally** bookmark favorite rooms and see how busy one is before joining. -The wishlist says "port pixelpass's friends-list/notification system." The -*protocol* code ports nearly verbatim — **both projects are on the exact same -`iroh = "1.0.0-rc.0"`** — but the *integration* is genuinely new architecture for -PeerSpeak, because the two apps have opposite networking lifecycles: +Explicitly **out of scope** (dropped from the old W7, with intent): in-app +"who's online right now" presence, a bespoke in-app invite inbox/drawer, and any +mechanism that requires peerspeak to be reachable while idle. Those are exactly +the parts that cost the security/privacy budget, and the user's position is that +Signal/OS notifications already do them better. -- **pixelpass** runs a **persistent identity** + an **always-on control-plane - endpoint** (bound at GUI start, online the whole time the app is open), - separate from any video session. The friends system rides that. -- **PeerSpeak** has **no endpoint at all when not in a call**: `core/mod.rs:500` - (the `Join` handler) builds the endpoint and tears it down on `Leave`, and - `core/mod.rs:421` mints a **fresh random `SecretKey::generate()` every launch**, - so a peer's `EndpointId` changes each run and nothing is listening while idle. +## The reframe that makes this cheap (the spine) -W7 fundamentally needs to reach a contact who is **not in a room** — which -PeerSpeak currently cannot do at all. **That gap, not the friends list, is the -real work.** Porting the proven protocol/store is ~60% of the effort (low risk); -the new 40% is the always-on control plane (Phase 1) and the all-new drawer/ -contacts UI (Phase 3). +**Established facts (verified in-code this session):** +- A ticket is `PeerSpeakTicket { host_addr: EndpointAddr, topic_id: [u8;32] }` + (`src/network/mod.rs:81`). It carries **one** bootstrap address (the creator's) + plus the room's gossip topic id. +- The room's durable identity is the **`topic_id`** — every member is subscribed + to it. The host address is just *one door* into that topic. +- `RoomState::join` already accepts **`extra_bootstrap: Vec`** (added + by the A8 fix) — multiple dial targets are already supported. +- Identity (`SecretKey`) is minted **once per process** (`src/core/mod.rs:421`), + so a node id is stable for a running session but **regenerated on every fresh + launch**. Retained peers (A8 `known_peers`) live **in memory only**. +- Consequence (the dead-end this redesign fixes): a ticket's lifespan equals **the + creator's address staying live**, NOT room occupancy. Host goes offline → the + ticket can't bootstrap anyone, even if the room is full, because it only ever + carried the host's address. -## Reusable prior art (pixelpass — `~/git/butter/pixelpass/`) +**The spine — member-issued, multi-bootstrap tickets.** Today only the founder +mints a ticket. Instead, let **any current member** mint a fresh, fully valid +ticket on demand, stamped with **their own live address + the room's existing +`topic_id`** (both already in hand inside a live session). Every member is a door. -Field-verified + merged (`04bc0a8` + audit `6d0bf99`/`cfc4800`). Same stack, same -iroh version → API-compatible. +This alone makes **rooms outlive their creator**: a room is reachable as long as +*anyone* inside can hand out a ticket. Extended to carry *several* present members +as bootstrap candidates (the `Vec` is already there), the room loses its single +point of failure — a newcomer dials all listed members and connects to whoever's +up. -| pixelpass file | lines | reuse for PeerSpeak | -| --- | --- | --- | -| `src/common/identity.rs` | ~140 | **near-verbatim** — persistent ed25519 key, `load_or_create()`, atomic 0600 write. | -| `src/common/control.rs` | ~260 | **protocol reusable as-is** — one-message-per-connection JSON over a bi-stream, **authenticated sender via `conn.remote_id()`** (not spoofable), one-byte ACK = delivery+parse signal, `serve()` accept loop → `mpsc::Receiver`. `ControlMsg` variants `Hello`/`FriendRequest`/`FriendAccept`/`FriendDecline`/`ShareCode`. | -| `src/common/friends.rs` | ~330 | **near-verbatim** — `FriendStore`, mutual-consent `FriendState` (`PendingOutgoing`/`PendingIncoming`/`Accepted`), atomic write, keyed by stable `EndpointId`. | -| `src/common/alpn.rs` | — | `CONTROL_ALPN = b"pixelpass/ctrl/0"` pattern → mint `b"peerspeak/ctrl/0"`. | -| `src/gui/presence.rs` | ~299 | **reference, not copy** — service orchestration + online/presence indicators; PeerSpeak's iced UI differs, so adapt. | +**Cost: almost nothing.** The ticket type and the multi-bootstrap join path +already exist; a live member already holds the `topic_id` and `endpoint.addr()`. +"Export this room as an invite" is a UI button + a `CoreCommand` that reads +existing session state and builds a ticket. No new networking, no new identity, no +discovery. It stays inside today's trust model (a ticket already grants a full +join; members can already forward the founder's ticket — this just hands out a +*fresher* pointer). -## Locked design decisions +## Layers (each optional, stack as desired) -*(none yet — see "Open decisions" below; the user must answer the four before build)* +### Layer 0 — Member-issued / multi-bootstrap tickets — **the spine, Small** +As above. Delivers "rooms outlive their creator" and redundant bootstrap with no +identity/discovery changes. -## Adaptations from pixelpass's model +### Layer 1 — One-click invite sharing (deep links + QR + file) — **Small–Medium** +Make a ticket trivially shareable through existing channels: +- **`peerspeak://join/` URI scheme.** Register via a `.desktop` file with + `MimeType=x-scheme-handler/peerspeak;` + `xdg-mime default` (Plasma + bare X11 + both honor it). Paste the link into Signal/email; the recipient clicks → peerspeak + opens with a **"Join room? [preview] [Join] [Cancel]"** prompt. **Never + auto-join** (the one real new surface; same defensive ticket validation we + already do). +- **QR code** of the same URI (pure-Rust `qrcode`, no network — vet the crate) for + in-person / screen-to-phone handoff. +- **`.peerspeak` invite file** + the same MimeType association (double-click → + open + prompt). Sendable over any transport, incl. the user's `croc`. +- **"Invite via…"** buttons: `xdg-open "mailto:?body=peerspeak://join/"`. -- pixelpass's `ControlMsg::ShareCode { name, ticket }` (push a video share-code to a - friend) maps directly onto PeerSpeak's need: a **room invite** carrying a - `PeerSpeakTicket` (`src/network/mod.rs:81`). Likely rename to `RoomInvite`. -- Store contacts/identity as **JSON** (PeerSpeak already uses `serde_json` - everywhere; pixelpass uses `toml`) to avoid adding the `toml` dependency — see - Open decision #3. +### Layer 2 — Persistent identity — **Small** (enables save-and-return) +Port pixelpass's `identity.rs` (~140 lines, near-verbatim) → a stable key file +(JSON, not TOML — see decisions); swap `core/mod.rs:421` `generate()` for +`load_or_create()`. **Only needed to make a *saved* member-address survive that +member's restart** (i.e. rejoin a favorite room tomorrow). Layer 0 works without +it. Side effect: the gossip-signing key (S2) becomes stable across launches → +minor cross-room linkability — note it. -## Phases +### Layer 3 — Name-derived rooms — **Small–Medium** (memorable identity) +`hash(room_name [+ passphrase]) → topic_id`, so the room's *identity* is a +memorable shared word instead of a 32-byte blob, host-independent. Note the +entropy reality (established this session): a name can carry the **topic** (it +*derives* it) but **cannot contain the host key/address** — that's ~32 bytes of +irreducible random data. So a named room still needs Layer 0 for the *address* +half (who to bootstrap from). Name = identity; member-ticket = reachability. +Security: a guessable name = joinable by anyone → treat the name as a secret or +require a passphrase in the hash. -### Phase 0 — Persistent identity · Small (~1 session) -Port `identity.rs` → `~/.config/peerspeak/identity.key` (0600). Swap -`core/mod.rs:421` `SecretKey::generate()` for `load_or_create()`. Unit-testable -(hex round-trip tests come with it). -- **Side effect:** the gossip-signing key (security S2) becomes stable across - launches → minor cross-room linkability. Note it (Open decision #2). +### Layer 4 — Favorite rooms (bookmarks) — **Small** +Persist a list of saved rooms = `{ friendly_name, topic_id, last-known member +addresses }`. On "rejoin," dial the saved member addresses as `extra_bootstrap`; +if any one is online, gossip pulls you back into the rest. Refresh the saved +addresses every time you're in the room. Reliable only in proportion to Layer 2 +(stable member ids) + how many addresses you retain. Dials **only on explicit +rejoin** — no always-on endpoint, no beacon. -### Phase 1 — Always-on control plane · Large (the crux, ~1–2 sessions) -Stand up a **second, long-lived endpoint** on `peerspeak/ctrl/0`, online whenever -the app runs, owned by the core loop **outside** the `Join`/`Leave` session -lifecycle (today *all* networking lives inside `ActiveSession`). -- Port `control.rs` (protocol as-is). Wire its inbound `mpsc::Receiver` - into the core→UI event flow (new `UiEvent` variants + `CoreCommand`s for - send-request / accept / decline / invite). -- **Discovery:** the control endpoint likely needs **`presets::N0` (n0 DNS - discovery)** to be reachable by bare id while idle, even if the call posture - stays `RelayNoDiscovery`. This is Open decision #1. -- This is net-new long-lived networking; the bulk of W7's risk lives here. +### Layer 5 — Silent occupancy peek — **Small** (optional, flagged) +"How busy is this room?" without fully joining. Mechanism (verified in-code): a +background joiner enters the gossip mesh but **never broadcasts its own +`Announce`**, so it doesn't appear in anyone's roster (roster is Announce-driven, +`gossip.rs:315`); existing members re-announce on `NeighborUp` (`gossip.rs:344`), +so the peeker collects the roster passively, reduces it to `peers.len()`, and +leaves. +- **Privacy contract (must hold):** receives identities transiently (unavoidable — + the count *is* the roster), but **retains only the integer count**; never + displays/logs/persists a name or address. One-liner in code: "receives + identities transiently, retains only the count." +- **Honest caveats:** "invisible" is UI-level, not wire-level — members' gossip + logs a `NeighborUp` (acceptable per user). Must linger briefly or it undercounts. +- **Consent note:** shipping this means anyone with a ticket can count a room + without occupants' knowledge — a deliberate break from today's symmetric model + (to see in, you announce yourself). Inside the app's trust boundary (a ticket + already grants a full join), but name it as a conscious choice. -### Phase 2 — Contacts store + friend handshake · Medium (~1 session) -Port `friends.rs` (as JSON). Wire `FriendRequest`/`Accept`/`Decline` through the -control plane and core. Persist `~/.config/peerspeak/contacts.json`. Pure store = -unit-testable. Implement the `RoomInvite` send/receive path (carry a -`PeerSpeakTicket`). +## Recommended build order -### Phase 3 — UI: contacts list + notification drawer + one-click join · Medium (~1 session) -All-new iced UI (PeerSpeak has **no drawer/notification panel today** — the only -"drawer" is the chat layout): -- **Contacts view** — add by id, see online/pending status, accept/decline. -- **Notification drawer** — top-right popup, **reuse the layout-switcher popup - pattern** (`SelectRoomLayout` flow) in the always-visible top-right cluster; - incoming invites listed with a one-click-join button. -- **One-click join** — the button routes the carried ticket into the join-room - text-input state (validate, then pre-fill; do **not** auto-join). -- Online/presence indicators driven by `ControlMsg::Hello` refreshes. -- Cross-ref **W4 avatars**: show a contact's avatar (ties into `PeerState.avatar`). +**Layer 0 first** (the spine; unblocks "rooms outlive their creator" immediately), +then **Layer 1** (the one-click-share headline). Layers 2–5 are independent +add-ons to pick up by appetite. The serverless persistent-room story = **0 + 2 + 3 ++ 4**. The "polished invite" UX = always keep a fresh, multi-bootstrap "Invite to +this room" ticket ready to copy while in a session. -### Phase 4 — Security review + 2-machine field test · Small–Medium -New untrusted surface: -- **Unsolicited control messages from arbitrary ids** = friend-request spam / DoS - vector → need a cap / rate-limit (Open decision #4). -- **Invite-carried tickets** are untrusted even from a contact → validate - defensively before pre-fill; never auto-join. -- Sender identity is **authenticated** (`remote_id()`) — inherited from - pixelpass's design, good. -- `cargo audit` (no new deps expected if we store as JSON, not TOML). -- 2-machine field test on dopedart: add-contact both ways, request/accept, - send a room invite out-of-room, one-click join. +## Open decisions (user) -## Open decisions (the user must answer before build) - -1. **Discovery vs privacy (the big one).** Reaching an idle contact by stable id - needs **n0 DNS discovery**, which conflicts with the privacy-minded - `RelayNoDiscovery` default ([[user-security-preferences]] / telemetry stance). - Proposed: run **only the control plane** on n0 discovery; keep the user's - chosen posture for the call itself. Accept? -2. **Stable identity → stable gossip-signing key** across rooms (minor - linkability). Acceptable? -3. **Dependency:** store contacts/identity as **JSON** (no new dep) rather than - pixelpass's TOML. Confirm (default: JSON). -4. **Spam control:** cap / rate-limit unsolicited friend requests from the start? +1. **Persistent identity (Layer 2):** adopt it? It's the gate for save-and-return + rooms, and it makes the gossip-signing key stable across launches (minor + linkability). (Layer 0/1 don't need it.) +2. **Multi-bootstrap ticket size:** how many present members to bundle as bootstrap + (1 = today's size; more = more robust but a longer ticket). Also: tighten the + ticket encoding (drop JSON/base64 fat, index the relay URL) — lossless, roughly + halves the string; worth doing alongside. +3. **Storage format:** JSON (no new dep; matches `serde_json` everywhere) vs + pixelpass's TOML. Default: JSON. +4. **Ship Layer 5 (silent peek)** at all, given the consent tradeoff? If yes, + confirm the "retain only the count" contract. +5. **Named rooms (Layer 3):** require a passphrase in the hash, or treat the bare + name as the secret? ## Effort summary -**Medium–High, ~4 focused sessions.** ~60% proven low-risk port (same iroh -version); ~40% new design — the always-on control endpoint (Phase 1) and the -all-new drawer/contacts UI (Phase 3). +Spine + headline (Layers 0 + 1): **Small–Medium, ~1–2 sessions.** Full serverless +persistent-room story (+ 2, 3, 4): **+1–2 sessions.** Dramatically lighter than the +heavyweight path, and it adds **no always-on surface, no discovery beacon, no new +network-reachable listener.** ## Cross-references -- Wishlist W7 (the request, with pixelpass-port pointer): `wishlist-handoff.md`. -- Notification system precedent (chimes): `src/notify.rs` + W6 (per-sound toggles, - `7e75ae3`). -- Top-right popup pattern to reuse: the layout switcher (`SelectRoomLayout`). -- Ticket type to carry in invites: `PeerSpeakTicket` (`src/network/mod.rs:81`). +- Ticket type / multi-bootstrap join seam: `src/network/mod.rs:81`, `RoomState::join` + `extra_bootstrap` (A8). +- Identity mint point to make persistent: `src/core/mod.rs:421`. +- Roster = Announce-driven; re-announce on NeighborUp: `src/network/gossip.rs:315,344`. +- Notification precedent: `src/notify.rs` + W6 (`7e75ae3`). +- Reusable pixelpass code (Layer 2 identity port): `~/git/butter/pixelpass/src/common/identity.rs`. + +--- + +## Heavyweight path (decided against 2026-06-15 — kept for reference) + +The original W7 cut: a **persistent identity** + an **always-on control-plane +endpoint** (`peerspeak/ctrl/0`, online whenever the app runs, separate from any +session) + **n0 DNS discovery** to dial idle contacts by stable id, porting +pixelpass's `identity.rs`/`control.rs`/`friends.rs` (same `iroh = 1.0.0-rc.0`, so +the protocol ports near-verbatim). It would have added an in-app contacts list, +friend-request handshake, a notification drawer, and out-of-room invite delivery. + +**Why dropped:** it requires three things the user specifically wants to avoid — +a persistent reachable identity (cross-room linkable), an always-on listening +endpoint (standing spam/DoS surface), and an n0 DNS **presence beacon** +(phone-home). The user's key observation: out-of-room **delivery + presence is +already solved, better, by Signal/Telegram/OS notifications** (which also work +when peerspeak is closed — the bespoke drawer can't). So peerspeak should make the +invite trivially shareable and **delegate identity/presence/delivery** to those +tools, which is what the redesign above does. The pixelpass `identity.rs` port +survives as the optional Layer 2 only.