From 9269f7032475ef73b7ce106c786a3157c1a8d7eb Mon Sep 17 00:00:00 2001 From: KodeStar Date: Sat, 10 Oct 2026 00:06:18 +0100 Subject: [PATCH 01/19] API reference: listening in the house, cast tokens, capabilities household and cast --- docs-developers/server/api/index.md | 21 ++- docs-developers/server/api/reference.md | 187 +++++++++++++++++++++++- 2 files changed, 200 insertions(+), 8 deletions(-) diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md index 24c513f..1f4fb91 100644 --- a/docs-developers/server/api/index.md +++ b/docs-developers/server/api/index.md @@ -102,6 +102,14 @@ three routes (`requireMediaAuth` in `internal/api/middleware.go`): a token in a string can leak into access logs and `Referer` headers, so no other route accepts it. Native clients should keep using the header even for media. +A book's cover and stream (not `/meta/cover`) also accept a **cast token** +(capability `cast`) as the `?token=`: a short-lived credential for one book that a +player mints with [`POST /cast/token`](reference.md#post-apiv1casttoken) and puts +in the URLs it hands a Chromecast receiver, so the receiver never sees the session +token. It is never accepted as a header, never beside one, and never on any other +route; the full rules are in +[Cast tokens as media auth](reference.md#cast-tokens-as-media-auth). + ### Client identification: `X-AudioSilo-Client` A client names itself on each request with an optional header: @@ -315,7 +323,9 @@ single-shot with a `limit` and no pagination. "user_stats": true, "my_devices": true, "annotations": true, - "addresses": true + "addresses": true, + "household": true, + "cast": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -367,6 +377,13 @@ each always `true` on a server that has it: | `annotations` | a bookmark's `label` (on [`POST …/bookmarks`](reference.md#get-apiv1librariesidbookmarks--post-apiv1librariesidbookmarks) and every bookmark answer), the owner's edits [`PATCH /bookmarks/{id}`](reference.md#patch-apiv1bookmarksid) and [`PATCH /notes/{id}`](reference.md#patch-apiv1notesid), the all-books lists [`/me/bookmarks`](reference.md#get-apiv1mebookmarks) and [`/me/notes`](reference.md#get-apiv1menotes), and `cursor` paging plus each row's `book` on [`/me/history`](reference.md#get-apiv1mehistory) | | `addresses` | the server's [home and away addresses](reference.md#home-and-away-addresses) on every pairing payload and its links, on the exchange, login and demo answers, and [`GET /addresses`](reference.md#get-apiv1addresses) | +The player redesign's Phase 8 added two, each always `true` on a server that has it: + +| Flag | Gates | +|---|---| +| `household` | [Listening in the house](reference.md#listening-in-the-house): the caller's opt-in at [`/me/household`](reference.md#get-apiv1mehousehold--put-apiv1mehousehold) and [`GET /household/listening`](reference.md#get-apiv1householdlistening). Without it the player hides the sharing switch and the Home row | +| `cast` | [`POST /cast/token`](reference.md#post-apiv1casttoken) and a cast token as the `?token=` of a book's cover and stream. Without it the player shows no Cast button | + Gate on the flag rather than on the server version: an older server answers `size=` and `narrator=` by ignoring them (full art, the unfiltered list) and the new routes with a `404`. Request bodies are decoded strictly, so a field added to an @@ -384,7 +401,7 @@ with an error envelope. | Limiter | Scope | Policy | |---|---|---| | General token bucket | every request except static files and media | ~50 requests/second, burst 200, per IP (`rate limit exceeded`) | -| Media token bucket | `GET /libraries/{id}/cover`, `GET /libraries/{id}/stream` | ~200 requests/second, burst 2000, per credential; a failed authentication counts against the general bucket ([details](../auth-and-security.md#rate-limiting)) | +| Media token bucket | `GET /libraries/{id}/cover`, `GET /libraries/{id}/stream`, `GET /libraries/{id}/meta/cover` | ~200 requests/second, burst 2000, per credential (a cast token has a bucket of its own); a failed authentication counts against the general bucket ([details](../auth-and-security.md#rate-limiting)) | | Login lockout | `POST /auth/login` | 10 *failed* attempts per 15 min per IP; a success resets the counter | | Redeem lockout | `POST /auth/redeem` | 10 *failed* attempts per 15 min per IP; a success resets | | Demo cap | `POST /demo/session` | at most 5 demo sessions per IP per 15 min, metered at admission (failures count too) | diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md index 3307ae2..7d30a32 100644 --- a/docs-developers/server/api/reference.md +++ b/docs-developers/server/api/reference.md @@ -8,7 +8,9 @@ The complete HTTP surface, derived from the route table in in the [API conventions](index.md) page and are not repeated per endpoint. **Auth legend** - *Public*: no token. *Session*: bearer session token. -*Session (media)*: session token via header **or** `?token=` query parameter. +*Session (media)*: session token via header **or** `?token=` query parameter +(a book's cover and stream also take a [cast token](#cast-tokens-as-media-auth) +as the `?token=`). *Admin*: session token + `admin` role. A personal **API key** authenticates as its owner anywhere a *Session* token does - so an admin's key also satisfies *Admin* - with one carve-out: it is refused on the credential-minting routes (see @@ -53,7 +55,9 @@ else and gate features on the flags. "user_stats": true, "my_devices": true, "annotations": true, - "addresses": true + "addresses": true, + "household": true, + "cast": true }, "auth": { "methods": ["auth_code", "password"] }, "demo": { "enabled": false } @@ -118,6 +122,14 @@ its links, on the exchange, login and demo answers, and at serves the community covers a book's `/meta` envelope hands out, as thumbnails at [`GET /libraries/{id}/meta/cover`](#get-apiv1librariesidmetacover). +The player redesign's Phase 8 adds two more, both always `true` on a server that +has them and absent on an older one: + +| Flag | Value | Gates | +|---|---|---| +| `household` | always `true` | [Listening in the house](#listening-in-the-house): `GET`/`PUT /me/household` and `GET /household/listening` | +| `cast` | always `true` | [Casting](#casting): `POST /cast/token`, and a cast token as the `?token=` of a book's [cover](#get-apiv1librariesidcover) and [stream](#get-apiv1librariesidstream) | + ### `GET /healthz` · `GET /api/v1/healthz` *Public.* Liveness/readiness probe: checks database read-reachability under a @@ -1581,7 +1593,9 @@ book (`matched`, `recording`, `series`, `web_url`) are **not** returned here. ## Streaming & media Both routes take *media auth* (header **or** `?token=`) and are exempt from the -30 s request timeout. See [Media](../media.md) for serving internals. +30 s request timeout. Both also take a [cast token](#cast-tokens-as-media-auth) as +the `?token=` (capability `cast`), for the book it was minted for. See +[Media](../media.md) for serving internals. ### `GET /api/v1/libraries/{id}/stream` @@ -1595,7 +1609,7 @@ path. | `download` | `1` | - | sets `Content-Disposition: attachment` so browsers save the file | | `transcode` | `1` | - | re-encode to MP3 via ffmpeg for codecs browsers can't decode | | `t` | float | `0` | with `transcode=1`: start the transcode this many seconds in | -| `token` | string | - | session token (media-auth fallback) | +| `token` | string | - | session token (media-auth fallback), or a [cast token](#cast-tokens-as-media-auth) for this file's book | Direct serving (default) supports HTTP **Range** (`206 Partial Content`) and sets the audio `Content-Type` from the file. Transcoded output is MP3 and **not @@ -1610,7 +1624,7 @@ kills it. | `401` | missing/invalid token | | `403` | path outside the caller's scope | | `404` | file does not exist | -| `503` | `transcode=1` but ffmpeg is not configured (check the `transcode` capability) | +| `503` | `transcode=1` but ffmpeg is not configured (check the `transcode` capability), or every transcode slot is busy (`server busy transcoding; try again shortly`, with `Retry-After: 5`) | ### `GET /api/v1/libraries/{id}/cover` @@ -1643,7 +1657,7 @@ three is tried, custom covers included. | `path` | string | yes | | | `size` | int | no | `160`, `320` or `640`: a JPEG thumbnail instead of the full art (`cover_sizes` capability; below) | | `v` | string | no | ignored by the server; clients append the book's `cover_version` as a cache buster | -| `token` | string | no | media-auth fallback | +| `token` | string | no | media-auth fallback: a session token, or a [cast token](#cast-tokens-as-media-auth) for this book | Response `200`: image bytes with the appropriate `Content-Type`; `404` (`no cover`) when there is no custom cover, no cover file and no embedded art. @@ -1689,6 +1703,40 @@ so a cover replaced since the last fetch is a new URL to its image cache. | `404` | `no cover`: no art at all, no book at the path, or (with `size`) art that cannot be decoded or is over 40 megapixels | | `500` | `could not load cover` - the art could not be read (an unreadable network mount) or a database failure; not cached, so the next request tries again | +### Cast tokens as media auth + +A Chromecast receiver plays a book from URLs the player hands it, so the player +never hands over its session token: it mints a **cast token** for the book with +[`POST /cast/token`](#post-apiv1casttoken) and builds the receiver's URLs with that +instead. The two routes above accept one as the `?token=` (the middleware +`requireBookMediaAuth`; `api.castAuthenticate`) only when **all** of these hold: + +- the request carries **no** `Authorization` header (a cast token is never a bearer + credential, nor accepted beside one) and the session or API-key lookup of the + `?token=` failed first; +- the token exists and has not expired (24 hours from its mint), and its user still + exists and is not disabled; +- the request's library is the token's, and its `?path=` (cleaned first, so `..` + can't climb out) is the token's book or a file or folder inside it on a segment + boundary: a token for `Author/Book` reaches `Author/Book/01.mp3`, never + `Author/Book 2/01.mp3`. + +Any refusal is the same `401` as a bad session token. The handler then checks the +person's **current** share scope as for every request, so a book whose share was +taken away since the mint is a `403`. A cast token is never accepted on any other +route, [`/meta/cover`](#get-apiv1librariesidmetacover) included. + +An answer authenticated by a cast token carries +`Cross-Origin-Resource-Policy: cross-origin` in place of the server's usual +`same-site`: the receiver is a web app served from another site (`gstatic.com`) +that loads the files as `