aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:33:28 +0200
commit1e8eae1f3de765fcd8163716de6e5fe5aab5a034 (patch)
treedfdb004229c8b0f57ea1a64a726ed2752c3d18e5 /docs
parentadda9613fad48a68239e2dbd93321818a835c286 (diff)
downloadmeshbay-1e8eae1f3de765fcd8163716de6e5fe5aab5a034.tar.gz
feat(hub): one account, several linked nodes
An account links up to MAX_NODES_PER_ACCOUNT node keys (node_keys table) instead of the one users.pk_node_ed25519; the migration carries each account's key over and the downgrade keeps the last one linked. /v1/nodes/auth accepts any linked key, and its token names the key that won it (nk). PUT /me/node_key adds a key, idempotently, 409 past the limit; DELETE /me/node_key names the key: a browser session any of its own, a node only its own. Unlinking a key closes that node's socket and refuses its tokens at once, on REST and on the socket; the account's other nodes are untouched. /pubkeys no longer gives a node key to other accounts, and gives the caller its last linked one, which installed desktop apps read. The node reports waiting_for_node_key while its key is unlinked and signs in again as soon as the hub says so, so a key linked again is back within seconds; unlinking itself signs in first to hold a token naming its key. Profile -> Linked nodes lists each node (connected, last seen, IP, linked since, This machine in the desktop app) with Copy and Unlink, and adds a key by hand. The Node page banner, Create group and the desktop app add this machine's key instead of replacing another's. Messages that pointed at Settings -> Link Node point at Profile -> Linked nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md20
-rw-r--r--docs/MESHBAY_HTTP_API.md7
-rw-r--r--docs/QUICKSTART.md5
-rw-r--r--docs/USERGUIDE.md7
4 files changed, 27 insertions, 12 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 2b0a166..21322bc 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -249,7 +249,8 @@ derived for that node, so one that leaks opens nothing on another.
Two consequences fall out and both are wanted. Two operators cannot tell they
host the same person by comparing keys. And **the hub stores and publishes no
user keys at all** — `users.pk_ed25519` / `pk_x25519` are dropped, `PUT /me/keys`
-does not exist, and `/pubkeys` returns an account id and the node's linking key.
+does not exist, and `/pubkeys` returns an account id — and, to the account itself
+only, its latest linked node key, which installed desktop applications read.
There is no directory to substitute from, which is what **H3** was.
Two further rules follow directly:
@@ -2239,6 +2240,18 @@ moderator route, even when the account behind it holds a hub role: what a node m
is its operator's roster pin, and a hub role is a person's, exercised from a client.
The operator manages groups from a client (**NS7**).
+**An account links one node key per machine it runs a node on** (`node_keys`), up to
+the number of node keys it may announce, ten. `/v1/nodes/auth` accepts a signature
+from any of them, and the token names the key that signed (`nk`); on the socket it
+registers that key's node and no other. Linking is a person's act, from a client
+session. A node's own token may unlink its own key and nothing else, so a node can let
+go of itself (`meshbay-node setup --reset`, the Node page) and never of a sibling.
+**Unlinking ends that machine at once and leaves the others alone**: the hub closes
+its socket, refuses every token naming the key, and answers its next sign-in
+"No node key", which is what the daemon reads to wait for its link rather than for an
+account (`waiting_for_node_key`). The list is the account's own (`GET /v1/users/me/node_keys`): no route names another account's node
+keys, so nobody learns which machines someone runs.
+
Signaling is rate-limited, SDP-size bounded, capped per user, and **the caller must
share an active group with the target node**. Otherwise any authenticated user
could make a third party's machine allocate peer connections on demand (**H6**).
@@ -2514,7 +2527,7 @@ A user can delete their own account from Settings behind a **passphrase re-entry
radius is proof of the passphrase. An admin can delete one too.
The row is **tombstoned rather than dropped**: username released, email and password
-hash cleared, bundle pepper cleared, node linking key dropped, memberships, notifications, refresh tokens,
+hash cleared, bundle pepper cleared, node linking keys dropped, memberships, notifications, refresh tokens,
node registrations and device keys removed, active tokens
refused at once by a status check rather than left to expire. Device keys go
because the desktop client keeps its half: left on the tombstone, the key would
@@ -4663,7 +4676,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **`connection_processes` has no CLI verb** | Set from the Node page or `node.toml`; `meshbay-node` has no command for it, unlike the transfer caps |
| **A connection process does not slow a peer the node cannot keep up with** | What a process receives is forwarded to the node unbounded. An upload is bounded by its own rate, 5 to 6 MB/s a connection, so it would take a node stalled for seconds to matter |
| **The home page says "No groups yet" when the hub cannot be reached** | An unreachable hub reads as an account with no groups, rather than as an error |
-| **`meshbay-node init` says "Settings → Link Node"** | The control is on the Profile page, as QUICKSTART says |
| **C4** for accounts with browser access | Closed against operators by the pepper; **open against an active hub**, which holds the pepper and can fetch a bundle with a token it mints — the adversary T3 already concedes for browsers (§3.7) |
| **A desktop that never held an identity cannot open its recovery copy** | The application opens a node's passphrase copy, not the recovery copy: after a reset, an identity it never held is recovered from a browser (§3.6). And an identity the application mints gets a recovery copy only when the recovery key is entered on its Profile page with browser access on |
| **T3** for browser users | **Accepted permanently.** Removed for native clients, and that removal's value depends on reproducible builds |
@@ -4679,7 +4691,7 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so |
| **The loopback path removes access without writing an audit entry** | `ops.revoke_member` and `ops.unpin_member` log to the daemon's log and nothing to `audit.db`; the MNP `member_revoke` handler doing the same work audits it (and `member_unpin` did, until it left MNP in 6.0). So a removal made from the node page or the CLI — the two doors an operator sitting at their own machine actually uses — leaves the journal showing an admission and then, whenever that person next connects, an `auth_failed` ("not admitted by the roster") with nothing in between to explain it. §5.4's signed transcript is not what is missing: a loopback caller is authorized by being on localhost with the run token and signs nothing, so the gap is the record, not the authority. Found by reading a node's audit log for a refusal whose cause was six hours earlier and unrecorded |
| **The Create group wizard calls two hooks after an early return** | `CreateGroupWizard` (`create-group-page.js`) returns during node detection, before its `useRef`/`useEffect` for provisioning, so the hook count changes between renders. Preact tolerates a list that grows, and nothing is known to break; `test_hook_ordering.py` checks declaration order, not this. Found while tracing the frozen-fields report, which had another cause (`ask.js`) |
-| **A node key is read from the terminal or the desktop client, never a browser** | **Accepted.** `meshbay-node status` on the node's own machine and Node → Overview in the desktop client are the two places the key can be read; the Node page is Electron-only, because `platform.node` resolves to "not available" without the bridge, and no hub route exposes the key. The create-group wizard links it automatically over that same bridge, so the manual paste in **Profile → Link Node** exists for the operator who runs the node from a terminal and the hub from a browser — who has a terminal by definition. Anyone linking a node is already at a shell prompt, so a browser-reachable copy would buy nothing and widen what the hub knows about the node |
+| **A node key is read from the terminal or the desktop client, never a browser** | **Accepted.** `meshbay-node status` on the node's own machine and Node → Overview in the desktop client are the two places the key can be read; the Node page is Electron-only, because `platform.node` resolves to "not available" without the bridge, and no hub route exposes the key. The create-group wizard links it automatically over that same bridge, so the manual paste in **Profile → Linked nodes** exists for the operator who runs the node from a terminal and the hub from a browser — who has a terminal by definition. Anyone linking a node is already at a shell prompt, so a browser-reachable copy would buy nothing and widen what the hub knows about the node |
| **A loopback eject or plug reaches open pages late** | `ops.eject_root` and `ops.plug_root` flip the live set and tell nobody; over MNP the broadcast `root_eject_ack` / `root_plug_ack` is what moves every open table. So an eject made from the desktop application or the CLI shows on members' pages only with the next index push — for a plug, the end of its rescan; for an eject, whatever changes next. `ops.update_root` had the same silence and now calls `DirectoryIndexer.publish_roots`; the same call belongs in these two. Found while moving the `writable`/`removable` switches to the loopback door (MNP 6.0) |
| **A group key rotated from the Node page leaves the chat key where it was** | `ops.set_gek(rotate=True)` replaces the group key and opens no chat epoch; the MNP `gek_rotate` handler opened one itself (`_new_chat_epoch`), and it was the only door that did — but no client ever sent it, and it is gone since 6.0. The removals that matter (revoke, unpin, device revoke) open an epoch in `ops` for every door, so what is missing is the follow-through for an operator who rotates by hand: §4.5's "rotate after a removal" means it for chat too. The fix is the `_after_removal` shape — `open_chat_epoch` inside `ops.set_gek` when `rotated`. Found while removing the MNP message |
| **iPhone playback is untested, and the player ignores what ManagedMediaSource asks of it** | An iPhone has no `MediaSource`, only `ManagedMediaSource` (iOS 17.1+), which `video-player.js` now falls back to; below 17.1 nothing plays. Nobody has watched a film on one yet. The player listens for neither `startstreaming`/`endstreaming` nor `bufferedchange`, and the browser may evict buffered ranges on its own, ahead of the playhead included: the read-ahead (`pump()`, `currentRange()`) assumes a buffer only it shrinks. AirPlay is switched off on that path, because the source never opens otherwise |
diff --git a/docs/MESHBAY_HTTP_API.md b/docs/MESHBAY_HTTP_API.md
index be4e7e2..70bcefe 100644
--- a/docs/MESHBAY_HTTP_API.md
+++ b/docs/MESHBAY_HTTP_API.md
@@ -61,10 +61,11 @@ hub also serves the web application at `/` and `/app/`, which are not listed.
| GET | `/v1/users/me/preferences` | user or node | The account's stored interface preferences. |
| PUT | `/v1/users/me/preferences/{key:path}` | user | Store one interface preference. |
| DELETE | `/v1/users/me/preferences/{key:path}` | user | Remove one interface preference. |
-| PUT | `/v1/users/me/node_key` | user | Link a node daemon's Ed25519 public key to the operator's account. |
-| DELETE | `/v1/users/me/node_key` | user or node | Remove the linked node key from the operator's account. |
+| GET | `/v1/users/me/node_keys` | user | The node keys linked to your account, oldest first, with what the hub saw of each node. |
+| PUT | `/v1/users/me/node_key` | user | Link a node daemon's Ed25519 public key to your account, alongside any already linked. |
+| DELETE | `/v1/users/me/node_key` | user or node | Unlink one node key from your account. |
| DELETE | `/v1/users/me` | user | Erase your own account. |
-| GET | `/v1/users/{username}/pubkeys` | user or node | Resolve a username to its account id, and its node's linking key. |
+| GET | `/v1/users/{username}/pubkeys` | user or node | Resolve a username to its account id. |
### Nodes
diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md
index cfb0743..dc263b8 100644
--- a/docs/QUICKSTART.md
+++ b/docs/QUICKSTART.md
@@ -148,8 +148,9 @@ and prints something like:
Node key: 7mK2p...base64...=
```
-**Copy that key.** In your browser, on meshbay.org: **Profile → Link Node**,
-paste it, submit. It is on the Profile page, not Settings — next to your
+**Copy that key.** In your browser, on meshbay.org: **Profile → Linked nodes**,
+paste it, **Link node**. Each machine you run a node on has its own key, and
+each is linked the same way; linking one leaves the others as they are. It is on the Profile page, not Settings — next to your
passphrase and recovery phrase, which is where everything tied to your identity
rather than to this browser lives.
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index e8bd8fb..5b6f77a 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -1069,9 +1069,10 @@ anything missing.
**"No nodes are currently online for this group."**
The node is not running, is not reachable, or has not linked its key to the
hub. On the node: `meshbay-node status`, then Step 4 and 5 of the quickstart.
-In the desktop app, the **Node** page says when your account is linked to the
-node of another machine (**Link this node instead**); **Profile → Unlink**
-lets go of a node from anywhere, for a machine that is gone.
+In the desktop app, the **Node** page says when this machine's node is not
+linked to your account (**Link this node**). **Profile → Linked nodes** lists
+every node your account has linked, one per machine, and **Unlink** lets go of
+one from anywhere — for a machine that is gone — without touching the others.
**A member sees the group but no files.**
They never redeemed an invitation code, or the group has no key