Files
peerspeak/docs/contacts-plan.md
T
molluskandClaude Opus 4.8 0af2af2dd9 docs: scope contract for contacts + room-invite notifications (W7)
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 <noreply@anthropic.com>
2026-06-14 23:27:56 -04:00

139 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<Inbound>`. `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, ~12 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<Inbound>`
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 · SmallMedium
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
**MediumHigh, ~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`).