aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md120
-rw-r--r--docs/MESHBAY_HTTP_API.md233
-rw-r--r--docs/QUICKSTART.md2
-rw-r--r--docs/USERGUIDE.md55
-rw-r--r--docs/generate_http_api.py164
5 files changed, 556 insertions, 18 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 525c5c3..fdf08f9 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) |
@@ -392,7 +393,11 @@ Four properties, each load-bearing:
attempt is an audit event.
3. **The node's roster is the authority**, not hub membership. A hub that invents
an account, adds it to a group and mints it a token gets
- `not_authorized_for_group`.
+ `not_authorized_for_group`. The Members list says the same: it shows the
+ accounts the node has admitted (the sealed group roster, §11.7 of the protocol).
+ One the hub counts as a member but that has not presented its code yet is
+ shown to the owner alone, as waiting for its code; when the roster cannot be
+ read, the hub's list is shown.
4. **Wrapping happens on every connection.** Nothing is stored per member, so key
rotation propagates by itself and revocation actually takes effect. (Rotating
the key after a revocation is still required — the ex-member holds the current
@@ -1564,7 +1569,8 @@ the whole tree or presents an empty directory to the next scan. Both propagate a
though the owner erased their library. So a root has two independent runtime
states:
-- **`ejected`** — operator-controlled, persisted in `roster.db`.
+- **`ejected`** — set by the operator, or by the safety net below; persisted in
+ `roster.db`, with which of the two set it.
- **`available`** — computed as `not ejected and is_live()`. This is what clients
and the indexer see.
@@ -1585,12 +1591,21 @@ let the following scan read the empty mount point as an erased library. It lives
hand-written config must not be rewritten because a USB drive was unplugged.
**Auto-eject is the safety net.** If a `removable` root's path disappears, the
-availability sweep sets `ejected` as though the operator had clicked it, and
-reports it so the daemon persists it. Nothing is deleted: index entries, cached
+availability sweep sets `ejected` and reports it so the daemon persists it, marked
+as the safety net's. Nothing is deleted: index entries, cached
metadata, thumbnails, chat history referencing those files and app directory
configurations all survive, the last flagged as temporarily invalid rather than
wrong.
+**The safety net's eject undoes itself; the operator's never does.** At startup
+and at every reconcile, an auto-ejected root whose path is readable again is
+checked against what the hash cache knows was under it: a few of those files,
+at the same path with the same size and mtime. One found, and the root is
+plugged back and rescanned, like a plug. None found, and it stays ejected: an
+empty mount point or another drive mounted in its place is exactly what the eject
+protects the index from. The case this serves is ordinary: a node started with
+the session, before the desktop has mounted its USB drives.
+
### 6.3 Indexing
The index is **content-addressed**: `GroupIndex` is keyed by blake3, so the same
@@ -1888,7 +1903,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 +1966,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).
@@ -2070,10 +2088,17 @@ A group's **identity is its UUID**, everywhere: the route, the node's configurat
membership. A group **name is unique per owner account**, case-insensitively and
trimmed, enforced by a functional unique index; two different owners may each have
a `photos`. Names are displayed as `name@owner`, which is a label plus a create-time
-check and **not an addressing scheme**. The handle is hub-local: the same
+check and **not an identity**. The handle is hub-local: the same
`name@owner` on two federated hubs are different groups, and a federated row shows
its source hub rather than an account.
+The client also accepts the handle in the address, as an alias for the UUID
+(§8.4): `#/name@owner`, optionally followed by a path inside the group. It is
+resolved **in the client, against the account's own `/v1/groups/mine`**, and no
+hub route answers "which group is called this" — so a handle tells nobody
+anything they could not already see, and cannot be used to probe for a group.
+A rename breaks the handle links to a group and none of its `#/group/<id>` ones.
+
`visibility` and `join_policy` are the two independent axes described in §3.5.
`join_policy` is read from the node's own configuration, never from the hub.
@@ -2527,6 +2552,26 @@ that decides where the hub is or fetches the API relative to the page origin.
That is a testable invariant, and it is what any feature adding third-party egress
must preserve — which is one of the reasons enrichment is node-side (§6.5).
+**Inside the application, every route is a fragment** (`#/…`). What follows `#`
+is never sent to a server, so it is in no hub or proxy log and no `Referer`;
+that is what lets an invitation carry its code (§3.4), and it is why the same
+router runs unchanged on `app://meshbay` and in the Android WebView, where no
+server could answer a path. Two forms name a group:
+
+| Route | Meaning |
+|---|---|
+| `#/group/<uuid>` | the group — every link the application draws |
+| `#/name@owner` | the same group by its handle (§7.3); the address shows this form while a group is open, written with `replace` so it is not a history entry |
+| `#/name@owner/<root>/<dir>/<file>` | a file: Files opens on its folder and the file is downloaded. A folder instead of a file opens Files there. The path is taken out of the address once acted on, so a reload does not download twice |
+
+The owner is after the **last** `@` (a username cannot contain one); each path
+segment is percent-decoded on its own. Opened signed out, the sign-in form
+stands in for the page and the address is left alone, so signing in lands on
+it. A download started this way has no user gesture behind it, so where a browser
+offers a Save As dialog it takes the fallback a dialog refused for want of a
+gesture already takes (`file-utils.js` `_openDownloadTarget`): streamed to the
+download folder. `static/group-link.js`.
+
### 8.5 Downloads and streaming
**Downloads go to disk, never through RAM, on every platform.** There are three
@@ -2772,6 +2817,7 @@ destructures what it needs — a new application does not get a bespoke prop lis
| `transportRef`, `gekRef` | **refs**, never state, so a reconnect does not re-render every application |
| `deviceReady` | **the exception, and why it is a prop.** A ref not re-rendering is right for a transport reached into on demand and wrong for a *fact about the connection* an application renders from |
| `mayUpload` | computed once; a second derivation would eventually disagree with the first |
+| `linkFor(entry \| folderPath)` | the `#/name@owner/path` link "Copy link" puts on the clipboard (§8.4), or null where none can be named. The group page builds it from the hub's row; Search from each result's own group and unprefixed path. An application offers the action only when this returns a link, and copies with `copy-link.js` `copyLink` |
An application that needs local state owns it. One pattern is worth carrying: **any
notion of "current location within the group" resets on group change**, because a
@@ -2880,6 +2926,16 @@ There is no folder-browsing protocol and this does not add one.
application with no toolbar renders none — an empty band still holds a strip of
the page open.
+9. **Licence.** An application may be under any licence, provided it reaches the
+ interface only through the *application interface*: the props of §9.2, the
+ registry fields above, the exports of `i18n.js`, `icon.js`, `file-utils.js`,
+ `settings-ui.js` and `folder-tree.js`, `style.css`'s classes and the
+ catalogues' keys (`static/licenses/APPLICATION-EXCEPTION.txt`, an AGPL §7
+ permission). Importing any other module of the interface makes the
+ application a work based on it, under the AGPL. Its registry line and its
+ catalogue entries are changes to the interface and stay AGPL. Starting from
+ `helloworld-app.js`, which is 0BSD, brings no AGPL code along.
+
No protocol change, no hub change, no daemon change. Steps 4 and 7 are the only
node-side and test-side touches, and both are allow-lists.
@@ -3422,6 +3478,58 @@ lands the target is shown, not the old stream's position. Pausing the receiver
pauses the local element too, which otherwise goes on fetching for nobody. The
copy-URL cast has no receiver to read and keeps the ordinary player.
+**A television is chosen for the session, not for one film**
+(`cast-session.js`). Any cast button — Videos', Photos' and Music's toolbars, a
+photo album's bar, the lightbox, the video player, the music bar — sets it, and
+it is held in memory only. A film opened
+while one is set starts its cast as a restart does: pending until the first
+segment, then the relay and `connect` (or `reload`, when the receiver is already
+connected), with the player drawn as the remote from the start. *Stop casting*
+clears it, from the player's remote or from any button.
+
+**A photo or a music track is served whole at `/file`, one at a time**
+(`cast:file:begin`, `:write`, `:end`), its cover at `/cover`. It crosses in
+pieces — binary frames of a megabyte on Android — because a lossless track is
+too large for one bridge message, and on Android it is written to the relay's
+spool directory rather than held. The relay starts for it if nothing else has,
+serves it behind the stream's token with a versioned address (a receiver handed
+the same URL twice shows what it already has), honours byte ranges (a receiver
+seeks in a track that way), and accepts only pictures and the audio types the
+music player plays. **The shell, not the page, says what the receiver loads:**
+the relay's file is loaded as what the relay was told it is — a photo as a
+picture (`streamType: NONE`), a track as music (`BUFFERED`, with title, artist,
+album and the relay's cover URL), at `startAt` seconds for a track picked up
+mid-song — any other relay URL as the stream, and on Android no URL but the
+relay's own is accepted at all. A file does not start the foreground service; a
+photo's load answers as soon as the receiver accepts it, since a photo never
+reaches PLAYING.
+
+**A photo is scaled before it leaves.** The page fits it to 1920×1080, applies
+its EXIF orientation and re-encodes it as JPEG: a camera original is too large
+for a receiver to decode in good time, and some formats it does not decode at
+all. Only the newest photo asked for is sent; paging quickly shows where the
+reader stopped.
+
+**With a television chosen, the music bar is its remote.** Each track, once
+decrypted, goes to the relay with its cover instead of to the `<audio>`
+element, which keeps the source for its duration only. Play, pause, seek
+(`cast:chromecast:seek`) and previous drive the receiver; the bar's clock is the
+receiver's, polled once a second; and the receiver's IDLE/FINISHED moves the
+queue on as `ended` does, once per track, because the receiver keeps reporting
+it until it is given something else. The session records which view the
+television is showing (`castSession.owner`): a film or a photo opened meanwhile
+takes it, the bar pauses and stops reading the receiver, and play gives it the
+track back from where it was. A television chosen mid-track picks the track up
+where it was; *Stop casting* carries it on locally from where the television
+was.
+
+**The interface itself is not cast.** The default media receiver plays what it
+is given and renders nothing of its own, and a rendered interface sent to it as
+live video does not work: it starts a progressive stream that arrives at real
+time only when the stream carries audio and its first seconds arrive faster than
+real time, and then holds that lead as latency — five seconds, measured. Showing
+the application on a television needs a registered receiver of our own.
+
**Subtitles are rebased onto the relay's clock before they are sent.** The node
extracts a track whole, so its cues carry the film's timeline, and the player
can use them unchanged because its SourceBuffer is given `timestampOffset =
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/QUICKSTART.md b/docs/QUICKSTART.md
index ce1eec1..ea75cab 100644
--- a/docs/QUICKSTART.md
+++ b/docs/QUICKSTART.md
@@ -359,7 +359,7 @@ A healthy node reads roughly like this:
```
hub https://meshbay.org (user yourname)
node key 7mK2p...=
-daemon running — ok
+daemon running
node_id 82.65.x.x:0
groups 1 files 4213 peers 1
config /home/you/.config/meshbay/node.toml
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index d6eab26..ea81c76 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -297,6 +297,19 @@ file browser — sort, select, download, preview.
- **Right-click a file or folder** for the same actions as the toolbar, listing
only the ones that apply to it. On a ticked row the menu acts on everything
ticked, like the toolbar does.
+- **A link to a group, a folder or a file.** While a group is open the address
+ bar shows `https://<hub>/#/name@owner` — the name under the group's title.
+ Add a path after it to point inside the group:
+ `#/name@owner/root/folder/photo.jpg` downloads that file, and a folder opens
+ Files there. Only members get anywhere with such a link — anyone else is told
+ the group is unknown — and someone not signed in is asked to sign in first,
+ then taken where the link pointed. Renaming the group breaks these links.
+- **Copy link** gives you that address for one file or folder: right-click it,
+ or tick it and use the link button in the toolbar (the way on a phone). Music
+ has it in a track's menu (**⋯** on a phone), Photos when you right-click a
+ photo or in the photo viewer's bar, and the video player and file preview
+ have a link button next to Download. Search offers the same, pointing at the
+ group each result comes from.
### Chat
@@ -352,7 +365,9 @@ An album browser and a player, over the folders the operator chose for it.
### Photos
An album browser over the folders the operator chose for it, where **an album
-is a folder**. Thumbnails come from the node, already rotated correctly.
+is a folder**. Thumbnails come from the node, already rotated correctly. A
+photo opens full size, with a slideshow button that moves on every five
+seconds and stops at the album's last photo.
**Location data is never shown.** Photos shows when a picture was taken and
what took it, and no coordinates anywhere. Worth knowing, though: the
@@ -392,15 +407,32 @@ resume.
### Casting to a TV
-**The desktop and Android applications.** A film playing in the application can
-be sent to a cast-capable TV or dongle on the same network: *Cast to device* in
-the player, pick one from the list. On a phone, the film goes on playing silently
-on the phone while the TV shows it, and the screen can be turned off. While a
-TV plays the film, the player becomes its remote: the position shown is the
-TV's, with play/pause, 30-second jumps and a slider to go anywhere in the film.
-Devices appear as they answer, usually within a couple
-of seconds; the search carries on for a few more, for a TV that is still
-waking up.
+**The desktop and Android applications.** Films, photos and music can be sent
+to a cast-capable TV or dongle on the same network. The cast button is in the
+Videos and Music toolbars, at the top of Photos, in an album's bar, in the photo
+viewer and in the music bar — in a group and in Search alike — and *Cast to
+device* is in the video player. Devices appear as
+they answer, usually within a couple of seconds; the search carries on for a
+few more, for a TV that is still waking up.
+
+**A TV, once chosen, stays chosen** until *Stop casting*, from any cast button
+or from the player. Every film opened plays on it, and the player opens as its
+remote: the position shown is the TV's, with play/pause, 30-second jumps and a
+slider to go anywhere in the film. Every photo opened in the viewer is shown on
+it, and a slideshow pages the TV along with the screen. Choosing a TV from an
+album's bar opens its first photo. Music plays on it too, with the cover, title
+and artist on the screen: the music bar becomes its remote — play, pause, the
+slider, previous and next — and the queue moves on when the TV reaches the end
+of a track. Choosing the TV in the middle of a song carries it on there; *Stop
+casting* carries it on here. On a phone, a film goes on playing silently on the
+phone while the TV shows it, and the screen can be turned off, as it can while
+music plays on the TV.
+
+Photos are sent scaled to the TV's screen, upright, as an ordinary picture.
+The menus, covers and summaries stay on your phone or computer: the TV shows
+what is playing, not the application. It plays it in the TV's standard cast
+player, which shows its own name, *Default Media Receiver*, on the screen; a
+player of MeshBay's own is not built.
The application decrypts the film and relays it to the TV itself, over your LAN.
The TV is not a group member and holds no key — which is also why the relay's
@@ -981,7 +1013,8 @@ Better to know now than to go looking for it:
- **Hubs do not talk to each other yet.** Everyone in a group needs an account
on the same hub.
- **Casting reaches Chromecast devices** from the desktop and Android
- applications. Support for other TV protocols is designed but not built.
+ applications, for films, photos and music. Support for other TV protocols is
+ designed but not built.
- **Some subtitle tracks cannot be shown** — the ones stored as images rather
than text, roughly one embedded track in five. Displaying them would need
text recognition.
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)