summaryrefslogtreecommitdiffstats
path: root/docs/MESHBAY_NODE_PROTOCOL.md
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 17:31:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 17:31:06 +0200
commitd30e95b2ce1ffe9dc4655855406f2784b5f7af34 (patch)
tree91d86ffd59eb3593a76139ab264e1eb5da1ae687 /docs/MESHBAY_NODE_PROTOCOL.md
parent339cb427f886a0177014126bb684335837eff067 (diff)
downloadmeshbay-d30e95b2ce1ffe9dc4655855406f2784b5f7af34.tar.gz
feat(node): invitation links — a code bound to no account until redeemed
New invite kind "link": member of one group, once, never operator, not spendable by an active member, capped at 20 per group, cancellable by handle. Signed ops invite_link_create / invite_cancel, loopback routes, and the known-device join path now accepts a link code. Adds the plan, docs/invite-links.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs/MESHBAY_NODE_PROTOCOL.md')
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md41
1 files changed, 36 insertions, 5 deletions
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 | ” |