aboutsummaryrefslogtreecommitdiffstats
path: root/docs/invite-pairing-v1.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-11 00:19:06 +0200
commitf059cb118c556d1f0279350507f74b8a47d5a98a (patch)
tree9a97762a844038a06134b4b7dcead1758477dfc1 /docs/invite-pairing-v1.md
parentb045ba0010d69360b6a0265eb7c73a07900fe328 (diff)
downloadmeshbay-f059cb118c556d1f0279350507f74b8a47d5a98a.tar.gz
docs: remove the documents MESHBAY_DESIGN.md replaces
Twenty-four files, about 17 000 lines: the two architecture drafts, the three security reviews, eleven design notes, the roadmap, the decisions file, the v1–v4 archive, the deprecated user guide and the stale quickstart. Their content is in MESHBAY_DESIGN.md, and git history holds the originals. The reason to delete rather than keep bannered: a document that is superseded but present still gets read, and a reader cannot always tell which of two accounts of one mechanism is the live one. That was the argument for retiring the user guide rather than repairing it, and it applies to the whole set. What made this safe is the concordance. Roughly 290 comments and docstrings cite these files by section — `musicbay.md §6`, `mediacenter.md §5.5`, `draft-v6 §2.11` — and section 16 maps every one onto its replacement, so not a single comment needs editing to stay followable. It now says plainly that the files are gone and where to recover them, and it gained rows for the three reviews (their findings are section 13), and for the two guides. Four kept documents pointed into the set and were repointed first: `playlists.md` (nine references — it is a live proposal and must not dangle), `WINDOWS-PORT.md`, and CLAUDE.md's example. No dangling reference remains outside section 16. Two files were dropped from the list after checking what they hold. `HTTPS.md` is an operational runbook — Caddy, certificate renewal, DNS, troubleshooting — and MESHBAY_DESIGN.md deliberately covers no operations, so nothing would replace it; the versioned Caddyfile is the config, not the procedure. `cast-smart-tv.md` is the plan for the unbuilt DLNA phase of a feature whose first two phases ship, and section 11.4 summarises it in four lines rather than carrying the SSDP/UPnP work. There is no user guide now, and section 0.1 says so rather than leaving a reader to discover it. Suites green: 2258 passed, 4 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YVoHVCcfBqud6ZjG4db3y7
Diffstat (limited to 'docs/invite-pairing-v1.md')
-rw-r--r--docs/invite-pairing-v1.md559
1 files changed, 0 insertions, 559 deletions
diff --git a/docs/invite-pairing-v1.md b/docs/invite-pairing-v1.md
deleted file mode 100644
index 8eb378f..0000000
--- a/docs/invite-pairing-v1.md
+++ /dev/null
@@ -1,559 +0,0 @@
-# MeshBay — Invitation and Pairing (design)
-
-> **Superseded by `MESHBAY_DESIGN.md`.** This was the admission and pairing design; its design
-> content now lives in §3.4 and §3.5.
->
-> It is kept because code comments, tests and other documents cite its
-> sections and its labels, and because it records reasoning a synthesis
-> compresses. **Where it disagrees with `MESHBAY_DESIGN.md`, the design
-> document is right; where either disagrees with the code, the code is.**
-> `MESHBAY_DESIGN.md` §16 maps every section reference here onto its
-> replacement, and §13 defines every label.
-
-> Status: **implemented and deployed** (2026-08-14). Written 2026-08-13 as a proposal;
-> §9bis records what shipped, what departed from the plan, and what a browser found
-> that the automated run could not.
-> Supersedes the invite flow described in `meshbay-draft-v5.md` §5.1 and the
-> Phase 12.1 milestone in `devel-phases-next.md`, if adopted.
->
-> Closes **H3** for the path where it is actually exploitable, and dissolves **M3**
-> rather than patching it. Follows the v5 convention: every claim names the
-> adversary it holds against.
-
----
-
-## 1. What is wrong today
-
-Two problems, one visible to users and one invisible.
-
-### 1.1 The workflow problem
-
-An invite runs entirely inside the inviter's browser (`app.js:1391-1420`):
-
-1. fetch the invitee's `pk_x25519` from the hub — `GET /v1/users/{name}/pubkeys`
-2. take the raw GEK out of the live transport connection
-3. wrap the GEK for that key and push the bundle to the node over MNP
-4. add the member on the hub
-
-Step 2 requires the inviter to be **connected to the node with the group key in
-hand**. Step 3 requires the node to accept the bundle, which since C5b means the
-signature must be the node operator's (`webrtc_server.py:1023-1035`). Together:
-
-- only the node operator can invite — v5 §5.1 records this as deliberate
-- the operator must be at a browser, connected, at the moment of the invite
-- a group admin who does not run the node cannot add anyone, ever
-
-That is not a workflow. It is the reason the demo needs `demo.py set-admin-pk`.
-
-### 1.2 The security problem (H3)
-
-Step 1 asks the **hub** which key belongs to `bob`. The hub stores those keys as
-mutable columns on the user row (`api/users.py:352-354`) and serves them with no
-signature and no history. A hub that answers with a key it holds is handed the GEK
-by an honest inviter following the protocol exactly. Nothing forged, nothing
-injected, and nothing in the client notices.
-
-The account is unique and the account is right. The **key attached to the account**
-is what the hub controls.
-
-### 1.3 M3, underneath both
-
-The daemon auto-pins its own keystore key as the admin key (`daemon.py:470-474`);
-the browser signs with the user identity key (`app.js:1416`). They differ, so
-invites and deletions fail closed with a signature error that looks like a bug
-elsewhere. The demo works only because a deploy script writes the browser key into
-`node.toml`.
-
-The tempting fix — have the daemon fetch the operator's key from the hub — turns M3
-into a second H3: the hub would then be able to install itself as node
-administrator. **The node must never learn authority from the hub.**
-
----
-
-## 2. Principle
-
-Three rules. Everything below follows from them.
-
-1. **The node wraps the group key.** The node already holds the GEK — it encrypts
- and serves the content. So it, not the inviter's browser, produces each member's
- bundle. No member ever handles another member's key material.
-2. **A key is bound to an identity by a one-time pairing code, then pinned.** The
- code travels out of band (the inviter sends it to the invitee the way they
- already talk). The hub never sees it and therefore cannot claim to be the
- invitee.
-3. **The node keeps its own roster.** Hub membership is an input, not an
- authorization. Otherwise a hub that invents an account and mints a token for it
- collects the GEK on connect.
-
-Human cost of the whole scheme: **one code per person, once per node**, plus one
-code for the operator at install. No fingerprint comparison, no per-member
-signature ceremony, no operator required to be online when someone joins.
-
----
-
-## 3. Flows
-
-### 3.1 Operator pairing (once per node, replaces M3)
-
-```
-operator (SSH) meshbay-node operator pair
-node prints PAIR-CODE: K7M2-QX4P (also written to data_dir/pair-code)
-operator (SPA) group → Members tab → "Pair this browser" → types the code
-SPA → node join_request {role_hint: operator, code, pk_ed25519, pk_x25519, sig}
-node code valid, unused, unexpired → pins the keys, role = operator
-node writes the pin to its roster DB, prints it in `status`
-```
-
-The operator types 8 characters into their own browser. Nothing is pasted, nothing
-is copied out of a terminal, no browser is needed on the node host, and the hub is
-not involved at any point. `admin_pk_ed25519` in `node.toml` became a legacy
-fallback, and was removed on 2026-08-15: the roster is the only source of operator
-authority. A config that still names the key is warned about at startup, so a
-deployment relying on it learns why its invites stopped rather than discovering a
-signature error.
-
-### 3.2 Invite (one click, operator or delegate)
-
-```
-grenet (SPA) Groups → Members → "Invite bob"
-SPA → node admin_request {op: invite_create, group_id, invitee: bob, ttl}
-node → SPA admin_challenge (structured transcript, H5 — the SPA shows one
- line: "authorize bob to join <group>")
-SPA → node admin_response {sig} ← signed silently with grenet's key
-node creates invite: code, group, invitee, expiry; status = pending
-node → SPA invite_created {code: "R3H8-TB6V", expires_at}
-grenet sends the code to bob however they already talk
-SPA → hub POST /v1/groups/{id}/members/bob (membership, unchanged)
-```
-
-Grenet's browser signs, but grenet does not *inspect* a signature — one click, one
-confirmation line, one code to pass on. Same gesture as any invite link on any
-platform.
-
-### 3.3 Join (fully automatic, operator may be asleep)
-
-```
-bob (SPA) opens the group; client has no GEK for it
-bob → node handshake {token, group_id, nonce_c} (pre-proof window)
-node → bob handshake_challenge {nonce_s, node_pk} ← bob signs over both
-bob → node join_request {group_id, pk_ed25519, pk_x25519, code, sig}
-node 1. rate-limit + attempt count on this connection
- 2. code matches a pending invite for this user_id and group
- 3. identity not already pinned to a different key
- 4. pin (user_id → pk_ed25519, pk_x25519), mark invite used,
- member status = active
- 5. wrap the ACTIVE GEK for pk_x25519 (ECIES, as today)
-node → bob join_result {ok, pk_eph_b64, nonce_b64, wrapped_b64}
-bob unwraps the GEK, completes the normal GEK proof, session proceeds
-```
-
-On every later connection bob sends `join_request` **without** a code; the node
-recognises the pinned identity, re-wraps the current GEK and answers. So GEK
-rotation propagates by itself, and a revoked member simply stops being served.
-
-### 3.4 Open-join groups: no code (decided 2026-08-13)
-
-A group whose hub-side `join_policy` is `"open"` (`groups.py:227-228`) admits anyone
-who asks. A pairing code there protects nothing — the hub can create an account,
-join through the front door and be a legitimate member — so it is pure friction.
-For those groups the node pins on first contact (TOFU, `pinned_via = 'tofu'`) and
-wraps the GEK immediately.
-
-Note the axis. `visibility` (public/private) controls **discoverability** and swarm
-hash registration (H7); `join_policy` (open/request/invite) controls **admission**.
-Only the second one decides whether a code is required. A `visibility = "public"`
-group with `join_policy = "invite"` keeps the code — being findable is not being
-open.
-
-**Stated plainly, per the v5 convention:** in an open-join group the hub can obtain
-the GEK, because it can become a member legitimately. That is a property of open
-joining, not of this design — it is equally true today. Content in such a group is
-protected from the network and from non-members, and from nobody else. The docs
-must say so, and the SPA should say so when someone sets `join_policy = "open"` on
-a group that already holds content.
-
----
-
-## 4. Why a pairing code, and not something lighter
-
-The choice is forced by one question: when bob connects for the first time, what
-stops the hub from being bob?
-
-| Option | What the hub can do | Verdict |
-|---|---|---|
-| Node wraps for the key the peer presents, no binding | Forge a JWT for bob, present its own key, receive the GEK | **Worse than today** — today a forged JWT yields a bundle wrapped to bob's real key, which is useless |
-| Bind to the key the inviter fetched from the hub | Substitute at invite time — H3 unchanged, just relocated into the node | No |
-| TOFU: first connection wins | Race the real bob with a forged token; small window, total consequence | No |
-| Safety-number comparison at invite | Nothing — but it needs two humans reading digits at the worst moment | Correct, unusable as the default |
-| **One-time pairing code** | Nothing: the code never reaches the hub | **Adopted** |
-
-The code is the cheapest thing that binds an identity to a key without the
-directory. It is also the familiar shape — invite links work this way everywhere.
-
-**Boundary, stated honestly:** for a browser client the SPA is served by the hub, so
-a hub that ships malicious client code can read the code out of the page. That is
-**T3, accepted permanently** by decision D1 and unchanged by this design. The code
-defeats a hub that *lies in its directory* — a silent, undetectable, per-request
-attack — not one that *rewrites the client*, which is an artifact and is what the
-native client (Phase 13) removes. Do not blur the two in the docs.
-
----
-
-## 5. Protocol
-
-### 5.1 New MNP messages (`meshbay_common/protocol.py`)
-
-```
-JOIN_REQUEST = "join_request" # client → node, served in the pre-proof window
-JOIN_RESULT = "join_result" # node → client
-INVITE_CREATE = "invite_create" # inviter → node (admin op, see 5.3)
-INVITE_RESULT = "invite_result" # node → inviter, carries the code once
-```
-
-`join_request`:
-
-| Field | Meaning |
-|---|---|
-| `group_id` | mandatory, as everywhere since 11.5.4 |
-| `pk_ed25519`, `pk_x25519` | the caller's own keys, base64 raw |
-| `code` | first pairing with this node only; omitted when `join_policy == "open"` (§3.4) |
-| `role_hint` | `operator` or absent; the node trusts the *code*, not the hint |
-| `sig` | Ed25519 over the transcript below |
-
-### 5.2 Join transcript
-
-```
-"meshbay:join:v1" ‖ len‖node_pk ‖ len‖group_id ‖ len‖user_id
- ‖ len‖pk_ed25519 ‖ len‖pk_x25519 ‖ len‖nonce_s ‖ len‖ts
-```
-
-Length-prefixed and domain-separated per 11.5.21. `nonce_s` is the handshake nonce
-the node just issued, so a `join_request` cannot be replayed onto another
-connection. `pk_x25519` is inside the signature, so the Ed25519 key vouches for the
-X25519 key it is paired with — this is what makes "wrap for the presented key" safe.
-
-The **code is never signed and never echoed** — it is a bearer secret, compared
-against a stored hash and destroyed on use.
-
-### 5.3 Invite creation reuses the existing admin machinery
-
-New op in `meshbay_common/adminop.py`, alongside `OP_FILE_DELETE` and
-`OP_GEK_BUNDLE_STORE`:
-
-```
-OP_INVITE_CREATE = "invite_create"
-"meshbay:admin:v1" ‖ len‖op ‖ len‖node_pk ‖ len‖group_id ‖ len‖subject ‖ len‖nonce ‖ len‖ts
- subject = invitee user_id
-```
-
-Authorized by the pinned **operator** role, or by a **delegate** (§6.2). TTL 120 s,
-same as every other admin op. The client rebuilds the transcript and refuses to
-sign if the subject is not the person the user typed — H5's rule, unchanged.
-
-### 5.4 Code format
-
-8 characters, Crockford base32 (no `I`, `L`, `O`, `U`), rendered `XXXX-XXXX` — 40
-bits. Single use, default TTL 24 h, stored only as `sha256(code)` — a password KDF
-would be pointless over 40 uniformly random bits, and `blake3` is not a node
-dependency. Guessing is
-bounded by: 5 attempts per connection, a node-wide limiter on failed
-`join_request`s, and the fact that a code is valid for exactly one `user_id` in one
-group. A brute-force attempt is an audit-log event, not a silent grind.
-
----
-
-## 6. Node state
-
-### 6.1 Schema (new tables, `roster.py`, same SQLite file style as `bundle_store.py`)
-
-```sql
-CREATE TABLE identities ( -- one row per person, not per group
- user_id TEXT PRIMARY KEY,
- username TEXT NOT NULL,
- pk_ed25519 TEXT NOT NULL,
- pk_x25519 TEXT NOT NULL,
- pinned_at TEXT NOT NULL,
- pinned_via TEXT NOT NULL -- 'code' | 'tofu' | 'legacy-config' | 'operator-reset'
-);
-
-CREATE TABLE members (
- group_id TEXT NOT NULL,
- user_id TEXT NOT NULL,
- role TEXT NOT NULL, -- 'operator' | 'delegate' | 'member'
- status TEXT NOT NULL, -- 'pending' | 'active' | 'revoked'
- approved_by TEXT NOT NULL, -- user_id whose signature created the invite
- approved_at TEXT NOT NULL,
- PRIMARY KEY (group_id, user_id)
-);
-
-CREATE TABLE invites (
- code_hash TEXT PRIMARY KEY,
- group_id TEXT NOT NULL,
- user_id TEXT NOT NULL,
- role TEXT NOT NULL,
- created_by TEXT NOT NULL,
- expires_at TEXT NOT NULL,
- used_at TEXT
-);
-```
-
-Identity is pinned **per node, not per group**: someone already paired for one group
-needs no code for the next one. The operator's pairing is the same mechanism with
-`role = 'operator'` and no group.
-
-### 6.2 Delegation — **deferred** (decided 2026-08-13)
-
-A `delegate` row would let a group admin who is not the node operator create invites
-without the operator being involved again. Not needed while the operator is the
-inviter (the demo, and every single-operator deployment), so it is **not built in
-v1**.
-
-The `role` column reserves the value and `invite_create` authorization is written as
-a role check rather than an equality test against the operator, so adding it later
-is a roster row and a CLI command — no protocol change, no migration.
-
-### 6.3 What the node stops doing
-
-- `gek_bundle_store` **no longer accepts member-supplied bundles at all.** Nothing
- arriving over MNP contributes key material. C5b's rule is not merely preserved,
- it becomes structural — the message can be deleted from the client path entirely.
-- Per-member rows in `gek_bundles` are no longer written. The node wraps on demand.
- The node's own `_node_{user_id}` bundle stays: that is how the daemon reloads its
- GEK across restarts.
-- **Consequence worth having:** revocation starts working for key delivery. A
- stored bundle today survives revocation; on-demand wrapping does not. (Rotating
- the GEK after a revocation is still required — the ex-member has the old key.)
-
----
-
-## 7. Security analysis
-
-### 7.1 Against each adversary
-
-| Attack | Today | With this design |
-|---|---|---|
-| Hub substitutes the invitee's key at invite time (**H3**) | Succeeds silently, hub gets the GEK | **Fails** — no key is ever fetched from the directory |
-| Hub forges a JWT for a real member | Gets a bundle wrapped to the member's real key: useless | Unchanged: no code, no pin match → refused |
-| Hub invents an account and adds it to the group | Blocked only accidentally, by the bundle requirement | **Blocked by the roster** — no invite, no code, no GEK |
-| Hub substitutes the *operator's* key (M3's tempting fix) | n/a | **Impossible** — the node pins by code, never asks the hub |
-| Member wraps a GEK of their choosing for the operator (**C5b**) | Blocked by operator signature | **Impossible** — the message no longer exists |
-| Member replays a `join_request` from another connection | n/a | Bound to `nonce_s` |
-| Member presents someone else's `pk_x25519` | n/a | Signed by the paired `pk_ed25519`, mismatch refused |
-| Ex-member reconnects after revocation | Stored bundle still unwraps | Not served; GEK rotation still needed |
-| Third party guesses a code | n/a | 40 bits, single use, per-user, rate-limited, audited |
-| Hub joins an **open-join** group and collects the GEK | Succeeds | Still succeeds — inherent to open joining (§3.4), must be documented, not hidden |
-
-### 7.2 What this does **not** fix
-
-- **T3** — the hub serves the SPA and can read the code out of the page. Accepted
- (D1); removed only by the native client plus reproducible builds.
-- **C4** — the keypair-bundle pre-proof window is untouched. A first-time joiner
- still needs their own identity keys before they can sign anything; that material
- belongs on the user's device (Phase 13.3).
-- **The node operator reads everything.** Inherent to the model.
-- **The hub still knows who is in which group.** Membership is hub-side; the roster
- only decides who receives the key.
-- **A member can still leak the GEK out of band.** Nothing prevents that, and
- nothing in the current design pretends to.
-
----
-
-## 8. Failure modes and edge cases
-
-| Case | Behaviour |
-|---|---|
-| Code lost or expired | Inviter clicks "Invite" again; the old invite is superseded and its hash deleted |
-| Bob pairs, then loses his keys and runs `regenerateKeys` | Pin mismatch → join refused with a clear message; needs a fresh invite (operator or delegate re-issues). This is the intended blocking warning, moved to the moment it matters |
-| Bob is in two groups on the same node | One pin, one code, ever |
-| Node reinstalled / roster lost | Everyone re-pairs. Same class of event as losing the keystore; `status` must say so plainly |
-| Operator pairs a second browser | New `operator pair` code; both browsers valid, both listed in `status` |
-| Two people race one code | Single-use row, `used_at` set under a transaction; the loser gets a plain refusal |
-| Invite created while the node is offline | Not possible — invites are created on the node. The SPA must say "node offline, cannot invite" instead of failing obscurely |
-| Member connects while the group has no active GEK | `join_result {ok: false, reason: "no_gek"}`; the operator runs `gek-init` |
-| Legacy deployment with `admin_pk_ed25519` set | Ignored since 2026-08-15, with a warning at startup naming the config file. The operator pairs a browser; there is no second path |
-
----
-
-## 9. Operator surface
-
-```
-meshbay-node operator pair # print a pairing code for a browser
-meshbay-node member list [--group G] # roster: who is pinned, role, status
-meshbay-node member invite bob [--group G] # same as the SPA button, from SSH
-meshbay-node member revoke bob [--group G] # stop serving the GEK to bob
-meshbay-node member unpin bob # force re-pairing after a key rotation
-```
-
-`--group` is optional whenever the node hosts exactly one group.
-
-`meshbay-node status` gains a line per group: pinned identities, pending invites,
-and — when nothing is paired — the exact command to fix it. Everything an operator
-needs is reachable over SSH with no browser on the host, per the standing
-constraint. This absorbs milestones 14.3 and 14.4.
-
----
-
-## 9bis. Implementation status
-
-**Slices 1 and 2 landed 2026-08-13/14.** Not yet exercised against a live
-deployment — the operator tests after slice 3, so the slices are written to be
-coherent with each other rather than individually demo-able.
-
-### Slice 1 — roster and operator pairing (M3)
-
-| Shipped | Where |
-|---|---|
-| `identities` / `members` / `invites`, codes, single-use redemption | `meshbay_node/roster.py` |
-| `join_transcript` — both public keys signed together | `meshbay_common/join.py` |
-| `join_request` / `join_result` handler, valid pre-proof and post-handshake | `transport/webrtc_server.py` |
-| Admin authority read from the roster on every check, and from nowhere else | `webrtc_server._verify_admin_sig` |
-| **Auto-pin of the keystore key deleted** (M3) | `daemon._legacy_admin_pk` |
-| `meshbay-node operator pair`, roster in `status` | `daemon.main`, `ui/app.py` |
-| Pairing form in the group's Members tab | `app.js`, `transport.js` |
-
-### Slice 2 — the node wraps the key (H3)
-
-| Shipped | Where |
-|---|---|
-| Node wraps the GEK for the key the joiner proved, on every connection | `webrtc_server._join_ok` |
-| Roster decides who may receive it — hub membership alone does not | `Roster.is_authorized` |
-| `invite_create` admin op; the SPA shows a code instead of handling keys | `adminop.py`, `app.js` |
-| **`gek_bundle_store` deleted** — no member ever hands the node key material | `protocol.py`, `webrtc_server.py` |
-| **`gek-init` no longer fetches member keys from the hub** — it was H3 with the node as victim | `ui/app.py` |
-| Open-join groups admit without a code; policy read from `node.toml` | `config.py`, `_group_join_policy` |
-| Client asks for the key when it has none; prompts for a code when required | `transport.js`, `app.js` |
-
-**Tests: 148 node, 174 hub/common** (from 121/168 before this work). The end-to-end
-one worth knowing about is `test_invite_then_join_delivers_the_gek`: over a real
-DataChannel, the operator gets a code, and a member who has never held the group
-key redeems it in the pre-proof window and receives the key wrapped for a key only
-they can open.
-
-### What a browser found that the harness could not
-
-The design was exercised end to end by `QE/deploy/e2e.py` and passed. It was then tried in
-a browser and failed four times, each for a different reason, none of them the pairing code:
-
-| Symptom | Cause |
-|---|---|
-| No code ever requested, "no GEK available" | the browser held no identity keys — session restore never re-read the encrypted bundle sitting in its own localStorage |
-| "No nodes available" | the node had gone deaf: its hub socket sat in CLOSE-WAIT because the WebRTC offer handler was awaited inside the read loop (a Phase 11 bug) |
-| "Not a member of this group" | the token predated being added to the group; `groups` is baked in at sign-in, and nothing refreshed it |
-| "Handshake incomplete — reconnect and retry" | `joinGroup()` read `node_pk` and `nonce_node` before they were assigned |
-
-The common thread is worth keeping: **e2e.py is a second implementation of the client**,
-written in the right order by construction, so it proves the protocol and nothing about
-`app.js`. Three of those four were ordering or plumbing mistakes it could never see. Hence
-`packages/meshbay-hub/tests/test_spa_ordering.py`, which asserts on source order — inelegant,
-and the only thing that catches them.
-
-### Deliberate departures from this document
-
-| Written | Built | Why |
-|---|---|---|
-| `blake3(code)` | `sha256(code)` | 40 uniformly random bits; blake3 is not a node dependency |
-| Pairing in Settings | Pairing in the group's Members tab | that is where a live node connection exists |
-| — | `gek-init` rewritten | not in the plan: it wrapped the GEK for keys fetched from the hub, which is the same substitution the design closes |
-
-Two C5b tests were rewritten rather than kept: they asserted that
-`gek_bundle_store` demanded an operator signature, and that message no longer
-exists. They now assert the stronger property — that no member can hand the node
-key material at all, and that the retired message reaches no handler.
-
-### Slice 3 — the operator surface
-
-| Shipped | Where |
-|---|---|
-| `member list` / `invite` / `revoke` / `unpin`, all over SSH, no browser | `daemon.main` |
-| Roster endpoints behind the per-run session token (11.5.3) | `ui/app.py` |
-| Roster view in the desktop client's Node page (Preact escapes by default; H2) | `static/node-page.js` |
-| `_daemon_api` / `_resolve_group` — one loopback call path for every command | `daemon.py` |
-| Codes written to `data_dir/invite-code` and `data_dir/pair-code` | `roster.write_code_file` |
-
-Revocation tells the operator what it does **not** do: the ex-member stops
-receiving the key on their next connection, but they still hold the current one,
-so the message ends with the `gek-init` command that rotates it.
-
-`member revoke`/`unpin` resolve a username against the roster and refuse an
-unknown one rather than acting on nobody — a typo must not look like success.
-
-**Tests: 158 node, 174 hub/common** (from 121/168 before this work).
-
-### Code lifetimes (settled 2026-08-14)
-
-| Code | Default | Configurable via |
-|---|---|---|
-| Member invitation | **7 days** | `[node] invite_ttl_hours` |
-| Operator pairing | 24 h | `[node] pair_ttl_hours` |
-
-They differ because the acts differ: an invitation waits for someone to read their
-messages, a pairing code is typed during the SSH session that printed it. The
-longer window costs little — single use, one account, never seen by the hub, and
-40 bits do not fall to guessing in a week against the node-wide lockout.
-
----
-
-## 10. Work to do
-
-### Node
-- `roster.py` — new module, the three tables and their queries
-- `transport/webrtc_server.py` — `join_request` / `join_result` in the pre-proof
- window (beside the existing bundle fetches, `:243-258`); delete member-supplied
- `gek_bundle_store`; `OP_INVITE_CREATE` in the admin-op dispatch
-- `transport/quic_server.py` — same handler via the shared path (11.5.4 parity test
- must cover `join_request`)
-- `daemon.py` — `_resolve_admin_pk` → roster lookup with the legacy config fallback;
- new CLI commands; `status` output
-- `ui/app.py` — roster and invites over the token-gated loopback API (JSON)
-
-### Common
-- `protocol.py` — four message constants
-- `adminop.py` — `OP_INVITE_CREATE`
-- `handshake.py` — expose `nonce_s` to the join transcript builder
-
-### Hub
-- **No change.** Membership endpoints stay as they are. Worth stating in the commit
- message: the fix for H3 removes a hub responsibility rather than adding one.
-
-### SPA
-- Invite dialog shows the code and a copy button, instead of doing crypto
-- Settings gains "Pair this browser with my node" (code entry)
-- `join_request` on connect when no GEK is held; drop the wrap-and-store path
-- Delete the `pubkeys` fetch from the invite flow — the line that is H3
-
-### Tests (negative assertions, per §10 of v5)
-```
-test_join_requires_code_first_time — unpinned identity without a code is refused
-test_join_rejects_key_swap — pinned user presenting a new key is refused
-test_join_replay_across_connections — join_request bound to nonce_s
-test_invite_requires_operator_role
-test_gek_never_wrapped_for_hub_supplied_key — the H3 regression test
-test_revoked_member_gets_no_gek
-test_code_bruteforce_bounded
-test_open_join_group_pins_on_first_contact — no code required, TOFU pin recorded
-test_invite_group_still_requires_code — public visibility does not skip it
-```
-
-### Docs to rewrite
-- `meshbay-draft-v5.md` §2 (H3 row in the claims table), §5.1 (the "a group admin
- who does not run the node can no longer invite" consequence is reversed), §9
- (H3 moves to closed; note what remains open — T3, C4)
-- `devel-phases-next.md` — Phase 12.1 becomes this; 14.3/14.4 absorbed
-- `second-review.md` — H3 and M3 marked closed by this design
-- `QE/deploy/README.md` — `demo.py set-admin-pk` retired in favour of `operator pair`
-
----
-
-## 11. Decisions
-
-Settled 2026-08-13 with the operator:
-
-| # | Question | Decision |
-|---|---|---|
-| 1 | Groups that skip the pairing code | **`join_policy == "open"` only** — asked as "public groups"; corrected to the admission axis, since `visibility` governs discoverability, not entry (§3.4) |
-| 2 | Delegation (group admin ≠ node operator) | **Deferred.** Role value reserved, authorization written as a role check so it drops in later (§6.2) |
-| 3 | Code TTL and length | 24 h, 40 bits, `XXXX-XXXX`. Default unless the first real use says otherwise |
-| 4 | `gek_bundle_store` from members | Deleted, not disabled — it is the C5b surface and keeping it dead-but-present invites its return |
-
-Items 3 and 4 are defaults chosen for v1, not constraints; both are one-line changes
-if the deployment argues against them.