Files
peerspeak/docs/contacts-plan.md
T
molluskandClaude Opus 4.8 c12d15ed7d feat(w7): recently-joined rooms list with one-click rejoin (P5)
Add a purely-local, most-recent-first recents list so users can hop back
into a room they were just in — meaningful now that rooms carry cosmetic
labels.

- src/recents.rs (new): `Recent {name, ticket, joined_at}`, `push_recent`
  (de-dupes by room `topic_id`, refresh-and-move-to-front, caps at
  RECENTS_MAX=12), `remove_recent`, `relative_time` ("5m ago"). 6 tests.
- PeerSpeakTicket::topic_of — the stable room identity used as the de-dup
  key (host addr + label change between members/sessions; topic doesn't).
- AppConfig.recents (`#[serde(default)]`, back-compat) — local UI state,
  never sent over the wire.
- Recorded on RoomJoined (label via label_of); rendered as a "Recent
  rooms" block in connect_card (each entry → JoinRecent, ✕ → RemoveRecent),
  shown only when non-empty.

Rejoin is best-effort by design: the stored ticket only admits us while
the room is still live and reachable (reliability is P6 discovery + the
member-issued ticket floor, not this list).

263 lib tests green, clippy --all-targets clean. Recents UI
screenshot-verified (seeded config → ages + Untitled-room fallback render).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:58:14 -04:00

273 lines
18 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.
# Friends-first contacts + ephemeral rooms (W7, redesigned) — plan / scope contract
**Status (2026-06-15):** IN PROGRESS. Model locked after an extended design
session; **P1 (identity) + P2 (friends store) + P3 (member tickets) DONE & 2-machine
field-verified; P4 (idle listener) pure core + wire transport DONE, live
integration NOT wired; P5 UI partial.** See the per-phase "Core primitives" section
below for exact status and `handoff.md` for the live summary. This is the scope
contract; update it as phases land. Source: wishlist **W7**.
> **This doc supersedes two earlier drafts:** (1) the heavyweight "port pixelpass's
> always-on control plane + reachable identity + n0 discovery" cut, and (2) the
> room-centric "durable named rooms" cut. Both are folded into the bottom section
> "Earlier directions (superseded)" with the reasons. The model below is the one to
> build.
## The model in one paragraph
**The friends list (stable node IDs) is the durable anchor; rooms are ephemeral
cosmetic labels.** You don't join a persistent room — you reach your *friends* and
label the gathering ("HangOut"). Identity is persistent so a friend's node ID never
changes. Presence is best-effort and private-by-default: your peerspeak, while
open, listens for pings *only from friends* and answers from your saved address —
no presence beacon. Reliability across network changes is opt-in per person
(voluntary discovery), and there's a universal floor that always works regardless:
a hand-shared member-issued ticket. Out-of-band coordination ("want to get on
peerspeak?") stays on Signal/Telegram — peerspeak is not a messenger.
## Locked design decisions (user, 2026-06-15)
1. **Persistent identity = default foundation.** Load/create a stable `SecretKey`
at a key file instead of `generate()` each launch (`core/mod.rs:421`). Node ID
stable across launches. iroh doesn't force rolling IDs; today's behaviour is an
unrevisited default.
2. **Settings "Regenerate identity" control** (confirm + warn) for a deliberate
unlink / fresh start. Breaks others' saved reachability to you until re-exchange.
3. **Friends list keyed by node ID** (+ a locally editable display name). Fully
doable now that IDs are stable.
4. **Rooms are ephemeral cosmetic labels**, not addressable durable entities. A
room is a gathering with a name tag shared between clients for display; the
underlying gossip topic is per-session, not a saved/named identity. (Drops
name-derived topics, favorite-room persistence, empty-room reachability — all
the room-centric complexity.)
5. **Recents list** = local-only saved entries (cosmetic room name + the session's
topic + the member addresses seen). Purely local UI state.
6. **Presence via a friends-only idle listener.** While the app is open, peerspeak
answers pings **only** from node IDs on your friends list (authenticated via
`remote_id()`); everyone else is silently dropped. **No DNS presence beacon by
default.** This is the one new always-on-while-open primitive, deliberately
accepted because it's whitelisted + beacon-free (it avoids the stranger-spam and
phone-home costs that sank the heavyweight design).
7. **Presence control axis: invisible ↔ normal ↔ discoverable.**
- *invisible* — don't answer pings at all.
- *normal* (default) — answer friends-only, from saved address; no beacon.
- *discoverable* — opt-in publish to n0 DNS so friends can find you after a
network change. **Default OFF.** Toggle in Settings **and** a shortcut in the
friends list. Ideally time-boxed ("available for 30 min").
8. **Discovery is asymmetric — only the mover publishes.** To find a friend who
moved networks, *that friend* enables discovery (publishes); everyone else just
looks them up (a DNS query, no publishing). So the privacy cost is localized to
the one person who needs the reliability; stationary friends change nothing and
keep no standing presence record.
9. **Saved addresses auto-heal silently.** Every successful connection to a friend
carries their current `PeerState.addr` over gossip; peerspeak compares and
re-saves it with no prompt. So for friends you actually talk to, saved addresses
stay current on their own. (Opportunistic: only updates while connected — a move
made while you're apart isn't seen until the next shared session, a ticket, or
discovery.)
10. **Universal floor: hand-shared member-issued tickets always work.** Whatever
auto-detection / discovery do, a friend can always mint a fresh ticket from
their current address and drop it in Signal; you bootstrap off it from any
network, any privacy setting, no beacon. Nobody is ever stuck — worst case is
"paste one link."
## Core primitives to build
### P1 — Persistent identity (+ regenerate) — ✅ DONE 2026-06-15
- **Foundation (`d157d78`):** `src/identity.rs` (`load_or_create` / `regenerate` /
`save` over a `0600` hex key file at `~/.config/peerspeak/identity.key`, atomic
write, malformed = hard error, path-injectable fs seam + 8 unit tests incl.
create/persist/regenerate/perms). Core loop loads the stable key instead of
`generate()`, falling back to ephemeral only if the file can't be read/written.
Key file is raw hex (a secret, not structured config).
- **Regenerate + degraded warning (`a99c789`):** `UiEvent::IdentityStatus
{ node_id, persisted, error }` (startup + after regenerate);
`CoreCommand::RegenerateIdentity` (mints+persists a fresh key, swaps the core's
live key for the next join); Settings "Identity" section showing the permanent
ID, a Regenerate button behind a destructive-action confirm modal, and a standing
red "Identity not saved" banner when `persisted == false`.
- **Verified:** 238 lib tests green, clippy clean, release builds. **Screenshot-
verified** — Identity section, confirm modal, and the degraded banner (`chmod 000`
the key → "Permission denied (os error 13)" warning; Regenerate clears it).
- **Deferred (small, optional):** an always-visible degraded badge *outside*
Settings (today the warning lives in the Identity section only). The
relay-locatability spike (a saved `EndpointAddr` resolving by node ID without
discovery while on the same relay) still wants confirming during 2-machine
testing — it underpins the P4 presence story.
- **Spike:** verify same-relay reachability by node ID without discovery on
`iroh = 1.0.0-rc.0` (a saved `EndpointAddr` should resolve while the peer stays
on the same relay). Underpins the whole presence story; do during 2-machine
testing.
### P2 — Friends list — Small
Persist friends = `{ node_id, display_name, last_known_addr }` (JSON store, atomic
write — pattern from pixelpass `friends.rs`). Add-from-room (you met them in a
gathering), local rename. No handshake required for the basic list (it's a local
address book), though a lightweight mutual-add is optional polish.
### P3 — Member-issued / multi-bootstrap tickets — ✅ core DONE 2026-06-15 (`c90dcf7`)
- **Member-issued ticket (DONE, non-breaking):** the ticket the UI shows/copies is
now stamped with the local member's own live address + the room's topic (pure
seam `PeerSpeakTicket::restamp`, +3 tests), so every member hands out a working
door pointing at themselves → rooms outlive their creator. Only the *display*
copy is re-stamped; the join/bootstrap `ticket_str` + A8 retain logic are
untouched. 248 lib tests green. ⚠️ **2-machine field test pending** (creator
leaves → a joiner's ticket still admits a newcomer).
- **Deferred (both BREAKING wire changes — want daylight + 2-machine):**
multi-bootstrap ticket (bundle several present members as `extra_bootstrap`,
needs a ticket struct change) and tighter ticket encoding (drop JSON/base64 fat,
index the relay URL; lossless ~halving).
### P4 — Friends-only idle listener + presence — pure core DONE 2026-06-15 (`a897f5c`)
- **Pure protocol + policy DONE** (`src/presence.rs`, +8 tests): `ControlMsg`
{Ping, Pong{room: Option<RoomPresence>}} (tagged JSON, unknown tags rejected);
`should_answer(from, friends, mode)` — the friends-only + not-invisible auth gate
(the anti-stranger-surface whitelist; `from` = authenticated `remote_id`);
`PresenceMode` {Invisible, Normal(default), Discoverable} persisted in
`AppConfig`; `interpret_pong` — defensive (sanitizes peer room name, only
surfaces a joinable room if the ticket parses, never auto-joins).
- **Transport DONE 2026-06-15 (`501f76a`):** `src/presence_net.rs` — bind/`probe`/
`serve` the ping→pong over ALPN `peerspeak/friends/0`; request/response per conn;
`serve` authenticates `remote_id` and asks an injected `Handler` (wraps
`should_answer` + builds the pong) what to reply, `None` = reveal nothing to a
stranger. **Loopback integration test PASSED over real iroh endpoints** (friend
gets Pong+room; stranger gets unusable reply); run with `cargo test -- --ignored
presence_net`.
- **Endpoint-lifecycle fork — DECIDED 2026-06-15 via a throwaway spike: option (b),
a SINGLE persistent endpoint.** The spike bound two endpoints sharing one
`SecretKey` (one node id), each accepting a different ALPN, and probed both from a
third endpoint. Result: **every inbound connection landed on ONE endpoint** (the
first-bound), and connections for the other ALPN failed at the QUIC handshake with
*"error 120: peer doesn't support any known protocol"* (the connection physically
reached the wrong instance, which doesn't speak that ALPN). So **one node id =
exactly one reachable endpoint instance** — option (a) (a second always-on
friends endpoint sharing our id) is impossible, not merely risky. This is a hard
handshake-layer collision, confirmed on one machine (no cross-network leg needed;
which instance "wins" is just bind-order). **⇒ build (b):** one persistent
endpoint bound once at startup, hosting friends-control + gossip + audio via a
single persistent `Router`; a room "join" becomes subscribe-a-gossip-topic +
spawn-audio-tasks (not rebuild-everything); `NetworkMode` changes require a full
endpoint rebuild (acceptable — already "applies on next join").
- **B1 (persistent network stack) — DONE + 2-machine field-verified 2026-06-15
(`fdab4e0`, merged).** A persistent `NetStack` (endpoint + gossip + `Router`) is
built once at startup and reused across calls; a new persistent `AudioRouter`
(`src/network/iroh_impl.rs`) delegates inbound audio links to the active session's
`Shared` (`bind` on join, `clear` on leave), so the single router/endpoint outlive
any room session. Join no longer rebuilds the endpoint; network-mode/identity
changes rebuild the stack when idle, else defer to the next Leave/Join.
- **B2 (live listener + friends ownership) — DONE + 2-machine field-verified
2026-06-15 (`1ed64cb`, merged).** Friends ownership moved into core (shared
`Mutex<FriendStore>`; malformed load → read-only, never overwrites = A16 fixed;
commands + `FriendsUpdated`; GUI is a read-only mirror). Live listener =
`FriendsProtocol` `ProtocolHandler` on the persistent Router for `FRIENDS_ALPN`
(NOT `serve` — the router owns `accept()`; shared `exchange()` body), reply policy
via `should_answer` reporting our restamped member ticket. Outbound scheduler
folded into the core loop (`tokio::select!`, 60s, 3s startup delay), **fully dark
while Invisible**, detached per-tick; `note_seen` auto-heal on connect; P5 UI shows
online/offline/in-room+Join. Bootstrap caveat: a friend with no saved addr shows
offline until one ticket-based call seeds `last_addr`.
### P5 — Recents + UI — ✅ DONE (UI status/Join, room labels, add-from-room, recents)
Friends-list UI with status (online / offline / in-room + Join) + Invisible/Normal/
Discoverable controls — **DONE (B2)**; the Friends panel + presence picker moved to
the **home screen** 2026-06-16 (`173585f`/`ddc78f1`/`be42941`). **Cosmetic room
labels — DONE 2026-06-16 (`4227ecc`):** an optional "Room name" field on Create mints
a ticket carrying the label (`PeerSpeakTicket.name`, `#[serde(default)]` → backward/
forward compatible; `restamp` preserves it, `label_of` reads it); every member sets
`current_room.name` from the ticket → presence reports "in <name>" + the room header
shows it. Sanitized via `sanitize_name` on mint + display. ⚠️ pure seam unit-tested +
home field screenshot-verified; in-room header + friend-side "in <name>" presence need
a live/2-machine confirm. **Add-friend-from-room — DONE 2026-06-16 (`fb17fd1`):** each
participant card has a star — clickable ☆ adds that peer (pulling their live presence
name + addr so they're reachable immediately, unlike a bare add-by-id), gold ★ once
they're already a friend; hidden while friends are read-only. `AddFriendFromRoom` msg.
⚠️ star + click want a live 2-machine confirm (needs a peer in the room). **Recents —
DONE 2026-06-16:** a purely-local, most-recent-first list of joined rooms
(`src/recents.rs` — `Recent {name,ticket,joined_at}`, `push_recent` de-dupes by
`topic_id` via the new `PeerSpeakTicket::topic_of`, caps at `RECENTS_MAX`=12;
`relative_time` for "5m ago"; 6 unit tests). Persisted in `AppConfig.recents`
(`#[serde(default)]`, back-compat). Recorded on `RoomJoined` (label via `label_of`),
rendered as a "Recent rooms" block in `connect_card` (each entry = label/"Untitled room"
+ relative time → `JoinRecent`, plus a ✕ → `RemoveRecent`); only shown when non-empty.
Rejoin is best-effort (works only while the room is still live + reachable through the
stored ticket — reliability is P6/the member-ticket floor, not this list).
**Screenshot-verified** (seeded config → 3 recents render with correct ages + fallback).
### P6 — Opt-in discovery — Small
Wire the *discoverable* state to n0 DNS publish (default off, time-boxed). Lookup
path for finding a discoverable friend whose saved address went stale.
### P7 — Security review + 2-machine field test — SmallMedium
Surface: the friends-only listener (confirm non-friends are truly dropped pre-any
state change; rate-limit pings), tickets from friends (validate defensively, no
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
## The connect flow (the user's scenario, end to end)
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
"HangOut."
2. You open peerspeak → it pings your friends → X answers (you have X's address; X
is on a reachable relay) and reports "in HangOut, here's a ticket."
3. Your friends list shows **X · HangOut [Join]**. Click → bootstrap off X's ticket
→ you're in. Gossip refreshes everyone's saved addresses.
4. If X had moved to an unreachable network: X either flips on *discoverable*, or
drops a fresh ticket in Signal — the manual floor. Once you connect once, X's new
address auto-saves for next time.
## Build order & effort
**P1 (identity + spike) first** — everything depends on it. Then **P2 (friends) →
P3 (tickets) → P4 (listener/presence)** is the spine that makes the friends list
live. **P5 (recents/UI)** alongside P4. **P6 (discovery)** and **P7 (security/field
test)** last. Rough total: **~34 focused sessions.** No always-on stranger-facing
surface, no default beacon.
## Open decisions (small, remaining)
1. **Mutual friend-add handshake** (accept/decline) vs a purely local add-by-id?
The local list works alone; a handshake is polish + prevents one-sided
"friends." Lean: start local, add handshake later if wanted.
2. **Ticket bundle size** for multi-bootstrap (1 = today; more = robust, longer).
3. **Discovery time-box default** (e.g. 30 min) vs sticky-until-off.
## Cross-references
- Ticket type / multi-bootstrap seam: `src/network/mod.rs:81`, `RoomState::join`
`extra_bootstrap` (A8).
- Identity mint point: `src/core/mod.rs:421`.
- Presence carries `addr` (auto-heal source): `PeerState.addr`, re-announce on
NeighborUp `src/network/gossip.rs:315,344`.
- Reusable pixelpass code: `~/git/butter/pixelpass/src/common/{identity,friends}.rs`
(port the store + identity; the always-on control plane is NOT ported).
---
## Earlier directions (superseded — kept for reference)
**Heavyweight control-plane cut (decided against 2026-06-15).** An always-on
control-plane endpoint reachable by *strangers* + a presence beacon (n0 DNS) +
porting pixelpass's full friends/control stack. Dropped: it needs a persistent
*publicly* reachable identity, a stranger-facing listener (spam/DoS surface), and a
default phone-home beacon — all of which the friends-only listener + opt-in
discovery avoid. The user's key point: out-of-room *delivery/presence* is already
done better by Signal/OS notifications, so delegate it.
**Room-centric cut (folded into the friends-first model 2026-06-15).** Durable
named rooms (`sha256(name) → topic`), favorite-room persistence, empty-room
reachability. Dropped because it fought the serverless reality (an empty room has
no one to bootstrap from) and the user reframed rooms as **ephemeral cosmetic
labels** — the durable thing is the friends list, not the room. The member-issued
ticket and the cosmetic name-tag survive from this cut; the persistence machinery
does not.
---
## 2-machine field test — 2026-06-15 (desktop ↔ dopedart, build `c5df7a3`) — ALL PASSED ✅
- **P1 persistent identity:** node ID stable across app restarts on both machines.
- **Call regression:** two-way audio works with persistent identity (no break).
- **P3 member-issued ticket ("rooms outlive their creator"):** both machines in a
room → dopedart copies ITS ticket → desktop leaves → desktop joins via dopedart's
ticket → reconnects. Confirms a joiner's ticket is a working self-door. **Clears
the P3 field-test that was pending.**
- **Friends add (storage):** adding a friend by node id persists on both ends (live
status still pending P4).
- dopedart resynced to `c5df7a3` (sha `7541773d…`); old binary at
`~/peerspeak.bak.20260615`.