summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:46:48 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:46:48 +0200
commit5bce0acad6d10f9b952874f1359406b4eae3a8f9 (patch)
tree7fefe593c03430c31f327c955fb25a703e463bf2
parentd30e95b2ce1ffe9dc4655855406f2784b5f7af34 (diff)
downloadmeshbay-5bce0acad6d10f9b952874f1359406b4eae3a8f9.tar.gz
docs: drop the invitation-link plan from the tree
The plan was a working document; what it decided lives in MESHBAY_DESIGN.md and MESHBAY_NODE_PROTOCOL.md. Code and tests now cite those instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md2
-rw-r--r--docs/invite-links.md361
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py2
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py2
-rw-r--r--packages/meshbay-node/tests/test_link_invites.py2
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py2
8 files changed, 7 insertions, 368 deletions
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 62a76ef..3ae533b 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -868,7 +868,7 @@ source of authority is a second thing to get wrong.
A code for someone who may have no account yet, so it names none: it is bound to the
first account that redeems it (`invite_link_create`, §10.4). What stops a stranger
holding it is not the node but the hub, which lets only the account whose verified
-address the inviter named reach the node at all (`docs/invite-links.md` §3.5). At the
+address the inviter named reach the node at all (`MESHBAY_DESIGN.md` §3.4). At the
node it is therefore a **bearer code**, and everything else about it is fixed:
* role `member`, never `operator`; a row that says otherwise is refused, not honoured;
diff --git a/docs/invite-links.md b/docs/invite-links.md
deleted file mode 100644
index fc8cca8..0000000
--- a/docs/invite-links.md
+++ /dev/null
@@ -1,361 +0,0 @@
-# Invitation links — plan
-
-> **Status: plan, partly built.** Written 2026-09-23 against MNP 3.3 / packages 0.15.0.
-> Built so far (§7): the remembered e-mail choice (step 1), the signed challenge
-> (step 2, MNP 3.4) and the node half of links (step 3). The rest is not true of
-> the code yet. When it lands, the design statements move
-> into `MESHBAY_DESIGN.md` (§3.4, §7.3, §13) and `MESHBAY_NODE_PROTOCOL.md` (§6, §8,
-> §10.4), and this file keeps only what a synthesis cannot carry, as `transfers-v1.md`
-> does.
-
-## 1. What is asked
-
-An operator sends somebody **one link**. Whoever receives it can open it before
-having an account, register, and land in the group with the invitation code entered
-for them. It must work across several situations (§6), without regressing anything
-and without weakening the admission model of §3.4.
-
-Decisions taken with the project owner before writing this:
-
-| Question | Decision |
-|---|---|
-| Binding | **Bearer code at the node, bound to a verified e-mail address at the hub** |
-| Mail sent by the hub | **Offered on both invitation boxes**, as a checkbox under the text field, **checked by default and remembered per account** (§3.8). Checked, the hub sees the code or the link, which is a documented trade-off rather than a defect (§5) |
-| Mail quota for links | **10 link mails per inviting account per day**, on top of the existing bounds. Ordinary invitations keep their current limits |
-| CLI | **Yes** — `meshbay-node member invite --link`, alongside the interface |
-| Desktop client | **Paste the link into the application**; no OS protocol handler |
-
-## 2. Why it cannot just be "put the code in a URL"
-
-Three facts about today's model decide the shape:
-
-1. **A code is bound to a `user_id` when it is created** (`roster.create_invite`,
- `consume_invite` checks `invite["user_id"] != user_id`). Somebody with no account
- has no `user_id`, so no code can exist for them.
-2. **Reaching the node needs hub membership.** Signaling is refused unless caller and
- node share an active group (H6, AV24), and `/v1/groups/{id}/nodes` answers members
- only (AV25). Today the invitation registers the invitee's membership when the code
- is created — again, by account.
-3. **The code is sent in the pre-proof window, to a node that has not proved who it
- is** (`MESHBAY_NODE_PROTOCOL.md` §6.5). `node_pk` in the challenge is unverified
- until the ack, and `join_request` goes before the ack. Whoever answers signaling
- receives the code in the clear (inside DTLS).
- Today that costs little, because the code only works for one account. **A bearer code
- changes that**, and so does a group with two hosting nodes: the code typed today
- goes to whichever node answers first, which may be **another operator's machine**.
-
-So a link needs two secrets with two jobs, and the node has to authenticate itself
-before the code goes out.
-
-## 3. Design
-
-### 3.1 Two halves, as everywhere else
-
-| Half | Secret | Who checks it | What it grants |
-|---|---|---|---|
-| **Hub ticket** | 128-bit random `t`, stored as `sha256(t)` | the hub, **plus** the redeeming account's verified e-mail blind index must equal the one the inviter named | hub membership = the right to *reach* the node. Nothing about keys (§7.1) |
-| **Node code** | the existing 40-bit code, stored as `sha256(code)` | the issuing node only | pinning + the group key, exactly as today |
-
-The hub never receives the code, and the node never learns the e-mail address. A hub
-membership obtained without the code gets `not_authorized_for_group`, as it does today.
-
-### 3.2 The link
-
-```
-https://<hub>/#/invite?v=1&g=<group_id>&t=<ticket>&n=<node_pk_b64url>&c=<CODE>
-```
-
-- **Everything after `#` is never sent in an HTTP request**. It does not reach Caddy
- logs, hub logs, `Referer`, or the service worker. The SPA reads it, stores it
- (§3.6), and **immediately rewrites the address to `#/invite`** with
- `history.replaceState`.
-- `g` lets the client check that the group the hub answers is the group the inviter
- meant.
-- `n` is **the issuing node's public key**. The code exists on that node only, so the
- client sends it to that node and to no other (fixes fact 3, and the multi-host leak).
-- No e-mail address in the link.
-
-### 3.3 The node authenticates before the code leaves (MNP 3.4, additive)
-
-`handshake_challenge` gains `sig = Ed25519(sk_node, C)` where
-
-```
-C = "meshbay:mnp:challenge:v1" || LP(group_id) || LP(nonce_c) || LP(nonce_s) || LP(binding)
-```
-
-`binding` is the same channel binding as §6.4, already known when the challenge is
-sent (both DTLS fingerprints are in the SDP). `nonce_c` makes it fresh, and `binding`
-means it cannot be relayed.
-
-Client rule, **by the peer's answer and not by version** (§5.6): when the client holds
-a link code for this group, it sends `join_request` only if the challenge has a `sig`
-that verifies under `n` from the link. Otherwise it refuses with a stated reason:
-"this invitation was issued by another machine" or "this node is too old for
-invitation links". Nothing changes for code typed by hand or for recognised
-devices. An old node cannot have issued a link code, so refusing it loses nothing.
-
-The code goes in `meshbay_common/handshake.py` (one implementation, both transports:
-**QUIC must stay at parity**) and in `transport.js`. A parity test covers the transcript
-in Python and JS, the same way `test_bundle_kdf_parity.py` covers the KDF.
-
-**What it buys, per the §2 convention:** for a link join, an active hub that hijacks
-signaling no longer receives the code on a **native** client. **Browser: unchanged**
-— T3, the hub serves the page that reads the fragment. That is the same exposure as
-today's typed code, and no worse.
-
-### 3.4 Node: link invitations in the roster
-
-- `invites` gains `kind TEXT NOT NULL DEFAULT 'account'` (values `account` | `link`)
- and `invite_id TEXT` (random, non-secret handle for cancelling). The migration is an
- `ALTER TABLE ADD COLUMN`, as `username` was.
- **`user_id = ''` never means "anyone" on its own** (the AV1 lesson: an empty value
- must not mean "unspecified"). Only `kind = 'link'` makes a row bearer, and every
- query that matches by `user_id` also filters on `kind`: `drop_invites`,
- `consume_invite`, the pending-invite check in `_do_join_request`, and `member list`.
-- `consume_invite` for a `link` row requires all of:
- `role == member`, `group_id != ''`, `group_id == session group`, not used, not
- expired, **caller has no member row in that group**. An existing member cannot spend
- someone else's link. The call then sets `used_at` **and** `user_id` in one `UPDATE
- … WHERE used_at IS NULL`, so single use still holds under a race.
- A link row can **never** carry `role = operator`, and `pair_operator` is untouched.
-- `_do_join_request`: the "device known" branch also accepts a link code. The case is
- a person already pinned on this node through another group, which is common. Today
- that branch only looks for bound invites.
-- New signed op **`invite_link_create`** (`adminop.py`), subject `link:<group_id>`,
- so the transcript names the outcome (H5). A new op instead of overloading
- `invite_create`, whose subject is an invitee id. **`invite_cancel`** takes subject
- `<invite_id>`. Both are operator-only, like `invite_create`, and both are audited.
-- `ops.create_link_invite` / `ops.cancel_invite`: one implementation behind MNP,
- loopback and CLI (§5.4).
-- Ceiling (AV lens): **20 unredeemed link invitations per group**. The refusal says
- so.
-- Audit: `invite_link_create`, `invite_cancel`, and `join_pinned … via=link`.
-
-### 3.5 Hub: tickets
-
-New table `group_invite_links`: `id`, `group_id` → groups, `created_by` → users,
-`ticket_hash` (unique), `email_hash` (the existing HMAC blind index), `email_masked`
-(`al***@ex***.com`, for the owner's list), `node_invite_id`, `created_at`,
-`expires_at`, `redeemed_by`, `redeemed_at`. Alembic migration with `sa.func.now()`.
-`test_migrations_reach_head` has to pass. `purge.py` finds it from the schema; a test
-confirms it on group deletion **and** on account deletion.
-
-| Route | Auth | Rule |
-|---|---|---|
-| `POST /v1/groups/{id}/invite-links` `{email, expires_at, node_invite_id}` | `get_current_user` + `group.admin_id == current_user.id` (the node token for the CLI, same guard as `add_group_member`) | group active, not `open`; ≤ 20 outstanding per group; rate-limited; expiry clamped to ≤ 30 days; **same answer whether or not an account holds that address** (M1). Returns `t` once |
-| `GET /v1/groups/{id}/invite-links` | owner | outstanding and redeemed links, masked e-mail |
-| `DELETE /v1/groups/{id}/invite-links/{link_id}` | owner | deletes the ticket |
-| `POST /v1/invite-links/preview` `{ticket}` | `require_user_scope` | if valid **and** `caller.email_hash == row.email_hash` **and** caller active → `{group_id, group_name, inviter}`. Wrong account → `403 invite_other_account`, which names no address. Otherwise one uniform `404` |
-| `POST /v1/invite-links/redeem` `{ticket}` | `require_user_scope` | same checks → `GroupMember` row + `redeemed_by`. **Idempotent for the same account** (a second tab, or a reload). Refused for anyone else once redeemed. One notification to the owner: "X joined through your link" |
-
-No route is added to the unauthenticated surface. `POST …/invite-links` takes
-`send_email: bool`; mail is §3.8.
-
-Binding is checked **at redemption**, against the account's current verified address.
-An address change already requires verifying the new address (`users.py`), so it
-cannot be spoofed.
-
-### 3.6 Client
-
-- **`#/invite` page** (`invite-page.js`). It parses the fragment, validates the
- shape of each field, stores `{g, t, n, c, exp}` in **`sessionStorage`**, then
- calls `replaceState`. Why `sessionStorage`: it survives a reload in the middle of
- registration and dies with the tab. Why not `localStorage`: that is shared across
- tabs and outlives them. The page never renders the code.
-- **Not signed in** → "You have been invited to a group on this hub", then *Create an
- account* / *Sign in*. It shows no group name, since that would be information for an
- unauthenticated reader.
-- **After registration** (form → recovery key → e-mail code → done → sign in) and
- **after any sign-in**: if an invitation is pending, `LoginPage` goes to `#/invite`
- instead of `/`.
-- **Signed in** → `preview`, then an **explicit confirmation**: "*inviter* invites you
- to *group* — Join / Ignore". The code is entered for them; the click stays theirs,
- because otherwise a link becomes a way to add anyone to any group without asking.
- Then `redeem`, then check `group_id == g`, then `#/group/<g>`.
-- **`GroupPage`**: a pending invitation for this group:
- (1) narrows the candidate nodes to the one whose key is `n`;
- (2) passes the code to `connect` as `pendingJoinCode` does today;
- (3) is cleared on `ok`, `code_invalid`, expiry, sign-out and "Ignore". It is **kept**
- on a network failure, node offline, `unknown_device` (device linking comes first)
- or a passphrase prompt.
-- **Members tab** (paired operator). A second box, **"Invite by link"**, goes directly
- under the existing **"Invite member"** box, with the same layout: an e-mail field and
- a *Create link* button. Under the field sits the same checkbox as in the box above
- (§3.8). The result shows the link with *Copy* (and `navigator.share` where it
- exists).
- ```
- INVITE MEMBER
- [ username ] [Invite]
- ☑ Send the invitation by e-mail (may land in spam)
-
- INVITE BY LINK
- [ e-mail address ] [Create link]
- ☑ Send the invitation by e-mail (may land in spam)
- → link + [Copy]
- Pending links: al***@ex***.com · expires 30/09 · [Cancel]
- ``` It also lists outstanding links, with a cancel action that does
- both halves, **node first, then hub, and reports a refusal without skipping the
- other half** (the member-removal lesson).
-- **Desktop client / paste**: a "Join with an invitation link" field on the home page
- (browser and application). It accepts the whole link and **refuses a link whose
- origin is not `platform.hubBase()`**, so a ticket is never sent to another hub.
-- `test_hub_address_seam.py`, `test_no_native_dialogs_in_the_spa.py`,
- `test_hook_ordering.py`, `test_spa_ordering.py` and `test_locales.py` all apply.
- New strings go in **all ten** catalogues.
-
-### 3.7 CLI
-
-```
-meshbay-node member invite --link --email alice@example.org --group "Family Photos"
-```
-
-This runs `ops.create_link_invite` and then the hub ticket through the node token
-(hub first, as `create_invite` does, so a failure leaves no orphaned code). It prints
-the link and writes it to `invite-link` beside `invite-code` (`write_code_file`).
-`member list` shows unredeemed links as such, not as a truncated empty `user_id`.
-`test_cli_dispatch.py` covers the new flag.
-
-### 3.8 Mail sent by the hub
-
-**The checkbox.** It sits under the text field in both boxes, reads *"Send the invitation
-by e-mail (may land in spam)"*, and is **checked by default**. It is **remembered per
-account** through `/v1/users/me/preferences`, under one key, `invite_email`, which
-both boxes share. That key is added to `ALLOWED_PREF_KEYS`, a closed namespace (AV11).
-It is remembered server-side rather than in `localStorage` so that it follows the
-account from browser to desktop client. It is read once when the Members tab opens,
-and written when it is toggled.
-
-**Invite member (existing).** Unchecked → `invite-notify` is not called, so the hub
-never sees the code. Checked → today's behaviour, **unchanged**: same endpoint, same
-20/hour per IP limit, same `invite` purpose.
-
-**Invite by link (new).** Checked → the hub mails the link to the address the inviter
-typed, which is the address the ticket is bound to. There is therefore no second
-address, and none that the inviter could choose separately. The mail's content is
-fixed: the subject comes from the group row and the inviter's username, and nothing
-in the text comes from the request (AV8). The link is built by the client, then
-**validated by the hub before it is sent**: it must have the hub's own origin, the
-exact shape of §3.2, and the same `g`/`t` as the ticket just created. A hub-signed
-mail must not become a way to send an arbitrary URL.
-
-**Bounds**, all enforced through `mail.reserve`, with none of the existing ones
-changed:
-
-| Bound | Value | Why |
-|---|---|---|
-| Per inviting account | **10 link mails / day** (`mail_quota` row `invite_link:<user_id>`, one-day window), clamped setting `mail.invite_link_daily_cap` beside the others | the recipient has **no account**: this is the only mail door besides registration that reaches an address the hub has no relationship with, and registration is open. A per-IP bound limits a caller, not the number of strangers mailed (AV10) |
-| Per recipient | existing: 10/day, 120 s cooldown | unchanged |
-| Instance | existing: hourly budget | the new purpose **`invite_link`** is added to the closed list (AV13) and **is not** a recovery purpose, so it cannot spend the share reserved for passphrase resets |
-| Creating a ticket | existing ≤ 20 outstanding per group | a ticket with no mail costs nothing to anyone else |
-
-A refused mail does **not** cancel the link: the answer says `email_status: refused`
-and the interface says "share the link yourself". The link is already shown.
-
-## 4. What this changes in the security model — to write in `MESHBAY_DESIGN.md`
-
-| Claim | Before | With a link |
-|---|---|---|
-| The code never reaches the hub | ✅ if the box is **unchecked**. Checked: the hub receives it (`invite-notify`) | ✅ if the box is **unchecked** (fragment only; browser: T3). Checked: the hub writes the mail and so holds the whole link |
-| A leaked code is useless to another account | ✅ bound to the account | ⚠️ **the node code is bearer**. What restricts *who reaches the node* is the hub ticket bound to a verified address. Leaking the link to a messaging provider is therefore not enough: you also need the mailbox |
-| Active hub | can already be the invitee (forged token + code if it sees it) | **unchanged**. It can create an account with the address it verifies itself, redeem, forge. Honest wording: the e-mail binding holds against **third parties**, not against the hub |
-| Signaling hijack at first contact | receives the code | **closed for a link on a native client** (§3.3). Browser: T3 |
-| Multi-host group | the code may go to another operator's node | **closed for a link** (`n`) |
-| Hub metadata | who is a member of what | **+ "owner X invited address Y into group Z"** (blind index + masked form) until expiry/deletion. The creation request carries the address in the clear over TLS to the hub |
-
-Residual risks, stated rather than hidden:
-
-- Addresses are compared **case-insensitively and nothing more**. `+alias` and dots
- are not folded, so the person has to register with exactly the address that was
- given. The error message says so.
-- An account created before e-mail verification existed may hold an address nobody
- verified. An account whose `status` is not `active` is refused.
-- The browser's local history may have recorded the full URL before `replaceState`.
- That exposes it to a local attacker only, and the code is single-use and short-lived.
-- A **group member** who obtains the link cannot spend it (§3.4, "caller has no
- member row"). An account that received hub membership some other way and has not
- been pinned could. That is narrow, audited, and visible to the owner, who sees who
- redeemed.
-
-New labels for §13 (numbered at write time), e.g. `AV29`: "A link invitation is
-capped per group, on the node **and** on the hub". And an admission rule: "A bearer
-code is sent only to a node that has proved the key the link names".
-
-## 5. Mail sent by the hub: a stated trade-off, no longer a silent one
-
-**`invite-notify` hands the hub the code in the clear**, and the link mail does the
-same with the link. It is kept, **as an option the inviter sees**, because a mail that
-arrives without anyone copying anything is worth more to most groups than the property
-it costs. Written honestly:
-
-- Checked, **an active hub can join in the invitee's place**. It holds the code, and
- for a link the ticket as well. It still has to go through the address binding, which
- it controls, because it is the one that verifies addresses.
-- Unchecked, the property of §3.4 holds exactly as written.
-- `MESHBAY_DESIGN.md` §3.4 (property 2 and the "adopted" line) and `USERGUIDE.md`
- §4 ("The code never passes through the hub") gain the same qualification: **"unless
- you ask the hub to send it by e-mail"**. The USERGUIDE gets one sentence next to the
- checkbox: for a group where it matters, uncheck it and send the link yourself.
-
-This replaces the §15.3 entry planned earlier: a choice stated in the interface and in
-the documentation is not an open defect.
-
-## 6. Scenarios to hold (each becomes a test or a manual check)
-
-| # | Situation | Expected |
-|---|---|---|
-| S1 | No account, opens the link | invitation page → register → recovery → e-mail code → sign in → confirmation → group open, files visible |
-| S2 | Has an account, signed out | sign in → confirmation → group |
-| S3 | Already signed in, right account | confirmation → group |
-| S4 | Signed in as **another** account | `invite_other_account`, suggests signing out; invitation kept |
-| S5 | Registers with another address | same, and the message mentions aliases |
-| S6 | Link expired / cancelled / already used by someone else | "invitation no longer valid, ask for a new one"; storage cleared |
-| S7 | Reload or tab closed between steps | reload: resumes (`sessionStorage`). Tab closed: reopening the link works (idempotent redeem) |
-| S8 | Node offline after redeem | group in the sidebar, "offline"; code kept; joins when it comes back |
-| S9 | Already pinned on this node through **another group** | "device known" branch → the link is accepted |
-| S10 | Account known on the node, **new browser** | `unknown_device` → device linking, then the link |
-| S11 | Browser without `bundleKey` (device sign-in) | passphrase prompt, code kept |
-| S12 | Already a member of the group | nothing spent, the group opens |
-| S13 | Group hosted by two nodes | the code goes to `n` only; the other node never sees it |
-| S14 | Old node (no challenge `sig`) | stated refusal, code not sent |
-| S15 | Desktop, pasted link | same flow; link from another hub refused |
-| S16 | Owner cancels before use | node + hub; the link answers S6 |
-| S17 | Owner removes the member after joining | existing removal flow, unchanged |
-| S18 | Open group (`join_policy = open`) | "invite by link" is not offered, as with the code today |
-| S19 | Node with no paired operator | same message as today for invitations |
-| S20 | Sign-out with an invitation pending | storage cleared |
-| S21 | Box unchecked, then the page is reopened (or another browser, or the desktop) | still unchecked, in both boxes |
-| S22 | 11th link mail of the day | link created and shown, `email_status: refused`, "share it yourself"; ordinary invitations unaffected |
-| S23 | Box unchecked | no call to `invite-notify` / no mail; a test checks that the hub never received the code |
-| S24 | Link mail with a forged URL (other origin, other ticket) | refused by the hub, no mail sent |
-
-## 7. Order of work — one commit per step, suite green between each
-
-0. **Baseline**: suite counts per package (hub, node, common) before touching
- anything, with the known pre-existing browser failures noted.
-1. `feat(hub):` the remembered **"Send by e-mail"** checkbox on the existing *Invite
- member* box (`invite_email` preference, i18n ×10), plus the §3.4 / USERGUIDE wording
- of §5. Standalone, and it changes nothing when the box stays checked.
-2. `feat(common,node):` challenge signature, MNP **3.4** (MINOR, floor unchanged),
- both transports, py↔js parity, client verification when `sig` is present. Worth
- having without links: it is the precondition for everything else.
-3. `feat(node):` `kind`/`invite_id`, `consume_invite`, join branches,
- `invite_link_create`/`invite_cancel`, ops, loopback, ceiling, audit, `member list`.
- Tests: node roster + join + races + regressions (bound invite, operator pairing,
- `drop_invites` on an empty `user_id`).
-4. `feat(hub):` table + migration + routes + purge + notification + **link mail**
- (`invite_link` purpose, per-account daily cap, URL validation). Tests: two accounts
- (`test_availability_between_members.py`), uniform responses, clamps, idempotency,
- the 11th mail refused while the other purposes still send, `invite_link` unable to
- spend the recovery reserve.
-5. `feat(hub):` SPA — `invite-page.js`, login/register routing, `GroupPage`,
- Members tab, paste field, i18n ×10. Source-reading tests: **no `hubFetch` carries
- `c`**, `replaceState` called before any `await`, storage cleared on sign-out. A
- **harness probe driving the real flow** in a browser against a local hub and node
- (S1, S4, S13): only launching it proves the ordering.
-6. `feat(node):` CLI `--link`.
-7. `docs:` `MESHBAY_DESIGN.md` §3.4 / §5.6 / §7.3 / §13 / §15, `MESHBAY_NODE_PROTOCOL.md`
- §6.5 / §8 / §10.4 / §13 / appendices, `USERGUIDE.md` §4 and §7, and this file
- reduced to what the synthesis does not carry.
-
-Deployment: node before SPA. A node on 3.4 signs, and nothing yet requires it; an SPA
-that offers the link without a 3.4 node gets the stated refusal from step 2.
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 23040be..c679718 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -36,7 +36,7 @@ ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
OP_FILE_DELETE = "file_delete"
OP_DIR_DELETE = "dir_delete"
OP_INVITE_CREATE = "invite_create"
-# An invitation bound to no account (docs/invite-links.md). Its own op rather
+# An invitation bound to no account (docs/MESHBAY_DESIGN.md §3.4). Its own op rather
# than `invite_create` with an empty subject: that subject is the invitee, and
# what the operator is shown before signing has to name the outcome (H5) — here
# "a link into this group", `link:<group_id>`.
diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py
index e6b2d1f..aae0a33 100644
--- a/packages/meshbay-node/src/meshbay_node/ops.py
+++ b/packages/meshbay-node/src/meshbay_node/ops.py
@@ -243,7 +243,7 @@ async def create_link_invite(state: dict, group_id: str, *,
Nothing is registered on the hub here, unlike `create_invite`: there is no
account to register yet. The hub half is a ticket the inviter's client asks
- the hub for, bound to the invitee's address (docs/invite-links.md §3.5).
+ the hub for, bound to the invitee's address (docs/MESHBAY_DESIGN.md §7.3).
"""
roster = _roster(state)
_group_ctx(state, group_id)
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 8360df3..7bc2067 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -61,7 +61,7 @@ DEFAULT_DEVICE_REQUEST_TTL = 3600
# What an invitation is bound to. An `account` code names its user_id when it is
# issued; a `link` code names nobody until it is redeemed, because a link goes to
-# someone who may not have an account yet (docs/invite-links.md). The kind is a
+# someone who may not have an account yet (docs/MESHBAY_DESIGN.md §3.4). The kind is a
# column rather than an empty user_id so that "bound to nobody" can never be
# read as "bound to anybody" — the mistake AV1 records, once per codebase.
KIND_ACCOUNT = "account"
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 95b628f..b7c8c92 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -1242,7 +1242,7 @@ class WebRTCPeerSession:
"""
Issue a code bound to no account, for an invitation link — into the
group this connection authenticated to, and no other: a link names its
- group, so the operator signs for exactly that one (docs/invite-links.md).
+ group, so the operator signs for exactly that one (docs/MESHBAY_DESIGN.md §3.4).
"""
group_id = self._group_id or ""
if not group_id:
diff --git a/packages/meshbay-node/tests/test_link_invites.py b/packages/meshbay-node/tests/test_link_invites.py
index f5081d2..10f9ca8 100644
--- a/packages/meshbay-node/tests/test_link_invites.py
+++ b/packages/meshbay-node/tests/test_link_invites.py
@@ -4,7 +4,7 @@ Invitation links: a code bound to no account until somebody redeems it.
A link is sent to someone who may not have an account yet, so its code cannot
name one. That makes it a bearer code at the node — the hub's ticket, bound to
a verified address, is what decides who can reach the node at all
-(docs/invite-links.md §3). Everything here is a way a bearer code could be made
+(docs/MESHBAY_DESIGN.md §3.4). Everything here is a way a bearer code could be made
to mean more than "one new member of this group, once", or a way an unbound row
could leak into the code paths written for bound ones. The second family is the
one to watch: `user_id = ''` must never read as "anyone" (AV1).
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index c0dc6d1..0245c4e 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -1449,7 +1449,7 @@ async def _bearer_join(transport, sk_hub, user_id, peer_id, code, x25519_keypair
async def test_a_link_is_issued_signed_redeemed_once_and_cancellable(
sk_node, sk_hub, gek, shared_dir, tmp_path, x25519_keypair):
"""
- Invitation links over a real DataChannel (docs/invite-links.md §3.4).
+ Invitation links over a real DataChannel (docs/MESHBAY_NODE_PROTOCOL.md §8.6).
The operator signs `invite_link_create` naming the outcome, `link:<group>`;
the first account to bring the code is admitted and handed the key; the