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>
This commit is contained in:
@@ -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<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, ~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<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 · 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`).
|
||||||
Reference in New Issue
Block a user