docs: redesign W7 around member-issued/multi-bootstrap tickets
Pivot the contacts plan away from the heavyweight control-plane design (always-on listener + persistent reachable identity + n0 DNS presence beacon) toward a serverless, no-beacon spine: member-issued, multi-bootstrap tickets. Any current member can mint a fresh ticket from their own live address + the room's existing topic_id (both already in hand; join() already takes extra_bootstrap: Vec), so rooms outlive their creator for ~no cost. Layered optional add-ons: one-click invite sharing (peerspeak:// deep links + QR + .peerspeak files, delegating delivery to Signal/email/OS), persistent identity (save-and-return), name-derived rooms, favorite-room bookmarks, and a privacy-bounded silent occupancy peek. The old heavyweight path is retained at the bottom of the doc as 'decided against', with the reasoning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+162
-111
@@ -1,138 +1,189 @@
|
||||
# Contacts list + room-invite notifications (W7) — plan / scope contract
|
||||
# Durable rooms + frictionless invites (W7, redesigned) — 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
|
||||
**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`.
|
||||
|
||||
## Goal
|
||||
> **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]].
|
||||
|
||||
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).
|
||||
## Goals (user-visible)
|
||||
|
||||
## The decisive architectural finding (read this first)
|
||||
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 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:
|
||||
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.
|
||||
|
||||
- **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.
|
||||
## The reframe that makes this cheap (the spine)
|
||||
|
||||
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).
|
||||
**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<EndpointAddr>`** (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.
|
||||
|
||||
## Reusable prior art (pixelpass — `~/git/butter/pixelpass/`)
|
||||
**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.
|
||||
|
||||
Field-verified + merged (`04bc0a8` + audit `6d0bf99`/`cfc4800`). Same stack, same
|
||||
iroh version → API-compatible.
|
||||
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.
|
||||
|
||||
| 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. |
|
||||
**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).
|
||||
|
||||
## Locked design decisions
|
||||
## Layers (each optional, stack as desired)
|
||||
|
||||
*(none yet — see "Open decisions" below; the user must answer the four before build)*
|
||||
### 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.
|
||||
|
||||
## Adaptations from pixelpass's model
|
||||
### Layer 1 — One-click invite sharing (deep links + QR + file) — **Small–Medium**
|
||||
Make a ticket trivially shareable through existing channels:
|
||||
- **`peerspeak://join/<ticket>` 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/<ticket>"`.
|
||||
|
||||
- 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.
|
||||
### 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.
|
||||
|
||||
## Phases
|
||||
### 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.
|
||||
|
||||
### 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).
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
### 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.
|
||||
|
||||
### 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`).
|
||||
## Recommended build order
|
||||
|
||||
### 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`).
|
||||
**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.
|
||||
|
||||
### 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 (user)
|
||||
|
||||
## 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?
|
||||
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
|
||||
|
||||
**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).
|
||||
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.**
|
||||
|
||||
## 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`).
|
||||
- Ticket type / multi-bootstrap join 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`.
|
||||
|
||||
---
|
||||
|
||||
## Heavyweight path (decided against 2026-06-15 — 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.
|
||||
|
||||
**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.
|
||||
|
||||
Reference in New Issue
Block a user