The HTTP API

Every app - the web page, the Android and iPhone shells, the native Apple TV app - talks to the same /api. It is built in one function, Routes() in internal/httpapi/httpapi.go, out of three nested muxes and a short chain of middleware that every request passes through.

Three muxes, one chain

The routes are registered on three http.ServeMuxes, each behind a stricter door than the last:

The public mux

Only what must work before anybody signs in: the app shell at / (one page for every state - it asks /api/session and draws the sign-up form, the sign-in form or the library), /static/, the service worker and manifest (served from the root, because a service worker's scope is its own directory), /healthz, /ca.crt for a device that wants to trust the local authority, the name service's reachability challenge, and GET /api/session, POST /api/signup, /api/login and /api/logout.

The guarded mux

Everything else under /api/ is mounted as s.auth.Require(s.withUserContext(guarded)). Require refuses a request without a valid session with 401. withUserContext then puts two things into the request's context: the account id (source.WithUserID) and what that account may reach (source.WithAccess).

Access is enforced by the registry, not remembered by each handler. Registry.All, Matching and ByID all take a context, so a handler physically cannot reach a source without passing the request's context - and with it the restriction. Changing those three signatures made the compiler find every call site; remembering to check in each handler would not have. A test walks every endpoint that can hand over bytes, metadata or a playable address and checks a restricted account is refused.

The owner mux

Routes only the owner may use live on a third mux, wrapped in s.auth.RequireOwner. One trap shaped how it is wired: an owner route must be mounted as well as registered. The owner mux only answers for paths also handed to the guarded mux with guarded.Handle("/api/...", RequireOwner(owner)). The read-along switch in Settings was once registered but not mounted, and answered 404 unnoticed until a test for another feature went through the same door. TestOwnerSettingsAnswer now asks every owner setting.

The middleware, outermost first

Logging

withLogging records method, path, status and milliseconds for each request. Media, art, book resources and static files log at debug - a film is hundreds of requests and would bury everything else. It wraps the response writer in a statusRecorder, and that recorder has an Unwrap method for a reason that cost months: http.ResponseController reaches the real connection only through wrappers that unwrap. Without it, every read deadline set behind the logger answered ErrNotSupported, each caller discarded the error, and both the body deadline and the upload window silently did nothing. Their unit tests built the handler without the logger and passed throughout; a test through the real route chain is what found it.

Security headers

secureHeaders sets X-Frame-Options: SAMEORIGIN (a PDF opens in an iframe of SoundStorm's own, so not DENY), nosniff and a same-origin referrer policy. HSTS is sent only on the real certificate's name and only while a valid real certificate is loaded - and for a week, not a year. A home server's certificate can genuinely lapse, and a browser pinned for a year to a name now served by the untrusted fallback would have no way through. A week heals itself, and every visit while the certificate works pushes it back out.

Same origin

The session cookie is SameSite=Lax, which is normally enough against cross-site writes. Here it is not: every install's real name is under soundstorm.dev, which is not yet on the Public Suffix List, so to a browser another install - including one an attacker set up - is the same site. So sameOrigin refuses any request other than GET, HEAD or OPTIONS whose Sec-Fetch-Site is anything but same-origin or none, falling back to comparing Origin with the host. A request with neither header did not come from a browser page - the installer, curl, the Apple TV app - and carries no cookie a page could have borrowed.

Body deadlines

bodyDeadline gives any request that has a body thirty seconds to send it. Every body here except a file is a few hundred bytes of JSON, so that is generous, and it stops a client holding a connection open by sending a byte at a time. File uploads - /api/upload, a phone's photo backup, a piece of a photo download - get a rolling window instead (stallReader): the deadline moves a minute forward only once 16KB has arrived since it last moved. A bad mobile link manages that easily; a trickle of one byte every fifty-nine seconds never does. An account may also have at most four uploads in flight.

Why there is no global ReadTimeout

The http.Server has a ReadHeaderTimeout of ten seconds, an IdleTimeout of two minutes and a 64KB header limit - and deliberately no ReadTimeout or WriteTimeout. A write timeout would cut off a film mid-playback. A read timeout is subtler: a read deadline that expires while a response is still being written cancels the request's context, and a stream stops with it. A film is a request with no body and a response that runs for two hours, so the deadline belongs on the body alone, which is exactly what bodyDeadline does.

Rate limits: allowances

Some writes are cheap to send and expensive to receive - each rewrites a person's whole collections file, or state.json under the lock every request takes, or calls a service outside the house. Those go through an allowance: a per-person token bucket with a burst and a refill rate, put in front of a handler with s.limited(&bucket, burst, every, handler). Past it the answer is 429, "too many changes at once; wait a moment".

AllowanceCoversShape
listWritesFavorites, playlists, custom covers, preferences60, then one a second
playsRecording a play (rewrites the collections file and appends to the listen log)20, then one every 10s; dropped quietly past it
positionsReading positions (each rewrites state.json)30, then one every 2s; the app keeps an unsynced copy and retries
otherWritesAudiobook positions sent to Audiobookshelf, the scrobbling token check, the read-along queueper route
nowPlaying, imports, plexPins, artUploads"Playing now" to ListenBrainz, playlist imports, Plex sign-ins, cover uploadsper route

Other ceilings are not token buckets: at most four Jellyfin video conversions per person (hlsSessions), four uploads in flight, two password hashes at once across the whole server (see accounts), and minimum gaps between library rescans.

Error conventions

Notable endpoints

AreaEndpoints
SessionGET /api/session (signed in? secure name to move to? remote-access state), POST /api/signup, /api/login (202 and a pending id when a new device needs approval), /api/login/pending/{id}, /api/logout, POST /api/account/password, PUT /api/account/pin
Devices and peopleGET /api/devices/pending and POST /api/devices/pending/{id} (allowing a new device), GET /api/profiles, POST /api/profiles/switch, POST /api/profiles/keep, DELETE /api/profiles/{id} ("Who's listening?"), POST /api/link, GET /api/link/{id}, /api/link/{id}/qr.png, GET|POST /api/link/code/{code} (signing a TV in from a phone)
Setup and libraryGET /api/setup (backends getting ready), GET /api/library (counts, free space), POST /api/library/rescan, GET /api/probe (a link-speed sample)
Search and browseGET /api/search (empty q browses a shelf), GET /api/home, GET /api/continue, GET /api/genres, GET /api/books/pairs|authors|series, GET /api/tv/show, /api/tv/next
PlayingGET /api/stream/{source}/{id}, GET /api/art/{source}/{id}, GET /api/playback/{source}/{id} (direct or HLS, tracks, chapters, position), PUT /api/playback/... (audiobook position), GET /api/hls/{source}/{path}, GET /api/subtitle/...
BooksGET /api/book/manifest, /api/book/resource (one file from inside an EPUB), GET|PUT /api/book/progress, GET|POST /api/readalong
Music/api/music/albums, /artists, /mixes, GET|POST /api/music/radio, /api/music/sound, /api/music/beats, /api/music/lyrics/..., POST /api/history, /api/scrobble, GET /api/recap
Collections/api/favorites, /api/playlists (and items, move, import), /api/plex/*, /api/myart, GET|PATCH /api/prefs
UploadsPOST /api/upload/plan (where would these go?), PUT /api/upload?path=&kind= (one file as the whole body)
Photos/api/photos/people, /places, /on-this-day, /usage, POST /api/photos/backup/check, PUT /api/photos/backup, /api/photos/import (resumable pieces)
Owner only/api/users (add, remove, reset password, libraries, photo limit), POST /api/users/new-passwords (everyone must choose a new password), PUT /api/remote, /api/settings/lyrics|discovery|readalong|new-devices, /api/books/pairs/not-same|by-hand, POST /api/delete/preview, /api/delete, /api/delete/undo

Item ids in paths are attacker-supplied and are treated as such: the hardened client in internal/httpx refuses absolute references, a second leading slash and dot segments for every adapter, after a path like /api/hls/jellyfin/%252e%252e/System/Info once reached Jellyfin's admin API through a double decode. HLS paths must match the shapes Jellyfin's own playlists use.

Body limits

Every JSON body is read through http.MaxBytesReader with a cap sized to what it can legitimately hold:

BodyCap
Sign-in, sign-up, password change, account creation4KB (maxCredentialBody) - an unauthenticated endpoint must not be a memory sink
Reading position8KB (maxProgressBody); a CFI is also capped at 2KB and a fraction must be in [0, 1]
Small settings (lyrics, discovery, radio taste, scrobbling token)1KB
Favorites, playlists, preferences, read-along requestsper-route constants of a few KB
Photo backup check, Plex import list1MB, 64KB
Custom cover4MB, and only JPEG, PNG or WebP by their bytes
File uploadsno fixed cap; refused when they would leave under 1GB free on the library's disk