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