Skip to content

Commit 94aa11a

Browse files
intendednullclaude
andauthored
docs(join-links): close out spec — flip [active]→[landed] + Realised-state addendum (#662)
All three substantive shareable-join-links audit findings have now landed in code: - PR #660 — JoinLink persistence (load_join_links at init, PersistJoinLinks on create/delete/used++). - PR #660 — JoinDenied emission for invalid links (link_disabled / link_expired), preserving anti-enumeration silent-drop for unknown link_ids. - PR #661 — Max-uses + expiration UI via <details> disclosure in settings.rs. This PR flips the README badge `[active]` → `[landed]` and adds a Realised-state addendum to the spec. The addendum: - Lists the substantive gaps that were closed and which PR closed each. - Documents the intentional deviation: spec §Error Handling lists `link_not_found` as a denial reason, but the realised listener drops unknown link_ids silently to preserve the anti-enumeration property. Only known-but-invalid links receive denials. - Flags remaining doc drift in the body (EndpointId vs String field types, the post-#309 `link_id` binding on JoinResponse/JoinDenied, the extra `server_id` + `created_at` fields on `JoinLink`, and the test-tier breakdown). Body preserved as original design target. Realised-state addendum is authoritative for current implementation shape. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent aa29620 commit 94aa11a

2 files changed

Lines changed: 73 additions & 1 deletion

File tree

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Every entry below carries one of:
6969
- [Epoch-driven channel key rotation](specs/2026-04-24-epoch-key-rotation.md) — derives fresh channel encryption epoch from every membership-changing state event. `[active]`
7070
- [Direct messages — seal+gift-wrap deferral to MLS](specs/2026-04-24-seal-gift-wrap-dms.md) — captures NIP-17/44/59 investigation; defers DMs to a future MLS-over-Willow spec. `[active]`
7171
- [Bech32-with-HRP user-facing identifiers](specs/2026-04-24-bech32-identifiers.md) — all UI-visible identifiers encoded as bech32m strings with type-tagging human-readable prefix. `[active]`
72-
- [Shareable join links](specs/2026-03-27-shareable-join-links-design.md) — single URL triggers automatic P2P key exchange, replacing multi-step invite flow. `[active]`
72+
- [Shareable join links](specs/2026-03-27-shareable-join-links-design.md) — single URL triggers automatic P2P key exchange, replacing multi-step invite flow. `[landed]`
7373

7474
**Plans**
7575

docs/specs/2026-03-27-shareable-join-links-design.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,77 @@
11
# Shareable Join Links
22

3+
**Date:** 2026-03-27
4+
**Status:** landed — wire protocol, key-exchange handshake, link generation/deletion, JoinPage, routing, persistence, denial emission for invalid links, and max-uses/expiration UI controls all shipped. See [`docs/plans/2026-03-27-shareable-join-links.md`](../plans/2026-03-27-shareable-join-links.md) for the original migration. Persistence + JoinDenied gaps closed in PR #660 (2026-05-22); max-uses/expiration UI shipped in PR #661 (2026-05-22). One spec divergence preserved intentionally: silent-drop for *unknown* link_ids is kept as an anti-enumeration property — see *Realised state* below.
5+
**Implementation plan:** [`docs/plans/2026-03-27-shareable-join-links.md`](../plans/2026-03-27-shareable-join-links.md)
6+
7+
> **Realised state (post-2026-05 audit + follow-up PRs).** The original
8+
> spec landed via the linked plan in late March 2026. A 2026-05 audit
9+
> surfaced nine misalignments, broken into three categories:
10+
>
11+
> **Substantive gaps — fixed in follow-up PRs:**
12+
>
13+
> - **JoinLinks now persist** across page reloads (PR #660). Previously
14+
> `create_join_link` / `delete_join_link` only mutated the in-memory
15+
> `Arc<Mutex<Vec<JoinLink>>>` and the listener's `used += 1` bump
16+
> never reached disk, so creating a link → refreshing wiped it and
17+
> exhausted links looked fresh after restart. `ClientHandle::new` now
18+
> hydrates `join_links` from `storage::load_join_links(server_id)`
19+
> for every loaded server, and mutation paths send `PersistJoinLinks`
20+
> to the persistence actor.
21+
> - **Invalid-link denial now emits `JoinDenied { reason }`** (PR #660).
22+
> The listener previously dropped requests silently for disabled /
23+
> expired / exhausted links — joiner saw a 30-second timeout instead
24+
> of the spec-promised explicit denial. Now distinguishes
25+
> `link_disabled` (active = false) and `link_expired` (used >=
26+
> max_uses OR expires_at < now) reasons.
27+
> - **Max-uses + expiration UI** shipped via a `<details>` disclosure
28+
> in `settings.rs` (PR #661). Defaults to `max_uses=5` /
29+
> `expires=Never`; selector offers Never / 1 hour / 24 hours / 7
30+
> days. The `create_join_link(max_uses, expires_at)` signature was
31+
> already correct; only the call site was hardcoded.
32+
>
33+
> **Intentional deviation from spec (anti-enumeration):**
34+
>
35+
> - **Unknown `link_id`s drop silently.** The spec's §Error Handling
36+
> table lists `link_not_found` as a valid `JoinDenied` reason. The
37+
> realised listener emits a denial for known-but-invalid links
38+
> (`link_disabled` / `link_expired`) but drops requests for `link_id`s
39+
> that aren't in the inviter's table at all. Rationale: replying for
40+
> unknown link_ids would let attackers enumerate which link_ids exist
41+
> ("I just need to find one that gets a Denied instead of a
42+
> timeout"). Silent drop preserves the property that the inviter is
43+
> indistinguishable from "wrong inviter" for unknown link_ids — only
44+
> link_ids you *plausibly created* receive a denial.
45+
>
46+
> **Doc drift — spec text below is stale on these points:**
47+
>
48+
> - **Wire field types.** `JoinRequest.peer_id`, `JoinResponse.target_peer`,
49+
> `JoinDenied.target_peer` are `EndpointId` in code, not `String`
50+
> (the spec sketches use `String`). `JoinToken.inviter_peer_id` is
51+
> likewise `EndpointId`. The wire protocol carries strongly-typed
52+
> identities; the spec's String sketches are pseudocode.
53+
> - **`link_id` binding on responses.** `JoinResponse` and `JoinDenied`
54+
> each carry an additional `link_id: String` field added for issue
55+
> #309 / SEC-A-07 (signer-binding gating). The joiner records the
56+
> expected inviter in `pending_joins` *before* broadcasting the
57+
> `JoinRequest`, then drops responses whose `link_id` doesn't match
58+
> the outstanding attempt or whose signer doesn't match the recorded
59+
> inviter. Meaningful security mechanism not described in the spec's
60+
> wire protocol section.
61+
> - **`JoinLink` extra fields.** The struct also carries `server_id:
62+
> String` (required for per-server persistence) and `created_at: u64`
63+
> (drives the UI's relative-age display).
64+
> - **Test coverage.** Spec §Testing calls for browser tests of the
65+
> join-flow UI and link-management surfaces; realised coverage is
66+
> at the client tier (state-actor + listener tests) plus a
67+
> markup-contract browser test for the new options disclosure (PR
68+
> #661). The end-to-end join flow itself is covered by
69+
> `e2e/join-links.spec.ts`.
70+
>
71+
> The body below is preserved as the original target. The *Realised
72+
> state* list above is authoritative for current implementation shape;
73+
> do not edit the body in place to match it.
74+
375
## Goal
476

577
Replace the multi-step invite flow (share PeerId, generate per-recipient invite, paste blob) with a single shareable URL that triggers automatic P2P key exchange when clicked.

0 commit comments

Comments
 (0)