From 0af2af2dd9e9b32ba9fbc306d77f75a13d3f03dc Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sun, 14 Jun 2026 23:27:56 -0400 Subject: [PATCH] docs: scope contract for contacts + room-invite notifications (W7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bank the W7 investigation as a scope contract. Key finding: pixelpass's friends/control/identity code ports near-verbatim (same iroh 1.0.0-rc.0), but PeerSpeak's endpoint is room-scoped (built in Join, torn down on Leave) with a fresh identity each launch — so the real work is a new always-on control-plane endpoint + persistent identity, not the friends list. Phased plan (0 identity / 1 control plane / 2 store+handshake / 3 drawer UI / 4 security+field-test), ~4 sessions, with 4 open decisions that block build (discovery-vs-privacy being the big one). Co-Authored-By: Claude Opus 4.8 --- docs/contacts-plan.md | 138 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/contacts-plan.md diff --git a/docs/contacts-plan.md b/docs/contacts-plan.md new file mode 100644 index 0000000..f36d657 --- /dev/null +++ b/docs/contacts-plan.md @@ -0,0 +1,138 @@ +# Contacts list + room-invite notifications (W7) — 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 +`~/Documents/handoff-docs/Gemini/peerspeak/wishlist-handoff.md`. + +## Goal + +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). + +## The decisive architectural finding (read this first) + +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: + +- **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. + +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). + +## Reusable prior art (pixelpass — `~/git/butter/pixelpass/`) + +Field-verified + merged (`04bc0a8` + audit `6d0bf99`/`cfc4800`). Same stack, same +iroh version → API-compatible. + +| 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. | + +## Locked design decisions + +*(none yet — see "Open decisions" below; the user must answer the four before build)* + +## Adaptations from pixelpass's model + +- 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. + +## Phases + +### 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). + +### 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. + +### 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`). + +### 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`). + +### 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 (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? + +## 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). + +## 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`).