diff options
Diffstat (limited to 'docs/MESHBAY_DESIGN.md')
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 120 |
1 files changed, 114 insertions, 6 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 = |