|
1 | 1 | # Shareable Join Links |
2 | 2 |
|
| 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 | +
|
3 | 75 | ## Goal |
4 | 76 |
|
5 | 77 | 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