# 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:///#/invite?v=1&g=&t=&n=&c= ``` - **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:`, 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 ``. 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/`. - **`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:`, 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.