summaryrefslogtreecommitdiffstats
path: root/docs/invite-links.md
blob: fc8cca864f0617eb7c54c4c25f69cd4d00a3e67a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
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.