Architecture: Catalog, query guard & restricted content
The shape of the catalog
catalog_items is a deliberately thin, polymorphic core table — fields every media type shares. Type-specific data lives in satellite tables (a movie's runtime, a track's album, an episode's season/episode number), joined by id rather than crammed into one god-object table. Adding a new media type is additive: a new item_type enum value plus one satellite table, no changes to existing tables or queries — the direct payoff of choosing this shape over the inheritance-tree model Loombre's design explicitly reacts against (docs/PLAN.md's pain-point ledger, P1).
The guard: why an unfiltered query can't exist
every catalog read
│
▼
┌─────────────────────────────────────┐
│ packages/db/src/query/*.ts │
│ (getItemById, listItems, search, …) │
│ │
│ requires a ViewerContext: │
│ { userId, │
│ allowedLibraryIds, │
│ restrictedCleared } │
└──────────────────┬───────────────────┘
│ applyGuard(ctx)
▼
┌─────────────────────────────────────┐
│ guard.ts's predicate, appended to │
│ EVERY query — not called by each │
│ query author, applied once at the │
│ query-builder layer: │
│ library_id = ANY(allowedLibraryIds) │
│ AND (content_class = 'general' │
│ OR restrictedCleared) │
│ AND <missing-file visibility> │
└──────────────────┬───────────────────┘
│
▼
filtered rows only — no caller
can construct a query that skips
this step; guard.ts itself is not
exported from the package's public
barrel, only already-guarded query
functions are.Two structural facts make "forgot to filter" impossible rather than merely unlikely:
guard.tsisn't part of the public API ofpackages/db. Only functions that already callapplyGuard()(or its joined/people/tags variants) internally are exported — there is no raw query-builder handle a consumer package could obtain and misuse.- dependency-cruiser forbids the alternative entirely. No
pgorkyselyimport is allowed outsidepackages/db(ruleno-raw-db-driver-outside-packages-db), so bypassing the query layer by talking to Postgres directly fromapps/serverisn't a lint nitpick — it's a build failure. A companion rule restrictspg-bossitself topackages/jobs, and another restricts the guard-free@loombre/db/internalwriter surface (used by the scanner and job consumers, which need to write catalog rows without a viewer in the loop) toapps/worker,packages/jobs, andpackages/dbonly.
Restricted content
The restricted-content class is gated by five conditions that must ALL hold before a restricted row is ever sent to a client — server capability enabled, the user is an adult, the user has opted in with their own PIN, an explicit per-library permission grant, and a currently-unlocked session (re-verified on every request, never persisted across logins). Full detail in docs/PLAN.md §6.4 (internal spec — see this repo). The user-facing version of the same model, in plain language, is the User Guide's restricted content page; the admin-facing version is Users & permissions.