summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md8
-rw-r--r--README.md6
-rw-r--r--docs/MESHBAY_DESIGN.md6
-rw-r--r--docs/MESHBAY_HTTP_API.md233
-rw-r--r--docs/generate_http_api.py164
-rwxr-xr-x[-rw-r--r--]examples/create_group.py0
-rwxr-xr-x[-rw-r--r--]examples/list_groups.py0
-rwxr-xr-x[-rw-r--r--]examples/upload.py0
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/federation.py2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/health.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/notifications.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/tests/test_http_api_doc.py30
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py55
31 files changed, 551 insertions, 8 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 6845b5f..2f3353f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,8 +9,11 @@ music, photos). It is not a public file-sharing network; public groups are an
optional hub feature and are off on the reference deployment.
**`docs/MESHBAY_DESIGN.md` is the architecture specification.**
-`docs/MESHBAY_NODE_PROTOCOL.md` is the wire format. Everything else under
-`docs/` is either an operational guide, or a superseded document kept for its
+`docs/MESHBAY_NODE_PROTOCOL.md` is the wire format. `docs/MESHBAY_HTTP_API.md`
+lists every route of the hub and of the node's control API; it is **generated**
+by `docs/generate_http_api.py` from the routes and their docstrings, never edited
+by hand, and `test_http_api_doc.py` fails when it drifts or when a route has no
+docstring. Everything else under `docs/` is either an operational guide, or a superseded document kept for its
cross-references and carrying a banner that says so.
**`docs/QUICKSTART.md` and `docs/USERGUIDE.md` are the user documentation** —
@@ -203,6 +206,7 @@ that produced it.
| Cryptography, key hierarchy, the group and chat envelopes | §4 |
| The protocol: handshake, authorization, signed ops, leases, versioning | §5, and `docs/MESHBAY_NODE_PROTOCOL.md` for the wire format |
| The node, the hub, the clients, the applications | §6, §7, §8, §9 |
+| Every HTTP route, hub and node control API | `docs/MESHBAY_HTTP_API.md` (generated) |
| Structural decisions that are not revisited | §14 |
| What is built, what is not, what is open | §15 |
| A reference to a document that no longer exists (`draft-v5 §5.2`, `apps.md §3`, …) — in git history, or in a document outside this repository | §16, the concordance — it maps every one onto its replacement section. The code itself cites `MESHBAY_DESIGN.md` and a section directly |
diff --git a/README.md b/README.md
index ebbc1a3..3c4cbd1 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,9 @@ feature, and they are off on the reference deployment, [meshbay.org](https://mes
- **How it works:** [`docs/MESHBAY_DESIGN.md`](docs/MESHBAY_DESIGN.md) is the
architecture specification, and
[`docs/MESHBAY_NODE_PROTOCOL.md`](docs/MESHBAY_NODE_PROTOCOL.md) the wire format.
+- **Programming against it:** [`docs/MESHBAY_HTTP_API.md`](docs/MESHBAY_HTTP_API.md)
+ lists every route of the hub and of the node's control API, and
+ [`examples/`](examples/) has small Python programs that use them.
## Repository layout
@@ -39,7 +42,8 @@ feature, and they are off on the reference deployment, [meshbay.org](https://mes
| `packages/meshbay-hub/` | Hub server (FastAPI + PostgreSQL) and the web client it serves |
| `packages/meshbay-node/` | Node daemon, its CLI and its local control UI |
| `packaging/` | `.deb`, `.rpm` and Windows packaging, systemd units, Caddy and firewall configuration |
-| `docs/` | Design specification, protocol, user and operator guides |
+| `docs/` | Design specification, protocol, HTTP API, user and operator guides |
+| `examples/` | Small Python programs using the hub, a node and its control API |
| `man/` | Manual page for `meshbay-node` |
| `site/` | Static website pages (not deployed) |
| `poc/` | Early proof-of-concept scripts, kept for reference |
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index d051d87..2cfe7a4 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -33,6 +33,7 @@
| `QUICKSTART.md` | one machine to a working group, for somebody who has installed nothing |
| `USERGUIDE.md` | using a group and running a node, for the person who does either |
| `MESHBAY_NODE_PROTOCOL.md` | the MNP wire format, message by message |
+| `MESHBAY_HTTP_API.md` | every route of the hub and of the node's control API, generated from the code |
| `transfers-v1.md` | the transfer system's failure-mode analysis, kept because a synthesis cannot carry "every way a slot can be lost" |
| `playlists.md` | the playlist design and its interface in full, with what building it corrected (§9.10) |
| `cast-smart-tv.md` | the DLNA/UPnP device backend — designed, not built (§11.4) |
@@ -1888,7 +1889,8 @@ is not authentication: any local process can reach it, as can a page in the
operator's browser via DNS rebinding — and this API re-initialises group keys,
issues invitations and reads the audit log. There is no server-rendered dashboard;
the desktop client's Node page and the CLI are the two consumers, and each
-operation endpoint is one `_op(...)` line onto `ops` (§5.4).
+operation endpoint is one `_op(...)` line onto `ops` (§5.4). Its routes are listed
+in `MESHBAY_HTTP_API.md`.
**The node's own controls are not on MNP.** Its status — which lists every group
on the machine with each root's absolute path — settings, roster, denylist and
@@ -1950,6 +1952,8 @@ an operator-signed op.
## 7. The hub
+Its routes are listed in `MESHBAY_HTTP_API.md`.
+
### 7.1 Role — chosen, not minimal
Hub minimisation was considered and **deferred, and may be dropped** (decision D4).
diff --git a/docs/MESHBAY_HTTP_API.md b/docs/MESHBAY_HTTP_API.md
new file mode 100644
index 0000000..74b9d77
--- /dev/null
+++ b/docs/MESHBAY_HTTP_API.md
@@ -0,0 +1,233 @@
+# MeshBay HTTP API
+
+> **Generated** by `docs/generate_http_api.py` from the routes themselves. Do not
+> edit this file: change the route's docstring and run the script again. A test
+> fails when the two disagree.
+
+MeshBay has two HTTP APIs: the **hub's**, for accounts, groups and signaling, and
+the **node's control API**, which only its own machine reaches. Files, the index
+and chat do not use either: they travel between a client and a node over MNP
+(`MESHBAY_NODE_PROTOCOL.md`). Why each API is shaped as it is, who may call
+what and what the hub must never see are in `MESHBAY_DESIGN.md`; this file lists
+what exists.
+
+`examples/` has small Python programs that use both.
+
+## Hub
+
+Under the hub's address, `https://meshbay.org` on the reference deployment. The
+hub also serves the web application at `/` and `/app/`, which are not listed.
+
+| Auth | What the request carries |
+|---|---|
+| none | No session. Any credential is in the request itself, as the route says |
+| user | `Authorization: Bearer`, a person's session. A node's token is refused |
+| user or node | A person's session, or a node daemon's token from `/v1/nodes/auth` |
+| node | A node daemon's token only |
+| moderator | A person's session, for an account with the moderator or admin role |
+| admin | A person's session, for an account with the admin role |
+| peer hub | Another hub's MHP token. Every route answers 503 unless federation is enabled |
+
+### Instance
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| GET | `/v1/hub/info` | none | Versions and the instance policy a client needs before signing in. |
+| GET | `/v1/hub/pubkey` | none | Hub Ed25519 public key PEM — cached by nodes on first contact. |
+| GET | `/v1/hub/version` | none | Version check endpoint for clients to detect updates. |
+
+### Accounts
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| POST | `/v1/users/register` | none | Create an account. |
+| POST | `/v1/users/verify-email` | none | Verify a registration email with the code received by mail. |
+| POST | `/v1/users/me/bundle-pepper` | user | The pepper, for a session that has just been given the passphrase again. |
+| POST | `/v1/users/login` | none | Sign in with the auth key derived from the passphrase. |
+| POST | `/v1/users/devices` | user | Register a device's hub authentication key. |
+| GET | `/v1/users/devices` | user or node | The account's registered devices. |
+| DELETE | `/v1/users/devices/{device_id}` | user | Retire a device's hub key. |
+| POST | `/v1/users/auth` | none | Sign in with a registered device key. |
+| POST | `/v1/users/token/refresh` | none | Exchange a refresh token for a new session. |
+| GET | `/v1/users/me` | user or node | The signed-in account: id, name, e-mail, role, status. |
+| PATCH | `/v1/users/me` | user | Update the signed-in account. |
+| POST | `/v1/users/verify-email-change` | user | Confirm an email change with the code sent to the new address. |
+| POST | `/v1/users/logout` | none | End this session on the hub, not only in the browser. |
+| POST | `/v1/users/me/sessions/revoke` | user | Sign out everywhere: no refresh token of this account renews any more. |
+| POST | `/v1/users/password` | user | Change the passphrase, proving the current one. |
+| POST | `/v1/users/password/reset-request` | none | Send a reset code by e-mail, when the username and the address match. |
+| POST | `/v1/users/password/reset` | none | Set a new passphrase with the code received by e-mail. |
+| 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. |
+| 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. |
+
+### Nodes
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| POST | `/v1/nodes/mnp-token` | user | Mint the short-lived token a member presents to a node in the MNP handshake. |
+| POST | `/v1/nodes/auth` | none | Authenticate a node daemon via Ed25519 challenge-response. |
+| POST | `/v1/nodes/announce` | user or node | Register a node record. |
+| GET | `/v1/nodes/{node_id}` | user or node | A node's public record: owner, key, endpoint hint. |
+
+### Groups
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| GET | `/v1/groups/mine` | user or node | List groups the current user belongs to. |
+| GET | `/v1/groups/invitations` | user or node | Groups somebody added this account to, waiting for it to say yes. |
+| POST | `/v1/groups/{group_id}/invitation/accept` | user | Accept an invitation: the account becomes a member of the group. |
+| POST | `/v1/groups/{group_id}/invitation/decline` | user | Decline an invitation to a group. |
+| POST | `/v1/groups/{group_id}/activity` | user or node | Bump a group's last_activity_at. |
+| GET | `/v1/groups/{group_id}/nodes` | user or node | Return online nodes that serve a group (for WebRTC connection). |
+| GET | `/v1/groups` | none | List/search public groups — local and optionally federated. |
+| GET | `/v1/groups/{group_id}/members` | user or node | A group's members, for its members only. |
+| POST | `/v1/groups/{group_id}/join` | user | Join an open group. |
+| POST | `/v1/groups` | user | Create a group, owned by the caller. |
+| DELETE | `/v1/groups/{group_id}/members/{username}` | user | Remove someone from a group. |
+| POST | `/v1/groups/{group_id}/leave` | user | Leave a group you are a member of. |
+| PATCH | `/v1/groups/{group_id}` | user | Change the group's description. |
+| POST | `/v1/groups/{group_id}/members/{username}` | user or node | Add an account to a group the caller owns. |
+| POST | `/v1/groups/{group_id}/mute` | user | Turn this group's notifications on or off, for this account. |
+| DELETE | `/v1/groups/{group_id}` | user | Delete a group. |
+| POST | `/v1/groups/{group_id}/invite-notify` | user | Send an invitation email to a member who was just invited. |
+| GET | `/v1/groups/{group_id}/hosts` | user | The nodes that host a group or asked to, for its owner. |
+| POST | `/v1/groups/{group_id}/hosts/{node_id}` | user | Approve a node that asked to host this group. |
+| DELETE | `/v1/groups/{group_id}/hosts/{node_id}` | user | Withdraw an approval, or turn a request down. |
+
+### Invitation links
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| POST | `/v1/groups/{group_id}/invite-links` | user or node | Mint the ticket for a link whose node half already exists. |
+| GET | `/v1/groups/{group_id}/invite-links` | user or node | The owner's view: the links nobody has used yet, masked. |
+| DELETE | `/v1/groups/{group_id}/invite-links/{link_id}` | user or node | Take the ticket back. |
+| POST | `/v1/invite-links/preview` | user | What the confirmation screen shows before anyone joins anything. |
+| POST | `/v1/invite-links/redeem` | user | Membership for the first account that asks, once — and the same answer again for that account, because a second tab or a reload is the same person. |
+
+### Revocation and the node socket
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| WS | `/v1/nodes/ws` | none | Persistent WebSocket connection for nodes, authenticated by the node's token in the first message. |
+| POST | `/v1/nodes/{node_id}/incoming` | user or node | Signal a node that a client wants to connect (NAT punch coordination). |
+| POST | `/v1/admin/revoke` | admin | Revoke a user or group. |
+
+### Moderation
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| POST | `/v1/reports` | user | Report a file of a public group, as a member of that group. |
+| GET | `/v1/blocklist` | node | The content blocklist, a page at a time, for a node hosting a public group. |
+| GET | `/v1/admin/blocklist` | admin | The content blocklist. |
+| POST | `/v1/admin/blocklist` | admin | Add a content hash (BLAKE3) to the blocklist. |
+| DELETE | `/v1/admin/blocklist/{content_hash}` | admin | Remove a content hash from the blocklist. |
+| GET | `/v1/admin/reports` | moderator | Hashes waiting for a decision, oldest first, with what was said about them. |
+| POST | `/v1/admin/reports/{content_hash}/block` | admin | Block reported content and close its reports. |
+| POST | `/v1/admin/reports/{content_hash}/dismiss` | admin | Dismiss the reports on a piece of content. |
+
+### Federation (MHP)
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| GET | `/mhp/info` | peer hub | Return this hub's identity for peer registration. |
+| GET | `/mhp/directory` | peer hub | This hub's public groups, for a peer hub presenting an MHP token. |
+| POST | `/mhp/directory` | peer hub | A peer hub's public groups, pushed with a single-use MHP token. |
+| POST | `/mhp/revoke` | peer hub | Act on a revocation from a peer hub. |
+| POST | `/mhp/peers` | admin | Admin: register a trusted peer hub. |
+| GET | `/mhp/peers` | admin | Admin: list registered peer hubs. |
+
+### Health
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| GET | `/v1/health` | none | Liveness: database reachable, version, connected nodes. |
+
+### Signaling
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| POST | `/v1/nodes/{node_id}/webrtc/offer` | user or node | Browser sends WebRTC SDP offer for a node. |
+
+### Administration
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| GET | `/v1/admin/settings` | moderator | Instance-wide policy an admin controls from the panel. |
+| PATCH | `/v1/admin/settings` | admin | Change instance policy. |
+| GET | `/v1/admin/mail` | moderator | Is the hub still sending, and how much of the hour is left. |
+| GET | `/v1/admin/stats` | moderator | Account, group and node counts. |
+| GET | `/v1/admin/users` | moderator | Search and list accounts. |
+| GET | `/v1/admin/users/{user_id}` | moderator | One account, with its group count. |
+| PATCH | `/v1/admin/users/{user_id}` | moderator | Change an account's status, or its role (admin only). |
+| DELETE | `/v1/admin/users/{user_id}` | admin | Erase an account, and every group it owns. |
+| GET | `/v1/admin/groups` | moderator | List groups with their member counts. |
+| PATCH | `/v1/admin/groups/{group_id}` | moderator | Change a group's status. |
+| GET | `/v1/admin/nodes` | moderator | Registered nodes, with the address the hub saw them announce from. |
+| GET | `/v1/admin/logs` | moderator | The connection log, filtered by account and event. |
+
+### Notifications
+
+| Method | Path | Auth | What |
+|---|---|---|---|
+| GET | `/v1/notifications` | user or node | The account's notifications, newest first. |
+| POST | `/v1/notifications/{notification_id}/read` | user or node | Dismiss one. |
+| DELETE | `/v1/notifications/{notification_id}` | user or node | Dismiss one. |
+| DELETE | `/v1/notifications` | user or node | Throw them all away. |
+| POST | `/v1/notifications/read-all` | user or node | Dismiss every one — the same thing as `DELETE ""`, under the name an older client knows it by. |
+
+## Node control API
+
+`http://127.0.0.1:<ui_port>`, port 18000 unless `ui_port` in `node.toml` says
+otherwise, and never on another address. Every request carries the token the
+daemon writes to `<data_dir>/ui-token` (`~/.local/share/meshbay/ui-token` on
+Linux), as the `X-MeshBay-Token` header or the `t` query parameter. The daemon
+draws a new one at each start and deletes the file when it stops.
+
+| Method | Path | What |
+|---|---|---|
+| GET | `/api/status` | The daemon's state, and what it still needs: a linked key, a group, an operator, a group key. |
+| DELETE | `/api/unlink` | Unlink the node's key from its hub account. |
+| GET | `/api/groups` | The groups this node hosts, with live status, and whether an operator is paired. |
+| POST | `/api/groups/attach` | Host a group that exists on the hub: add it to node.toml with its first folder, then reload. |
+| POST | `/api/groups/detach` | Stop hosting a group: remove it from node.toml, then reload. |
+| DELETE | `/api/groups/{group_id}/files/{file_id}` | Delete a file from the group's folder on disk. |
+| GET | `/api/denylist` | What the node currently refuses. |
+| POST | `/api/denylist/clear` | Drop denylist entries: all of them, or one identifier. |
+| GET | `/api/index-cache` | Size of the index cache. |
+| POST | `/api/index-cache/prune` | Drop index cache rows that no longer match a file on disk. |
+| POST | `/api/groups/{group_id}/video/rematch` | Forget the automatic matches of the group's videos, so they are looked up again. |
+| GET | `/api/groups/{group_id}/files` | The group's files, from its index. |
+| GET | `/api/peers` | The connected peers. |
+| GET | `/api/audit` | The audit log, filtered by time, account and event. |
+| POST | `/api/operator/pair` | A one-time code that pairs an application as this node's operator. |
+| GET | `/api/roster` | The pinned identities, for one group or all. |
+| POST | `/api/groups/{group_id}/invites` | An invitation code for one account, for this group. |
+| POST | `/api/groups/{group_id}/invite-links` | A whole invitation link: the node's code, then the hub's ticket. |
+| DELETE | `/api/groups/{group_id}/invite-links/{invite_id}` | Take an invitation link back, on the node and on the hub. |
+| GET | `/api/resolve` | Map a username to an account id, through the hub. |
+| POST | `/api/members/{user_id}/revoke` | Stop serving the group key to a member. |
+| POST | `/api/members/{user_id}/unpin` | Forget a pinned identity, so the person can pair again with a new key. |
+| GET | `/api/groups/{group_id}/chat` | What the operator needs to decide anything about the group's chat. |
+| POST | `/api/groups/{group_id}/chat/epoch` | Open a new chat epoch. |
+| POST | `/api/groups/{group_id}/chat/encrypt-history` | Re-encrypt the messages written before the group's chat was encrypted. |
+| POST | `/api/groups/{group_id}/chat/prune` | Delete chat messages older than a number of days. |
+| POST | `/api/groups/{group_id}/gek` | Generate the group key, or rotate it with ?rotate=true. |
+| POST | `/api/groups/{group_id}/roots` | Add a folder to a group. |
+| PATCH | `/api/groups/{group_id}/roots/{root_name}` | Make a folder writable or removable, or not. |
+| PUT | `/api/groups/{group_id}/roots/{root_name}/eject` | Eject a removable folder so its disk can be unplugged. |
+| PUT | `/api/groups/{group_id}/roots/{root_name}/plug` | Bring an ejected folder back. |
+| DELETE | `/api/groups/{group_id}/roots/{root_name}` | Remove a folder from a group. |
+| GET | `/api/groups/{group_id}/index-status` | One group's indexing progress. |
+| GET | `/api/index-status` | Every group's indexing progress. |
+| PUT | `/api/groups/{group_id}/apps` | Which applications members see for the group. |
+| POST | `/api/reload` | Reload node.toml. |
+| POST | `/api/shutdown` | Stop the daemon. |
+| GET | `/api/node-settings` | The node's effective settings. |
+| PUT | `/api/node-settings` | Change node settings, written to roster.db and node.toml. |
+| GET | `/api/transfers` | Live transfer leases and queue depth. |
+| PUT | `/api/groups/{group_id}/transfer-limits` | How many transfers one member may run at once in this group. |
diff --git a/docs/generate_http_api.py b/docs/generate_http_api.py
new file mode 100644
index 0000000..d064519
--- /dev/null
+++ b/docs/generate_http_api.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""
+Write docs/MESHBAY_HTTP_API.md from the routes of the hub and of the node's
+control API.
+
+ python docs/generate_http_api.py
+
+Generated rather than written: a list of a hundred routes kept by hand is wrong
+by the next one added. `test_http_api_doc.py` fails when the file and the code
+disagree, and when a route has no docstring to describe it.
+"""
+
+import inspect
+import re
+import sys
+from pathlib import Path
+
+from fastapi.routing import APIRoute, APIWebSocketRoute
+
+OUT = Path(__file__).resolve().parent / "MESHBAY_HTTP_API.md"
+
+# The hub's routers, by module, in the order the hub includes them.
+SECTIONS = {
+ "hub": "Instance",
+ "users": "Accounts",
+ "nodes": "Nodes",
+ "groups": "Groups",
+ "invite_links": "Invitation links",
+ "revocation": "Revocation and the node socket",
+ "moderation": "Moderation",
+ "federation": "Federation (MHP)",
+ "health": "Health",
+ "signaling": "Signaling",
+ "admin": "Administration",
+ "notifications": "Notifications",
+}
+
+# Strongest first: a route is labelled by the first dependency it carries.
+AUTH = [
+ ("require_admin", "admin"),
+ ("require_moderator", "moderator"),
+ ("require_node_scope", "node"),
+ ("require_user_scope", "user"),
+ ("get_current_user", "user or node"),
+ ("_decode_token", "token"),
+ ("_federation_open", "peer hub"),
+]
+
+AUTH_LEGEND = """\
+| Auth | What the request carries |
+|---|---|
+| none | No session. Any credential is in the request itself, as the route says |
+| user | `Authorization: Bearer`, a person's session. A node's token is refused |
+| user or node | A person's session, or a node daemon's token from `/v1/nodes/auth` |
+| node | A node daemon's token only |
+| moderator | A person's session, for an account with the moderator or admin role |
+| admin | A person's session, for an account with the admin role |
+| peer hub | Another hub's MHP token. Every route answers 503 unless federation is enabled |
+"""
+
+HEADER = """\
+# MeshBay HTTP API
+
+> **Generated** by `docs/generate_http_api.py` from the routes themselves. Do not
+> edit this file: change the route's docstring and run the script again. A test
+> fails when the two disagree.
+
+MeshBay has two HTTP APIs: the **hub's**, for accounts, groups and signaling, and
+the **node's control API**, which only its own machine reaches. Files, the index
+and chat do not use either: they travel between a client and a node over MNP
+(`MESHBAY_NODE_PROTOCOL.md`). Why each API is shaped as it is, who may call
+what and what the hub must never see are in `MESHBAY_DESIGN.md`; this file lists
+what exists.
+
+`examples/` has small Python programs that use both.
+"""
+
+HUB_INTRO = """\
+
+## Hub
+
+Under the hub's address, `https://meshbay.org` on the reference deployment. The
+hub also serves the web application at `/` and `/app/`, which are not listed.
+
+"""
+
+NODE_INTRO = """\
+## Node control API
+
+`http://127.0.0.1:<ui_port>`, port 18000 unless `ui_port` in `node.toml` says
+otherwise, and never on another address. Every request carries the token the
+daemon writes to `<data_dir>/ui-token` (`~/.local/share/meshbay/ui-token` on
+Linux), as the `X-MeshBay-Token` header or the `t` query parameter. The daemon
+draws a new one at each start and deletes the file when it stops.
+
+| Method | Path | What |
+|---|---|---|
+"""
+
+
+def flatten(routes):
+ for r in routes:
+ if hasattr(r, "original_router"):
+ yield from flatten(r.original_router.routes)
+ elif isinstance(r, (APIRoute, APIWebSocketRoute)):
+ yield r
+
+
+def summary(route) -> str:
+ """The docstring's first sentence."""
+ doc = inspect.getdoc(route.endpoint) or ""
+ first = " ".join(doc.split("\n\n")[0].split())
+ return re.split(r"(?<=[.!?])\s+(?=[A-Z`])", first)[0].replace("|", "\\|")
+
+
+def method(route) -> str:
+ if isinstance(route, APIWebSocketRoute):
+ return "WS"
+ return ", ".join(sorted(route.methods - {"HEAD"}))
+
+
+def auth(route) -> str:
+ names = set()
+
+ def walk(dependant):
+ for d in dependant.dependencies:
+ if d.call is not None:
+ names.add(getattr(d.call, "__name__", ""))
+ walk(d)
+
+ walk(route.dependant)
+ return next((label for name, label in AUTH if name in names), "none")
+
+
+def hub_routes() -> list:
+ from meshbay_hub.app import create_app
+ return [r for r in flatten(create_app().routes)
+ if r.endpoint.__module__.rsplit(".", 1)[-1] != "webapp"]
+
+
+def node_routes() -> list:
+ from meshbay_node.ui.app import create_ui_app
+ return list(flatten(create_ui_app({}).routes))
+
+
+def render() -> str:
+ out = [HEADER, HUB_INTRO, AUTH_LEGEND]
+ by_module: dict[str, list] = {}
+ for r in hub_routes():
+ by_module.setdefault(r.endpoint.__module__.rsplit(".", 1)[-1], []).append(r)
+ for module, routes in by_module.items():
+ out.append(f"\n### {SECTIONS.get(module, module.replace('_', ' ').capitalize())}\n\n")
+ out.append("| Method | Path | Auth | What |\n|---|---|---|---|\n")
+ for r in routes:
+ out.append(f"| {method(r)} | `{r.path}` | {auth(r)} | {summary(r)} |\n")
+ out.append("\n" + NODE_INTRO)
+ for r in node_routes():
+ out.append(f"| {method(r)} | `{r.path}` | {summary(r)} |\n")
+ return "".join(out)
+
+
+if __name__ == "__main__":
+ OUT.write_text(render(), encoding="utf-8")
+ print(f"wrote {OUT}", file=sys.stderr)
diff --git a/examples/create_group.py b/examples/create_group.py
index c03e5e8..c03e5e8 100644..100755
--- a/examples/create_group.py
+++ b/examples/create_group.py
diff --git a/examples/list_groups.py b/examples/list_groups.py
index 61773e2..61773e2 100644..100755
--- a/examples/list_groups.py
+++ b/examples/list_groups.py
diff --git a/examples/upload.py b/examples/upload.py
index 518b5ec..518b5ec 100644..100755
--- a/examples/upload.py
+++ b/examples/upload.py
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index dbda197..1cb8bf9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -213,6 +213,7 @@ async def admin_stats(
current_user: User = Depends(require_moderator),
db: AsyncSession = Depends(get_db),
):
+ """Account, group and node counts."""
# Deleted accounts are tombstoned rather than dropped, so that the
# connection log stays readable. They are not users any more and must not be
# counted as any: a hub whose user count only ever rises is measuring its
@@ -244,6 +245,7 @@ async def admin_list_users(
offset: int = 0,
limit: int = Query(default=50, le=200),
):
+ """Search and list accounts."""
query = (select(User).where(User.status != "deleted")
.order_by(User.created_at.desc()))
if q:
@@ -279,6 +281,7 @@ async def admin_get_user(
current_user: User = Depends(require_moderator),
db: AsyncSession = Depends(get_db),
):
+ """One account, with its group count."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -310,6 +313,7 @@ async def admin_patch_user(
current_user: User = Depends(require_moderator),
db: AsyncSession = Depends(get_db),
):
+ """Change an account's status, or its role (admin only)."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -473,6 +477,7 @@ async def admin_list_groups(
offset: int = 0,
limit: int = Query(default=50, le=200),
):
+ """List groups with their member counts."""
query = (
select(
Group,
@@ -524,6 +529,7 @@ async def admin_patch_group(
current_user: User = Depends(require_moderator),
db: AsyncSession = Depends(get_db),
):
+ """Change a group's status."""
group = await db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -624,6 +630,7 @@ async def admin_list_logs(
offset: int = 0,
limit: int = Query(default=50, le=200),
):
+ """The connection log, filtered by account and event."""
query = (
select(IPLog, User.username)
.outerjoin(User, IPLog.user_id == User.id)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/federation.py b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
index 755639e..e737a1c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/federation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/federation.py
@@ -190,6 +190,7 @@ async def export_directory(
db: AsyncSession = Depends(get_db),
authorization: str = Header(...),
):
+ """This hub's public groups, for a peer hub presenting an MHP token."""
try:
await _verify_mhp_token(authorization.removeprefix("Bearer "), db)
except Exception as e:
@@ -232,6 +233,7 @@ async def receive_directory(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
):
+ """A peer hub's public groups, pushed with a single-use MHP token."""
try:
payload = await _verify_mhp_token(
authorization.removeprefix("Bearer "), db, single_use=True)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index fc117bf..d58e32c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -120,6 +120,7 @@ async def accept_invitation(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Accept an invitation: the account becomes a member of the group."""
inv = await db.get(GroupInvitation, (group_id, current_user.id))
group = await db.get(Group, group_id)
if inv is None or group is None or group.status != "active":
@@ -141,6 +142,7 @@ async def decline_invitation(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Decline an invitation to a group."""
inv = await db.get(GroupInvitation, (group_id, current_user.id))
if inv is None:
raise HTTPException(status_code=404, detail="No such invitation")
@@ -281,6 +283,7 @@ async def group_members(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """A group's members, for its members only."""
group = await db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -319,6 +322,7 @@ async def join_group(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Join an open group."""
group = await db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -421,6 +425,7 @@ async def create_group(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Create a group, owned by the caller. No node hosts it yet."""
# Being listed and being open are one question, not two.
#
# A public group that admits nobody is a contradiction: it is in the
@@ -643,6 +648,7 @@ async def add_group_member(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """Add an account to a group the caller owns. Also called by the owner's node."""
# `get_current_user`, not `require_user_scope`: the node calls this after a
# CLI `member invite` so the group becomes visible in the invitee's SPA
# (commit 0443cf8). The node authenticates with a node-scoped token, and the
@@ -713,6 +719,7 @@ async def delete_group(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Delete a group. Its owner only."""
group = await db.get(Group, group_id)
if not group:
raise HTTPException(status_code=404, detail="Group not found")
@@ -827,6 +834,7 @@ async def list_hosts(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """The nodes that host a group or asked to, for its owner."""
from meshbay_hub.api.revocation import is_node_connected
await _owned(db, group_id, current_user)
rows = (await db.execute(
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/health.py b/packages/meshbay-hub/src/meshbay_hub/api/health.py
index 516856b..abb1b53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/health.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/health.py
@@ -13,6 +13,7 @@ router = APIRouter(tags=["health"])
@router.get("/v1/health")
async def health(db: AsyncSession = Depends(get_db)):
+ """Liveness: database reachable, version, connected nodes."""
await db.execute(text("SELECT 1"))
return {
"status": "ok",
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index 2a3efaf..d1246a3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -22,6 +22,7 @@ def set_config(cfg: HubConfig) -> None:
@router.get("/info")
async def hub_info(db: AsyncSession = Depends(get_db)):
+ """Versions and the instance policy a client needs before signing in."""
engine = get_engine()
return {
"hub_version": __version__,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index 038f310..ecca4df 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -244,6 +244,7 @@ async def admin_list_blocklist(
db: AsyncSession = Depends(get_db),
limit: int = 500,
):
+ """The content blocklist."""
result = await db.execute(
select(ContentBlocklist)
.order_by(ContentBlocklist.added_at.desc())
@@ -269,6 +270,7 @@ async def admin_add_blocklist(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
+ """Add a content hash (BLAKE3) to the blocklist."""
if not _is_hash(body.content_hash):
raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
existing = await db.get(ContentBlocklist, body.content_hash)
@@ -291,6 +293,7 @@ async def admin_remove_blocklist(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
+ """Remove a content hash from the blocklist."""
entry = await db.get(ContentBlocklist, content_hash)
if not entry:
raise HTTPException(status_code=404, detail="Hash not in blocklist")
@@ -344,6 +347,7 @@ async def admin_block_reported(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
+ """Block reported content and close its reports."""
await _decide(db, content_hash, "blocked", current_user.username)
reasons = Counter((await db.execute(select(ContentReport.reason).where(
ContentReport.content_hash == content_hash))).scalars().all())
@@ -363,6 +367,7 @@ async def admin_dismiss_reported(
current_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
+ """Dismiss the reports on a piece of content."""
await _decide(db, content_hash, "dismissed", current_user.username)
await db.commit()
return {"status": "dismissed", "hash": content_hash}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index decd20e..b158621 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -250,6 +250,7 @@ async def get_node(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """A node's public record: owner, key, endpoint hint."""
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=404, detail="Node not found")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
index d96ec18..e4bac2e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py
@@ -43,6 +43,7 @@ async def list_notifications(
offset: int = Query(default=0, ge=0),
unread_only: bool = False,
):
+ """The account's notifications, newest first."""
query = select(Notification).where(Notification.user_id == current_user.id)
if unread_only:
query = query.where(Notification.read == False) # noqa: E712
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index 5a33d77..2499374 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -438,7 +438,8 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup
@router.websocket("/v1/nodes/ws")
async def node_websocket(ws: WebSocket):
"""
- Persistent WebSocket connection for nodes.
+ Persistent WebSocket connection for nodes, authenticated by the node's token in the
+ first message.
Finding C2: this used to take `node_id` and `group_ids` straight from the
client's first message, with no check that the authenticated user owned that
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 9326bfb..795a902 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -183,6 +183,7 @@ async def register(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ """Create an account. It stays inactive until its e-mail address is verified."""
eh = hash_email_blind(body.email)
# Unique regardless of case: invitations and member management name people
@@ -485,6 +486,10 @@ async def login(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ """
+ Sign in with the auth key derived from the passphrase. Returns the session and the bundle
+ pepper.
+ """
ip = client_ip(request)
if not body.auth_key and not body.password:
@@ -662,6 +667,7 @@ async def list_devices(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """The account's registered devices."""
result = await db.execute(
select(UserDevice).where(UserDevice.user_id == current_user.id)
.order_by(UserDevice.created_at))
@@ -777,6 +783,7 @@ async def token_refresh(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ """Exchange a refresh token for a new session. Reusing a spent one revokes the whole family."""
rt_hash = hash_refresh_token(body.refresh_token)
result = await db.execute(
select(RefreshToken).where(RefreshToken.token_hash == rt_hash))
@@ -840,6 +847,7 @@ async def get_current_user_info(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """The signed-in account: id, name, e-mail, role, status."""
email = ""
try:
email = decrypt_email(current_user.email) if current_user.email else ""
@@ -1144,6 +1152,7 @@ async def change_password(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Change the passphrase, proving the current one."""
await _take_login_attempt(db, _session_counter(current_user))
if not await verify_password_off_loop(body.old_auth_key, current_user.pw_hash,
current_user.pw_salt, current_user.pw_version):
@@ -1231,6 +1240,7 @@ async def password_reset_request(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ """Send a reset code by e-mail, when the username and the address match."""
if _cfg and _cfg.captcha.enabled:
await _verify_captcha_or_raise(body.captcha_token, request)
@@ -1310,6 +1320,7 @@ async def password_reset(
request: Request,
db: AsyncSession = Depends(get_db),
):
+ """Set a new passphrase with the code received by e-mail."""
now = datetime.now(UTC)
result = await db.execute(select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
@@ -1407,6 +1418,7 @@ async def get_preferences(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """The account's stored interface preferences."""
result = await db.execute(
select(UserPreference).where(UserPreference.user_id == current_user.id))
prefs = {p.key: p.value for p in result.scalars().all()}
@@ -1424,6 +1436,7 @@ async def set_preference(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Store one interface preference."""
if not _valid_pref_key(key):
raise HTTPException(status_code=400, detail=f"Unknown preference key: {key[:80]}")
if len(body.value) > MAX_PREFERENCE_VALUE:
@@ -1454,6 +1467,7 @@ async def delete_preference(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ """Remove one interface preference."""
result = await db.execute(
select(UserPreference).where(
UserPreference.user_id == current_user.id,
@@ -1624,6 +1638,7 @@ async def get_user_pubkeys(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """Resolve a username to its account id, and its node's linking key. Not a key directory."""
result = await db.execute(select(User).where(User.username == username))
target = result.scalar_one_or_none()
if not target:
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index cb95816..c12db72 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -276,7 +276,8 @@ const WELCOME_DOCS = [
['welcome.docs_userguide', `${REPO}docs/USERGUIDE.md`]]],
['gear', 'welcome.docs_devel', [
['welcome.docs_design', `${REPO}docs/MESHBAY_DESIGN.md`],
- ['welcome.docs_protocol', `${REPO}docs/MESHBAY_NODE_PROTOCOL.md`]]],
+ ['welcome.docs_protocol', `${REPO}docs/MESHBAY_NODE_PROTOCOL.md`],
+ ['welcome.docs_api', `${REPO}docs/MESHBAY_HTTP_API.md`]]],
];
// Under the sign-in form rather than at the foot of the text: on a desktop the
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index bb54c0d..0221c9a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -123,6 +123,7 @@ export default {
'welcome.docs_devel': 'Entwicklerdoku',
'welcome.docs_design': 'Architektur',
'welcome.docs_protocol': 'Protokoll',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Herunterladen (Beta)',
'welcome.legal': 'Rechtliche Hinweise',
'register.err_mismatch': 'Die Passwörter stimmen nicht überein',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 1bed529..4ee28cd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -126,6 +126,7 @@ export default {
'welcome.docs_devel': 'Developer docs',
'welcome.docs_design': 'Design',
'welcome.docs_protocol': 'Protocol',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Download (beta)',
'welcome.legal': 'Legal information',
'register.err_mismatch': 'Passwords do not match',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 936d8b6..f9f1497 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -122,6 +122,7 @@ export default {
'welcome.docs_devel': 'Docs de desarrollo',
'welcome.docs_design': 'Diseño',
'welcome.docs_protocol': 'Protocolo',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Descargar (beta)',
'welcome.legal': 'Información legal',
'register.err_mismatch': 'Las contraseñas no coinciden',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 811db8d..6561ace 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -122,6 +122,7 @@ export default {
'welcome.docs_devel': 'Docs développeur',
'welcome.docs_design': 'Conception',
'welcome.docs_protocol': 'Protocole',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Télécharger (bêta)',
'welcome.legal': 'Informations légales',
'register.err_mismatch': 'Les mots de passe ne correspondent pas',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 63bff86..052d02f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -123,6 +123,7 @@ export default {
'welcome.docs_devel': 'Documentazione tecnica',
'welcome.docs_design': 'Architettura',
'welcome.docs_protocol': 'Protocollo',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Scarica (beta)',
'welcome.legal': 'Note legali',
'register.err_mismatch': 'Le password non coincidono',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index d9677c7..0c91c56 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -123,6 +123,7 @@ export default {
'welcome.docs_devel': '開発者向け',
'welcome.docs_design': '設計',
'welcome.docs_protocol': 'プロトコル',
+ 'welcome.docs_api': 'API',
'welcome.download': 'ダウンロード(ベータ版)',
'welcome.legal': '法的情報',
'register.err_mismatch': 'パスワードが一致しません',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2ddb872..cccb3cb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -123,6 +123,7 @@ export default {
'welcome.docs_devel': 'Ontwikkelaarsdocs',
'welcome.docs_design': 'Ontwerp',
'welcome.docs_protocol': 'Protocol',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Downloaden (bèta)',
'welcome.legal': 'Juridische informatie',
'register.err_mismatch': 'De wachtwoorden komen niet overeen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 504dcd9..9937af6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -126,6 +126,7 @@ export default {
'welcome.docs_devel': 'Dla programistów',
'welcome.docs_design': 'Architektura',
'welcome.docs_protocol': 'Protokół',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Pobierz (beta)',
'welcome.legal': 'Informacje prawne',
'register.err_mismatch': 'Hasła nie są zgodne',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index df1c140..0f6b331 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -124,6 +124,7 @@ export default {
'welcome.docs_devel': 'Docs de desenvolvimento',
'welcome.docs_design': 'Arquitetura',
'welcome.docs_protocol': 'Protocolo',
+ 'welcome.docs_api': 'API',
'welcome.download': 'Baixar (beta)',
'welcome.legal': 'Informações legais',
'register.err_mismatch': 'As senhas não coincidem',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 8e72359..ebefc71 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -123,6 +123,7 @@ export default {
'welcome.docs_devel': '开发文档',
'welcome.docs_design': '设计',
'welcome.docs_protocol': '协议',
+ 'welcome.docs_api': 'API',
'welcome.download': '下载(测试版)',
'welcome.legal': '法律信息',
'register.err_mismatch': '两次输入的密码不一致',
diff --git a/packages/meshbay-hub/tests/test_http_api_doc.py b/packages/meshbay-hub/tests/test_http_api_doc.py
new file mode 100644
index 0000000..5568e63
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_http_api_doc.py
@@ -0,0 +1,30 @@
+"""
+docs/MESHBAY_HTTP_API.md is generated from the routes of the hub and of the
+node's control API. A route added, removed or redescribed without running
+`python docs/generate_http_api.py` fails here.
+"""
+
+import importlib.util
+from pathlib import Path
+
+GENERATOR = Path(__file__).resolve().parents[3] / "docs" / "generate_http_api.py"
+
+
+def _generator():
+ spec = importlib.util.spec_from_file_location("generate_http_api", GENERATOR)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_the_api_listing_matches_the_routes():
+ gen = _generator()
+ assert gen.OUT.read_text(encoding="utf-8") == gen.render(), (
+ "docs/MESHBAY_HTTP_API.md is out of date: run python docs/generate_http_api.py")
+
+
+def test_every_route_says_what_it_does():
+ gen = _generator()
+ bare = [f"{gen.method(r)} {r.path}" for r in gen.hub_routes() + gen.node_routes()
+ if not gen.summary(r)]
+ assert not bare, f"give these routes a docstring, it is their line in the listing: {bare}"
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index db11a09..03db8b6 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -120,6 +120,10 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/api/status")
async def api_status():
+ """
+ The daemon's state, and what it still needs: a linked key, a group, an operator, a group
+ key.
+ """
indexes = state.get("indexes", {})
total_files = sum(idx.count for idx in indexes.values())
groups_ctx = state.get("groups_ctx", {})
@@ -163,6 +167,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.delete("/api/unlink")
async def api_unlink():
+ """Unlink the node's key from its hub account."""
hub = state.get("hub")
if not hub:
raise HTTPException(status_code=503, detail="Hub not connected")
@@ -171,9 +176,13 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/api/groups")
async def api_groups():
+ """The groups this node hosts, with live status, and whether an operator is paired."""
return await _op(lambda: ops.list_groups(state))
@app.post("/api/groups/attach")
async def attach_group(payload: dict):
+ """
+ Host a group that exists on the hub: add it to node.toml with its first folder, then reload.
+ """
result = await _op(lambda: ops.attach_group(
state,
(payload.get("name") or "").strip(),
@@ -188,6 +197,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.post("/api/groups/detach")
async def detach_group(payload: dict):
+ """Stop hosting a group: remove it from node.toml, then reload."""
result = await _op(lambda: ops.detach_group(
state,
(payload.get("name") or payload.get("group_id") or "").strip(),
@@ -199,31 +209,41 @@ def create_ui_app(state: dict) -> FastAPI:
@app.delete("/api/groups/{group_id}/files/{file_id}")
async def delete_file(group_id: str, file_id: str):
- """Milestone 14.11 — the last operator action that needed a browser."""
+ """
+ Delete a file from the group's folder on disk.
+
+ Milestone 14.11 — the last operator action that needed a browser.
+ """
return await _op(lambda: ops.delete_file(state, group_id, file_id))
@app.get("/api/denylist")
async def api_denylist():
+ """What the node currently refuses."""
return await _op(lambda: ops.read_denylist(state))
@app.post("/api/denylist/clear")
async def api_denylist_clear(subject: str = ""):
+ """Drop denylist entries: all of them, or one identifier."""
return await _op(lambda: ops.clear_denylist(state, subject=subject))
@app.get("/api/index-cache")
async def api_index_cache_stats():
+ """Size of the index cache."""
return await _op(lambda: ops.index_cache_stats(state))
@app.post("/api/index-cache/prune")
async def api_index_cache_prune():
+ """Drop index cache rows that no longer match a file on disk."""
return await _op(lambda: ops.prune_index_cache(state))
@app.post("/api/groups/{group_id}/video/rematch")
async def api_video_rematch(group_id: str):
+ """Forget the automatic matches of the group's videos, so they are looked up again."""
return await _op(lambda: ops.rematch_video(state, group_id))
@app.get("/api/groups/{group_id}/files")
async def api_group_files(group_id: str):
+ """The group's files, from its index."""
groups_ctx = state.get("groups_ctx", {})
ctx = groups_ctx.get(group_id)
if not ctx:
@@ -247,6 +267,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/api/peers")
async def api_peers():
+ """The connected peers."""
webrtc = state.get("webrtc")
if not webrtc:
return {"peers": []}
@@ -275,6 +296,7 @@ def create_ui_app(state: dict) -> FastAPI:
user_id: str | None = Query(default=None),
event: str | None = Query(default=None),
):
+ """The audit log, filtered by time, account and event."""
audit = state.get("audit_store")
if not audit:
return {"entries": [], "offset": 0, "limit": limit, "has_more": False}
@@ -316,66 +338,80 @@ def create_ui_app(state: dict) -> FastAPI:
@app.post("/api/operator/pair")
async def operator_pair():
+ """A one-time code that pairs an application as this node's operator."""
return await _op(lambda: ops.pair_operator(state))
@app.get("/api/roster")
async def api_roster(group_id: str = ""):
+ """The pinned identities, for one group or all."""
return await _op(lambda: ops.read_roster(state, group_id))
@app.post("/api/groups/{group_id}/invites")
async def create_invite(group_id: str, username: str):
+ """An invitation code for one account, for this group."""
return await _op(lambda: ops.create_invite(state, group_id, username))
# Both halves, node and hub, for the CLI: an operator at the machine gets a
# whole link, not a code without a ticket.
@app.post("/api/groups/{group_id}/invite-links")
async def create_link_invite(group_id: str, email: str = ""):
+ """A whole invitation link: the node's code, then the hub's ticket."""
return await _op(lambda: ops.create_link_invitation(state, group_id, email))
@app.delete("/api/groups/{group_id}/invite-links/{invite_id}")
async def cancel_invite(group_id: str, invite_id: str):
+ """Take an invitation link back, on the node and on the hub."""
return await _op(lambda: ops.cancel_link_invitation(state, group_id, invite_id))
@app.get("/api/resolve")
async def resolve_user(username: str):
+ """Map a username to an account id, through the hub."""
return await _op(lambda: ops.resolve_user(state, username))
@app.post("/api/members/{user_id}/revoke")
async def revoke_member(user_id: str, group_id: str):
+ """Stop serving the group key to a member."""
return await _op(lambda: ops.revoke_member(state, user_id, group_id))
@app.post("/api/members/{user_id}/unpin")
async def unpin_member(user_id: str):
+ """Forget a pinned identity, so the person can pair again with a new key."""
return await _op(lambda: ops.unpin_member(state, user_id))
# ── Chat encryption (operator only, localhost) ─────────────────────────
@app.get("/api/groups/{group_id}/chat")
async def chat_status(group_id: str):
+ """What the operator needs to decide anything about the group's chat."""
return await _op(lambda: ops.chat_status(state, group_id))
@app.post("/api/groups/{group_id}/chat/epoch")
async def rotate_chat_epoch(group_id: str):
+ """Open a new chat epoch."""
return await _op(lambda: ops.open_chat_epoch(state, group_id))
@app.post("/api/groups/{group_id}/chat/encrypt-history")
async def encrypt_chat_history(group_id: str):
+ """Re-encrypt the messages written before the group's chat was encrypted."""
return await _op(lambda: ops.encrypt_chat_history(state, group_id))
@app.post("/api/groups/{group_id}/chat/prune")
async def prune_chat(group_id: str, max_age_days: int):
+ """Delete chat messages older than a number of days."""
return await _op(lambda: ops.prune_chat(state, group_id, max_age_days))
# ── GEK initialization (operator only, localhost) ──────────────────────
@app.post("/api/groups/{group_id}/gek")
async def init_gek(group_id: str, rotate: bool = False):
+ """Generate the group key, or rotate it with ?rotate=true."""
return await _op(lambda: ops.set_gek(state, group_id, rotate=rotate))
# ── Roots management (operator only, localhost) ────────────────────────
@app.post("/api/groups/{group_id}/roots")
async def add_root(group_id: str, payload: dict):
+ """Add a folder to a group."""
result = await _op(lambda: ops.add_root(
state, group_id,
(payload.get("path") or "").strip(),
@@ -392,6 +428,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.patch("/api/groups/{group_id}/roots/{root_name}")
async def update_root(group_id: str, root_name: str, payload: dict):
+ """Make a folder writable or removable, or not."""
result = await _op(lambda: ops.update_root(
state, group_id, root_name,
writable=payload.get("writable"),
@@ -404,14 +441,17 @@ def create_ui_app(state: dict) -> FastAPI:
@app.put("/api/groups/{group_id}/roots/{root_name}/eject")
async def eject_root(group_id: str, root_name: str):
+ """Eject a removable folder so its disk can be unplugged."""
return await _op(lambda: ops.eject_root(state, group_id, root_name))
@app.put("/api/groups/{group_id}/roots/{root_name}/plug")
async def plug_root(group_id: str, root_name: str):
+ """Bring an ejected folder back."""
return await _op(lambda: ops.plug_root(state, group_id, root_name))
@app.delete("/api/groups/{group_id}/roots/{root_name}")
async def remove_root(group_id: str, root_name: str):
+ """Remove a folder from a group. At least one must remain."""
result = await _op(lambda: ops.remove_root(state, group_id, root_name))
reload_fn = state.get("reload_fn")
if reload_fn:
@@ -427,6 +467,8 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/api/groups/{group_id}/index-status")
async def index_status(group_id: str):
"""
+ One group's indexing progress.
+
Polled by the Create Group wizard and by "add a directory" in
Settings — the same source either way, since both just start a scan
on this group's indexer. `current_dir` is a basename only, and is
@@ -454,7 +496,9 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/api/index-status")
async def index_status_all():
"""
- Every group's indexing at once, for the client's progress band — which
+ Every group's indexing progress.
+
+ All groups at once, for the client's progress band — which
is on screen whatever page the operator is on, so it cannot ask per
group. Names roots, like `current_dir` above: loopback only, the
operator's own screen. Reads state["indexers"] for the same reason.
@@ -488,6 +532,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.put("/api/groups/{group_id}/apps")
async def set_enabled_apps(group_id: str, payload: dict):
+ """Which applications members see for the group."""
apps = payload.get("apps")
if not isinstance(apps, list) or not apps:
raise HTTPException(400, "apps must be a non-empty list")
@@ -497,6 +542,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.post("/api/reload")
async def reload_config():
+ """Reload node.toml. Returns before the reload finishes."""
# start_reload, not reload_config: this must return before a
# brand-new group's synchronous initial scan finishes (minutes, not
# seconds, on a real library) — see ops.start_reload for why.
@@ -506,6 +552,7 @@ def create_ui_app(state: dict) -> FastAPI:
@app.post("/api/shutdown")
async def shutdown():
+ """Stop the daemon."""
# The graceful stop every front door tries first (the desktop app, the
# CLI, the installer): it reaches a node in any session -- a service
# node runs in session 0, where taskkill and CTRL_BREAK from the user's
@@ -521,20 +568,24 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/api/node-settings")
async def get_node_settings():
+ """The node's effective settings."""
return await _op(lambda: ops.get_node_settings(state))
@app.put("/api/node-settings")
async def update_node_settings(payload: dict):
+ """Change node settings, written to roster.db and node.toml."""
return await _op(lambda: ops.set_node_settings(state, payload))
# ── Transfers (operator only, localhost) ───────────────────────────────
@app.get("/api/transfers")
async def get_transfers():
+ """Live transfer leases and queue depth."""
return await _op(lambda: ops.list_transfers(state))
@app.put("/api/groups/{group_id}/transfer-limits")
async def set_transfer_limits(group_id: str, payload: dict):
+ """How many transfers one member may run at once in this group."""
# The only door to `ops.set_transfer_limits` (the CLI uses it). It once
# had only a signed MNP message, which nothing anywhere sent — so the
# per-member cap sat at its default of 2 with no way to change it.