Invariants
These are the cross-cutting rules that shape the server and player. Each exists because violating it broke something real, or predictably would. Each rule gives what it is, why it holds, what breaks if you violate it, and where it's enforced.
When a proposed change conflicts with one of these rules, the rule wins. Changing
a rule is a workspace-level design decision, so update
~/dev/audiosilo/CROSS-REPO.md first, not just the code.
1. Path is the identity
The rule. All content is addressed by (library_id, rel_path). The path is
passed as a ?path= query param. It is a query param, not a URL path segment, to
avoid encoded-slash issues. books.id is an internal, rebuildable index artifact.
It must never appear in the API contract or in durable user state.
Durable user state is keyed on (user_id, library_id, rel_path), or on
(library_id, path) for per-library config, with no foreign key to the books
index. That state is progress, bookmarks, notes, listening history, folder
overrides, book enrichment, admin metadata edits and custom covers.
A cheap content fingerprint exists only to detect moves. It is the sha256 of the
size and the first and last 64 KB, stored in books.content_hash. It is not an
identity.
Why. Audiobook metadata is unreliable. Tags are wrong or missing, or they change when a user re-tags files. The one thing a self-hosted library reliably has is its directory structure. Making the path the identity means:
- the index can be dropped and rebuilt at any time without losing anyone's progress (no FK means nothing cascades from a rebuild);
- re-tagging a book keeps its listening state (the path didn't change);
- moving a book keeps its state too, via the fingerprint: when a path vanishes
and a new path with a matching fingerprint appears,
Scanner.detectMovesmoves durable state from the old path to the new one (catalog.MoveDurableState).
What breaks if violated. Put books.id on the wire or in durable state and
every rescan, library rebuild, or re-detection silently orphans user progress,
bookmarks, and shares. Key durable state to the index with an FK and a temporarily
unmounted network share (see the unavailable-root guard, ErrLibraryUnavailable)
could cascade-delete a user's entire listening history.
Where it's enforced.
- Server:
catalog.GetBookByPath. Content routes resolve(library, path)to a book, indexing it on demand throughScanner.IndexPath.- The migrations in
internal/store/migrations/. Durable tables have no FK tobooks. Scanner.detectMovesandcatalog.MoveDurableStatefor moves.
- Frontend: every content call in
src/api/client.tspasses?path=. Client state persists keyed by(library_id, path). The helpers are insrc/lib/paths.ts.
2. The filesystem is the truth; the database is a rebuildable index
The rule. Content lives on disk. The SQLite database is an index and cache that can be deleted and rebuilt from the filesystem at any time. Never put content, or anything the user would miss, only in the DB. Durable user state is the one exception. It lives in the DB, but it is path-keyed and has no link to the index (rule 1), so it survives a rebuild.
Why. Portability and trust. A self-hosted user's library must outlive the
server install. The user can move the folder to a new machine, point a fresh server
at it, and everything works. It also makes the scanner safe to be aggressive. It
can re-derive everything (books, chapters, codecs, covers), because nothing
canonical lives in its output. And it enables the no-wait first connection. The
/fs view (internal/library/fsview.go) browses the real filesystem directly, so a
brand-new server is usable before the first scan finishes.
What breaks if violated. Content stored only in the DB is lost on rebuild, and pointing a fresh server at the folder no longer restores everything. It also creates two sources of truth that drift apart.
Where it's enforced.
internal/library/scanner.go. The scanner builds and rebuilds the index. It detects books and folders automatically (booksInDir), with durable, path-keyedfolder_overridesfor the exceptions.- The unavailable-root guard. The scanner aborts with
ErrLibraryUnavailableand does not prune when a library root is missing, unreadable or suspiciously empty. This protects the index, and everything users associate with it, when an SMB or NFS mount drops.
3. Stream the file, never the book
The rule. A track URL handed to any playback engine must be a real audio
file path: a chapter's file_path or a BookFile.rel_path. It is never a folder
or book path. So playback starts only after the chapters and files have loaded,
because until then the client may only know the book's folder path.
Why. GET /libraries/{id}/stream?path= serves one file with Range support
(internal/media/media.go). A folder path isn't a streamable resource. The
failure is not a clean 4xx in the UI. On iOS, a folder path given to AVPlayer
produced the opaque MediaToolbox error -12864 on multi-file books, and the
chapter info was lost.
The sibling bug class is Content-Type. iOS AVPlayer rejects audio served as
application/octet-stream under nosniff with -12847. That is why
media.ServeFile byte-sniffs magic bytes instead of trusting extensions:
ftyp gives audio/mp4, ID3 or MPEG sync gives audio/mpeg, ADTS gives
audio/aac, and FLAC, Ogg and WAV get their own types.
What breaks if violated. Multi-file books fail with -12864-class errors.
They reproduce only on a device, and the error does not mention the path. Chapter
overlays disappear. Single-file books may appear to work, which hides the bug.
Where it's enforced.
- Frontend:
src/playback/book-queue.tsbuilds tracks fromfiles. Without them it derives distinct files from the chapters'file_path, and without those it uses the single-file book path. It never uses a folder. The player gates the start of playback onuseChapterssettling. - Server:
metadata.Chaptercarriesfile_pathprecisely so clients always have a streamable path per chapter;media.ServeFiledoes the byte-sniffing.
4. The wire format is hand-mirrored, so a wire change is a multi-repo change
The rule. There is no codegen. The Go handlers
(internal/api/handlers_*.go) define the JSON. The frontend re-declares the same
shapes in src/api/types.ts and unwraps them in src/api/client.ts. Any change to
the wire format (a field rename, a new envelope, changed semantics) is made in
all affected repos in one logical change, with tests on each side.
Why. Codegen was deliberately rejected. The surface is small, and hand-written
types stay idiomatic on each side (Go structs on one, TypeScript unions on the
other). The cost is that nothing mechanical catches drift. A renamed Go JSON tag
compiles fine in both repos and fails only at runtime, as an undefined field. So
the discipline is procedural: paired changes, paired tests, and the seam catalog in
the workspace CROSS-REPO.md.
What breaks if violated. The app breaks at runtime, and no build or test
reports it. TypeScript trusts types.ts, types.ts no longer matches the wire,
and the symptom shows up far from the cause: an empty screen, a NaN duration, a
book that "has no chapters".
Where it's enforced.
- By convention and review: the
wire-change checklist
and the workspace
CODE-HEALTH.mdDefinition of Done. - By tests:
internal/api/*_test.goon the server andsrc/api/client.test.tson the frontend. A shape change without a matching test update fails the corresponding CI.
5. Secure by default
The rule. The server must be safe for an inexperienced user to expose to the internet. Concretely: no default credentials; secrets are stored only hashed; codes and setup tokens ride URL fragments, never query strings or logs; all user-derived filesystem access goes through one safe join; and the served HTML runs under a strict, scoped CSP.
Why. It's design priority #1, above performance and convenience. The target user will port-forward the server and forget about it, and every default has to assume that.
What breaks if violated. The threat model of the whole product. A default password, a loggable invite code or one un-sandboxed path join lets a remote attacker read files on the user's NAS.
Where it's enforced.
- No default creds: first run prints admin credentials and an auth code
once. The banner is the only place plaintext secrets ever appear. With
--setup, first run instead enables a one-time-token setup page at/setup(internal/api/handlers_setup.go), which closes itself the moment an admin exists. - Hashed secrets: tokens and auth codes are stored only as SHA-256 hashes;
passwords use argon2id (
internal/auth/hash.go).auth.ResolveRequestresolves opaque bearer tokens against the hashes. - Fragment-carried codes: invite links are
<base>/connect#code=…, and the setup page is/setup#token=…. Fragments never reach the server or its access logs. The setup POST verifies its token in constant time. - Path safety:
library.SafeJoinrejects traversal outside the library root; every filesystem access derived from user input goes through it. Share scoping (Scope.Allows,Scope.VisibleInBrowse,pathFilterSQL) authorizes every content path against the caller's grants (authorizedPathin the handlers). A household member's grants derive from their owner's by the member rule. - Scoped CSP: the admin console and the connect page keep a strict
same-origin CSP with no inline script or style at all. The web player at
/webgets a per-document CSP whosescript-srccarries a sha256 hash of that document's inline scripts (web.htmlCSPininternal/web/web.go). - Confined token-in-URL: the server accepts the
?token=media-auth fallback only on these routes. Every other route is header-only, so session tokens can't leak into logs or Referer headers elsewhere.- The cover, stream and community cover GETs: a book's
/meta/coverand an upcoming release's/me/upcoming/cover(bearerToken(r, true)throughrequireBookMediaAuthorrequireMediaAuthininternal/api/middleware.go). - The calendar feed,
GET /me/upcoming/releases.ics, which takes afeedtoken and no other kind (requireFeedAuth).
- The cover, stream and community cover GETs: a book's
- Tested both ways: security-critical code (
SafeJoin, scope checks, rate limiters,ResolveRequestandlookupToken,htmlCSP) requires both an allowed and a denied regression test. See Gates and CI.
Quick self-check before you ship
- Did anything new address content by a DB id? (Rule 1. If so, stop.)
- Would this survive deleting the database? (Rule 2.)
- Can any code path hand a folder path to a playback engine? (Rule 3.)
- Did the JSON change? Then did
types.tsandclient.tschange too, with tests on each side? (Rule 4.) - Did a secret touch a log, a query string, or plaintext storage? Does new
user-derived path handling go through
SafeJoin? (Rule 5.)