summaryrefslogtreecommitdiffstats
path: root/docs/invite-pairing-v1.md
diff options
context:
space:
mode:
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.