Wire the Discoverable presence posture to n0 DNS publish/lookup, the last
core piece of W7 (friends-first contacts). When a friend moves networks and
their saved address goes stale, they flip Discoverable to publish their
current address; everyone else resolves it by node id. Asymmetric: only the
mover publishes.
- src/discovery.rs (pure seam, +3 tests): lookup_plan(network_mode, want_publish)
-> LookupPlan { resolver, publisher }. Relay-capable modes always resolve and
publish only when Discoverable; DirectOnly (the explicit no-server posture)
gets neither, overriding the toggle. DISCOVERY_TIMEBOX = 30 min.
- apply_discovery (core edge): clears + reinstalls the bound endpoint's
address-lookup services at runtime (no endpoint rebuild). memory-lookup always;
n0 PkarrResolver + DnsAddressLookup when resolver; PkarrPublisher when publisher.
Toggling publish off drops the publisher (republish task ends; TTL-30s record
expires). build_net_stack now binds uniformly with Minimal + per-mode relay and
installs discovery via apply_discovery (drops the per-mode presets::N0 build).
- Toggle + time-box: SetPresenceMode re-applies discovery and arms/cancels a
discovery_deadline; a select! branch fires at the deadline -> revert to Normal,
stop publishing, and emit UiEvent::PresenceModeReverted so the GUI mirrors and
persists it. Re-selecting Discoverable restarts the clock.
Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always
on in relay-capable modes so a stationary friend in Normal can look up a mover.
266 lib tests green, clippy clean (--all-targets). Runtime smoke-tested: the new
Minimal+apply_discovery path binds and runs with no error/panic for both Normal
and Discoverable startup postures. Cross-network publish->lookup and the live
30-min revert still want a 2-machine field test (P7).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
295 lines
20 KiB
Markdown
295 lines
20 KiB
Markdown
# 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 — ✅ DONE 2026-06-16
|
||
Wires the *Discoverable* presence state to n0 DNS publish (default off, 30-min
|
||
time-boxed) plus an always-on lookup path. **Decisions (user, 2026-06-16):** 30-min
|
||
auto-revert (not sticky); resolver always on in relay-capable modes (the stationary
|
||
"looker" is usually in Normal, so lookups must work there).
|
||
- **Pure seam (`src/discovery.rs`, +3 tests):** `lookup_plan(network_mode, want_publish)
|
||
→ LookupPlan { resolver, publisher }` — relay modes always resolve + publish only when
|
||
Discoverable; **`DirectOnly` gets neither** (the explicit no-server posture overrides
|
||
the toggle). `DISCOVERY_TIMEBOX = 30 min`.
|
||
- **iroh edge (`apply_discovery` in `core/mod.rs`):** at runtime, on the bound endpoint,
|
||
`clear()` + reinstall the address-lookup services — memory-lookup always, n0 `PkarrResolver`
|
||
+ `DnsAddressLookup` when `resolver`, `PkarrPublisher` when `publisher`. **No endpoint
|
||
rebuild** — toggling publish off drops the publisher (its republish task ends; the
|
||
TTL-30s record expires). `build_net_stack` now binds uniformly with `Minimal` + per-mode
|
||
relay and calls `apply_discovery` (the old per-mode `presets::N0` build is gone), seeded
|
||
by the startup presence mode.
|
||
- **Toggle + time-box (core loop):** `SetPresenceMode` re-applies discovery and arms/cancels
|
||
a `discovery_deadline`; a `tokio::select!` branch fires at the deadline → revert to Normal +
|
||
stop publishing + `UiEvent::PresenceModeReverted` so the GUI mirrors/persists it (status:
|
||
"Discoverable timed out — back to Normal"). Re-selecting Discoverable restarts the clock.
|
||
- **Verified:** 266 lib tests green, clippy clean (`--all-targets`); runtime smoke-tested —
|
||
app binds + runs the new `Minimal`+`apply_discovery` path with no error/panic, both Normal
|
||
and Discoverable startup postures (the `PkarrPublisher` build path). ⚠️ **The actual cross-
|
||
network publish→lookup (a friend whose saved addr went stale resolving via n0 DNS) and the
|
||
live 30-min auto-revert need a 2-machine field test (P7).**
|
||
|
||
### P7 — Security review + 2-machine field test — Small–Medium
|
||
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: **~3–4 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`.
|