diff --git a/docs/contacts-plan.md b/docs/contacts-plan.md index 08856d5..f81010c 100644 --- a/docs/contacts-plan.md +++ b/docs/contacts-plan.md @@ -1,189 +1,171 @@ -# Durable rooms + frictionless invites (W7, redesigned) — plan / scope contract +# Friends-first contacts + ephemeral rooms (W7, redesigned) — plan / scope contract -**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`. +**Status:** SCOPED, not started. **Model locked 2026-06-15** after an extended +design session with the user. This is the scope contract; update it as phases +land. Source: wishlist **W7**. -> **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]]. +> **This doc supersedes two earlier drafts:** (1) the heavyweight "port pixelpass's +> always-on control plane + reachable identity + n0 discovery" cut, and (2) the +> room-centric "durable named rooms" cut. Both are folded into the bottom section +> "Earlier directions (superseded)" with the reasons. The model below is the one to +> build. -## Goals (user-visible) +## The model in one paragraph -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 friends list (stable node IDs) is the durable anchor; rooms are ephemeral +cosmetic labels.** You don't join a persistent room — you reach your *friends* and +label the gathering ("HangOut"). Identity is persistent so a friend's node ID never +changes. Presence is best-effort and private-by-default: your peerspeak, while +open, listens for pings *only from friends* and answers from your saved address — +no presence beacon. Reliability across network changes is opt-in per person +(voluntary discovery), and there's a universal floor that always works regardless: +a hand-shared member-issued ticket. Out-of-band coordination ("want to get on +peerspeak?") stays on Signal/Telegram — peerspeak is not a messenger. -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. +## Locked design decisions (user, 2026-06-15) -## The reframe that makes this cheap (the spine) +1. **Persistent identity = default foundation.** Load/create a stable `SecretKey` + at a key file instead of `generate()` each launch (`core/mod.rs:421`). Node ID + stable across launches. iroh doesn't force rolling IDs; today's behaviour is an + unrevisited default. +2. **Settings "Regenerate identity" control** (confirm + warn) for a deliberate + unlink / fresh start. Breaks others' saved reachability to you until re-exchange. +3. **Friends list keyed by node ID** (+ a locally editable display name). Fully + doable now that IDs are stable. +4. **Rooms are ephemeral cosmetic labels**, not addressable durable entities. A + room is a gathering with a name tag shared between clients for display; the + underlying gossip topic is per-session, not a saved/named identity. (Drops + name-derived topics, favorite-room persistence, empty-room reachability — all + the room-centric complexity.) +5. **Recents list** = local-only saved entries (cosmetic room name + the session's + topic + the member addresses seen). Purely local UI state. +6. **Presence via a friends-only idle listener.** While the app is open, peerspeak + answers pings **only** from node IDs on your friends list (authenticated via + `remote_id()`); everyone else is silently dropped. **No DNS presence beacon by + default.** This is the one new always-on-while-open primitive, deliberately + accepted because it's whitelisted + beacon-free (it avoids the stranger-spam and + phone-home costs that sank the heavyweight design). +7. **Presence control axis: invisible ↔ normal ↔ discoverable.** + - *invisible* — don't answer pings at all. + - *normal* (default) — answer friends-only, from saved address; no beacon. + - *discoverable* — opt-in publish to n0 DNS so friends can find you after a + network change. **Default OFF.** Toggle in Settings **and** a shortcut in the + friends list. Ideally time-boxed ("available for 30 min"). +8. **Discovery is asymmetric — only the mover publishes.** To find a friend who + moved networks, *that friend* enables discovery (publishes); everyone else just + looks them up (a DNS query, no publishing). So the privacy cost is localized to + the one person who needs the reliability; stationary friends change nothing and + keep no standing presence record. +9. **Saved addresses auto-heal silently.** Every successful connection to a friend + carries their current `PeerState.addr` over gossip; peerspeak compares and + re-saves it with no prompt. So for friends you actually talk to, saved addresses + stay current on their own. (Opportunistic: only updates while connected — a move + made while you're apart isn't seen until the next shared session, a ticket, or + discovery.) +10. **Universal floor: hand-shared member-issued tickets always work.** Whatever + auto-detection / discovery do, a friend can always mint a fresh ticket from + their current address and drop it in Signal; you bootstrap off it from any + network, any privacy setting, no beacon. Nobody is ever stuck — worst case is + "paste one link." -**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. +## Core primitives to build -**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. +### P1 — Persistent identity (+ regenerate) — Small, foundational +Port pixelpass's `identity.rs` (~140 lines, near-verbatim) → stable key file (JSON, +not TOML). Swap `core/mod.rs:421`. Settings regenerate control (confirm + warn). +**Spike first:** verify same-relay reachability by node ID without discovery on +`iroh = 1.0.0-rc.0` (a saved `EndpointAddr` should resolve while the peer stays on +the same relay). This underpins the whole presence story. -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. +### P2 — Friends list — Small +Persist friends = `{ node_id, display_name, last_known_addr }` (JSON store, atomic +write — pattern from pixelpass `friends.rs`). Add-from-room (you met them in a +gathering), local rename. No handshake required for the basic list (it's a local +address book), though a lightweight mutual-add is optional polish. -**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). +### P3 — Member-issued / multi-bootstrap tickets — Small (the ticket mechanism) +Let any current member mint a ticket from `{ their live addr, the session topic }` +(both already in hand). Pre-exists: the type `PeerSpeakTicket` and `RoomState::join` +already takes `extra_bootstrap: Vec` (A8). Optionally bundle several +present members for redundancy. Also tighten ticket encoding (drop JSON/base64 fat, +index the relay URL — lossless, ~halves the string). -## Layers (each optional, stack as desired) +### P4 — Friends-only idle listener + presence — Medium (the new primitive) +A long-lived endpoint while the app is open (own ALPN, e.g. `peerspeak/friends/0`), +**outside** the room-session lifecycle, that: +- answers a ping only if `remote_id()` ∈ friends list; +- replies with online + (if in a gathering) a fresh member-issued ticket + the + cosmetic room name; +- is governed by the invisible/normal/discoverable state. +On app open (and a slow rate-limited refresh), ping each friend; render their +status in the friends list. If a friend reports a gathering, show a small +**room-name tag + Join** entry; clicking bootstraps off their ticket. -### 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. +### P5 — Recents + UI — Medium +Local recents list (cosmetic room tags). Friends-list UI with status dots +("available" / "appears offline"), the discoverable shortcut toggle, and the +join-friend's-room entries from P4. Invisible/discoverable controls in Settings. -### 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/"`. +### P6 — Opt-in discovery — Small +Wire the *discoverable* state to n0 DNS publish (default off, time-boxed). Lookup +path for finding a discoverable friend whose saved address went stale. -### 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. +### P7 — Security review + 2-machine field test — Small–Medium +Surface: the friends-only listener (confirm non-friends are truly dropped pre-any +state change; rate-limit pings), tickets from friends (validate defensively, no +auto-join), the discovery publish (only when toggled, ideally auto-expiring). +`cargo audit` (JSON store → no new deps expected). Field test on dopedart. -### 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. +## The connect flow (the user's scenario, end to end) +1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled + "HangOut." +2. You open peerspeak → it pings your friends → X answers (you have X's address; X + is on a reachable relay) and reports "in HangOut, here's a ticket." +3. Your friends list shows **X · HangOut [Join]**. Click → bootstrap off X's ticket + → you're in. Gossip refreshes everyone's saved addresses. +4. If X had moved to an unreachable network: X either flips on *discoverable*, or + drops a fresh ticket in Signal — the manual floor. Once you connect once, X's new + address auto-saves for next time. -### 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. +## Build order & effort +**P1 (identity + spike) first** — everything depends on it. Then **P2 (friends) → +P3 (tickets) → P4 (listener/presence)** is the spine that makes the friends list +live. **P5 (recents/UI)** alongside P4. **P6 (discovery)** and **P7 (security/field +test)** last. Rough total: **~3–4 focused sessions.** No always-on stranger-facing +surface, no default beacon. -### 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. - -## Recommended build order - -**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. - -## Open decisions (user) - -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 - -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.** +## Open decisions (small, remaining) +1. **Mutual friend-add handshake** (accept/decline) vs a purely local add-by-id? + The local list works alone; a handshake is polish + prevents one-sided + "friends." Lean: start local, add handshake later if wanted. +2. **Ticket bundle size** for multi-bootstrap (1 = today; more = robust, longer). +3. **Discovery time-box default** (e.g. 30 min) vs sticky-until-off. ## Cross-references -- Ticket type / multi-bootstrap join seam: `src/network/mod.rs:81`, `RoomState::join` +- Ticket type / multi-bootstrap 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`. +- Identity mint point: `src/core/mod.rs:421`. +- Presence carries `addr` (auto-heal source): `PeerState.addr`, re-announce on + NeighborUp `src/network/gossip.rs:315,344`. +- Reusable pixelpass code: `~/git/butter/pixelpass/src/common/{identity,friends}.rs` + (port the store + identity; the always-on control plane is NOT ported). --- -## Heavyweight path (decided against 2026-06-15 — kept for reference) +## Earlier directions (superseded — 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. +**Heavyweight control-plane cut (decided against 2026-06-15).** An always-on +control-plane endpoint reachable by *strangers* + a presence beacon (n0 DNS) + +porting pixelpass's full friends/control stack. Dropped: it needs a persistent +*publicly* reachable identity, a stranger-facing listener (spam/DoS surface), and a +default phone-home beacon — all of which the friends-only listener + opt-in +discovery avoid. The user's key point: out-of-room *delivery/presence* is already +done better by Signal/OS notifications, so delegate it. -**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. +**Room-centric cut (folded into the friends-first model 2026-06-15).** Durable +named rooms (`sha256(name) → topic`), favorite-room persistence, empty-room +reachability. Dropped because it fought the serverless reality (an empty room has +no one to bootstrap from) and the user reframed rooms as **ephemeral cosmetic +labels** — the durable thing is the friends list, not the room. The member-issued +ticket and the cosmetic name-tag survive from this cut; the persistence machinery +does not.