Compare commits

...
21 Commits
Author SHA1 Message Date
John O'Keefe 4ab4f9fa90 docs(readme): reconcile quickstart block post-inline-bootstrap
Release / build-and-push (push) Successful in 3m6s
The 12:21 sync (cf73eec2) made the README block a byte-copy of
docker-compose.yml as it stood before the inline db-init landed;
reconcile to the current file: initdb.d mount dropped, inline
hBcescaped bootstrap, PGPORT in db-init env (DB_PORT is a variable in
every connection path). Block verified byte-identical to
docker-compose.yml; no secrets in either version (placeholders only).
2026-10-29 13:12:36 -04:00
John O'Keefe 304ea9599f feat(deploy): inline bootstrap into db-init — compose is the single source; DB_PORT everywhere
z_rls_role.sh is gone. The db-init service carries the bootstrap SQL
inline (the script's docker-exec/local-socket mode was its only reason
to exist as a file, and the no-clone deployment path could not use it:
docker bind-mounts of missing host paths auto-create empty DIRECTORIES,
so a compose-only install got '/z_rls_role.sh: no such file').

The inline form surfaced two silent bugs the script had masked:
- psql defaulted its DATABASE to the username (POSTGRES_DB is not a
  libpq variable) — the ownership loop ran against 'postgres' and
  no-opped on existing volumes. Fixed with PGDATABASE: bookhoard.
- PGPORT was never propagated: with a custom DB_PORT, db-init dialed
  5432 while Postgres listened on the custom port. Fixed with
  PGPORT: ${DB_PORT:-5432} — DB_PORT is now a variable in every
  connection path (db listener, host mappings, app, db-init).

Also removed the db service's initdb.d mount: the app executes the full
schema as the runtime-role owner on every boot incl. first (pg_trgm is
a trusted extension; the db owner can create it), so the bootstrap SQL
was its only content.

Verified: dev two-boot against the existing 40-table volume (owners
converged, 0 off-owner tables/functions), custom-port throwaway
(DB_PORT=15432: db-init + app both on the custom port, healthy),
default-port throwaway (fresh volume, healthy), full Go suite green.
Manual repair path: docker compose run --rm db-init.
2026-10-29 13:06:16 -04:00
John O'Keefe cf73eec2e4 docs(readme): sync quickstart compose block to docker-compose.yml verbatim
The 'copy this wholesale' docker-compose.yml block in the README had
drifted from the real make-of-record file: it hardcoded 5432/8765
(missing the DB_PORT/SERVER_PORT configurability from de8f71b2), lost
the loopback-only db publishing (b03ed9a2), carried its own hand-tailored
db-init service with inline SQL instead of the z_rls_role.sh script
version shipped in 313268a7, and still contained the vestigial
./uploads mount on the db service (dropped in the previous commit).

Replace the whole yaml block with a byte-for-byte copy of
docker-compose.yml so the quickstart can no longer drift — the
documented optional envs, loopback bind, db-init script path, and
env_file wiring all now match what actually ships. Verified by diffing
the fenced block against the file.
2026-10-29 12:21:07 -04:00
John O'Keefe ab4bddc784 fix(compose): drop vestigial uploads mount from db service
The db service mounted ./uploads:/app/uploads, which Postgres has never
used and nothing in the repo relies on: no SQL COPY TO/pg_dump path, no
doc/test reference to an uploads path inside the db container (the only
/app/uploads mentions in TEST_DATA.md are the app/test containers), and
the backup scan is app-side.

Forensics: the mount was introduced by accident in 6ebe9749 ("Simplify
docker-compose healthcheck configuration"), which incidentally REPLACED
the db service's volumes with the app service's uploads mount. The
follow-up 91456d11 ("restore essential database configuration")
restored postgres_data and the schema-init mount but nobody noticed the
stray uploads mount riding along, and it has survived every compose
refactor since.

The tests service in docker-compose.dev.yml keeps its own ./uploads
mount — that is the app-like test runner (TEST_UPLOAD_PATH=/app/uploads)
and is legitimate. The dev db inherits its volumes from the base file,
so it is fixed automatically.
2026-10-29 12:21:03 -04:00
John O'Keefe de26544d9d docs(readme): prod install without a repo clone — copyable compose
The Installation section assumed 'git clone the repo'. Self-hosted
operators only need three files. The install is now: mkdir + copy this
compose block + .env (three openssl secrets) + uploads dir + pull/up.

The compose is fully self-contained: the runtime-role bootstrap is
inlined into db-init's command (same SQL as
database/schema/z_rls_role.sh — keep in sync) — no ./database/schema
bind mount, because the app itself executes the schema as the runtime
role (owner) on first boot; with the role as DATABASE owner the trusted
pg_trgm extension needs no superuser.

Verified end to end in a throwaway project: fresh volume, pasted block,
.env with generated secrets, db-init converged role+ownership, app ran
the full schema and came up healthy — no repo checkout anywhere.

Compose-quoting lessons encoded (the reason for odd dollars): compose
interpolates '532016' to '$' before the container sees it, so the plpgsql
532016 tags in the inline SQL are written 532016532016, and PGUSER must be set
explicitly (POSTGRES_USER is not a libpq variable).
2026-10-29 12:05:56 -04:00
John O'Keefe efbe312f16 docs(agents): family-lens enforcement moves to RLS — one source
Approves and lands the doctrine rewrite from the verified rollout:
RLS on media_items IS the enforcement (policy joins library_visibility
on app.user_id, declared per transaction by ScopedDB); new
media-listing queries and endpoints need no visibility plumbing.
Documents the three identity bridge sites, background-job identities,
the FORCE/owner role and db-init provisioning, per-boot schema
idempotency, and the two auditors (TestRlsInvariant + tests/ui).
Item-scoped gates may answer 404 as well as 403 (contract_pins
2026-10-29). History records the convention-era leak the dashboard
section queries carried.
2026-10-29 12:01:37 -04:00
John O'Keefe d49a9e6ca2 fix(devices): device deletion detaches reading history instead of 500ing
Deleting any device whose stats rows still referenced it failed with
500: reading_history.device_id is a NO-ACTION FK by design (the rows
are real captured reading stats — Users reads them), so a blind DELETE
violated the constraint. Surfaced by the Android app's new logout
deregister call, but the admin Devices page Deletes hit the same stone.

Fix: DetachReadingHistoryFromDevice — NULL the device association on
the user's history rows before the device DELETE (single handler
pass). device_id is nullable; the stats survive, the device frees its
identifier. sqlc regenerated.
2026-10-29 11:57:20 -04:00
John O'Keefe 313268a7ae feat(deploy): db-init one-shot service — paste-and-go bootstrap for the runtime role
The role split previously required a one-time manual script run on
existing volumes (initdb.d only fires on fresh ones). This folds the
bootstrap into the compose file the way split-account deployments
standardly do it: a one-shot db-init service runs z_rls_role.sh
(idempotent, network mode via PGHOST=db + PGPASSWORD from DBPASS) on
every 'compose up', and the app gates on
db-init: condition: service_completed_successfully.

- Zero manual steps for fresh AND existing deployments: update compose,
  add DBSRVPASS to .env, 'compose pull && up -d'.
- Security posture improves: the superuser password is now held ONLY by
  the init service — the app container connects exclusively as the
  non-superuser runtime role.
- z_rls_role.sh gains dual connection modes (local socket for docker
  exec, network for the init service) with the DBPASS requirement
  enforced only in network mode.
- Verified on the dev stack: init runs and exits clean, app gates on it
  and comes up healthy, second 'up' re-converges idempotently, invariant
  test passes through the new dependency chain.

The initdb.d mount stays as-is (harmless double coverage on fresh
volumes; both paths are idempotent).
2026-10-29 11:17:42 -04:00
John O'Keefe 1457dc7289 docs(env): DBSRVPASS in .env.example — the runtime role password
.env.example is the template prod/new deployments copy; it was missing
the runtime role password the app now requires (DATABASE_USER
bookhoard_srv). Notes distinguish it from the bootstrap DBPASS and
point existing-volume operators at the one-time z_rls_role.sh run.
2026-10-29 11:02:41 -04:00
John O'Keefe f579696633 test(ui): Playwright RLS sweep — real-browser proof, both personas
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.
2026-10-29 10:25:08 -04:00
John O'Keefe 4d697f5fcf test(visibility): full suite green under RLS — sync paths, device-auth bridge, seed pool
Phase 5 (plan §7): the entire integration suite passes with the RLS
policies live. The suite was the triage enumerator the plan predicted:

- device_auth middleware: the THIRD identity bridge — KOReader/Kobo
  device-authenticated requests read media as the DEVICE OWNER
  (plan A9 called this path out; the flip was 404s on sync metadata).
- conflicts/analytics/progress handlers: detached
  context.Background() reads threaded to the request context (the
  requesting user's identity) — 12+ sites; resolve helpers take ctx.
- scanner: ScanFolders self-declares its configured admin identity
  (server maintenance, product-correct for any caller); generic job
  creation stamps the requesting admin.
- harness: setup.DBPool and the sync-test pool declare
  rls.SeedIdentity at SESSION scope via BeforeAcquire — the pool never
  serves a second identity, so no A3 bleed; setup.DB falls back to the
  seed identity (NewSeedDB) for ungoverned-context calls; filter tests
  use seedExec for raw governed-table statements.
- contract pin WIDENED (docs/contract_pins.md 2026-10-29): under RLS,
  item-scoped gates may answer 404 (row invisible — no existence leak)
  as well as 403 (Go-level checks on listings keep strict 403).
- TestWorker_JobStatusTracking — the pre-existing red from the Phase 0
  baseline — passes with the harness seed work; no product change.
2026-10-29 10:14:08 -04:00
John O'Keefe bb2cc26e9d feat(scanner): scanner entrypoints + generic jobs carry RLS identity; SeedDB harness fallback
The full suite enumerated the remaining triage class: tests drive the
scanner directly (ScanFolders(context.Background())) and read seeded
media via Queries with bare contexts — every governed read raised.

- MediaScanner.ScanFolders self-declares its configured admin
  (SetAdminID — set by main AND the test helpers): a scan is server
  maintenance, product-correct regardless of caller.
- ScopedDB gains a Fallback identity, exposed as NewSeedDB for the
  TEST HARNESS ONLY: setup.DB falls back to rls.SeedIdentity when the
  context carries none, so direct seeding/verification helpers work
  uniformly instead of per-site context threading. rls.SeedIdentity is
  the shared constant (no library_visibility rows => full visibility).
- generic job creation (jobs.go) stamps the requesting admin into the
  job Context, same as the scan endpoint.

The sync queue processor stays identity-free: sync_queue is not an
RLS-governed table.
2026-10-29 09:27:04 -04:00
John O'Keefe 385a5d9dba feat(scanner): background jobs declare acting identity for RLS
Phase 4 of the RLS plan: detached contexts (context.Background()) that
reach media_items now carry identities — with the policies live, a bare
context made governed reads raise (fresh connections: 42704 unrecognized
parameter; reused pool connections: 22P02 empty-string cast after the
LOCAL GUC reverted — the plan's A2 signature).

- scanner jobs (API-triggered, mtime-poll, initial scan): the job's
  Context now carries the acting admin's identity — the library owner,
  first-admin fallback (the job's UserID already encoded this trust
  decision; the Context just needed to carry it). performInitialScan
  uses the scanner's configured admin.
- websocket initial-sync read (GetUserMediaItemsForSync): runs as the
  connection's own user.
- hash backfill: runs as first admin (server self-heal over all media).

Verified on the dev stack: forced rescan of the 1199-file Audiobooks
library completed with 0 errors under RLS (previously every SHA lookup
and create raised); hash-backfill refusals gone. The sync queue
processor needs no identity — sync_queue is not an RLS-governed table.
Known remaining detached reads (conflicts/analytics handlers using
context.Background()) are HTTP-path triage, enumerated by the full
suite next.
2026-10-29 09:08:54 -04:00
John O'Keefe 922b92a8b6 feat(db): RLS library-visibility enforcement — invariant GREEN
The dashboard leak is closed at the database engine. The black-box
invariant (restricted user + hidden library => absent from every
media-returning surface) went RED->GREEN with this change set; clause-
protected surfaces never regressed, proving enforcement came from the
policies, not the test.

Phase 3 of plan ~/.opencode/plan/server-rls-library-visibility.md:
- schema.sql: idempotent RLS block — ENABLE + FORCE on media_items,
  SELECT policy mirroring COALESCE(is_visible,true) exactly, permissive
  per-command write stand-ins (FOR ALL would OR into SELECT).
- internal/rls + internal/database/scoped.go: ScopedDB declares the
  acting user per statement (LOCAL GUC). DESIGN CORRECTION during
  execution: the decorator never refuses — the ENGINE is the fail-loud
  layer (policies raise on governed tables; auth lookups and settings,
  ungoverned, simply work). A decorator refusal would have been a new
  convention gate — plan B thinking — and broke login (chicken-and-egg:
  credentials are checked before any identity exists).
- database/schema.go: the migration tx declares a synthetic identity —
  schema.sql's dedup DO block SELECTs media_items, which raises once
  policies are live (plan A11). Caught by the two-boot gate: boot 1
  created the policies after the block ran; boot 2 raised.
- router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device
  auth) — echo-local c.Set never reaches the stdlib ctx the DB layer
  receives.
- search: the search service's raw DBTX path receives the ScopedDB
  itself, never the bare pool.
- harness: the server under test is wired exactly like main.go; the
  seed handle is also ScopedDB-wired but carries seedCtx() — a
  synthetic identity with no visibility rows — because sqlc :one
  inserts use RETURNING, which evaluates the SELECT policy even on
  INSERT. Regex UUID-scraping in the invariant replaced with a
  structured JSON decode.
- invariant test: RED -> GREEN across every surface incl. View All;
  403 pins and admin-scoping pins hold.
2026-10-29 08:56:05 -04:00
John O'Keefe 8c3d919aa0 wip(rls): engine-side fail-loud + harness seed identity [pin: work interrupted]
Design correction discovered by the auth chicken-and-egg: the
decorator-level refusal was re-created a convention gate (plan B
thinking). ScopedDB now NEVER refuses — it declares the GUC when the
context carries an identity and declares nothing otherwise; the ENGINE
is the fail-loud layer (policies raise on governed tables, ungoverned
tables like auth lookups simply work). rls.WithInternal removed as a
dead concept.

- search service's raw DBTX path now receives the ScopedDB itself
  (main.go + harness) — a bare pool bypassed the GUC.
- database/schema.go: migration tx declares a synthetic identity
  (schema.sql's dedup DO block SELECTs media_items; with policies
  live, boot 2 raised 'unrecognized configuration parameter' — A11).
- harness: TestServerSetup.DB is a raw-pool SEED handle (write
  policies permissive, other seeded tables ungoverned); the server
  under test keeps the scoped wiring. 33 CreateMediaItem sites use
  seedCtx() — a seed identity with no visibility rows — because
  RETURNING requires SELECT visibility of the new row.
- invariant test: regex UUID-scraping replaced with a structured JSON
  decode of the sections payload.

INTERRUPTION NOTE: an edit to this same effect (seedCtx) was applied,
confirmed by the tool, and subsequently vanished from disk while an
'opencode serve' process (PID 84551) is alive on the machine. This
commit pins the tree against further loss; implementation is PAUSED
pending the user killing that process.
2026-10-29 08:44:12 -04:00
John O'Keefe 004d4197a4 fix(test): rls fixture — reuse declared err variable 2026-10-29 08:26:10 -04:00
John O'Keefe f4b581872f wip(rls): ScopedDB + rls package + schema policies + echo bridge [pin compiling state]
Phase 3 of the RLS plan, pinned mid-flight because a parallel writer
was actively editing cmd/server/tests/rls_visibility_test.go (A14):
the file flip-flopped between two implementations and gained a compile
error after the previous commit. This snapshot makes the whole tree
compile; functional verification (two-boot gate, invariant green,
full suite) lands in the next commit(s).

- internal/rls: typed ctx keys — user identity (bridged from echo by
  the router) and an INTERNAL marker whose semantics grant nothing:
  no GUC is declared, so policy'd tables still raise at the engine;
  only RLS-ungoverned tables (system_settings, users counts) work for
  user-less startup paths (settings registry Load, CountAdmins).
- internal/database/scoped.go: DBTX decorator — per-statement tx,
  commit-on-Close (:many) / commit-on-Scan (:one), error paths always
  terminate the tx, refusal before SQL when no identity and not
  internal.
- database/schema/schema.sql: idempotent policy block (DROP IF EXISTS
  + CREATE — schema.sql re-runs every boot): ENABLE + FORCE, read
  policy exactly mirroring COALESCE(is_visible,true), permissive
  per-command write stand-ins (FOR ALL would OR into SELECT).
- router.go: RLS bridge at BOTH c.Set("user") sites (JWT + device
  auth) — echo-local storage never reaches the stdlib ctx.
- main.go + test harness: database.New over ScopedDB at both wiring
  sites; startup reads use rls.WithInternal.
2026-10-29 08:11:20 -04:00
John O'Keefe a19d7a4cbf test(visibility): fixture fixes from first live runs
Three harness corrections found while executing the invariant test:
- library type is 'ebooks' (plural) in library_types, not 'ebook'
- cleanup ordering: t.Cleanup is LIFO — register the pool Close BEFORE
  the fixture's library deletes, or the deletes run against a closed
  pool and fail silently into ignored errors (leaving RLSMARKER rows
  behind; found because a second run saw the first run's 'deleted'
  books still leaking through Recently Added)
- collection detail is addressed by the section's collection_id (the
  UUID in view_all_url), not the section name
2026-10-29 08:02:34 -04:00
John O'Keefe cb258b6d20 feat(db): dedicated non-superuser runtime role for RLS
The app connected as 'postgres' — the bootstrap SUPERUSER — which
bypasses row-level security unconditionally (FORCE RLS does not apply
to superusers), making any future policy a silent no-op. This is the
prerequisite commit for the RLS visibility work (plan
~/.opencode/plan/server-rls-library-visibility.md REV 2, §4.0).

What changed:
- database/schema/z_rls_role.sh: creates the bookhoard_srv role
  (LOGIN, non-superuser, non-bypassrls) and transfers ownership of
  every public table/sequence/function + schema + database to it.
  Ownership (not mere grants) because executeSchema re-runs the whole
  schema.sql on EVERY boot and needs owner DDL. Idempotent, catalog-
  only — no user data is read or written.
- docker-compose.yml app service + docker-compose.dev.yml tests
  service: DATABASE_USER postgres -> bookhoard_srv, DATABASE_PASSWORD
  DBPASS -> DBSRVPASS (new .env key, generated locally).
- POSTGRES_USER: postgres stays as the container bootstrap account;
  on FRESH volumes z_rls_role.sh runs automatically via
  docker-entrypoint-initdb.d AFTER schema.sql (alphabetical: z > s).

Verified on the dev stack: role has rolsuper=f, rolbypassrls=f, owns
all 40 tables and every public function; single boot as bookhoard_srv
executed schema.sql cleanly (CREATE EXTENSION IF NOT EXISTS pg_trgm
skips fine as non-superuser when the extension exists); health +
authenticated smoke pass. Behavior otherwise unchanged — no policies
yet.
2026-10-29 08:02:34 -04:00
John O'Keefe 3dcb794032 test(visibility): black-box invariant — hidden library absent from every surface
Adds the permanent auditor for per-user library visibility: a restricted
user with a hidden library sees none of its items on any media-returning
surface (media list, search, dashboard sections, series, facets, OPDS
device catalog, collection detail, scoped/detail access).

RED now, on exactly the surfaces the audit predicted: the dashboard
section queries (GetNotStartedItems / GetRecentlyAddedItems /
GetCollectionItemsForDashboard et al.) never carried the
visible_library_ids clause, and the collection-detail endpoint (mobile
View All) exposes the full section query results. Clause-protected
surfaces (media list, search, series) pass, proving the test itself
isn't a false positive.

Implementation-agnostic on purpose: it asserts the user-level invariant
through the public API, so it stays valid when RLS replaces the
per-query clauses and catches any future regression regardless of how
the code is written. Also pins: hidden library_id=403 (never silently
passed), item detail denial, and that the hide is USER-scoped (admin
still sees the hidden library).

Test-lesson encoded: cleanup ordering is LIFO — the pool Close must be
registered BEFORE fixture cleanups, or library deletes run against a
closed pool and fail silently into ignored errors.
2026-10-29 07:59:05 -04:00
John O'Keefe 2b122f2481 feat(web): densify all-books and series grids
Release / build-and-push (push) Successful in 3m14s
Book cards on the library (all-books) page were oversized at full
window width, and both the all-books and series grids showed only
2 columns on phones, making covers unnecessarily large on mobile.

Shift the all-books column ladder up one at every breakpoint
(2/4/5/6 -> 3/5/6/7) so the responsive reflow the grid is liked for
is preserved while every window size gets smaller cards; the series
grid gains a column at mobile only (2 -> 3), leaving md/lg untouched
per request. Dashboard carousel sizing (w-36/w-40) and the series
stacked-covers component are deliberately unchanged.

templ codegen and the tailwind build output commit with the source
change that produced them (repo convention).
2026-10-28 10:12:20 -04:00
51 changed files with 1297 additions and 284 deletions

No files matched your search

+9 -1
View File
@@ -6,10 +6,18 @@
# Generate with: openssl rand -hex 32
JWT_SECRET=your-secure-jwt-secret-key-here
# PostgreSQL database password
# PostgreSQL database password (the bootstrap superuser account; kept
# for container initialization — the app itself connects as bookhoard_srv)
# Generate with: openssl rand -hex 16
DBPASS=your-secure-database-password-here
# Runtime database role password (bookhoard_srv — the non-superuser
# account the app connects as; subject to RLS). Required for the app to
# start. Consumed by the one-shot db-init service on every startup
# (idempotent — creates/refreshes the role and its privileges).
# Generate with: openssl rand -hex 16
DBSRVPASS=your-secure-runtime-role-password-here
# Networking: change a port if it conflicts on your host
# Postgres port, host + container (e.g. another local DB already uses 5432)
# DB_PORT=15432
+41 -21
View File
@@ -106,30 +106,50 @@ inside quoted attribute values (gotcha #1 below), so dynamic
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
every user sees every library unless an admin hides one) must be
enforced on **every endpoint that returns media metadata or content**.
It is NOT automatic — enforcement is per-handler.
every user sees every library unless an admin hides one) is enforced by
**Postgres RLS** — one source of truth: the labeled policy block in
`database/schema/schema.sql` + the runtime role provisioned by the
`db-init` compose service.
- **New endpoints that list/filter media or series** MUST filter by the
requesting user's visible-library set, and MUST reject an explicit
`library_id` outside that set with 403 (never silently pass).
- Helpers (do not hand-roll):
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
- `resolveLibrary` (helpers.go) only honors requested library ids
inside the visible set.
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
- Content endpoints (file bytes, page images) additionally gate per item
like `ReaderHandler.ensureItemVisible` / the download endpoint.
- Tests must pin visibility: hidden library → 403 on the endpoint, and
absent from unscoped aggregates.
- **The engine is the enforcement.** Every `media_items` read is
filtered by the policy, which joins `library_visibility` on the
per-transaction GUC `app.user_id`. New media-listing queries need
NO visibility clause; new endpoints need NO visibility plumbing.
- **The app declares identity; it never computes visibility.** All
sqlc statements run through `ScopedDB`
(`internal/database/scoped.go`), which declares `app.user_id` from
the stdlib request context. Three bridge sites put it there:
`router.go` JWT, `router.go` cookie/device, and
`middleware/device_auth.go` (KOReader/Kobo sync devices). A media
read on a detached `context.Background()` RAISES — thread the
request context or the acting identity (see conflicts.go and
analytics.go for the pattern).
- **Background jobs declare the acting identity** before detached
work: scan jobs carry the library owner / first-admin fallback
(the job Context carries it — `ScanFolders` self-declares the
configured admin), the hash backfill runs as first admin. The sync
queue processor needs nothing (`sync_queue` is not RLS-governed).
- **NEVER**: session-scoped `app.user_id` (pooled-connection identity
bleed); a bypass flag; connecting the app as the bootstrap
superuser (superusers bypass RLS unconditionally — `DATABASE_USER`
stays `bookhoard_srv`; the `db-init` compose service provisions the
role on every startup).
- **schema.sql re-runs on EVERY boot** (executeSchema) — everything
added to it must be idempotent (`DROP .. IF EXISTS` + `CREATE`).
The migration tx declares a synthetic identity
(`schemaMigrationIdentity`) because the schema's own dedup block
reads `media_items`.
- **Auditors**: `TestRlsInvariant` (API black-box — add new
media-returning surfaces to it; loosening requires a
docs/contract_pins.md entry) and the Playwright sweep (`tests/ui`,
new pages/controls go into `tests/ui/COVERAGE.md`). Item-scoped
gates may answer 404 as well as 403 (row invisible vs Go-level
check — contract_pins 2026-10-29).
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
leaked hidden libraries to any authenticated account — series endpoints
were name-only across ALL libraries. Fixed; do not regress.
leaked hidden libraries; **2026-10-29 enforcement moved to RLS** — the
dashboard section queries had silently never carried the clauses,
and the invariant test went red → green and stays as the auditor.
## Tests
+146 -141
View File
@@ -21,160 +21,165 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
- **Podman** (recommended) or Docker
- **5 minutes** of your time
### Installation
### Installation (no source checkout needed)
Create a directory on the Docker host. You only need the compose file,
an `.env`, and an `uploads` folder — everything else rides the published
image.
```bash
# 1. Clone the repository
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
cd bookhoard
# 2. Set up environment
cp .env.example .env
# Generate secure passwords (no special characters):
# JWT_SECRET: openssl rand -hex 32
# DBPASS: openssl rand -hex 16
# Edit .env with your generated values
# 3. Pull images and start the server
docker compose pull
docker compose up -d
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
# 4. Open your browser
open http://localhost:8765
mkdir bookhoard && cd bookhoard
mkdir uploads
```
The first user to register automatically becomes an admin.
**`docker-compose.yml`** — copy this wholesale:
---
```yaml
services:
# PostgreSQL Database
db:
image: postgres:15-alpine
container_name: bookhoard_db
environment:
POSTGRES_DB: bookhoard
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DBPASS}
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
PGPORT: ${DB_PORT:-5432}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
env_file:
- .env
## 📖 Key Features
# One-shot bootstrap: creates the bookhoard_srv runtime role and
# transfers object ownership to it (RLS prerequisite). Idempotent —
# safe on every startup; the app waits for its success. Passwords
# accept inline values or .env, like the rest of this file.
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
# per pair before the container sees the SQL.
db-init:
image: postgres:15-alpine
container_name: bookhoard_db_init
environment:
PGHOST: db
PGPORT: ${DB_PORT:-5432}
PGUSER: postgres
PGDATABASE: bookhoard
PGPASSWORD: ${DBPASS}
DBSRVPASS: ${DBSRVPASS}
entrypoint: ["bash", "-c"]
command:
- |
set -e
: "${DBSRVPASS:?DBSRVPASS required in .env}"
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
DO $$$$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
CREATE ROLE bookhoard_srv LOGIN;
END IF;
END $$$$;
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
DO $$$$
DECLARE r RECORD;
BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
END LOOP;
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
END LOOP;
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
END LOOP;
END $$$$;
ALTER SCHEMA public OWNER TO bookhoard_srv;
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
EOSQL
depends_on:
db:
condition: service_healthy
restart: "no"
### Universal Cross-Platform Sync
# Bookhoard Application
# In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
app:
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
container_name: bookhoard
restart: unless-stopped
environment:
# Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
# the app must NEVER connect as it (superusers bypass RLS).
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
- **Offline Queue**: Changes sync when you reconnect, priority-processed
- **Conflict Resolution**: Smart handling when same book read on multiple devices
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: ${SERVER_PORT:-8765}
# IMPORTANT: Device sync requires full URL with protocol
# Local: http://localhost:8765
# Local network: http://192.168.1.X:8765
# Domain: https://bookhoard.example.com
BASE_URL: ${BASE_URL:-http://localhost:8765}
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
COOKIE_SECURE: ${COOKIE_SECURE:-false}
### Media Management
# Rate Limiting Configuration
TEST_MODE: ${TEST_MODE:-false}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
REQUESTS_PER_MINUTE: ${REQUESTS_PER_MINUTE:-10}
- **Calibre Integration**: Automatic metadata import from Calibre `metadata.opf` sidecar files
- **Smart Search**: Partial matching with fuzzy search fallback for typos
- **Advanced Filtering**: Filter by author, series, genre, language, year, cover images
- **Dynamic Sorting**: By title, author, date added, published date, page count, series
- **Rich Metadata**: Title, author, series, publisher, ISBN, language, edition, tags
- **5-Star Ratings**: Half-star precision (1-10 scale)
- **Notes & Highlights**: Color-coded annotations with linked notes
- **Usage Analytics**: Reading statistics, device usage, popular books
# Conversion Service Configuration
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
### Smart Collections
# Library Maintenance
# Days an archived item (file missing from disk for 2+ scans) is kept,
# with its reading history, before library scans purge it for good.
# Set 0 to keep archived items until purged manually on the library admin page.
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
- **Test Before Creating**: Preview which books match your rules
# System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC}
ports:
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
### Library Organization
- **Multi-Library Support**: Ebooks, Comics, and Manga with type-specific file formats
- **Multiple Folders**: Add multiple scanning folders per library
- **Visibility Control**: Admins control which libraries each user can see
- **Background Scanning**: Auto-scan with per-user frequency settings
- **Watch Mode**: Real-time file system monitoring for instant updates
### Security
- **JWT Authentication**: Short-lived access tokens (1 hour) with refresh tokens (7 days)
- **Strong Passwords**: Complexity requirements enforced (8+ chars, uppercase, lowercase, number, special)
- **Account Lockout**: 5 failed attempts = 15-minute lockout
- **Rate Limiting**: 10 requests/minute on auth endpoints
- **Input Validation**: Comprehensive validation on all inputs
- **No Passwords on Devices**: Web-based device approval with QR codes
---
## 📚 Documentation
### For Users & Self-Hosters
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
### For Developers
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
---
## 🎯 Supported Devices
| Platform | Sync | OPDS | Status |
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
| **Web Browser** | ✅ | ✅ | Full support |
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
---
## 💾 Network Storage (NAS / NFS / SMB)
Libraries on network mounts are supported for everyday use — serving,
reading, progress, and annotation sync all work over the mount — with a
few behaviors worth knowing:
- **Outage hysteresis.** When the mount drops, files miss one scan
(marked missing, hidden) then a second (archived, still recoverable).
When the mount returns — by path or by identical content elsewhere —
everything is restored automatically: annotations, progress, and
covers included.
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
(default **90**, `0` = never auto-purge) are deleted for good by
library scans, annotations included. A NAS offline longer than the
retention window loses those books' metadata on return.
- **Deleting while the storage is down is refused.** Deleting a book
cascades its annotations, so deletes are rejected with a clear error
while the item's library storage is unreachable. Archive the item
instead, or retry when the mount is back.
- **Watch mode does not fire on network mounts** (inotify doesn't work
over NFS/SMB). The periodic scan is the change detector.
- **Latency** — folder reachability checks are cached ~30s; large
libraries on slow mounts will still feel slower than local disk.
---
## 🛠 Tech Stack
- **Backend**: Go 1.25+ with Echo framework
- **Database**: PostgreSQL 15+ with pgx v5
- **Frontend**: HTMX + Tailwind CSS + Templ
- **Auth**: JWT tokens with bcrypt password hashing
- **Container**: Podman (Docker compatible)
---
## 🧪 Testing
```bash
# Run all tests
make test-all
# Run integration tests (with test mode)
make test-integration
# Run Bruno OpenCollection YAML API tests
npm install -g @usebruno/cli
bruno run
# Named Volumes
volumes:
postgres_data:
bookhoard_conversion_cache:
```
---
+10 -2
View File
@@ -41,7 +41,12 @@ func main() {
}
defer dbPool.Close()
queries := database.New(dbPool)
// The search service's raw DBTX path must ALSO carry the RLS
// identity — hand it the ScopedDB itself (a DBTX), never the bare
// pool, or search queries bypass the per-user GUC and raise under
// the policies.
scopedDB := database.NewScopedDB(dbPool)
queries := database.New(scopedDB)
// Initialize database schema
log.Println("🔧 Ensuring database schema is initialized...")
@@ -53,6 +58,9 @@ func main() {
// Load tunable settings from the DB into the registry. All values fall back
// to compiled defaults if a row is missing, so this never blocks startup.
// (Startup has no end-user identity; system_settings is not RLS-
// governed, and ScopedDB declares nothing when no identity exists —
// the engine only raises on governed tables.)
registry := database.NewSettingsRegistry(queries)
if err := registry.Load(ctx); err != nil {
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
@@ -165,7 +173,7 @@ func main() {
dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
+6 -1
View File
@@ -289,7 +289,12 @@ func TestAudiobookTrackEndpoints(t *testing.T) {
resp, err := client.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
// PIN WIDENED for RLS (docs/contract_pins.md 2026-10-29): the
// handler's own Go-level gate still answers 403, but under
// engine-level visibility the hidden row is simply ABSENT —
// 404 is equally gated (no existence leak). Both accepted.
require.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound,
"%s must be gated (403 or 404), got %d", path, resp.StatusCode)
}
// Restore visibility so harness cleanup ordering never sees a
+1 -4
View File
@@ -2,7 +2,6 @@ package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -367,8 +366,6 @@ func TestPreviewCollection(t *testing.T) {
}
func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID string, count int) []string {
ctx := context.Background()
libUUID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library ID")
@@ -379,7 +376,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
for i := 0; i < count; i++ {
genre := genres[i%len(genres)]
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Preview Test Book " + string(rune('A'+i)),
Authors: []string{"Test Author " + string(rune('A'+i))},
+1 -1
View File
@@ -108,7 +108,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
})
for title, genre := range titlesByGenre {
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Authors: []string{"Smart Rule Author"},
@@ -113,7 +113,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
params.AlternateInfo = []byte(v)
}
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
require.NoError(t, err, "Should create media item")
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
@@ -596,7 +596,7 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
Isbn: pgtype.Text{String: "9781234567890", Valid: true},
}
mediaItem, err := setup.DB.CreateMediaItem(ctx, params)
mediaItem, err := setup.DB.CreateMediaItem(seedCtx(), params)
require.NoError(t, err, "Should create ebook")
mediaUUID, err := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
@@ -740,7 +740,7 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
// Create a media item with ALL metadata fields populated
alternateInfoStr := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
var mediaItem database.MediaItems
mediaItem, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
mediaItem, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Complete Metadata Comic",
FilePath: "/test/complete.cbz",
+7 -7
View File
@@ -25,7 +25,7 @@ func TestComicMetadataExtraction(t *testing.T) {
require.NoError(t, err, "Should parse library UUID")
// Insert test media item with full comic metadata
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Test Manga",
FilePath: "/test/manga.cbz",
@@ -64,7 +64,7 @@ func TestComicMetadataExtraction(t *testing.T) {
parsedLibraryID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library UUID")
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Test Comic",
FilePath: "/test/comic.cbz",
@@ -94,7 +94,7 @@ func TestComicMetadataExtraction(t *testing.T) {
parsedLibraryID, err := uuid.Parse(libraryID)
require.NoError(t, err, "Should parse library UUID")
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "Minimal Comic",
FilePath: "/test/minimal.cbz",
@@ -136,7 +136,7 @@ func TestReadingDirectionAPI(t *testing.T) {
}
for _, tc := range testCases {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: tc.title,
FilePath: "/test/" + tc.title + ".cbz",
@@ -205,7 +205,7 @@ func TestUniversalMetadataFields(t *testing.T) {
require.NoError(t, err, "Should parse ebook library UUID")
t.Run("Comic with universal fields", func(t *testing.T) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedComicLibraryID, Valid: true},
Title: "Comic with Universal Metadata",
FilePath: "/test/comic.cbz",
@@ -231,7 +231,7 @@ func TestUniversalMetadataFields(t *testing.T) {
})
t.Run("Ebook with universal fields", func(t *testing.T) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedEbookLibraryID, Valid: true},
Title: "Ebook with Universal Metadata",
FilePath: "/test/book.epub",
@@ -270,7 +270,7 @@ func TestComicSpecificFields(t *testing.T) {
t.Run("Alternate series info as JSONB", func(t *testing.T) {
alternateInfo := `{"alternate_series":"Ultimate X-Men","alternate_number":1,"alternate_count":12}`
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: parsedLibraryID, Valid: true},
Title: "X-Men with Alternate Series",
FilePath: "/test/xmen.cbz",
+1 -1
View File
@@ -120,7 +120,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
seriesName := fmt.Sprintf("Test Contract Series %d", time.Now().UnixNano())
itemTitle := fmt.Sprintf("Test Contract Item %d", time.Now().UnixNano())
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle,
Authors: []string{"Test Contract Author"},
+6 -7
View File
@@ -89,7 +89,7 @@ func TestFacetRegistryParamsFilter(t *testing.T) {
if mutate != nil {
mutate(&p)
}
_, err := setup.DB.CreateMediaItem(ctx, p)
_, err := setup.DB.CreateMediaItem(seedCtx(), p)
require.NoError(t, err, "create %s", title)
}
@@ -194,7 +194,7 @@ func TestFacetsEndpoint(t *testing.T) {
// Two items share the genre; one carries the language + narrator.
for _, title := range []string{"Facet List One", "Facet List Two"} {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: title,
Authors: []string{"Facet Listing Author"},
@@ -206,7 +206,7 @@ func TestFacetsEndpoint(t *testing.T) {
})
require.NoError(t, err)
}
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Facet List Three",
Authors: []string{"Facet Listing Author"},
@@ -318,7 +318,6 @@ func mergeParams(base, extra url.Values) url.Values {
// "Baen Books") cross-match at 0.3+ (session gotcha).
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
t.Helper()
ctx := context.Background()
books := []struct {
title string
genre string
@@ -329,7 +328,7 @@ func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UU
{prefix + " Gamma", "history", "Pinecrest"},
}
for _, b := range books {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: b.title,
Authors: []string{prefix + " Author"},
@@ -426,7 +425,7 @@ func TestFacetCountsUnderFilters(t *testing.T) {
facetFixtureBooks(t, setup, library.ID, "Counts")
// A book in the HIDDEN library: distinctive values that must never
// surface for the regular user.
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: hiddenLib.ID,
Title: "Counts Hidden Book",
Authors: []string{"Counts Secret Author"},
@@ -559,7 +558,7 @@ func TestFieldValuesAllLibraries(t *testing.T) {
id pgtype.UUID
name string
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: lib.id,
Title: fmt.Sprintf("Field Values %s Book", lib.name),
Authors: []string{"Verdana Scribe"},
+18 -31
View File
@@ -156,7 +156,7 @@ func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, n
}
books[i].TagsSearch = lowered
}
_, err := setup.DB.CreateMediaItem(ctx, books[i])
_, err := setup.DB.CreateMediaItem(seedCtx(), books[i])
require.NoError(t, err, "create %s", books[i].Title)
}
return libUUID, library.ID
@@ -267,11 +267,9 @@ func TestFilterComposedSemantics(t *testing.T) {
assert.Len(t, got, 4, "mime_type is")
// Series full membership: the alternate_info series matches too.
ctx := context.Background()
_, err := setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
WHERE title = $1`, "Sem Fen")
require.NoError(t, err)
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
@@ -327,43 +325,34 @@ func TestFilterUserRelativeFields(t *testing.T) {
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
// Stone, time on Wick (2h), collection holding Tide.
_, err := setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
tideID, aID, stoneID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
tideID, bID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
aID, wickID, aID, tideID)
require.NoError(t, err)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx,
err := setup.DBPool.QueryRow(context.Background(),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
require.NoError(t, err)
titlesFor := func(token string, qf string) []string {
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
@@ -445,7 +434,7 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// Add a second distinct author+genre pair (trigram-disjoint).
ctx := context.Background()
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Sug Dune",
Authors: []string{"Zephyrine Hale"},
@@ -572,18 +561,17 @@ func TestFilterSuggestionsUnderRules(t *testing.T) {
// offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx,
err = setup.DBPool.QueryRow(context.Background(),
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
var tideID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
err = setup.DBPool.QueryRow(context.Background(), `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
require.NoError(t, err)
status, results = suggest("collection", " ", "")
require.Equal(t, http.StatusOK, status)
@@ -632,7 +620,7 @@ func TestFilterFamilyLens(t *testing.T) {
id pgtype.UUID
title string
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: lib.id, Title: lib.title,
Authors: []string{"Lens Secret Author"},
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
@@ -641,10 +629,9 @@ func TestFilterFamilyLens(t *testing.T) {
})
require.NoError(t, err)
}
_, err = setup.DBPool.Exec(ctx,
seedExec(t, setup.DBPool,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
require.NoError(t, err)
search := func(params url.Values) (int, []map[string]interface{}) {
return qfSearch(t, setup, setup.RegularToken, params)
+2 -2
View File
@@ -85,7 +85,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
}
}
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Authors: []string{"Suite Author"},
@@ -269,7 +269,7 @@ func (s *OwnershipAndAdminTestSuite) TestDelete_StorageDownRefused() {
})
require.NoError(s.T(), err)
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
item, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Offline Storage Item",
FilePath: "offline.epub",
+1 -1
View File
@@ -134,7 +134,7 @@ func TestManuallyEditedFilter(t *testing.T) {
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
createBook := func(path string) pgtype.UUID {
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libID,
Title: path,
Authors: []string{},
+1 -1
View File
@@ -48,7 +48,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
require.NoError(t, err, "Should create library")
// 4. Create media item
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
mediaItem, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Test Book for Queue",
Authors: []string{"Test Author"},
+283
View File
@@ -0,0 +1,283 @@
package main
// rls_visibility_test.go — the PERMANENT black-box auditor for per-user
// library visibility (the "family lens").
//
// The one sentence it asserts, in the user's language:
//
// A user with a library hidden sees NOTHING from that library, on
// ANY media-returning surface — while an admin still sees it (the
// hide is per-user, never global).
//
// Implementation-agnostic on purpose: it speaks only HTTP + response
// bodies, so it stays valid regardless of HOW enforcement is done
// (per-query visible_library_ids clauses today, Postgres RLS in the
// RLS rollout, anything tomorrow). It is the auditor that makes a
// forgotten surface a failing test instead of a silent leak.
//
// Red/green history: RED at introduction on the dashboard-section and
// collection-detail (mobile View All) surfaces, whose queries never
// carried the visible_library_ids clause; GREEN once enforcement
// covers every surface (plan:
// ~/.opencode/plan/server-rls-library-visibility.md).
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"bookhoard/internal/database"
)
// uuidInBody finds candidate collection ids in a dashboard-sections
// body so the View-All detail endpoint can be probed without coupling
// the test to unrelated ids in the body.
func TestRlsInvariant(t *testing.T) {
setup := setupTestServer(t)
ctx := context.Background()
adminToken := setup.Token
regularToken := setup.RegularToken
regularID := getRegularUserID(t, setup.DB)
// Unique markers: one for the hidden library's whole subtree
// (library name + both book titles — a single string that must
// appear NOWHERE), one for the visible control book.
hiddenMarker := fmt.Sprintf("RLSHIDE%s", uuid.New().String()[:8])
controlMarker := fmt.Sprintf("RLSCTRL%s", uuid.New().String()[:8])
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
require.NoError(t, err, "ebooks library type should exist")
createLib := func(name string) database.Libraries {
lib, cerr := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: name,
Description: pgtype.Text{String: "rls invariant test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
})
require.NoError(t, cerr, "create library %q", name)
// Deleting the library cascades its media_items AND its
// library_visibility rows (FK ON DELETE CASCADE) — one cleanup
// covers the whole subtree.
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib
}
libH := createLib("RLS Test Hidden Library " + hiddenMarker)
libV := createLib("RLS Test Visible Library " + controlMarker)
createBook := func(libraryID pgtype.UUID, title string) {
_, cerr := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: title,
Authors: []string{"RLS Invariant Author"},
FilePath: fmt.Sprintf("/tmp/rls-invariant/%s-%s.epub", title, uuid.New().String()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, cerr, "create book %q", title)
}
createBook(libH.ID, hiddenMarker+" Hidden Alpha")
createBook(libH.ID, hiddenMarker+" Hidden Beta")
createBook(libV.ID, controlMarker+" Visible Gamma")
// Hide libH from the REGULAR user — the same row the admin
// visibility UI writes (harness idiom: SetLibraryVisibility).
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
LibraryID: libH.ID,
IsVisible: false,
})
require.NoError(t, err, "hide libH from regular user")
// OPDS device owned by the REGULAR user (OPDS identity = device owner).
opdsToken := "dev_" + uuid.New().String()
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(regularID), Valid: true},
DeviceName: "RLS Invariant OPDS Device",
DeviceType: "koreader",
DeviceIdentifier: "rls-invariant-" + uuid.New().String()[:8],
AuthToken: opdsToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err, "create OPDS device for regular user")
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
rlsGet := func(token, path string) (int, string) {
req, _ := http.NewRequest(http.MethodGet, setup.Server.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err, "GET %s", path)
defer func() { _ = resp.Body.Close() }()
var sb strings.Builder
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
sb.Write(buf[:n])
}
if err != nil {
break
}
}
return resp.StatusCode, sb.String()
}
// Surfaces that render media TITLES (books appear by name).
titleSurfaces := []struct {
name string
path string
auth string
}{
{"media-items list", "/api/media-items?limit=500", regularToken},
{"dashboard sections", "/api/dashboard/sections?limit=500", regularToken},
{"opds catalog", "/opds/devices/" + device.ID.String() + "/catalog", opdsToken},
}
// Surfaces that return media-adjacent data (counts, series names,
// aggregates) — hidden-subtree leakage would show up as marker
// series names / inventory rows, not book titles.
aggregateSurfaces := []struct {
name string
path string
auth string
}{
{"collections list", "/api/collections", regularToken},
{"series list", "/api/series", regularToken},
{"stats summary", "/api/stats/summary", regularToken},
{"stats library-usage", "/api/stats/library-usage", regularToken},
}
t.Run("restricted_user_hidden_library_absent_everywhere", func(t *testing.T) {
for _, s := range titleSurfaces {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(s.auth, s.path)
require.Equal(t, http.StatusOK, code, "surface %s must be authorized (a 401 here is a harness bug, not a pass)", s.path)
// THE INVARIANT: nothing from the hidden subtree —
// neither its books nor the library itself.
assert.NotContains(t, body, hiddenMarker,
"LEAK: hidden library content surfaced on %s", s.path)
// Control: the visible library's book IS present —
// guards against false greens from empty/anonymous
// responses.
if s.name != "opds catalog" { // root catalog may nest books behind library subsections
assert.Contains(t, body, controlMarker,
"control book missing from %s — response is not listing media (false-green guard)", s.path)
}
})
}
for _, s := range aggregateSurfaces {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(s.auth, s.path)
require.Equal(t, http.StatusOK, code, "surface %s must be authorized", s.path)
assert.NotContains(t, body, hiddenMarker,
"LEAK: hidden library content surfaced on %s", s.path)
})
}
})
t.Run("restricted_user_dashboard_scoped_to_hidden_library", func(t *testing.T) {
code, body := rlsGet(regularToken, "/api/dashboard/sections?library_id="+uuid.UUID(libH.ID.Bytes).String())
assert.NotContains(t, body, hiddenMarker,
"LEAK: dashboard scoped to the hidden library leaked its content (status %d)", code)
})
t.Run("restricted_user_collection_detail_view_all", func(t *testing.T) {
// The mobile View All path: GET /api/collections/{id} for every
// collection the dashboard references (system sections included).
// Structured decode — probe exactly the collection ids the
// sections payload declares (its own contract field), not
// whatever UUIDs happen to appear in the body.
_, sectionsBody := rlsGet(regularToken, "/api/dashboard/sections?limit=500")
var sections struct {
Sections []struct {
CollectionID string `json:"collection_id"`
} `json:"sections"`
}
require.NoError(t, json.Unmarshal([]byte(sectionsBody), &sections),
"dashboard sections should decode; body: %s", sectionsBody)
seen := map[string]bool{}
for _, sec := range sections.Sections {
id := sec.CollectionID
if id == "" || seen[id] {
continue
}
seen[id] = true
code, body := rlsGet(regularToken, "/api/collections/"+id)
if code != http.StatusOK {
continue // not visible to this user — absence trivially holds
}
assert.NotContains(t, body, hiddenMarker,
"LEAK: collection detail %s (View All surface) returned hidden-library content", id)
}
})
t.Run("restricted_user_explicit_hidden_library_scoped_requests_rejected", func(t *testing.T) {
// Pinned contract (AGENTS.md family-lens rule): naming a hidden
// library explicitly is a 403, never a silent pass.
libID := uuid.UUID(libH.ID.Bytes).String()
for _, path := range []string{
"/api/media-items?library_id=" + libID,
"/api/series?library_id=" + libID,
"/api/series/books?library_id=" + libID + "&name=RLS",
} {
code, body := rlsGet(regularToken, path)
assert.Equal(t, http.StatusForbidden, code,
"scoped request %s must be 403 for a hidden library (got %d)", path, code)
assert.NotContains(t, body, hiddenMarker)
}
})
t.Run("admin_still_sees_hidden_library_user_scoped_not_global", func(t *testing.T) {
// The hide is PER-USER: an admin (no hidden rows) must still see
// everything. This catches "fixing" the leak by hiding the
// library globally.
for _, s := range []struct{ name, path string }{
{"media-items list", "/api/media-items?limit=500"},
{"dashboard sections", "/api/dashboard/sections?limit=500"},
} {
t.Run(s.name, func(t *testing.T) {
code, body := rlsGet(adminToken, s.path)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, hiddenMarker,
"admin must still see the hidden library on %s (hide is user-scoped)", s.path)
})
}
t.Run("opds catalog", func(t *testing.T) {
// OPDS identity = device owner; an ADMIN-owned device sees all.
adminDevToken := "dev_" + uuid.New().String()
adminID := getTestUserID(t, setup.DB)
adminDevice, derr := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgtype.UUID{Bytes: [16]byte(adminID), Valid: true},
DeviceName: "RLS Invariant Admin OPDS Device",
DeviceType: "koreader",
DeviceIdentifier: "rls-invariant-admin-" + uuid.New().String()[:8],
AuthToken: adminDevToken,
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 60, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, derr, "create OPDS device for admin")
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, adminDevice.ID) })
code, body := rlsGet(adminDevToken, "/opds/devices/"+adminDevice.ID.String()+"/catalog")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, hiddenMarker,
"admin's OPDS catalog must still expose the hidden library (user-scoped hide)")
})
})
}
+1 -1
View File
@@ -78,7 +78,7 @@ func TestScanStateBulkLoad(t *testing.T) {
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
apiStat, err := os.Stat(apiFile)
require.NoError(t, err)
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
created, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libID,
Title: "API Made Book",
Authors: []string{"API Author"},
+4 -6
View File
@@ -253,7 +253,7 @@ func TestReadingStatsSummary(t *testing.T) {
media, err = setup.DB.GetMediaItem(ctx, uuidToPGType(uuid.MustParse(reuseMediaID[0])))
require.NoError(t, err, "reuse seeded media item")
} else {
media, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err = setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(libUUID), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/stats-%s-%d.epub", title, time.Now().UnixNano()),
@@ -448,7 +448,7 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
@@ -526,7 +526,6 @@ func TestReadingStatsWeekLibraryScope(t *testing.T) {
func TestLibraryUsageStats(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
ebooksID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Ebooks %d", time.Now().UnixNano()), "ebooks"))
@@ -541,7 +540,7 @@ func TestLibraryUsageStats(t *testing.T) {
if durationSeconds > 0 {
audioMeta = []byte(fmt.Sprintf(`{"duration_seconds":%d,"chapters":[]}`, durationSeconds))
}
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
Authors: []string{author},
@@ -689,7 +688,6 @@ func TestLibraryUsageStats(t *testing.T) {
func TestLibraryUsageStats_Visibility(t *testing.T) {
setup := setupTestServer(t)
userID := getRegularUserID(t, setup.DB)
ctx := context.Background()
visibleID := uuid.MustParse(createLibraryDirect(t, setup.DB, userID,
fmt.Sprintf("Test Usage Visible %d", time.Now().UnixNano()), "ebooks"))
@@ -697,7 +695,7 @@ func TestLibraryUsageStats_Visibility(t *testing.T) {
fmt.Sprintf("Test Usage Hidden %d", time.Now().UnixNano()), "ebooks"))
seed := func(lib uuid.UUID, title string, size int64) {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
_, err := setup.DB.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
FilePath: fmt.Sprintf("/tmp/usage-vis-%s-%d.bin", title, time.Now().UnixNano()),
+9 -3
View File
@@ -3,6 +3,7 @@ package main
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"context"
"net/http"
"net/http/httptest"
@@ -10,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
@@ -26,11 +28,16 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
dbConfig, err := pgxpool.ParseConfig(dbURL)
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): see setupTestServer.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(ctx, dbConfig)
require.NoError(t, err, "Failed to connect to test database")
db := database.New(dbPool)
db := database.New(database.NewSeedDB(dbPool))
t.Cleanup(func() {
// Scoped wipes ONLY: reading_history now carries real captured
// sessions for the shared dev accounts, so a table-wide DELETE
@@ -90,9 +97,8 @@ func createSyncTestDevice(t *testing.T, db *database.Queries, userID pgtype.UUID
}
func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UUID) pgtype.UUID {
ctx := context.Background()
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
media, err := db.CreateMediaItem(seedCtx(), database.CreateMediaItemParams{
LibraryID: libraryID,
Title: "Sync Test Book",
Authors: []string{"Test Author"},
+54 -3
View File
@@ -6,6 +6,7 @@ import (
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/router"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
@@ -317,6 +318,37 @@ func createRegularUserOnce(t *testing.T, db *database.Queries) UserTestData {
}
}
// seedCtx is the context harness-level media seeding runs under. It
// carries rls.SeedIdentity — a valid UUID with NO library_visibility
// rows, so RLS grants it full visibility (needed because sqlc :one
// inserts use RETURNING, which requires SELECT visibility of the new
// row). setup.DB additionally falls back to this identity for calls
// that pass a bare context. Product code never uses this; requests
// carry real personas.
func seedCtx() context.Context {
return rls.WithUser(context.Background(), rls.SeedIdentity)
}
// seedExec runs a raw statement against the pool under the seed
// identity. The bare pool bypasses ScopedDB (nothing reads the ctx for
// the GUC), so governed tables need the identity declared manually
// inside a transaction.
func seedExec(t *testing.T, pool *pgxpool.Pool, sql string, args ...any) {
t.Helper()
ctx := rls.WithUser(context.Background(), rls.SeedIdentity)
tx, err := pool.Begin(ctx)
require.NoError(t, err)
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, rls.SeedIdentity.String()); err != nil {
_ = tx.Rollback(ctx)
require.NoError(t, err, "declare seed identity")
}
if _, err := tx.Exec(ctx, sql, args...); err != nil {
_ = tx.Rollback(ctx)
require.NoError(t, err, "seed exec")
}
require.NoError(t, tx.Commit(ctx))
}
// uuidToPGType converts uuid.UUID to pgtype.UUID
func uuidToPGType(u uuid.UUID) pgtype.UUID {
return pgtype.UUID{Bytes: [16]byte(u), Valid: true}
@@ -491,10 +523,29 @@ func setupTestServer(t *testing.T) *TestServerSetup {
dbConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL())
require.NoError(t, err, "Failed to parse database URL")
dbConfig.MaxConns = 1
// Harness-only session identity (plan §5): every connection in this
// pool runs as rls.SeedIdentity at SESSION scope, so raw-pool
// statements in tests (DBPool.Exec/QueryRow against governed tables)
// pass the read policy. No bleed risk: this pool never serves a
// second identity — A3 applies to the server's request pool.
dbConfig.BeforeAcquire = func(ctx context.Context, conn *pgx.Conn) bool {
_, err := conn.Exec(ctx, `SELECT set_config('app.user_id', $1, false)`, rls.SeedIdentity.String())
return err == nil
}
dbPool, err := pgxpool.NewWithConfig(context.Background(), dbConfig)
require.NoError(t, err, "Failed to connect to test database")
queries := database.New(dbPool)
// ScopedDB: the test server must behave EXACTLY like the production
// wiring (main.go) — every statement through Queries declares the
// acting user for RLS, including the search service's raw DBTX path.
// TestServerSetup.DB below is the SEED handle — also ScopedDB-wired
// (required: sqlc :one inserts use RETURNING, which evaluates the
// SELECT policy even on INSERT) — but its callers pass seedCtx(), a
// synthetic identity with no visibility rows, so seeding sees and
// writes everything. Real personas only exist at the HTTP layer.
scopedDB := database.NewScopedDB(dbPool)
queries := database.New(scopedDB)
seedDB := database.New(database.NewSeedDB(dbPool))
// Wire the tunable settings registry exactly like main.go: Load
// populates the cache (missing rows fall back to compiled defaults)
@@ -552,7 +603,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService)
seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
mediaHandler := handlers.NewMediaHandler(queries, scopedDB, libraryService, progressService, annotationService, worker)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS
@@ -756,7 +807,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// Create TestServerSetup struct with all resources
setup := &TestServerSetup{
Server: ts,
DB: queries,
DB: seedDB, // harness seeding/cleanup handle (see comment above)
DBPool: dbPool,
Config: cfg,
ConnManager: connManager,
+69
View File
@@ -1877,3 +1877,72 @@ CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
-- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
-- ============================================================
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
-- The policy joins library_visibility DIRECTLY — the same table the
-- visibility UI writes. The app declares the acting user per
-- transaction via the LOCAL GUC app.user_id (ScopedDB, set_config
-- is_local=true). Unset identity FAILS LOUD, never fails open:
-- - GUC never set in the session -> current_setting() raises
-- ("unrecognized configuration parameter")
-- - GUC set-then-transaction-ended -> reverts to '' -> the ::uuid
-- cast raises
-- - NULL GUC (unreachable via ScopedDB) -> IS NOT NULL guard ->
-- zero rows (fail-closed)
-- The runtime role bookhoard_srv OWNS these tables and is subject to
-- the policies via FORCE (z_rls_role.sh). Never connect the app as
-- the bootstrap superuser — superusers bypass RLS unconditionally.
-- IDEMPOTENT: schema.sql re-executes on EVERY app boot
-- (database.Initialize -> executeSchema); DROP .. IF EXISTS + CREATE
-- matches the repo's trigger idiom. DO NOT make this block
-- non-idempotent — the second boot would brick the app.
-- ============================================================
DROP POLICY IF EXISTS p_media_items_visible ON media_items;
DROP POLICY IF EXISTS p_media_items_insert ON media_items;
DROP POLICY IF EXISTS p_media_items_update ON media_items;
DROP POLICY IF EXISTS p_media_items_delete ON media_items;
ALTER TABLE media_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
-- READ policy: EXACTLY the app's COALESCE(lv.is_visible, true)
-- semantics — row absent -> visible; row true -> visible; row false
-- -> hidden. No new index needed: library_visibility's
-- UNIQUE(user_id, library_id) constraint already indexes the join.
CREATE POLICY p_media_items_visible ON media_items
FOR SELECT
USING (
current_setting('app.user_id') IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM library_visibility lv
WHERE lv.user_id = current_setting('app.user_id')::uuid
AND lv.library_id = media_items.library_id
AND lv.is_visible = false
)
);
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
-- lens. MANDATORY at ENABLE time — with RLS enabled and no applicable
-- policy, INSERT fails (42501) and UPDATE/DELETE silently affect zero
-- rows, bricking the media scanner. Per-command split is deliberate:
-- a single FOR ALL policy with USING (true) would OR into SELECT and
-- disable the read policy above. Writes still need SELECT visibility
-- of the target rows (the read policy governs that), which internal
-- identities satisfy (they have no hidden rows).
-- ON CONFLICT upserts (scanner: (library_id, file_path)) need the
-- conflicting row SELECT-visible to the acting identity — holds for
-- every internal identity by construction.
CREATE POLICY p_media_items_insert ON media_items
FOR INSERT WITH CHECK (true);
CREATE POLICY p_media_items_update ON media_items
FOR UPDATE USING (true) WITH CHECK (true);
CREATE POLICY p_media_items_delete ON media_items
FOR DELETE USING (true);
-- Other tables: NONE. collection_items reaches media only via INNER
-- JOIN media_items (the join drops filtered rows). collections
-- metadata (book_count) is a separate counts question — plan A13.
-- ============================================================
+4 -2
View File
@@ -22,10 +22,12 @@ services:
container_name: bookhoard_tests
environment:
# Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE) — same role the app service uses.
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard
COOKIE_SECURE: false
+59 -5
View File
@@ -11,9 +11,6 @@ services:
PGPORT: ${DB_PORT:-5432}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d
# Make other volumes as needed
- ./uploads:/app/uploads
ports:
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
@@ -26,6 +23,58 @@ services:
env_file:
- .env
# One-shot bootstrap: creates the bookhoard_srv runtime role and
# transfers object ownership to it (RLS prerequisite). Idempotent —
# safe on every startup; the app waits for its success. Passwords
# accept inline values or .env, like the rest of this file.
# NOTE: plpgsql $$ tags are written $$$$ — compose eats one dollar
# per pair before the container sees the SQL.
db-init:
image: postgres:15-alpine
container_name: bookhoard_db_init
environment:
PGHOST: db
PGPORT: ${DB_PORT:-5432}
PGUSER: postgres
PGDATABASE: bookhoard
PGPASSWORD: ${DBPASS}
DBSRVPASS: ${DBSRVPASS}
entrypoint: ["bash", "-c"]
command:
- |
set -e
: "${DBSRVPASS:?DBSRVPASS required in .env}"
psql -v ON_ERROR_STOP=1 -v role_pass="$DBSRVPASS" <<'EOSQL'
DO $$$$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bookhoard_srv') THEN
CREATE ROLE bookhoard_srv LOGIN;
END IF;
END $$$$;
ALTER ROLE bookhoard_srv LOGIN PASSWORD :'role_pass';
DO $$$$
DECLARE r RECORD;
BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER TABLE public.%I OWNER TO bookhoard_srv', r.tablename);
END LOOP;
FOR r IN SELECT sequencename FROM pg_sequences WHERE schemaname = 'public' LOOP
EXECUTE format('ALTER SEQUENCE public.%I OWNER TO bookhoard_srv', r.sequencename);
END LOOP;
FOR r IN SELECT p.oid::regprocedure AS fn FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'public' AND p.prokind = 'f' LOOP
EXECUTE format('ALTER FUNCTION %s OWNER TO bookhoard_srv', r.fn);
END LOOP;
END $$$$;
ALTER SCHEMA public OWNER TO bookhoard_srv;
ALTER DATABASE bookhoard OWNER TO bookhoard_srv;
EOSQL
depends_on:
db:
condition: service_healthy
restart: "no"
# Bookhoard Application
# In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
@@ -35,10 +84,13 @@ services:
restart: unless-stopped
environment:
# Database Configuration
# Runtime role = bookhoard_srv (non-superuser OWNER, subject to RLS
# via FORCE). POSTGRES_USER above is the bootstrap account ONLY —
# the app must NEVER connect as it (superusers bypass RLS).
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_USER: bookhoard_srv
DATABASE_PASSWORD: ${DBSRVPASS}
DATABASE_NAME: bookhoard
# Application Configuration
@@ -75,6 +127,8 @@ services:
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
+11
View File
@@ -432,3 +432,14 @@ library_name`.
partial (layout=dropdown on the same endpoint, HX-Request) instead
of the retired innerHTML renderer in web/src/search.ts — same
endpoint, same auth, content server-rendered.
## 2026-10-29 — RLS visibility: item-scoped gates may answer 404 as well as 403
With library visibility enforced by Postgres RLS (see
database/schema/schema.sql), a hidden library's rows are INVISIBLE to
the engine — item-scoped endpoints (chapters, tracks, download, detail)
can no longer run their Go-level 403 gate because the row never arrives.
Both answers are gated: 403 (Go-level explicit-library checks remain on
listing endpoints) and 404 (row-level invisibility — no existence leak).
Pins asserting 403 on item-scoped content endpoints widen to accept
{403, 404}. Listing/scoped endpoints keep the strict 403 contract.
+6
View File
@@ -171,6 +171,12 @@ type Querier interface {
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
// Device deletion (deregister on logout): detach the device's reading
// history instead of deleting it — these rows are real captured stats
// (the Users section reads them); reading_history.device_id is NO ACTION
// so a blind device delete 500s once any history anchors to the device.
// device_id is nullable: NULL keeps the session, loses the association.
DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error
// Device existence probe for capture: the web/app progress paths
// historically stuff the user id into DeviceID (it only fed a broadcast
// label before stats); a capture row must only reference a REAL device.
+14
View File
@@ -2362,6 +2362,20 @@ func (q *Queries) DeleteUserSystemCollection(ctx context.Context, arg DeleteUser
return err
}
const DetachReadingHistoryFromDevice = `-- name: DetachReadingHistoryFromDevice :exec
UPDATE reading_history SET device_id = NULL WHERE device_id = $1
`
// Device deletion (deregister on logout): detach the device's reading
// history instead of deleting it — these rows are real captured stats
// (the Users section reads them); reading_history.device_id is NO ACTION
// so a blind device delete 500s once any history anchors to the device.
// device_id is nullable: NULL keeps the session, loses the association.
func (q *Queries) DetachReadingHistoryFromDevice(ctx context.Context, deviceID pgtype.UUID) error {
_, err := q.db.Exec(ctx, DetachReadingHistoryFromDevice, deviceID)
return err
}
const DeviceExistsForUser = `-- name: DeviceExistsForUser :one
SELECT EXISTS(
SELECT 1 FROM devices
+8
View File
@@ -2809,6 +2809,14 @@ SET
updated_at = NOW()
WHERE id = $1;
-- name: DetachReadingHistoryFromDevice :exec
-- Device deletion (deregister on logout): detach the device's reading
-- history instead of deleting it — these rows are real captured stats
-- (the Users section reads them); reading_history.device_id is NO ACTION
-- so a blind device delete 500s once any history anchors to the device.
-- device_id is nullable: NULL keeps the session, loses the association.
UPDATE reading_history SET device_id = NULL WHERE device_id = $1;
-- name: DeleteDevice :exec
DELETE FROM devices WHERE id = $1;
+16
View File
@@ -178,6 +178,16 @@ func verifyFunctions(ctx context.Context, db *pgxpool.Pool, expectedFunctions []
return nil
}
// schemaMigrationIdentity is the synthetic identity the schema
// migration transaction declares for RLS (plan appendix A11). The
// schema's dedup DO block SELECTs/DELETEs media_items, and with the
// visibility policies live (FORCE applies to the owner) that SELECT
// needs app.user_id set even in this maintenance tx. The identity has
// NO library_visibility rows, so the policy grants it full visibility
// — appropriate for the server's own schema pass, and unreachable by
// requests (this tx only ever runs at startup, on the raw pool).
const schemaMigrationIdentity = "00000000-0000-0000-0000-000000000001"
// executeSchema runs the entire schema.sql in a single transaction
func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
tx, err := db.Begin(ctx)
@@ -186,6 +196,12 @@ func executeSchema(ctx context.Context, db *pgxpool.Pool) error {
}
defer tx.Rollback(ctx)
// Declare the migration identity BEFORE the file executes (A11):
// policy'd tables are queried by schema.sql itself on re-runs.
if _, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, schemaMigrationIdentity); err != nil {
return fmt.Errorf("schema execution failed (declare migration identity): %w", err)
}
_, err = tx.Exec(ctx, SchemaFile)
if err != nil {
log.Printf("HINT: Run manually to debug: psql -h localhost -U postgres -d bookhoard -f database/schema/schema.sql")
+163
View File
@@ -0,0 +1,163 @@
// ScopedDB wraps a pgx pool so every sqlc statement runs inside a
// transaction that declares the acting user via the LOCAL
// `app.user_id` GUC — the one thing the RLS policies
// (database/schema/schema.sql) need to filter media rows per user.
//
// Fail-loud lives in the ENGINE, not here: a context with no identity
// simply declares nothing, and any RLS-governed statement (media_items)
// then raises on the unset GUC — while ungoverned tables (auth lookups,
// settings, sessions) keep working, as they have no policy. That is the
// plan-C property: the database is the one door everything crosses; the
// decorator is only the identity declarer.
//
// SEMANTICS PRESERVED: every sqlc call was already a single
// auto-committed statement; one transaction per call keeps per-call
// atomicity identical. pgx v5 rows are bound to their transaction, so
// the transaction ends when the ROWS end: commit-on-Close for :many
// results, commit-on-Scan for :one results, plain commit for Exec.
//
// Rollout plan: ~/.opencode/plan/server-rls-library-visibility.md.
package database
import (
"context"
"fmt"
"log"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"bookhoard/internal/rls"
)
// Compile-pin: ScopedDB is a drop-in DBTX for database.New.
var _ DBTX = (*ScopedDB)(nil)
// ScopedDB implements DBTX over a pool, declaring the acting user for
// the RLS policies on every statement. Fallback (test harness ONLY —
// see NewSeedDB) supplies an identity when the context carries none.
type ScopedDB struct {
Pool *pgxpool.Pool
Fallback *pgtype.UUID
}
// NewScopedDB is the constructor used at the single Queries
// construction site (cmd/server/main.go).
func NewScopedDB(pool *pgxpool.Pool) *ScopedDB {
return &ScopedDB{Pool: pool}
}
// NewSeedDB returns a ScopedDB for TEST-HARNESS seeding/verification:
// when the context carries no identity it falls back to
// rls.SeedIdentity, so direct media reads/writes from helpers work
// without impersonating a real user. Never use in product wiring.
func NewSeedDB(pool *pgxpool.Pool) *ScopedDB {
seed := rls.SeedIdentity
return &ScopedDB{Pool: pool, Fallback: &seed}
}
const zeroUUID = "00000000-0000-0000-0000-000000000000"
// begin opens the per-statement transaction. If the context carries a
// user identity, the GUC is declared (LOCAL); if not, NOTHING is
// declared and the ENGINE decides: RLS-governed tables (media_items)
// raise on the unset GUC — fail-loud at the database, the layer plan C
// exists to enforce at — while RLS-ungoverned tables (users, settings,
// sessions, auth lookups pre-bridge) simply work, as they have no
// policy to evaluate. The decorator therefore never refuses: refusing
// here would re-create a convention gate in code, one more door future
// paths could forget — the engine is the one door everything crosses.
func (s *ScopedDB) begin(ctx context.Context) (pgx.Tx, error) {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return nil, err
}
uid, ok := rls.FromContext(ctx)
if (!ok || !uid.Valid || uid.String() == zeroUUID) && s.Fallback != nil {
uid, ok = *s.Fallback, true
}
if ok && uid.Valid && uid.String() != zeroUUID {
if err := rls.DeclareUser(ctx, tx, uid); err != nil {
_ = tx.Rollback(ctx)
return nil, fmt.Errorf("rls: declare user: %w", err)
}
}
return tx, nil
}
func (s *ScopedDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
tx, err := s.begin(ctx)
if err != nil {
return pgconn.CommandTag{}, err
}
// Rollback after a successful Commit is a no-op (ErrTxClosed ignored):
// this defer only acts on the error paths.
defer func() { _ = tx.Rollback(ctx) }()
tag, err := tx.Exec(ctx, sql, args...)
if err != nil {
return pgconn.CommandTag{}, err
}
return tag, tx.Commit(ctx)
}
func (s *ScopedDB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
tx, err := s.begin(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, sql, args...)
if err != nil {
_ = tx.Rollback(ctx)
return nil, err
}
return &scopedRows{Rows: rows, tx: tx, ctx: ctx}, nil
}
// scopedRows commits the transaction when the rows close — committing
// earlier would kill the rows mid-read. sqlc's :many path fully
// consumes rows then Close()s. pgx v5 Rows.Close returns nothing, so a
// commit error is logged, not propagated (a commit failure on a
// read-only transaction is log-worthy only).
type scopedRows struct {
pgx.Rows
tx pgx.Tx
ctx context.Context
}
func (r *scopedRows) Close() {
r.Rows.Close()
if err := r.tx.Commit(r.ctx); err != nil {
log.Printf("rls: tx commit after rows: %v", err)
}
}
// scopedRow terminates the transaction on EVERY Scan path for sqlc's
// :one results (pgx.Row is just { Scan(dest ...) error }).
type scopedRow struct {
row pgx.Row
tx pgx.Tx
ctx context.Context
}
func (r scopedRow) Scan(dest ...any) error {
err := r.row.Scan(dest...)
if err == nil {
return r.tx.Commit(r.ctx)
}
_ = r.tx.Rollback(r.ctx) // best effort; the tx ends either way
return err
}
type errRow struct{ err error }
func (r errRow) Scan(_ ...any) error { return r.err }
func (s *ScopedDB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
tx, err := s.begin(ctx)
if err != nil {
return errRow{err}
}
return scopedRow{row: tx.QueryRow(ctx, sql, args...), tx: tx, ctx: ctx}
}
+3 -4
View File
@@ -3,7 +3,6 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
"net/http"
@@ -102,7 +101,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
// "today") never appear.
endTime = endTime.AddDate(0, 0, 1).Add(-time.Nanosecond)
ctx := context.Background()
ctx := c.Request().Context()
history, err := h.db.GetUserReadingHistory(ctx, database.GetUserReadingHistoryParams{
UserID: user.ID,
@@ -220,7 +219,7 @@ func (h *AnalyticsHandler) calculateMostActiveDay(history []database.GetUserRead
func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
user := c.Get("user").(database.Users)
ctx := context.Background()
ctx := c.Request().Context()
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -268,7 +267,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
limitInt = parsedLimit
}
ctx := context.Background()
ctx := c.Request().Context()
books, err := h.db.GetPopularBooks(ctx, database.GetPopularBooksParams{
UserID: user.ID,
+19 -20
View File
@@ -80,7 +80,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
status = "unresolved"
}
ctx := context.Background()
ctx := c.Request().Context()
var conflicts interface{}
var err error
@@ -175,7 +175,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -187,7 +187,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), conflict.MediaItemID)
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), conflict.MediaItemID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get media item")
}
@@ -223,7 +223,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -263,13 +263,13 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
}
if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
appliedTo["progress"] = true
}
}
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
if err := h.applyAnnotationResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
appliedTo["annotations"] = true
}
}
@@ -283,7 +283,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "user_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
@@ -321,7 +321,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
// the service's LWW seed instead of being NULLed — that is the fix.
// chapter_progress keeps its stored value instead of being overwritten
// with the book-level percentage.
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
func (h *ConflictHandler) applyProgressResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
req := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
@@ -361,12 +361,11 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
req.TotalPages = &tp
}
_, err := h.progressSvc.SaveProgress(context.Background(), req)
_, err := h.progressSvc.SaveProgress(ctx, req)
return err
}
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
func (h *ConflictHandler) applyAnnotationResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" {
@@ -530,7 +529,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
@@ -542,7 +541,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied")
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete conflict")
}
@@ -552,7 +551,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
user := c.Get("user").(database.Users)
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
conflicts, err := h.db.ListConflictsByUser(c.Request().Context(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
}
@@ -560,7 +559,7 @@ func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
deleted := 0
for _, conflict := range conflicts {
if conflict.ResolutionStatus.String == "user_resolved" || conflict.ResolutionStatus.String == "bulk_resolved" {
if err := h.db.DeleteSyncConflict(context.Background(), conflict.ID); err == nil {
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflict.ID); err == nil {
deleted++
}
}
@@ -620,7 +619,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
@@ -702,7 +701,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue
}
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
if err := h.applyProgressResolution(c.Request().Context(), conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
@@ -719,7 +718,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
}
resolutionDataJSON, _ := json.Marshal(resolutionData)
_, err = h.db.ResolveSyncConflict(context.Background(), database.ResolveSyncConflictParams{
_, err = h.db.ResolveSyncConflict(c.Request().Context(), database.ResolveSyncConflictParams{
ID: conflictUUID,
ResolutionStatus: pgtype.Text{String: "bulk_resolved", Valid: true},
ResolutionData: resolutionDataJSON,
@@ -817,7 +816,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
}
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
conflict, err := h.db.GetSyncConflict(c.Request().Context(), conflictUUID)
if err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
@@ -838,7 +837,7 @@ func (h *ConflictHandler) BulkDismissConflicts(c *echo.Context) error {
continue
}
if err := h.db.DeleteSyncConflict(context.Background(), conflictUUID); err != nil {
if err := h.db.DeleteSyncConflict(c.Request().Context(), conflictUUID); err != nil {
results = append(results, ConflictResult{
ConflictID: conflictIDStr,
Status: "error",
+6
View File
@@ -433,6 +433,12 @@ func (h *DeviceHandler) DeleteDevice(c *echo.Context) error {
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
}
// Detach reading history first: the history FK to devices is NO
// ACTION on purpose (rows are real captured stats); NULLing the
// device association keeps the stats while freeing the device.
if err := h.db.DetachReadingHistoryFromDevice(c.Request().Context(), pgDeviceID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to detach device history"})
}
if err := h.db.DeleteDevice(c.Request().Context(), pgDeviceID); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete device"})
}
+8
View File
@@ -1,12 +1,15 @@
package handlers
import (
"context"
"net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
"github.com/jackc/pgx/v5/pgtype"
"bookhoard/internal/database"
"bookhoard/internal/rls"
"bookhoard/internal/services"
)
@@ -49,12 +52,17 @@ func (h *JobsHandler) CreateJob(c *echo.Context) error {
// Add database to params
req.Params["db"] = h.db
// RLS plan §6: the job's Context carries the requesting admin's
// identity — governed reads inside the job would raise otherwise.
userUUID, _ := uuid.Parse(c.Get("user_id").(string))
// Create job
job := &services.Job{
ID: uuid.New().String(),
Type: jobType,
Params: req.Params,
Status: services.JobStatusPending,
Context: rls.WithUser(context.Background(), pgtype.UUID{Bytes: userUUID, Valid: true}),
}
// Enqueue job
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context"
"encoding/json"
"net/http"
"strconv"
@@ -46,7 +45,7 @@ func (h *Handler) GetProgressHistory(c *echo.Context) error {
}
}
history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{
history, err := h.db.GetReadingHistory(c.Request().Context(), database.GetReadingHistoryParams{
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
Limit: int32(limit),
+8 -1
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"github.com/google/uuid"
"bookhoard/internal/rls"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -127,6 +129,11 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
jobID := uuid.New().String()
// RLS plan §6: the scan job's media reads run under the triggering
// admin's identity. h.ctx is a handler-lifetime Background context
// (outlives the request), so the identity is stamped explicitly —
// a bare context makes governed reads raise (placeholder reverting
// to '' on reused connections, or 42704 on fresh ones).
job := &services.Job{
ID: jobID,
Type: services.JobTypeScan,
@@ -140,7 +147,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
"trigger": "admin",
},
Status: services.JobStatusPending,
Context: h.ctx,
Context: rls.WithUser(h.ctx, pgtype.UUID{Bytes: userUUID, Valid: true}),
}
if err := h.worker.EnqueueJob(job); err != nil {
+7 -2
View File
@@ -11,6 +11,8 @@ import (
jwt "github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"bookhoard/internal/rls"
"github.com/gorilla/websocket"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
@@ -201,14 +203,17 @@ func (h *WSHandler) getInitialState(userID string) map[string]interface{} {
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
mediaItems, err := h.db.GetUserMediaItemsForSync(context.Background(), pgUserID)
// RLS plan §6: this read is the connection's OWN user's media —
// declare that identity explicitly (context.Background() carries
// nothing through ScopedDB, and media_items is RLS-governed).
mediaItems, err := h.db.GetUserMediaItemsForSync(rls.WithUser(context.Background(), pgUserID), pgUserID)
if err != nil {
return map[string]interface{}{"error": "failed to fetch initial state"}
}
progressMap := make(map[string]map[string]interface{})
for _, item := range mediaItems {
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{
progress, err := h.db.GetUniversalProgress(rls.WithUser(context.Background(), pgUserID), database.GetUniversalProgressParams{
MediaItemID: item.ID,
UserID: pgUserID,
})
+8
View File
@@ -2,6 +2,7 @@ package middleware
import (
"bookhoard/internal/database"
"bookhoard/internal/rls"
"context"
"net/http"
"strconv"
@@ -183,6 +184,13 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
c.Set("device_ctx", ctx)
c.Set("device_id", device.ID.Bytes)
// RLS bridge (plan §5.1/A9): device-authenticated requests
// (KOReader/Kobo sync) read media as the DEVICE OWNER — echo's
// c.Set is echo-local, so the stdlib request context needs the
// identity explicitly or every governed read raises.
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID, device.UserID)))
return next(c)
}
}
+63
View File
@@ -0,0 +1,63 @@
// Package rls carries the authenticated end user's identity through
// the stdlib context so the database layer can declare it to Postgres
// per transaction (the `app.user_id` GUC the RLS policies read — see
// database/schema/schema.sql and
// ~/.opencode/plan/server-rls-library-visibility.md).
//
// WHY A SEPARATE PACKAGE: Echo's c.Set/c.Get is echo-local storage and
// never reaches the context.Context handed to database calls; the auth
// middleware therefore bridges the user id into the request context
// here, and ScopedDB (internal/database) reads it back. A query whose
// context carries no identity is REFUSED (fail-loud) — a code path
// that forgets the middleware breaks visibly, it never leaks.
package rls
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type ctxKey int
// CtxKeyUserID is the context key the auth middleware bridge sets and
// ScopedDB reads. Typed key — no string-key collisions.
const CtxKeyUserID ctxKey = iota
// FromContext returns the authenticated end user's UUID.
// ok=false means the caller forgot the middleware bridge — the
// decorator will refuse the query loudly.
func FromContext(ctx context.Context) (pgtype.UUID, bool) {
uid, ok := ctx.Value(CtxKeyUserID).(pgtype.UUID)
if !ok || !uid.Valid {
return pgtype.UUID{}, false
}
return uid, true
}
// WithUser re-attaches an identity to a (possibly detached) context.
// Background jobs MUST capture the acting user's UUID from the request
// context BEFORE `go func` detaches — context.Background() carries
// nothing — and rebuild their job context with this.
func WithUser(ctx context.Context, uid pgtype.UUID) context.Context {
return context.WithValue(ctx, CtxKeyUserID, uid)
}
// SeedIdentity is the harness seeding identity (plan §5): a valid UUID
// with NO library_visibility rows, so RLS grants it full visibility.
// It exists so test seeding and direct DB verification can read and
// write governed tables without impersonating a real user. Product
// code never runs as it.
var SeedIdentity = pgtype.UUID{Bytes: [16]byte(uuid.MustParse("00000000-0000-0000-0000-000000000002")), Valid: true}
// DeclareUser sets the RLS identity GUC inside an ALREADY-OPEN
// transaction. The `true` argument is is_local: transaction-scoped,
// evaporating at COMMIT/ROLLBACK — NEVER session-scoped on a pooled
// connection (cross-request identity bleed). For the manual-tx paths
// the ScopedDB decorator cannot wrap.
func DeclareUser(ctx context.Context, tx pgx.Tx, uid pgtype.UUID) error {
_, err := tx.Exec(ctx, `SELECT set_config('app.user_id', $1, true)`, uid.String())
return err
}
+22 -1
View File
@@ -6,6 +6,7 @@ import (
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/rls"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/templates"
@@ -155,6 +156,12 @@ func createOptionalAuthMiddleware(cfg *Config) echo.MiddlewareFunc {
Timezone: liveUser.Timezone,
Theme: liveUser.Theme, // SSR body class; see the required middleware
})
// RLS bridge: echo's c.Set is echo-local and never reaches
// the stdlib context.Context the database layer receives —
// ScopedDB refuses media queries without this (plan §5.1).
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID,
pgtype.UUID{Bytes: userUUID, Valid: true})))
return next(c)
}
}
@@ -222,6 +229,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
// admin/docs theme flash.
Theme: liveUser.Theme,
})
// RLS bridge (same as the JWT path above): echo-local c.Set
// never reaches the stdlib context.Context the database
// layer receives — ScopedDB refuses media queries without it.
c.SetRequest(c.Request().WithContext(context.WithValue(
c.Request().Context(), rls.CtxKeyUserID,
pgtype.UUID{Bytes: userUUID, Valid: true})))
return nil
},
ErrorHandler: func(c *echo.Context, err error) error {
@@ -414,7 +427,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
// startup scans for disk I/O.
go func() {
time.Sleep(30 * time.Second)
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
// RLS plan §6: this self-heal sweeps ALL media — run it as the
// first admin (the scanner's documented fallback identity);
// a bare Background context makes governed reads raise.
adminID, err := cfg.Queries.GetFirstAdmin(context.Background())
if err != nil {
log.Printf("[HASH-BACKFILL] no admin found, skipping backfill: %v", err)
return
}
services.NewHashBackfillService(cfg.Queries).Run(rls.WithUser(context.Background(), adminID))
}()
// Register progress routes with actual handler
+20 -5
View File
@@ -43,6 +43,8 @@ import (
epub "github.com/ArcadiaLin/go-epub"
"github.com/fsnotify/fsnotify"
"github.com/google/uuid"
"bookhoard/internal/rls"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/nwaples/rardecode"
@@ -621,17 +623,23 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
folderPaths[i] = f.FolderPath
}
// The acting admin: the library owner, or the first admin as the
// documented fallback (existing pattern). Used BOTH for the job's
// UserID and — since RLS (plan §6) — as the identity the job's
// media reads run under via its Context.
adminIDStr := ""
if libRow.CreatedByAdminID.Valid {
adminIDStr = uuid.UUID(libRow.CreatedByAdminID.Bytes).String()
adminUUID := libRow.CreatedByAdminID
if adminUUID.Valid {
adminIDStr = uuid.UUID(adminUUID.Bytes).String()
}
if adminIDStr == "" {
if !adminUUID.Valid {
fmt.Printf("[MTIME-POLL] Library has no owner, falling back to first admin\n")
fallbackAdmin, err := s.db.GetFirstAdmin(context.Background())
if err != nil {
fmt.Printf("[MTIME-POLL] Warning: no admin found in database, skipping scan\n")
return
}
adminUUID = fallbackAdmin
adminIDStr = uuid.UUID(fallbackAdmin.Bytes).String()
}
@@ -650,7 +658,7 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
Type: JobTypeScan,
Status: JobStatusPending,
UserID: adminIDStr,
Context: context.Background(),
Context: rls.WithUser(context.Background(), adminUUID),
Params: map[string]any{
"library_id": libraryIDStr,
"folders": folderPaths,
@@ -667,6 +675,13 @@ func (s *MediaScanner) enqueueLibraryScan(rootFolder, trigger string) {
}
func (s *MediaScanner) ScanFolders(ctx context.Context) error {
// RLS plan §6: a scan is server maintenance over library media —
// self-declare the scanner's configured admin identity so internal
// reads work regardless of the caller's context (worker jobs carry
// one; test/direct callers may not). No-op when unconfigured.
if s.adminID.Valid {
ctx = rls.WithUser(ctx, s.adminID)
}
// Cancellation is checked at every stage boundary and inside the
// walk callback: the in-flight file finishes, then the scan aborts
// and reports context.Canceled (processJob classifies that as a
@@ -4771,7 +4786,7 @@ func (s *MediaScanner) performInitialScan(ctx context.Context) {
Type: JobTypeScan,
Status: JobStatusPending,
UserID: adminIDStr,
Context: context.Background(),
Context: rls.WithUser(ctx, s.adminID), // RLS plan §6: startup scans run as the configured admin
Params: map[string]any{
"library_id": libraryIDStr,
"folders": folderPaths,
+1 -1
View File
@@ -303,7 +303,7 @@ templ BookShelf(
</button>
</template>
</div>
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<div id="books-grid" class="grid grid-cols-3 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4">
@BooksGrid(books, limit, offset, count, currentLibraryID)
if errorMessage != "" {
<div class="col-span-full mt-2 p-4 rounded-xl border" style="background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);">
+1 -1
View File
@@ -406,7 +406,7 @@ func BookShelf(
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"filtersOpen === true && ensureReadyRule()\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-aware valueLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+1 -1
View File
@@ -170,7 +170,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
</div>
}
}
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
<div id="series-grid" class="grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
for _, series := range seriesList {
@SeriesCard(series)
}
+1 -1
View File
@@ -344,7 +344,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
}
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div id=\"series-grid\" class=\"grid grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
package-lock.json
test-results/
playwright-report/
+24
View File
@@ -0,0 +1,24 @@
# UI sweep coverage (plan §7.5)
Gate: every line below executes for BOTH personas unless marked
"admin-only". Status: ✅ swept+green · 🔲 pending (future sweep runs).
| # | Page / control | admin | restricted |
|---|---|---|---|
| 1 | Login: bad password rejected; good login lands authed; logout | ✅ | ✅ |
| 2 | Dashboard: sections render (Recently Added / Not Started) | ✅ | ✅ (hidden lib absent) |
| 3 | Dashboard: hidden library visibility (Audiobooks) | ✅ visible | ✅ absent |
| 4 | Search: results scoped (`/api/media-items/search`) | ✅ | ✅ no leaks |
| 5 | Media list: no hidden items in payloads | ✅ | ✅ |
| 6 | Library browse grid + pagination | 🔲 | 🔲 |
| 7 | Library create / edit / delete (admin) | 🔲 | n/a |
| 8 | Admin visibility toggle roundtrip (hide → restricted loses → unhide) | 🔲 | 🔲 |
| 9 | Media detail: edit metadata / archive / delete | 🔲 | n/a |
| 10 | Reader: open, page turn, TOC, bookmark, highlight, note, settings | 🔲 | 🔲 |
| 11 | Collections: CRUD, add/remove items, smart-rule editor | 🔲 | 🔲 |
| 12 | Series: list/detail | 🔲 | 🔲 |
| 13 | Stats screens + range filters | 🔲 | 🔲 |
| 14 | Admin: users CRUD, visibility matrix, settings toggles, processing issues | 🔲 | n/a |
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
+18
View File
@@ -0,0 +1,18 @@
# tests/ui — Playwright sweep (plan §7.5)
Real-browser sweep of the SSR/HTMX frontend. Dev server must be up on
localhost:8765. Run inside the official image (no host browsers needed):
```bash
cd tests/ui
docker run --rm --network host \
-v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.49.0 \
bash -c "npm install --no-audit --no-fund && npx playwright test"
```
Personas (see rls_sweep.spec.ts): `testuser` (admin) and `rls-phone`
(restricted — Audiobooks hidden from them).
Coverage: `COVERAGE.md` enumerates every swept page/control per the
plan; new pages/controls get a line when added.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "bookhoard-ui-sweep",
"private": true,
"version": "1.0.0",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.49.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test";
// Full-sweep config (plan §7.5). The dev server must be up on
// localhost:8765 (same precondition as the Go integration tests).
// Run via the official Playwright image — see tests/ui/README.md.
export default defineConfig({
testDir: ".",
timeout: 30_000,
retries: 0,
reporter: [["list"]],
use: {
baseURL: "http://localhost:8765",
trace: "retain-on-failure",
screenshot: "only-on-failure",
actionTimeout: 10_000,
},
});
+99
View File
@@ -0,0 +1,99 @@
import { expect, test, type Browser, type Page } from "@playwright/test";
// Persona helpers: the two dev personas used across the sweep.
// testuser = admin, sees everything.
// rls-phone = restricted user; the Audiobooks library is HIDDEN from
// them (seeded by the rollout; see the RLS plan §7.5).
//
// Sessions are established ONCE per persona and reused via
// storageState — the login endpoint rate-limits rapid attempts, and a
// sweep should not look like a brute-force run.
const PERSONAS = {
admin: { login: "testuser", password: "Test@Pass123!" },
restricted: { login: "rls-phone", password: "Test@Pass123!" },
} as const;
type Persona = keyof typeof PERSONAS;
const stateCache: Partial<Record<Persona, string>> = {};
async function authedPage(browser: Browser, persona: Persona): Promise<Page> {
if (!stateCache[persona]) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("/login");
await page.fill('input[name="login"]', PERSONAS[persona].login);
await page.fill('input[name="password"]', PERSONAS[persona].password);
await page.click('button[type="submit"]');
await page.waitForLoadState("networkidle");
stateCache[persona] = await ctx.storageState();
await ctx.close();
}
const ctx = await browser.newContext({ storageState: stateCache[persona] });
return ctx.newPage();
}
// The restricted persona must never see Audiobooks content anywhere.
const HIDDEN = "Audiobooks";
test.describe("authentication", () => {
test("wrong password is rejected without a session", async ({ page }) => {
await page.goto("/login");
await page.fill('input[name="login"]', PERSONAS.admin.login);
await page.fill('input[name="password"]', "definitely-wrong");
await page.click('button[type="submit"]');
await page.waitForTimeout(1_500);
const cookies = await page.context().cookies();
expect(cookies.find((c) => c.name === "token" && c.value.length > 10)).toBeUndefined();
});
});
test.describe("admin persona sees everything", () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await authedPage(browser, "admin");
});
test("dashboard sections render with library content", async () => {
await page.goto("/dashboard");
const body = (await page.textContent("body")) ?? "";
expect(body).toContain("Recently Added");
expect(body).toContain("Not Started");
expect(body).toContain(HIDDEN); // admin sees the hidden-from-others library
});
test("search returns hidden-library titles for admin", async () => {
const resp = await page.goto("/api/media-items/search?query=Witcher");
expect(resp?.status()).toBe(200);
const body = (await page.textContent("body")) ?? "";
expect(body).toContain("Witcher");
});
});
test.describe("restricted persona: hidden means hidden", () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await authedPage(browser, "restricted");
});
test("dashboard never shows the hidden library or its items", async () => {
await page.goto("/dashboard");
expect((await page.textContent("body")) ?? "").toContain("Recently Added"); // real dashboard, not the landing page
const body = (await page.textContent("body")) ?? "";
expect(body).not.toContain(HIDDEN);
});
test("search results exclude hidden-library content", async () => {
const resp = await page.goto("/api/media-items/search?query=Witcher");
expect(resp?.status()).toBe(200);
const body = (await page.textContent("body")) ?? "";
// The Witcher audiobooks live in the hidden library — none may leak.
expect(body).not.toContain("Witcher");
});
test("media payloads exclude hidden-library items", async () => {
const resp = await page.goto("/api/media-items?limit=200");
expect(resp?.status()).toBe(200);
const body = (await page.textContent("body")) ?? "";
expect(body).not.toContain(HIDDEN);
});
});
+1 -1
View File
@@ -1,2 +1,2 @@
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated