THE CLASS: the /api JWT middleware's TokenLookup is
"cookie:token,header:Authorization:Bearer" — the token cookie always
rides same-origin fetches, so a signed-in user never needs the
localStorage bearer. But a dozen call sites gated their fetch on
`localStorage.getItem("token")` and early-returned when absent. The
global 401 interceptor CLEARS that localStorage entry on any session
rotation/blip — after which every gated write silently did nothing:
theme changes reverted on reload (the reported "can no longer change
themes", on top of the interpolation bug d5dbaa48 fixed), chain-order
selects, collection deletion, Add-to-collection submits, filter
save/load/delete, conflict actions, linking actions, stats charts,
tag suggestions, device management, admin scanner actions — all dead
for perfectly authenticated cookie users.
Fix: storage.ts exports authHeaders() (bearer when present, cookie
carries otherwise) and every gate/header site now uses it — theme.ts,
collections.ts (htmx auth, doRemoveBook/doBulkRemove, setChainOrder),
bookPicker submit, filter_builder suggestions + match count,
collection-rules apply hook, bookshelf save/load/delete filter,
conflicts x4, linking x3, admin scanner actions + polls (requireToken
removed — its "Session expired" toast fired for cookie users too),
dashboard preferences/sections, custom-section-builder, header
scanner poll, stats x3, tag suggestions, profile delete-account
(which also wrongly redirected cookie users to /login), and
device-management x4. Reads keep their graceful-empty behavior; the
bearers still ride along when present.
Pinned: the knob-matrix theme spec now REMOVES localStorage.token and
asserts the theme PUT still fires (cookie-only) and the class flips —
the exact strand that started this.
Found by noticing the panel browser's Nord click switched the class
but produced no PUT: the persistence half was gated on a token the
401 interceptor had cleared.
Six specs driving the user-reported controls the way a human does,
encoding the sweep rules COVERAGE.md now states: menus/modals assert
VISIBILITY (never DOM text — hidden menus' text is why the 2026-10-30
panel sweep certified dead controls), geometry asserts the z-ladder
via elementFromPoint and viewport containment via boundingBox, every
click must produce an observable outcome (PUT fired, class flipped,
chip text swapped) and zero new console problems — the silent
CSP-throw family (theme buttons, connective chip, invisible op menu)
now fails a test instead of surviving to a user's mouse.
Covers: theme picker (interpolation regression from eb57b720),
pencil-glyph vs card-body contract, builder field menu / op menu
(full non-empty vocabulary) / connective both directions, the
Smart→Episodes create-modal seed, the picker overlay's ladder and
height, and the chain-order select's persistence round-trip.
Fixtures self-create/self-delete under test-prefixed names; localStorage
token + selectedLibrary=__all__ are seeded because the API login
establishes only the cookie; run the folder with --workers=1 (the login
route rate-limits and all suites share testuser — a parallel worker's
login revokes another's mid-run).
Marks the sweep-candidate list as executed (every knob driven in a real
browser), preserves the four defect classes the sweep fixed so the next
agent does not re-learn them the hard way, and pins the panel-vs-
Playwright limitation (value assignment without change events) that any
future automated spec of the htmx-triggered controls must work around.
User report: Remove Feed still used the browser dialog. Headless-proven
root cause (tests/ui/confirm_probe.spec.ts): the htmx:confirm handler
read evt.detail.element, which htmx 2 does not provide (its detail is
{ elt, question, issueRequest, ... }) — the handler returned at
'if (!elt) return' BEFORE preventDefault, so every hx-confirm sitewide
fell through to htmx's native confirm. The acbcc61a migration only ever
worked for direct confirmDialog() callers; hx-confirm interception was
dead on arrival against htmx 2 and no test watched the JS side.
Fix: read detail.question (htmx's own closest-attribute resolution of
hx-confirm); return without preventing default when absent. Probe
spec pins: click Remove Feed → #confirm-modal opens with the message,
cancel hides it, no dialog event fires.
Companion fixes in the same user-test round (one commit, one defect
family — templ boolean attributes + confirm-modal placement):
- Add-modal Download order never revealed: #podcast-download-all was
nested in a wrapper div, so ':checked ~ .podcast-add-order' had no
sibling relationship. Input, label, and order row are now siblings.
- Order/auto-download dropdowns always DISPLAYED the last option
('Newest first'/'Newest') whatever was saved: templ selected={ bool }
emits selected="false" — presence selects, so every option was
selected and the browser honored the last. Swept to selected?={...}
across podcast_detail (14), book_detail_modals (10, incl. two
always-checked checkboxes), collection_modal (2, same). Saves were
correct all along; display only.
- Global confirm modal moved from SidebarSignIn (signed-out only!) into
Header — signed-in pages had no #confirm-modal, so confirmDialog
degraded to window.confirm even with a working interceptor.
Facet bar scale (user question): values now carry feed counts, sort
frequency-first, cap at 6 chips per group with a native <details>
'+N more' expander.
Probes: tests/ui/confirm_probe.spec.ts + add_order_probe.spec.ts
(live-server, BH_FEED-gated) — both pass against the dev instance.
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB
doctrine (test*-named libraries, cascading cleanup, no binary
fixtures):
- TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber
404 on feed detail + SSR page (existence leak), episodes absent from
generic media reads, admin escape, subscriber visibility, then the
hidden-library flip hiding an already-subscribed feed (the feed
policy's library-visibility join). Seeding runs as the FIRST ADMIN —
the seed identity has neither subscription nor users row, so podcast
INSERT..RETURNING (SELECT policy under FORCE) 42501s for it;
production pods run as exactly that admin (invariant 12). All direct
row probes read through the admin scope for the same reason.
- TestPodcastAddFeedFlow: member add + subscribe + metadata-only
upsert; duplicate-URL dedupe (no second row); allow_member_feeds
gate (member 403 / admin 201 / subscribe-still-allowed).
- TestPodcastShrunkenFeedRetention: feed purges an episode from the
XML, both rows persist (upsert-without-delete pin, D14).
- TestPodcastEditSegregation: generic edit 400 + write-to-file refusal
for episodes; control book edit unaffected.
- TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file
lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed;
re-download of a downloaded episode never re-fetches (idempotency
pin, enclosure-hit counted); dead enclosure → failed + persisted
reason + metadata intact. Fetch/SSRF tests swap the client via
SetPodcastHTTPClientForTests (the production client refuses
loopback httptest servers by design — the real client gets its own
negative test in services).
- TestPodcastScannerExclusion: SetFolders on a podcasts library root
yields zero scanner folders (invariant 4 pin) — MediaScanner
.FolderCount() accessor added.
Unit: internal/services/podcast_rss_test.go — namespace-aware parsing
fixture (three XML namespaces, fallback chains, hosts/guests),
duration spellings, RFC-822 variants, explicit variants, slug/path
layout, enclosure-extension selection, stripHTML, and the SSRF matrix
(private/loopback/link-local/multicast/mapped-v6 blocked; a loopback
httptest server PROVES the production client refuses it).
suites green: full integration run 161s / 0 failures; vitest 12 files
163 tests.
Contract widening: TestContractPin_MediaListBothBranches' raw-row
allowlist gains the 15 podcast episode columns (additive; documented in
docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/
podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars.
tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain —
Playwright sweep pending.
Phase 5.5 (plan §7.5) first increment: the SSR/HTMX frontend is swept
in a real browser (official Playwright image, no host browsers) for
BOTH personas — testuser (admin) and rls-phone (restricted; Audiobooks
hidden from them via the visibility API).
Green checks: auth rejection + session establishment; admin dashboard
renders sections incl. the hidden-from-others library; restricted
dashboard renders real sections (Recently Added present — not a
vacuous landing-page pass) with ZERO hidden-library traces; search and
media payloads leak nothing for the restricted persona.
Harness lessons encoded: / is a landing page (dashboard lives at
/dashboard); the login endpoint rate-limits rapid attempts so sessions
are established once per persona and reused via storageState — a sweep
must not look like a brute-force run; getByText visibility assertions
are wrong for <option> text (library switcher) — body-textContent
assertions instead.
COVERAGE.md tracks the full 14-area sweep per the plan; the remaining
areas (reader, collections CRUD, admin matrix, upload roundtrip) are
enumerated and pending their sweep runs.