diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 11 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 41 | ||||
| -rw-r--r-- | docs/invite-links.md | 361 |
3 files changed, 408 insertions, 5 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 4b99fb4..371163e 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -420,6 +420,15 @@ being bob on his first connection: | Safety-number comparison | Nothing — but it needs two humans reading digits at the worst moment | correct, unusable as a default | | **One-time pairing code** | **Nothing: the code never reaches the hub** | **adopted** | +**An invitation link is the one code that names no account**, because it goes to +someone who may not have one yet. At the node it is a bearer code — a member of one +group, once, never an operator, never for someone already in the group, at most +twenty outstanding per group, cancellable (`MESHBAY_NODE_PROTOCOL.md` §8.6). Who may +bring it is decided before the node is reached: the hub admits only the account whose +verified address the inviter named, and the client sends the code only to the node +the link names, once that node has signed its challenge (§5.2). The node half is +built; the hub ticket and the client are not yet (§15.2, `docs/invite-links.md`). + **Mailing the code is the inviter's choice, and it gives up the row above.** The Members tab can ask the hub to mail the code to the invitee's address on file (`invite-notify`); that request carries the code, so an active hub holds it and @@ -973,6 +982,7 @@ from anything in the response. | `file_delete` | the operator, or **any non-revoked device of the uploading account** | | `dir_delete` | the operator alone, and only on an empty directory | | `invite_create` | the operator (or a delegate, when delegation ships) | +| `invite_link_create`, `invite_cancel` | the operator | | `gek_rotate` | operator-signed; the node generates the key itself | | initial `gek-init` | **local admin API or CLI only** | | root add/remove/update/eject/plug, `apps_enabled`, app directories, transfer limits | operator-signed | @@ -3190,6 +3200,7 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | — | DLNA/UPnP casting (§11.4) | | — | **Bitmap subtitles** (PGS, VOBSUB — about a fifth of the embedded streams). No WebVTT without OCR; they are not listed rather than listed and blank. Burn-in covers them and costs `-c:v copy`, which is what the eight-slot sizing assumes never happens | | — | Delegation (§3.4) | +| — | **Invitation links** (§3.4). The node half is built — the unbound code, its signed ops and the join path; the hub ticket bound to an address and the client that reads the link are not (`docs/invite-links.md`) | | — | Tier 3 roster attestation (§3.3) | | — | Android client | | — | **Federation between two hubs.** The protocol is written and switched off in the code (§7.6); what is not built is one run between two machines | diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index ed06edd..62a76ef 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -811,11 +811,12 @@ Evaluated in order (`_do_join_request`): | account has devices here, this key is not one | `unknown_device` — the way in is a device-add (§9), not a new invite | | device known, no member row, group policy `open` | member row created (`approved_by: "open-join"`) | | device known, a **pending invite** exists for this user | code required even for a known device; `code_required` / `code_invalid` on failure | +| device known, not an active member of the session group, a code offered | redeemed like any code — an invitation **link** reaches here from someone pinned through another group, or removed and invited back; `code_invalid` on failure | | device known, member row resolved | `join_result{ok, recognised:true, role}` + wrapped GEK | | unknown device, no code, policy `open` | pin TOFU, admit, wrap (`via: "tofu"`, audited) | | unknown device, no code, policy `invite` | `code_required` | | unknown device, code invalid or spent | `code_invalid` | -| unknown device, code valid | pin identity, set member row from the invite, wrap | +| unknown device, code valid | pin identity, set member row from the invite, wrap (`via: "code"`, or `"link"`) | The member row is resolved as: this group's row, then the row for the join message's `group_id`, then the node-wide (`""`) row — which is where an operator opening any @@ -845,7 +846,7 @@ membership alone must not produce a key. The longer window costs little: a code is single use, bound to one account, never seen by the hub, and 40 bits do not fall to guessing in a week against the node-wide lockout below. -* Valid for exactly one `user_id` in one group. +* Valid for exactly one `user_id` in one group — **except an invitation link** (§8.6). * Guessing is bounded per connection and node-wide, and every failure is an audit event rather than a silent grind. * `join_policy` is read from the **node's own configuration**, never from the hub: a @@ -862,6 +863,29 @@ There is **one** source of operator authority and it is the roster: a node.toml an `admin_pk_ed25519` is warned about at startup and never obeyed, because a second source of authority is a second thing to get wrong. +### 8.6 Invitation links (`kind = "link"`) + +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 +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; +* one named group, never node-wide, and only redeemed on a connection authenticated to + that group — the account codes keep redeeming anywhere, as before; +* not by someone already an **active** member of that group, who would otherwise spend + it for the person it was meant for; a member row that is revoked does not count; +* single use: redemption sets `used_at` **and** `user_id` in one guarded `UPDATE`; +* at most **20 unredeemed per group** (`MAX_LINK_INVITES_PER_GROUP`); +* cancellable before use by `invite_cancel`, by an `invite_id` handle unrelated to the + code; a redeemed one stays as the record of the join. + +`kind` is a column, not an empty `user_id`: every query that matches invitations by +account filters on `kind = "account"`, so a row bound to nobody can never be read as +bound to anybody (AV1). A roster from before this gains `kind` and `invite_id` by +`ALTER TABLE`, and its codes stay account codes. + --- ## 9. Device linking @@ -1059,6 +1083,8 @@ broadcast, every connected peer in the group learns the change without reconnect | `file_delete` | `file_id` | operator **or** the file's recorded `uploader_pk` | `file_delete_ack{file_id}` | no | | `dir_delete` | path relative to the root | operator | `dir_delete_ack{dir}` | no | | `invite_create` | invitee `user_id` | operator only (delegation designed, deferred) | `invite_result{code, expires_at, user_id, username}` | no — the code is shown once | +| `invite_link_create` | `link:<group_id>`, the session's group | operator only | `invite_link_result{code, invite_id, expires_at, group_id}` | no — the code is shown once | +| `invite_cancel` | `invite_id` (32 hex) | operator only | `ack{detail: "invite_cancelled", invite_id}` | no | | `member_revoke` | `user_id` | operator | `member_revoke_ack` | no | | `member_unpin` | `user_id` | operator | `member_unpin_ack{user_id}` | no | | `gek_rotate` | `group_id` | operator | `gek_rotate_ack{group_id, authorized_members, note}` | no | @@ -1940,6 +1966,8 @@ it back (§3.5). | `gek_bundle_fetch` / `_resp` | C→N / N→C | pre | the caller's wrapped GEK | | `join_request` / `join_result` | C→N / N→C | pre | pin or recognise an identity; wrap the GEK | | `invite_create` / `invite_result` | C→N / N→C | signed | issue a one-time pairing code | +| `invite_link_create` / `invite_link_result` | C→N / N→C | signed | issue a code bound to no account, for an invitation link (§8.6) | +| `invite_cancel` | C→N | signed | take back an unredeemed invitation link | | `device_add_request` / `_ack` | C→N / N→C | auth | file a new device as pending | | `device_lookup` / `device_lookup_result` | C→N / N→C | auth | candidates for the approver to hash-match | | `device_add` / `device_add_ack` | C→N / N→C | auth | admit a device, countersigned | @@ -2042,7 +2070,9 @@ message: MNP versions independently of the package version. Current: **`3.4`**; oldest peer accepted: **`3.0`** — 3.1, 3.2, 3.3 and 3.4 are all additive, so the floor does not move -with them. 3.4 adds the challenge signature (section 6.5). +with them. 3.4 adds the challenge signature (section 6.5) and invitation links +(section 8.6): `invite_link_create`, `invite_link_result`, `invite_cancel`, which an older +node answers as unknown messages. The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks; `MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about @@ -2172,8 +2202,8 @@ walks through the gate meant to stop it. rest), which carry the same folder names the sealed index carries; the media-metadata replies (`media_meta_resp`, `music_meta_resp`, `link_preview_resp`), which carry titles, artists and synopses; `node_status_ack`, which carries absolute paths on the operator's disk to an operator session; the - identity replies (`roster_read_ack`, `device_list_result`); or `invite_result`, which - carries a pairing code. All are inside DTLS/TLS and none reaches the hub, but none is + identity replies (`roster_read_ack`, `device_list_result`); or `invite_result` and + `invite_link_result`, which carry a pairing code. All are inside DTLS/TLS and none reaches the hub, but none is behind the group key. * **Transfer messages are in clear on purpose**, and that is a deliberate line rather than an omission: `tr` is opaque, `bytes` and `chunks` are numbers, and there is no @@ -2255,6 +2285,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions | `MAX_JOIN_FAILURES_WINDOW` / `JOIN_FAILURE_WINDOW` | 20 / 600 s, node-wide | ” | | Device attempts | 5 per connection | ” | | `MAX_DEVICES_PER_USER` | 5 | `roster.py` | +| `MAX_LINK_INVITES_PER_GROUP` | 20 unredeemed invitation links | `roster.py` | | `PRE_HANDSHAKE_MAX_MSG` / `MAX_MSG` | 64 KiB / 64 MiB | `webrtc_server.py` | | `CHUNK_SIZE` | 1 MiB | ” | | `DOWNLOAD_BUFFER_HIGH` | 2 MiB | ” | diff --git a/docs/invite-links.md b/docs/invite-links.md new file mode 100644 index 0000000..fc8cca8 --- /dev/null +++ b/docs/invite-links.md @@ -0,0 +1,361 @@ +# 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. |