Rewrite the listener onboarding, the role row, and the fail-closed law: the
claim grants every signer, revocation happens on the ABS side (disable or
delete the user), and what actually guards the library is who can reach the
audiobookshelf application in authentik. Name both e2e gates in Verification.
AGENTS.md states the three exposure postures and why the guard chain cannot front a
non-browser client. Infrastructure/Caddy.md gains *Unguard a vhost* — route first,
IdP rows last, audit lists after — since deleting the proxy provider while traffic
still enters :8016 hands browsers authentik's error page. The Audiobookshelf page
retires its two-doors law (the guarded vhost as a browser-only door, remote
listening via VPN) for one address every client carries, adds how a listener gets an
account, and says plainly what the exemption costs.
`abs.` was a three-letter abbreviation of the container; the library it serves is
/mnt/entropy/Audiobooks, and `audiobooks.` says what the door is. Renamed in one
commit on purpose: the pihole seam fails the suite if a Caddyfile vhost has no
addn-hosts record, so any split would leave a red commit, and the `abs` name was
never deployed at the IdP with this fix, so nothing has to keep answering it —
no alias, no redirect, no stale URI.
Moves together: the Caddyfile site block, its matcher (@abs -> @audiobooks), the
LAN record, ABS_EXTRA_BASES in authentik-audiobookshelf-sso.sh (that script
REPLACES the provider's redirect-URI list, so the old origin's callback leaves the
RP when it is next run), the homepage admin tile, the guard spec's GUARDED list and
leak marker, and the three vault pages that name the door. authentik's provider row
follows in the deploy window (external_host + name), which re-derives its
redirect_uris through scripts/authentik-proxy-provider-defaults.sh; the Application
row keeps its own name, "Audiobookshelf Guard" — it names the app, not the vhost.
ABS checks every OIDC callback against the origin it believes it is served from:
getRequestOrigin() is x-forwarded-proto://Host (dist-server/server/utils/requestUtils.js,
read in the pinned image digest 2026-11-03). The post-auth http://:8017 listener is fed
over plain http, so Caddy's own X-Forwarded-Proto is http, ABS derives
http://abs.franksdank.cloud, and answers its own https callback with
400 {"message":"Invalid callback URL - must be same-origin"}
Measured at the app with the callback held fixed: Host abs.franksdank.cloud alone
reproduces the 400 (as does every other Host, including the bare LAN IP), and adding
`X-Forwarded-Proto: https` turns it into a 302 to /application/o/authorize/ carrying
the registered redirect_uri. The edge for a guarded name is TLS-only, so https is the
truthful value, not a spoof; the LAN door needs nothing because there browser and
server already agree on http, which is why only the guarded path was broken.
Infrastructure/Caddy.md now owns the order of operations for exposing a vhost
(proxy provider + application + embedded-outpost claim, then the route, then the
name and the chain audit), because the IdP row is invisible state that nobody
recreates. Audiobookshelf's Internal-only row and law become Two doors, one app;
the homepage link and index.md follow.
authOpenIDMatchExistingBy is unset on this deployment, so ABS matches an
OpenID userinfo on sub only and auto-registers everyone else. A new authentik
username that equals an existing ABS username therefore dies on the unique
constraint inside the callback, reported only as 'Unauthorized' - which is
exactly what a future agent would hit by 'just creating a frank user' in the
IdP to reach the root account. Records the two real doors (match by username,
which links and keeps root, since setUserGroup refuses to downgrade a root;
or a non-colliding name), and corrects the admin-membership row: akadmin is
the operator's own authentik account, already granted site admin.
Records that an ORM-created provider has no grant types and that the claim
mapping is not the whole property_mappings list, with the failure signatures
to recognise; names qa-e2e-admin as the admin-group fixture and the new
Playwright spec as the live verification.
Measured on both shapes: ping returns success:true and healthcheck returns OK at the root whether or not the app is mounted under a prefix, which is why the phone app's connectivity check works before it ever resolves the base path. The paragraph now says which routes were probed instead of asserting 200s in general, and the frontmatter description fits the OKF 120-char budget the linter enforces.
The page owned the URL as a plain host:port; every client now carries the /audiobookshelf prefix. Added the two laws a next operator needs (why the base path is not a choice, why the redirect subfolder must be sent) and the Single sign-on section naming which script owns which half.
The page told an agent how to sign in from a browser and nothing about the phone.
Measured 2026-11-03 on the deployed service:
- Server Address `http://192.168.0.235:8000`, user `frank`, plain HTTP, no base-path
prefix; `/healthcheck` and `/ping` answer 200 from a second host on the LAN and
`iptables -S INPUT` has no rule for 8000, so no phone-side setup is needed.
`download: true` on the root account is what enables offline listening.
- `POST /login` puts the bearer at `user.token`, not top level. Reading
`response.token` yields "", the client sends `Authorization: Bearer ` with nothing
after it, and every `/api/*` answers 401 as though the password were wrong — which
it was not. That is the failure I hit inside five minutes of scripting this.
- The LAN-only law gains its consequence for clients: the Anubis + Authentik chain
serves browsers, so guarding a vhost does not make the phone app work remotely.
Remote listening wants a VPN into the LAN; the LAN address is then still correct.
bats: 822 seams green.
The array root carried 48 planning artifacts (journals, plans, run logs)
accumulated by the relayout campaigns since 2026-11-02. They sit beside the
libraries now, which is how a media server ends up indexing a journal.
Moved all 48 (219.8 MiB) into the dot-directory `/mnt/entropy/.media-campaign`
with a journaled `media-apply` run (plan, apply, verify — 48 verified at their
targets, 0 root files left). Jellyfin and Plex ignore dot-directories, and the
overlay view exposes the array root to both servers, so the same pollution
existed on the view side.
Every vault page that named a root-level journal path is rewritten to the new
home (26 occurrences across 6 pages), and the law is stated once, in
[[Media Relayout]], which owns the tool chain: `media-apply.py` defaults its
journal to `media-relayout.jsonl` beside `--root`, so a run that omits
`--journal` recreates exactly the root-level log this now forbids.
Four laws from finishing the un-shadow, correcting one:
- A book whose files ALL fail to probe never becomes an item, so a disk-vs-DB
folder gap beside `Invalid data` lines is corrupt media, not a lost book. A
format-only ffprobe clears files the scanner rejects — do not trust it.
- `Disc 01`/`CD 1` subfolders are parts of one item (110 of them here, under a
192-track book); the extension census counts them, ABS does not.
- `.cbr`/`.cbz` group like media but `parseComicMetadata` throws `ERAR_BAD_ARCHIVE`,
so those items land media-less: the file, not the scanner.
- Scan completion is a NEW scan uuid; `podman logs --since` is a sliding window
and ages its own evidence out.
- `?force=1` is now recorded as neither discovery nor repair: a forced full
re-scan left item count, path set and media-less count identical.
Verification names the five book folders that need re-acquiring.
Corrects the law pushed earlier today. The mechanism is
`groupFileItemsIntoLibraryItemDirs` (utils/scandir.js), called on a live
`recurseFiles` walk of the tree EVERY scan: each file attaches to the FIRST
directory along its path that already has a group, so one media file at a
collection root owns everything beneath it. Measured: one 949 MB `.cbr` hid
652 book folders behind a single 5,693-track item.
- "Media file" is audio OR ebook (`audiobooksOnly=false` -> SupportedAudioTypes
∪ SupportedEbookTypes, 17 audio + 6 ebook extensions). An audio-only census
cannot see the file that hides an Audiobooks library.
- Deleting the covering item does not fix a shadow: grouping is recomputed from
disk and the next scan rebuilds the item (measured: `1 Added`, same path).
- Un-shadow by MOVE: one file per folder is ABS's own single-book layout.
- `?force=1` is not a discovery arm (both arms walk the disk; force re-scans
every existing item, 1:33 against 0:04).
- Paging corrected: `offset` is ignored outright, `page` is the parameter.
- `DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()` of the whole
swallowed subtree. Never pass `hard`.
Shape table gains the layout row and the move journal; the stale "1,103
shelves" scan count is destroyed.
Measured on the live library: 6 stray files at the root of _unsorted made the
whole collection dump ONE item of 5,693 tracks, with 542 real books folded into
it; no scan subdivides an existing item. Also pinned: the items endpoint hands
back ceil(total/limit)*limit rows because an out-of-range offset repeats the
tail (that is where "exactly 1,500" and "exactly 4,000" came from), and
DELETE /api/items/:id?hard=1 runs fs.remove() on the item's folder - pointed at
a covering item that is the whole subtree, so un-swallowing is a DB-only delete
after the stray root files are filed.
Two censuses this session reported exactly 1,500 and exactly 4,000 books for one
library mid-scan. Cause: an out-of-range offset returns the last item instead of an
empty page, so a loop written "until the page is empty" manufactures its own
total, and total itself moves while a scan is running. Stop on total; count after
the pass settles.
The first full scan logged "Invalid data found when processing input" per file and
carried on. That line is about the file: the scanner skips it, so the book never
appears in the library at all. Census of the 43 refused files: 38 were 0 bytes, 3
held WAV bytes behind a .mp3 name, 2 were unreadable. The check to run first is a
byte count, not a server investigation.
Full suite: 818 tests, exit 0.
The page claimed the account can only be created from a LAN address; measured,
the door is POST /init and it answers plain text. Replaced with the bootstrap the
deployer now runs, and with the doors a consumer actually needs: /status reports
isInit with no /api prefix, the item count rides /api/libraries/{id}/items ->
total (the library DTO has no totals), and the list DTO carries media.numTracks
while /api/items/{id} returns media.audioFiles.
New law: the folder watcher cannot cross the rootless podman bind — the same gap
as Jellyfin's auto-scan note — so files added on the host stay invisible until a
scan runs, and the deployer's re-run of the bootstrapper is the cheap trigger.
Testing Policy lists the new seam; index.md regenerated from frontmatter.
The first deploy of the unit failed its own health gate in the worst possible shape:
systemd said active (running), the engine logged a clean database init, and the
server then listened on :80 under base path /audiobookshelf - so every probe of
:8000 was refused and wait_for_health timed out at 180s beside a container that
looked healthy.
Cause: ABS_PORT / ABS_CONFIG_DIR / ABS_METADATA_DIR / ABS_PUID are the npm
package's names, printed by the server README; this image never reads them. Its own
contract, read off the pinned digest with podman inspect, is PORT=80
CONFIG_PATH=/config METADATA_PATH=/metadata, and the server greps for exactly PORT,
CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH. The two path variables appeared to
work only because the image already exports the same values - the coincidence that
made the inert lines look correct.
The unit now speaks the image's names, adds ROUTER_BASE_PATH=/ so the service is
the plain host:port the docs and the homepage card promise, and the dead ABS_*
lines are deleted instead of kept as decoration. Proven against the same digest in
a throwaway container (temp state dirs, loopback-only mapping, removed after):
base path "/", listening on :8000, GET /healthcheck -> 200, and the old prefixed
path -> 404.
Guards: the quadlet seam now refuses an Environment=ABS_* line and requires
PORT/ROUTER_BASE_PATH; the vault page gains the law with its failure signature and
the two-second image-inspect check. Suite: ALL TESTS PASSED, 812 ok.
The page owns the shape, the first-run account step (ABS accepts the initial
account only from a local-network address) and the laws: why the array bind is
read-only and what the one-line rollback is if on-disk sidecars are ever wanted;
why the metadata view is not that rollback; why the mount gate, the wget health
check and the amd64 digest pin are each mandatory; and that LAN-only is a posture,
not an accident.
Media Audiobook Relayout gains the third reader: ABS stores absolute paths, so the
relayout procedure has to reach its library too or it keeps rows for folders that
are gone. The vault index is regenerated, not hand-carved.