@murumets-ee/content-api 0.93.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +91 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +0 -0
- package/dist/index.mjs.map +1 -1
- package/dist/query-params.d.mts +20 -2
- package/dist/query-params.d.mts.map +1 -1
- package/dist/query-params.mjs +1 -1
- package/dist/query-params.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["blockTreeFieldsOf","sharedBlockTreeFieldsOf"],"sources":["../src/resolution-budget.ts","../src/blocks.ts","../src/detail-template.ts","../src/cache-tags.ts","../src/cache.ts","../src/cors.ts","../src/limits.ts","../src/query-params.ts","../src/media.ts","../src/references.ts","../src/response.ts","../src/schema-endpoint.ts","../src/handler.ts"],"sourcesContent":["/**\n * The per-document resolution budget (plan/api phase 05).\n *\n * Rendering ONE document can trigger four independent kinds of backend work:\n * `query()`-field resolutions, link resolutions, media resolutions and entity-\n * reference projections. Every one of them scales with the document's own\n * content — which an EDITOR controls, not an operator — and all four compete\n * for the same connection pool.\n *\n * **One budget, spanning all four. Not four caps.** A per-kind cap lets a\n * single document sum four separate ceilings into one much larger pool draw,\n * which is the same \"it's only N today\" defence CLAUDE.md's No-Unbounded-Fan-Out\n * section refuses. The kinds are recorded so a DROP can be attributed; they are\n * never separately bounded.\n *\n * **This is the COUNT half of the rule.** The concurrency half is\n * `@murumets-ee/core`'s `runBounded`, which caps how many of the granted\n * resolutions are in flight at once. Both are needed: a bound on peak\n * concurrency still lets an unbounded list through, just more slowly, and a\n * bound on count still lets a bounded list arrive as one burst.\n *\n * **Nothing here is silent.** Whatever the ceiling refuses is counted per kind\n * and reported by {@link ResolutionBudget.dropSummary}, which the handler logs.\n * CLAUDE.md is explicit that silent truncation reads as \"covered everything\" —\n * and phase 05's failure semantics make an unresolved target INDISTINGUISHABLE\n * from a deleted, unpublished or unviewable one ON THE WIRE, deliberately, so\n * an editor asking \"why is my related-article link gone?\" can only ever be\n * answered from the operator-facing log. That makes this log load-bearing\n * rather than nice-to-have.\n */\n\n/**\n * The kinds of per-document resolution work this budget spans.\n *\n * The union is DERIVED from the array, not declared beside it. Declared\n * separately, a fifth member added to the union but forgotten in the array\n * would still be accepted by `claim` and counted in `drops`, then silently\n * omitted from `dropSummary` — the exact silent truncation this module exists\n * to prevent, and the compiler would not have caught it, because a shorter\n * `readonly ResolutionKind[]` is a perfectly valid value of that type.\n */\nconst RESOLUTION_KINDS = ['reference', 'populate', 'media', 'block'] as const\n\nexport type ResolutionKind = (typeof RESOLUTION_KINDS)[number]\n\n/**\n * Absolute ceiling on the total resolutions ONE document may trigger, across\n * every kind. A host may lower it (see\n * `ContentApiConfig.maxResolutionsPerDocument`); nothing can raise it.\n *\n * 200 is calibrated against the shape of the work rather than against a\n * measurement. Be precise about that shape, because an authoritative-sounding\n * cost model that is wrong is worse than none:\n *\n * - A unit is at most one row in a BATCHED, keyed read — N references to the\n * same entity cost one query, not N.\n * - A REFERENCE unit additionally costs one routing-seam lookup, which in this\n * repo is a single-row `toolkit_paths` read per resolved row. So the\n * worst-case query count is roughly 2× the granted unit count, not 1×.\n * - `runBounded` holds peak concurrency at a small constant, so those lookups\n * arrive paced rather than as one burst.\n * - Every read on this path is on the read-only pool, which carries a\n * `statement_timeout` (PR 2) — including the storage lookup behind media,\n * which is why `StorageClient` keeps a separate read-only table client.\n *\n * A document needing more than 200 resolved targets to render is not a page, it\n * is an export — and the honest answer for one is a truncated render plus an\n * operator log, not an unbounded pool draw from an unauthenticated caller.\n */\nexport const MAX_RESOLUTIONS_PER_DOCUMENT = 200\n\n/**\n * A claim-based counter. Callers ask for `n` units BEFORE doing the work and\n * receive how many they may actually do.\n *\n * Claim-before, never report-after: a budget checked afterwards has already\n * paid for the overrun, which is a check that cannot fail in the direction it\n * is supposed to.\n */\nexport interface ResolutionBudget {\n /**\n * Claim up to `n` units for `kind`. Returns how many were GRANTED — `0`\n * through `n`. Anything short of `n` is recorded as a drop for that kind.\n *\n * A non-positive or non-integer `n` grants 0 and records nothing; callers\n * derive `n` from array lengths, so a defensive floor here beats a\n * `Math.max` at every call site.\n */\n claim(kind: ResolutionKind, n: number): number\n /** Units still available. */\n readonly remaining: number\n /** Units granted so far, across every kind. */\n readonly granted: number\n /** True when anything at all was refused — the log trigger. */\n readonly hasDrops: boolean\n /**\n * Per-kind refused counts, for the operator log. Only kinds that actually\n * lost something appear, so a log line names what was cut rather than\n * printing four zeroes.\n */\n dropSummary(): Partial<Record<ResolutionKind, number>>\n /** The ceiling this budget was built with — published in `_schema` (D008). */\n readonly ceiling: number\n}\n\n/**\n * Build a budget for ONE document's response.\n *\n * Per RESPONSE, not per process: the bound being enforced is \"what one request\n * costs\", and a process-lifetime counter would instead be a rate limit, which\n * is phase 09's (PR 6) and a different mechanism. Neither substitutes for the\n * other — the same split D007 made between `?offset=`'s ceiling and arrival\n * rate.\n */\nexport function createResolutionBudget(ceiling: number): ResolutionBudget {\n if (!Number.isInteger(ceiling) || ceiling < 0) {\n throw new Error(\n `content-api: resolution budget ceiling must be a non-negative integer, received ${String(ceiling)}.`,\n )\n }\n\n let remaining = ceiling\n let granted = 0\n const drops = new Map<ResolutionKind, number>()\n\n return {\n claim(kind: ResolutionKind, n: number): number {\n if (!Number.isInteger(n) || n <= 0) return 0\n const give = Math.min(n, remaining)\n remaining -= give\n granted += give\n const short = n - give\n if (short > 0) drops.set(kind, (drops.get(kind) ?? 0) + short)\n return give\n },\n get remaining(): number {\n return remaining\n },\n get granted(): number {\n return granted\n },\n get hasDrops(): boolean {\n return drops.size > 0\n },\n dropSummary(): Partial<Record<ResolutionKind, number>> {\n const out: Partial<Record<ResolutionKind, number>> = {}\n for (const kind of RESOLUTION_KINDS) {\n const count = drops.get(kind)\n if (count !== undefined && count > 0) out[kind] = count\n }\n return out\n },\n ceiling,\n }\n}\n\n/**\n * Refuse a host ceiling that would invert the bound rather than tighten it —\n * the same posture `assertPositiveCeiling` takes for `maxLimit`/`maxOffset`,\n * and for the same reason: a config mistake whose only symptom is \"resolution\n * quietly stopped working\" must be attributable to the config.\n *\n * `0` is ACCEPTED and coherent: it means \"resolve nothing on this deployment\",\n * which is a real posture (every reference, media field and block link comes\n * back unresolved, loudly logged) — unlike `maxLimit: 0`, which would mean\n * \"return nothing\" and only ever reads as an unset environment variable.\n */\nexport function resolveMaxResolutions(configured: number | undefined): number {\n if (configured === undefined) return MAX_RESOLUTIONS_PER_DOCUMENT\n if (!Number.isInteger(configured) || configured < 0) {\n throw new Error(\n 'content-api: `maxResolutionsPerDocument` must be an integer >= 0, received ' +\n `${String(configured)}. If this came from an environment variable, it is probably unset — ` +\n `omit the option to get the package default (${MAX_RESOLUTIONS_PER_DOCUMENT}).`,\n )\n }\n return Math.min(configured, MAX_RESOLUTIONS_PER_DOCUMENT)\n}\n","/**\n * `blockTree()` field resolution on the public surface (plan/api phase 05).\n *\n * A block tree is the one field type whose CONTENT can itself trigger backend\n * work: it embeds internal links (`LinkValue`s resolved through the routing\n * seam), `query()` fields (stored, field-author-bound queries executed against\n * an entity), and media refs. All three already have server-side resolvers in\n * `@murumets-ee/blocks`; what phase 05 adds is a caller and a bound.\n *\n * **The caller is a seam, not an import.** `content-api` is a leaf and `blocks`\n * depends back on it, so the resolver reaches this surface as\n * `ContentApiConfig.resolveBlocks`, exactly like `resolution`, `menus`,\n * `chrome` and `resolveMedia`.\n *\n * **The bound is the response's ONE budget, handed to the provider.** Block\n * content is not a fifth kind of work with a ceiling of its own: a document's\n * links and query fields compete for the same connection pool as its\n * references and media. Handing the provider the budget rather than a number\n * is what makes that true rather than merely intended — the provider claims\n * from the same counter the reference and media passes drew on, and whatever it\n * is refused lands in the same operator log.\n *\n * **The resolved wire FORMAT is now settled, and this is where it lives.**\n * Phase 05 deliberately refused to settle it, on the grounds that deciding a\n * format \"inside an implementation… is exactly how a wire format ends up being\n * whatever the first caller happened to emit\". That refusal was honoured: the\n * requirements were filed upstream (`plan/api` findings 069 + 075), `plan/api`\n * closed without scheduling them, and the format was then decided deliberately\n * — with a real consumer's requirements in hand — by `plan/frontend-delivery`\n * D019. It is published HERE, as a type, beside `MediaProjection` and the\n * reference projection, because this package owns the wire shapes.\n *\n * See {@link ResolvedBlockTree} for the format and the reasoning behind each\n * half of it.\n */\n\nimport type { Entity } from '@murumets-ee/entity'\nimport {\n blockTreeTemplateOf,\n blockTreeFieldsOf as sharedBlockTreeFieldsOf,\n} from '@murumets-ee/entity'\nimport type { CacheTagSink } from './cache-tags.js'\nimport { createResolutionBudget, type ResolutionBudget } from './resolution-budget.js'\n\n/**\n * One ref a resolver was asked for and could not deliver.\n *\n * **This channel is the whole reason in-place media resolution is safe here**\n * (D019, R022 §3). When a resolved value REPLACES a stored ref, \"I tried and\n * failed\" has nowhere left to go — every surveyed CMS that resolves in place\n * either goes silent (Sanity, Strapi), overloads `null` and documents it as a\n * hazard (Directus: \"you must treat `null` as both a value and an indication of\n * restricted permissions\"), or ships a type that is wrong in one of the two\n * states (Payload). Contentful is the only one that escapes it, and it escapes\n * by adding a SECOND, independent channel. This is that channel.\n *\n * `ref` is the media id for `kind: 'media'` and the `linkValueKey` for\n * `kind: 'link'`. Neither discloses anything new: both are values the consumer\n * already holds, because they came out of the stored tree it is being served.\n */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n /**\n * `'unresolvable'` — the target does not exist, is unpublished for this\n * caller, is trashed, or is not public. ONE bucket, several causes,\n * deliberately: phase 05's failure semantics do not let an anonymous consumer\n * learn which.\n *\n * `'budget'` — this response ran out of resolution budget before reaching it.\n * A fact about the RESPONSE, not about the target, so naming it separately\n * discloses nothing and is the difference between \"retry later\" and \"stop\n * asking\".\n */\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/**\n * A `blockTree` field, resolved — the format `plan/frontend-delivery` D019\n * settled after `plan/api` closed with findings 069 and 075 still open.\n *\n * **The envelope's PRESENCE is the marker** (F075 requirement 3). A stored tree\n * is an array; a resolved one is this object. `Array.isArray(value)` tells them\n * apart without inspecting contents, which is what stops \"resolution is not\n * wired on this deployment\" from looking like valid data. `resolved` versions\n * the format so later additions (`queries`, when something can render them) are\n * additive rather than breaking.\n *\n * **Nothing emits an empty envelope, with ONE named exception.** With no\n * provider wired the stored tree crosses unchanged — see\n * {@link resolveBlockTrees}. That is deliberate and it is Storyblok's\n * documented wart avoided: its responses carry `rels: []` even when resolution\n * was never requested, so empty-because-nothing-to-resolve is indistinguishable\n * from empty-because-not-asked.\n *\n * The exception is D010's third state: a provider that failed AND then failed\n * its I/O-free retry is served {@link EMPTY_RESOLVED_BLOCK_TREE}, loudly\n * logged. That is narrow by construction — by the time it is reached the\n * provider has failed a pure in-memory walk, so it is broken rather than\n * unlucky — and it does not reintroduce Storyblok's ambiguity, because the\n * ambiguity there was between \"not asked\" and \"nothing found\". This envelope\n * means neither: it means \"asked twice, answered neither time\", and the\n * operator log says so in those words.\n *\n * **Links are a keyed sidecar; media is replaced in place. The split is not\n * arbitrary — it is what each side of the contract already agreed on.**\n *\n * A `LinkValue` is an opaque pointer with no href field at all, and every\n * renderer already looks its href up by `linkValueKey` (`ResolvedLinksContext`,\n * `resolveLinkHref(link, resolved)`, `RichTextRenderer`'s `resolvedLinks`\n * prop). `blocks`' own `editor-state` route already serialises exactly this\n * `Record<string, string | null>` for its wire. Writing the href INTO the link\n * would also fight the security path: `asLinkValue` returns \"a fresh, minimal\n * `LinkValue` (extraneous keys dropped)\" and is the richtext sanitiser's\n * normaliser, so the href would be stripped by the code whose job is stripping.\n *\n * A media ref is NOT an opaque pointer: it is a whole `MediaRef` carrying a url\n * denormalised at write time, which every render reads directly as\n * `image.url`. That url has to be overwritten regardless — it is minted by\n * `StorageClient.getUrl`, which returns a SIGNED url for any non-public file,\n * and freezing one into content puts a bearer capability on a cacheable\n * anonymous response. So the sidecar's headline advantage (\"the stored tree\n * crosses byte-identical\") is not on offer for media, while its cost — every\n * render in every project stops reading `image.url` and consults a lookup —\n * very much is.\n */\n/**\n * One row a resolved block tree DEPENDS ON — named so a cache holding this\n * response can be evicted when that row changes.\n *\n * `entity` is `'media'` for an image the tree embeds, and the target's entity\n * name for an internal link. That is deliberately the SAME shape\n * `CacheTagSink.addDocument(entity, id)` takes, because the point of the field\n * is that neither consumer has to walk the tree again to learn what is in it.\n *\n * 🔒 **RESOLVED ROWS ONLY.** A ref appears here only if the provider actually\n * resolved it, which means it passed that provider's publish + permission gate.\n * An unpublished link target is reported through `unresolved` (which carries a\n * `linkValueKey`, a value the consumer already holds) and NEVER here — listing\n * it would disclose the id of a document this caller may not see. The same\n * rule `ResolveBlocks`' docblock puts on hrefs, applied to identity.\n *\n * There is no cap on the array. Both consumers of it are already bounded — the\n * `Cache-Tag` header by `MAX_CACHE_TAGS_PER_RESPONSE`, a frontend's own tag set\n * by its own cap — and it is bounded upstream anyway by the resolution budget\n * the provider claimed against.\n */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\nexport interface ResolvedBlockTree {\n /** Format marker and version. Its presence means resolution RAN. */\n readonly resolved: 1\n /**\n * The block tree, with each media ref replaced by a `MediaProjection` — or\n * by `null` when it did not resolve.\n *\n * **Replaced, never merged.** Falling back to the stored url for an id that\n * failed would preserve the leak exactly, because \"did not resolve\" and \"is\n * not public\" are the same branch in the shipped resolver. `null` is already\n * what every render treats as \"no image\".\n */\n readonly blocks: readonly unknown[]\n /**\n * `linkValueKey → href`. `null` means \"no usable link\" and the renderer emits\n * the text without an `<a>` — the contract `resolveLinkHref` already has.\n */\n readonly links: Readonly<Record<string, string | null>>\n /**\n * What could not be resolved. **Omitted entirely when nothing failed**, so\n * its presence is itself information (Contentful's rule for `errors[]`).\n */\n readonly unresolved?: readonly UnresolvedBlockRef[]\n /**\n * The rows this tree resolved against — see {@link BlockTreeRef}. **Omitted\n * when the tree references nothing**, matching `unresolved`.\n *\n * WHY IT EXISTS. Until this field, a resolved block tree was opaque to every\n * cache: `_schema` published `blockTreeContents` as an `uncovered`\n * dependency, meaning re-publishing an image did not evict the pages\n * embedding it — they served the old one until their TTL expired. The\n * resolver already knew every id (it had just looked each one up); the\n * information simply had nowhere to go. It goes here, and BOTH consumers read\n * the one field rather than each re-walking the tree: `content-api` mints its\n * own `Cache-Tag` entries from it, and a frontend mints whatever its own\n * purge vocabulary is.\n *\n * `resolved: 1` versions the format precisely so an addition like this is\n * additive; a consumer that does not read `refs` is unaffected.\n */\n readonly refs?: readonly BlockTreeRef[]\n /**\n * Collection block id → the rows it resolved to (editor PR53, D001's dynamic\n * half). **Omitted when the tree holds no collection**, matching `unresolved`\n * and `refs`.\n *\n * A keyed SIDECAR, for the reason `links` is one: a render reads rows by the\n * block's id (`CollectionRowsContext`), and writing them into the block's\n * props would put resolved, per-reader data into a value that otherwise\n * crosses as the author stored it. Each row carries only the slots the app\n * mapped — never the row — and exists because it passed the SAME whitelist ∧\n * `view` gate as any other read on this API ({@link ResolveBlocksOptions}).\n * Row ids are listed in `refs`; the entity is tagged as a collection\n * dependency by `resolveBlockTrees`.\n */\n readonly collections?: Readonly<Record<string, ResolvedCollection>>\n /**\n * What this document's live-bound Price fields resolved to (block-library\n * PR03 + F012) — structural mirror of `@murumets-ee/blocks`' `LiveValues`,\n * minus `asked` (canvas-merge bookkeeping with no reader on a one-shot\n * render). **Omitted when the site registers no live sources at all** —\n * matching `unresolved`/`refs`/`collections`'s own rule, and letting the\n * `Price` primitive tell \"this deployment wires no live values\" (absent)\n * from \"wired, but this source doesn't exist\" (`sources` present and\n * missing the key) exactly the way the canvas already does.\n *\n * `sources` is load-bearing on the SITE, not just the canvas: the `Price`\n * primitive checks `live.sources[declaredSource]` before it will draw\n * ANYTHING for a bound price (`rich-field-primitives-media.tsx`), so a\n * `live` sidecar carrying only `values` would make every live price on a\n * delivered page render as nothing.\n */\n readonly live?: ResolvedLiveValues\n /**\n * The site's currency (ISO 4217), from `blocksPlugin({ currency })`\n * (block-library F012). Independent of `live` — a plain, typed-in Price\n * needs it to show a currency symbol regardless of whether this tree binds\n * any live price — so it is present whenever the site configures one,\n * **never omitted on the \"nothing to resolve\" test** `unresolved`/`refs`\n * follow, because there is always something to say: draw it plain, or draw\n * it in this currency.\n */\n readonly currency?: string\n /**\n * The row this tree is drawn for, projected through its entity's detail\n * declaration (block-library PR16) — `blocksPlugin({ details })`. A price, a\n * list of facts, an HTML body the server has already sanitised (with its\n * headings), a third-party viewer's allow-listed reference, and the text\n * that stands in for the row's title and summary. Absent unless the app\n * declares a detail for the entity and the row has a value for a declared\n * part. Only DECLARED columns are projected — never the row.\n */\n readonly detail?: ResolvedDetail\n}\n\n/** One labelled value of a row's detail — structural mirror of a collection row's meta line. */\nexport interface ResolvedDetailFact {\n readonly key: string\n readonly label: string\n readonly value: string | number\n readonly format?: 'price'\n}\n\n/** See {@link ResolvedBlockTree.detail}. Structural mirror of `@murumets-ee/blocks`' `DocumentDetail`. */\nexport interface ResolvedDetail {\n readonly title?: string\n readonly text?: string\n readonly price?: { readonly amount: number; readonly compareAt?: number; readonly unit?: string }\n readonly facts?: readonly ResolvedDetailFact[]\n readonly body?: {\n readonly html: string\n readonly headings: readonly {\n readonly id: string\n readonly text: string\n readonly level: 2 | 3\n }[]\n }\n readonly embed?: { readonly provider: string; readonly reference: string }\n}\n\n/** Upper bounds on a `detail` sidecar — the producer's own caps, checked again here. */\nconst MAX_WIRE_DETAIL_FACTS = 12\nconst MAX_WIRE_DETAIL_HEADINGS = 100\nconst MAX_WIRE_DETAIL_HTML = 500_000\nconst MAX_WIRE_DETAIL_TEXT = 2_000\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Is `value` a well-formed `detail` sidecar? Shape and bounds only: whether\n * `body.html` is SAFE is the producer's job (it is the only thing that\n * sanitises) and the consuming render's tripwire — this guard cannot\n * re-sanitise without importing the sanitiser into a package that must not\n * depend on `@murumets-ee/blocks`.\n */\nfunction isResolvedDetail(value: unknown): value is ResolvedDetail {\n if (!isPlainObject(value)) return false\n const optionalText = (v: unknown): boolean =>\n v === undefined || (typeof v === 'string' && v.length <= MAX_WIRE_DETAIL_TEXT)\n const optionalAmount = (v: unknown): boolean =>\n v === undefined || (typeof v === 'number' && Number.isFinite(v) && v >= 0)\n if (!optionalText(value.title) || !optionalText(value.text)) return false\n const price = value.price\n if (price !== undefined) {\n if (!isPlainObject(price)) return false\n if (typeof price.amount !== 'number' || !Number.isFinite(price.amount) || price.amount < 0) {\n return false\n }\n if (!optionalAmount(price.compareAt) || !optionalText(price.unit)) return false\n }\n const facts = value.facts\n if (facts !== undefined) {\n if (!Array.isArray(facts) || facts.length > MAX_WIRE_DETAIL_FACTS) return false\n for (const fact of facts as readonly unknown[]) {\n if (!isPlainObject(fact)) return false\n if (typeof fact.key !== 'string' || typeof fact.label !== 'string') return false\n if (typeof fact.value !== 'string' && typeof fact.value !== 'number') return false\n if (fact.format !== undefined && fact.format !== 'price') return false\n }\n }\n const body = value.body\n if (body !== undefined) {\n if (!isPlainObject(body) || typeof body.html !== 'string') return false\n if (body.html.length > MAX_WIRE_DETAIL_HTML) return false\n if (!Array.isArray(body.headings) || body.headings.length > MAX_WIRE_DETAIL_HEADINGS) {\n return false\n }\n for (const heading of body.headings as readonly unknown[]) {\n if (!isPlainObject(heading)) return false\n if (typeof heading.id !== 'string' || typeof heading.text !== 'string') return false\n if (heading.level !== 2 && heading.level !== 3) return false\n }\n }\n const embed = value.embed\n if (embed !== undefined) {\n if (!isPlainObject(embed)) return false\n if (typeof embed.provider !== 'string' || typeof embed.reference !== 'string') return false\n }\n return true\n}\n\n/**\n * One live source's PUBLIC metadata — structural mirror of\n * `@murumets-ee/blocks`' `LiveSourceMeta`.\n */\nexport interface ResolvedLiveSourceMeta {\n readonly label: string\n readonly labelKey?: string\n /** The library fields the site granted this source, as `'<block>.<field>'`. */\n readonly fields?: readonly string[]\n /** What the source's values are — absent means `'price'` (block-library PR18). */\n readonly kind?: 'price' | 'text' | 'list'\n}\n\n/** One resolved live value — structural mirror of `@murumets-ee/blocks`' `LiveValue`. */\nexport interface ResolvedLiveValue {\n /**\n * Optional to MIRROR `@murumets-ee/blocks`' own `LiveValue` exactly (itself\n * `Pick<PriceValue, 'amount'|'compareAt'|'unit'>`), not because the wire\n * ever actually carries one without it: `readLiveAnswer` already drops any\n * answer with no amount before it reaches `LiveValues.values` at all. A\n * narrower (`amount: number`) type here would make the mirror a lie\n * `tsc` catches the moment a consumer assigns a real `LiveValues` to it.\n */\n readonly amount?: number\n readonly compareAt?: number\n readonly unit?: string\n /**\n * A live TEXT (block-library PR18) — one line of plain text a render draws\n * inside authored text. Text only: a consumer escapes it like any text and\n * never puts it in markup, a URL or an attribute.\n */\n readonly text?: string\n /** A live LIST (block-library PR18) — a few lines of plain text. */\n readonly items?: readonly string[]\n}\n\n/** What a document's live-bound Price fields resolved to. See {@link ResolvedBlockTree.live}. */\nexport interface ResolvedLiveValues {\n readonly sources: Readonly<Record<string, ResolvedLiveSourceMeta>>\n readonly values: Readonly<Record<string, Readonly<Record<string, ResolvedLiveValue>>>>\n}\n\n/**\n * Upper bounds on a `live` sidecar, checked by the guard — generous against\n * the producer's own caps (`MAX_LIVE_SOURCES_PER_DOCUMENT` = 8,\n * `MAX_LIVE_KEYS_PER_SOURCE` = 50 in `@murumets-ee/blocks`, not imported here:\n * this package is the leaf the blocks package depends ON), matching\n * `MAX_WIRE_COLLECTIONS`'s own \"reject a MALFORMED provider, don't\n * second-guess a policy this package does not own\" reasoning.\n */\nconst MAX_WIRE_LIVE_SOURCES = 32\nconst MAX_WIRE_LIVE_KEYS_PER_SOURCE = 200\n/**\n * A live text or list on the wire — generous against the producer's own bounds\n * (`MAX_LIVE_TEXT_LENGTH` = 200, `MAX_LIVE_LIST_ITEMS` = 25,\n * `MAX_LIVE_LIST_ITEM_LENGTH` = 300), for the same reason as the two above.\n * Counted in UTF-16 units, so a 300-character line of astral characters still\n * fits.\n */\nconst MAX_WIRE_LIVE_TEXT_LENGTH = 1200\nconst MAX_WIRE_LIVE_LIST_ITEMS = 100\nconst MAX_WIRE_LIVE_FIELDS = 64\n\n/** A string a live text or list item may be: bounded, and one line. */\nfunction isWireLiveLine(value: unknown): value is string {\n if (typeof value !== 'string' || value.length > MAX_WIRE_LIVE_TEXT_LENGTH) return false\n // No line break or other C0 control: the producer strips them, so one here\n // means the provider is not the producer this guard was written against.\n for (let i = 0; i < value.length; i++) if (value.charCodeAt(i) < 0x20) return false\n return true\n}\n\nfunction isResolvedLiveValues(value: unknown): value is ResolvedLiveValues {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const { sources, values } = value as Record<string, unknown>\n if (typeof sources !== 'object' || sources === null || Array.isArray(sources)) return false\n if (typeof values !== 'object' || values === null || Array.isArray(values)) return false\n const sourceEntries = Object.entries(sources as Record<string, unknown>)\n if (sourceEntries.length > MAX_WIRE_LIVE_SOURCES) return false\n for (const [, meta] of sourceEntries) {\n if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return false\n const m = meta as Record<string, unknown>\n if (typeof m.label !== 'string') return false\n if (m.labelKey !== undefined && typeof m.labelKey !== 'string') return false\n if (m.kind !== undefined && m.kind !== 'price' && m.kind !== 'text' && m.kind !== 'list') {\n return false\n }\n if (m.fields !== undefined) {\n if (!Array.isArray(m.fields) || m.fields.length > MAX_WIRE_LIVE_FIELDS) return false\n if (!(m.fields as readonly unknown[]).every((f) => typeof f === 'string')) return false\n }\n }\n const valueEntries = Object.entries(values as Record<string, unknown>)\n if (valueEntries.length > MAX_WIRE_LIVE_SOURCES) return false\n for (const [, bySource] of valueEntries) {\n if (typeof bySource !== 'object' || bySource === null || Array.isArray(bySource)) return false\n const items = Object.entries(bySource as Record<string, unknown>)\n if (items.length > MAX_WIRE_LIVE_KEYS_PER_SOURCE) return false\n for (const [, item] of items) {\n if (typeof item !== 'object' || item === null || Array.isArray(item)) return false\n const v = item as Record<string, unknown>\n // `Number.isFinite`, not `typeof === 'number'`: NaN/Infinity pass a bare\n // typeof check and would reach the wire as a \"valid\" price.\n if (v.amount !== undefined && (typeof v.amount !== 'number' || !Number.isFinite(v.amount)))\n return false\n if (\n v.compareAt !== undefined &&\n (typeof v.compareAt !== 'number' || !Number.isFinite(v.compareAt))\n )\n return false\n if (v.unit !== undefined && typeof v.unit !== 'string') return false\n if (v.text !== undefined && !isWireLiveLine(v.text)) return false\n if (v.items !== undefined) {\n if (!Array.isArray(v.items) || v.items.length > MAX_WIRE_LIVE_LIST_ITEMS) return false\n if (!(v.items as readonly unknown[]).every(isWireLiveLine)) return false\n }\n }\n }\n return true\n}\n\n/** One resolved collection block — the structural mirror of `@murumets-ee/blocks`' `CollectionResult`. */\nexport interface ResolvedCollection {\n readonly status: 'ok' | 'unset' | 'unavailable' | 'over-budget'\n readonly entity: string | null\n readonly rows: readonly ResolvedCollectionRow[]\n}\n\n/** One row, projected onto a card's slots — never the entity row itself. */\nexport interface ResolvedCollectionRow {\n readonly id: string\n readonly title: string | null\n readonly text: string | null\n readonly image: {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n } | null\n readonly href: string | null\n}\n\n/**\n * Upper bounds on a `collections` sidecar, checked by the guard. Generous\n * against the producer's own caps (six collections of at most twelve rows), so\n * the guard rejects a MALFORMED provider rather than second-guessing a policy it\n * does not own.\n */\nconst MAX_WIRE_COLLECTIONS = 64\nconst MAX_WIRE_COLLECTION_ROWS = 100\nconst COLLECTION_STATUSES: ReadonlySet<string> = new Set([\n 'ok',\n 'unset',\n 'unavailable',\n 'over-budget',\n])\n\nfunction isResolvedCollection(value: unknown): value is ResolvedCollection {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const entry = value as Record<string, unknown>\n if (typeof entry.status !== 'string' || !COLLECTION_STATUSES.has(entry.status)) return false\n // An empty name would mint a malformed `lumi:` cache tag — the refs guard\n // above refuses `''` for the same reason.\n if (entry.entity !== null && (typeof entry.entity !== 'string' || entry.entity === '')) {\n return false\n }\n if (!Array.isArray(entry.rows) || entry.rows.length > MAX_WIRE_COLLECTION_ROWS) return false\n // Status-specific invariants, which the producer keeps and a host-injected\n // provider might not: an `ok` result names its entity (or its rows reach the\n // wire with no cache tag, and a new matching row leaves the page stale); any\n // other status carries no rows; `unset` and `unavailable` name no entity.\n if (entry.status === 'ok' && entry.entity === null) return false\n if (entry.status !== 'ok' && entry.rows.length > 0) return false\n if ((entry.status === 'unset' || entry.status === 'unavailable') && entry.entity !== null) {\n return false\n }\n for (const row of entry.rows as readonly unknown[]) {\n if (typeof row !== 'object' || row === null || Array.isArray(row)) return false\n const r = row as Record<string, unknown>\n if (typeof r.id !== 'string' || r.id === '') return false\n for (const slot of [r.title, r.text, r.href]) {\n if (slot !== null && typeof slot !== 'string') return false\n }\n if (r.image !== null) {\n if (typeof r.image !== 'object' || Array.isArray(r.image)) return false\n // Every member the wire type declares, not just the url: an `alt: 7`\n // would otherwise cross under a type that promises text.\n const image = r.image as Record<string, unknown>\n if (typeof image.url !== 'string') return false\n if (image.alt !== null && typeof image.alt !== 'string') return false\n for (const size of [image.width, image.height]) {\n if (size !== null && typeof size !== 'number') return false\n }\n }\n }\n return true\n}\n\n/**\n * Host-injected block-tree resolver.\n *\n * Receives the STORED tree and returns the {@link ResolvedBlockTree} that\n * should cross the wire in its place. Called once per `blockTree` field per\n * row, and once per `_chrome` region.\n *\n * **SECURITY (CLAUDE.md #1).** Every read the provider performs must be\n * PUBLISHED-only and must honour the caller's permissions — a `query()` field\n * resolved through an `AdminClient` would surface drafts on the anonymous\n * surface, and an internal link resolved without a publish filter would leak an\n * unpublished document's URL. `content-api` cannot enforce either (the resolver\n * is injected), so the WIRING must guarantee both — the same posture, for the\n * same reason, as `ContentApiResolution.resolvePath`.\n *\n * `budget` is the response's ONE resolution budget. Claim BEFORE doing work,\n * and resolve only what was granted; whatever is refused is reported by the\n * handler's existing drop log.\n */\nexport type ResolveBlocks = (\n blocks: unknown,\n opts: ResolveBlocksOptions,\n) => Promise<ResolvedBlockTree>\n\n/**\n * How many COLLECTION blocks one response may resolve, across every document in\n * it (editor PR53).\n *\n * The per-document cap (`MAX_COLLECTIONS_PER_DOCUMENT`, six) bounds a page. It\n * does not bound a LIST response: a hundred rows carrying six collections each\n * is six hundred row queries on an anonymous endpoint, and the shared\n * resolution budget — which collections also claim from — would admit its\n * whole ceiling of them. Twelve covers a document's own tree, and a chrome\n * response's header and footer at six each. A list's collection work becomes\n * a constant rather than a multiple of its page size.\n *\n * ⚠️ It CAN bind a single-document response, and fails closed when it does:\n * `?populate=` targets resolve their trees BEFORE the root row's (references\n * project first) and share this allowance, as does an entity with more than\n * two `blockTree` fields. The collections past it resolve `over-budget` and\n * render empty, and `reportBudget` logs the refusal — a truncated page, never\n * an unbounded one.\n */\nexport const MAX_COLLECTIONS_PER_RESPONSE = 12\n\n/** A per-response collection counter. Both members are detachable closures. */\nexport interface CollectionAllowance {\n /** Take one slot; `false` once the ceiling is spent. */\n readonly claim: () => boolean\n /** How many claims were refused — reported by the handler, never silent. */\n readonly refusedCount: () => number\n}\n\nexport function createCollectionAllowance(ceiling: number): CollectionAllowance {\n if (!Number.isInteger(ceiling) || ceiling < 0) {\n throw new Error(\n `content-api: collection allowance must be a non-negative integer, received ${String(ceiling)}.`,\n )\n }\n let remaining = ceiling\n let refused = 0\n return {\n claim: () => {\n if (remaining === 0) {\n refused += 1\n return false\n }\n remaining -= 1\n return true\n },\n refusedCount: () => refused,\n }\n}\n\n/** What a {@link ResolveBlocks} provider is called with. */\nexport interface ResolveBlocksOptions {\n locale?: string | undefined\n budget: ResolutionBudget\n entity: string\n field: string\n /**\n * May THIS caller read rows of `entityName`? The whitelist ∧ `view` gate the\n * handler applies to every entity read, handed to the provider so a\n * COLLECTION block (editor PR53) cannot list an entity this API withholds.\n *\n * Optional, and absence means NO: a provider that is not told who may read\n * what must resolve no collection rows at all, rather than guess.\n */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /**\n * Take one slot from THIS response's collection allowance\n * ({@link MAX_COLLECTIONS_PER_RESPONSE}). Absent means NO, like\n * `canReadEntity`: a provider not told how many collections the response may\n * resolve resolves none.\n */\n claimCollection?: (() => boolean) | undefined\n /**\n * Set when the field is a CODED TEMPLATE — `field.blockTree({ template })`\n * (editor D017) — and names the template's root block.\n *\n * A templated document has no stored tree: its layout is rebuilt from the\n * block registry on every load, and its content lives in entity COLUMNS the\n * template's blocks BIND. So the provider is handed the template name instead\n * of a tree, builds the tree itself (this package cannot — it has no idea\n * what a block looks like), and serves it as the envelope's `blocks`.\n *\n * Absent for an ordinary tree, and the provider then resolves `blocks` as it\n * always has.\n */\n template?: string | undefined\n /**\n * The row this document's BOUND blocks read (frontend-delivery F109) — handed\n * over for every tree, and always when {@link template} is set.\n *\n * A bound rich-text column's link marks live HERE, not in any tree, so the\n * envelope's `links` sidecar never carried them — an internal link in a\n * recipe's body rendered as plain text. The provider resolves the links in\n * exactly the columns the template binds, in the SAME call and against the\n * SAME budget as the tree's own, so a document costs one batch per linked\n * entity type however many columns carry links — never one request per link.\n *\n * Read-only: the provider must not write to it. The row is the response being\n * built, and its columns cross the wire as the handler already projected them.\n */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n /**\n * The tree is a STORED DETAIL TEMPLATE drawn for `boundValues` (block-library\n * D009), not that row's own document. The provider must then project the\n * row's detail and NOTHING else of it: in particular it must not walk the\n * row's columns for links on the say-so of a bound block in the tree, since\n * the tree is editor-authored and the row is not the editor's to expose.\n *\n * ⚠️ A contract the provider has to honour: this package cannot enforce it.\n * `@murumets-ee/content`'s resolver does; a host-supplied one must too.\n */\n rowIsForeign?: boolean | undefined\n}\n\n/**\n * Is `value` a {@link ResolvedBlockTree}?\n *\n * A structural check on a host-injected return value, not a cast. It checks the\n * marker, the tree and the sidecar — and deliberately does NOT walk `blocks`\n * (that is the document's own content, of unbounded size, and validating it\n * here would duplicate the consumer-side validator that already exists for the\n * unresolved case).\n *\n * **The sidecar's VALUES are checked, and the unbounded-content argument above\n * does not excuse skipping them.** `links` is bounded by the link count the\n * provider already claimed against the budget, so walking it costs what the\n * response has already paid for. Without this a provider returning a nested\n * object or a number where an href belongs passes the guard and the value is\n * serialised straight onto the wire. The Astro decoder defends itself with its\n * own check, so this is defence in depth rather than a live hole — but it is\n * the one field this package can cheaply own, and owning it here means a\n * consumer that is NOT ours inherits the guarantee too.\n *\n * The same argument covers `unresolved`, which is bounded by the same claim.\n *\n * TYPE only, deliberately — not scheme policy. A host may legitimately emit any\n * scheme its own consumers accept, and `sanitizeHref` at the consumer's process\n * boundary is where that decision belongs. Two places deciding it is how they\n * come to disagree.\n */\nexport function isResolvedBlockTree(value: unknown): value is ResolvedBlockTree {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const candidate = value as Record<string, unknown>\n if (candidate.resolved !== 1) return false\n if (!Array.isArray(candidate.blocks)) return false\n const links = candidate.links\n if (typeof links !== 'object' || links === null || Array.isArray(links)) return false\n for (const href of Object.values(links as Record<string, unknown>)) {\n if (href !== null && typeof href !== 'string') return false\n }\n // `refs` gets the same treatment for the same reason, and it has one more:\n // its values are handed straight to a cache-tag minter, so a non-string\n // reaching that would be serialised into a header — or, on the frontend\n // side, into a purge key that can never match anything.\n // Collected as they are validated, so the `collections` check below can\n // require that every row it lists is named here too.\n const refKeys = new Set<string>()\n const refs = candidate.refs\n if (refs !== undefined) {\n if (!Array.isArray(refs)) return false\n for (const ref of refs as unknown[]) {\n // `Array.isArray` is not decoration: `typeof [] === 'object'` and an\n // array can carry `entity`/`id` as properties, so without it a\n // `[]`-with-attached-props passes here — and then JSON.stringify drops\n // those properties, putting `[]` on the wire. This end would have minted\n // `lumi:media:<id>` from the in-memory object while the consumer's own\n // `isRecord` check discarded the entry, so the two ends would silently\n // disagree about what the page references. `isUnresolvedBlockRef` two\n // functions down already guards it; this was the odd one out.\n if (typeof ref !== 'object' || ref === null || Array.isArray(ref)) return false\n const entry = ref as Record<string, unknown>\n if (typeof entry.entity !== 'string' || entry.entity === '') return false\n if (typeof entry.id !== 'string' || entry.id === '') return false\n refKeys.add(`${entry.entity}\u0000${entry.id}`)\n }\n }\n // `unresolved` is bounded by the SAME claim `links` is, so the argument that\n // justified checking one justifies checking the other — leaving it out was an\n // inconsistency, not a policy. Unchecked, a provider returning\n // `unresolved: 'oops'` passed the guard and was serialised under a field the\n // published type declares as `readonly UnresolvedBlockRef[]`.\n // `collections` (editor PR53) — every entry checked, for the reason `refs`\n // is: its values are serialised straight onto the wire, and a slot that is not\n // a string would put something that is not text where a card renders text.\n const collections = candidate.collections\n if (collections !== undefined) {\n if (typeof collections !== 'object' || collections === null || Array.isArray(collections)) {\n return false\n }\n const entries = Object.entries(collections as Record<string, unknown>)\n if (entries.length > MAX_WIRE_COLLECTIONS) return false\n for (const [blockId, entry] of entries) {\n // Keyed by block id; an empty key is a result no block can find, so the\n // collection it was meant for would render empty in silence.\n if (blockId === '') return false\n if (!isResolvedCollection(entry)) return false\n // Every listed row must also be a REF. The collection tag covers \"a row\n // was added or removed\", not \"this row changed\" — so a row the envelope\n // lists without naming it in `refs` mints no document tag, and editing it\n // leaves the cached page as it was (CodeRabbit review, PR53).\n if (entry.status !== 'ok' || entry.entity === null) continue\n for (const row of entry.rows) {\n if (!refKeys.has(`${entry.entity}\u0000${row.id}`)) return false\n }\n }\n }\n const unresolved = candidate.unresolved\n if (unresolved !== undefined) {\n if (!Array.isArray(unresolved)) return false\n for (const entry of unresolved as readonly unknown[]) {\n if (!isUnresolvedBlockRef(entry)) return false\n }\n }\n // block-library F012 — same reasoning as `collections`: a malformed `live`\n // sidecar would otherwise serialise a value where a `Price` primitive\n // expects `amount: number`.\n if (candidate.live !== undefined && !isResolvedLiveValues(candidate.live)) return false\n if (candidate.currency !== undefined && typeof candidate.currency !== 'string') return false\n // block-library PR16 — same reasoning: a malformed `detail` would put a\n // value of the wrong shape where a render expects a number or a string.\n if (candidate.detail !== undefined && !isResolvedDetail(candidate.detail)) return false\n return true\n}\n\nfunction isUnresolvedBlockRef(value: unknown): value is UnresolvedBlockRef {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const entry = value as Record<string, unknown>\n if (entry.kind !== 'media' && entry.kind !== 'link') return false\n if (typeof entry.ref !== 'string') return false\n return entry.reason === 'unresolvable' || entry.reason === 'budget'\n}\n\n/**\n * The declared, non-internal columns of an entity that hold a BLOCK TREE.\n *\n * The public API must never project an internal column, so this narrows\n * `@murumets-ee/entity`'s shared lookup rather than re-deriving it — the\n * `internal` flag rides on each ref for exactly this caller.\n *\n * ⚠️ **Both kinds, deliberately.** Since `field.json({ blockTreeShape: true })`\n * existed this also returns those columns, so a JSON column holding a block\n * tree is block-reference-resolved on a public read where before it crossed\n * untouched. That is the wanted direction and not an accident of the shared\n * lookup: the resolver is what replaces author-time media URLs, and\n * `resolveBlockTrees`' own comment calls skipping it a security hole rather\n * than a saving. Chrome is exactly the content most likely to carry a logo.\n *\n * It is bounded by the same `ResolutionBudget` as every other field, and\n * `/v1/_chrome` is unaffected — that path goes through\n * `@murumets-ee/content`'s chrome provider, not this one.\n *\n * If a future column should be validated as a tree but NOT resolved, filter on\n * `ref.canvas` here; the discriminator exists for that.\n */\nexport function blockTreeFieldsOf(entity: Entity): string[] {\n return sharedBlockTreeFieldsOf(entity)\n .filter((ref) => !ref.internal)\n .map((ref) => ref.name)\n}\n\nexport interface BlockResolutionDeps {\n budget: ResolutionBudget\n locale?: string | undefined\n resolveBlocks?: ResolveBlocks | undefined\n /** Forwarded to the provider — see {@link ResolveBlocksOptions.canReadEntity}. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /**\n * ONE per response, shared by every tree it resolves — see\n * {@link MAX_COLLECTIONS_PER_RESPONSE}. Its `claim` is what the provider\n * receives as {@link ResolveBlocksOptions.claimCollection}.\n */\n collectionAllowance?: CollectionAllowance | undefined\n /**\n * Where a resolved tree's {@link BlockTreeRef}s are recorded. Optional so a\n * caller resolving trees outside a request (a test, a build step) need not\n * invent one; when it is absent the refs still cross on the envelope.\n */\n cacheTags?: CacheTagSink | undefined\n logger?:\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined\n}\n\n/**\n * The envelope served when a provider failed twice — D010's THIRD state.\n *\n * Reachable only after the I/O-free retry below has ALSO failed, which means\n * the provider could not complete a pure in-memory walk. That is a broken\n * provider rather than an unlucky one, and blanking a region is the\n * security-correct direction for a component that cannot be trusted to have\n * stripped anything.\n *\n * It carries no `unresolved` and no `refs`, and neither omission is laziness:\n * `ref` is defined as a real media id or `linkValueKey`, and enumerating those\n * means walking the tree — the exact work this package cannot do, by its own\n * comment below (\"it has no idea what a block looks like\"). A synthetic entry\n * would lie about a published contract, and an empty `unresolved: []` would\n * claim nothing failed. Saying nothing is the only honest option here; the\n * operator log carries what the wire cannot.\n *\n * FROZEN, and shared across responses on purpose. Every consumer of it either\n * serialises it or copies out of it (`handler.ts` rebuilds its region field by\n * field), so one value is safe — and freezing means a future consumer that\n * tries to mutate it fails loudly in a test rather than corrupting the next\n * response.\n */\nexport const EMPTY_RESOLVED_BLOCK_TREE: ResolvedBlockTree = Object.freeze({\n resolved: 1,\n blocks: Object.freeze([]),\n links: Object.freeze({}),\n})\n\n/** How one `resolveBlocks` call failed. */\nexport type BlockResolutionFailure = 'threw' | 'malformed'\n\n/**\n * What one field's (or one chrome region's) resolution ended up being, after\n * D010's retry policy ran.\n *\n * `envelope` is ALWAYS an envelope and never the stored tree — that is the\n * whole of F056. The two failure fields are for the operator log, and their\n * combination is what distinguishes the three outcomes:\n *\n * | `failure` | `retryFailure` | what happened |\n * |-----------|----------------|------------------------------------------------|\n * | absent | absent | resolved first time |\n * | set | absent | failed, then the I/O-free retry stripped it |\n * | set | set | failed twice — `envelope` is the empty one |\n */\nexport interface BlockResolutionOutcome {\n /** The envelope to serve. Never the stored tree. */\n readonly envelope: ResolvedBlockTree\n /** How the FIRST call failed. Absent when it succeeded. */\n readonly failure?: BlockResolutionFailure\n /** How the RETRY failed. Absent unless `envelope` is the empty one. */\n readonly retryFailure?: BlockResolutionFailure\n /** The first thrown value, for the operator log. */\n readonly error?: unknown\n}\n\n/**\n * An EXPLICIT boolean discriminant, not a presence check.\n *\n * `'envelope' in first` walks the prototype chain, so `Object.prototype.envelope`\n * pollution makes the failure variant read as a success and hands the polluted\n * value straight to `row[field]`, past `isResolvedBlockTree` — the one guard\n * between a host-injected value and the wire. `ok` is always an OWN property of\n * the literals below, so the prototype is never consulted. (`Object.hasOwn`\n * would be equally safe at runtime but narrows nothing, which is how this was\n * written as an `in` check in the first place.)\n */\ntype Attempt =\n | { readonly ok: true; readonly envelope: ResolvedBlockTree }\n | { readonly ok: false; readonly failure: BlockResolutionFailure; readonly error?: unknown }\n\nasync function attemptResolve(\n resolve: ResolveBlocks,\n stored: unknown,\n opts: ResolveBlocksOptions,\n): Promise<Attempt> {\n try {\n // Each attempt gets its OWN deep copy of the row. The provider is\n // host-injected: a nested write (a doc column's `content` array) through a\n // shallow copy would reach the row this handler serves, and the retry after\n // a failed first attempt would read what that attempt left behind. A DB row\n // is plain data, so the clone cannot throw on it; if it ever did, it throws\n // inside this `try` and degrades like any provider failure.\n const attemptOpts =\n opts.boundValues === undefined\n ? opts\n : { ...opts, boundValues: structuredClone(opts.boundValues) }\n const envelope = await resolve(stored, attemptOpts)\n // Shape-checked, not trusted. The provider is host-injected, so its return\n // value is not this package's data — a malformed one must be treated\n // exactly like a throw, and never put a shape on the wire that `_schema`\n // does not describe.\n if (!isResolvedBlockTree(envelope)) return { ok: false, failure: 'malformed' }\n return { ok: true, envelope }\n } catch (error) {\n return { ok: false, failure: 'threw', error }\n }\n}\n\n/**\n * Resolve ONE stored block tree into an envelope — never into the stored tree\n * (D010, closing F056).\n *\n * 🔴 **WHY A SECOND CALL RATHER THAN A FALLBACK.** Both failure branches used\n * to leave the STORED tree in place, on the reasoning that a broken link\n * resolver must degrade to unlinked text rather than to a 500 on every page\n * that has content. That reasoning is still right; the fallback it chose was\n * not. The stored tree is the value carrying the author-time media urls\n * `frontend-delivery` D019 exists to replace — signed urls, on a deployment\n * whose media is private, on a cacheable anonymous response. The comment four\n * frames down calls skipping the resolver on a SPENT budget \"a SECURITY hole,\n * not a saving\" for exactly that reason, and a throw does not make the served\n * bytes any safer than a skip did.\n *\n * The fix is not for this package to build a safe envelope: it cannot. `ref` is\n * a real media id or `linkValueKey`, and enumerating them means walking a tree\n * this package has no vocabulary for. So the work goes back to the component\n * that HAS the vocabulary — the provider — under a budget that makes the call\n * safe to repeat.\n *\n * **The retry is I/O-free, and that is a checked property of the shipped\n * provider rather than a hope** (R034). `resolveBlockTree` gates every read\n * behind a grant: `coveredMedia`/`coveredTargets` are empty slices at a zero\n * grant, `resolveMediaBatch` and `resolveEntityPaths` both return early on an\n * empty input, and the remaining `resolveLinks` pass is pure map lookups. What\n * still runs is the walk that REPLACES every media ref with `null` — which is\n * precisely the outcome wanted.\n *\n * ⚠️ **Be honest about WHOSE failures the middle state actually rescues.** The\n * obvious causes — a saturated read pool, a dropped connection, a throwing\n * media resolver — are exactly the ones the SHIPPED provider never surfaces\n * here: `resolveMediaBatch` and `resolveEntityPaths` each catch their own I/O\n * and return an empty result. What reaches this `catch` from\n * `@murumets-ee/content` is a DETERMINISTIC throw (a non-array stored column;\n * a failure constructing the app), which by definition recurs on the retry. So\n * with the shipped wiring the practical path is failure → blank, and the\n * middle state is for a THIRD-PARTY provider whose throws are conditional on\n * having work to do. That does not weaken the argument for retrying — the\n * retry costs one in-memory walk and is the difference between blanking a\n * field and blanking it while ALSO having tried — but a reader should not\n * expect \"images gone, page intact\" to be the common outcome on this\n * deployment. It is the uncommon one.\n *\n * **A FRESH zero-ceiling budget, not the request's.** `createResolutionBudget`\n * documents `0` as coherent (\"resolve nothing on this deployment\"). A fresh one\n * cannot draw down the response's real ceiling, which is what keeps this\n * bounded — and it keeps the retry's own refusals out of the response's\n * `dropSummary`, where they would read as \"a ceiling refused this\" and send an\n * operator to raise a limit that was never involved. `block-resolver.ts` fixed\n * exactly that mis-attribution once already for its no-media-resolver path.\n *\n * ⚠️ **That saving has a cost, and it lands on the WIRE.** The shipped provider\n * derives `UnresolvedBlockRef.reason` from its own grant, so on the retry every\n * LINK comes back `reason: 'budget'`, as does every media ref on a deployment\n * that wires a media resolver (it skips the claim entirely when it does not,\n * so those stay `'unresolvable'`) — while `dropSummary` shows no block drops\n * at all. The two ends of the contract then disagree, and `'budget'`\n * published to a consumer means \"retry later\", which is advice about a\n * response and not about a resolver that is broken. `'unresolvable'` would be\n * worse (it is a claim about the TARGET, and false), so this is the lesser of\n * two wrong answers rather than a right one. Reaching a right one means the\n * provider distinguishing a zero-CEILING budget from an exhausted shared one,\n * which is a change to a published enum's meaning and belongs in its own PR.\n *\n * **Bounded**, per CLAUDE.md's no-unbounded-fan-out rule: at most ONE extra\n * call per failing field, in memory, only on a path that has already failed.\n * Not a retry loop, and not a schedule.\n */\nexport async function resolveOneBlockTree(\n resolve: ResolveBlocks,\n stored: unknown,\n opts: ResolveBlocksOptions,\n): Promise<BlockResolutionOutcome> {\n const first = await attemptResolve(resolve, stored, opts)\n if (first.ok) return { envelope: first.envelope }\n\n const retry = await attemptResolve(resolve, stored, {\n ...opts,\n budget: createResolutionBudget(0),\n // The I/O-free retry lists nothing, so it must not spend the response's\n // collection slots on blocks that were never going to read.\n claimCollection: undefined,\n })\n if (retry.ok) {\n return { envelope: retry.envelope, failure: first.failure, error: first.error }\n }\n return {\n envelope: EMPTY_RESOLVED_BLOCK_TREE,\n failure: first.failure,\n retryFailure: retry.failure,\n error: first.error ?? retry.error,\n }\n}\n\n/**\n * Resolve every `blockTree` field on `rows`, IN PLACE.\n *\n * With no provider wired the stored tree crosses UNCHANGED — deliberately, and\n * unlike media. A media uuid is unusable to any consumer, so leaving it would\n * publish a value nobody can act on; a block tree is the document's actual\n * content and renders fine without its links resolved (an unresolved link is\n * what the in-process renderers already emit as plain text). Nulling it would\n * delete the page to avoid an unresolved href.\n *\n * 🔴 **That is the ONLY path on which the stored tree still crosses.** When a\n * provider IS wired and fails, the field is served an envelope — see\n * {@link resolveOneBlockTree} for why a second, I/O-free call is what produces\n * it. The distinction is load-bearing: \"no resolution on this deployment\" is a\n * deployment fact a consumer must be able to read off the wire shape, while \"a\n * resolver that is wired and broke\" must not be allowed to publish author-time\n * media urls just because it broke.\n *\n * **SERIAL over rows, deliberately — do not \"fix\" this into a `runBounded`\n * fan-out.** Each provider call claims from the shared budget as it works, so\n * running rows concurrently would let several of them claim past the ceiling\n * before any of them observed it, and which rows ended up truncated would\n * depend on scheduling. Serial makes exhaustion deterministic: rows resolve in\n * order until the budget runs out, and the remainder cross unresolved. The cost\n * is latency on a large list page, which is bounded by the same ceiling.\n */\nexport async function resolveBlockTrees(\n entity: Entity,\n rows: Record<string, unknown>[],\n deps: BlockResolutionDeps,\n): Promise<void> {\n if (rows.length === 0) return\n const resolve = deps.resolveBlocks\n if (!resolve) return\n const fields = blockTreeFieldsOf(entity)\n if (fields.length === 0) return\n\n // Counted here and reported ONCE at the end, not logged per field.\n //\n // The loop below is `rows × fields`, and `MAX_LIMIT` is 100 — while a\n // provider's failure modes (link index down, read pool saturated) are\n // persistent rather than transient, so they fail for EVERY row. A `warn` at\n // each one is 200 lines with a serialized error per request on an\n // unauthenticated endpoint whose only bound is arrival rate: an operator log\n // amplifier, which is the shape CLAUDE.md's no-unbounded-fan-out section is\n // about even when the resource is stdout rather than the pool.\n let threw = 0\n let malformed = 0\n let blanked = 0\n // TWO latches, one per warn that carries an `err`, and the pair is the point.\n //\n // A single latch was wrong twice over. Captured only under `threw`, a field\n // that returned a non-envelope first and THREW on the retry discarded the\n // only Error the request produced, and the blanked warn logged\n // `err: undefined`. Made unconditional, that same Error latches into the\n // THREW warn — whose `fields` list does not name that field, because it went\n // into `malformedFields`. An operator would then be handed an error\n // belonging to a field the line does not mention, in the log this file calls\n // the only place a degrade survives at all. The chrome twin has no such\n // problem because it logs per region rather than aggregating.\n let firstThrewError: unknown\n let firstBlankedError: unknown\n // The FAILING field names, not the entity's whole blockTree list. An entity\n // with two such fields where one provider call fails would otherwise name\n // both, sending an operator to inspect a field that resolved perfectly — in\n // the one log documented as the only place a degrade survives at all.\n const threwFields = new Set<string>()\n const malformedFields = new Set<string>()\n const blankedFields = new Set<string>()\n for (const row of rows) {\n for (const field of fields) {\n const stored = row[field]\n // A CODED TEMPLATE is resolved whatever is stored — and what is stored is\n // normally `null`, because the layout is code and the column is never\n // written. Skipping it on `null` (as an ordinary tree is skipped) is what\n // served a templated document with no layout and no link sidecar, so the\n // bound columns' links rendered as plain text (F109). The template wins\n // over a stray stored value for the same reason the canvas's does: the\n // layout is the registry's, not the row's.\n //\n // ⚠️ Only when the row CARRIES the key — a guard against adding a field to\n // a response that did not ask for it. Today `?fields=` cannot select a\n // `blockTree` column at all (`query-params.ts`), so this is what keeps a\n // future projection honest; an unprojected row carries the never-written\n // column as `null`, which is the case the template branch exists for.\n const template = blockTreeTemplateOf(entity, field)\n if (template !== undefined && !Object.hasOwn(row, field)) continue\n if (template === undefined && (stored === null || stored === undefined)) continue\n // Nothing is claimed HERE, and the provider is called even when the\n // budget is SPENT. Both halves matter.\n //\n // The provider claims per unit of work it actually performs, because only\n // it knows how many links and media refs the tree contains — a flat 1 per\n // field would be a ceiling that does not bound what it names.\n //\n // 🔴 And skipping the call on an exhausted budget was a SECURITY hole,\n // not a saving. An exhausted budget is the ordinary case on a list page\n // (references and media claim first, blocks resolve last), and skipping\n // meant the STORED tree crossed — carrying the author-time media urls\n // that `frontend-delivery` D019 exists to replace, on a cacheable\n // anonymous response. The provider handed a spent budget resolves nothing\n // and still strips every ref it could not resolve, which is exactly the\n // outcome wanted. What it costs is a pure in-memory walk per field.\n const outcome = await resolveOneBlockTree(resolve, template === undefined ? stored : [], {\n locale: deps.locale,\n budget: deps.budget,\n entity: entity.name,\n field,\n // A snapshot of the row, taken before this field is overwritten below:\n // the provider reads the bound columns and must see them as the handler\n // projected them, not as this loop leaves them. `attemptResolve` deep-\n // copies it again per attempt, so nothing a provider writes — nested\n // or not — reaches the served row or the retry. Handed over for\n // EVERY tree, not only a template's: a bound block can sit in an ordinary\n // composable tree too, and its column's links must resolve there as well.\n ...(template !== undefined && { template }),\n boundValues: { ...row },\n ...(deps.canReadEntity !== undefined && { canReadEntity: deps.canReadEntity }),\n ...(deps.collectionAllowance !== undefined && {\n claimCollection: deps.collectionAllowance.claim,\n }),\n })\n if (outcome.failure === 'threw') {\n threw += 1\n threwFields.add(field)\n firstThrewError ??= outcome.error\n } else if (outcome.failure === 'malformed') {\n malformed += 1\n malformedFields.add(field)\n }\n if (outcome.retryFailure !== undefined) {\n blanked += 1\n blankedFields.add(field)\n // Whichever attempt threw — the first or the retry — this field is one\n // of the ones `blankedFields` names, so the attribution holds.\n firstBlankedError ??= outcome.error\n // This response is now known to be missing content, so no cache may\n // keep it. See `CacheTagSink.markUncacheable` for why a STRIPPED\n // response deliberately stays storable and only a BLANKED one does not.\n //\n // ⚠️ **This moves availability in the wrong direction, and that is a\n // deliberate trade rather than an oversight.** A provider failure is\n // persistent, not transient, so \"blanked\" tends to mean EVERY document\n // — and every one of those responses then bypasses the CDN, putting\n // full uncached traffic on an origin whose resolver is already broken.\n // The alternative is worse and is the reason this exists: a cached\n // blank outlives the fix by a full TTL and nothing can purge it early,\n // because a blanked envelope carries no `refs`.\n //\n // It is also the SAME trade `apps/front-astro` already makes for a\n // `_chrome` 404 — an equally persistent failure, since that is the\n // shape of a missing `site_chrome:view` grant — and that precedent was\n // taken deliberately, in writing. Bounding it (a circuit breaker, a\n // \"blank at most N% before serving stale\") is a policy that would have\n // to cover both, so it is filed rather than invented here.\n deps.cacheTags?.markUncacheable()\n }\n // Assigned unconditionally, and that assignment IS the fix for F056. The\n // two failure branches used to `continue`, leaving `row[field]` as the\n // stored array.\n row[field] = outcome.envelope\n // The reason `blockTreeContents` is no longer an `uncovered`\n // dependency. Recorded from the ENVELOPE rather than by re-walking the\n // tree: the provider already resolved each of these ids against its own\n // publish gate, so this names exactly what the body actually contains.\n if (outcome.envelope.refs !== undefined) {\n for (const ref of outcome.envelope.refs) deps.cacheTags?.addDocument(ref.entity, ref.id)\n }\n // A COLLECTION depends on WHICH rows of its entity exist, not only on the\n // rows it happened to list — publishing a new recipe must evict a page\n // listing \"the latest three\". Row ids already arrived through `refs`.\n if (outcome.envelope.collections !== undefined) {\n for (const result of Object.values(outcome.envelope.collections)) {\n if (result.entity !== null) deps.cacheTags?.addCollection(result.entity)\n }\n }\n }\n }\n\n // WARN, not debug, and it says what the degrade cost.\n //\n // What it no longer says is that the response carried author-time urls,\n // because it no longer does: the retry re-walked the tree with a spent budget\n // and every media ref came back `null`. That is a CONTENT degrade — images\n // gone, links plain — which an operator still has to be able to see, and a\n // `debug` line cannot tell them. Neither can 200 identical warns.\n if (threw > 0) {\n deps.logger?.warn(\n {\n entity: entity.name,\n fields: [...threwFields],\n rows: rows.length,\n failures: threw,\n err: firstThrewError,\n },\n 'content-api: block resolver threw — no tree is served as stored; each was re-resolved ' +\n 'with an exhausted budget, so its media refs and link hrefs are stripped rather than ' +\n 'carrying the urls recorded when an editor picked them. Fields that failed that retry ' +\n 'as well are reported separately, as empty.',\n )\n }\n if (malformed > 0) {\n deps.logger?.warn(\n { entity: entity.name, fields: [...malformedFields], rows: rows.length, failures: malformed },\n 'content-api: block resolver returned a value that is not a ResolvedBlockTree — no tree ' +\n 'is served as stored; each was re-resolved with an exhausted budget, so its media refs ' +\n 'and link hrefs are stripped rather than carrying the urls recorded when an editor ' +\n 'picked them. Fields that failed that retry as well are reported separately, as empty.',\n )\n }\n // The THIRD state, and the loudest, because it is the one that costs a\n // visitor something they can see. Separate from the two above rather than\n // folded into them: those say \"the resolver is failing\", this says \"and the\n // page is now missing content\", which is a different call to action.\n if (blanked > 0) {\n deps.logger?.warn(\n {\n entity: entity.name,\n fields: [...blankedFields],\n rows: rows.length,\n failures: blanked,\n err: firstBlankedError,\n },\n 'content-api: block resolver failed TWICE, including on the in-memory retry — those ' +\n 'fields are served as an empty resolved tree, so that content is missing from the ' +\n 'response. Serving the stored tree instead would put the media urls recorded when an ' +\n 'editor picked them, which may be signed, on a cacheable anonymous response.',\n )\n }\n}\n","/**\n * A row DRAWN BY A STORED TEMPLATE (block-library D009) — what the public API\n * serves for an entity that declares `frontend: { drawnBy: 'template' }`.\n *\n * Such an entity has no block-tree field: its rows are synced, not authored, so\n * the layout of its detail page is ONE stored template document per entity\n * type, edited in the admin. This module is what joins the two on the way out.\n *\n * ## 🔴 The row is NARROWED, and that is the security property\n *\n * A template-drawn entity is public because its detail page is, not because its\n * table is. So what crosses is:\n *\n * - `id`;\n * - `title` / `description`, IF the injected provider projects them — it does\n * so from the site's code-declared detail bindings, never from a request\n * parameter or a stored value;\n * - {@link DETAIL_TEMPLATE_FIELD}: the published template, resolved against\n * this row, whose `detail` sidecar carries the remaining bound values.\n *\n * Every other column of the row stays in this process. That holds wherever the\n * row is reached from — the by-id route and a `?populate=` expansion both come\n * through {@link narrowTemplateDrawnRow} — and it holds with NO provider wired,\n * in which case the row is `{ id }` (deny by default).\n *\n * The list, count and slug routes do not exist for such an entity (the handler\n * 404s them, as it does a singleton's): a filter or a sort over a column that\n * is not served would otherwise be an oracle for it.\n *\n * ## Fan-out\n *\n * One template read per BY-ID response, claimed against the response's one\n * resolution budget and skipped when it is spent. A populated row gets no\n * template — a reference to a room is drawn as a link, not as a page.\n */\n\nimport type { Entity } from '@murumets-ee/entity'\nimport { type BlockResolutionDeps, type ResolvedBlockTree, resolveOneBlockTree } from './blocks.js'\n\n/**\n * The key the resolved template crosses under. Underscore-led so it cannot be\n * mistaken for a column, and stable: a frontend lists it beside its own body\n * block fields.\n */\nexport const DETAIL_TEMPLATE_FIELD = '_template'\n\n/** Longest `title` / `description` served. Matches the detail sidecar's text cap. */\nconst MAX_PUBLIC_TEXT = 2_000\n\n/**\n * The detail-template provider. Injected, not imported, so this package stays a\n * leaf — `@murumets-ee/content`'s `createDetailTemplateProvider` is the\n * reference implementation, and its header states what each method may reach.\n */\nexport interface ContentApiDetailTemplates {\n /** The entity templates are stored in — named on the response's cache tags. */\n readonly entity: string\n /**\n * The PUBLISHED template for rows of `entity` at `locale`, or `null`.\n * Implementations must apply the publish filter themselves: this package\n * serves whatever comes back.\n */\n readTemplate(opts: {\n entity: string\n locale: string\n }): Promise<{ readonly id: string; readonly blocks: unknown[] } | null>\n /**\n * The row's public name and summary. Only string `title` and `description`\n * are read from the answer; anything else it returns is dropped here.\n */\n publicRow(opts: {\n entity: string\n row: Readonly<Record<string, unknown>>\n locale: string\n }): Promise<Record<string, unknown>>\n}\n\nexport interface TemplateDrawnDeps extends BlockResolutionDeps {\n detailTemplates?: ContentApiDetailTemplates | undefined\n /** The locale to read when the request named none. */\n defaultLocale?: string | undefined\n}\n\nfunction publicText(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed === '' ? undefined : trimmed.slice(0, MAX_PUBLIC_TEXT)\n}\n\n/**\n * Replace `row`'s contents, IN PLACE, with what a visitor may see of it — and,\n * when `attachTemplate`, the entity type's published template resolved against\n * the row as it was.\n *\n * In place because every caller holds the row inside a response it is still\n * building (`afterExpand` hands over the rows it will serve). The full row is\n * snapshotted first; nothing of it survives on `row` except `id`.\n *\n * Never throws for a provider failure: a detail page whose template could not\n * be read is served as the plain fallback and marked uncacheable, so the miss\n * does not outlive the fix.\n */\nexport async function narrowTemplateDrawnRow(\n entity: Entity,\n row: Record<string, unknown>,\n deps: TemplateDrawnDeps,\n attachTemplate: boolean,\n): Promise<void> {\n const full: Readonly<Record<string, unknown>> = { ...row }\n for (const key of Object.keys(row)) {\n if (key !== 'id') delete row[key]\n }\n\n const provider = deps.detailTemplates\n if (!provider) return\n const locale = deps.locale ?? deps.defaultLocale\n // No locale to project or read at: the row keeps its id and nothing else.\n if (locale === undefined) return\n\n try {\n const projected = await provider.publicRow({ entity: entity.name, row: full, locale })\n const title = publicText(projected.title)\n const description = publicText(projected.description)\n if (title !== undefined) row.title = title\n if (description !== undefined) row.description = description\n } catch (err) {\n deps.logger?.warn(\n { entity: entity.name, err },\n 'content-api: detail-template provider failed to project a row — served with its id only',\n )\n deps.cacheTags?.markUncacheable()\n }\n\n if (!attachTemplate) return\n const resolve = deps.resolveBlocks\n // No resolver, no template: a stored tree carries the media urls recorded\n // when an editor picked them, which may be signed, and this is a cacheable\n // anonymous response. An entity's OWN tree crosses unresolved in that case\n // for historical reasons; a new field does not inherit the exception.\n if (!resolve) return\n\n // Publishing (or unpublishing) the template changes this response whether or\n // not one was found just now, so the collection tag is recorded before the\n // read rather than after a hit.\n deps.cacheTags?.addCollection(provider.entity)\n\n if (deps.budget.claim('block', 1) < 1) return\n\n let template: { readonly id: string; readonly blocks: unknown[] } | null\n try {\n template = await provider.readTemplate({ entity: entity.name, locale })\n } catch (err) {\n deps.logger?.warn(\n { entity: entity.name, err },\n 'content-api: detail-template provider failed to read the template — the row is served ' +\n 'without one and the response is not cacheable',\n )\n deps.cacheTags?.markUncacheable()\n return\n }\n if (template === null || typeof template.id !== 'string' || !Array.isArray(template.blocks)) {\n return\n }\n deps.cacheTags?.addDocument(provider.entity, template.id)\n\n const outcome = await resolveOneBlockTree(resolve, template.blocks, {\n locale: deps.locale,\n budget: deps.budget,\n // The ROW's entity, not the template's: the provider projects the detail\n // sidecar for `entity` from `boundValues`, and that pairing is what makes\n // a bound block show this row.\n entity: entity.name,\n field: DETAIL_TEMPLATE_FIELD,\n boundValues: full,\n // A block in the stored tree must not get to choose which of the row's\n // columns are read: only the code-declared detail is projected from it.\n rowIsForeign: true,\n ...(deps.canReadEntity !== undefined && { canReadEntity: deps.canReadEntity }),\n ...(deps.collectionAllowance !== undefined && {\n claimCollection: deps.collectionAllowance.claim,\n }),\n })\n if (outcome.failure !== undefined) {\n deps.logger?.warn(\n {\n entity: entity.name,\n field: DETAIL_TEMPLATE_FIELD,\n failure: outcome.failure,\n retryFailure: outcome.retryFailure,\n err: outcome.error,\n },\n 'content-api: block resolver failed on a detail template — served re-resolved with an ' +\n 'exhausted budget, or empty if that failed as well',\n )\n }\n if (outcome.retryFailure !== undefined) deps.cacheTags?.markUncacheable()\n recordEnvelopeTags(outcome.envelope, deps)\n row[DETAIL_TEMPLATE_FIELD] = outcome.envelope\n}\n\n/** The same tag bookkeeping `resolveBlockTrees` does for an entity's own tree. */\nfunction recordEnvelopeTags(envelope: ResolvedBlockTree, deps: BlockResolutionDeps): void {\n if (envelope.refs !== undefined) {\n for (const ref of envelope.refs) deps.cacheTags?.addDocument(ref.entity, ref.id)\n }\n if (envelope.collections !== undefined) {\n for (const result of Object.values(envelope.collections)) {\n if (result.entity !== null) deps.cacheTags?.addCollection(result.entity)\n }\n }\n}\n","/**\n * Cache-tag emission — the header only, never a purge protocol (D055, plan/api\n * PR 11, resolves F73).\n *\n * **What this is for.** R05 surveyed how headless CMSes invalidate a CDN and\n * found one convergent pattern: stamp each cached response with tags naming the\n * documents that composed it, then purge by tag on write. R05 also recorded the\n * asymmetry that makes the two halves separable — **emitting the header costs\n * nothing at request time and needs no purge infrastructure to be useful, while\n * deferring it means a later migration touches every cached response.** This\n * module is the emission half. The purge half stays out for the reason phase 10\n * gave and SD006 left standing: `resolveQueryField` records a cache dependency\n * only under `renderContext === 'production-build'`, so a request-path purge\n * would have no dependency graph to consult.\n *\n * **Emitting a tag nobody purges on changes nothing about S6.** Until a\n * consumer wires purge-on-write, TTL expiry is still the entire reconciliation\n * mechanism between a shared cache and S6. What ships here is the thing that\n * makes that wiring possible later without a response-format change.\n *\n * # The format (D055)\n *\n * Two kinds, both namespaced, told apart by SEGMENT COUNT:\n *\n * | Kind | Shape | Means |\n * |---|---|---|\n * | Document | `lumi:<entity>:<id>` | this row's CONTENT is in the body |\n * | Collection | `lumi:<entity>` | this response depends on WHICH rows exist |\n *\n * **Both kinds are needed and a document tag alone is a silent failure.** A\n * per-document tag cannot invalidate a list when a BRAND-NEW row is created,\n * because no cached response ever carried that row's tag. Drupal answers this\n * with a first-class `node_list` tag; Vercel documents `cacheTag('products',\n * 'product-${id}')` as the recommended hierarchy; Stellate names the failure\n * verbatim. See R16.\n *\n * **Segment count rather than a suffix**, because Drupal's `node_list` shape\n * would collide with an entity genuinely named `article_list` — a legal entity\n * name here.\n *\n * **The `lumi:` prefix is not decoration.** The named consumer is an\n * application, not a CDN: `plan/frontend-delivery`'s Astro BFF reads these and\n * hands them to `Astro.cache.set({ tags })`, which merges them into ONE flat\n * tag space alongside the app's own tags and Astro's `astro-path:` tags. Four\n * bytes makes a collision structurally impossible.\n *\n * # The disclosure invariant — why raw ids are defensible here\n *\n * Akamai ships a knob to hide its tag header from clients, because \"cache tags\n * can include back-end information, or at least allow a malicious user to\n * decipher back-end information from them\". That is a real argument and it is\n * answered structurally rather than waved away:\n *\n * > **Every id a tag names is already visible to that same caller, in that same\n * > response, or was supplied by that caller in the request.**\n *\n * Row ids are in the body (`data.id`); resolved reference and media ids are in\n * the body (`row[field].id`); `_chrome`'s document id and `_alternates`' id came\n * from the caller's own query string; `_menu`'s handle is in the URL. The header\n * therefore discloses nothing the body does not — asserted in the tests, not\n * assumed. {@link CacheTagsConfig.enabled} exists anyway for an operator who\n * wants no ids in a header regardless; the invariant is why the DEFAULT can be\n * on, not a reason to skip the switch.\n *\n * # Bounds\n *\n * Emitted to the STRICT INTERSECTION of every CDN surveyed in R16, always,\n * whichever one is actually configured — so switching vendors can never\n * invalidate tags already emitted. THREE caps, and the third is not implied by\n * the other two (128 × 128 plus separators overshoots every vendor's header\n * limit — see {@link MAX_CACHE_TAG_HEADER_BYTES}):\n *\n * 1. {@link MAX_CACHE_TAG_LENGTH} characters per tag — Akamai's, the tightest.\n * 2. {@link MAX_CACHE_TAGS_PER_RESPONSE} tags — Akamai's and Vercel's.\n * 3. {@link MAX_CACHE_TAG_HEADER_BYTES} bytes for the assembled field, so the\n * whole response-header block stays inside a default proxy buffer.\n *\n * Plus the charset: no spaces, no commas, printable ASCII from an allowlist.\n *\n * A tag that fails the charset or the length cap is **DROPPED, never truncated\n * and never escaped**: a malformed tag is one a purge silently misses, which is\n * strictly worse than a tag that was never there. A tag that does not fit the\n * count or byte budget is dropped from the TAIL, so what survives is always the\n * first N added — which is what makes insertion order a degradation strategy\n * rather than an accident.\n */\n\n/**\n * The namespace every tag this package emits begins with.\n *\n * Lowercase and fixed. Cloudflare matches tags case-INSENSITIVELY while Akamai\n * matches case-sensitively, so a namespace whose case varied would mean\n * different things on two CDNs.\n */\nexport const CACHE_TAG_NAMESPACE = 'lumi'\n\n/**\n * Akamai's documented per-tag ceiling — \"If your tag is longer than 128\n * characters, the associated content won't be purged\" — and the tightest of the\n * four surveyed (Fastly 1024 bytes, Cloudflare 1024 chars, Vercel 256).\n */\nexport const MAX_CACHE_TAG_LENGTH = 128\n\n/**\n * Akamai's documented per-object ceiling (\"The maximum number of tags per\n * cached object is 128. Any additional tags are ignored\"), which Vercel matches\n * exactly.\n */\nexport const MAX_CACHE_TAGS_PER_RESPONSE = 128\n\n/**\n * The ceiling on the emitted header FIELD — `name: value` — in bytes. A THIRD\n * bound, which the count and length caps do not imply.\n *\n * 128 tags of 128 characters plus 127 separators is 16,511 bytes, which is 127\n * PAST Fastly's documented 16,384-byte header cap — where the documented\n * behaviour is to ignore \"the key currently being parsed and all keys following\n * it\", i.e. exactly the silent purge-miss this module refuses everywhere else.\n * Two caps that each looked safe multiplied into one that was not.\n *\n * **Why 3072, and why the first draft's 8192 was the wrong number for the very\n * reasons it cited.** That draft justified 8192 as \"Akamai's documented overall\n * maximum response-header size\" and as fitting \"the 8k `proxy_buffer_size` a\n * default nginx will forward\". Both numbers govern the WHOLE header block, not\n * one field — so capping a single field AT the block maximum guarantees the\n * block is exceeded whenever the cap binds. And nginx's default is documented\n * as \"equal to one memory page… 4K or 8K depending on the platform\", which is\n * 4096 on x86-64 Linux, and that one buffer must hold the entire block.\n *\n * So the budget is sized against a **4096-byte block**, leaving roughly a\n * kilobyte for the status line, `Content-Type`, `Cache-Control`, `Vary`, up to\n * four `Access-Control-*` headers and whatever the runtime adds. An intermediary\n * that refuses an oversized header turns a 200 into a 502 — an availability\n * failure caused by a caching hint, on a public surface, by default, which is\n * the direction CLAUDE.md's \"availability is a security property\" rules out.\n *\n * **It binds before the 128-tag cap on a large page, deliberately.** At ~49\n * bytes per tag (`lumi:article:` plus a uuid) this admits roughly 60 documents.\n * A default `?limit=20` page fits with room to spare; a caller who asks for the\n * `?limit=100` maximum will see tags dropped and logged. That is the right\n * trade because the degradation is SAFE: the collection tag is added first and\n * covers every row on the page, so a purge still invalidates the response — it\n * is precision that is lost, not correctness. Vercel's documented advice for\n * exactly this situation is to carry a coarser tag rather than enumerate.\n */\nexport const MAX_CACHE_TAG_HEADER_BYTES = 3072\n\n/**\n * The entity name media tags are emitted under.\n *\n * `content-api` is a leaf and never imports `@murumets-ee/media`, so the media\n * entity's name is not structurally available here. `media` is not a guess: it\n * is the permission RESOURCE this package already names in `media.ts`, whose\n * resolver \"THROWS when the caller's role lacks `media:view`\". The tag\n * namespace follows the permission resource, which is the name a purge caller\n * would reach for.\n */\nexport const MEDIA_TAG_ENTITY = 'media'\n\n/**\n * Characters a tag SEGMENT may contain — an ALLOWLIST, per CLAUDE.md's\n * deny-by-default rule.\n *\n * Excludes the space (Cloudflare forbids it outright; Fastly uses it as the key\n * SEPARATOR) and the comma (Cloudflare's and Akamai's separator).\n *\n * **And excludes `:`, which the first draft admitted.** That draft reasoned a\n * left-anchored parse stays unambiguous even if an id contained one — true for\n * the ID, and irrelevant, because the hazard is the ENTITY segment:\n * `addCollection('article:x')` would emit `lumi:article:x`, byte-identical to\n * the DOCUMENT tag for entity `article`, id `x`. One kind forging the other.\n *\n * That is not caller-reachable today — entity names come from the\n * `config.entities` whitelist and `chrome.entity`, both operator-controlled —\n * but it falsified D055's own discriminator, which is that the two kinds are\n * told apart by SEGMENT COUNT. A count only discriminates if a segment cannot\n * contain the delimiter. Excluding it makes the stated property true by\n * construction instead of true by nobody having named an entity that way.\n *\n * Nothing legitimate loses: entity names are identifiers, row ids are uuids,\n * and menu handles are identifiers. None can contain a colon.\n */\nconst CACHE_TAG_CHARSET = /^[A-Za-z0-9_.-]+$/\n\n/**\n * What fills the SITE slot of a tag for a row that belongs to no site — a\n * shared entity (media) on an app serving several sites (plan `multisite`\n * D008): `lumi:_:media:<id>`.\n *\n * A slot rather than an omission, for the reason {@link CACHE_TAG_CHARSET}\n * gives: the two tag kinds are told apart by SEGMENT COUNT. Were shared rows\n * to skip the site segment, a shared DOCUMENT tag (`lumi:media:<id>`) and a\n * site COLLECTION tag (`lumi:kulli:page`) would both have three segments. With\n * the slot always present, a multi-site deployment's document tags have four\n * and its collection tags three.\n *\n * `_` cannot be a site: a handle starts with a letter.\n */\nexport const SHARED_SITE_TAG_SEGMENT = '_'\n\n/**\n * Host configuration for cache-tag emission. Every field is optional; omitting\n * the whole object emits `Cache-Tag`, comma-separated.\n */\nexport interface CacheTagsConfig {\n /**\n * Emit the header at all. Defaults to `true`.\n *\n * Default-on is free for the reason D010 already established — no\n * `content-api` consumer is live, so there is nothing deployed to surprise —\n * and it is the point of R05's asymmetry argument: a header nobody reads yet\n * still costs nothing, while adding it later is a contract change across\n * every cached response.\n *\n * Set `false` for a deployment that does not want document ids in a response\n * header regardless of the disclosure invariant. See the module docblock.\n */\n enabled?: boolean\n /**\n * The header name. Defaults to `Cache-Tag`.\n *\n * `Cache-Tag` is Cloudflare's native name, is accepted bare by Netlify, and\n * is trivially parseable by the BFF that is the actual named consumer. A\n * Fastly deployment sets `Surrogate-Key` and `separator: ' '`; an Akamai one\n * sets `Edge-Cache-Tag`.\n *\n * Emitting SEVERAL vendor headers at once is deliberately not offered: R16\n * could find no product anywhere doing it, and it multiplies the disclosure\n * surface for a hedge nobody needs.\n */\n header?: string\n /**\n * What joins the tags. Defaults to `,`.\n *\n * Cloudflare and Akamai are comma-separated; Fastly is SPACE-separated. Only\n * these two are accepted, because they are the only two any surveyed vendor\n * parses — a third would produce one giant unparseable key rather than an\n * error.\n */\n separator?: ',' | ' '\n}\n\n/** {@link CacheTagsConfig} with every default resolved. */\nexport interface ResolvedCacheTagsConfig {\n readonly enabled: boolean\n readonly header: string\n readonly separator: ',' | ' '\n}\n\nconst DEFAULT_CACHE_TAGS: ResolvedCacheTagsConfig = {\n enabled: true,\n header: 'Cache-Tag',\n separator: ',',\n}\n\n/**\n * RFC 9110 §5.1 field-name grammar (`token`). A header name outside it cannot\n * be set on a `Response` at all — `new Headers()` throws — so validating at\n * construction converts a per-request throw with no CORS headers and no log\n * into one loud boot failure, exactly as `assertCacheConfig` does for the\n * window.\n */\nconst HEADER_NAME_TOKEN = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/\n\n/**\n * Headers this package already writes on the responses the tag header joins.\n *\n * A token-valid name is not enough. The tag header is merged into the same map\n * as these, so `header: 'Cache-Control'` would silently REPLACE the directive on\n * every storable response — turning `public, max-age=60, …` into a tag list —\n * and `header: 'Vary'` would be silently discarded by the `Vary` written after\n * it. Both are operator error rather than caller-reachable, and both fail\n * SILENTLY at runtime, which is precisely the shape this module throws at\n * construction for everywhere else.\n */\nconst RESERVED_HEADER_NAMES = ['cache-control', 'vary', 'content-type']\n\n/**\n * Header FAMILIES this package writes on the same responses, denied by PREFIX.\n *\n * `buildCorsHeaders` contributes `Access-Control-Allow-Origin` (and, on the\n * preflight, `-Allow-Methods` / `-Allow-Headers` / `-Max-Age`), and the tag\n * header is spread AFTER those in `buildResponseHeaders`. So\n * `header: 'Access-Control-Allow-Origin'` — a perfectly valid RFC 9110 token —\n * would pass the name check and then silently REMOVE the CORS grant from every\n * cached success, at which point a browser blocks a response the server\n * considered a 200.\n *\n * A prefix rather than four literals, so a CORS header added later is covered\n * without anyone remembering to extend a list. Deny-by-default, per CLAUDE.md.\n */\nconst RESERVED_HEADER_PREFIXES = ['access-control-']\n\n/**\n * Validate the host's cache-tag configuration AT CONSTRUCTION.\n *\n * Same posture as {@link assertCacheConfig}: every host ceiling in this package\n * is checked when the handler is built, because a value that only throws\n * per-request produces a partial outage that is undetectable at boot.\n */\nexport function assertCacheTagsConfig(config: CacheTagsConfig | undefined): void {\n if (!config) return\n if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {\n throw new Error(\n `content-api cache: \\`cache.tags.enabled\\` must be a boolean, received ${JSON.stringify(config.enabled)}.`,\n )\n }\n if (config.header !== undefined) {\n if (typeof config.header !== 'string' || !HEADER_NAME_TOKEN.test(config.header)) {\n throw new Error(\n `content-api cache: \\`cache.tags.header\\` must be a valid HTTP field name (RFC 9110 §5.1 token), received ${JSON.stringify(config.header)}. ` +\n 'Common values: `Cache-Tag` (Cloudflare/Netlify, the default), `Surrogate-Key` (Fastly), `Edge-Cache-Tag` (Akamai).',\n )\n }\n const lowered = config.header.toLowerCase()\n if (\n RESERVED_HEADER_NAMES.includes(lowered) ||\n RESERVED_HEADER_PREFIXES.some((prefix) => lowered.startsWith(prefix))\n ) {\n throw new Error(\n `content-api cache: \\`cache.tags.header\\` may not be ${JSON.stringify(config.header)} — this package writes that header itself ` +\n 'on the same responses, so the tags would either overwrite it or be overwritten, silently and on every request.',\n )\n }\n }\n if (config.separator !== undefined && config.separator !== ',' && config.separator !== ' ') {\n throw new Error(\n `content-api cache: \\`cache.tags.separator\\` must be ',' or ' ', received ${JSON.stringify(config.separator)}. ` +\n 'Cloudflare and Akamai parse comma-separated tags; Fastly parses space-separated ones. A third separator ' +\n 'would not error at the CDN — it would parse as one giant unmatched key, which is the silent failure this refuses.',\n )\n }\n}\n\n/** Apply {@link DEFAULT_CACHE_TAGS} to whatever the host declared. */\nexport function resolveCacheTagsConfig(\n config: CacheTagsConfig | undefined,\n): ResolvedCacheTagsConfig {\n return {\n enabled: config?.enabled ?? DEFAULT_CACHE_TAGS.enabled,\n header: config?.header ?? DEFAULT_CACHE_TAGS.header,\n separator: config?.separator ?? DEFAULT_CACHE_TAGS.separator,\n }\n}\n\n/**\n * The write half of a {@link CacheTagSet} — what the resolution layer receives.\n *\n * Narrowed deliberately: `references.ts` and `media.ts` may CONTRIBUTE ids and\n * may not read the accumulated set or decide anything about emission. Same\n * split of concerns the refusal path uses, where `errorResponse` owns \"is it\n * stored\" and `buildRefusalHeaders` owns only \"what does it vary on\".\n */\nexport interface CacheTagSink {\n /**\n * Record that `entityName`'s row `id` composed this response.\n *\n * `id` is `unknown` because a row's `id` column is `unknown` at every call\n * site; a non-string is DROPPED rather than coerced, since `String(id)` on an\n * object yields `[object Object]` — a tag that looks valid and matches\n * nothing.\n */\n addDocument(entityName: string, id: unknown): void\n /** Record that this response depends on WHICH rows of `entityName` exist. */\n addCollection(entityName: string): void\n /**\n * Declare that this response MUST NOT be stored by any cache.\n *\n * 🔴 It lives on the tag sink rather than on a seventh parameter to\n * {@link buildResponseHeaders}, and that is the whole point. This object is\n * already threaded to every resolution pass AND to every response site, so a\n * new endpoint inherits the behaviour instead of having to remember a flag —\n * the same argument `resolveRows` is built on (\"four hand-written copies is\n * four chances to add a fifth endpoint that forgets one\"). It is not a\n * category error either: this object already decides cacheability, since\n * `buildResponseHeaders` drops its tags entirely on an unstorable response.\n *\n * The one caller today is a block tree that reached editor D010's third\n * state — the provider failed, then failed its I/O-free retry, so the field\n * or region is served EMPTY. Caching that would hold content-less pages at\n * every CDN for a full TTL, long after the resolver recovered, and no purge\n * key could evict them: the blanked envelope carries no `refs`, so nothing\n * a media re-publish invalidates ever names it.\n *\n * ⚠️ **A STRIPPED response is deliberately still storable.** A tree the retry\n * rescued is complete, correct content with its media refs replaced by\n * `null` — which is precisely what an exhausted RESOLUTION BUDGET already\n * produces, and those responses are cached today by design. Making one\n * uncacheable and not the other would be an unannounced change to that\n * policy. Only \"we know this response is missing content\" gets `no-store`.\n */\n markUncacheable(): void\n}\n\n/** A request-scoped, bounded, de-duplicated set of cache tags. */\nexport interface CacheTagSet extends CacheTagSink {\n /** The accepted tags, in insertion order — which is value order (D055 §5). */\n readonly tags: readonly string[]\n /**\n * How many `add*` calls were REFUSED — malformed, over-long, or past the\n * count or byte cap. Non-zero is worth a log line: something this response\n * depends on is not named, so a purge for it will silently miss.\n *\n * A count of CALLS, not of distinct documents, and the difference is real\n * once a cap is reached: a repeat of an already-ACCEPTED tag is free, but a\n * repeat of one that never made it in is refused again and counted again, so\n * sixty rows referencing one over-cap author report sixty. It is a\n * severity-ish signal, not a ledger — the actionable facts are that drops\n * happened at all, and on which endpoint.\n */\n readonly dropped: number\n /** True once {@link CacheTagSink.markUncacheable} has been called. */\n readonly uncacheable: boolean\n /**\n * The header to merge into a STORABLE response, or `{}` when emission is\n * disabled or nothing was tagged.\n *\n * Callers must not consult this on an unstorable response — and structurally\n * cannot, because only the two builders that emit a non-`no-store`\n * `Cache-Control` are handed the set.\n */\n headers(): Record<string, string>\n}\n\n/** `true` when `segment` is safe to place in a tag. */\nfunction isTagSegment(value: unknown): value is string {\n return typeof value === 'string' && value !== '' && CACHE_TAG_CHARSET.test(value)\n}\n\n/**\n * Build a request-scoped tag set.\n *\n * **Per REQUEST, never shared** — for the same reason `createResolutionBudget`\n * is: the set describes what THIS response was composed from, and a\n * process-lifetime accumulator would describe nothing at all.\n *\n * Tags are collected even when emission is disabled. One code path rather than\n * two, and the cost is bounded at 128 short strings; a second \"collect nothing\"\n * path would be a branch no test naturally covers.\n */\nexport function createCacheTagSet(\n config: ResolvedCacheTagsConfig,\n /**\n * The site segment for `entityName`'s tags (plan `multisite` D008). The\n * handler supplies this only on an app serving several sites; it answers the\n * request's site for a site-bound entity and {@link SHARED_SITE_TAG_SEGMENT}\n * for a shared one, so EVERY tag of such a deployment has a site slot.\n * Omitted — every single-site install — the grammar is exactly the\n * pre-multisite one, byte for byte.\n */\n siteFor?: (entityName: string) => string,\n): CacheTagSet {\n /** `lumi` or `lumi:<site>` — the prefix every tag of `entityName` starts with. */\n function prefixFor(entityName: string): string | undefined {\n if (siteFor === undefined) return CACHE_TAG_NAMESPACE\n const site = siteFor(entityName)\n // A site that is not a tag segment would mint a tag no purge can name.\n return isTagSegment(site) ? `${CACHE_TAG_NAMESPACE}:${site}` : undefined\n }\n\n // A Set, because JS Sets iterate in INSERTION order — which is the property\n // D055 §5 leans on: collection and primary-row tags are added before resolved\n // reference and media tags, so a response that overflows degrades to \"every\n // row on this page, plus the collection\" rather than to an arbitrary 128.\n const tags = new Set<string>()\n let dropped = 0\n // Tracked incrementally rather than re-joined per add: the header is built\n // once, but the BUDGET has to be known on every add or the last tag accepted\n // could be the one that pushes the assembled field past the cap.\n //\n // Seeded with the FIELD overhead — `<name>: ` plus the trailing CRLF — because\n // the budget is a statement about the emitted header, and a deployment that\n // configures a longer name (`Edge-Cache-Tag`, `Netlify-Cache-Tag`) genuinely\n // has less room for tags. Budgeting the value alone would make the bound\n // depend on a name nobody accounted for.\n let uncacheable = false\n let bytes = config.header.length + 4\n // Latched once the byte budget refuses anything, so the emitted set stays a\n // PREFIX of insertion order.\n //\n // Without it the byte check is per tag, so a long tag is refused while a\n // SHORTER one offered afterwards still fits — the surviving set is then no\n // longer \"the first N added\", which is the degradation property `_schema`\n // publishes and which the whole insert-order-is-value-order design rests on.\n // Deterministic and slightly smaller beats maximal and order-dependent: which\n // documents a response names should not depend on how long their ids happen\n // to be.\n let byteBudgetSpent = false\n\n function add(tag: string): void {\n // Already present is NOT a drop. A list of 20 rows all referencing one\n // author yields one tag and zero drops; counting the 19 repeats would make\n // the drop log fire on the healthy case, which is how a signal stops being\n // read.\n if (tags.has(tag)) return\n if (tag.length > MAX_CACHE_TAG_LENGTH || tags.size >= MAX_CACHE_TAGS_PER_RESPONSE) {\n dropped += 1\n return\n }\n // Tags are restricted to single-byte ASCII by CACHE_TAG_CHARSET, so\n // `.length` IS the byte count — no `TextEncoder` needed, and the charset\n // check above is what makes that true rather than assumed.\n const cost = tag.length + (tags.size === 0 ? 0 : 1)\n if (byteBudgetSpent || bytes + cost > MAX_CACHE_TAG_HEADER_BYTES) {\n byteBudgetSpent = true\n dropped += 1\n return\n }\n bytes += cost\n tags.add(tag)\n }\n\n return {\n addDocument(entityName: string, id: unknown): void {\n if (!isTagSegment(entityName) || !isTagSegment(id)) {\n dropped += 1\n return\n }\n const prefix = prefixFor(entityName)\n if (prefix === undefined) {\n dropped += 1\n return\n }\n add(`${prefix}:${entityName}:${id}`)\n },\n addCollection(entityName: string): void {\n if (!isTagSegment(entityName)) {\n dropped += 1\n return\n }\n const prefix = prefixFor(entityName)\n if (prefix === undefined) {\n dropped += 1\n return\n }\n add(`${prefix}:${entityName}`)\n },\n get tags(): readonly string[] {\n return [...tags]\n },\n markUncacheable(): void {\n uncacheable = true\n },\n get dropped(): number {\n return dropped\n },\n get uncacheable(): boolean {\n return uncacheable\n },\n headers(): Record<string, string> {\n if (!config.enabled || tags.size === 0) return {}\n return { [config.header]: [...tags].join(config.separator) }\n },\n }\n}\n","/**\n * Cache-Control header builders.\n *\n * **The configuration declares ONE number** — a total staleness window in\n * seconds — and this module derives the `max-age` / `stale-while-revalidate`\n * split from it (D036). So the sum of what is emitted always equals what the\n * operator declared; there is no way to write a configuration expressing a\n * window the deployment does not honour. The default window is 360, which\n * reproduces the historical `max-age=60, stale-while-revalidate=300`. A window\n * of `0` yields `no-store` out of the same field, for an operator who needs a\n * published edit visible on the very next request (S6 verbatim).\n *\n * **Principal-aware short-circuit, on SUCCESS responses.** When the caller is\n * anything other than the anonymous principal, {@link buildResponseHeaders}\n * forces `no-store`: the URL is the same for an anonymous and an editor caller\n * but the BODY is caller-specific, so a shared edge keyed on the URL alone would\n * serve one to the other.\n *\n * **REFUSALS ARE NEVER STORED, and nothing here decides that** (SD005). Every\n * 4xx and 5xx is `no-store`, unconditionally, for every caller — stamped by\n * `errorResponse`, which is the single owner of that question.\n * {@link buildRefusalHeaders} supplies only CORS and `Vary`.\n *\n * The value of the uniform rule is not the branch it deletes, it is the class of\n * question it deletes. With no per-caller variation on the refusal path, D006's\n * \"a denial reveals nothing\" costs nothing at runtime and cannot be reopened by\n * a future header — `RateLimit-*`, cache tags and `ETag` can all be added\n * without a byte-identity audit. Two earlier attempts in this PR made\n * storability depend first on the caller's role and then on `AuthVaryPolicy`;\n * the first was a credential-validity oracle, the second replayed a stored 404\n * to a principal who would have received a 200.\n */\n\nimport { assertCacheTagsConfig, type CacheTagSet } from './cache-tags.js'\nimport type { CacheConfig } from './types.js'\n\n/**\n * The default total staleness window, in seconds.\n *\n * 360 is not a new number: it is exactly what `defaultMaxAge: 60` plus\n * `defaultStaleWhileRevalidate: 300` already served on every deployment in this\n * repo. D036 changes what is DECLARED, not what is emitted by default.\n */\nexport const DEFAULT_MAX_STALE_SECONDS = 360\n\n/**\n * Split a declared window into the `max-age` / `stale-while-revalidate` pair.\n *\n * Package-owned and deliberately not tunable (D036). One sixth fresh, five\n * sixths revalidating, which reproduces the historical 60/300 at the historical\n * total of 360 — so this is a change of vocabulary, not of behaviour, for any\n * deployment that had not touched the old fields (which is all of them).\n *\n * The invariant that matters is `maxAge + swr === window`, asserted in the\n * tests: whatever the split, the sum of what is emitted equals what was\n * declared.\n */\nfunction splitWindow(windowSeconds: number): { maxAge: number; swr: number } {\n const maxAge = Math.ceil(windowSeconds / 6)\n return { maxAge, swr: windowSeconds - maxAge }\n}\n\n/** Fields D036 removed. Passing one is refused LOUDLY — see {@link assertCacheConfig}. */\nconst REMOVED_CACHE_FIELDS = [\n 'defaultMaxAge',\n 'defaultStaleWhileRevalidate',\n 'maxAge',\n 'staleWhileRevalidate',\n] as const\n\nfunction assertWindow(value: unknown, where: string): void {\n if (value === undefined) return\n if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n throw new Error(\n `content-api cache: \\`${where}.maxStaleSeconds\\` must be a non-negative integer, received ${JSON.stringify(value)}. ` +\n 'Use 0 for `no-store` if you need a published edit visible on the very next request.',\n )\n }\n}\n\nfunction assertNoRemovedFields(bag: Record<string, unknown>, where: string): void {\n for (const removed of REMOVED_CACHE_FIELDS) {\n if (!(removed in bag)) continue\n throw new Error(\n `content-api cache: \\`${where}.${removed}\\` was removed (D036). Use \\`maxStaleSeconds\\` — ONE number naming the ` +\n \"TOTAL staleness window in seconds. The old pair's real window was their SUM and nothing said so, which is \" +\n 'the defect D036 exists to close: `defaultMaxAge: 10` bought a 310-second window, not a 10-second one. ' +\n 'The max-age / stale-while-revalidate split is now derived, so what is emitted always sums to what you declared.',\n )\n }\n}\n\n/**\n * Validate the host's cache configuration AT CONSTRUCTION.\n *\n * Two failures this closes, both found in review:\n *\n * **A removed field was silently ignored.** `resolveWindow` reads only\n * `maxStaleSeconds`, so a host still passing `defaultMaxAge: 0` — or a JS host,\n * or any host mid-upgrade — got the 360-second DEFAULT instead. The direction is\n * MORE caching on a public surface, from configuration the operator wrote to\n * reduce it. This package already throws for exactly this shape one config field\n * over (`exposeInternalFields`), on the stated grounds that silently dropping a\n * field you configured is the failure worth throwing to prevent.\n *\n * **The window was the one ceiling validated lazily.** Every other host ceiling\n * (`maxLimit`, `maxOffset`, `publicRole`, `credentialHeaders`, the resolution\n * budget, the availability limits) is checked when the handler is built. A\n * `maxStaleSeconds: -1` used to construct fine and throw PER REQUEST — and since\n * the preflight now builds cache headers too, that throw had no `try` above it\n * on the `OPTIONS` path: every CORS preflight would reject with no response, no\n * CORS headers and no log. A per-entity bad value produced a partial outage\n * visible only for that entity. All of it undetectable at boot.\n */\nexport function assertCacheConfig(config: CacheConfig | undefined): void {\n if (!config) return\n assertNoRemovedFields(config as unknown as Record<string, unknown>, 'cache')\n assertWindow(config.maxStaleSeconds, 'cache')\n // Same reason as the window: a bad header name only throws when a `Response`\n // is constructed, which on the `OPTIONS` path has no `try` above it.\n assertCacheTagsConfig(config.tags)\n for (const [entityName, entityConfig] of Object.entries(config.entities ?? {})) {\n if (!entityConfig) continue\n const where = `cache.entities.${entityName}`\n assertNoRemovedFields(entityConfig as unknown as Record<string, unknown>, where)\n assertWindow(entityConfig.maxStaleSeconds, where)\n }\n}\n\nfunction resolveWindow(entityName: string, config: CacheConfig | undefined): number {\n const entityConfig = config?.entities?.[entityName]\n if (entityConfig?.cacheable === false) return 0\n // Validated at construction by `assertCacheConfig`, so this is a read, not a\n // check — a bad value cannot reach here.\n return entityConfig?.maxStaleSeconds ?? config?.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS\n}\n\nexport function buildCacheHeaders(\n entityName: string,\n config: CacheConfig | undefined,\n): Record<string, string> {\n const windowSeconds = resolveWindow(entityName, config)\n\n // A zero window is `no-store`, out of the SAME field — the operator who needs\n // S6 verbatim does not reach for a second knob (D036).\n if (windowSeconds === 0) {\n return { 'Cache-Control': 'no-store' }\n }\n\n const { maxAge, swr } = splitWindow(windowSeconds)\n return {\n 'Cache-Control': `public, max-age=${maxAge}, stale-while-revalidate=${swr}`,\n }\n}\n\n/**\n * Which shared-cache class a caller's responses belong to.\n *\n * `'anonymous'` is the ONE caller whose response body cannot vary per principal:\n * no identity was resolved and the role is the configured public one, so every\n * such caller gets the same bytes and a shared cache may reuse them.\n *\n * `'identified'` is everything else — a non-public role (preview/editor) OR a\n * resolved identity, INDEPENDENTLY. The identity half is not redundant: a\n * logged-in visitor who has been granted no extra role sits on `publicRole`\n * while still carrying a real `user.id` into `RequestContext`, which is exactly\n * the seam D016 adds owner-scoped rows through. A rule keyed on role alone would\n * mark that caller publicly cacheable while its body varies per principal.\n */\nexport type CallerCacheClass = 'anonymous' | 'site-keyed' | 'identified'\n\n/**\n * `'site-keyed'` (plan `multisite` D008) — a caller with no identity and the\n * public role whose CREDENTIAL selected the site. The body is the same for\n * every holder of that key, so it is storable; but it differs from what the\n * next site's key gets for the SAME URL, so it must not sit in a cache shared\n * between them. It is emitted `private`, not `public`.\n *\n * `Vary: Authorization` already says this, and is still sent. It is not enough\n * on its own: real shared caches honour `Vary` unevenly (Cloudflare honours\n * only `Accept-Encoding`), and one that stores the response and ignores the\n * header would serve site A's pages, menu and `_site` to site B's frontend.\n * `private` is the directive every shared cache does honour. The consumer that\n * matters — the site's own frontend, which holds the key — is a private cache\n * in exactly HTTP's sense and may keep it.\n */\n\n/**\n * The request header EVERY response this module builds varies on, whatever else\n * it varies on.\n *\n * These builders merge `buildCorsHeaders`' output, and that output is a function\n * of the request's `Origin`: an allowed origin gets\n * `Access-Control-Allow-Origin`, everything else gets no CORS headers at all. So\n * the response headers vary on `Origin` even on the requests where the CORS\n * layer emitted nothing to vary — which is precisely the variant a `Vary`-less\n * entry would be replayed to.\n *\n * Deliberately NOT listed here: `Accept-Language` (locale is a query param, so\n * it is already part of the URL cache key) and ANY credential header. Which\n * header carries a credential is not knowable from inside this package — see\n * {@link AuthVaryPolicy}.\n */\nconst ALWAYS_VARY_ON = ['Origin'] as const\n\n/**\n * How `ContentApiConfig.authenticate` participates in shared-cache variant\n * selection — a per-deployment fact, DECLARED by the host rather than guessed\n * here.\n *\n * The hook is host-supplied and may read ANY request header. A fixed selector\n * list (`Cookie, Authorization` — what this module used to emit) is therefore\n * wrong in both directions at once:\n *\n * - **Too narrow.** A host whose hook reads `X-Preview-Token` gets an anonymous\n * response stored with nothing keyed on it, and the next request carrying\n * only that header matches the stored public variant — `authenticate` is\n * never called, from the edge. The direction is fail-SAFE (the public body is\n * the one replayed), but \"the CDN quietly ignores your credential\" is not a\n * property to leave to chance.\n * - **Too wide.** `Vary: Cookie` on the public branch is close to fatal for\n * caching in practice: Cloudflare honours no `Vary` value except\n * `Accept-Encoding` and will not serve a cached variant at all, while\n * Fastly/Varnish key on the RAW cookie header, so hit rate collapses for\n * every visitor carrying an analytics or consent cookie. A deployment with no\n * `authenticate` hook was paying that for a credential nothing parses.\n *\n * The three states are exhaustive, and `'undeclared'` is the fail-CLOSED one:\n * a hook is wired, so a credential can arrive, but nothing says what selects it\n * — there is no correct `Vary` to emit, so nothing is offered for storage.\n */\nexport type AuthVaryPolicy =\n /** No `authenticate` hook — no credential is parsed, so none selects. */\n | { kind: 'none' }\n /** A hook plus the request headers the host declared it reads. Non-empty. */\n | { kind: 'declared'; headers: readonly string[] }\n /** A hook whose selectors the host did not declare — forces `no-store`. */\n | { kind: 'undeclared' }\n\n/**\n * Union `existing` with {@link ALWAYS_VARY_ON} and `extra`, case-insensitively\n * and order-stably.\n *\n * A union rather than a concatenation because `buildCorsHeaders` already emits\n * `Vary: Origin` on an allowed origin: appending would produce `Origin, Origin,\n * …`, which is legal but makes two otherwise byte-identical responses differ.\n */\nfunction mergeVary(existing: string | undefined, extra: readonly string[]): string {\n const seen = new Set<string>()\n const merged: string[] = []\n for (const raw of [...(existing?.split(',') ?? []), ...ALWAYS_VARY_ON, ...extra]) {\n const token = raw.trim()\n if (token === '') continue\n const key = token.toLowerCase()\n if (seen.has(key)) continue\n seen.add(key)\n merged.push(token)\n }\n return merged.join(', ')\n}\n\n/**\n * Combined response headers: CORS + Cache-Control + `Vary`.\n *\n * For an `'identified'` caller, returns `Cache-Control: no-store` regardless of\n * the per-key cache config — see file-level comment.\n *\n * **`Vary` goes on BOTH branches, and it is the `'anonymous'` one that needs\n * it.** RFC 9111 §4.1: a stored response with no `Vary` matches ANY subsequent\n * request for the same URL. The cacheable branch is the only response a shared\n * cache can actually store, so without a `Vary` the `public, max-age=…` body a\n * CDN stored for an anonymous caller is eligible to be replayed to the next\n * credentialed one — which silently defeats preview/authenticated reads at the\n * edge. (The direction is fail-safe: the replayed body is the PUBLIC one, so\n * there is no over-disclosure — but \"the edge quietly ignores your credential\"\n * is not a property to leave undeclared.) It was previously emitted on the\n * `no-store` branch ALONE, where it is inert: a response that must not be\n * stored has no stored variant to key.\n *\n * It is kept on the `no-store` branch too, identical, for two reasons: an\n * intermediary configured to cache regardless of `no-store` at least keys\n * correctly, and one expression for both branches removes the \"which branch\n * carries the header\" question that produced the original defect.\n *\n * **`authVary` decides WHICH selectors that `Vary` names, and whether the\n * cacheable branch is reachable at all** — see {@link AuthVaryPolicy}. An\n * `'undeclared'` policy collapses both branches onto `no-store`: a credential\n * can arrive and nothing says what selects it, so there is no correct variant\n * key and nothing is offered for storage.\n *\n * `cacheKey` is the entity name — whatever `CacheConfig.entities` is keyed by.\n * `GET /_menu` does NOT come through here; its body is principal-invariant, so\n * it builds its headers with {@link buildPrincipalInvariantHeaders} under the\n * reserved `_menu` key instead.\n *\n * **`tags` rides on the SAME decision that produced the directive** (D055 §6).\n * A cache tag is a promise that purging it invalidates this entry; a response\n * no cache may store has no entry to invalidate, so the header would be dead\n * weight at best and a second, unaudited channel of ids at worst. Making one\n * function own both answers is why \"does a refusal carry a tag\" is not a rule\n * ~40 call sites have to remember — {@link buildRefusalHeaders} takes no tag\n * argument at all, so a refusal structurally cannot carry one.\n *\n * Note the test is on the RESULT, not on `storable`: an anonymous caller still\n * gets `no-store` when the window is `0` or the entity is `cacheable: false`,\n * and those responses must not be tagged either.\n */\nexport function buildResponseHeaders(\n cacheKey: string,\n corsHeaders: Record<string, string>,\n caller: CallerCacheClass,\n cacheConfig: CacheConfig | undefined,\n authVary: AuthVaryPolicy,\n tags?: CacheTagSet,\n): Record<string, string> {\n // 🔴 Three conditions, and the third is not about the CALLER. The first two\n // ask \"may a shared cache hold a response shaped like this?\"; `uncacheable`\n // asks \"is THIS response one we already know is wrong to keep?\" A block tree\n // that reached editor D010's third state is served empty, and storing it\n // holds content-less pages at every CDN for a full TTL — with no purge key\n // that can evict them, because a blanked envelope carries no `refs`.\n //\n // Read off the tag set rather than taken as a parameter so a new endpoint\n // cannot forget it: that object is already threaded to every resolution pass\n // and every response site. See `CacheTagSink.markUncacheable`.\n const storable =\n caller !== 'identified' && authVary.kind !== 'undeclared' && tags?.uncacheable !== true\n const built = storable\n ? buildCacheHeaders(cacheKey, cacheConfig)\n : { 'Cache-Control': 'no-store' }\n // See `CallerCacheClass`: a site-keyed response is never offered to a cache\n // that two sites share.\n const directive = built['Cache-Control'] ?? 'no-store'\n const cacheControl = {\n 'Cache-Control':\n caller === 'site-keyed' ? directive.replace(/^public\\b/, 'private') : directive,\n }\n\n return {\n ...corsHeaders,\n ...cacheControl,\n ...(cacheControl['Cache-Control'] === 'no-store' ? {} : tags?.headers()),\n Vary: mergeVary(corsHeaders.Vary, authVary.kind === 'declared' ? authVary.headers : []),\n }\n}\n\n/**\n * Headers for a response whose body is the same for EVERY caller — today, `GET\n * /_menu` alone.\n *\n * A sibling of {@link buildResponseHeaders} rather than a call into it, because\n * the caller is deliberately not an input here: `_menu`'s resolver receives\n * `(handle, { locale, currentPath })` and nothing principal-derived, so there is\n * no caller-specific body for a shared cache to leak and no credential to select\n * on. Uniform headers are also what stops the `Cache-Control` shape from\n * advertising that a credential verified on an endpoint with no permission gate\n * (see `handleMenu`'s JSDoc).\n *\n * **It still needs `Vary`, and that is why this exists.** It was previously the\n * one cacheable response emitting none. An SSR frontend fetches `_menu`\n * server-side with no `Origin`, so `buildCorsHeaders` contributes nothing and a\n * CDN stores `public, max-age=…` with NO selector; per RFC 9111 §4.1 that entry\n * then matches a browser's request from an allowed origin and is replayed\n * WITHOUT the `Access-Control-Allow-Origin` that request needed, so the browser\n * blocks a response the server considered a success.\n */\nexport function buildPrincipalInvariantHeaders(\n cacheKey: string,\n corsHeaders: Record<string, string>,\n cacheConfig: CacheConfig | undefined,\n tags?: CacheTagSet,\n): Record<string, string> {\n // The SIBLING has to honour `uncacheable` too, and the reason is the whole\n // argument for putting the flag on the tag set rather than in a parameter: a\n // new endpoint inherits it. Half the builders inheriting it is not that.\n //\n // `_menu` cannot serve a block tree today — `handleMenu` never reaches\n // `resolveRows` — so this is a latent case rather than a live one. That is\n // exactly why it is worth closing now: the next principal-invariant endpoint\n // that resolves blocks would otherwise store a blanked body in silence, and\n // nothing about writing it would prompt anyone to look here.\n const cacheControl =\n tags?.uncacheable === true\n ? { 'Cache-Control': 'no-store' }\n : buildCacheHeaders(cacheKey, cacheConfig)\n return {\n ...corsHeaders,\n ...cacheControl,\n // Same rule, same reason as `buildResponseHeaders`: tags only on a response\n // that is actually stored. This branch is reachable — `maxStaleSeconds: 0`\n // or `cacheable: false` on the reserved `_menu` key yields `no-store` here\n // too, as does an `uncacheable` set.\n ...(cacheControl['Cache-Control'] === 'no-store' ? {} : tags?.headers()),\n // `Vary` is SET, not merged from the input — and that distinction is\n // load-bearing rather than stylistic.\n //\n // This used to be `mergeVary(corsHeaders.Vary, [])`, which inherits whatever\n // the caller happened to pass. That was harmless while the only input was\n // `buildCorsHeaders`' own `Vary: Origin` — and stopped being harmless the\n // moment `dispatch` began shadowing its headers with the D037 refusal set,\n // which carries the host's declared credential selectors. `_menu` then\n // started emitting `Vary: Origin, Authorization` for a body that provably\n // does not vary by `Authorization`, fragmenting the cache per credential on\n // the one endpoint whose whole design is to be absorbed by a CDN.\n //\n // Not a disclosure — the body and the directive stayed identical for every\n // caller — but a silent, compounding cache-hit-rate loss, and the kind of\n // defect that only appears when two correct-looking changes meet.\n //\n // Setting it makes the contract structural: this builder emits exactly the\n // variance its own name promises, whatever it is handed. A future caller\n // cannot widen it by accident.\n Vary: mergeVary(undefined, []),\n }\n}\n\n/* (A refusal is never stored, so it is priced under no cache key at all — SD005.) */\n\n/**\n * The CORS and `Vary` half of a refusal's headers. It deliberately does NOT\n * decide storability — `errorResponse` owns that, unconditionally (SD005).\n *\n * One owner for \"is it stored\", one for \"what does it vary on\". That division is\n * the point: this function previously computed a directive too, and the two\n * concerns drifting apart is how a refusal's storability came to depend on\n * `AuthVaryPolicy`, a host-config axis with nothing to do with refusals. See\n * `REFUSAL_CACHE_CONTROL` for the full history and why uniform won.\n *\n * `Vary` still matters on an unstorable response, for the same two reasons\n * {@link buildResponseHeaders} keeps it on its own `no-store` branch: an\n * intermediary configured to store regardless of `no-store` at least keys\n * correctly, and one expression for every branch removes the \"which branch\n * carries the header\" question that produced F030 in the first place.\n *\n * It is also what closes F030 on the refusal path specifically:\n * `buildCorsHeaders` contributes NOTHING when the request carries no `Origin` —\n * the normal case for an API whose callers phase 01 defines as long-lived server\n * processes — so a refusal previously carried no selector at all.\n */\nexport function buildRefusalHeaders(\n corsHeaders: Record<string, string>,\n authVary: AuthVaryPolicy,\n): Record<string, string> {\n return {\n ...corsHeaders,\n Vary: mergeVary(corsHeaders.Vary, authVary.kind === 'declared' ? authVary.headers : []),\n }\n}\n","/**\n * CORS — Origin allowlist with exact match.\n *\n * No wildcards, no regex. This is a CMS content API — you know which\n * frontends consume it. Vary: Origin ensures CDNs cache per-origin.\n */\n\nimport { buildCacheHeaders } from './cache.js'\nimport type { CacheConfig, CorsConfig } from './types.js'\n\n/**\n * `Vary` describes what the ENDPOINT varies on — a property of its\n * configuration — not a report on what THIS request happened to send (D040).\n *\n * This function used to return `{}` on three of its four paths (no CORS config,\n * no `Origin` header, or an `Origin` off the allowlist) and emit `Vary: Origin`\n * only on the fourth. So one cacheable URL produced responses with two different\n * `Vary` sets, and RFC 9111 §4.1 makes the STORED response's `Vary` govern\n * reuse: whichever request populated the entry decided the keying for everyone\n * after it. Populate it from a server-side fetch — no `Origin`, and phase 01\n * defines this API's callers as long-lived server processes, so that is the\n * NORMAL case — and a later browser request from an allowlisted origin matches\n * that selector-less entry and is blocked for want of an\n * `Access-Control-Allow-Origin` (F030).\n *\n * Calibrated: this fails CLOSED. The reverse direction was impossible, because a\n * response minted for origin A always carried `Vary: Origin` and so could never\n * be reused for origin B. The defect is availability and nondeterminism — whether\n * a browser consumer works depended on cache-population order, which is the\n * class of bug that reproduces in production and not in CI.\n *\n * When no CORS config exists the endpoint provably does NOT vary by `Origin`, so\n * nothing is emitted: adding a key dimension for an axis the response does not\n * vary on would fragment the key for nothing, and key fragmentation on this\n * surface is not free (D042).\n */\nexport function buildCorsHeaders(\n req: Request,\n config: CorsConfig | undefined,\n): Record<string, string> {\n if (!config) return {}\n\n const origin = req.headers.get('Origin')\n // Exact match only — no regex, no wildcards.\n const allowed = origin !== null && config.allowedOrigins.includes(origin)\n\n if (!allowed) {\n // The endpoint still varies by Origin; this request just does not get the\n // allow header. Declaring the variance is the whole point of D040.\n return { Vary: 'Origin' }\n }\n\n return {\n 'Access-Control-Allow-Origin': origin,\n 'Access-Control-Allow-Methods': 'GET, OPTIONS',\n 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',\n 'Access-Control-Max-Age': String(config.maxAge ?? 3600),\n Vary: 'Origin',\n }\n}\n\n/**\n * The 204 preflight.\n *\n * Gets the declared staleness window like every other response (D037 part 3):\n * its content is a function of the `Origin` and a fixed method/header set and\n * varies on nothing else. It previously carried CORS headers alone and NO\n * `Cache-Control` at all — `Access-Control-Max-Age` governs the BROWSER's\n * preflight cache, not a shared one, so a shared cache was left to a vendor\n * heuristic here too.\n *\n * Priced under the reserved `_preflight` key so a host cannot make it diverge\n * from the surface window per entity — a preflight has no entity.\n */\nexport function corsPreflightResponse(\n req: Request,\n config: CorsConfig | undefined,\n cacheConfig: CacheConfig | undefined,\n): Response {\n const headers = {\n ...buildCorsHeaders(req, config),\n ...buildCacheHeaders('_preflight', cacheConfig),\n }\n return new Response(null, { status: 204, headers })\n}\n","/**\n * Availability guard for the public content surface — plan/api PR 6.\n *\n * TWO MECHANISMS WITH DIFFERENT TRIGGER CONDITIONS (D025), not one limiter.\n * The obvious move was to lift `@murumets-ee/search`'s `SearchMechanics` onto\n * this surface — CLAUDE.md names it as the reference implementation. Doing that\n * unchanged would have shipped an amplifier: `enforceRateLimit(userId, ip)`\n * requires BOTH keys to pass, and its reasoning depends entirely on those keys\n * DISTINGUISHING callers. On an anonymous public surface both are constants —\n * `getClientIp` is optional and falls back to a literal, and every\n * unauthenticated caller resolves to the same public principal. Two constant\n * keys is not a limiter with its per-IP half disabled; it is an aggregate cap on\n * every visitor on Earth, which fails CLOSED for all of them simultaneously at\n * whatever rate one attacker chooses (F011).\n *\n * So:\n *\n * - {@link AvailabilityGuard.acquire} / {@link AvailabilityGuard.runGuarded} —\n * SURVIVAL. Bounded concurrency, a bounded wait queue, and a per-request wall\n * clock. Keyless BY CONSTRUCTION: there is no configuration field whose\n * absence disables them, so no host can lose them by omission.\n * - {@link AvailabilityGuard.consumeFairness} — FAIRNESS. Per-caller, and\n * applied ONLY when a distinguishing key exists. When none does it is SKIPPED\n * (returns `undefined`) and never degrades into a shared bucket. That looks\n * like a weakening and is the opposite: the protection anonymous traffic\n * actually needs is the keyless one, and a shared bucket buys nothing except\n * a cheaper way to take the site down.\n *\n * WHAT THE CONCURRENCY CEILING BOUNDS, because the name is load-bearing\n * (SD004). `maxConcurrentRequests` counts IN-FLIGHT REQUESTS, not database\n * connections. D025 originally pinned it below the `db.readOnly` pool size (10)\n * on the premise that \"there is no `statement_timeout` anywhere in the repo, so\n * ten slow requests saturate the pool\". PR 2 of this plan shipped exactly that\n * `statement_timeout` (5s default), so the premise is gone — and D025's own\n * ordering constraint (below) forces the slot to be held for the WHOLE request\n * rather than just the query, at which point a ceiling of 8 would cap this\n * surface at ~145 rps against the ~540 rps this plan measured. A field pinned to\n * the pool size would invite the belief that one slot equals one connection, and\n * that belief is false the moment a request does per-document resolution work.\n *\n * What bounds the CONNECTION layer is stated rather than implied by this number:\n * postgres-js's own pool queue, plus PR 2's `statement_timeout`. This guard's\n * job is to bound unbounded in-flight pile-up and to SHED rather than queue\n * forever. Those are different jobs.\n *\n * ORDERING IS A SECURITY CONSTRAINT, NOT A PERFORMANCE ONE (D025). Both\n * mechanisms are evaluated BEFORE the entity-whitelist and `view` gates. A limit\n * that only fired for entities that exist would let a caller distinguish \"not in\n * the whitelist\" from \"whitelisted but throttled\" by observing which paths can\n * be made to 429 — an enumeration oracle over the served set, delivered through\n * the abuse-resistance layer, defeating the 404-not-403 rule D006 exists to\n * enforce.\n *\n * PER-INSTANCE, AND THE FIELD NAMES SAY SO (D028). An in-memory limiter on N\n * replicas enforces N x the configured value. The defect is not the `Map` — it\n * is the unstated claim: a field spelled `tokens: 120` invites every reader to\n * believe 120 is what the deployment enforces. No distributed limiter ships in\n * v1; {@link ContentApiLimits.store} is the seam for one, and\n * `@murumets-ee/merit`'s `rate-governor.ts` is the worked reference.\n *\n * NOT A FIFTH COPY (D033). Every primitive here comes from\n * `@murumets-ee/core/limits`.\n */\n\nimport {\n InMemoryRateLimitStore,\n type RateLimitDecision,\n type RateLimitStore,\n runWithDeadline,\n Semaphore,\n} from '@murumets-ee/core/limits'\n\n/**\n * Thrown by {@link AvailabilityGuard.acquire} when both the concurrency slots\n * and the wait queue are full. Distinct from a fairness refusal: this one is\n * not about the caller.\n */\nexport class ServiceOverloadedError extends Error {\n constructor() {\n super('Service overloaded')\n this.name = 'ServiceOverloadedError'\n }\n}\n\n/** Thrown by {@link AvailabilityGuard.runGuarded} when the wall clock fires. */\nexport class RequestDeadlineError extends Error {\n constructor() {\n super('Request deadline exceeded')\n this.name = 'RequestDeadlineError'\n }\n}\n\n/**\n * In-flight request ceiling. Sized ABOVE the measured healthy operating point\n * (~540 rps at p95 55ms implies ~30 concurrent by Little's law) so the guard\n * never sheds on healthy traffic — shedding at the normal operating point is\n * the failure this guard exists to prevent, arriving from the other side.\n */\nconst DEFAULT_MAX_CONCURRENT_REQUESTS = 64\n\n/** Burst absorber. Sustained arrival above service capacity still sheds. */\nconst DEFAULT_MAX_QUEUED_REQUESTS = 128\n\n/**\n * Wall clock. Deliberately well above PR 2's 5s `statement_timeout`: a request\n * makes at most two queries plus bounded resolution work, so a request that has\n * burned 10s is not slow, it is stuck.\n */\nconst DEFAULT_REQUEST_TIMEOUT_MS = 10_000\n\n/** Default fairness budget for traffic carrying a distinguishing key. */\nconst DEFAULT_ANONYMOUS_TOKENS = 120\nconst DEFAULT_INTERVAL_MS = 60_000\n\n/**\n * Server-owned ceiling on any credential's budget (D029, C5). A credential's\n * budget is set when it is ISSUED — an operator action — and clamped here. It\n * is never raisable by a request parameter.\n */\nconst DEFAULT_MAX_BUDGET_PER_INSTANCE = 6_000\n\nexport interface ContentApiLimits {\n /**\n * Max requests IN FLIGHT at once, per instance. NOT a connection ceiling —\n * see the module docblock. Default {@link DEFAULT_MAX_CONCURRENT_REQUESTS}.\n */\n maxConcurrentRequests?: number\n\n /**\n * Max requests queued waiting for a slot before the guard sheds 503, per\n * instance. Default {@link DEFAULT_MAX_QUEUED_REQUESTS}.\n */\n maxQueuedRequests?: number\n\n /** Per-request wall clock in ms. Default {@link DEFAULT_REQUEST_TIMEOUT_MS}. */\n requestTimeoutMs?: number\n\n /**\n * Fairness budget applied to UNCREDENTIALED traffic, PER INSTANCE.\n *\n * Only consumed when a distinguishing key exists — which for anonymous\n * traffic means {@link getClientIp} is wired. Without it the fairness limit is\n * skipped entirely rather than collapsed onto one shared bucket (F011).\n */\n anonymousTokensPerInstance?: number\n\n /**\n * Interval for every fairness budget on this surface, in ms. Default 60_000.\n */\n intervalMs?: number\n\n /**\n * Ceiling on any credential's own budget, PER INSTANCE. A credential\n * presenting a larger `rateBudgetPerInstance` is clamped to this. C5: the\n * ceiling moves by operator action, never by request parameter.\n */\n maxBudgetPerInstance?: number\n\n /**\n * How the host extracts a trustworthy client IP — the SECOND fairness key,\n * used when no server-verified principal identity exists.\n *\n * Deliberately optional and deliberately host-supplied: only the deployment\n * knows whether it sits behind a proxy it trusts, and a package-side default\n * would either trust a forgeable header or invent a constant. Returning\n * `undefined` (or omitting this) means anonymous traffic has NO distinguishing\n * key, so the fairness limit is SKIPPED for it. That is the correct outcome,\n * not a degraded one.\n */\n getClientIp?: (req: Request) => string | undefined\n\n /**\n * The fairness counter's backing store (D028 part 3). Defaults to an\n * in-memory, per-instance one. Supply a shared implementation only if a global\n * quota is genuinely needed; see the module docblock.\n */\n store?: RateLimitStore\n}\n\n/** Why a fairness decision came out the way it did — for the log, not the wire. */\nexport type FairnessKeyClass = 'principal' | 'client-ip' | 'none'\n\nexport interface FairnessOutcome {\n readonly decision: RateLimitDecision\n readonly keyClass: Exclude<FairnessKeyClass, 'none'>\n}\n\nexport interface ResolvedLimits {\n readonly maxConcurrentRequests: number\n readonly maxQueuedRequests: number\n readonly requestTimeoutMs: number\n readonly anonymousTokensPerInstance: number\n readonly intervalMs: number\n readonly maxBudgetPerInstance: number\n}\n\nfunction nonNegativeIntOr(value: number | undefined, fallback: number, field: string): number {\n if (value === undefined) return fallback\n if (!Number.isInteger(value) || value < 0) {\n throw new Error(\n `content-api limits: \\`${field}\\` must be a non-negative integer (got ${value})`,\n )\n }\n return value\n}\n\nfunction positiveIntOr(value: number | undefined, fallback: number, field: string): number {\n if (value === undefined) return fallback\n if (!Number.isInteger(value) || value < 1) {\n throw new Error(`content-api limits: \\`${field}\\` must be a positive integer (got ${value})`)\n }\n return value\n}\n\nexport function resolveLimits(limits: ContentApiLimits | undefined): ResolvedLimits {\n return {\n maxConcurrentRequests: positiveIntOr(\n limits?.maxConcurrentRequests,\n DEFAULT_MAX_CONCURRENT_REQUESTS,\n 'maxConcurrentRequests',\n ),\n // ZERO IS ALLOWED here and nowhere else in this block: it names a real,\n // coherent posture — \"shed immediately, never queue\" — which an operator who\n // prefers a fast 503 over a slow 200 has no other way to express.\n // `Semaphore` already accepts `maxWaiters === 0`; rejecting it here would\n // have been the config layer withholding a capability the mechanism has.\n // Every other field below is a ceiling whose zero value disables a bound\n // rather than tightening one, which is why they stay strictly positive.\n maxQueuedRequests: nonNegativeIntOr(\n limits?.maxQueuedRequests,\n DEFAULT_MAX_QUEUED_REQUESTS,\n 'maxQueuedRequests',\n ),\n requestTimeoutMs: positiveIntOr(\n limits?.requestTimeoutMs,\n DEFAULT_REQUEST_TIMEOUT_MS,\n 'requestTimeoutMs',\n ),\n anonymousTokensPerInstance: positiveIntOr(\n limits?.anonymousTokensPerInstance,\n DEFAULT_ANONYMOUS_TOKENS,\n 'anonymousTokensPerInstance',\n ),\n intervalMs: positiveIntOr(limits?.intervalMs, DEFAULT_INTERVAL_MS, 'intervalMs'),\n maxBudgetPerInstance: positiveIntOr(\n limits?.maxBudgetPerInstance,\n DEFAULT_MAX_BUDGET_PER_INSTANCE,\n 'maxBudgetPerInstance',\n ),\n }\n}\n\n/**\n * The identity inputs the fairness limit keys on. Supplied by the caller rather\n * than read from ambient state, so the guard stays testable (phase 09's S4\n * requirement: a guard that reaches for its own inputs is a guard nobody can\n * watch fail).\n */\nexport interface FairnessSubject {\n /** The resolved principal id, or `undefined` when the caller is anonymous. */\n readonly principalId: string | undefined\n /** That principal's own per-instance budget, if the credential carries one. */\n readonly budgetPerInstance: number | undefined\n}\n\nexport class AvailabilityGuard {\n private readonly semaphore: Semaphore\n private readonly store: RateLimitStore\n readonly limits: ResolvedLimits\n\n private readonly getClientIp: ((req: Request) => string | undefined) | undefined\n\n constructor(limits: ContentApiLimits | undefined) {\n // Read off `limits` rather than taken as a second argument. It was both,\n // which is two sources for one value: a future call site passing only\n // `limits` would get `getClientIp: undefined`, so `resolveKey` would return\n // undefined for every anonymous caller and the fairness limit would silently\n // stop applying — a security-relevant regression with no compile error and\n // no failing test, because \"no key means no limit\" is the DESIGNED\n // behaviour (D025) and is indistinguishable from this bug at the boundary.\n this.getClientIp = limits?.getClientIp\n this.limits = resolveLimits(limits)\n this.semaphore = new Semaphore(this.limits.maxConcurrentRequests, this.limits.maxQueuedRequests)\n // ONE store for every key, because the ceiling travels with the CALL rather\n // than with the store (D029): a credentialed SSR frontend and an anonymous\n // visitor share this map and not their budgets.\n this.store = limits?.store ?? new InMemoryRateLimitStore()\n }\n\n /**\n * Take an in-flight slot, or throw {@link ServiceOverloadedError} when both\n * the slots and the queue are full. Pair every RESOLVED call with exactly one\n * {@link release} in a `finally`.\n *\n * `signal` makes a QUEUED acquire abortable, so a caller who disconnects while\n * waiting leaves the queue instead of waking to take a slot and run work whose\n * response is already gone.\n */\n async acquire(signal?: AbortSignal): Promise<void> {\n await this.semaphore.acquire(() => new ServiceOverloadedError(), signal)\n }\n\n release(): void {\n this.semaphore.release()\n }\n\n /**\n * Run `work` under the wall clock.\n *\n * `work` is a PARAMETER rather than something this class reaches for, which is\n * what makes the clock observable: a test hands it a promise that never\n * resolves and watches the deadline end the request. A guard that closed over\n * `getApp().db` could not be tested at all, and an untestable guard is one\n * nobody will ever watch fail.\n */\n async runGuarded<T>(work: (signal: AbortSignal) => Promise<T>): Promise<T> {\n return await runWithDeadline(\n work,\n this.limits.requestTimeoutMs,\n () => new RequestDeadlineError(),\n )\n }\n\n /**\n * Resolve the fairness key and consume one token, or return `undefined` when\n * NO distinguishing key exists.\n *\n * `undefined` means \"not applied\", never \"allowed by a shared bucket\" (D025,\n * F011). The precedence is: a server-verified principal first, a host-declared\n * client-IP source second, nothing third.\n *\n * Counting is FLAT — one request, one token (D029). What this surface governs\n * spans 0 to 2 queries and is flat in collection size, and both genuinely\n * variable shapes (search, per-document resolution) are bounded by other\n * mechanisms. A weighting table would price a spread that has already been\n * collapsed, and a new endpoint added without a weight would silently cost 1 —\n * a check that cannot fail.\n */\n consumeFairness(\n req: Request,\n subject: FairnessSubject,\n now?: number,\n ): FairnessOutcome | undefined {\n const resolved = this.resolveKey(req, subject)\n if (!resolved) return undefined\n const decision = this.store.consume(\n resolved.key,\n { tokens: this.budgetFor(subject), intervalMs: this.limits.intervalMs },\n now,\n )\n return { decision, keyClass: resolved.keyClass }\n }\n\n private budgetFor(subject: FairnessSubject): number {\n const declared = subject.budgetPerInstance ?? this.limits.anonymousTokensPerInstance\n // C5: clamped server-side. A credential cannot raise its own ceiling past\n // what the surface allows, and the clamp is silent by design — a credential\n // issued with a too-large budget is an operator error, not a caller's.\n return Math.min(declared, this.limits.maxBudgetPerInstance)\n }\n\n private resolveKey(\n req: Request,\n subject: FairnessSubject,\n ): { key: string; keyClass: Exclude<FairnessKeyClass, 'none'> } | undefined {\n if (subject.principalId !== undefined) {\n return { key: `principal:${subject.principalId}`, keyClass: 'principal' }\n }\n const ip = this.getClientIp?.(req)\n if (ip !== undefined && ip !== '') {\n return { key: `ip:${ip}`, keyClass: 'client-ip' }\n }\n return undefined\n }\n}\n","/**\n * Query parameter validation — Zod schemas for all content API endpoints.\n *\n * All user input is validated before it touches QueryClient.\n * Pagination hard-capped at MAX_LIMIT / MAX_OFFSET — exceeding either is a 400.\n * NOTE for consumers of this published subpath: only `limit`'s cap is enforced\n * by the schema. `offset`'s is enforced by the HANDLER (`ceilingViolation`), so\n * that an over-large offset can carry the `?cursor=` remedy instead of a bare\n * \"Invalid query parameters\" — see the comment on the field. Parsing a request\n * with this schema alone therefore bounds `limit` and not `offset`,\n * never a clamp. Sort/filter restricted to queryable columns.\n */\n\nimport { escapeLikePattern, LOCALE_PATTERN } from '@murumets-ee/core'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { z } from 'zod'\n\nexport const MAX_LIMIT = 100\n\n/**\n * Absolute ceiling on `?offset=`. Beyond it the answer is a 400\n * `VALIDATION_ERROR`, never a silent clamp — a clamp cannot be observed\n * failing, and it would leave a deep-paging client re-reading the same page\n * forever. `?cursor=` is the supported path past this point.\n *\n * The cost being bounded is a full traversal of the MATCHING set on every\n * request: `QueryClient` issues a plain SQL `OFFSET`, so the work is\n * `O(min(offset, matching rows))` — bounded by the result set, not by the\n * caller's integer. That makes a large offset cheap on a small collection and\n * real on a large one, from a caller who is unauthenticated by default with no\n * rate limit in front of them.\n */\nexport const MAX_OFFSET = 1000\n\nexport const MAX_FILTER_TEXT_LENGTH = 200\n\n/**\n * Page size applied when the caller sends no `?limit=`.\n *\n * Named rather than inlined because `GET /_schema` PUBLISHES it: a client that\n * reads the default off the wire and one that hard-codes it can never disagree,\n * and lowering it here lowers what the contract advertises in the same edit.\n * Same reasoning for every other cap in this block.\n */\nexport const DEFAULT_LIMIT = 20\n\n/** Offset applied when the caller sends no `?offset=`. */\nexport const DEFAULT_OFFSET = 0\n\n/** Longest `?search=` accepted — rejected as a 400 before any DB work. */\nexport const MAX_SEARCH_LENGTH = 200\n\n/**\n * Shortest `?search=` accepted, measured AFTER trim. Below it: `400\n * VALIDATION_ERROR` (D046).\n *\n * **Its direction is inverted from every other ceiling here, deliberately.** A\n * limit like `maxLimit` is protective when LOWERED, so a host may only lower it.\n * A minimum is protective when RAISED, so the package sets the floor and a host\n * may make it stricter and never looser. Same rule in both cases — the caller\n * cannot move it, and the host cannot move it toward more work.\n *\n * No new error code: D031's partition rule is that a code names what the client\n * should do next, never which parameter was wrong, and `VALIDATION_ERROR`\n * already means \"fix your request\". Rejecting rather than silently returning an\n * empty set is the other half — a silent empty result is unobservable, so the\n * check could never be SEEN to fail, and a frontend could not tell the user to\n * type more.\n *\n * **BE HONEST ABOUT WHAT THIS BUYS.** It removes the most degenerate case —\n * `%a%` matching essentially every row, forcing a full scan and returning an\n * arbitrary page — and removes nothing else; `%ab%` on a Latin-script corpus\n * still matches almost everything. THE BOUND ON SEARCH COST IS THE SURVIVAL\n * GUARD'S WALL CLOCK AND SEMAPHORE (see `limits.ts`), NOT THIS FLOOR, and it\n * must not be presented in `_schema`, in a docstring or in review as though it\n * were the protection.\n *\n * Nor is it peer convention: R15 verified that Algolia enforces no server-side\n * minimum (its `minLength` is a client-side InstantSearch affordance) and found\n * no documented one for Contentful or Sanity. The justification is local — an\n * unindexed `ILIKE '%q%'` OR'd with a correlated per-row `EXISTS` on the\n * translations table, with no trigram index anywhere in this repo.\n *\n * 2 matches `DEFAULT_MECHANICS.minQueryLength` in `@murumets-ee/search`.\n */\nexport const MIN_SEARCH_LENGTH = 2\n\n/**\n * Zero-width and word-joiner characters, stripped before the floor is measured.\n *\n * `String.prototype.trim()` removes Unicode `White_Space`, which does NOT\n * include U+200B ZERO WIDTH SPACE and friends. So `?search=a` trims to\n * length 2, clears the floor, and produces the `ILIKE '%a%'` full scan the\n * floor exists to refuse — while matching nothing, so the caller pays no penalty\n * for the trick. Stripping them costs one regex on a string already capped at\n * 200 characters.\n *\n * Not exhaustive against every homoglyph or combining sequence, and not meant to\n * be: per {@link MIN_SEARCH_LENGTH}, the floor is a cheap filter on the worst\n * input and the survival guard is the actual bound. This closes the one bypass\n * that costs nothing to close.\n */\n// Written as escapes, not literals: the characters are invisible in source, so\n// a literal class is unreviewable and un-greppable.\n// U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 WORD JOINER, U+FEFF BOM.\nconst ZERO_WIDTH_RE = /[\\u200B-\\u200D\\u2060\\uFEFF]/g\n\n/** Trim, then discard zero-width padding, for length measurement only. */\nfunction normalizeSearchLengthInput(raw: string): string {\n return raw.replace(ZERO_WIDTH_RE, '').trim()\n}\n\n/** Longest `?cursor=` accepted. Opaque base64 JSON; the cap bounds the decode. */\nexport const MAX_CURSOR_LENGTH = 500\n\n/** Longest `?path=` accepted by `_resolve` — a generous URL-path cap. */\nexport const MAX_RESOLVE_PATH_LENGTH = 2048\n\n/** Longest `?entity=` accepted by `_paths` / `_alternates`. */\nexport const MAX_ENTITY_NAME_LENGTH = 100\n\n/**\n * Longest `?handle=` accepted by `_menu` — a defensive cap, deliberately NOT\n * tied to the menu entity's `handle` column width (`varchar(255)`). A handle is\n * a hand-authored navigation key, so 128 is far above any real one; the point is\n * only to bound the string before it reaches the allowlist check, never to\n * mirror the column. An over-long value is a 400 either way.\n */\nexport const MAX_MENU_HANDLE_LENGTH = 128\n\n/** Page size `_paths` applies when the caller sends no `?limit=`. */\nexport const DEFAULT_PATHS_LIMIT = 20\n\n/**\n * Most reference fields one request may `?populate=` (D013).\n *\n * Server-owned and non-raisable: full expansion replaces a fixed-size\n * projection with the target's whole public DTO, so each named field makes the\n * response's size a function of what it happens to point at. Exceeding this is\n * a 400 `VALIDATION_ERROR`, never a silent truncation — the same reject-never-\n * clamp posture D007 fixed for `?offset=`, and for the same reason: a clamp\n * answers 200 with something the caller did not ask for and cannot be observed\n * failing from the outside.\n *\n * Five, because it bounds a MULTIPLIER rather than a count. The number of rows\n * is already bounded by `maxLimit`, and the number of resolutions by the\n * per-document budget; this bounds how much each row can grow.\n */\nexport const MAX_POPULATE_FIELDS = 5\n\n/**\n * Longest `?populate=` string accepted, before it is split. Bounds the parse\n * itself, independently of the field-count ceiling — a caller must not be able\n * to hand us a megabyte to `split(',')`.\n */\nexport const MAX_POPULATE_LENGTH = 200\n\n/** List endpoint query params. */\n/**\n * `.strict()` on every schema in this module: an unrecognized parameter is a 400\n * `VALIDATION_ERROR`, not a silently-stripped key (D042).\n *\n * Zod's default is `strip`, so `?limit=20` and `?limit=20&anything=1` returned a\n * BYTE-IDENTICAL body under two distinct URLs — and so did the next million\n * values of `anything`. On a URL-keyed shared cache that is unbounded,\n * caller-controlled key fragmentation, worse than an ordinary miss in both\n * directions at once: every fabricated key MISSES (so it reaches the origin) and\n * OCCUPIES an entry (so it evicts the working set serving real visitors). No\n * credential required, a few bytes per request (F031).\n *\n * This is the whitelist rule reaching the last input surface still running\n * blacklist-shaped — filter keys, sort fields, cursor fields and `?fields=`\n * already go through `isInternalField`, and `config.entities` / `menus.handles`\n * are already deny-by-default allowlists.\n *\n * **What this does and does not stop, stated precisely.** The parse precedes\n * every query, so a fabricated-key request is refused BEFORE any database work —\n * that is the whole of the protection, and it is real: the flood costs a schema\n * parse rather than two queries. It is NOT absorbed at the edge. An earlier\n * revision of this docblock said it was, on the strength of D037 giving that 4xx\n * a cacheable directive; SD005 made every refusal `no-store`, so 100% of such a\n * flood reaches the origin and is metered by the survival guard and the fairness\n * limit instead (D025). Left corrected rather than deleted because a stale\n * security rationale is the kind of comment a later risk assessment leans on.\n *\n * Breaking-shaped and free: D010 records that no content-api consumer is live,\n * and D008's \"a client derives nothing\" means a conforming client reads its\n * parameter set from `_schema` and can never send one the server did not\n * publish.\n *\n * **What it does NOT bound: parameter VALUES.** `?search=` is a whitelisted name\n * with an unbounded value space inside its 200-character cap, so a fabricated\n * search value is still a guaranteed miss against the most expensive query on\n * the surface. That is F040, and it is the survival guard's and the fairness\n * limit's to meter, not this schema's.\n */\nexport const listParamsSchema = z\n .object({\n limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT),\n // NO `.max(MAX_OFFSET)` here — deliberately, and it is not an oversight.\n //\n // The handler's `ceilingViolation` answers an over-large offset with the\n // remedy the caller actually needs: \"?offset=N exceeds the maximum of M. Use\n // ?cursor= for deep pagination.\" A zod `.max()` fires FIRST and flattens that\n // to a bare \"Invalid query parameters\", so the remedy was unreachable for the\n // package ceiling and only appeared for a host-lowered one — the deep-paging\n // caller, i.e. exactly the caller who needs the hint, never saw it.\n //\n // Nothing is unbounded by removing it: `maxOffset` is `Math.min(config ?? MAX_OFFSET,\n // MAX_OFFSET)`, so the effective ceiling is still capped at the package value,\n // and `ceilingViolation` runs before any DB work. `limit` keeps its `.max()`\n // because it has no equivalent remedy to lose.\n offset: z.coerce.number().int().min(0).default(DEFAULT_OFFSET),\n sort: z\n .string()\n .regex(/^-?[a-zA-Z_]+$/)\n .optional(),\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n /**\n * `?search=` — see {@link MIN_SEARCH_LENGTH} for why the floor exists and\n * what it does NOT buy. Whitespace-only is treated as absent, matching the\n * pre-existing `buildSearchCondition` behaviour; anything else below the floor\n * is a 400.\n */\n search: z\n .string()\n .max(MAX_SEARCH_LENGTH)\n .refine(\n (raw) => {\n const trimmed = normalizeSearchLengthInput(raw)\n return trimmed.length === 0 || trimmed.length >= MIN_SEARCH_LENGTH\n },\n { message: `search must be at least ${MIN_SEARCH_LENGTH} characters` },\n )\n .optional(),\n /** Opaque cursor for keyset pagination (base64-encoded JSON). */\n cursor: z.string().max(MAX_CURSOR_LENGTH).optional(),\n /**\n * Comma-separated reference fields to expand to their target's full public\n * DTO (D013). The LENGTH cap is here so a malformed value is a 400 before it\n * is split; the FIELD-COUNT ceiling is enforced by the handler so the 400 can\n * name the ceiling — same division of labour as `limit` vs `offset` above.\n */\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n })\n .strict()\n\n/** Single entity query params. */\nexport const singleParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n })\n .strict()\n\n/** Count endpoint query params. */\nexport const countParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n })\n .strict()\n\n/**\n * Parameter NAMES this surface accepts dynamically rather than by fixed key.\n *\n * `filter[<field>]` is a published parameter family, read by\n * `buildFilterConditions` straight off `url.searchParams` in a pass separate\n * from the zod parse. It must be partitioned out before a `.strict()` parse or\n * every filtered request would 400 — see {@link toParamBag}.\n *\n * **ONE pattern, exported, used by BOTH the partition and the extraction.** They\n * were two copies of the same literal, and they had already drifted from what an\n * entity field name can actually be: the class excluded DIGITS, so\n * `filter[heading2]` silently matched neither. Before D042 that meant the filter\n * was quietly ignored; with `.strict()` in front it would have become a 400 on a\n * request that used to work — a regression manufactured by two copies\n * disagreeing about the same rule. Sharing the pattern makes that\n * unrepresentable rather than merely fixed.\n *\n * The capture group is the inner field name. It is whitelisted by a LATER layer\n * (`isInternalField` plus the entity's own field map), which is the right\n * division of labour: this bounds the cache-key space by parameter NAME, that\n * bounds which columns a filter may touch. A `filter[...]` whose inner name does\n * not match — `filter[a.b]`, `filter[]` — is NOT dynamic, falls through to the\n * strict parse, and is refused, which is the whitelist direction D042 asks for.\n */\nexport const FILTER_PARAM_PATTERN = /^filter\\[([a-zA-Z0-9_]+)\\]$/\n\n/**\n * Flatten a URL's query string into the object the param schemas parse, with\n * dynamically-named parameters removed.\n *\n * **This exists because `.strict()` cannot be applied to the raw bag (D042).**\n * Every endpoint parses `Object.fromEntries(url.searchParams)`, which mixes the\n * fixed keys with the dynamic `filter[...]` ones; a naive `.strict()` therefore\n * rejects every filtered list request. Verified by live repro against the pinned\n * `zod@3.25.76` (R15), which also confirmed zod v3 has no pattern-matched key\n * support — `.catchall()` accepts ANY extra key, which would defeat the purpose.\n * So the partition happens here, before the parse.\n *\n * Known and unchanged by this: a repeated parameter (`?limit=1&limit=2`)\n * collapses to the last value, because `Object.fromEntries` over\n * `URLSearchParams.entries()` is last-write-wins. That is a property of the\n * flattening step, not of strictness.\n */\nexport function toParamBag(url: URL, opts?: { allowFilters?: boolean }): Record<string, string> {\n const bag: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n // The exemption is SCOPED to the one endpoint that reads these keys.\n // Stripping them everywhere made `_menu?handle=main&filter[x]=y` silently\n // ignore the filter, which is the blacklist-shaped behaviour D042 exists to\n // replace — and it is precisely the endpoint whose responses are the most\n // cacheable, so an ignored-but-accepted parameter is a free cache-buster\n // there. On every other route a `filter[...]` is simply unknown, and\n // unknown means 400.\n if (opts?.allowFilters === true && FILTER_PARAM_PATTERN.test(key)) continue\n bag[key] = value\n }\n return bag\n}\n\n// Re-export from core so existing imports from this module continue to work\nexport { escapeLikePattern }\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * ISO-8601 calendar date or full timestamp.\n * Accepts: 2024-01-31, 2024-01-31T12:34:56Z, 2024-01-31T12:34:56.789+02:00.\n * Note: shape only — calendar validity (e.g. month 1–12, valid day-of-month)\n * is enforced separately by `isValidIsoDate` so impossible-date values cannot\n * reach Postgres and trigger a 500 cast error.\n */\nconst ISO_DATE_RE =\n /^\\d{4}-\\d{2}-\\d{2}(?:[Tt ]\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?)?$/\n\n/**\n * Validate ISO-8601 shape AND calendar validity.\n * `Date.parse('2024-13-01')` returns NaN; `Date.parse('2024-02-30')` rolls over\n * to 2024-03-01 in some hosts but produces a different YYYY-MM-DD prefix from\n * the input — comparing the round-tripped date prefix catches both cases.\n */\nexport function isValidIsoDate(raw: string): boolean {\n if (!ISO_DATE_RE.test(raw)) return false\n const ms = Date.parse(raw)\n if (Number.isNaN(ms)) return false\n // For bare YYYY-MM-DD, confirm the parsed date round-trips to the same prefix.\n if (raw.length === 10) {\n return new Date(ms).toISOString().slice(0, 10) === raw\n }\n return true\n}\n\n/** True if `s` parses as a finite number with no trailing junk. */\nfunction isNumericLiteral(s: string): boolean {\n if (s.trim() === '') return false\n const n = Number(s)\n return Number.isFinite(n)\n}\n\n/** True if `id` matches the canonical UUID format used by entity primary keys. */\nexport function isUuid(id: string): boolean {\n return UUID_RE.test(id)\n}\n\n/**\n * Validate a filter value against the field type.\n * Returns the validated value (possibly coerced) or null if invalid.\n *\n * For `date`, accepts a `gte:` / `lte:` prefix; the prefix itself is preserved\n * so the caller can route to the right comparison operator. The bare value\n * (with prefix stripped) must match ISO-8601.\n */\nexport function validateFilterValue(fieldConfig: FieldConfig, raw: string): unknown {\n switch (fieldConfig.type) {\n case 'boolean':\n if (raw === 'true') return true\n if (raw === 'false') return false\n return null\n\n case 'reference':\n case 'media':\n return UUID_RE.test(raw) ? raw : null\n\n case 'select':\n return fieldConfig.options.includes(raw) ? raw : null\n\n case 'date': {\n const bare = raw.startsWith('gte:') || raw.startsWith('lte:') ? raw.slice(4) : raw\n return isValidIsoDate(bare) ? raw : null\n }\n\n case 'number':\n return isNumericLiteral(raw) ? Number(raw) : null\n\n case 'text':\n return raw.length <= MAX_FILTER_TEXT_LENGTH ? raw : null\n\n default:\n return null\n }\n}\n\n/**\n * True when a field is off-limits on this public surface — server-managed\n * (INTERNAL) or ADMIN-ONLY — and therefore absent from rows and `_schema` and\n * refused as a caller-supplied field name (filter, sort, cursor, `?fields=`).\n *\n * The single predicate behind every caller-input boundary in this package, and\n * the two rules `shapeDto` applies to internal fields, plus a third:\n * 1. **Naming convention** — a `_`-prefixed name (`_scopeId`, `_versionId`,\n * `_workflowStatus`, …), checked even when the field isn't declared, so\n * generator-added infrastructure columns are covered too.\n * 2. **Explicit flag** — `internal: true`, which catches fields that don't\n * follow the underscore convention.\n * 3. **Admin-only** — `adminOnly: true` (`auditable()`'s `createdBy` /\n * `updatedBy`). `shapeDto` strips these by a separate gate, so `QueryClient`\n * never returns them; this rule keeps them out of filter/sort too (#1141).\n *\n * Rule 2 matters independently of rule 1: without it a field declared\n * `internal: true` under a plain name is filterable and sortable by any\n * anonymous caller, which is a blind oracle over a server-managed value even\n * though the field never appears in the response body.\n *\n * Since D024 removed `ContentApiConfig.exposeInternalFields`, this predicate is\n * the WHOLE of the rule rather than half of it: an internal field is neither\n * emitted in the read projection nor reachable as a filter, sort, cursor or\n * `?fields=` name — in any configuration, because there is no configuration.\n * That is what lets `_schema` describe the read projection exactly.\n */\nexport function isInternalField(name: string, config: FieldConfig | undefined): boolean {\n // `adminOnly` (e.g. `auditable()`'s `createdBy`/`updatedBy`, #1141) is not\n // internal to the admin, but on THIS surface it is exactly as off-limits:\n // `QueryClient` already never emits it, and without this rule it would stay\n // filterable and sortable — a blind oracle over which user edited what.\n return name.startsWith('_') || config?.internal === true || config?.adminOnly === true\n}\n\n/**\n * Parse field selection string into validated field names.\n * Unknown/internal field names are silently dropped (no info leakage).\n *\n * `undefined` (= \"no projection, return every public field\") is returned ONLY\n * when the caller supplied no `fields` param at all. A caller who DID ask to\n * narrow but named nothing valid gets `['id']`, not `undefined` — otherwise the\n * most restrictive request possible (`?fields=_overrides`) would widen to the\n * full row, which is the opposite of what was asked for.\n */\nexport function parseFieldSelection(\n fieldsParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): string[] | undefined {\n if (!fieldsParam) return undefined\n\n const requested = fieldsParam.split(',').map((f) => f.trim())\n const valid = requested.filter((name) => {\n // `Object.hasOwn` (not a bare lookup) so prototype-chain keys\n // (`constructor`, `toString`, …) can never resolve to a \"config\".\n if (!Object.hasOwn(allFields, name)) return false\n const config = allFields[name]\n return config !== undefined && !isInternalField(name, config) && config.type !== 'blockTree'\n })\n\n // Always include id — including when nothing else survived, so a narrowing\n // request can never widen back to the full row (see JSDoc).\n if (!valid.includes('id')) {\n valid.unshift('id')\n }\n\n return valid\n}\n\n/** Outcome of parsing `?populate=` — accepted field names plus what was refused. */\nexport interface ParsedPopulate {\n /** Reference fields to expand. De-duplicated, declaration order not preserved. */\n fields: string[]\n /** Names refused by the whitelist — logged server-side at debug, never on the wire. */\n dropped: string[]\n /**\n * How many DISTINCT names the caller asked for, before the whitelist ran.\n * The ceiling is checked against what was ASKED, not against what survived:\n * counting survivors would let a caller name 50 fields and get a 200 as long\n * as 45 of them were typos, so the ceiling would refuse a correct request and\n * wave through a malformed one.\n */\n requested: number\n}\n\n/**\n * Parse `?populate=a,b` into the reference fields to expand (D013).\n *\n * **Whitelist, not blacklist, and it is the SAME whitelist every other\n * caller-supplied field name passes** — `Object.hasOwn` against the declared\n * fields (so prototype-chain keys cannot resolve to a \"config\"), then\n * {@link isInternalField}, then a type check. A name that does not exist, is\n * internal, or is not a `reference` field is DROPPED rather than refused: on\n * the wire that is indistinguishable from an entity that has no such field,\n * which is the same 404-not-403 reasoning the entity gate uses one level up. An\n * over-large field COUNT is a different thing and is refused loudly by the\n * handler — the server never explains a missing target, but it always rejects a\n * malformed request.\n *\n * Note `dropped` carries the refused names for the operator log ONLY. Echoing\n * them back would turn `?populate=` into a field-existence oracle over every\n * entity a caller can view.\n */\nexport function parsePopulate(\n populateParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): ParsedPopulate {\n if (!populateParam) return { fields: [], dropped: [], requested: 0 }\n\n const requested = [\n ...new Set(\n populateParam\n .split(',')\n .map((f) => f.trim())\n .filter((f) => f.length > 0),\n ),\n ]\n\n const fields: string[] = []\n const dropped: string[] = []\n for (const name of requested) {\n if (!Object.hasOwn(allFields, name)) {\n dropped.push(name)\n continue\n }\n const config = allFields[name]\n if (!config || isInternalField(name, config) || config.type !== 'reference') {\n dropped.push(name)\n continue\n }\n fields.push(name)\n }\n\n return { fields, dropped, requested: requested.length }\n}\n","/**\n * `media()` field resolution on the public surface (plan/api phase 06, D021).\n *\n * A media field stores a bare uuid, and G1's most concrete gap is that a\n * database-less frontend cannot render CMS images at all: there is no\n * media-id→URL endpoint anywhere in the toolkit, and the only resolver requires\n * the database. This closes it by resolving the field to a projection whose URL\n * is derived at read time.\n *\n * **Everything that decides the URL lives OUTSIDE this package**, injected as\n * `ContentApiConfig.resolveMedia` — `content-api` is a leaf and cannot import\n * `@murumets-ee/media` or `@murumets-ee/storage`. What this module owns is the\n * wire shape, the budget claim, and the rule that an unresolved media field is\n * an absence like any other.\n *\n * **Why the shape is uniform even when nothing is wired.** With no provider,\n * every media field resolves to `null` rather than falling back to the stored\n * uuid. A uuid is not a weaker answer here, it is an unusable one — no endpoint\n * accepts it — and letting the wire TYPE vary per deployment is precisely the\n * unfalsifiable claim `_schema` exists to prevent. One rule, no per-deployment\n * variance.\n */\n\nimport type { Entity, FieldConfig, MediaField } from '@murumets-ee/entity'\nimport { type CacheTagSink, MEDIA_TAG_ENTITY } from './cache-tags.js'\nimport { isInternalField } from './query-params.js'\nimport type { ResolutionBudget } from './resolution-budget.js'\n\n/**\n * One generated image file — a `srcset` candidate. `width`/`height` are the\n * file's own, never the ladder rung that was requested (images D003).\n */\nexport interface MediaSource {\n url: string\n width: number\n height: number\n}\n\n/**\n * One media item as it reaches the wire.\n *\n * Structurally identical to `@murumets-ee/media`'s `PublicMediaProjection`; the\n * app that wires the two is where TypeScript checks they agree, exactly as for\n * `ContentApiResolution` and `ContentApiMenus`.\n */\nexport interface MediaProjection {\n id: string\n /** A PUBLIC url. Never signed — see {@link ContentApiConfig.resolveMedia}. */\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n /** Translatable on the Media entity — this projection is locale-dependent. */\n alt: string | null\n /** Translatable on the Media entity — this projection is locale-dependent. */\n title: string | null\n /** Variant urls by image-style name, inlined as data (D008: a client derives nothing). */\n variants: Record<string, string>\n /**\n * Every generated file by SHAPE name (`natural` = the photograph's own ratio),\n * ascending by width, each with the width and height the FILE actually has —\n * what a `srcset` is built from (images D007, lumi-cms#1160). A shape the\n * photograph has no files for yet is absent. Bounded by configuration, like\n * `variants`.\n */\n sources: Record<string, MediaSource[]>\n /**\n * The AVIF copy of `sources`, by the same shape names (images PR12), for a\n * `<picture><source type=\"image/avif\">`. ABSENT — never `{}` — when there is\n * none: AVIF is off (the default) or the catch-up has not reached the\n * photograph, so a site that has not turned it on sends what it did before.\n * A shape is listed only when its AVIF files have exactly the widths of its\n * `sources` list. Never an AVIF file in `sources` itself.\n */\n avifSources?: Record<string, MediaSource[]>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n * ALWAYS present — `{ x: 0.5, y: 0.5 }` for a row that has none — so no\n * consumer branches on its absence (D010). A shaped slot crops around it; the\n * `object-position` it needs depends on the slot's own ratio, so it is\n * computed at render and never published here (`@murumets-ee/media/crop`).\n */\n focalPoint: { x: number; y: number }\n /**\n * The blurred preview a render draws while the photograph loads — a\n * `data:image/webp` or `data:image/png` base64 URL of a tiny image of it, or\n * `null` when there is none (images PR11). Validated by the provider before\n * it gets here; a consumer that writes it into a `style` validates again\n * (`@murumets-ee/blocks` does).\n */\n blurDataUrl: string | null\n}\n\n/**\n * Host-injected media resolver. Ids that do not exist, are unpublished for this\n * caller, or whose file is not public must be ABSENT from the returned map —\n * one absence, several causes, per phase 05's failure semantics.\n */\nexport type ResolveMedia = (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, MediaProjection>>\n\n/** The declared, non-internal `media` fields of an entity. */\nexport function mediaFieldsOf(entity: Entity): Array<[string, MediaField]> {\n const out: Array<[string, MediaField]> = []\n for (const [name, config] of Object.entries(entity.allFields as Record<string, FieldConfig>)) {\n if (config.type !== 'media' || isInternalField(name, config)) continue\n out.push([name, config])\n }\n return out\n}\n\nexport interface MediaResolutionDeps {\n /** The SAME budget references and blocks draw on — not a fifth ceiling. */\n budget: ResolutionBudget\n locale?: string | undefined\n resolveMedia?: ResolveMedia | undefined\n /**\n * Where resolved media ids are recorded for cache-tag emission (D055), under\n * the {@link MEDIA_TAG_ENTITY} namespace. Write half only.\n *\n * Only ids the resolver actually RETURNED are recorded: an id that does not\n * exist, is unpublished for this caller, or whose file is not public is\n * absent from the map and did not compose anything, so tagging it would name\n * a row this response does not depend on.\n */\n cacheTags?: CacheTagSink | undefined\n logger?:\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined\n}\n\n/**\n * Replace every `media` field on `rows` with its projection, IN PLACE.\n *\n * `field.media()` has no cardinality option — it is single-valued — so there is\n * no one-versus-many split to design here.\n *\n * One provider call for the whole batch, after ONE budget claim covering every\n * distinct id: N rows referencing the same image cost one resolution, and a\n * page that blows the budget renders with the excess images unresolved rather\n * than draining the pool.\n */\nexport async function projectMedia(\n entity: Entity,\n rows: Record<string, unknown>[],\n deps: MediaResolutionDeps,\n): Promise<void> {\n if (rows.length === 0) return\n const fields = mediaFieldsOf(entity)\n if (fields.length === 0) return\n\n const ids = new Set<string>()\n for (const row of rows) {\n for (const [name] of fields) {\n // Absent means the caller excluded it via `?fields=` — leave it absent.\n // See the same guard in `projectReferences`: without it, a narrowing\n // request comes back WIDER than it asked for, with `cover: null` reading\n // as \"this row has no cover image\".\n if (!Object.hasOwn(row, name)) continue\n const value = row[name]\n if (typeof value === 'string' && value !== '') ids.add(value)\n }\n }\n\n let resolved = new Map<string, MediaProjection>()\n if (ids.size > 0 && deps.resolveMedia) {\n const wanted = [...ids]\n const granted = deps.budget.claim('media', wanted.length)\n const covered = wanted.slice(0, granted)\n if (covered.length > 0) {\n try {\n resolved = await deps.resolveMedia(covered, { locale: deps.locale })\n } catch (err) {\n // A broken media backend degrades every image on the page to absent —\n // it must not 500 the document. The operator gets the only signal, so\n // it is `warn`, not `debug`: the most likely cause is not a broken\n // backend at all but a MISSING PERMISSION. The shipped resolver reads\n // through `QueryClient`, whose `resolveAuthContext` THROWS when the\n // caller's role lacks `media:view` — so a deployment that granted\n // `article:view` and stopped there has every image on the site resolve\n // to null, and a signal below the default level cannot arrive to say\n // so.\n deps.logger?.warn(\n { entity: entity.name, err },\n 'content-api: media resolver threw — media fields resolve to null ' +\n '(if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n }\n }\n } else if (ids.size > 0) {\n // No provider wired. NOT counted as a budget drop — the budget reports work\n // the server DECLINED, and there was no work to decline here; conflating\n // the two would make \"you configured nothing\" read as \"you hit a ceiling\"\n // in the one log an operator uses to tell them apart. The dedicated line\n // below is the signal.\n deps.logger?.debug(\n { entity: entity.name, mediaFields: fields.map(([name]) => name) },\n 'content-api: no `resolveMedia` provider wired — media fields resolve to null',\n )\n }\n\n // REQUESTED, RESOLVED, AND SELF-CONSISTENT — all three, because `resolveMedia`\n // is host-injected and its returned map is not this package's data.\n //\n // The disclosure invariant (D055 §3) is that every id in the header is in the\n // body. Two different ways a misbehaving provider could break it, and the key\n // check alone only closes the first:\n //\n // - It returns an id nothing on this page asked for. The key check closes\n // that. `ids` (not `covered`) is the right set — an id outside the budget\n // grant was never fetched, so it cannot legitimately come back either.\n // - It returns the requested key mapped to a projection whose OWN `id` is\n // something else. The body then shows `projection.id` while the tag would\n // name the key — an id in the header appearing nowhere in the body, which\n // is the exact invariant, against the exact threat model, that the key\n // check was added for.\n //\n // So the tag is emitted only when the key the caller's row referenced and the\n // id the body will display are the SAME value. A provider that disagrees with\n // itself gets no tag rather than a misleading one. `handlePaths` refuses a\n // host-supplied `entityType` for the same reason; this is that discipline on\n // the id side.\n // `projection?.id`, not `projection.id`. The type says `MediaProjection`, but\n // this map came from a host-injected resolver — the same reason the two checks\n // above exist — and a JS host, or one building the map dynamically, can put a\n // null or undefined value in it. Reading `.id` off that throws, and this loop\n // sits AFTER the `try` that guards the resolver call, so the throw escapes to\n // the handler's catch and turns every media-bearing page into a 500.\n //\n // Distrusting the provider's keys while trusting its values was the\n // inconsistency: the write-back loop three lines down has always defended\n // itself with `resolved.get(value) ?? null`.\n for (const [id, projection] of resolved) {\n if (ids.has(id) && projection?.id === id) deps.cacheTags?.addDocument(MEDIA_TAG_ENTITY, id)\n }\n\n for (const row of rows) {\n for (const [name] of fields) {\n if (!Object.hasOwn(row, name)) continue\n const value = row[name]\n row[name] = typeof value === 'string' ? (resolved.get(value) ?? null) : null\n }\n }\n}\n","/**\n * Entity `reference()` resolution (plan/api phase 05, D013).\n *\n * Of the four things that reach the wire as an unusable identifier, this is the\n * only one with no existing resolver — and the only one that CANNOT be pushed\n * to a consumer. A `query()` field names its entity, a link carries\n * `{kind:'entity', entity, id}`, a media ref names its media; a reference field\n * carries an id and nothing else, with its target knowable solely from the\n * FIELD DEFINITION. A consumer without the schema cannot know what to fetch.\n *\n * **A projection by default: `{ id, entityType, title, href }`.** Not the\n * target's full DTO. The decisive property is structural rather than economic:\n * the projection holds only scalars, so there is nothing at depth 2 to expand\n * and unbounded recursion is impossible BY CONSTRUCTION rather than prevented\n * by a depth counter. A counter is a check that has to work; a shape with no\n * references in it is a property that cannot fail. `?populate=` opts into full\n * expansion at depth 1, where an expanded target's own references resolve to\n * projections — so the same property terminates the recursion one level down.\n *\n * **`href` comes from the routing seam, not from a slug.** The same\n * host-injected `resolveEntityHref` `resolveLinks` uses, so a reference and a\n * link to the same document produce the same URL by construction rather than by\n * two implementations agreeing.\n *\n * **Every failure looks identical (phase 05 §failure semantics).** Deleted,\n * unpublished-for-this-principal, not viewable, entity not whitelisted, or\n * simply beyond the resolution budget — all five yield the same absence, with\n * no error and no distinguishing marker. Splitting them would hand an anonymous\n * caller an existence oracle, and a reference field is a far cheaper and more\n * repeatable probe than guessing document URLs. The operator log is the only\n * place the distinction survives.\n */\n\nimport { runBounded } from '@murumets-ee/core'\nimport type { Entity, FieldConfig, ReferenceField } from '@murumets-ee/entity'\nimport { getEntityTitleField, isTemplateDrawn } from '@murumets-ee/entity'\nimport type { CacheTagSink } from './cache-tags.js'\nimport { isInternalField } from './query-params.js'\nimport type { ResolutionBudget } from './resolution-budget.js'\n\n/**\n * What a reference resolves to by default.\n *\n * `title` and `href` are nullable INDEPENDENTLY of the projection itself: the\n * target resolved (so `id`/`entityType` are real), but the entity may have no\n * usable title field, or may not be routable, in which case there is no href to\n * give. That is a different outcome from \"the target did not resolve\", which is\n * an absent projection — see {@link projectReferences}.\n */\nexport interface ReferenceProjection {\n id: string\n entityType: string\n title: string | null\n href: string | null\n}\n\n/**\n * Host-injected routing seam — a STRUCTURAL mirror of\n * `@murumets-ee/blocks`' `ResolveEntityHref`. Redeclared rather than imported\n * for exactly the reason spelled out on `ContentResolveResult`: `content-api`\n * is a leaf and `blocks` depends back on it, so the edge is a build cycle. The\n * app wires ONE implementation into both.\n *\n * **This one does not need a publish filter, and that is a property rather than\n * an oversight.** It is only ever asked about ids that already came back from a\n * published-only `QueryClient` read, so an unpublished target never reaches it.\n * The same seam wired into `resolveLinks` — which resolves arbitrary\n * editor-authored link targets — DOES need one, because nothing upstream of it\n * has proven the target visible.\n */\nexport type ResolveEntityHref = (target: {\n entity: string\n id: string\n locale?: string\n}) => string | null | Promise<string | null>\n\n/** Reads rows by id through the caller's published-only query path. */\nexport type ReadRowsByIds = (\n entity: Entity,\n ids: readonly string[],\n select: readonly string[] | undefined,\n) => Promise<Record<string, unknown>[]>\n\nexport interface ReferenceResolutionDeps {\n /** The ONE budget spanning references, populate, media and blocks. */\n budget: ResolutionBudget\n /** Resolved content locale for this request, if any. */\n locale?: string | undefined\n /**\n * Whitelist ∧ `view` for the CALLER. Returns the target entity only when this\n * principal may reach it; `undefined` otherwise, which makes an unreachable\n * target indistinguishable from a missing one.\n */\n reachableEntity: (name: string) => Entity | undefined\n readRowsByIds: ReadRowsByIds\n resolveEntityHref?: ResolveEntityHref | undefined\n /**\n * The DECLARED title of a row drawn by a stored template (block-library\n * D009) — the same value its by-id read serves as `title`. Absent, or\n * answering `null`, such a reference carries no title: the entity's admin\n * title column is never read for one.\n */\n templateDrawnTitle?:\n | ((target: Entity, row: Readonly<Record<string, unknown>>) => Promise<string | null>)\n | undefined\n /**\n * Where resolved reference ids are recorded for cache-tag emission (D055).\n *\n * The WRITE half only — this module contributes ids and decides nothing about\n * whether a tag is emitted. It is recorded here rather than re-derived at the\n * response site because this is the only place that knows both halves of the\n * pair: the group's target entity name and the ids that actually came back.\n * Walking the finished body instead would mean re-reading each row against\n * its field schema to recover what was already in hand.\n */\n cacheTags?: CacheTagSink | undefined\n /** Structured operator log — the only place a dropped resolution survives. */\n logger?:\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined\n /**\n * Run after a `?populate=` expansion has produced the target's full DTOs, so\n * whatever else the response cycle does to a row happens to expanded rows too\n * — media projection, today.\n *\n * A HOOK rather than a direct call, so this module stays unaware of media:\n * the two resolutions share a budget, not a dependency. Without it an\n * expanded author's avatar would come back as a bare uuid while the same\n * field on the root document came back as a projection, which is a wire shape\n * that varies by how the row was reached.\n */\n afterExpand?: ((entity: Entity, rows: Record<string, unknown>[]) => Promise<void>) | undefined\n}\n\n/** The declared, non-internal `reference` fields of an entity. */\nexport function referenceFieldsOf(entity: Entity): Array<[string, ReferenceField]> {\n const out: Array<[string, ReferenceField]> = []\n for (const [name, config] of Object.entries(entity.allFields as Record<string, FieldConfig>)) {\n // Internal fields never reach the projection at all, so resolving one would\n // be work whose result is discarded — and, if that ever changed, a way to\n // read a server-managed reference. Same predicate as every other boundary.\n if (config.type !== 'reference' || isInternalField(name, config)) continue\n out.push([name, config])\n }\n return out\n}\n\n/** True when a value is a plausible stored reference id. */\nfunction isId(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0\n}\n\n/**\n * Read the stored ids out of one row's reference field.\n *\n * `cardinality: 'many'` is a `uuid[]` column and `'one'` a bare `uuid`, so this\n * normalises both onto a list and remembers which it was.\n */\nfunction idsOf(value: unknown, cardinality: 'one' | 'many'): string[] {\n if (cardinality === 'many') {\n if (!Array.isArray(value)) return []\n return value.filter(isId)\n }\n return isId(value) ? [value] : []\n}\n\n/**\n * Resolve every reference field on `rows` IN PLACE.\n *\n * `populate` names the fields to expand fully instead of projecting; the\n * expanded DTOs' own references are then projected by a nested call with an\n * EMPTY populate set, which is what makes depth-1 terminate structurally.\n *\n * **What replaces the stored value.**\n * - `cardinality: 'one'` — the projection (or expanded DTO), or `null` when\n * the target did not resolve.\n * - `cardinality: 'many'` — an array with the unresolved entries REMOVED, not\n * nulled. A consumer rendering a list of related documents wants the ones it\n * can render; a `null` hole would force every consumer to filter, and the\n * positions carry no meaning the caller could act on anyway.\n */\nexport async function projectReferences(\n entity: Entity,\n rows: Record<string, unknown>[],\n populate: ReadonlySet<string>,\n deps: ReferenceResolutionDeps,\n /**\n * Depth guard for the `?populate=` recursion. Depth 1 is the ONLY expansion\n * level (D013); this exists so a future edit that reuses this function\n * cannot silently open a second one, not because the current call graph could\n * reach it — the nested call passes an empty `populate`, which produces\n * projections, which contain no references.\n */\n depth = 0,\n): Promise<void> {\n if (rows.length === 0) return\n const fields = referenceFieldsOf(entity)\n if (fields.length === 0) return\n\n // Group every (target entity, id) pair across every row and field, so N rows\n // pointing at the same target cost ONE read rather than N. The group key is\n // the target entity plus whether it is being expanded, because those two\n // shapes read different columns.\n interface Group {\n target: Entity\n expand: boolean\n ids: Set<string>\n }\n const groups = new Map<string, Group>()\n /** Per (row, field) the ids to look up, so the write-back stays positional. */\n const perCell: Array<{\n row: Record<string, unknown>\n field: string\n cardinality: 'one' | 'many'\n groupKey: string | null\n ids: string[]\n }> = []\n\n for (const row of rows) {\n for (const [name, config] of fields) {\n // A field the caller excluded via `?fields=` is ABSENT from the shaped\n // row, and must stay absent. Without this guard the write-back below\n // resurrects it as `null` / `[]`, so `?fields=id,title` answers\n // `{id, title, author: null, tags: []}` — a consumer reads `author: null`\n // as \"this article has no author\" when it simply was not requested. That\n // also contradicts `parseFieldSelection`'s own invariant that a narrowing\n // request can never widen back to the full row.\n if (!Object.hasOwn(row, name)) continue\n const ids = idsOf(row[name], config.cardinality)\n const expand = depth === 0 && populate.has(name)\n // Whitelist ∧ view for the CALLER, not for the server. An unreachable\n // target resolves to nothing at all — same as deleted (D006).\n const target = deps.reachableEntity(config.entity)\n if (!target || ids.length === 0) {\n perCell.push({ row, field: name, cardinality: config.cardinality, groupKey: null, ids })\n continue\n }\n const groupKey = `${target.name}::${expand ? 'full' : 'projection'}`\n let group = groups.get(groupKey)\n if (!group) {\n group = { target, expand, ids: new Set() }\n groups.set(groupKey, group)\n }\n for (const id of ids) group.ids.add(id)\n perCell.push({ row, field: name, cardinality: config.cardinality, groupKey, ids })\n }\n }\n\n // One resolved-row map per group, filled below. A group whose budget claim\n // came up short simply has fewer entries — every id it could not cover then\n // resolves to absence, like any other unresolvable target.\n const resolved = new Map<string, Map<string, ReferenceProjection | Record<string, unknown>>>()\n\n for (const [groupKey, group] of groups) {\n const ids = [...group.ids]\n // Claim BEFORE the read, never after: a budget checked afterwards has\n // already paid for the overrun.\n const granted = deps.budget.claim(group.expand ? 'populate' : 'reference', ids.length)\n const covered = ids.slice(0, granted)\n if (covered.length === 0) {\n resolved.set(groupKey, new Map())\n continue\n }\n\n const titleField = getEntityTitleField(group.target)\n // A row DRAWN BY A STORED TEMPLATE (block-library D009) crosses with the\n // values its site's detail bindings name and no other column — and the\n // admin title field is not one of them (a room's is its `code`). So that\n // column is never read for one: its title is the DECLARED one, projected by\n // the host's provider from the whole row (the same value `/:id` serves), or\n // `null` when no provider is wired.\n const templateDrawn = isTemplateDrawn(group.target)\n const declaredTitle = templateDrawn ? deps.templateDrawnTitle : undefined\n // A projection needs two columns; an expansion needs the whole public row —\n // and so does projecting a declared title, which reads columns this module\n // cannot name.\n const select =\n group.expand || declaredTitle !== undefined\n ? undefined\n : templateDrawn\n ? ['id']\n : dedupe(['id', titleField])\n const found = await deps.readRowsByIds(group.target, covered, select)\n\n const byId = new Map<string, ReferenceProjection | Record<string, unknown>>()\n if (group.expand) {\n for (const row of found) {\n const id = row.id\n if (isId(id)) byId.set(id, row)\n }\n // Depth 1: the expanded DTOs' OWN references become projections. Empty\n // populate set → projections only → no references inside them → nothing\n // left to expand. The recursion terminates on the SHAPE, not on `depth`.\n await projectReferences(group.target, found, new Set(), deps, depth + 1)\n // ...and whatever else the response does to a row, so an expanded row is\n // shaped like a root one rather than like a half-processed database read.\n await deps.afterExpand?.(group.target, found)\n } else {\n const hrefs = await resolveHrefs(group.target.name, found, deps)\n for (const [index, row] of found.entries()) {\n const id = row.id\n if (!isId(id)) continue\n const rawTitle = templateDrawn\n ? ((await declaredTitle?.(group.target, row)) ?? null)\n : row[titleField]\n byId.set(id, {\n id,\n entityType: group.target.name,\n title: typeof rawTitle === 'string' ? rawTitle : null,\n href: hrefs[index] ?? null,\n })\n }\n }\n // Every id in `byId` came back from the read AND is referenced by at least\n // one cell (the group's id set was built from the cells), so these are\n // exactly the target rows that compose the body — no more, no less. Added\n // AFTER the primary rows the handler tagged, which is what makes an\n // over-budget response degrade to \"the page's own rows plus the\n // collection\" rather than to an arbitrary 128 (D055 §5).\n for (const id of byId.keys()) deps.cacheTags?.addDocument(group.target.name, id)\n resolved.set(groupKey, byId)\n }\n\n for (const cell of perCell) {\n const byId = cell.groupKey === null ? undefined : resolved.get(cell.groupKey)\n if (cell.cardinality === 'many') {\n // Unresolved entries are REMOVED, not nulled — see the JSDoc.\n cell.row[cell.field] = cell.ids\n .map((id) => byId?.get(id))\n .filter(\n (value): value is ReferenceProjection | Record<string, unknown> => value !== undefined,\n )\n continue\n }\n const first = cell.ids[0]\n cell.row[cell.field] = first === undefined ? null : (byId?.get(first) ?? null)\n }\n}\n\n/** `[...new Set(xs)]`, kept local so the intent reads at the call site. */\nfunction dedupe(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Resolve one batch of rows to hrefs through the injected seam, at bounded peak\n * concurrency (the seam is an independent DB lookup per call — the same\n * amplification shape F009 records for `resolveLinks`).\n *\n * A seam that throws yields `null` for that row rather than failing the\n * request: a broken routing lookup must degrade to an unlinked reference, not\n * to a 500 on every page that carries one.\n */\nasync function resolveHrefs(\n entityName: string,\n rows: readonly Record<string, unknown>[],\n deps: ReferenceResolutionDeps,\n): Promise<Array<string | null>> {\n const seam = deps.resolveEntityHref\n if (!seam) return rows.map(() => null)\n return runBounded(rows, async (row) => {\n const id = row.id\n if (!isId(id)) return null\n try {\n return (\n (await seam({\n entity: entityName,\n id,\n ...(deps.locale !== undefined && { locale: deps.locale }),\n })) ?? null\n )\n } catch (err) {\n deps.logger?.debug(\n { entity: entityName, id, err },\n 'content-api: resolveEntityHref threw — reference resolves without an href',\n )\n return null\n }\n })\n}\n","/**\n * Response helpers — JSON envelope builders.\n */\n\n/**\n * The COMPLETE machine-readable error-code set this API can emit.\n *\n * Declared ONCE, here, beside the only function that can produce one: the\n * compiler refuses a code that isn't in this tuple, and `GET /_schema` publishes\n * the tuple verbatim. So a new code cannot be introduced without landing in both\n * the type system and the contract in the same edit — there is no second,\n * hand-kept list to forget (the drift shape D031 makes a hard requirement, and\n * the mechanism behind the SDK that drifted 4.5 months undetected).\n *\n * `code` is machine contract; `message` is not, and is free to be specific.\n */\nexport const ERROR_CODES = [\n 'NOT_FOUND',\n 'VALIDATION_ERROR',\n 'INTERNAL_ERROR',\n // plan/api PR 6 (D025). TWO codes rather than the one D031 anticipated, and\n // the split earns its keep: a code names what the client should do NEXT.\n // `RATE_LIMITED` says \"retry after your window resets, with the same\n // request\"; `SERVICE_OVERLOADED` says \"the origin is shedding load, back off\n // and expect this to clear independently of anything you did\". A client that\n // conflated them would retry a saturated origin on a per-caller schedule,\n // which is the wrong behaviour.\n 'RATE_LIMITED',\n 'SERVICE_OVERLOADED',\n] as const\n\n/** One of {@link ERROR_CODES}. */\nexport type ContentApiErrorCode = (typeof ERROR_CODES)[number]\n\nexport function jsonResponse(\n data: unknown,\n status = 200,\n extraHeaders?: Record<string, string>,\n): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: {\n 'Content-Type': 'application/json',\n ...extraHeaders,\n },\n })\n}\n\n/**\n * EVERY refusal this API emits is unstorable. Unconditionally, for every caller,\n * on every path (SD005).\n *\n * A 404 with no explicit freshness is HEURISTICALLY cacheable (RFC 9111 §4.2.2;\n * RFC 9110 §15.1 lists 404 among the twelve heuristically-cacheable codes,\n * verified verbatim in R15), and a 404 is exactly how this surface refuses: an\n * entity outside the whitelist, a role without `view`, an unlisted menu handle\n * and a row that does not exist are ONE answer (D006). So emitting no directive\n * would not withhold a policy — it would delegate the policy to whichever\n * intermediary happens to be deployed. Stating one is not optional.\n *\n * **Why UNIFORM, which is SD005 amending D037.** D037 asked for 4xx to get \"the\n * same role-aware treatment as 2xx\", on the argument that a public-role 404 is\n * the anonymous 404 and so is safely shareable. This PR tried that, and then\n * tried a caller-independent cacheable variant, before arriving here. Both\n * earlier positions were defensible; both cost more than the benefit was worth:\n *\n * - Role-aware storability made a valid-but-unpermitted credential's 404\n * distinguishable from an anonymous one, which is a credential-validity\n * oracle and breaks D006 — where *indistinguishable* means status, body AND\n * headers.\n * - Caller-independent storability fixed the oracle and left a subtler hole: a\n * refusal's BODY does not vary, but whether you get a refusal AT ALL does\n * (`hasViewPermission` is per-role). A stored 404 keyed without a credential\n * selector is replayed to the principal who would have received a 200, whose\n * request then never reaches the origin at all.\n *\n * **What the uniform rule buys is not the deleted branch — it is the deleted\n * class of question.** With no per-caller variation here, D006's byte-identity\n * requirement costs nothing at runtime and cannot be reintroduced by a future\n * header. `RateLimit-*` (which genuinely varies per caller and genuinely belongs\n * on a 429), cache tags, and `ETag` can all be added without a byte-identity\n * audit. A refusal is identical across callers by CONSTRUCTION rather than by a\n * check somebody has to keep right.\n *\n * **The cost, stated rather than discovered:** a 404 flood is 100% origin\n * traffic rather than absorbed at the edge. That is affordable because the same\n * attack is now metered twice over by mechanisms D037 could not assume when it\n * was written — the keyless survival guard and the fairness limit bound arrival,\n * and D042 makes a fabricated-key request a 400 decided before any database\n * work. Edge absorption is re-addable later by widening exactly one function.\n *\n * Written AFTER `extraHeaders` so no call site can weaken it. One honest\n * qualification: the spread only overwrites an identically-CASED key, and\n * `Headers` would append rather than replace a differently-cased one\n * (`cache-control` + `Cache-Control` yields both, comma-joined). Not reachable\n * today — every `extraHeaders` producer in this package uses exact casing and no\n * host-supplied key ever enters that map — and noted rather than defended\n * against, because the guard would be a check on a caller that does not exist.\n */\nconst REFUSAL_CACHE_CONTROL = { 'Cache-Control': 'no-store' } as const\n\n/**\n * The declared code of every error response this module built, keyed by the\n * response itself.\n *\n * Dispatch has to report `code` to the monitoring seam (D035), and it holds the\n * `Response` rather than the arguments that produced it — the body is a stream\n * by then, so reading the code back off the wire would mean cloning and parsing\n * it. Recording the fact at the ONE chokepoint every refusal passes through\n * makes \"the seam fires with the right code on every error response\" a property\n * of construction, instead of a rule each of the ~30 call sites has to\n * remember. The alternative shapes were both worse: threading a reporter\n * through every call site is the per-call-site enrichment D032 rejected, and a\n * marker header would put an internal detail on the wire.\n *\n * `WeakMap`, so an entry dies with the response it describes.\n */\nconst ERROR_CODE_BY_RESPONSE = new WeakMap<Response, ContentApiErrorCode>()\n\n/**\n * The code {@link errorResponse} stamped on `res`, or `undefined` if `res` did\n * not come from this module.\n */\nexport function errorCodeOf(res: Response): ContentApiErrorCode | undefined {\n return ERROR_CODE_BY_RESPONSE.get(res)\n}\n\n/**\n * `message` is for a human reading the response; `code` is the machine\n * contract. **A message may never be built from caller input or from a caught\n * exception** — every one is a literal today, and the 500 path is the model:\n * the real detail goes to the logger, the fixed string goes on the wire.\n */\nexport function errorResponse(\n message: string,\n code: ContentApiErrorCode,\n status: number,\n extraHeaders?: Record<string, string>,\n): Response {\n // SD005: the directive is UNCONDITIONAL and is written AFTER `extraHeaders`,\n // so no call site can weaken it — including the three pre-principal refusals\n // and the ~40 that pass bare CORS headers. This function is the ONE owner of\n // \"is a refusal stored\"; `buildRefusalHeaders` owns only what it varies on.\n const res = jsonResponse({ error: { status, message, code } }, status, {\n ...extraHeaders,\n ...REFUSAL_CACHE_CONTROL,\n })\n ERROR_CODE_BY_RESPONSE.set(res, code)\n return res\n}\n\nexport function notFoundResponse(extraHeaders?: Record<string, string>): Response {\n return errorResponse('Not found', 'NOT_FOUND', 404, extraHeaders)\n}\n\n/**\n * `429 RATE_LIMITED` — you specifically have spent your budget (D025).\n *\n * `Retry-After` is sanctioned for 429 by RFC 6585 §4 (\"MAY include a Retry-After\n * header\"), the document that DEFINES 429 — not by RFC 9110 §10.2.3, which\n * scopes the field normatively to 503 and 3xx. Both citations are legitimate and\n * they are different documents; R15 verified this because attributing it to 9110\n * is the easy mistake.\n */\nexport function rateLimitedResponse(\n retryAfterSeconds: number,\n extraHeaders?: Record<string, string>,\n): Response {\n return errorResponse('Rate limit exceeded', 'RATE_LIMITED', 429, {\n ...extraHeaders,\n 'Retry-After': String(retryAfterSeconds),\n })\n}\n\n/**\n * `503 SERVICE_OVERLOADED` — the server is at capacity, this is not about you\n * (D025).\n *\n * Distinct from 429 because the correct client behaviour differs: a 429 clears\n * when the caller's own window resets, a 503 clears when the origin recovers,\n * independently of anything the caller did.\n */\nexport function serviceOverloadedResponse(\n retryAfterSeconds: number,\n extraHeaders?: Record<string, string>,\n): Response {\n return errorResponse('Service overloaded', 'SERVICE_OVERLOADED', 503, {\n ...extraHeaders,\n 'Retry-After': String(retryAfterSeconds),\n })\n}\n","/**\n * Schema introspection endpoint — GET /_schema\n *\n * Describes the SERVED SURFACE, not only the entities (D008): the version this\n * instance mounts, the endpoint families it actually serves with their\n * server-owned ceilings, the response envelopes and the complete error-code set,\n * and the role-filtered entity metadata.\n *\n * Everything is DERIVED from the handler's live config rather than restated as\n * literals. That is the entire point: a `surface` advertising `_menu` when no\n * `menus` provider was injected, or a ceiling copy-pasted as `100` when the host\n * lowered it to 25, is worse than not publishing it at all — it is an\n * unfalsifiable claim, which is the mechanism (not merely the symptom) behind an\n * SDK generator that can drift from its server for months undetected.\n *\n * **The response is role-filtered and `no-store`, and every field inherits\n * both.** Only entities the caller's role can `view` are included, the surface\n * names endpoint FAMILIES rather than entities, the menu handle allowlist is\n * structurally unreachable from here (see {@link SchemaBuildInput.hasMenus}),\n * and a reference target is named only when the caller could reach it (F053).\n * An introspection endpoint that let a caller enumerate past the gate protecting\n * the endpoints it describes would be worse than no introspection at all.\n */\n\nimport {\n type Entity,\n type FieldConfig,\n getSearchableConfig,\n isTemplateDrawn,\n} from '@murumets-ee/entity'\nimport {\n CACHE_TAG_NAMESPACE,\n MAX_CACHE_TAG_HEADER_BYTES,\n MAX_CACHE_TAG_LENGTH,\n MAX_CACHE_TAGS_PER_RESPONSE,\n type ResolvedCacheTagsConfig,\n SHARED_SITE_TAG_SEGMENT,\n} from './cache-tags.js'\nimport type { ResolvedLimits } from './limits.js'\nimport type { MediaProjection } from './media.js'\nimport {\n DEFAULT_LIMIT,\n DEFAULT_OFFSET,\n DEFAULT_PATHS_LIMIT,\n isInternalField,\n MAX_CURSOR_LENGTH,\n MAX_ENTITY_NAME_LENGTH,\n MAX_MENU_HANDLE_LENGTH,\n MAX_POPULATE_FIELDS,\n MAX_POPULATE_LENGTH,\n MAX_RESOLVE_PATH_LENGTH,\n MAX_SEARCH_LENGTH,\n MIN_SEARCH_LENGTH,\n} from './query-params.js'\nimport type { ReferenceProjection } from './references.js'\nimport { ERROR_CODES } from './response.js'\nimport type {\n SchemaCacheTags,\n SchemaEndpoint,\n SchemaEntity,\n SchemaField,\n SchemaLimits,\n SchemaParam,\n SchemaProjection,\n SchemaResponse,\n} from './types.js'\n\n/**\n * The wire shapes `SchemaField.wire` names (plan/api PR 5 — D008/D013/D021).\n *\n * A CONSTANT derived from the projection interfaces it describes, kept beside\n * nothing else so the one place to edit when a projection gains a key is\n * obvious. `ReferenceProjection` and `MediaProjection` are the source of truth;\n * this publishes them.\n */\nconst PROJECTIONS: {\n referenceProjection: SchemaProjection<ReferenceProjection>\n mediaProjection: SchemaProjection<MediaProjection>\n} = {\n referenceProjection: {\n fields: { id: 'string', entityType: 'string', title: 'string', href: 'string' },\n // The target resolved, but may have no usable title field and may not be\n // routable — those are different from \"did not resolve\".\n nullableFields: ['title', 'href'],\n nullable: true,\n arrayForCardinalityMany: true,\n },\n mediaProjection: {\n fields: {\n id: 'string',\n url: 'string',\n mimeType: 'string',\n width: 'number',\n height: 'number',\n alt: 'string',\n title: 'string',\n variants: 'object',\n sources: 'object',\n avifSources: 'object',\n focalPoint: 'object',\n blurDataUrl: 'string',\n },\n nullableFields: ['mimeType', 'width', 'height', 'alt', 'title', 'blurDataUrl'],\n optionalFields: ['avifSources'],\n nullable: true,\n // `field.media()` has no cardinality option — it is single-valued.\n arrayForCardinalityMany: false,\n },\n}\n\n/**\n * True when the named entity is reachable by the CALLING role — whitelisted in\n * `config.entities` AND passing the same `view` predicate that filters the\n * response's `entities` array.\n *\n * Threaded down rather than precomputed because visibility is PER-CALLER: the\n * answer depends on the requesting role, so it cannot be baked into an\n * entity-shaped conversion that has no caller context.\n */\ntype EntityVisibility = (entityName: string) => boolean\n\n/** Convert a FieldConfig to the public-safe SchemaField subset. */\nfunction toSchemaField(config: FieldConfig, isEntityVisible: EntityVisibility): SchemaField {\n const field: SchemaField = { type: config.type }\n\n if (config.required) {\n field.required = true\n }\n\n // Which fields actually respond to `?locale=` — unknowable from the outside\n // without it (F005).\n if (config.translatable) {\n field.translatable = true\n }\n\n if (config.type === 'select') {\n field.options = config.options\n }\n\n if (config.type === 'blockTree') {\n // `parseFieldSelection` drops it, silently, like every other refused name.\n field.selectable = false\n }\n\n if (config.type === 'media') {\n // Stores a uuid, emits a projection (or null). Without this a generated\n // client types `cover` as `string`.\n field.wire = 'mediaProjection'\n }\n\n if (config.type === 'reference') {\n field.cardinality = config.cardinality\n field.wire = 'referenceProjection'\n // The TARGET, but ONLY when this caller could actually reach it (F053).\n //\n // Naively emitting it hands any caller who can view `article` the names\n // `category` and `tag` — entities the whitelist withholds on purpose, whose\n // every route 404s. The data gate would hold and the introspection endpoint\n // would give the names up anyway. The same hole exists on the ROLE\n // dimension (entity A viewable, its target B not), not just the whitelist\n // one, and `isEntityVisible` closes both because it is derived from the\n // very set the `entities` array publishes.\n //\n // Nothing real is lost: an expansion can only expand a reference the caller\n // may fetch, so a target that 404s everywhere is not expandable and\n // publishing it buys a generator nothing. Omitted — not `null`, not a\n // sentinel — so an absent key stays indistinguishable from \"no target\n // declared\" (D006's denial-looks-like-absence shape).\n if (isEntityVisible(config.entity)) {\n field.entity = config.entity\n // `?populate=` can only expand a target the caller may fetch, so this\n // rides the SAME visibility gate as the target name rather than being a\n // second, independently-maintained condition.\n field.populatable = true\n }\n }\n\n return field\n}\n\n/** Convert an Entity to public-safe schema metadata. */\nexport function toSchemaEntity(entity: Entity, isEntityVisible: EntityVisibility): SchemaEntity {\n const allFields = entity.allFields as Record<string, FieldConfig>\n const fields: Record<string, SchemaField> = {}\n const templateDrawn = isTemplateDrawn(entity)\n\n for (const [name, config] of Object.entries(allFields)) {\n // Skip internal fields — via the shared predicate, so the schema advertises\n // exactly what the read path returns. The bare underscore check this\n // replaced missed fields marked `internal: true` under a plain name\n // (`path` / `depth` from `hierarchical()`, `deletedAt` / `deletedBy` from\n // `trashable()`), which `shapeDto` has always stripped — so `_schema`\n // promised an SDK consumer columns no response ever carried.\n if (isInternalField(name, config)) continue\n // A template-drawn entity's rows cross narrowed (block-library D009): of\n // its own columns only `id` is ever served, so only `id` is advertised.\n if (templateDrawn && name !== 'id') continue\n fields[name] = toSchemaField(config, isEntityVisible)\n }\n\n // D048 — publish WHICH fields `?search=` actually matches, per entity.\n //\n // The public search predicate is defined by what it INCLUDES: the non-internal\n // subset of `searchable().fields`, plus the main-table tsvector when one\n // exists, plus the locale-constrained translations `EXISTS`. Publishing that\n // set is what lets `searchable().defaultFilters` and `includes` be absent\n // because they were never in the definition, rather than because two branches\n // were left unwritten.\n //\n // It also closes F039's operator-facing half: an entity with no `searchable()`\n // declares no searchable fields here, so a consumer knows `?search=` will not\n // narrow rather than discovering it from a full unfiltered list that looks\n // like a corpus in which everything matched.\n //\n // Filtered through the SAME `isInternalField` predicate the search builder\n // applies, so the published set cannot name a field the predicate would drop\n // or the response body would hide.\n const searchableConfig = getSearchableConfig(entity)\n for (const fieldName of searchableConfig?.fields ?? []) {\n const field = fields[fieldName]\n if (field) field.searchable = true\n }\n\n return {\n name: entity.name,\n pluralName: `${entity.name}s`,\n // `Entity.kind` is optional; `'collection'` is its documented default.\n // Resolved here rather than passed through so a client never has to know\n // the default to read the contract.\n kind: entity.kind ?? 'collection',\n // The published contract must not disagree with the surface (D008): a\n // generated client reading `kind: 'collection'` alone would call routes\n // that 404 for this entity.\n ...(templateDrawn && { drawnBy: 'template' as const }),\n fields,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Surface\n// ---------------------------------------------------------------------------\n\n/** `?locale=` — accepted by every read endpoint. */\nconst LOCALE_PARAM: SchemaParam = { type: 'string' }\n/** `?fields=` — comma-separated projection. */\nconst FIELDS_PARAM: SchemaParam = { type: 'string' }\n/**\n * `?populate=` — comma-separated reference fields to expand (D013).\n *\n * BOTH caps are published because they answer different questions: `maxLength`\n * bounds the raw string so it can be split at all, `maxItems` bounds how many\n * fields may be named. A client that read only one of them would still hit a\n * 400 it could not have predicted — which is the trial-and-400 discovery this\n * whole document exists to replace.\n */\nconst POPULATE_PARAM: SchemaParam = {\n type: 'string',\n maxLength: MAX_POPULATE_LENGTH,\n maxItems: MAX_POPULATE_FIELDS,\n}\n\n/**\n * The endpoint families this instance serves, with their EFFECTIVE ceilings.\n *\n * Families, never instances: `/:entity` describes every whitelisted entity's\n * list route and the surface never names one. Which entities exist is the\n * role-filtered `entities` array's job, and repeating it here would be a second\n * enumeration of the same set — an ungated one.\n *\n * `_schema` is deliberately absent. It is the bootstrap document: the one\n * response a client reads before it knows what the envelopes are, and therefore\n * the one endpoint that cannot describe itself through the contract.\n */\nfunction buildSurface(input: SchemaBuildInput): SchemaEndpoint[] {\n const { maxLimit, maxOffset, hasResolution, hasMenus, hasChrome } = input\n\n const endpoints: SchemaEndpoint[] = [\n {\n id: 'list',\n path: '/:entity',\n data: 'array',\n entityKind: 'collection',\n params: {\n // The default is published CLAMPED, exactly as the handler applies it.\n // Publishing the bare `DEFAULT_LIMIT` beside a host-lowered `max` emits\n // a self-contradicting object (`max: 10, default: 20`) describing a\n // value the server does not use — and a generator that seeds its own\n // param from `default` would send 20 and take a 400. This is the\n // copy-pasted-ceiling failure this file exists to prevent, in the one\n // field that was still a literal.\n limit: {\n type: 'number',\n min: 1,\n max: maxLimit,\n default: Math.min(DEFAULT_LIMIT, maxLimit),\n },\n offset: { type: 'number', min: 0, max: maxOffset, default: DEFAULT_OFFSET },\n sort: { type: 'string' },\n locale: LOCALE_PARAM,\n fields: FIELDS_PARAM,\n search: {\n type: 'string',\n maxLength: MAX_SEARCH_LENGTH,\n // D046. Published so a consumer disables its own search input below\n // the floor rather than discovering it by rejection.\n minLength: MIN_SEARCH_LENGTH,\n },\n cursor: { type: 'string', maxLength: MAX_CURSOR_LENGTH },\n populate: POPULATE_PARAM,\n // A template, like `:entity` in a path — `<field>` stands for any\n // non-internal field name on the entity being listed.\n 'filter[<field>]': { type: 'string' },\n },\n meta: {\n // `number | null` (D047) — `null` means \"not counted on this request\",\n // and `suppressedBy` names exactly which parameters cause it, so a\n // client READS the condition instead of inferring it from a null it did\n // not expect (D008: a client derives nothing).\n //\n // A searched list is the second endpoint on this side of a line phase 01\n // already drew — `_paths` omits `total` because its generator source has\n // no cheap count — and the rule was stated generally there: a collection\n // endpoint promises `limit` and `hasMore`; `total` is promised only where\n // a count is affordable.\n total: { type: 'number', nullable: true, suppressedBy: ['search'] },\n limit: { type: 'number' },\n offset: { type: 'number' },\n hasMore: { type: 'boolean' },\n nextCursor: { type: 'string', optional: true },\n },\n },\n {\n // A singleton IS the resource at `/:entity`. Same path as `list`,\n // discriminated by `entityKind`; no pagination, so no `meta`.\n id: 'singleton',\n path: '/:entity',\n data: 'object',\n entityKind: 'singleton',\n params: { locale: LOCALE_PARAM, fields: FIELDS_PARAM, populate: POPULATE_PARAM },\n },\n {\n id: 'count',\n path: '/:entity/count',\n data: 'object',\n entityKind: 'collection',\n params: { locale: LOCALE_PARAM },\n },\n {\n id: 'byId',\n path: '/:entity/:id',\n data: 'object',\n entityKind: 'collection',\n params: { locale: LOCALE_PARAM, fields: FIELDS_PARAM, populate: POPULATE_PARAM },\n },\n {\n id: 'bySlug',\n path: '/:entity/slug/:slug',\n data: 'object',\n entityKind: 'collection',\n params: { locale: LOCALE_PARAM, fields: FIELDS_PARAM, populate: POPULATE_PARAM },\n },\n ]\n\n // Per-DEPLOYMENT, not per-version: these 404 unless the app injected a\n // `resolution` provider, so two deployments of identical code serve\n // different surfaces.\n if (hasResolution) {\n endpoints.push(\n {\n id: 'resolve',\n path: '/_resolve',\n data: 'object',\n params: {\n path: { type: 'string', required: true, maxLength: MAX_RESOLVE_PATH_LENGTH },\n locale: { type: 'string', required: true },\n },\n },\n {\n id: 'paths',\n path: '/_paths',\n data: 'array',\n params: {\n locale: { type: 'string', required: true },\n entity: { type: 'string', required: true, maxLength: MAX_ENTITY_NAME_LENGTH },\n cursor: { type: 'string' },\n // Clamped for the same reason as `list.limit` above.\n limit: {\n type: 'number',\n min: 1,\n max: maxLimit,\n default: Math.min(DEFAULT_PATHS_LIMIT, maxLimit),\n },\n },\n // NO `total` — deliberately. The generator source has no cheap count,\n // and modelling that divergence as DATA means a client reads it rather\n // than having to know it as an exception.\n meta: {\n limit: { type: 'number' },\n hasMore: { type: 'boolean' },\n nextCursor: { type: 'string', optional: true },\n },\n },\n {\n id: 'alternates',\n path: '/_alternates',\n data: 'array',\n params: {\n entity: { type: 'string', required: true, maxLength: MAX_ENTITY_NAME_LENGTH },\n id: { type: 'string', required: true },\n },\n },\n )\n }\n\n // Always present: it needs no provider. It answers 404 for a request that\n // has no site (an anonymous caller on an app serving several).\n endpoints.push({\n id: 'site',\n path: '/_site',\n data: 'object',\n // No parameters — and in particular no `site`: the site is the\n // credential's (multisite D008).\n params: {},\n })\n\n if (hasMenus) {\n endpoints.push({\n id: 'menu',\n path: '/_menu',\n data: 'object',\n params: {\n // The param NAME only. The handle ALLOWLIST is never published — see\n // `SchemaBuildInput.hasMenus`.\n handle: { type: 'string', required: true, maxLength: MAX_MENU_HANDLE_LENGTH },\n locale: { type: 'string', required: true },\n currentPath: { type: 'string', maxLength: MAX_RESOLVE_PATH_LENGTH },\n },\n })\n }\n\n if (hasChrome) {\n endpoints.push({\n id: 'chrome',\n path: '/_chrome',\n data: 'object',\n params: {\n locale: { type: 'string', required: true },\n // `entity` + `id` name the requesting document and are optional — but\n // only TOGETHER. `required` is per-param and cannot express that pairing,\n // so it stays false on both and the handler answers 400 for half a\n // reference. Noted here rather than left as a surprise.\n entity: { type: 'string', maxLength: MAX_ENTITY_NAME_LENGTH },\n id: { type: 'string' },\n },\n })\n }\n\n return endpoints\n}\n\n// ---------------------------------------------------------------------------\n// Response\n// ---------------------------------------------------------------------------\n\n/**\n * Everything `GET /_schema` needs, taken from the handler's LIVE config so\n * nothing here is a restated literal.\n */\nexport interface SchemaBuildInput {\n /** The whitelisted entities, BEFORE role filtering. */\n entities: Entity[]\n /** `view` predicate for the calling role. Gates both the entity list and F053. */\n checkPermission: (entityName: string) => boolean\n /** The API version this instance mounts. */\n version: string\n /** EFFECTIVE `?limit=` ceiling, including a host-lowered one. */\n maxLimit: number\n /** EFFECTIVE `?offset=` ceiling, including a host-lowered one. */\n maxOffset: number\n /**\n * EFFECTIVE per-response resolution ceiling, including a host-lowered one.\n * Published under `limits` rather than under a param, because no parameter\n * names it — see {@link SchemaLimits}.\n */\n maxResolutionsPerDocument: number\n /** True when a `resolution` provider was injected (`_resolve`/`_paths`/`_alternates`). */\n hasResolution: boolean\n /**\n * True when a `menus` provider was injected (`_menu`).\n *\n * A BOOLEAN, deliberately — not the provider. `ContentApiMenus.handles` is the\n * endpoint's entire gate, so publishing it would hand every caller the\n * allowlist that protects it. Taking only the boolean makes that leak\n * structurally impossible here rather than merely avoided by care.\n */\n hasMenus: boolean\n /** True when a `chrome` provider was injected (`_chrome`). */\n hasChrome: boolean\n\n /**\n * The availability guard's RESOLVED numbers (D025/D028/D029) — what this\n * instance actually enforces, defaults included, not what the host wrote.\n */\n limits: ResolvedLimits\n\n /**\n * The RESOLVED cache-tag configuration (D055) — defaults applied, exactly\n * what the emitter uses. Same rule as `limits`: published from the resolved\n * value, never restated, so the contract cannot disagree with the wire.\n */\n cacheTags: ResolvedCacheTagsConfig\n\n /**\n * True on an app serving several sites (plan `multisite` D008): every tag\n * then carries a site slot, and the published templates say so. Derived from\n * the same fact the emitter keys on.\n */\n siteScopedTags?: boolean\n}\n\n/**\n * Publish the cache-tag grammar (D055), or `null` when emission is off.\n *\n * The templates are BUILT from {@link CACHE_TAG_NAMESPACE} rather than written\n * out, so changing the namespace changes the contract in the same edit. That is\n * the same derivation rule `errorCodes` follows — a second, hand-kept list is\n * exactly the drift D031 and D008 exist to make impossible.\n */\nfunction buildCacheTagsContract(\n config: ResolvedCacheTagsConfig,\n siteScoped: boolean,\n): SchemaCacheTags | null {\n if (!config.enabled) return null\n const prefix = siteScoped ? `${CACHE_TAG_NAMESPACE}:{site}` : CACHE_TAG_NAMESPACE\n return {\n header: config.header,\n separator: config.separator,\n document: `${prefix}:{entity}:{id}`,\n collection: `${prefix}:{entity}`,\n sharedSite: siteScoped ? SHARED_SITE_TAG_SEGMENT : null,\n maxTagLength: MAX_CACHE_TAG_LENGTH,\n maxTagsPerResponse: MAX_CACHE_TAGS_PER_RESPONSE,\n maxHeaderBytes: MAX_CACHE_TAG_HEADER_BYTES,\n // Always the same list — every entry is a property of this package's\n // structure, not of a deployment. A resolved menu item carries no entity\n // identity; a `variants` key set is decided entirely outside this package\n // by the injected `resolveMedia`; an href entirely outside it by the\n // injected `resolveEntityHref`. Every deployment that wires those seams has\n // all four gaps, so none of them is per-deployment variance.\n //\n // `blockTreeContents` is deliberately NOT here any more: a resolved tree\n // now names the rows it resolved against (`ResolvedBlockTree.refs`), and\n // `resolveBlockTrees` records each into the same sink references and media\n // use. `mediaVariantStyles` and `outOfBandPathChanges` were the two\n // untraced candidates in #852 — traced, both real, both added.\n uncovered: [\n 'menuTargets',\n 'crossEntityPathChanges',\n 'mediaVariantStyles',\n 'outOfBandPathChanges',\n ],\n }\n}\n\n/**\n * Build the full /_schema response.\n * Only includes entities that pass the permission check.\n */\nexport function buildSchemaResponse(input: SchemaBuildInput): SchemaResponse {\n const { entities, checkPermission, version } = input\n\n // Every number here is DERIVED from the guard's own resolved configuration,\n // never restated — a published ceiling that disagrees with the enforced one is\n // an unfalsifiable claim, which is the mechanism behind the SDK that drifted\n // for months undetected.\n const limits: SchemaLimits = {\n maxResolutionsPerDocument: input.maxResolutionsPerDocument,\n anonymousRequestsPerInstance: input.limits.anonymousTokensPerInstance,\n // MILLISECONDS, published as configured. Dividing by 1000 and rounding was\n // lossy in both directions — a 1500ms window published as 2s, a 400ms one as\n // 0 — so `_schema` could state a ceiling the surface does not enforce. That\n // is precisely the drift D008 exists to prevent, and a published number that\n // disagrees with the enforced one is worse than not publishing it: it is an\n // unfalsifiable claim, the mechanism behind the SDK that drifted for months.\n rateLimitWindowMs: input.limits.intervalMs,\n maxRequestsPerInstance: input.limits.maxBudgetPerInstance,\n maxConcurrentRequests: input.limits.maxConcurrentRequests,\n requestTimeoutMs: input.limits.requestTimeoutMs,\n }\n\n const visible = entities.filter((e) => checkPermission(e.name))\n\n // Reachable ⇔ whitelisted AND viewable — exactly the set the `entities` array\n // below publishes. Derived from that same set (rather than recomputed) so the\n // two can never disagree about what this caller may see.\n const visibleNames = new Set(visible.map((e) => e.name))\n const isEntityVisible: EntityVisibility = (name) => visibleNames.has(name)\n\n return {\n version,\n surface: buildSurface(input),\n envelopes: {\n dataKey: 'data',\n metaKey: 'meta',\n errorKey: 'error',\n errorCodes: ERROR_CODES,\n },\n limits,\n projections: PROJECTIONS,\n cacheTags: buildCacheTagsContract(input.cacheTags, input.siteScopedTags === true),\n entities: visible.map((e) => toSchemaEntity(e, isEntityVisible)),\n }\n}\n","/**\n * Content API handler factory\n *\n * Creates a read-only catch-all route handler for public content delivery.\n * Mirrors the `createAdminApiHandler` pattern but with:\n * - Every route served under the API version segment and only under it —\n * `/v1/article`, `/v1/_schema` (see {@link API_VERSION}). The version is\n * consumed by the handler, so the mount stays a plain catch-all.\n * - No mutations (GET + OPTIONS only)\n * - Permission-gated (public role starts with 0 permissions)\n * - Published content only (QueryClient auto-filters)\n * - CORS + Cache-Control headers\n *\n * @example\n * ```typescript\n * // app/api/content/[...path]/route.ts — serves GET /api/content/v1/*\n * import { createContentApiHandler } from '@murumets-ee/content-api'\n * import { Article, Page } from '@/entities'\n *\n * const handler = createContentApiHandler({\n * entities: [Article, Page],\n * loadPermissions: async () => {\n * const roles = await loadRoles(getApp())\n * return buildPermissionChecker(roles)\n * },\n * cors: { allowedOrigins: ['https://www.example.com'] },\n * })\n *\n * // `OPTIONS` takes only the Request, so it mounts directly. `GET` takes a\n * // framework-neutral `RouteContext` (`{ path: string[] }`), so the host's own\n * // shape is adapted at the MOUNT — under Next 15+ that means awaiting\n * // `params`, which is the only Next-specific line in the whole surface.\n * export const { OPTIONS } = handler\n * export async function GET(req: Request, ctx: { params: Promise<{ path?: string[] }> }) {\n * return handler.GET(req, { path: (await ctx.params).path ?? [] })\n * }\n * ```\n */\n\nimport type {\n PermissionChecker,\n RequestContext,\n RouteContext,\n SiteRecordView,\n} from '@murumets-ee/core'\nimport {\n getApp,\n getAppOrUndefined,\n getContext,\n getCurrentSite,\n LOCALE_PATTERN,\n REQUEST_ID_HEADER,\n readSiteOriginIndex,\n readSiteRecord,\n requireSiteFor,\n resolveImplicitSite,\n resolveRequestId,\n runWithContextAsync,\n siteSecurityFields,\n} from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { CursorInput, Entity, FieldConfig, SlugField } from '@murumets-ee/entity'\nimport {\n buildTranslationsSearchExists,\n CountCache,\n decodeCursor,\n encodeCursor,\n filterTranslatableFields,\n getSearchableConfig,\n isSiteBound,\n isSortOnlyInternalField,\n isTemplateDrawn,\n isValidSiteHandle,\n SITE_COLUMN,\n SiteRequiredError,\n} from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport {\n and,\n asc,\n desc,\n eq,\n getTableColumns,\n gte,\n ilike,\n inArray,\n lte,\n or,\n type SQL,\n sql,\n} from 'drizzle-orm'\nimport type { PgColumn, PgTable } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport {\n type BlockResolutionDeps,\n createCollectionAllowance,\n MAX_COLLECTIONS_PER_RESPONSE,\n resolveBlockTrees,\n resolveOneBlockTree,\n} from './blocks.js'\nimport {\n type AuthVaryPolicy,\n assertCacheConfig,\n buildPrincipalInvariantHeaders,\n buildRefusalHeaders,\n buildResponseHeaders,\n type CallerCacheClass,\n} from './cache.js'\nimport {\n type CacheTagSet,\n type CacheTagSink,\n createCacheTagSet,\n MAX_CACHE_TAG_HEADER_BYTES,\n MAX_CACHE_TAGS_PER_RESPONSE,\n resolveCacheTagsConfig,\n SHARED_SITE_TAG_SEGMENT,\n} from './cache-tags.js'\nimport { buildCorsHeaders, corsPreflightResponse } from './cors.js'\nimport { narrowTemplateDrawnRow, type TemplateDrawnDeps } from './detail-template.js'\nimport { AvailabilityGuard, RequestDeadlineError, ServiceOverloadedError } from './limits.js'\nimport { type MediaResolutionDeps, projectMedia } from './media.js'\nimport {\n countParamsSchema,\n DEFAULT_PATHS_LIMIT,\n escapeLikePattern,\n FILTER_PARAM_PATTERN,\n isInternalField,\n isUuid,\n listParamsSchema,\n MAX_ENTITY_NAME_LENGTH,\n MAX_LIMIT,\n MAX_MENU_HANDLE_LENGTH,\n MAX_OFFSET,\n MAX_POPULATE_FIELDS,\n MAX_RESOLVE_PATH_LENGTH,\n parseFieldSelection,\n parsePopulate,\n singleParamsSchema,\n toParamBag,\n validateFilterValue,\n} from './query-params.js'\nimport { projectReferences, type ReferenceResolutionDeps } from './references.js'\nimport { createResolutionBudget, resolveMaxResolutions } from './resolution-budget.js'\nimport {\n errorCodeOf,\n errorResponse,\n jsonResponse,\n notFoundResponse,\n rateLimitedResponse,\n serviceOverloadedResponse,\n} from './response.js'\nimport { buildSchemaResponse } from './schema-endpoint.js'\nimport type {\n ContentApiChromeRegion,\n ContentApiChromeRegions,\n ContentApiConfig,\n ContentApiPrincipal,\n ContentApiSite,\n ContentPathEntry,\n CorsConfig,\n} from './types.js'\n\n// ---------------------------------------------------------------------------\n// Principal\n// ---------------------------------------------------------------------------\n\n/**\n * The identity every UNAUTHENTICATED request resolves to.\n *\n * Deliberately the same literal `@murumets-ee/core`'s `runAsPublicReader` uses\n * for the SSG/build-time reader, so the two anonymous readers of this codebase\n * are one identity rather than two look-alikes — and so consolidating this\n * handler onto that primitive stays a pure deletion.\n *\n * It is a stable identity, not a permission: what an anonymous caller may reach\n * is decided entirely by `checker(publicRole, entity, 'view')`, which grants\n * nothing unless a role explicitly says so.\n */\nconst ANONYMOUS_PRINCIPAL_ID = 'public'\n\n/** Shared empty set — avoids allocating one per non-FTS search. */\nconst EMPTY_FIELD_SET: ReadonlySet<string> = new Set<string>()\n\n/**\n * `Retry-After` on a survival refusal (503) and on a wall-clock termination.\n *\n * A fixed small number rather than a computed one, because unlike a fairness\n * refusal there is no window to read it off: a shed clears when the origin\n * recovers, which this process cannot predict. One second is short enough to be\n * useful to a well-behaved client and is a floor, not a promise.\n */\nconst SHED_RETRY_AFTER_SECONDS = 1\n\n/**\n * A principal after resolution — every optional field on\n * {@link ContentApiPrincipal} has been defaulted, so the dispatch path never\n * re-derives them and cannot forget to.\n */\ninterface ResolvedPrincipal {\n /** Always present: the host's id, else {@link ANONYMOUS_PRINCIPAL_ID}. */\n id: string\n /** Always present: the host's role, else the configured `publicRole`. */\n role: string\n name?: string\n email?: string\n /**\n * This credential's own fairness budget (D029), passed through unchanged from\n * {@link ContentApiPrincipal}. Left OPTIONAL rather than defaulted here,\n * because \"the credential declared no budget\" and \"the credential declared the\n * surface default\" must stay distinguishable: the clamp against the\n * server-owned maximum is applied where the budget is CONSUMED, so defaulting\n * it here would launder a host-supplied number past that clamp.\n */\n rateBudgetPerInstance?: number\n /**\n * Did the HOST resolve an identity for this caller?\n *\n * Carried explicitly rather than inferred from `id ===\n * ANONYMOUS_PRINCIPAL_ID`, because that literal is `'public'` — an ordinary\n * string a real user id could equal. Under the inferred form such a user, on\n * the default role, would be classified `'anonymous'` and served a\n * publicly-cacheable body that varies on their identity. Contrived, but the\n * collision is free to remove and a cache-class decision is the wrong place to\n * carry a \"surely not\" in.\n *\n * `false` for the synthesised anonymous principal AND for the role-only hook\n * shape (`{ role }` with no `id`), which asserts no identity — see\n * `resolvePrincipal`.\n */\n hasIdentity: boolean\n\n /**\n * The ONE site this caller's credential is bound to (plan `multisite` D008),\n * already validated as a handle. The request's site is derived from it — see\n * `resolveRequestSite`.\n */\n site?: string\n}\n\n/**\n * The site ONE request reads (plan `multisite` D008), decided once in\n * `dispatch` before any handler runs.\n *\n * `site` is what goes on `RequestContext.site`: the site the request NAMED —\n * through its credential, an ambient `runInSite`, or the mount's Host map. On\n * a single-site install that names none it stays `undefined` and the data\n * layer answers with the install's one site, exactly as before; `record` is\n * then that site's row.\n */\ninterface RequestSiteScope {\n site?: string\n /** The registry row of the request's site, when a booted app resolved one. */\n record?: SiteRecordView\n /** The app declares `sites.multiple` — it never guesses a site. */\n multiSite: boolean\n}\n\n/**\n * Runtime guard for a principal field that must carry a value.\n *\n * `''` is rejected alongside the wrong types because an empty string is not a\n * weaker version of a value here, it is a DIFFERENT KIND of thing: an empty role\n * matches no permission yet compares unequal to `publicRole`, and an empty id\n * identifies nobody yet compares unequal to {@link ANONYMOUS_PRINCIPAL_ID}. Both\n * therefore read as \"not the anonymous caller\" to every check downstream while\n * carrying nothing that could satisfy one. `??` does not catch either.\n */\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value !== ''\n}\n\n/**\n * `Array.isArray` narrows to `any[]` — its lib signature is\n * `(arg: any) => arg is any[]` — so using it directly on a declared\n * `readonly string[]` turns every element back into `any` and costs the package\n * its 100% type-coverage floor. This guard narrows to `readonly unknown[]`\n * instead, so each element still has to be proven a string by\n * {@link isNonEmptyString} before it is used.\n *\n * The runtime check is not redundant with the static type: `ContentApiConfig`\n * is host-supplied and reaches us from plain JavaScript, where the declared\n * type is a promise rather than a guarantee.\n */\nfunction isUnknownArray(value: unknown): value is readonly unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * An HTTP field-name token (RFC 9110 §5.1 `tchar`). Used to validate\n * {@link ContentApiConfig.credentialHeaders} — those values are emitted verbatim\n * into a response header, so anything that is not a bare token (whitespace, a\n * comma, a colon, a CR/LF) must never reach it.\n */\nconst HTTP_FIELD_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/\n\n/**\n * Resolve, and VALIDATE, how `authenticate` participates in shared-cache\n * variant selection — see {@link AuthVaryPolicy} for why the answer has to come\n * from the host.\n *\n * Run once at construction: a malformed selector is a config error, and this\n * package's posture for those is to throw where the mistake is attributable\n * rather than to let it surface as \"caching just behaves oddly\" months later.\n */\nfunction resolveAuthVaryPolicy(config: ContentApiConfig): AuthVaryPolicy {\n const declared = config.credentialHeaders\n if (declared !== undefined) {\n if (!isUnknownArray(declared)) {\n throw new Error(\n 'content-api: `credentialHeaders` must be an array of HTTP header names, received ' +\n `${JSON.stringify(declared)}.`,\n )\n }\n for (const header of declared) {\n if (!isNonEmptyString(header) || header === '*' || !HTTP_FIELD_NAME_RE.test(header)) {\n throw new Error(\n `content-api: \\`credentialHeaders\\` entry ${JSON.stringify(header)} is not a valid ` +\n 'HTTP header name. Each entry is emitted verbatim in `Vary`, so it must be a bare ' +\n 'field-name token (`Cookie`, `Authorization`, `X-Preview-Token`). `*` is refused: it ' +\n 'means \"never reuse this response\", which is `Cache-Control: no-store` spelled ' +\n 'obscurely — omit the option instead, which fails closed to exactly that.',\n )\n }\n }\n }\n\n if (!config.authenticate) {\n // Selectors with no hook to select for. Harmless to serve, but it can only\n // fragment a shared cache for a credential nothing here parses — which is\n // half of the defect this option exists to fix, reintroduced by hand.\n if (declared !== undefined && declared.length > 0) {\n throw new Error(\n 'content-api: `credentialHeaders` was declared without an `authenticate` hook. ' +\n 'This package parses no credential itself, so nothing reads those headers and ' +\n 'listing them in `Vary` can only fragment a shared cache. Remove the option, or ' +\n 'wire the `authenticate` hook it describes.',\n )\n }\n return { kind: 'none' }\n }\n\n // Fail closed. A credential can arrive and nothing declares what selects it,\n // so `buildResponseHeaders` will refuse to offer any entity response for\n // storage rather than store one under a variant key that is known to be wrong.\n if (declared === undefined || declared.length === 0) return { kind: 'undeclared' }\n return { kind: 'declared', headers: declared }\n}\n\n// ---------------------------------------------------------------------------\n// Resolution / enumeration query-param schemas (routing PR07 — `_resolve`,\n// `_paths`, `_alternates`). Kept PRIVATE to the handler: these three endpoints\n// are internal to `createContentApiHandler`'s single export, not part of the\n// `./query-params` public subpath. Validation STYLE mirrors query-params.ts\n// (zod, capped limit via `MAX_LIMIT`, `isUuid` for UUID-typed inputs, the\n// shared `LOCALE_PATTERN`), and the CAPS themselves live there — one literal\n// each, read by both the zod schema below and the `GET /_schema` surface\n// descriptor that publishes it.\n// ---------------------------------------------------------------------------\n\n/** `GET /_resolve?path=&locale=` params — both required. */\nconst resolveParamsSchema = z\n .object({\n path: z.string().min(1).max(MAX_RESOLVE_PATH_LENGTH),\n locale: z.string().regex(LOCALE_PATTERN),\n })\n .strict()\n\n/** `GET /_paths?locale=&entity=&cursor=&limit=` params — locale + entity required. */\nconst pathsParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n // Opaque keyset cursor — a document id (a UUID; the `id` of a prior page's\n // last `_paths` entry). A malformed value is a 4xx here, never a Postgres\n // cast 500 downstream.\n cursor: z\n .string()\n .refine((v) => isUuid(v), 'Invalid cursor')\n .optional(),\n limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_PATHS_LIMIT),\n })\n .strict()\n\n/** `GET /_alternates?entity=&id=` params — both required; id is a UUID. */\nconst alternatesParamsSchema = z\n .object({\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n id: z.string().refine((v) => isUuid(v), 'Invalid id'),\n })\n .strict()\n\n/**\n * `GET /_menu?handle=&locale=¤tPath=` params — handle + locale required\n * (routing PR09). `currentPath` is optional: SSG frontends typically fetch\n * WITHOUT it once per build and compute active state per page from the returned\n * hrefs (`markActiveTrail`).\n */\n/**\n * `GET /_chrome?locale=&entity=&id=` params (plan/api phase 07, D026).\n *\n * `locale` is required — chrome is per-locale and there is no sensible default\n * for \"which site header\". `entity` + `id` name the requesting DOCUMENT and are\n * OPTIONAL, but only TOGETHER: half a reference is a malformed request, not a\n * request for the site default, and answering 200 for it would silently ignore\n * a caller who believes they asked for overrides.\n *\n * A malformed value is a 400; a WELL-FORMED reference to something that does\n * not exist, is outside the whitelist, is not viewable, or is unpublished for\n * this principal yields the site default plus a debug log (D026 part 3). That\n * split — always reject a malformed request, never explain a missing target —\n * is what keeps the parameter from becoming an existence oracle over document\n * ids.\n */\n/**\n * The chrome regions, in the order they are resolved.\n *\n * Declared as data so the resolution loop cannot silently become concurrent or\n * reordered — the budget claims are serial by construction, and a comment\n * asserting that is not the same as code enforcing it. Mirrors\n * `ContentApiChromeRegions`' own key order; `satisfies` makes a drift between\n * the two a compile error rather than a region that never resolves.\n */\nconst CHROME_REGION_FIELDS = ['header', 'footer'] as const satisfies ReadonlyArray<\n keyof ContentApiChromeRegions\n>\n\nconst chromeParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH).optional(),\n id: z\n .string()\n .refine((v) => isUuid(v), 'Invalid id')\n .optional(),\n })\n .strict()\n .refine(\n (v) => (v.entity === undefined) === (v.id === undefined),\n 'Both ?entity= and ?id= are required to name a document, or neither.',\n )\n\n/**\n * `GET /_site` takes NO parameters — in particular no `site` (plan `multisite`\n * D008): the site is the credential's. Strict, so naming one is a 400.\n */\nconst siteParamsSchema = z.object({}).strict()\n\n/**\n * The query parameter this API will never have (plan `multisite` D008). The\n * request's site is derived from its credential; a site the CALLER names is an\n * authorization decision made by the party being authorized (Craft CMS shipped\n * that as CVE-2026-79990). Refused on every route — including `_schema`, which\n * parses no parameters and would otherwise ignore it — so it cannot become a\n * silently-ignored parameter that a later change starts reading.\n */\nconst FORBIDDEN_SITE_PARAM = 'site'\n\n/**\n * Is `err` the data layer's \"this site-bound operation names no site\" refusal?\n *\n * 🔴 By NAME as well as by class, and the name is the half that matters.\n * `@murumets-ee/entity` builds each entry point separately, so its index, its\n * `/query` entry and its `/admin` entry each carry their OWN copy of\n * `SiteRequiredError`. The class imported here is the index's; the one a\n * `QueryClient` throws is `/query`'s — a different constructor, so\n * `instanceof` is false for exactly the error this exists to catch. With\n * `instanceof` alone, every list, count, slug, id, `_chrome` and `_menu` read\n * answered 500 while `_resolve` (whose error comes from the index copy)\n * answered 404, and a unit test that threw the index class passed.\n * `admin-ui`'s api-handler matches the same way for the same reason.\n */\nfunction isSiteRequiredError(err: unknown): boolean {\n return (\n err instanceof SiteRequiredError || (err instanceof Error && err.name === 'SiteRequiredError')\n )\n}\n\n/** What `_site` reports for a site whose registry row declares no URL strategy. */\nconst DEFAULT_SITE_ROUTING_STRATEGY = 'no-prefix-default'\n\nconst menuParamsSchema = z\n .object({\n handle: z.string().min(1).max(MAX_MENU_HANDLE_LENGTH),\n locale: z.string().regex(LOCALE_PATTERN),\n currentPath: z.string().min(1).max(MAX_RESOLVE_PATH_LENGTH).optional(),\n })\n .strict()\n\n// ---------------------------------------------------------------------------\n// Entity resolution (whitelist-based, same pattern as admin handler)\n// ---------------------------------------------------------------------------\n\n/**\n * Path segments dispatched BEFORE entity-whitelist resolution (`_schema`\n * pre-dates PR07; `_resolve`/`_paths`/`_alternates` came with it; `_menu` with\n * PR09). An entity whose singular or plural name matches one would be\n * permanently unreachable via the generic `/:entity` routes — silently\n * shadowed, not 404'd for a diagnosable reason. Rejected at construction time\n * (fail fast, boot-time config error) rather than left as a confusing runtime\n * dead end.\n */\nconst RESERVED_ROUTE_SEGMENTS = new Set([\n '_schema',\n '_site',\n '_resolve',\n '_paths',\n '_alternates',\n '_menu',\n '_chrome',\n])\n\n/**\n * The API version this instance mounts. Every route is served under it and\n * ONLY under it — `/v1/article`, `/v1/_schema`. There is no unprefixed mount,\n * no deprecation marker, no `Link` successor header (D010): no consumer\n * predates the prefix, so there is nothing to transition.\n *\n * **What `v1` means.** Adding an endpoint, an entity, an error code, a `meta`\n * field, or an OPTIONAL query parameter is additive and stays inside v1.\n * CHANGING or REMOVING any of them — a field's type, an endpoint's shape, a\n * default, a previously-optional param becoming required — is a v2. `GET\n * /v1/_schema` publishes the version this instance mounts, so a client can\n * assert against it rather than infer it from the URL it happened to be given.\n *\n * The version is consumed by the HANDLER, not by the mount. The app mounts one\n * catch-all whose only job is to hand over an ordered array of path segments,\n * so a consuming app needs no edit at all, and the package that owns the\n * contract is the one that enforces and introspects it — rather than whichever\n * framework the app happens to route with.\n */\nexport const API_VERSION = 'v1'\n\n/**\n * The version segment is reserved as a PATTERN (`v` followed by digits), not as\n * the literal {@link API_VERSION}. An entity named `v2` must be refused TODAY,\n * years before a `/v2/` exists — otherwise whoever ships v2 finds the segment\n * already taken by an entity added in the meantime, and every `/v2/...` request\n * silently resolves to that entity instead of to the new contract.\n *\n * Exact by construction: `video` is not `v`+digits, so ordinary words are\n * unaffected.\n */\nconst VERSION_SEGMENT_RE = /^v\\d+$/\n\nfunction buildEntityMap(entities: Entity[]): Map<string, Entity> {\n const map = new Map<string, Entity>()\n for (const entity of entities) {\n const pluralName = `${entity.name}s`\n if (RESERVED_ROUTE_SEGMENTS.has(entity.name) || RESERVED_ROUTE_SEGMENTS.has(pluralName)) {\n throw new Error(\n `@murumets-ee/content-api: entity name \"${entity.name}\" collides with a reserved ` +\n `route segment (${[...RESERVED_ROUTE_SEGMENTS].join(', ')}). Rename the entity — ` +\n `its API surface would otherwise be permanently unreachable, shadowed by the ` +\n `special dispatch that runs before entity-whitelist resolution.`,\n )\n }\n if (VERSION_SEGMENT_RE.test(entity.name) || VERSION_SEGMENT_RE.test(pluralName)) {\n throw new Error(\n `@murumets-ee/content-api: entity name \"${entity.name}\" is a reserved API-version ` +\n `segment. Every route is served under a version prefix (this instance mounts ` +\n `/${API_VERSION}/), and the prefix is matched as a PATTERN (\"v\" followed by digits) ` +\n `so that a future /v2/ can never be shadowed by an entity added in the meantime. ` +\n `Rename the entity.`,\n )\n }\n // A non-global-scoped entity is refused OUTRIGHT, at construction.\n //\n // `QueryClient` derives its scope filter from `RequestContext.scope`, and\n // reads pass `strictScope: false` — so when no scope is in context,\n // `resolveAuthContext` returns `scopeId: undefined`, `buildScopeCondition`\n // is skipped, and the query returns EVERY tenant's rows. Nothing on this\n // surface populates a scope (`ContentApiPrincipal` carries an identity and a\n // role, deliberately: tenant-scoped authorization is out of scope for this\n // plan). So the only shapes available today are \"correctly refused\" and\n // \"silently cross-tenant\", and the failure is invisible — a 200 with more\n // rows than the caller should see reads exactly like a working endpoint.\n //\n // Refusing here makes it fail closed AND loud, at boot rather than in\n // production, and this is the deliberate place to open the door: whoever\n // populates `RequestContext.scope` from a principal lifts this throw in the\n // same change, with the negative test that proves the filter actually\n // applies. `scope: 'global'` and an absent `scope` are both fine — those are\n // the only entities the read path can currently serve correctly.\n if (entity.scope !== undefined && entity.scope !== 'global') {\n throw new Error(\n `@murumets-ee/content-api: entity \"${entity.name}\" declares scope ` +\n `\"${entity.scope}\", which this surface cannot serve. The public read path ` +\n `resolves no scope for a caller, so QueryClient would apply NO scope filter ` +\n `and return every tenant's rows to every caller. Remove it from the ` +\n `content-api whitelist, or serve it through a surface that establishes a ` +\n `scope in the request context.`,\n )\n }\n\n map.set(entity.name, entity)\n map.set(pluralName, entity)\n }\n return map\n}\n\n// ---------------------------------------------------------------------------\n// Table-column helpers (typed via drizzle's getTableColumns)\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the registered Drizzle table + its column map for an entity by name.\n * Returns undefined if the entity isn't registered (caller decides whether\n * that's a 404 or a no-op).\n */\n/** A bound entity's `_site` column — throws rather than silently widening. */\nfunction requireSiteColumn(cols: Record<string, PgColumn>, entityName: string): PgColumn {\n const col = cols[SITE_COLUMN]\n if (!col) throw new Error(`site-bound '${entityName}' translations have no ${SITE_COLUMN} column`)\n return col\n}\n\nfunction resolveTableCols(\n name: string,\n): { table: PgTable; cols: Record<string, PgColumn> } | undefined {\n const table = schemaRegistry.get(name)\n if (!table) return undefined\n return { table, cols: getTableColumns(table) as Record<string, PgColumn> }\n}\n\n// ---------------------------------------------------------------------------\n// Query helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build WHERE condition for `?search=q` against a public entity.\n *\n * Reads `searchable()` declaration (when present):\n * - `fts: {...}` → `_search_tsv @@ websearch_to_tsquery(language, q)`\n * - no fts → OR-combined ILIKE across `searchable().fields`\n *\n * Returns `undefined` when the entity has no `searchable()` (search\n * param ignored — public consumers won't get filtered results without\n * the entity opting in).\n *\n * **Differences from admin-ui's `buildSearchableWhere`:**\n * - This helper does NOT apply `searchable().defaultFilters`. Defaults\n * are an admin-side UX feature (\"hide closed tickets from the\n * inbox by default\"); public consumers already get publish-status\n * filtering from `publishable()` and don't need a second-layer\n * default-filter mechanism.\n * - This helper does NOT honour `searchable().includes`. Public\n * cross-entity OR-search would expose join structure that the\n * public API contract doesn't promise. Admin path supports it.\n *\n * **Translated content (#543):** translatable `fields` additionally match\n * via a correlated EXISTS on `<entity>_translations`, constrained to the\n * request's resolved locale — a frontend listing `?locale=et&search=Meist`\n * matches the et title even though it lives in the translation row. When\n * NO locale resolves (no `?locale` param and the host configured no\n * `defaultLocale`), the EXISTS is skipped entirely: base-table search only.\n * The admin surfaces deliberately match any locale in that case; the public\n * API stays conservative — returned rows carry base-locale values, so an\n * any-locale match would surface rows that don't visibly contain the term.\n */\nfunction buildSearchCondition(entity: Entity, search: string, locale?: string): SQL | undefined {\n const config = getSearchableConfig(entity)\n if (!config) return undefined\n const trimmed = search.trim()\n if (trimmed.length === 0) return undefined\n\n const resolved = resolveTableCols(entity.name)\n if (!resolved) return undefined\n const { cols } = resolved\n\n const pieces: SQL[] = []\n const pattern = `%${escapeLikePattern(trimmed)}%`\n\n // `searchable().fields` is entity-author-declared, not caller input, but it is\n // NOT validated against the internal rule anywhere — so\n // `searchable({ fields: ['_workflowStatus'] })` would hand an anonymous caller\n // an ILIKE oracle over a column the response body hides, and `ilike()` on a\n // non-text internal column (`_version`) is a 500. Same predicate as every\n // other boundary; the author keeps the field searchable in the admin surface,\n // it just stops being probe-able from the public one.\n const allFields = entity.allFields as Record<string, FieldConfig>\n const searchableFields = config.fields.filter(\n (fieldName) => !isInternalField(fieldName, allFields[fieldName]),\n )\n\n // The same rule has to cover FTS, not just the ILIKE fallback above.\n // `_search_tsv` is a PRECOMPUTED tsvector: whatever `fts.fields` names is\n // baked into that column at write time by `searchable()`'s DDL. Filtering\n // `config.fields` therefore does nothing for it — an entity declaring\n // `fts: { fields: ['_workflowStatus'] }` would still let `?search=` probe\n // that text through `websearch_to_tsquery`, which is precisely the oracle\n // the ILIKE filter above exists to close. Skip the tsvector entirely in\n // that case and fall through to the already-filtered ILIKE path: slower for\n // a misconfigured entity, but never probe-able.\n //\n // This is a guard at the QUERY boundary. The internal field's text is still\n // physically present in `_search_tsv`; keeping it out of the column in the\n // first place belongs in `searchable()`'s DDL in `@murumets-ee/entity` and\n // is schema-affecting, so it is deliberately not done here.\n const ftsExposesInternal =\n config.fts?.fields.some((fieldName) => isInternalField(fieldName, allFields[fieldName])) ??\n false\n\n // D049 — the tsvector match and the ILIKE over the NON-FTS remainder are OR'd,\n // never alternatives.\n //\n // This used to run the ILIKE loop only `if (pieces.length === 0)`, so whenever\n // a main-table `_search_tsv` existed, every declared field outside\n // `fts.fields` was dropped from public search — and `fts.fields` is capped at\n // four by `searchable()`'s setweight partition. That contradicts\n // `searchable()`'s own contract, which is that `fields` is ALWAYS the\n // searchable set and `fts.fields` is the ranking-eligible SUBSET, and it\n // contradicts it in the direction of returning FEWER results (F036).\n //\n // admin-ui's `buildSearchableWhere` composes it the correct way and its\n // comment records what the mistake cost the last time: \"silently dropped\n // requesterEmail/Name from search and broke the inbox UX — typing a sender\n // email returned nothing.\"\n //\n // Calibrated: this was LATENT, not live. Both entities the one production\n // mount whitelists declare all their FTS fields translatable, so the tsvector\n // lands on `<entity>_translations`, the main table has no `_search_tsv`, and\n // both already took the full ILIKE path. The defect fires on the first content\n // entity with a non-translatable FTS field plus any additional searchable\n // field — so the fix is free in measured cost today.\n //\n // Safe by the predicate that already guards the path: `searchableFields` is\n // filtered through `isInternalField`, so widening the match cannot reach a\n // field the response body hides, and `QueryClient`'s publish filter is\n // untouched.\n //\n // ONE COST, NAMED SO THE FIRST ENTITY TO PAY IT IS NOT SURPRISED. Where a\n // main-table tsvector exists, the predicate used to be a pure `@@\n // websearch_to_tsquery(...)` — index-backed by the GIN index `searchable({\n // fts })` emits. OR-ing an unindexable `ILIKE` beside it forces a sequential\n // scan of the collection. That is free today (no whitelisted entity has a\n // non-translatable FTS field, so none takes this branch) and is not free for\n // the first entity that does, on the most expensive query of an always-on\n // unauthenticated surface. D047 offsets part of it by removing the count from\n // a searched list, and the survival guard bounds the rest. The alternative —\n // keeping the index and silently searching four fields when the author\n // declared ten — is the defect (F036), not a trade.\n let ftsFieldsMatched: ReadonlySet<string> = EMPTY_FIELD_SET\n if (config.fts && !ftsExposesInternal) {\n const tsvCol = cols._search_tsv\n if (tsvCol) {\n const language = config.fts.language ?? 'simple'\n pieces.push(sql`${tsvCol} @@ websearch_to_tsquery(${language}, ${trimmed})`)\n ftsFieldsMatched = new Set(config.fts.fields)\n }\n // Translatable-only FTS — no main-table tsvector, so every declared field\n // goes through ILIKE below.\n }\n\n for (const fieldName of searchableFields) {\n // Already covered by the tsvector predicate above — ILIKE'ing it again would\n // be a redundant OR term on every row.\n if (ftsFieldsMatched.has(fieldName)) continue\n const column = cols[fieldName]\n if (!column) continue\n pieces.push(ilike(column, pattern))\n }\n\n // Translated content (#543) — correlated EXISTS on the translations table\n // for the translatable subset of the declared fields. Public API only\n // searches translations when a locale actually resolved (see JSDoc).\n const translatableFields = filterTranslatableFields(\n entity.allFields as Record<string, { translatable?: boolean }>,\n searchableFields,\n )\n const idColumn = cols.id\n if (locale !== undefined && translatableFields.length > 0 && idColumn) {\n const translationsTable = schemaRegistry.get(`${entity.name}_translations`)\n if (translationsTable) {\n const translationsExists = buildTranslationsSearchExists({\n idColumn,\n translationsTable,\n fields: translatableFields,\n locale,\n buildCondition: (column) => ilike(column, pattern),\n })\n if (translationsExists) pieces.push(translationsExists)\n }\n }\n\n if (pieces.length === 0) return undefined\n const first = pieces[0]\n if (!first) return undefined\n if (pieces.length === 1) return first\n const tail = pieces.slice(1)\n return or(first, ...tail)\n}\n\n/**\n * Result of resolving `?sort=`. `field` is the ACCEPTED sort field — set only\n * when `orderBy` was actually built — so callers never re-derive it from the\n * raw param (which would let a rejected sort leak back into e.g. the cursor).\n * `dropped` carries the rejected param for logging.\n */\ninterface ResolvedOrderBy {\n orderBy: SQL | undefined\n field?: string | undefined\n dropped?: string | undefined\n}\n\nfunction buildOrderBy(\n entity: Entity,\n sortParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): ResolvedOrderBy {\n const entityName = entity.name\n if (!sortParam) return { orderBy: undefined }\n\n const descending = sortParam.startsWith('-')\n const fieldName = descending ? sortParam.slice(1) : sortParam\n const drop = { orderBy: undefined, dropped: sortParam }\n\n // Reject prototype-chain keys (`constructor`, `toString`, …) — a bare lookup\n // would resolve those to a truthy non-FieldConfig and hand a junk column ref\n // to drizzle.\n if (!Object.hasOwn(allFields, fieldName)) return drop\n\n const fieldConfig = allFields[fieldName]\n if (!fieldConfig || fieldConfig.type === 'blockTree') return drop\n // Reject internal fields. ORDER BY on `_workflowStatus` / `_version` is a\n // blind oracle over a server-managed value for any caller who can page the\n // list — the same leak `buildFilterConditions` closes, through the other\n // door. Note this gate is now the WHOLE story: since D024 removed the\n // internal-field exposure allowlist, an internal field is neither readable\n // nor queryable, in any configuration.\n //\n // ONE declared exception: an `orderable()` entity's order key may be sorted\n // by (editor-curation D001), so a listing can follow the manual order. The\n // ORDER is what the listing shows anyway; the key's VALUE stays out of rows,\n // filters, `?fields=` and cursors — `nextCursor` is minted from the shaped\n // row, which never carries it, so paging that sort is offset-only.\n if (isInternalField(fieldName, fieldConfig) && !isSortOnlyInternalField(entity, fieldName)) {\n return drop\n }\n\n const resolved = resolveTableCols(entityName)\n if (!resolved) return drop\n const column = resolved.cols[fieldName]\n if (!column) return drop\n\n return { orderBy: descending ? desc(column) : asc(column), field: fieldName }\n}\n\n/** Accepted filter conditions plus the `filter[…]` keys that were rejected. */\ninterface ResolvedFilters {\n conditions: SQL[]\n dropped: string[]\n}\n\nfunction buildFilterConditions(\n entityName: string,\n url: URL,\n allFields: Record<string, FieldConfig>,\n): ResolvedFilters {\n const conditions: SQL[] = []\n const dropped: string[] = []\n const resolved = resolveTableCols(entityName)\n if (!resolved) return { conditions, dropped }\n const { cols } = resolved\n\n for (const [key, raw] of url.searchParams.entries()) {\n const match = key.match(FILTER_PARAM_PATTERN)\n if (!match) continue\n\n const fieldName = match[1]\n if (fieldName === undefined) continue\n // Reject prototype-chain keys (`constructor`, `toString`, …).\n if (!Object.hasOwn(allFields, fieldName)) {\n dropped.push(fieldName)\n continue\n }\n\n const fieldConfig = allFields[fieldName]\n if (!fieldConfig || fieldConfig.type === 'blockTree') {\n dropped.push(fieldName)\n continue\n }\n // Reject internal fields (`_scopeId`, `_versionId`, `internal: true`, …) —\n // the shared `isInternalField` predicate every caller-input boundary uses.\n if (isInternalField(fieldName, fieldConfig)) {\n dropped.push(fieldName)\n continue\n }\n\n const column = cols[fieldName]\n if (!column) {\n dropped.push(fieldName)\n continue\n }\n\n const validated = validateFilterValue(fieldConfig, raw)\n if (validated === null) {\n dropped.push(fieldName)\n continue\n }\n\n // Date range filters: gte: / lte: prefix is preserved by validateFilterValue.\n if (fieldConfig.type === 'date' && typeof validated === 'string') {\n if (validated.startsWith('gte:')) {\n conditions.push(gte(column, validated.slice(4)))\n continue\n }\n if (validated.startsWith('lte:')) {\n conditions.push(lte(column, validated.slice(4)))\n continue\n }\n }\n conditions.push(eq(column, validated))\n }\n\n return { conditions, dropped }\n}\n\n/**\n * Enforce a pagination ceiling LOUDLY — returns the 400 to short-circuit with,\n * or `undefined` when the value is within bounds (D007).\n *\n * Never a clamp. A clamp answers 200 with a page the caller did not ask for:\n * it cannot be observed failing from the outside, and it leaves a deep-paging\n * client silently re-reading the same page forever. The zod schemas enforce the\n * PACKAGE ceilings (`MAX_LIMIT`/`MAX_OFFSET`) on the way in; this covers the\n * host-lowered ones, which zod cannot see.\n *\n * `code` stays `VALIDATION_ERROR` — it is machine contract, and an over-large\n * page is not a new class of failure. `message` is not contract, so it names\n * the ceiling and, for `offset`, the supported way past it.\n */\nfunction ceilingViolation(\n param: 'limit' | 'offset',\n value: number,\n ceiling: number,\n corsHeaders: Record<string, string>,\n /**\n * Whether the CALLER supplied this parameter. D007's \"reject, never clamp\"\n * governs what a caller ASKED FOR — it says nothing about our own default,\n * because a request that omitted the parameter expressed no intent to\n * violate anything.\n *\n * Without this distinction a host that lowers `maxLimit` below\n * `DEFAULT_LIMIT` gets `400 \"?limit=20 exceeds the maximum of 10.\"` on a\n * request carrying NO `?limit=` at all — an endpoint unusable for that host,\n * failing with a message naming a parameter the caller never sent. The\n * caller-supplied value is still rejected loudly; only the default is\n * clamped, by `resolveLimit` below.\n *\n * `offset` needs no such treatment: `DEFAULT_OFFSET` is 0 and every ceiling\n * is ≥ 0, so the default can never exceed it.\n */\n supplied = true,\n): Response | undefined {\n if (!supplied || value <= ceiling) return undefined\n const remedy = param === 'offset' ? ' Use ?cursor= for deep pagination.' : ''\n return errorResponse(\n `?${param}=${value} exceeds the maximum of ${ceiling}.${remedy}`,\n 'VALIDATION_ERROR',\n 400,\n corsHeaders,\n )\n}\n\n/**\n * The effective `limit` once a host ceiling below `DEFAULT_LIMIT` is accounted\n * for. Pair with `ceilingViolation(..., supplied)`: that rejects what the caller\n * asked for, this clamps only the default we supplied ourselves.\n */\nfunction resolveLimit(parsed: number, ceiling: number, supplied: boolean): number {\n return supplied ? parsed : Math.min(parsed, ceiling)\n}\n\n/**\n * Refuse a host ceiling that would invert the bound rather than tighten it.\n * `undefined` is fine (the package default applies); anything else must be a\n * finite integer, and > 0 unless the caller opts into `allowZero` (`maxOffset:\n * 0` is a coherent \"no offset paging at all\" — `maxLimit: 0` is not, it means\n * \"return nothing\", which no host wants and which reads as an unset env var).\n */\nfunction assertPositiveCeiling(\n name: 'maxLimit' | 'maxOffset',\n value: number | undefined,\n { allowZero = false }: { allowZero?: boolean } = {},\n): void {\n if (value === undefined) return\n const floor = allowZero ? 0 : 1\n if (!Number.isInteger(value) || value < floor) {\n throw new Error(\n `content-api: \\`${name}\\` must be an integer >= ${floor}, received ${String(value)}. ` +\n 'A ceiling below that does not tighten the bound, it removes it: the limit reaches ' +\n 'the query layer falsy, no LIMIT clause is emitted, and the whole table is returned ' +\n 'on a public route. If this came from an environment variable, it is probably unset.',\n )\n }\n}\n\n// ---------------------------------------------------------------------------\n// Bounds on what caller-controlled text may reach a log line or a monitoring\n// payload (D032, D035)\n// ---------------------------------------------------------------------------\n\n/**\n * Longest caller-supplied string copied into a diagnostic or a monitoring\n * report.\n *\n * Availability is a security property, and both destinations are per-request:\n * a log line is a write an operator pays for, and a monitoring report leaves\n * the process. Neither may be sized by the caller. Generous enough that a real\n * `?sort=` or a real path is never touched.\n */\nconst MAX_LOGGED_VALUE_LEN = 256\n\n/** Most distinct rejected filter keys named in one diagnostic. */\nconst MAX_REPORTED_DROPPED_KEYS = 20\n\n/** Cap a caller-controlled string, marking the cut so it cannot read as the whole value. */\nfunction truncateForLog(value: string): string {\n return value.length <= MAX_LOGGED_VALUE_LEN\n ? value\n : `${value.slice(0, MAX_LOGGED_VALUE_LEN)}…[truncated]`\n}\n\n// ---------------------------------------------------------------------------\n// Request-bound logging (D032)\n// ---------------------------------------------------------------------------\n\n/**\n * The child logger bound for a request, keyed by that request's\n * {@link RequestContext}.\n *\n * The binding — correlation id and principal id — is established ONCE, where\n * the context is, and every later log line on that request inherits it. No call\n * site passes `requestId`; no call site passes the principal. That is the whole\n * point: per-call-site enrichment is a check every future author has to\n * remember, and its failure is silent (a line missing its correlation id looks\n * exactly like a line), whereas a binding cannot be forgotten by code that does\n * not mention it. Same asymmetry as the projection with nothing at depth 2 to\n * expand and the credential-less principal with nothing to authenticate\n * against.\n *\n * Keyed off the context OBJECT rather than an id string, so two concurrent\n * requests cannot collide and an entry dies with the request.\n */\nconst REQUEST_LOGGERS = new WeakMap<RequestContext, ToolkitLogger>()\n\n/**\n * The site's declared default locale per request (plan `multisite` phase 03) —\n * the fallback for a request with no `?locale=`. Kept beside the context\n * rather than ON it: `RequestContext.defaultLocale` means the STORAGE default\n * everywhere else, which is install-wide.\n */\nconst REQUEST_SITE_DEFAULT_LOCALES = new WeakMap<RequestContext, string>()\n\ntype ToolkitLogger = ReturnType<typeof getApp>['logger']\n\n/** Bind the request's identity to a child logger, once. */\nfunction bindRequestLogger(ctx: RequestContext): void {\n if (!ctx.requestId) return\n try {\n REQUEST_LOGGERS.set(\n ctx,\n getApp().logger.child({\n requestId: ctx.requestId,\n ...(ctx.user?.id !== undefined && { principalId: ctx.user.id }),\n }),\n )\n } catch {\n // App not yet bootstrapped. The request will still be served; only the\n // binding is lost, and `requestLog()` falls back to the unbound logger.\n }\n}\n\n/**\n * The logger for the request in flight — bound per {@link bindRequestLogger},\n * or the app logger when there is no context (pre-bootstrap paths only).\n *\n * Throws exactly where `getApp()` does; callers that can run before bootstrap\n * keep their existing `try`/`console` fallback.\n */\n/**\n * The locales the request's SITE serves and its default (plan `multisite`\n * phase 03, D007). `locales` empty = the site does not narrow the install's\n * set. Resolved per request (the registry read is cached briefly by core); a\n * request that names no site on an app serving several gets no narrowing here\n * — every site-bound read it makes refuses on its own.\n */\nasync function siteLocaleScope(\n site: string | undefined,\n mountDefault: string | undefined,\n): Promise<{ locales: string[]; defaultLocale?: string }> {\n // No booted app (a harness mounting the handler alone): nothing to narrow by.\n const app = getAppOrUndefined()\n if (!app) return { locales: [] }\n // A failed implicit-site lookup is \"no implicit site\" (the reads below then\n // refuse on their own); a failed REGISTRY read propagates to the 500 path —\n // it must not silently lift the site's locale restriction.\n const handle = site ?? (await resolveImplicitSite(app).catch(() => undefined))\n if (handle === undefined) return { locales: [] }\n const record = await readSiteRecord(app, handle)\n if (!record) return { locales: [] }\n // The record's default is the site's only on an app serving several sites;\n // a single-site install keeps the mount's default (the record holds a\n // one-time copy of the i18n setting, which the Languages editor still owns).\n const serves = (locale: string | undefined): locale is string =>\n locale !== undefined && (record.locales.length === 0 || record.locales.includes(locale))\n const declared = app.config.sites?.multiple === true ? record.defaultLocale : undefined\n const defaultLocale = serves(declared)\n ? declared\n : serves(mountDefault)\n ? mountDefault\n : record.locales[0]\n return { locales: record.locales, ...(defaultLocale !== undefined && { defaultLocale }) }\n}\n\nfunction requestLog(): ToolkitLogger {\n const ctx = getContext()\n const bound = ctx ? REQUEST_LOGGERS.get(ctx) : undefined\n return bound ?? getApp().logger\n}\n\n// ---------------------------------------------------------------------------\n// Handler factory\n// ---------------------------------------------------------------------------\n\nexport function createContentApiHandler(config: ContentApiConfig) {\n const {\n entities,\n defaultLocale: configDefaultLocale,\n publicRole = 'public',\n cors: corsConfig,\n cache: cacheConfig,\n onError,\n resolution,\n menus,\n chrome,\n site: siteProvider,\n } = config\n\n // The opt-in Host→site map (multisite D008), validated ONCE: a value that is\n // not a handle would put an unusable site on every request from that host,\n // which reads as \"the site has no content\" months later.\n const hostSites = new Map<string, string>()\n for (const [host, handle] of Object.entries(config.siteForHost ?? {})) {\n if (!isValidSiteHandle(handle)) {\n throw new Error(\n `content-api: \\`siteForHost['${host}']\\` is ${JSON.stringify(handle)}, which is not a site handle.`,\n )\n }\n hostSites.set(host.trim().toLowerCase(), handle)\n }\n\n /**\n * The site slot of this request's cache tags (multisite D008), or `undefined`\n * on a single-site install — whose tags stay exactly as they were.\n */\n function cacheTagSiteFor(\n siteScope: RequestSiteScope,\n ): ((entityName: string) => string) | undefined {\n if (!siteScope.multiSite) return undefined\n const registry = getAppOrUndefined()?.entities\n return (entityName) => {\n const entity = registry?.get(entityName)\n // A shared row is one row on every site: one tag, named once by a purge.\n // Anything else — a bound entity, or a reserved projection name such as\n // `_menu`, which is per site — carries the request's site.\n if (entity !== undefined && !isSiteBound(entity)) return SHARED_SITE_TAG_SEGMENT\n return siteScope.site ?? SHARED_SITE_TAG_SEGMENT\n }\n }\n\n /**\n * The locale an UNLOCALISED request reads: its site's declared default (plan\n * `multisite` phase 03) when there is one, else the mount's default.\n *\n * ⚠️ Only the fallback for a missing `?locale=`. The STORAGE default — which\n * locale a row's base columns hold, passed to every read as `defaultLocale` —\n * stays the mount's configured one: it is install-wide (shared rows are\n * written from every site) and never varies by site.\n */\n const requestLocaleFallback = (): string | undefined => {\n const ctx = getContext()\n return (ctx ? REQUEST_SITE_DEFAULT_LOCALES.get(ctx) : undefined) ?? configDefaultLocale\n }\n // Both ceilings are configurable DOWNWARD only — a host can tighten them,\n // never raise them past the package cap. Exceeding either is a 400\n // (see `enforceCeiling`), never a clamp.\n //\n // The FLOOR is enforced by throwing, because without it the most restrictive\n // configuration produces the LEAST restrictive query — a ceiling that inverts\n // rather than tightens:\n //\n // - `maxLimit: 0` (or `NaN`, e.g. `Number(process.env.X)` with X unset)\n // clamps the default limit to 0, and `QueryClient.findMany` guards its\n // limit with `if (options?.limit)` — 0 is FALSY, so no `LIMIT` clause is\n // emitted at all and an unauthenticated `GET /v1/articles` returns the\n // whole table.\n // - The same value makes `_paths` drain its async iterable to exhaustion\n // (`if (data.length === limit) break` never fires), contradicting that\n // handler's own \"never to exhaustion\" contract and CLAUDE.md's\n // No-Unbounded-Fan-Out rule on an always-on public path.\n // - `maxOffset: -1` makes `ceilingViolation` reject EVERY request, including\n // ones carrying no `?offset=` at all.\n //\n // This package's posture for config mistakes is to throw where the mistake is\n // attributable, on the grounds that a silent one looks like \"the feature just\n // doesn't work\" months later. A config mistake that silently removes a BOUND\n // deserves it more than most.\n assertPositiveCeiling('maxLimit', config.maxLimit)\n assertPositiveCeiling('maxOffset', config.maxOffset, { allowZero: true })\n // Same posture, one config field over: a removed cache field must not be\n // silently ignored (it would mean MORE caching than the operator asked for),\n // and the staleness window must not be the one ceiling validated per-request\n // — the preflight path builds cache headers with no `try` above it.\n assertCacheConfig(cacheConfig)\n // Resolved ONCE at construction — the header name and separator are a\n // deployment fact, not a per-request one. Only the tag SET is per-request.\n const cacheTagsConfig = resolveCacheTagsConfig(cacheConfig?.tags)\n // The same \"empty is a different kind of thing\" hazard as `isNonEmptyString`,\n // one layer up. `publicRole: ''` (an unset env var read straight into config)\n // is NOT a tighter default: it gives every anonymous caller `groups: []`,\n // which `resolveAuthContext` refuses — so the entire public surface answers\n // 500 rather than 404, and every request confirms it was received. Thrown at\n // construction for the same reason the ceilings are: a config mistake whose\n // only symptom is \"the surface doesn't work\" must be attributable to the\n // config, not rediscovered from a stack trace on a live route.\n if (!isNonEmptyString(publicRole)) {\n throw new Error(\n `content-api: \\`publicRole\\` must be a non-empty string, received ${JSON.stringify(publicRole)}. ` +\n 'An empty public role matches no permission yet is not the anonymous role either, so every ' +\n 'unauthenticated request reaches the read path with no groups and fails as a 500. If this ' +\n 'came from an environment variable, it is probably unset — omit the option to get \"public\".',\n )\n }\n const maxLimit = Math.min(config.maxLimit ?? MAX_LIMIT, MAX_LIMIT)\n const maxOffset = Math.min(config.maxOffset ?? MAX_OFFSET, MAX_OFFSET)\n // Which request headers a shared cache must key on to tell one caller's body\n // from another's — DECLARED by the host, because only the host knows what its\n // `authenticate` hook reads. Resolved + validated once; see\n // `resolveAuthVaryPolicy` and `ContentApiConfig.credentialHeaders`.\n const authVary: AuthVaryPolicy = resolveAuthVaryPolicy(config)\n\n // The availability guard (D025). Constructed here rather than per request, so\n // its state is per PROCESS — which is the honest scope for an in-memory\n // limiter and is what `perInstance`/`...PerInstance` in the config field names\n // is telling the reader (D028).\n //\n // No `if (config.limits)` around this. The survival half is keyless BY\n // CONSTRUCTION and there is deliberately no configuration field whose absence\n // disables it: a host cannot lose the semaphore or the wall clock by\n // forgetting something, which is the property that makes them safe to rely on.\n // `limits` only TUNES numbers that already have defaults.\n const guard = new AvailabilityGuard(config.limits)\n const entityMap = buildEntityMap(entities)\n\n // D024 — `exposeInternalFields` is GONE, and its absence is the property. The\n // read projection is one rule with no per-deployment variance (every declared\n // non-internal field, always), so `_schema` describes exactly what the\n // surface emits.\n //\n // It is refused LOUDLY rather than ignored. TypeScript rejects the key, but a\n // JS host — or one upgrading across this change — would otherwise keep the\n // option in its config and silently stop getting the field, which is the\n // \"feature just doesn't work\" failure this package throws at construction to\n // prevent everywhere else. The option was never a footgun; it was a careful\n // per-entity, per-field allowlist validated at construction. What removed it\n // was that D011 forbade its ONLY sanctioned use (`_overrides` no longer\n // reaches the wire in any configuration), leaving a config key whose entire\n // documentation was a warning — and a hole in the drift gate, since `_schema`\n // deliberately never described what it published.\n if (Object.hasOwn(config, 'exposeInternalFields')) {\n throw new Error(\n 'content-api: `exposeInternalFields` has been removed (plan/api D024). Its only ' +\n 'sanctioned use was publishing `_overrides`, which D011 replaced by composing site ' +\n 'chrome SERVER-side — so the field no longer needs to reach the wire, and no other ' +\n 'internal field was ever safe to list (`_workflowStatus` and `_workflowAssignee` ' +\n 'describe an editorial process and name a user; `_version`/`_versionedAt` expose ' +\n 'revision cadence). Fetch composed chrome from `GET /_chrome` instead. Remove the ' +\n 'option: it is refused rather than ignored, because silently dropping a field you ' +\n 'configured is exactly the failure this package throws at construction to prevent.',\n )\n }\n\n // The `_menu` allowlist, resolved once at construction. A Set (not the raw\n // array) so the deny-by-default check is O(1) and can't be turned into a scan\n // by a long config list.\n const menuHandles = new Set(menus?.handles ?? [])\n\n // ONE per-response resolution ceiling spanning references, `?populate=`\n // expansions, media and block-tree resolutions — not four independent caps,\n // which a single document could sum into a much larger pool draw. Validated\n // at construction like every other ceiling; see `resolveMaxResolutions`.\n const maxResolutions = resolveMaxResolutions(config.maxResolutionsPerDocument)\n\n // Shared count cache — reduces COUNT(*) queries on paginated list pages.\n const countCache = new CountCache(5000)\n\n // Lazy-loaded permission checker — cached with 30s TTL.\n // Hosts that mutate roles at runtime should call invalidatePermissionCache()\n // immediately after the change so the next request reloads.\n //\n // ⚠️ INVARIANT — THIS CACHE HOLDS THE CHECKER, NEVER ITS ANSWERS.\n //\n // A `PermissionChecker` is a pure function of `(role, entity, action)`, so one\n // instance is correct for every caller and memoising it is safe no matter how\n // many principals share the process. Memoising what it RETURNED would not be:\n // the key would have to include the principal, and the first miss that got the\n // key wrong would serve one caller's answer to another. Now that callers are\n // distinguishable principals rather than a single anonymous role, that is a\n // live foot-gun rather than a theoretical one — the adjacent admin surface has\n // a recorded defect of exactly this shape (an app-level checker cache that\n // never expires, so revocations never take effect and never cross processes).\n //\n // Two properties keep this one honest and must both survive any edit here:\n // 1. Nothing principal-derived may enter this cache — no id, no role, no\n // per-(role, entity) memo layered on top.\n // 2. The TTL must stay bounded and `invalidatePermissionCache()` must stay\n // reachable, so this layer alone can never hold a stale checker for\n // longer than the TTL.\n //\n // ⚠️ WHAT THIS TTL DOES *NOT* BUY YOU. It bounds THIS layer, per process, and\n // that is the whole of the guarantee:\n //\n // - It is per-process. `loadPermissions` is re-run by whichever process\n // expires first; nothing invalidates the others. Under any multi-instance\n // deployment a revocation is visible only after each instance's own TTL.\n // - `invalidatePermissionCache()` is EXPORTED BUT UNCALLED — zero call sites\n // repo-wide as of this commit (the one live wiring, `apps/admin`'s content\n // route, never calls it; the identically-named call in that app's ADMIN\n // route belongs to `createAdminApiHandler`, a different handler). So the\n // TTL is the only expiry that actually runs today.\n // - Hosts may add their own cache in FRONT of this one, and the only shipped\n // consumer does: `apps/admin/app/api/content/[...path]/route.ts` memoises\n // its checker for 30s inside `loadPermissions`. The two TTLs compose\n // additively, so the real worst case there is ~60s, not 30 — and calling\n // `invalidatePermissionCache()` would not fix it, because the next\n // `loadPermissions` would be served from the app's own cache. That\n // app-level cache is a known finding owned by `plan/admin-api-hardening`\n // (F001); it is deliberately NOT changed from here.\n //\n // Read the bound as \"≤ TTL for this layer, in this process\" — not as an\n // end-to-end revocation latency, which this package cannot promise.\n const PERMISSION_CACHE_TTL_MS = 30_000\n let permissionChecker: PermissionChecker | null = null\n let permissionCacheTime = 0\n\n async function getChecker(): Promise<PermissionChecker> {\n const now = Date.now()\n if (!permissionChecker || now - permissionCacheTime > PERMISSION_CACHE_TTL_MS) {\n permissionChecker = await config.loadPermissions()\n permissionCacheTime = now\n }\n return permissionChecker\n }\n\n function invalidatePermissionCache(): void {\n permissionChecker = null\n permissionCacheTime = 0\n }\n\n // -- Principal resolution --------------------------------------------------\n\n /** The anonymous principal, rebuilt per call so no caller can mutate a shared one. */\n function anonymousPrincipal(): ResolvedPrincipal {\n return { id: ANONYMOUS_PRINCIPAL_ID, role: publicRole, hasIdentity: false }\n }\n\n /**\n * Record a thrown `authenticate` hook, server-side, at `warn`.\n *\n * Serving the public response is the right answer (a 500 would confirm to the\n * caller that the attempt was noticed) — but discarding the error entirely\n * means a hook broken in production degrades the WHOLE surface to anonymous\n * with no symptom except \"authenticated callers stopped seeing more data\".\n * This is the only signal that a credential path is down.\n *\n * **The line carries the error and nothing else.** No URL, no headers, no\n * principal — the input to a failed authentication is by definition\n * unverified, and half of it is the credential itself. The log is not part of\n * the response, so it cannot become the oracle D006 forbids.\n *\n * The logger call is guarded because `getApp()` throws before the app is\n * bootstrapped: a degraded authentication must not become a crash on the way\n * to reporting itself.\n */\n function logAuthenticateFailure(err: unknown): void {\n try {\n requestLog().warn(\n { err },\n 'content-api: `authenticate` hook threw — serving the anonymous principal',\n )\n } catch {\n // App not yet bootstrapped (or no logger) — last-resort, same fallback the\n // dispatch-level error path uses.\n console.warn('[content-api] `authenticate` hook threw:', err)\n }\n }\n\n /**\n * Resolve the caller into a {@link ResolvedPrincipal}. NEVER throws and never\n * returns a partial principal: absent hook, `null`, or a thrown error all\n * produce the anonymous principal on `publicRole`.\n *\n * That uniformity is the point. A credential this instance cannot verify must\n * leave the caller with EXACTLY the reach an anonymous visitor has, rather\n * than with a role and no identity (which would authorize a caller nothing\n * could later attribute) or an identity and no role (which `resolveAuthContext`\n * would reject downstream as a 500 — an error shape that distinguishes \"your\n * credential was seen\" from \"you sent none\", which is the oracle D006 exists\n * to prevent).\n *\n * There are therefore exactly TWO outcomes, never three: the principal the\n * host resolved, or the anonymous one. In particular an EMPTY-STRING role is\n * not a third state — it passes a bare `typeof` check, then satisfies neither\n * branch coherently (`'' !== publicRole` marks the response authenticated\n * while `groups: []` makes the read path throw), so it collapses like any\n * other malformed value.\n *\n * No 401 is produced here, deliberately: this package parses no credential, so\n * it cannot tell a malformed one from an absent one. If a host later wants the\n * malformed-credential 401 that phase 03 allows, it belongs in the hook, which\n * is the only code that has seen the credential.\n */\n async function resolvePrincipal(req: Request): Promise<ResolvedPrincipal> {\n if (!config.authenticate) return anonymousPrincipal()\n\n let result: ContentApiPrincipal | null\n try {\n result = await config.authenticate(req)\n } catch (err) {\n // A hook that throws is a FAILED authentication. The RESPONSE is the\n // public one rather than a 500 that would confirm the attempt was noticed\n // — but the failure is recorded server-side, where it is invisible to the\n // caller and is the only way an operator learns the credential path is\n // down. See `logAuthenticateFailure`.\n logAuthenticateFailure(err)\n return anonymousPrincipal()\n }\n if (!result) return anonymousPrincipal()\n\n // EVERY field is runtime-validated, not just `role`. The hook is a trust\n // boundary crossed by a host we do not compile — a JS caller, a `JSON.parse`\n // of a token payload, a mis-shaped adapter — so the static type is a\n // statement of intent, not a guarantee about the value that arrives. An\n // unvalidated `id: 123` lands in `RequestContext.user.id`, which is typed\n // `string` and consumed by everything downstream as one.\n //\n // A malformed ANY field collapses the WHOLE principal to anonymous rather\n // than being dropped in isolation, because a hook that got one field wrong\n // has not demonstrated that the others are right — keeping the role while\n // discarding a bad id would authorize a caller nothing could later\n // attribute, which is the exact half-trusted state this function exists to\n // make unrepresentable.\n if (!isNonEmptyString(result.role)) return anonymousPrincipal()\n if (result.id !== undefined && !isNonEmptyString(result.id)) return anonymousPrincipal()\n if (result.name !== undefined && typeof result.name !== 'string') return anonymousPrincipal()\n if (result.email !== undefined && typeof result.email !== 'string') return anonymousPrincipal()\n // Part of what the credential IS, not a quota attached to it: a hook that\n // returned a site that is not a handle has not shown which site it meant,\n // and dropping the field alone would serve the single-site install's one\n // site to a credential minted for another.\n if (\n result.site !== undefined &&\n !(typeof result.site === 'string' && isValidSiteHandle(result.site))\n ) {\n return anonymousPrincipal()\n }\n // A malformed budget DROPS THE BUDGET, keeping the principal. It must NOT\n // collapse to anonymous, which is what this did in an earlier revision and\n // is strictly weaker than doing nothing at all.\n //\n // Trace why. `anonymousPrincipal()` sets `hasIdentity: false`, so `dispatch`\n // passes `principalId: undefined` to the guard, so `resolveKey` falls\n // through to the host's client-IP source — and if the host did not wire one,\n // there is NO distinguishing key, so the fairness limit is SKIPPED ENTIRELY\n // (D025). A credential whose issuer fat-fingered its budget would end up\n // rate-limited by nothing whatsoever. The comment this replaces claimed to\n // be avoiding exactly that outcome (\"silently ignoring the field would grant\n // the surface default\"), and produced something weaker than the thing it was\n // avoiding.\n //\n // Keeping the identity and discarding only the bad number is tighter in\n // every dimension: the fairness limit still applies, still keyed on this\n // principal, at the uncredentialed default. That default is the FLOOR of\n // what any credential can be granted, so a malformed budget can only ever\n // narrow a caller's throughput, never widen it.\n //\n // Unlike `role` / `id` / `name` / `email` above, this field is not part of\n // the principal's IDENTITY — it is a quota attached to it — so discarding it\n // leaves nothing half-trusted to reason about.\n const budgetIsUsable =\n result.rateBudgetPerInstance !== undefined &&\n Number.isSafeInteger(result.rateBudgetPerInstance) &&\n result.rateBudgetPerInstance >= 1\n\n return {\n // Fail-closed on a role-only hook (the pre-existing `{ role }` shape):\n // the host resolved no identity, so we assert none rather than invent one\n // — and say so explicitly, rather than leaving `cacheClassOf` to infer it\n // by comparing against a literal a real id could equal.\n id: result.id ?? ANONYMOUS_PRINCIPAL_ID,\n hasIdentity: result.id !== undefined,\n role: result.role,\n ...(result.name !== undefined && { name: result.name }),\n ...(result.email !== undefined && { email: result.email }),\n ...(budgetIsUsable && { rateBudgetPerInstance: result.rateBudgetPerInstance }),\n ...(result.site !== undefined && { site: result.site }),\n }\n }\n\n // -- Site resolution (plan `multisite` D008) --------------------------------\n\n /** The mount's opt-in Host→site answer for this request, if it has one. */\n function siteForHost(req: Request): string | undefined {\n if (hostSites.size === 0) return undefined\n const host = (req.headers.get('host') ?? new URL(req.url).host).trim().toLowerCase()\n return hostSites.get(host)\n }\n\n /**\n * Decide the request's site — ONCE, from server-side facts only — or refuse.\n *\n * Sources, in order: the credential's binding; an ambient site (a mount\n * wrapped in `runInSite`); the mount's opt-in Host→site map; and, on a\n * single-site install, the one site (left implicit, as before). No request\n * parameter, header or body field is consulted — see\n * {@link FORBIDDEN_SITE_PARAM}.\n *\n * `null` is a refusal, answered 404 like every other absence here (D006):\n * - a credential bound to one site inside a mount running as another;\n * - a NAMED site the registry does not hold, or holds as `disabled` — a\n * disabled site is not served (`lumi site:disable`). The registry read is\n * cached briefly by core, so a disable takes effect within that window.\n *\n * A request that names no site on an app serving several is NOT refused\n * here: shared entities (media) are still its to read. Every site-bound read\n * it attempts refuses in the data layer and is answered 404 in `dispatch`.\n */\n async function resolveRequestSite(\n req: Request,\n principal: ResolvedPrincipal,\n ): Promise<RequestSiteScope | null> {\n const ambient = getCurrentSite()\n if (principal.site !== undefined && ambient !== undefined && ambient !== principal.site) {\n safeWarn(\n { credentialSite: principal.site, mountSite: ambient },\n 'content-api: a credential bound to one site was presented to a mount running as another',\n )\n return null\n }\n const named = principal.site ?? ambient ?? siteForHost(req)\n // No booted app (a harness mounting the handler alone): no registry to\n // check against, so the named site is taken as given.\n const app = getAppOrUndefined()\n if (!app) return { ...(named !== undefined && { site: named }), multiSite: false }\n const multiSite = app.config.sites?.multiple === true\n // A failed implicit-site lookup is \"no implicit site\" — the bound reads\n // below then refuse on their own. A failed REGISTRY read propagates to the\n // 500 path: it must not be mistaken for \"no such site\".\n const handle = named ?? (await resolveImplicitSite(app).catch(() => undefined))\n if (handle === undefined) return { multiSite }\n const record = await readSiteRecord(app, handle)\n if (named !== undefined && (record === undefined || record.status !== 'active')) return null\n return {\n ...(named !== undefined && { site: named }),\n ...(record !== undefined && { record }),\n multiSite,\n }\n }\n\n /**\n * The CORS allowlist for a response that precedes the request's site: a\n * preflight (which carries no credential), and the guard's own refusals.\n *\n * On an app serving several sites that is the union of every active site's\n * origins — the only thing a credential-less request can be checked against.\n * It is never used for a response that carries content: that one is allowed\n * for the request's own site's origins only (see `dispatch`).\n */\n async function preSiteCorsConfig(req: Request): Promise<CorsConfig | undefined> {\n // No `Origin`, nothing to allow — and that is the NORMAL request here: a\n // site frontend fetches server-side. Skipping the registry read for it\n // keeps this (which runs before the concurrency guard) off the hot path.\n const app = getAppOrUndefined()\n const multiSite = app?.config.sites?.multiple === true\n if (!app || !multiSite) return corsConfig\n // An empty list rather than `undefined` when the mount configured none: on\n // an app serving several sites the response DOES vary by `Origin` (the\n // registry decides), so `Vary: Origin` must be declared even on the\n // answer that allows nothing.\n if (!req.headers.has('Origin')) return corsConfig ?? { allowedOrigins: [] }\n try {\n const index = await readSiteOriginIndex(app)\n if (index.truncated) {\n // Never silent: past the cap, the later sites' origins are missing\n // from this list and their preflights are refused.\n safeWarnOnce(\n 'site-origin-index-truncated',\n { listed: index.byOrigin.size },\n 'content-api: the site registry holds more active sites than the origin index reads — some sites’ CORS preflights will be refused',\n )\n }\n return {\n allowedOrigins: [\n ...new Set([...(corsConfig?.allowedOrigins ?? []), ...index.byOrigin.keys()]),\n ],\n ...(corsConfig?.maxAge !== undefined && { maxAge: corsConfig.maxAge }),\n }\n } catch (err) {\n // The registry is unreadable. Falling back to the mount's own list keeps\n // preflights answering; the request that follows fails on its own.\n safeWarn({ err }, 'content-api: could not read site origins for CORS')\n return corsConfig\n }\n }\n\n /**\n * The shared-cache class of this caller — see {@link CallerCacheClass}.\n *\n * Keyed on the IDENTITY as well as the role, deliberately. `role !==\n * publicRole` alone misses the ordinary \"logged-in visitor who was granted no\n * extra role\" case: `{ id: 'usr_x', role: publicRole }` is a perfectly legal\n * principal whose `user.id` reaches every `QueryClient` read, so the response\n * may vary per caller while a role-only rule stamps it `public, max-age=…`.\n * Nothing on the CURRENT read path varies on `user.id`, which is what makes\n * this latent rather than live — but D016's stated destination (owner-scoped\n * rows) activates it, and a cache rule that is only correct until the next\n * feature lands is a leak with a delay on it.\n *\n * Keyed on `hasIdentity` rather than on `id === ANONYMOUS_PRINCIPAL_ID`: that\n * literal is `'public'`, so a real principal whose id happened to equal it\n * would classify as anonymous and be served a publicly-cacheable body that\n * varies on their identity. See {@link ResolvedPrincipal.hasIdentity}.\n */\n function cacheClassOf(principal: ResolvedPrincipal): CallerCacheClass {\n if (principal.hasIdentity || principal.role !== publicRole) return 'identified'\n // The credential selected the site: storable by its holder, never by a\n // cache two sites share — see `CallerCacheClass`.\n return principal.site !== undefined ? 'site-keyed' : 'anonymous'\n }\n\n // -- Permission check (returns 404 on deny to avoid info leakage) ----------\n\n function hasViewPermission(\n checker: PermissionChecker,\n role: string,\n entityName: string,\n ): boolean {\n return checker(role, entityName, 'view')\n }\n\n // -- Create QueryClient from entity ----------------------------------------\n\n function createClient(entity: Entity): QueryClient {\n const app = getApp()\n return new QueryClient({\n entity,\n db: app.db.readOnly,\n // Derived from the REQUEST-bound logger, so a QueryClient diagnostic\n // inherits the correlation id and principal without `QueryClient` having\n // any notion of either (D032).\n logger: requestLog().child({ entity: entity.name }),\n countCache,\n contextResolver: () => {\n const ctx = getContext()\n if (!ctx?.user || !ctx?.checker) return undefined\n return {\n user: ctx.user,\n checker: ctx.checker,\n ...(ctx.scope !== undefined && { scope: ctx.scope }),\n // multisite D003 — the request's site (or the single-site answer).\n ...siteSecurityFields(ctx, app),\n }\n },\n })\n }\n\n // -- Resolution (phase 05 / D013) ------------------------------------------\n\n /**\n * Everything one response needs in order to turn stored identifiers into\n * something a database-less frontend can render, plus the ONE budget that\n * bounds the whole lot.\n *\n * Built per REQUEST, never shared: the budget is a statement about what THIS\n * response may cost, and a process-lifetime counter would be a rate limit\n * instead — a different mechanism, owned by phase 09, that neither\n * substitutes for nor is substituted by this one.\n */\n function buildResolutionDeps(\n checker: PermissionChecker,\n role: string,\n locale: string | undefined,\n cacheTags: CacheTagSink,\n ): ReferenceResolutionDeps & MediaResolutionDeps & BlockResolutionDeps {\n // Named, because `afterExpand` closes over the very object it belongs to —\n // the hook must see the SAME budget, not a copy.\n const deps: ReferenceResolutionDeps & MediaResolutionDeps & BlockResolutionDeps = {\n // ONE budget object, shared by every kind of resolution this response\n // performs. Handing each kind its own would be four ceilings wearing one\n // name.\n budget: createResolutionBudget(maxResolutions),\n // The SAME request-scoped set the handler will hand to the header\n // builder. Reference and media resolution are the only places that know\n // which target rows actually came back, so they record them here rather\n // than the response site re-deriving them from the finished body (D055).\n // Blocks contribute too, as of editor PR24. D055 §8 stated the boundary\n // as structural — the block provider returned an opaque `unknown`, so\n // what a tree embedded was unknowable here — and that stopped being true\n // when `frontend-delivery` D019 gave it a typed `ResolvedBlockTree`. The\n // remaining objection was that the invalidation sender did not speak the\n // vocabulary; `frontend-delivery` PR 9 settled it, and the frontend now\n // mints a document tag per `ResolvedBlockTree.refs` entry. So\n // `resolveBlockTrees` records them here, and `blockTreeContents` is no\n // longer published as an `uncovered` dependency.\n cacheTags,\n resolveMedia: config.resolveMedia,\n resolveBlocks: config.resolveBlocks,\n // Expanded `?populate=` targets get the same treatment root rows do, so a\n // media field's wire shape does not depend on how the row was reached.\n afterExpand: async (target, rows) => {\n // A populated row of a TEMPLATE-DRAWN entity is narrowed exactly as its\n // by-id read is — otherwise a reference field on any other entity would\n // be the way to read the columns `/:id` withholds. No template: a\n // reference to a room is a link, not a page.\n if (isTemplateDrawn(target)) {\n for (const row of rows)\n await narrowTemplateDrawnRow(target, row, templateDeps(deps), false)\n return\n }\n await projectMedia(target, rows, deps)\n await resolveBlockTrees(target, rows, deps)\n },\n locale,\n // Whitelist ∧ `view`, for THIS caller. A target the caller may not reach\n // resolves to absence — byte-identical to a deleted one, so a reference\n // field cannot become a cheap, repeatable existence oracle (D006).\n //\n // Singular names only: `ReferenceField.entity` is an entity name, never a\n // plural route alias, and accepting the alias here would let the two\n // spellings mean different things.\n reachableEntity: (name) => {\n const target = entityMap.get(name)\n if (!target || target.name !== name) return undefined\n return hasViewPermission(checker, role, target.name) ? target : undefined\n },\n // The SAME gate, for a collection block's rows (editor PR53): whitelist\n // ∧ `view`, for THIS caller, singular names only. A page must not become a\n // side door around the entity whitelist — a collection over an entity\n // this API withholds lists nothing, byte-identical to one that matched\n // nothing.\n canReadEntity: (name) => {\n const target = entityMap.get(name)\n return (\n target !== undefined && target.name === name && hasViewPermission(checker, role, name)\n )\n },\n // Per RESPONSE, not per document: a list endpoint resolves up to its\n // page size of trees, each allowed six collections of its own.\n collectionAllowance: createCollectionAllowance(MAX_COLLECTIONS_PER_RESPONSE),\n // The declared title of a template-drawn reference target — through the\n // SAME provider projection its by-id read uses, so a reference and the\n // row it points at cannot name it differently.\n templateDrawnTitle: async (target, row) => {\n const provider = config.detailTemplates\n const titleLocale = locale ?? configDefaultLocale\n if (!provider || titleLocale === undefined) return null\n try {\n const projected = await provider.publicRow({\n entity: target.name,\n row,\n locale: titleLocale,\n })\n const title = typeof projected.title === 'string' ? projected.title.trim() : ''\n return title === '' ? null : title.slice(0, 2_000)\n } catch (err) {\n deps.logger?.warn(\n { entity: target.name, err },\n 'content-api: detail-template provider failed to project a reference title',\n )\n // As `narrowTemplateDrawnRow` does for the same failure: a cached\n // `title: null` would outlive the fix by a full TTL.\n cacheTags.markUncacheable()\n return null\n }\n },\n readRowsByIds: async (target, ids, select) => {\n const resolved = resolveTableCols(target.name)\n const idColumn = resolved?.cols.id\n // No registered table → no rows. Absence, not an error: a target whose\n // table is unregistered is exactly as unresolvable as a deleted one.\n if (!idColumn) return []\n // Nothing to look up, and — decisively — never a query with `limit: 0`.\n // `QueryClient.findMany` guards its limit with `if (options?.limit)`, so\n // a falsy limit emits NO `LIMIT` clause and returns the whole table. At\n // `maxResolutionsPerDocument: 0` the clamp below is exactly 0, so the\n // most restrictive configuration would otherwise produce the least\n // restrictive query — the same inversion `assertPositiveCeiling` throws\n // for. Callers already skip a zero grant; this is the backstop.\n if (ids.length === 0 || maxResolutions === 0) return []\n // Reads through the SAME `createClient` path every endpoint uses, so\n // the publish filter, locale merge and `view` gate apply identically —\n // an unpublished target simply does not come back, which is how it\n // becomes indistinguishable from a missing one at no extra cost.\n const client = createClient(target)\n const rows = await client.findMany({\n where: inArray(idColumn, [...ids]),\n // `ids` is already bounded by the resolution budget upstream; the\n // limit is belt-and-braces so a future caller cannot widen the read\n // by widening the id list alone.\n limit: Math.min(ids.length, maxResolutions),\n ...(select !== undefined && { select: [...select] }),\n ...(locale !== undefined && { locale }),\n defaultLocale: configDefaultLocale,\n })\n return rows as Record<string, unknown>[]\n },\n resolveEntityHref: config.resolveEntityHref,\n logger: resolutionLogger(),\n }\n return deps\n }\n\n /**\n * The structured logger the resolution layer drops diagnostics into, or\n * `undefined` before the app is bootstrapped.\n *\n * Request-BOUND (D032), so a resolution diagnostic joins the same correlation\n * id as everything else on the request without the resolution layer knowing\n * that correlation exists.\n *\n * Guarded because `getApp()` throws pre-bootstrap: a degraded resolution must\n * not become a crash on the way to reporting itself — the same shape\n * `logAuthenticateFailure` uses.\n */\n function resolutionLogger():\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined {\n try {\n return requestLog()\n } catch {\n return undefined\n }\n }\n\n /**\n * Report whatever the budget refused.\n *\n * **Load-bearing, not diagnostics-for-completeness.** Phase 05 makes an\n * unresolved target indistinguishable from a deleted, unpublished or\n * unviewable one ON THE WIRE — deliberately, because splitting them is an\n * existence oracle. The consequence is that the operator log is the ONLY\n * place the distinction survives, so an editor asking \"why did my\n * related-article link disappear?\" can only be answered from here. Silent\n * truncation would read as \"covered everything\" (CLAUDE.md).\n *\n * `warn`, not `debug`: a rejected `filter[…]` key is a caller's typo, but a\n * budget drop is the SERVER declining work it was asked to do, and a signal\n * below the default level cannot arrive (F023).\n */\n function reportBudget(\n deps: ReferenceResolutionDeps & Pick<BlockResolutionDeps, 'collectionAllowance'>,\n entityName: string,\n path: string,\n ): void {\n const refusedCollections = deps.collectionAllowance?.refusedCount() ?? 0\n if (refusedCollections > 0) {\n try {\n requestLog().warn(\n {\n entity: entityName,\n path: truncateForLog(path),\n cap: MAX_COLLECTIONS_PER_RESPONSE,\n refused: refusedCollections,\n },\n 'content-api: per-response collection cap reached — the collection blocks past it rendered empty',\n )\n } catch {\n // Pre-bootstrap; the response is still correct, only the signal is lost.\n console.warn('[content-api] collection cap reached for', entityName, truncateForLog(path))\n }\n }\n if (!deps.budget.hasDrops) return\n try {\n requestLog().warn(\n {\n entity: entityName,\n // Capped like every other caller-controlled string that reaches a\n // log line — this one is a request pathname.\n path: truncateForLog(path),\n ceiling: deps.budget.ceiling,\n resolved: deps.budget.granted,\n dropped: deps.budget.dropSummary(),\n },\n 'content-api: per-document resolution budget exhausted — response rendered with unresolved targets',\n )\n } catch {\n // Pre-bootstrap; the response is still correct, only the signal is lost.\n // Bounded like the structured line above: the pathname is caller-supplied.\n console.warn(\n '[content-api] resolution budget exhausted for',\n entityName,\n truncateForLog(path),\n )\n }\n }\n\n /**\n * Parse `?populate=` and enforce its ceiling.\n *\n * Returns the accepted field set, or a `Response` to short-circuit with.\n * The ceiling REFUSES (400) rather than truncating — D007's posture, and for\n * its reason: a silent truncation answers 200 with an expansion the caller\n * did not get and cannot detect. Names refused by the whitelist are DROPPED\n * instead, because on the wire a dropped name is indistinguishable from an\n * entity that has no such field. The server never explains a missing target;\n * it always rejects a malformed request.\n */\n function resolvePopulate(\n populateParam: string | undefined,\n entity: Entity,\n allFields: Record<string, FieldConfig>,\n corsHeaders: Record<string, string>,\n ): { fields: ReadonlySet<string> } | { response: Response } {\n const parsed = parsePopulate(populateParam, allFields)\n // Counted against what was ASKED, not what survived: counting survivors\n // would refuse a correct request of six and wave through a malformed one of\n // fifty whose typos happened to be dropped first.\n if (parsed.requested > MAX_POPULATE_FIELDS) {\n return {\n response: errorResponse(\n `?populate= names ${parsed.requested} fields, which exceeds the maximum of ${MAX_POPULATE_FIELDS}.`,\n 'VALIDATION_ERROR',\n 400,\n corsHeaders,\n ),\n }\n }\n if (parsed.dropped.length > 0) {\n resolutionLogger()?.debug(\n { entity: entity.name, droppedPopulate: parsed.dropped },\n 'content-api: rejected ?populate= fields (unknown, internal, or not a reference)',\n )\n }\n return { fields: new Set(parsed.fields) }\n }\n\n /**\n * The ONE resolution pass every read endpoint runs, in the ONE order it must\n * run in.\n *\n * Extracted because the order is load-bearing and the steps are\n * security-relevant: an endpoint that ran three of the four would emit a bare\n * uuid where a projection belongs, or lose the only record of a budget drop.\n * Four hand-written copies is four chances to add a fifth endpoint that\n * forgets one.\n *\n * Note what is NOT in here: `handleList` mints its `nextCursor` BEFORE\n * calling this, because `?sort=` accepts reference fields and projecting\n * first would encode a projection object where the keyset WHERE expects the\n * stored uuid.\n */\n /** `deps` plus what a template-drawn row's narrowing needs — see `detail-template.ts`. */\n function templateDeps(deps: BlockResolutionDeps): TemplateDrawnDeps {\n return { ...deps, detailTemplates: config.detailTemplates, defaultLocale: configDefaultLocale }\n }\n\n async function resolveRows(\n entity: Entity,\n rows: Record<string, unknown>[],\n populate: ReadonlySet<string>,\n deps: ReferenceResolutionDeps & MediaResolutionDeps & BlockResolutionDeps,\n path: string,\n ): Promise<void> {\n await projectReferences(entity, rows, populate, deps)\n await projectMedia(entity, rows, deps)\n await resolveBlockTrees(entity, rows, deps)\n reportBudget(deps, entity.name, path)\n }\n\n // -- Endpoint handlers -----------------------------------------------------\n\n async function handleList(\n req: Request,\n entity: Entity,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = listParamsSchema.safeParse(toParamBag(url, { allowFilters: true }))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n const {\n limit: parsedLimit,\n offset,\n sort,\n locale,\n fields,\n search,\n cursor: cursorParam,\n populate: populateParam,\n } = params.data\n\n // Ceilings reject, never clamp (D007). Checked before any DB work.\n // `supplied` splits \"the caller asked for too much\" (400) from \"our own\n // default exceeds this host's ceiling\" (clamp) — see `ceilingViolation`.\n const limitSupplied = url.searchParams.has('limit')\n const overLimit = ceilingViolation('limit', parsedLimit, maxLimit, corsHeaders, limitSupplied)\n if (overLimit) return overLimit\n const overOffset = ceilingViolation('offset', offset, maxOffset, corsHeaders)\n if (overOffset) return overOffset\n const limit = resolveLimit(parsedLimit, maxLimit, limitSupplied)\n\n const allFields = entity.allFields as Record<string, FieldConfig>\n\n // `?populate=` is validated BEFORE any DB work — a ceiling violation must\n // not cost a query first.\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n // Cursor-based pagination (opt-in, replaces OFFSET when provided)\n let cursor: CursorInput | undefined\n if (cursorParam) {\n const decoded = decodeCursor(cursorParam)\n if (!decoded) {\n return errorResponse('Invalid cursor', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n // Whitelist via `Object.hasOwn` so prototype-chain keys (`__proto__`,\n // `constructor`, …) cannot pass.\n if (!Object.hasOwn(allFields, decoded.field) && decoded.field !== 'id') {\n return errorResponse('Invalid cursor field', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n // A cursor is caller-supplied (`decodeCursor` just base64-decodes it), and\n // it becomes a keyset WHERE — so an internal cursor field is a filter by\n // another name, and would let a caller binary-search `_workflowStatus` or\n // `_version` from the outside. Same gate as filter/sort.\n if (decoded.field !== 'id' && isInternalField(decoded.field, allFields[decoded.field])) {\n return errorResponse('Invalid cursor field', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n cursor = decoded\n }\n\n const where: SQL[] = []\n /** Did `?search=` actually add a predicate? See {@link isSearched} below. */\n let searchNarrowed = false\n if (search) {\n const searchCondition = buildSearchCondition(\n entity,\n search,\n locale ?? requestLocaleFallback(),\n )\n if (searchCondition) {\n where.push(searchCondition)\n searchNarrowed = true\n }\n }\n\n const filters = buildFilterConditions(entity.name, url, allFields)\n where.push(...filters.conditions)\n\n const sortResult = buildOrderBy(entity, sort, allFields)\n const orderBy = sortResult.orderBy\n // The manual-order sort (editor-curation D001) is OFFSET-only: no cursor is\n // minted for it (the key never leaves the server), and a caller-made cursor\n // on any other field would set a keyset boundary that does not follow the\n // result order — pages would skip or repeat rows. Refused, not ignored.\n if (\n cursor !== undefined &&\n sortResult.field !== undefined &&\n isSortOnlyInternalField(entity, sortResult.field)\n ) {\n return errorResponse('Invalid cursor for this sort', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const select = parseFieldSelection(fields, allFields)\n\n // Rejected filter/sort keys are dropped SILENTLY on the wire — deliberately,\n // so the response can't be used to probe which field names exist or which\n // are internal (the same 404-not-403 reasoning the entity gate uses). That\n // leaves an operator with nothing to grep when a frontend's `filter[path]`\n // quietly stops matching, so it is recorded server-side. Mirrors\n // `@murumets-ee/blocks`' where-builder, which reports its own `dropped` keys\n // the same way.\n //\n // `info`, NOT `debug`, and that is the whole of F023: `createApp` defaults\n // the app logger to `info` and both real mounts configure `info`, so this\n // record was CONSTRUCTED AND DISCARDED in every default deployment — the\n // response silent by design and the log silent by configuration, which is\n // indistinguishable from the feature working. A record that exists to\n // explain a deliberate silence belongs at a level a default deployment\n // collects; `debug` stays right for volume-heavy internals nobody is\n // expected to be reading, of which this surface has none.\n //\n // BOUNDED, and promoting the level is what made that mandatory. The payload\n // is entirely caller-chosen — `filters.dropped` gains an entry per matching\n // `filter[…]` param with duplicates counted separately, and `droppedSort`\n // is the raw `sort` value — so at `debug` under an `info` default it was\n // discarded, and at `info` it becomes a per-request, attacker-sized write\n // an unauthenticated caller controls. Deduped, capped, and truncated, with\n // the overflow COUNTED rather than dropped silently: a diagnostic that\n // hides how much it hid is worse than one that says \"and 400 more\".\n if (filters.dropped.length > 0 || sortResult.dropped !== undefined) {\n // Each KEY is capped too, not only the count: `filter[…]`'s own pattern\n // is `[a-zA-Z_]+`, unbounded, so twenty keys of eight kilobytes each\n // would clear the count cap and still be the same per-request write.\n const distinctDropped = [...new Set(filters.dropped)]\n const shown = distinctDropped.slice(0, MAX_REPORTED_DROPPED_KEYS).map(truncateForLog)\n requestLog().info(\n {\n entity: entity.name,\n ...(filters.dropped.length > 0 && {\n droppedFilters: shown,\n ...(distinctDropped.length > shown.length && {\n droppedFiltersOmitted: distinctDropped.length - shown.length,\n }),\n }),\n ...(sortResult.dropped !== undefined && {\n droppedSort: truncateForLog(sortResult.dropped),\n }),\n },\n 'content-api: rejected query params (unknown, internal, or invalid)',\n )\n }\n\n const client = createClient(entity)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n // D047 — A SEARCHED LIST DOES NOT COUNT.\n //\n // `findMany` stops at `limit` rows; `count` cannot. Against an unindexed\n // `ILIKE '%q%'` OR'd with a correlated per-row `EXISTS` on the translations\n // table, the count evaluates the predicate against EVERY row in the\n // collection, on every request — strictly more expensive than the page it\n // accompanies, and the half nobody asked for.\n //\n // And the `CountCache` cannot absorb it: `buildCountCacheKey` serializes the\n // WHERE including its bind parameters (deliberately — its docblock records\n // the bug where a `?search=foo` count was served for `?search=bar`), so the\n // search term makes every key fresh. The miss is not even free: the cache is\n // a plain `Map` with no max-entries bound whose entries are removed only by\n // a later `get()` on the same key, which for a one-off search term never\n // comes. Every distinct term deposited a permanent entry on the always-on\n // public read path (F035).\n //\n // So: fetch `limit + 1` rows, slice to `limit`, and derive `hasMore` from\n // what came back. Exact, one extra row, no count query — and no cache key,\n // so there is nothing to bound rather than a bound that has to be right.\n //\n // A second property falls out, which phase 01 flagged the absence of: the\n // old `hasMore` was `offset + limit < total`, so it INHERITED the count's\n // freshness and a stale or locale-mismatched `total` made it lie. The\n // `limit + 1` form observes the rows themselves and cannot.\n //\n // F035 is WIDER than search — `filter[...]` values are caller-controlled and\n // key the same cache the same way. Bounding `CountCache` is a\n // `@murumets-ee/entity` change affecting both surfaces and both client\n // types. This removes the widest door structurally; it does not close the\n // room.\n // Derived from whether a search predicate was actually ADDED, not from the\n // presence of the parameter.\n //\n // `buildSearchCondition` returns `undefined` for an entity that declares no\n // `searchable()`, in which case `?search=` narrows nothing and the response\n // is the full unfiltered list. Keying the count-suppression off the raw\n // parameter would have made such a request pay the `limit + 1` fetch AND\n // report `total: null` — i.e. silently drop the count for a list that was\n // never searched, which is not what D047 suppresses and not what `_schema`\n // publishes. `SchemaField.searchable` tells a consumer this entity matches\n // nothing; the response should not additionally lie about the count.\n const isSearched = searchNarrowed\n const queryOpts = {\n where: where.length > 0 ? and(...where) : undefined,\n limit: isSearched ? limit + 1 : limit,\n offset,\n orderBy,\n select,\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n cursor,\n }\n\n const [fetched, total] = await Promise.all([\n client.findMany(queryOpts),\n // Locale threads into count's publish filter so `total` agrees with\n // the per-locale effective statuses the list itself applies — a\n // base-published/locale-draft row must not inflate total past\n // items.length (hasMore would be wrongly true).\n isSearched\n ? Promise.resolve(null)\n : client.count({\n where: where.length > 0 ? and(...where) : undefined,\n locale: resolvedLocale,\n }),\n ])\n\n const items = isSearched && fetched.length > limit ? fetched.slice(0, limit) : fetched\n const hasMore = isSearched ? fetched.length > limit : offset + limit < (total ?? 0)\n\n // Build nextCursor from last item for keyset pagination.\n //\n // The field comes from the ACCEPTED sort (`sortResult.field`), never the raw\n // `?sort=` param: a rejected sort produces no ORDER BY, so minting a cursor\n // for it would hand the client a token that the cursor gate 400s on the very\n // next request. It closes by never naming a field the sort layer refused.\n let nextCursor: string | undefined\n if (items.length > 0 && items.length === limit) {\n const lastItem = items[items.length - 1] as Record<string, unknown>\n const cursorField = cursor?.field ?? sortResult.field\n const cursorDirection: 'asc' | 'desc' =\n cursor?.direction ?? (sort?.startsWith('-') ? 'desc' : 'asc')\n if (cursorField && lastItem[cursorField] !== undefined) {\n nextCursor = encodeCursor(lastItem, cursorField, cursorDirection)\n }\n }\n\n // AFTER the cursor is minted, deliberately. `?sort=` accepts any\n // non-internal, non-blockTree field, `reference` included — so projecting\n // first would hand `encodeCursor` a projection OBJECT where the keyset\n // WHERE expects the stored uuid, and the next page would 400 or silently\n // return the wrong rows. The cursor is a statement about STORED values; the\n // projection is a statement about the wire.\n //\n // Stored reference ids become projections (or, for `?populate=`d fields,\n // full DTOs at depth 1) — the whole page under ONE budget, because every\n // row's references compete for the same pool. A 50-row page whose rows each\n // point at 3 documents is 150 resolutions from one unauthenticated request,\n // which is exactly the amplification the budget exists to bound.\n // TAGGED BEFORE RESOLUTION, and the order is the design (D055 §5).\n //\n // A list is set-shaped: a BRAND-NEW row changes page 1 while carrying no\n // tag any cached response ever held, so the per-document tags alone would\n // silently never invalidate this response on create. The collection tag is\n // what covers that, and it is the half R05's \"tag the composing documents\"\n // summary did not reach — see R16.\n //\n // Adding the page's own rows here rather than after `resolveRows` puts them\n // ahead of every resolved reference and media id in the insertion order, so\n // a page that overflows the 128-tag cap degrades to \"this page's rows plus\n // the collection\" rather than to an arbitrary 128 of the two mixed.\n tags.addCollection(entity.name)\n for (const item of items as Record<string, unknown>[]) {\n tags.addDocument(entity.name, item.id)\n }\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, resolvedLocale, tags)\n await resolveRows(\n entity,\n items as Record<string, unknown>[],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n\n return jsonResponse(\n {\n data: items,\n // `total` is `number | null` in the v1 collection contract, where\n // `null` means \"not counted on this request\" (D047). `_schema`\n // publishes WHICH parameters suppress it, so a client reads that rather\n // than inferring it.\n //\n // Phase 01 already drew this line and already had an endpoint on the\n // other side of it — `_paths` omits `total` because its generator source\n // has no cheap count — and stated the rule generally: a collection\n // endpoint promises `limit` and `hasMore`; `total` is promised only\n // where a count is affordable. A searched list is the second place it is\n // not. This applies the existing rule rather than inventing one.\n //\n // Free now, expensive later: widening `number` to `number | null` breaks\n // a client doing arithmetic on it, and D010 records that no content-api\n // consumer is live.\n meta: { total, limit, offset, hasMore, nextCursor },\n },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleCount(\n req: Request,\n entity: Entity,\n principal: ResolvedPrincipal,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = countParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n // ?countMode=estimate is only safe for entities that don't apply\n // publish/scope filters at query time — otherwise the pg_class row count\n // leaks unpublished or out-of-scope rows. For publishable entities we\n // always run the filtered count instead.\n //\n // We fall through to the filtered count even when the public client asked\n // for an estimate; the response shape stays compatible (`{count}`).\n const client = createClient(entity)\n // Locale threads into the publish filter (per-locale effective status)\n // so the count endpoint agrees with what a locale-filtered list returns.\n const count = await client.count({ locale: params.data.locale ?? requestLocaleFallback() })\n\n // THE COLLECTION TAG ALONE, and this endpoint is the honest negative\n // control for the whole scheme (D055).\n //\n // A count body names no row, so there is no document to tag — and it is\n // set-shaped in the purest way: the number changes when ANY row is created\n // or deleted and when no row it could name was touched. It is storable, it\n // goes through the same builder as every other success, and it must carry\n // zero `lumi:<entity>:<id>` tags. `_schema` carrying none proves less, since\n // `_schema` is `no-store` and would carry none for that reason alone.\n tags.addCollection(entity.name)\n\n return jsonResponse(\n { data: { count } },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleById(\n req: Request,\n entity: Entity,\n id: string,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n // Entity primary keys are UUID-typed throughout the toolkit. Reject\n // non-UUID inputs as 404 rather than letting Postgres throw a cast error\n // that would surface as a 500 (and side-channel \"valid format but missing\"\n // vs \"bogus format\").\n if (!isUuid(id)) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = singleParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n const { locale, fields, populate: populateParam } = params.data\n const allFields = entity.allFields as Record<string, FieldConfig>\n // A template-drawn row is read WHOLE: its bound blocks are fed from columns\n // the caller cannot name, and what crosses is narrowed below regardless.\n const select = isTemplateDrawn(entity) ? undefined : parseFieldSelection(fields, allFields)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n const client = createClient(entity)\n const item = await client.findById(id, {\n ...(select !== undefined && { select }),\n ...(resolvedLocale !== undefined && { locale: resolvedLocale }),\n defaultLocale: configDefaultLocale,\n })\n if (!item) return notFoundResponse(corsHeaders)\n\n // NO collection tag, and the argument is specific rather than an omission\n // (D055 §1). The set of rows answering `/:id` cannot change without WRITING\n // the row already named here; and had no row answered, the response would\n // be a 404, which is unconditionally `no-store` (SD005) and so has no\n // stored entry to go stale.\n tags.addDocument(entity.name, id)\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, resolvedLocale, tags)\n if (isTemplateDrawn(entity)) {\n // A row drawn by a stored template (block-library D009): narrowed to what\n // its page shows, with the entity type's published template resolved\n // against it. `?fields=` and `?populate=` name columns this response does\n // not carry, so neither is applied. See `detail-template.ts`.\n await narrowTemplateDrawnRow(\n entity,\n item as Record<string, unknown>,\n templateDeps(resolutionDeps),\n true,\n )\n reportBudget(resolutionDeps, entity.name, url.pathname)\n } else {\n await resolveRows(\n entity,\n [item as Record<string, unknown>],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n }\n\n return jsonResponse(\n { data: item },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleBySlug(\n req: Request,\n entity: Entity,\n slug: string,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = singleParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n // Check entity has a slug field\n const allFields = entity.allFields as Record<string, FieldConfig>\n if (!allFields.slug) return notFoundResponse(corsHeaders)\n\n const { locale, fields, populate: populateParam } = params.data\n const select = parseFieldSelection(fields, allFields)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n const resolved = resolveTableCols(entity.name)\n if (!resolved) return notFoundResponse(corsHeaders)\n const tableSlug = resolved.cols.slug\n const tableId = resolved.cols.id\n if (!tableSlug || !tableId) return notFoundResponse(corsHeaders)\n\n const client = createClient(entity)\n const slugConfig = allFields.slug as SlugField\n\n // For translatable slugs, also search the translation table.\n // A non-default locale's slug may live in {entity}_translations, not the main table.\n let whereClause: SQL = eq(tableSlug, slug)\n\n if (slugConfig.translatable && resolvedLocale) {\n const translationTableName = `${entity.name}_translations`\n const translationResolved = resolveTableCols(translationTableName)\n if (translationResolved) {\n const translationSlug = translationResolved.cols.slug\n const translationLocale = translationResolved.cols.locale\n const translationEntityId = translationResolved.cols.entityId\n if (translationSlug && translationLocale && translationEntityId) {\n // Find entity IDs whose translated slug matches (for the requested locale).\n // QueryClient applies publish + scope filters on the follow-up findMany,\n // so unpublished/out-of-scope translated slugs cannot leak through here.\n const app = getApp()\n // Confined to the request's site for a site-bound entity (multisite\n // D003): another site's translated slug must not even change which\n // follow-up query runs (a timing/existence oracle), let alone match.\n const site = await requireSiteFor(entity, app)\n const bySlug = and(eq(translationSlug, slug), eq(translationLocale, resolvedLocale))\n const translationMatches = await app.db.readOnly\n .select({ entityId: translationEntityId })\n .from(translationResolved.table)\n .where(\n site !== undefined\n ? and(bySlug, eq(requireSiteColumn(translationResolved.cols, entity.name), site))\n : bySlug,\n )\n\n if (translationMatches.length > 0) {\n const translatedIds = translationMatches.map((r) => r.entityId as string)\n // Match by translated slug OR by main table slug (fallback).\n // `or(...)` returns SQL when given two non-undefined inputs; the\n // `?? eq(...)` keeps the type system honest without a non-null\n // assertion.\n whereClause =\n or(inArray(tableId, translatedIds), eq(tableSlug, slug)) ?? eq(tableSlug, slug)\n }\n }\n }\n }\n\n const results = await client.findMany({\n where: whereClause,\n limit: 1,\n select,\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n })\n\n if (results.length === 0) return notFoundResponse(corsHeaders)\n\n // COLLECTION TAG, because a slug is not a key — and the first version of\n // this comment claimed otherwise, which review falsified with a concrete\n // sequence rather than a doubt.\n //\n // The WHERE above is deliberately an OR: `id IN (rows whose TRANSLATED slug\n // matches) OR main.slug = <slug>`. Two different rows can therefore match,\n // and `findMany` runs with `limit: 1` and NO `orderBy`, so which one answers\n // is unordered. Concretely: article A has main slug `foo`, article B has\n // main slug `bar` and a French translated slug `foo`. `?locale=fr` matches\n // both; the response is cached carrying whichever came back. Editing the\n // OTHER one purges a tag this response does not hold, the origin's\n // unordered pick can flip, and the entry serves the wrong row for the whole\n // window.\n //\n // The collection tag is what closes that, and it is why only `handleById` —\n // the one read addressed by PRIMARY KEY — omits it.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, (results[0] as Record<string, unknown> | undefined)?.id)\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, resolvedLocale, tags)\n await resolveRows(\n entity,\n results as Record<string, unknown>[],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n\n return jsonResponse(\n { data: results[0] },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /:entity` for an entity declaring `kind: 'singleton'` — the resource\n * IS the collection, so the path addresses it directly and the response is\n * the SINGLE-resource envelope `{ data: row }`.\n *\n * Before this existed, a singleton was served as a one-row collection and the\n * client was expected to know, out of band, to take `data[0]`. `_schema` now\n * publishes {@link SchemaEntity.kind} so that is discoverable rather than\n * folklore.\n *\n * **No `meta`** — a singleton has no pagination, which is also why\n * `singleParamsSchema` (not `listParamsSchema`) is the right validator here:\n * `?locale=` and `?fields=` are the only params that mean anything.\n *\n * **No row → 404** (D006). A singleton with no row is an ABSENCE, and absence\n * is shaped as absence — the same 404 every other missing resource returns,\n * never a 200 carrying an empty body a client would have to special-case.\n *\n * Reads through the same `createClient(entity)` path as every other endpoint\n * — no new query primitive — threading `locale`/`defaultLocale` and\n * `locale`/`defaultLocale` exactly as {@link handleBySlug} does.\n */\n async function handleSingleton(\n req: Request,\n entity: Entity,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = singleParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n const { locale, fields, populate: populateParam } = params.data\n const allFields = entity.allFields as Record<string, FieldConfig>\n const select = parseFieldSelection(fields, allFields)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n // NO `orderBy` — deliberately. `QueryClient.findMany` already falls back to\n // `singletonReadOrder()` for a `kind: 'singleton'` entity, which orders\n // `[asc(createdAt), asc(id)]` (D011). Passing one here would WIN over that\n // fallback (`options?.orderBy ?? singletonReadOrder(...)`) and drop the\n // `createdAt` term — so on an entity that wrongly has two rows, this\n // endpoint would return the lowest UUID while every other read path in the\n // toolkit (admin edit screen, chrome renderer, live-site composition — all\n // `findMany({ limit: 1 })` with no order) returns the oldest row. The public\n // API and the rendered site would disagree about which row IS the\n // singleton, and a caching frontend would pin the disagreement.\n const client = createClient(entity)\n const results = await client.findMany({\n limit: 1,\n select,\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n })\n\n const row = results[0]\n if (!row) return notFoundResponse(corsHeaders)\n\n // COLLECTION TAG, for the reason this function's own comment above already\n // states: `kind: 'singleton'` is not yet ENFORCED (D011), so \"an entity that\n // wrongly has two rows\" is a live state, and the read is an ordered pick\n // over a set rather than a keyed lookup. Rows A (older, draft) and B (newer,\n // published): the publish filter drops A, so B answers and is tagged.\n // Publishing A makes A win the `asc(createdAt)` order — and purging A's\n // document tag misses an entry that only ever named B.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, (row as Record<string, unknown>).id)\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, resolvedLocale, tags)\n await resolveRows(\n entity,\n [row as Record<string, unknown>],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n\n return jsonResponse(\n { data: row },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n // -- Resolution / enumeration endpoints (routing PR07) ---------------------\n\n /**\n * Resolve an entity from the whitelist by singular or plural name. Deny-by-\n * default: returns undefined for anything not explicitly registered.\n */\n function resolveWhitelistedEntity(name: string): Entity | undefined {\n return entityMap.get(name) ?? entityMap.get(name.endsWith('s') ? name.slice(0, -1) : name)\n }\n\n /**\n * Say — to the SERVER LOG, in development only — which of the two\n * deny-by-default gates refused. The response is untouched.\n *\n * ## Why this exists\n *\n * Reaching a public page passes two gates that a plugin-contributed entity\n * fails for two completely different reasons:\n *\n * 1. `createContentApiHandler({ entities })` — the whitelist. An unlisted\n * entity is not served at all. Fixed by editing the route file.\n * 2. The persisted role map. `buildInitialRoleDefinitions()` seeds `public`\n * with the entities the SCAFFOLD ships and cannot know about entities a\n * plugin contributes later. Fixed by granting `view`, in the Permission\n * Matrix or a seeder.\n *\n * Both refuse as a 404, never a 403 (D006) — deliberately, so an outsider\n * cannot tell \"you may not\" from \"it does not exist\". That property is worth\n * keeping and is NOT weakened here: the status, headers and body are\n * identical, and nothing is added to the response.\n *\n * But the same ambiguity is aimed at the OPERATOR, who is not an outsider,\n * and for whom it is pure cost. `entity.access.view` does not help — it is\n * advisory metadata the firewall never reads, and both yhikas entities\n * declare `view: 'group.admin'` while needing a `public` grant, which reads\n * as the exact opposite of the fix. So the honest signal is a server-side\n * line naming which gate it was.\n *\n * Development-only, deliberately, and as an ALLOWLIST rather than a\n * not-production denylist: this is per-request work on an anonymous,\n * cacheable path, so an unauthenticated caller can drive log volume simply\n * by asking for names that do not exist. `NODE_ENV` is not a two-valued\n * flag — a staging or preview deployment commonly runs with `staging`, and a\n * container that never sets it at all leaves it `undefined`. Both are\n * public, and both would have been opted IN by a `!== 'production'` guard.\n */\n function explainEntityRefusal(requestedName: string, entity: Entity | undefined): void {\n if (process.env.NODE_ENV !== 'development') return\n // 🔴 The requested name is CALLER-CONTROLLED at three of the four call\n // sites — a percent-decoded url segment, or a Zod string bounded only in\n // LENGTH — so it reaches here with newlines intact and would otherwise let\n // anyone forge a `[content-api]` line into the log. `JSON.stringify`\n // escapes the control characters and quotes the value, and the slice bounds\n // it. This is the same rule stated a few lines below at the monitoring\n // seam (\"only a whitelisted entity gets named … so a probe string cannot\n // ride into a monitoring payload\"); the diagnostic must not reintroduce\n // what that comment exists to keep out.\n const safeName = JSON.stringify(requestedName.slice(0, MAX_ENTITY_NAME_LENGTH))\n const detail =\n entity === undefined\n ? `${safeName} is not in the content-api whitelist — add it to ` +\n `createContentApiHandler({ entities: [...] }) in your content route.`\n : `\"${entity.name}\" is whitelisted but the requesting role lacks 'view' — grant it in ` +\n `the Permission Matrix, or seed it. Note that entity.access.view is advisory ` +\n `metadata the firewall never reads, so a restrictive value there is not the cause.`\n console.warn(\n `[content-api] 404 for ${safeName}: ${detail} ` +\n `(Both refusals are 404 by design — this line is the server-side disambiguation, ` +\n `and the response is unchanged.)`,\n )\n }\n\n /**\n * `GET /_resolve?path=&locale=` — project a `(path, locale)` onto the routing\n * resolution result (document / redirect / none) via the injected\n * PUBLISHED-only resolver (`resolution.resolvePath`). The wiring must pass the\n * published resolver, never a draft/preview one that skips publish checks — see\n * {@link ContentApiResolution}. The result is GATED:\n * - `none` → 404.\n * - `redirect` → returned as data (no entity involved; the frontend decides).\n * - `document` → the resolved `entityType` must be in THIS instance's\n * whitelist AND viewable by the caller's role, else 404 (indistinguishable\n * from \"path doesn't exist\"). The entity payload is NOT inlined — the\n * frontend follows up with `GET /:entity/:id` so caching/perms stay\n * per-entity.\n *\n * 404s when no `resolution` is wired (opt-in; deny-by-default).\n */\n async function handleResolve(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!resolution) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = resolveParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { path, locale } = params.data\n\n const result = await resolution.resolvePath(path, locale)\n\n if (result.kind === 'none') {\n return notFoundResponse(corsHeaders)\n }\n\n if (result.kind === 'redirect') {\n // A redirect projection carries no entity, so no whitelist/view gate\n // applies. `no-store` mirrors the other non-entity special endpoint\n // (`_schema`): the target changes on rename/unpublish and is cheap to\n // recompute, and it keeps the invariant \"no per-entity cache without an\n // entity\" simple.\n return jsonResponse({ data: result }, 200, { ...corsHeaders, 'Cache-Control': 'no-store' })\n }\n\n // result.kind === 'document'\n const entity = resolveWhitelistedEntity(result.entityType)\n if (!entity || !hasViewPermission(checker, principal.role, entity.name)) {\n explainEntityRefusal(result.entityType, entity)\n return notFoundResponse(corsHeaders)\n }\n\n // COLLECTION TAG TOO — this is a routing-layer answer, not a keyed read.\n // Which document owns a path is a property of the path index: another row\n // of this entity can take the path over without the row named here being\n // written, and then only the collection tag catches it.\n //\n // ONE RESIDUAL, and it is published rather than left implicit: a takeover\n // by a document of a DIFFERENT entity is not covered, because the tag can\n // only name the entity that answered. `_schema.cacheTags.uncovered` carries\n // `crossEntityPathChanges` for exactly this.\n //\n // `entity.name`, not `result.entityType`: the two are equal here, and using\n // the RESOLVED name is the same discipline `facts.entity` follows two\n // functions over — only a whitelisted name reaches a payload that leaves\n // the process. The redirect branch above is `no-store` and carries nothing.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, result.id)\n\n return jsonResponse(\n { data: result },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_paths?locale=&entity=&cursor=&limit=` — paginated enumeration of a\n * single entity type's PUBLISHED canonical paths via `resolution.enumeratePaths`.\n * `entity` is REQUIRED and gated (whitelist ∧ view) UP FRONT — unknown/\n * unpermitted → 404, no enumeration attempted (deny-by-default; never per-type\n * mid-stream gating).\n *\n * Pagination reuses the response-envelope shape (not `handleList`'s cursor\n * idiom): we consume at most `limit` entries off the async iterable (never to\n * exhaustion), and `nextCursor` is the last consumed entry's `id` (an opaque\n * DOCUMENT id the provider round-trips back through `afterDocumentId`). `hasMore` is\n * true iff we consumed exactly `limit` (there might be more) — `handleList`'s\n * convention. No `total` (the generator source has no cheap count).\n *\n * 404s when no `resolution` is wired (opt-in; deny-by-default).\n */\n async function handlePaths(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!resolution) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = pathsParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { locale, entity: entityName, cursor, limit: parsedLimit } = params.data\n\n // Same loud ceiling as `handleList` — `_paths` is the shape to copy, not an\n // exception. No OFFSET ceiling because it has no offset: it is cursor-only\n // by construction. Same supplied-vs-default split, for the same reason.\n const limitSupplied = url.searchParams.has('limit')\n const overLimit = ceilingViolation('limit', parsedLimit, maxLimit, corsHeaders, limitSupplied)\n if (overLimit) return overLimit\n const limit = resolveLimit(parsedLimit, maxLimit, limitSupplied)\n\n const entity = resolveWhitelistedEntity(entityName)\n if (!entity || !hasViewPermission(checker, principal.role, entity.name)) {\n explainEntityRefusal(entityName, entity)\n return notFoundResponse(corsHeaders)\n }\n\n const iterable = resolution.enumeratePaths({\n locale,\n entityType: entity.name,\n ...(cursor !== undefined && { afterDocumentId: cursor }),\n })\n\n const data: ContentPathEntry[] = []\n for await (const entry of iterable) {\n data.push(entry)\n if (data.length === limit) break\n }\n\n // hasMore ⇔ we consumed exactly `limit` (there MIGHT be more) — the same\n // convention `handleList` uses. We never run the iterable to exhaustion.\n const hasMore = data.length === limit\n const nextCursor = hasMore ? data[data.length - 1]?.id : undefined\n\n // Set-shaped: a new document adds a path this response does not list, so\n // the collection tag is load-bearing exactly as it is for `handleList`.\n //\n // `entity.name` rather than each entry's own `entityType`: the endpoint\n // gates on a single `?entity=`, so they are the same value — and the gated\n // one is the only one that has been through the whitelist. An entry's\n // `entityType` comes from an injected host provider, and a host-supplied\n // string must not reach a response header unchecked.\n tags.addCollection(entity.name)\n for (const entry of data) tags.addDocument(entity.name, entry.id)\n\n return jsonResponse(\n { data, meta: { limit, hasMore, nextCursor } },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_alternates?entity=&id=` — the published per-locale canonical URLs for\n * one document (the hreflang set) via `resolution.getAlternates`. `entity` +\n * `id` REQUIRED; `entity` gated (whitelist ∧ view) UP FRONT — unknown/\n * unpermitted → 404 before any lookup.\n *\n * 404s when no `resolution` is wired (opt-in; deny-by-default).\n */\n async function handleAlternates(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!resolution) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = alternatesParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { entity: entityName, id } = params.data\n\n const entity = resolveWhitelistedEntity(entityName)\n if (!entity || !hasViewPermission(checker, principal.role, entity.name)) {\n explainEntityRefusal(entityName, entity)\n return notFoundResponse(corsHeaders)\n }\n\n // The site's languages only (multisite D007). The path index holds a row\n // per INSTALL locale, so the provider returns one for a language this site\n // does not serve — and a frontend would advertise it as an `hreflang`\n // pointing at a URL the site answers 404 for. `availableLocales` is set on\n // the request context only when the site narrows the install's set.\n const served = getContext()?.availableLocales\n const all = await resolution.getAlternates(entity.name, id)\n const alternates = served ? all.filter((alternate) => served.includes(alternate.locale)) : all\n\n // The body is `[{locale, path}]` and names no id — but every entry is a\n // per-locale path OF this one document, so the document is what composed\n // it. The id is the caller's own `?id=`, which keeps the disclosure\n // invariant (D055 §3) trivially true here: it is echoing back what the\n // caller sent.\n //\n // COLLECTION TAG TOO, same reason as `_resolve`: a path is not only a\n // function of its own row. Where paths nest, renaming an ANCESTOR rewrites\n // this document's paths without writing this document — so the document tag\n // alone would leave the entry stale.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, id)\n\n return jsonResponse(\n { data: alternates },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_menu?handle=&locale=¤tPath=` — a render-ready navigation menu\n * for one locale via the injected `menus.getMenu` (routing PR09, phase 11 §3).\n *\n * **Gate: UP FRONT, on the handle allowlist** — `handle` is the required\n * identifying param, so it is checked against `menus.handles` BEFORE\n * `getMenu` is ever called, exactly like `_paths`/`_alternates` gate their\n * required `entity`. Deny-by-default; an unlisted handle is indistinguishable\n * from a non-existent one (404, never 403).\n *\n * **No per-entity `view` check, deliberately.** A resolved item carries no\n * entity identity to check (phase 11 §2's shape has no `entityType`), and a\n * menu is not an entity in the whitelist sense — its `handles` allowlist IS\n * the operator's explicit \"this navigation is public\" decision, the same\n * shape of opt-in as `config.entities`. This handler therefore takes NO\n * `checker`: it structurally cannot consult a permission. See\n * {@link ContentApiMenus}, which documents exactly what that exposes.\n *\n * **Cache-Control: the public policy for every caller — with ONE exception,\n * stated at the end of this comment — and this endpoint alone gets to do\n * that.** The response is cacheable\n * (`max-age`/`stale-while-revalidate` from `CacheConfig`) for EVERY caller,\n * not just the anonymous one. Two independent reasons, and both are needed:\n *\n * 1. The body cannot vary per principal. `getMenu` receives `(handle, {\n * locale, currentPath })` and nothing else — no request, no role, no\n * identity — so there is no caller-specific body for a shared cache to\n * leak. The `cacheClassOf` split every entity route applies exists to\n * protect a body that varies; here there is none to protect.\n * 2. Applying the split anyway would make CREDENTIAL VALIDITY observable.\n * This endpoint has no entity permission to gate on, so a credentialed\n * caller holding zero permissions still reaches it — and would receive\n * `no-store` + a credential selector in `Vary` where an anonymous caller\n * gets `public, max-age=…`. A token-guesser would learn \"this credential\n * verified, and resolved something other than the public principal\" from\n * an endpoint that required no permission at all. Uniform headers make\n * the two answers byte-identical.\n *\n * **Uniform across CALLERS is not uniform across ORIGINS, and this response\n * still carries `Vary`.** It was the one cacheable response emitting none. An\n * SSR frontend fetches it server-side with NO `Origin`, so `buildCorsHeaders`\n * contributes nothing; a CDN stores `public, max-age=…` with no selector, and\n * per RFC 9111 §4.1 that entry then matches a browser request from an allowed\n * origin and is replayed WITHOUT `Access-Control-Allow-Origin` — so the\n * browser blocks a response the server treated as a success.\n * `buildPrincipalInvariantHeaders` adds `Vary: Origin` and NOTHING else: a\n * credential selector would fragment the cache for a variance that, by reason\n * 1, does not exist.\n *\n * Cacheability also matters more here than for a single row: one menu render\n * costs several batched DB round-trips and a public SSR frontend needs it on\n * EVERY uncached page view, so an unconditional `no-store` — the other way to\n * make the headers uniform — would make it un-absorbable by a CDN. `_menu` is\n * a RESERVED route segment, so its cache key can never collide with a real\n * entity name; an app that wants a different menu TTL sets\n * `cache.entities['_menu']`, otherwise the global defaults apply.\n *\n * This handler therefore takes no `checker`: it structurally cannot consult\n * a permission. **If `getMenu` ever gains a principal-derived input, reason 1\n * dies and this MUST move to `buildResponseHeaders(..., cacheClassOf(…),\n * authVary)` — accepting the reason-2 oracle, or closing it with a real\n * gate.**\n *\n * **THE EXCEPTION, and it is that clause coming true (multisite D008).** A\n * menu belongs to a site, and a site-bound key selects the site — so for a\n * caller whose credential carries a site, the body DOES vary by credential:\n * reason 1 is dead for that caller. It takes the principal for that one\n * purpose, and such a response goes through `buildResponseHeaders`\n * (`private`, `Vary: Origin, Authorization`). The reason-2 oracle is\n * ACCEPTED there, knowingly: a caller presenting a site-bound key can read\n * off the headers that the key verified. What it learns is that its own key\n * works, which it already knows from getting a menu at all — on an app\n * serving several sites a caller with no site gets a 404 here.\n *\n * 404s when no `menus` provider is wired (opt-in; deny-by-default).\n */\n async function handleMenu(\n req: Request,\n principal: ResolvedPrincipal,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!menus) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = menuParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { handle, locale, currentPath } = params.data\n\n if (!menuHandles.has(handle)) return notFoundResponse(corsHeaders)\n\n const menu = await menus.getMenu(handle, {\n locale,\n ...(currentPath !== undefined && { currentPath }),\n })\n // Unknown or unpublished menu — same 404 as an unlisted handle.\n if (!menu) return notFoundResponse(corsHeaders)\n\n // ONE TAG, UNDER THE RESERVED `_menu` NAME — the same reserved key this\n // response's cache config is already keyed by, so the two agree.\n //\n // **The boundary, stated rather than left to be discovered (D055 §8).** A\n // resolved menu item carries an `id` but deliberately NO `entityType`\n // (`ContentApiResolvedMenuItem`) — which is also why this endpoint has no\n // per-entity `view` gate. So document tags are structurally impossible\n // here, and the honest consequence is: editing the MENU purges this\n // response; renaming a page the menu links to does NOT, and falls back to\n // the declared staleness window like everything else this scheme cannot\n // see.\n tags.addDocument('_menu', handle)\n\n // `buildPrincipalInvariantHeaders`, NOT `buildResponseHeaders` — the caller\n // is deliberately not an input here. See this function's JSDoc. It still\n // emits `Vary`: a stored variant is shared across CALLERS, never across\n // ORIGINS, and the CORS layer answers differently per origin.\n //\n // 🔴 THE ONE EXCEPTION (multisite D008): a caller whose CREDENTIAL selects\n // its site. A menu belongs to a site, so two site-bound keys get two\n // different bodies for the same URL — the body now DOES vary by credential,\n // and a principal-invariant `Vary: Origin` would let a shared cache replay\n // site A's menu to site B's frontend. Such a response goes through the\n // caller-aware builder, which keys it on the declared credential headers\n // (or refuses to store it when none are declared).\n return jsonResponse(\n { data: menu },\n 200,\n principal.site !== undefined\n ? buildResponseHeaders(\n '_menu',\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n )\n : buildPrincipalInvariantHeaders('_menu', corsHeaders, cacheConfig, tags),\n )\n }\n\n /**\n * `GET /_site` — the public config of the site this request reads (plan\n * `multisite` D007/D008): its handle and name, the locales it serves with\n * its default and URL strategy, and its public origins. A frontend reads its\n * languages from here rather than from a list compiled into it.\n *\n * Takes no parameters: the site is the credential's (or, on a single-site\n * install, the one site). 404 when the request has no site — an anonymous\n * caller on an app serving several — so the endpoint cannot be used to\n * enumerate sites.\n *\n * Nothing here is secret (every field is visible on the site's own pages),\n * so there is no `view` gate; what protects it is that a caller only ever\n * sees its OWN site's row.\n */\n async function handleSite(\n req: Request,\n principal: ResolvedPrincipal,\n siteScope: RequestSiteScope,\n siteLocales: { locales: string[]; defaultLocale?: string },\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const params = siteParamsSchema.safeParse(toParamBag(new URL(req.url)))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const record = siteScope.record\n if (!record) return notFoundResponse(corsHeaders)\n\n // The host's resolver knows the install's enabled-locale registry and the\n // single-site install's i18n setting; this package knows neither. Without\n // one, the registry row and the mount's default are the answer.\n const provided = await siteProvider?.getI18n(record.handle)\n if ((provided?.locales ?? record.locales).length === 0) {\n // An empty list on the registry row means \"every locale the install has\n // enabled\" — and this package does not know which those are. Answering\n // with the one locale it CAN name would tell a frontend its site has a\n // single language, and that frontend would then 404 every other one.\n // An absence is the honest answer; the log names the fix.\n // Once per SITE, not once in all: one of the two fixes is per site, so\n // fixing the first must not silence the second. Bounded by the registry.\n safeWarnOnce(\n `site-i18n-unwired:${record.handle}`,\n { site: record.handle },\n 'content-api: GET /_site cannot answer — this site serves every enabled locale and the ' +\n 'mount wires no `site.getI18n` to say which those are. Wire it (see the content route ' +\n 'the admin app ships), or give the site an explicit locale list.',\n )\n return notFoundResponse(corsHeaders)\n }\n const locales = provided?.locales ?? record.locales\n const defaultLocale = provided?.defaultLocale ?? siteLocales.defaultLocale ?? locales[0]\n if (defaultLocale === undefined) return notFoundResponse(corsHeaders)\n const data: ContentApiSite = {\n handle: record.handle,\n name: record.name,\n locales: locales.length > 0 ? [...locales] : [defaultLocale],\n defaultLocale,\n routingStrategy:\n provided?.routingStrategy ?? record.routingStrategy ?? DEFAULT_SITE_ROUTING_STRATEGY,\n origins: [...record.origins],\n }\n\n // Under the reserved `_site` name, like `_menu`: editing the registry row\n // purges this response.\n tags.addDocument('_site', record.handle)\n return jsonResponse(\n { data },\n 200,\n buildResponseHeaders(\n '_site',\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_chrome?locale=&entity=&id=` — the site's composed header and\n * footer for one locale, optionally overridden by one document\n * (plan/api phase 07, D026/D027).\n *\n * **A projection, not an entity.** Overrides are per-PAGE: `_overrides` lives\n * on the page and is applied to the CHROME document, so the same stored row\n * yields different bytes depending on who asks. That is the definition of a\n * projection, and it is precisely what a singleton entity read is not.\n *\n * **Gated like `_menu`'s absent provider and like an entity's `view`, both.**\n * No provider → 404. Role without `view` on the chrome entity → 404. Never\n * 403 (D006), so a denial is indistinguishable from an absence — and\n * `_schema`'s `surface` advertises the endpoint's presence, so a consumer\n * learns chrome is unavailable from DATA rather than from a 404 it cannot\n * interpret.\n *\n * **Both forms ship together and the override path is exercised.** Shipping\n * only the default form would leave the override branch unwatched, which is\n * D016's objection restated: a path nobody has seen work is untested, and one\n * nobody has seen REFUSE is unverified. This matters more here than usual —\n * `composeMultiScopeDocument` is DELETED (PR30) and `composeChromeRegion` has\n * exactly one (the editor canvas), so the override feature has never run end\n * to end anywhere (F014).\n *\n * **The two forms collapse structurally, not by heuristic.** `applyOverrides`\n * returns `composed.slice()` when the map is empty or nothing in it applies,\n * so a document that overrides nothing produces output byte-identical to the\n * no-document form — a property of shipped code rather than a cache rule\n * someone maintains. Today that is EVERY site, which is why a design making\n * the per-document form expensive would have made the whole site pay for a\n * path nothing uses.\n *\n * The provider is handed the response's ONE resolution budget. Chrome is not\n * a fifth kind of work with a ceiling of its own — it competes for the same\n * connection pool as the document's own references and media.\n *\n * That now covers the blocks INSIDE the regions too. The chrome provider\n * claims for its own two reads; this handler then runs each region through\n * the same injected `resolveBlocks` provider a document's own `blockTree`\n * field goes through, drawing on the SAME budget, and merges the resulting\n * envelope into the region. So chrome and layout carry one format, which is\n * `plan/api` finding 075 requirement 5. With no provider wired, both still\n * cross unresolved — see {@link ResolvedBlockTree} for why nothing emits an\n * empty envelope.\n */\n async function handleChrome(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!chrome) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = chromeParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { locale, entity: documentEntity, id: documentId } = params.data\n\n // The read gate: `view` on the entity that BACKS chrome. Note this is the\n // roles table, never the entity's own `access` map — that is advisory\n // (#616, D020), and the chrome entity's docblock says so rather than\n // claiming otherwise (F013).\n //\n // Since PR30 the gate and the SOURCE are the same entity: a region reads\n // the `site_chrome` document whose `placement` matches it. Before that,\n // this gated a standalone layout entity while the blocks came from two\n // other entities, so one grant here plus a missing grant there produced a\n // 200 with an absent header — a partial answer that read as an\n // unprovisioned site.\n if (!hasViewPermission(checker, principal.role, chrome.entity)) {\n return notFoundResponse(corsHeaders)\n }\n\n // A named document is honoured only when the CALLER may reach it. Every\n // way it can fail — unknown entity, outside the whitelist, no `view` —\n // collapses to \"serve the site default\", never to an error, so the\n // parameter cannot be used to probe which documents exist.\n let document: { entityType: string; id: string } | undefined\n if (documentEntity !== undefined && documentId !== undefined) {\n const target = resolveWhitelistedEntity(documentEntity)\n if (target && hasViewPermission(checker, principal.role, target.name)) {\n document = { entityType: target.name, id: documentId }\n } else {\n resolutionLogger()?.debug(\n { entity: documentEntity, locale },\n 'content-api: _chrome document is unreachable for this caller — serving the site default',\n )\n }\n }\n\n // CHROME IS THE TWO-SIDED CASE F73 NAMED, and both sides are tagged.\n //\n // The chrome ROW gets a COLLECTION tag, not a document tag, and that is a\n // structural limit rather than a choice: `chrome.getChrome` is host-injected\n // and returns `ContentApiChromeRegions`, which carries no row id on any\n // region — the chrome document's id is never in scope in this package at\n // all. `lumi:<chrome.entity>` is therefore the finest tag expressible, and\n // it costs almost nothing to be coarse here: chrome is effectively a\n // singleton, so its collection and its document are very nearly the same\n // set.\n //\n // The REQUESTING DOCUMENT gets a document tag even though its id never\n // appears in the response body. It composed the body all the same —\n // `_overrides` live on the page and are applied to the chrome document\n // inside the provider (D026/D027) — so editing that page's overrides must\n // invalidate this entry. It is also the one place the disclosure invariant\n // needs stating out loud: the id came from the caller's own query string,\n // and it is only tagged on the branch where the caller was already granted\n // `view` on it, so the header echoes nothing the caller did not send and\n // was not entitled to.\n tags.addCollection(chrome.entity)\n if (document !== undefined) tags.addDocument(document.entityType, document.id)\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, locale, tags)\n const regions = await chrome.getChrome({\n locale,\n ...(document !== undefined && { document }),\n budget: resolutionDeps.budget,\n })\n\n // A provider `null` means NO CHROME DOCUMENT EXISTS — every region is\n // `null`, which a consumer reads as \"render your own fallback\". It is NOT\n // an empty region: an empty `blocks` array means the page hid everything\n // and the correct render is nothing at all. Conflating them would turn an\n // unseeded CMS into a site rendering a default header it was told to\n // suppress.\n //\n // PROJECTED onto exactly the four named regions AND onto each region's two\n // keys, never pass-through. The provider is host-supplied, so serialising\n // its return value verbatim would make D011's \"`_overrides` never reaches\n // the wire, in any configuration\" conditional on every provider's\n // discipline — and the shipped provider is the one component that\n // legitimately READS the override map, so it holds the exact value D011\n // bans a few frames down the stack.\n //\n // Projecting only the four outer KEYS was not enough: a region object\n // carrying a stray `_overrides` beside `blocks` would still have crossed.\n // `blocks` itself is `unknown[]` and inherently unprojectable here — this\n // package cannot know a block's shape — so that layer's guarantee is\n // D027's `stripScopeTags` in the provider, asserted in\n // `@murumets-ee/content`'s own suite. What IS this package's own is\n // everything above the block array.\n // COERCED, not just picked. `getChrome` is a host-supplied hook, which\n // makes its return value a trust boundary in exactly the way\n // `authenticate`'s is — and that one is validated field by field. A region\n // that is present but not an object would otherwise yield\n // `{ blocks: undefined, overridden: undefined }`, which `JSON.stringify`\n // drops entirely, so the response omits `blocks` and a consumer reading\n // `region.blocks.length` throws on data the server called a success.\n const projectRegion = (\n region: ContentApiChromeRegion | null | undefined,\n ): ContentApiChromeRegion | null =>\n region && typeof region === 'object'\n ? {\n blocks: Array.isArray(region.blocks) ? region.blocks : [],\n overridden: region.overridden === true,\n }\n : null\n\n /**\n * Resolve one region's blocks through the SAME injected provider a\n * document's own `blockTree` field goes through, and MERGE the envelope\n * into the region.\n *\n * 🔴 This lives here rather than inside `createChromeProvider`, where an\n * earlier comment anticipated it, and the reason is the one CLAUDE.md's\n * declarative-contributions section gives: a host that wires\n * `resolveBlocks` ONCE must not also have to remember to wire it into a\n * second place. Two wiring points is how a contribution gets silently\n * skipped. Running it here also makes \"chrome and layout use one format\"\n * (finding 075 requirement 5) true by construction rather than by two\n * implementations agreeing.\n *\n * SERIAL across regions, on purpose, for the same reason\n * `resolveBlockTrees` is serial across rows: each call claims from the\n * shared budget as it works, so running the regions concurrently would\n * let several claim past the ceiling before any observed it, and WHICH\n * region ended up truncated would depend on scheduling.\n *\n * 🔴 A throw or a malformed return NEVER degrades to the stored blocks —\n * it goes through {@link resolveOneBlockTree}'s retry, exactly as a\n * document's own field does (D010, closing F056). Both branches used to\n * `return region`, and this is the HIGHER-risk of the two sites that did:\n * `blockTreeFieldsOf`'s own comment says chrome \"is exactly the content\n * most likely to carry a logo\", and a logo is a media ref carrying the url\n * an editor recorded — signed, wherever media is private. The old comment\n * here said a broken resolver must cost a page its hrefs and never its\n * header. It still does: the retry strips the refs and keeps the blocks, so\n * the header renders without its images rather than vanishing.\n *\n * ONE call to the shared helper rather than a second hand-written copy of\n * the policy, because two implementations of the same two branches is how\n * the two sites came to disagree in the first place — this one was missed\n * entirely when the entity path was reviewed.\n */\n const resolveRegion = async (\n field: (typeof CHROME_REGION_FIELDS)[number],\n region: ContentApiChromeRegion | null,\n ): Promise<ContentApiChromeRegion | null> => {\n if (region === null || !config.resolveBlocks || region.blocks.length === 0) return region\n const outcome = await resolveOneBlockTree(config.resolveBlocks, region.blocks, {\n locale,\n budget: resolutionDeps.budget,\n entity: chrome.entity,\n field,\n // Chrome is a page's header and footer; a collection there gets the\n // same whitelist ∧ `view` gate a document's own tree does.\n ...(resolutionDeps.canReadEntity !== undefined && {\n canReadEntity: resolutionDeps.canReadEntity,\n }),\n ...(resolutionDeps.collectionAllowance !== undefined && {\n claimCollection: resolutionDeps.collectionAllowance.claim,\n }),\n })\n // WARN for the same reason the entity path warns, and it matters MORE\n // here: chrome runs on every page render, under the same cache policy. At\n // most FOUR lines per request — two regions, and a blanked one emits\n // both its cause and its consequence — so there is still no flood to\n // trade against and no aggregation to do.\n // 🔴 The CAUSE and the CONSEQUENCE are separate lines, and a blanked\n // region emits BOTH — mutually-exclusive branches meant the one log an\n // operator gets for the worst outcome never said HOW the provider\n // failed. This is deliberately the same emit shape the entity path\n // aggregates to, because \"both sites behave identically\" is the property\n // this whole helper exists to hold.\n if (outcome.failure === 'threw') {\n resolutionLogger()?.warn(\n { entity: chrome.entity, region: field, err: outcome.error },\n 'content-api: block resolver threw — this chrome region is not served as stored; it ' +\n 'was re-resolved with an exhausted budget, so its media refs and link hrefs are ' +\n 'stripped rather than carrying the urls recorded when an editor picked them',\n )\n } else if (outcome.failure === 'malformed') {\n resolutionLogger()?.warn(\n { entity: chrome.entity, region: field },\n 'content-api: block resolver returned a value that is not a ResolvedBlockTree — this ' +\n 'chrome region is not served as stored; it was re-resolved with an exhausted ' +\n 'budget, so its media refs and link hrefs are stripped rather than carrying the ' +\n 'urls recorded when an editor picked them',\n )\n }\n if (outcome.retryFailure !== undefined) {\n // Chrome is where this matters most: a cached blank header is a\n // headerless SITE, not a headerless page, for the whole TTL.\n tags.markUncacheable()\n resolutionLogger()?.warn(\n { entity: chrome.entity, region: field, err: outcome.error },\n 'content-api: block resolver failed TWICE, including on the in-memory retry — ' +\n 'serving this chrome region EMPTY, so whatever it held is missing from every page ' +\n 'render. Serving the stored region instead would put the media urls recorded when ' +\n 'an editor picked them, which may be signed, on a cacheable anonymous response',\n )\n }\n const envelope = outcome.envelope\n // 🔴 EVERY field the envelope carries has to be named here, and the\n // region is rebuilt field by field rather than spread — so an addition\n // to `ResolvedBlockTree` that nobody adds to this literal is dropped in\n // SILENCE, on the one surface where it matters most. `refs` was exactly\n // that: a logo in the header resolved fine, the frontend's ref loop had\n // an empty array to iterate, and no tag ever named the asset every page\n // on the site renders.\n if (envelope.refs !== undefined) {\n for (const ref of envelope.refs) tags.addDocument(ref.entity, ref.id)\n }\n // `collections` was the next field this literal dropped (editor PR53):\n // a footer's \"latest recipes\" resolved, spent its reads, and reached the\n // frontend as nothing. Its tag names the ENTITY, not the rows — a newly\n // published recipe changes which rows it lists.\n if (envelope.collections !== undefined) {\n for (const result of Object.values(envelope.collections)) {\n if (result.entity !== null) tags.addCollection(result.entity)\n }\n }\n return {\n blocks: [...envelope.blocks],\n overridden: region.overridden,\n resolved: envelope.resolved,\n links: envelope.links,\n ...(envelope.unresolved !== undefined &&\n envelope.unresolved.length > 0 && { unresolved: envelope.unresolved }),\n ...(envelope.refs !== undefined && envelope.refs.length > 0 && { refs: envelope.refs }),\n ...(envelope.collections !== undefined && { collections: envelope.collections }),\n // block-library F012 — a site header is exactly where a live-bound\n // price (a room rate, say) is as likely to live as in a page body.\n ...(envelope.live !== undefined && { live: envelope.live }),\n ...(envelope.currency !== undefined && { currency: envelope.currency }),\n }\n }\n\n // 🔴 A LOOP, not one await per region in an object literal, and the\n // difference is that the invariant now lives in the code rather than in a\n // comment above it. Object-literal properties DO evaluate in source order, so the literal\n // form was correct — but a later edit that reordered the keys or reached for\n // `Promise.all` would break the budget-claim ordering with no test failing,\n // because two regions rarely reach a ceiling in a suite. An explicit\n // sequence puts the constraint where it would be broken.\n const resolvedRegions: Partial<ContentApiChromeRegions> = {}\n for (const field of CHROME_REGION_FIELDS) {\n resolvedRegions[field] = await resolveRegion(field, projectRegion(regions?.[field]))\n }\n const data: ContentApiChromeRegions = {\n header: resolvedRegions.header ?? null,\n footer: resolvedRegions.footer ?? null,\n }\n\n // AFTER the block pass, not before it: `reportBudget` is the only operator\n // signal that something was refused, and running it before the resolution\n // it is supposed to report made every block-tree drop invisible.\n reportBudget(resolutionDeps, chrome.entity, url.pathname)\n\n return jsonResponse(\n { data },\n 200,\n // Keyed on the chrome ENTITY, so an app can give chrome its own TTL, and\n // routed through the same role-aware split every entity response uses:\n // the body varies with the caller (which documents they may see, which\n // content is published for them), so it is not principal-invariant the\n // way `_menu` is.\n buildResponseHeaders(\n chrome.entity,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n // -- Central dispatch -------------------------------------------------------\n\n /**\n * The two request facts the monitoring seam needs that only DISPATCH can\n * know: which entity the request resolved to, and which principal it\n * resolved as. Filled in as the request gets far enough to have them; both\n * stay absent on a request that 404s before either.\n *\n * A per-invocation object rather than a closure variable on the factory —\n * `createContentApiHandler` is called once per process and serves every\n * concurrent request.\n */\n interface DispatchFacts {\n entity?: string\n principalId?: string\n }\n\n async function handleGet(req: Request, ctx: RouteContext): Promise<Response> {\n // Resolved FIRST, before anything can fail, so the id exists on every path\n // out of here — including the ones that never reach a request context.\n // Absent or malformed, the server mints one: an optional correlation id is\n // missing precisely on the requests an operator most wants to trace (D032).\n const requestId = resolveRequestId(req)\n const facts: DispatchFacts = {}\n\n // Correlation is bound around the WHOLE dispatch, including principal\n // resolution and the 500 catch — both of which run outside the\n // authenticated context, and both of which log. The inner context replaces\n // this one (`runWithContextAsync` replaces rather than merges) and\n // re-states `requestId`.\n // A mount that runs inside a site (`runInSite`) keeps it on both contexts\n // (multisite phase 03) — the replacements below would otherwise drop it.\n const site = getCurrentSite()\n const outerContext: RequestContext = { requestId, ...(site !== undefined && { site }) }\n bindRequestLogger(outerContext)\n return await runWithContextAsync(outerContext, async () => {\n const res = await guardedDispatch(req, ctx, requestId, facts)\n echoRequestId(res, requestId)\n // INSIDE the context, deliberately: `reportMonitoringFailure` logs, and\n // outside it that one line — the only evidence a monitoring backend is\n // down — would be the one line missing its correlation id. A host hook\n // calling `getContext()` also sees a real context this way.\n reportToMonitoring(res, req, requestId, facts)\n return res\n })\n }\n\n /**\n * Echo the correlation id, EXCEPT on a response a shared cache may store.\n *\n * A per-request id in a `public, max-age=…` response is replayed: caller A's\n * id is stored with the body and handed to every anonymous caller for the\n * lifetime of the entry, for requests that never reached this process and\n * have no log line. An operator grepping the quoted id then lands on a\n * different request — worse than no id, because it is confidently wrong.\n *\n * `Vary: x-request-id` would be literally correct and practically fatal: every\n * request carries a distinct id, so the variant key would be unique per\n * request and the cacheable branch would never hit. Not echoing is the honest\n * trade — the id is still minted, bound, and logged.\n *\n * The test is the emitted `Cache-Control` rather than a flag threaded down\n * from each handler, because that header IS the storability decision; a flag\n * would be a second copy of it, free to disagree.\n */\n function echoRequestId(res: Response, requestId: string): void {\n const cacheControl = res.headers.get('Cache-Control') ?? ''\n if (!cacheControl.includes('no-store')) return\n res.headers.set(REQUEST_ID_HEADER, requestId)\n }\n\n /**\n * Fire the monitoring seam for any error response, exactly once, with derived\n * facts only.\n *\n * Reads `code` from the side table `errorResponse` stamps, so this cannot\n * drift from what actually went on the wire, and cannot be forgotten by a new\n * refusal call site.\n */\n function reportToMonitoring(\n res: Response,\n req: Request,\n requestId: string,\n facts: DispatchFacts,\n ): void {\n if (!onError || res.status < 400) return\n const code = errorCodeOf(res)\n if (code === undefined) return\n try {\n // Not awaited — but the rejection IS contained. Both shipped mounts pass\n // an `async` function into what used to be a `=> void` slot, so a throw\n // inside the hook escaped as an unhandled rejection. A failing monitoring\n // backend must not be able to affect the response, in either direction.\n void Promise.resolve(\n onError({\n status: res.status,\n code,\n // CAPPED. `path` is the one caller-controlled field left in this\n // payload, and the hook now fires on every 404 — which is\n // unauthenticated and repeatable at whatever rate the caller likes.\n // `message` was removed for being exception-derived; leaving `path`\n // uncapped would have replaced one unbounded field with another,\n // this one sized entirely by the attacker. Bounding ARRIVALS is\n // phase 09's rate limiter; bounding each report's SIZE is this\n // function's job and costs nothing.\n path: truncateForLog(new URL(req.url).pathname),\n ...(facts.entity !== undefined && { entity: facts.entity }),\n requestId,\n ...(facts.principalId !== undefined && { principalId: facts.principalId }),\n }),\n ).catch(reportMonitoringFailure)\n } catch (err) {\n reportMonitoringFailure(err)\n }\n }\n\n /**\n * `requestLog().warn`, but it cannot itself become the failure.\n *\n * `requestLog()` falls through to `getApp().logger`, which THROWS before\n * bootstrap. The guard's warnings sit outside `dispatch`'s try, so an\n * unguarded one would turn a load-shed into an uncaught throw — the logger\n * that exists to explain a refusal becoming the reason there is no refusal to\n * explain. Every other error-path logger call in this file is wrapped for the\n * same reason.\n */\n /** Keys {@link safeWarnOnce} has already logged. Bounded by its callers' fixed keys. */\n const warnedOnce = new Set<string>()\n\n /** {@link safeWarn}, at most once per `key` for the life of the handler. */\n function safeWarnOnce(key: string, fields: Record<string, unknown>, message: string): void {\n if (warnedOnce.has(key)) return\n warnedOnce.add(key)\n safeWarn(fields, message)\n }\n\n function safeWarn(fields: Record<string, unknown>, message: string): void {\n try {\n requestLog().warn(fields, message)\n } catch {\n // Pre-bootstrap, or the logger itself is what is broken — the one case\n // this guard exists for. A SILENT catch here would defeat the obligation\n // the callers state: without a line, \"nobody is hitting the limit\" and\n // \"the limiter was never wired\" produce identical evidence. So fall back\n // to the console, as `logAuthenticateFailure` and `reportBudget` do.\n try {\n console.warn('[content-api]', message, fields)\n } catch {\n // Nothing left to try. The response is unaffected either way, which is\n // the whole point of guarding here.\n }\n }\n }\n\n function reportMonitoringFailure(err: unknown): void {\n try {\n requestLog().warn({ err }, 'content-api: `onError` monitoring hook failed')\n } catch {\n // Pre-bootstrap, or the logger itself is the thing that is broken. The\n // response has already been produced and is unaffected.\n }\n }\n\n /**\n * The KEYLESS survival guard (D025), wrapped around every request.\n *\n * Two mechanisms, both always on and neither keyed on caller identity:\n * a bounded-concurrency semaphore with a bounded wait queue, and a per-request\n * wall clock. They sit HERE — outside `dispatch` entirely — for two reasons.\n *\n * **Ordering is a security constraint, not a performance one.** Both fire\n * before `resolveWhitelistedEntity` and the `view` gate, and before principal\n * resolution. A limit that only fired for entities that exist would let a\n * caller distinguish \"not in the whitelist\" from \"whitelisted but throttled\"\n * by observing which paths can be made to shed — an enumeration oracle over\n * the served set, delivered through the abuse-resistance layer, defeating the\n * 404-not-403 rule D006 exists to enforce. Wrapping the whole of `dispatch`\n * makes that ordering structural rather than a line someone has to keep at the\n * top of a 200-line function.\n *\n * **The 503 must not become a 500.** `dispatch`'s own catch turns anything\n * that escapes into `INTERNAL_ERROR`, so a shed handled inside it would be\n * reported as a server fault — wrong on the wire and wrong in the monitoring\n * seam.\n *\n * The slot is held for the WHOLE request, which is why the ceiling counts\n * requests rather than connections (SD004) — see `limits.ts`.\n */\n async function guardedDispatch(\n req: Request,\n ctx: RouteContext,\n requestId: string,\n facts: DispatchFacts,\n ): Promise<Response> {\n const corsHeaders = buildCorsHeaders(req, await preSiteCorsConfig(req))\n // CORS and `Vary` for the guard's own refusals. Storability is not in\n // question for these or for any other refusal — `errorResponse` stamps\n // `no-store` on every one (SD005). Worth stating that a 503 and a 429 would\n // each deserve it independently even if the rule were narrower: a shed is a\n // fact about this instance at this instant, and a 429 is a fact about ONE\n // caller, which a shared cache would hand to everyone else.\n const guardHeaders = buildRefusalHeaders(corsHeaders, authVary)\n\n try {\n // `req.signal` makes a QUEUED acquire abortable: a caller who disconnects\n // while waiting leaves the queue rather than waking to take a slot and run\n // work whose response is already gone.\n await guard.acquire(req.signal)\n } catch (err) {\n if (err instanceof ServiceOverloadedError) {\n // LOGGED, and that is an obligation rather than a nicety. Without it,\n // \"nobody is hitting the limit\" and \"the limiter was never wired\"\n // produce identical evidence in production — F001's defect, relocated\n // from CI to operations.\n safeWarn(\n {\n limit: 'survival',\n maxConcurrentRequests: guard.limits.maxConcurrentRequests,\n maxQueuedRequests: guard.limits.maxQueuedRequests,\n },\n 'content-api: shedding load — concurrency slots and wait queue both full',\n )\n return serviceOverloadedResponse(SHED_RETRY_AFTER_SECONDS, guardHeaders)\n }\n // NOT a shed — an aborted acquire (the caller disconnected while queued),\n // or something unforeseen. Answered as a 500 rather than rethrown.\n //\n // Rethrowing would escape `handleGet` entirely, so the host framework\n // would receive a rejected promise instead of a `Response`, and\n // `echoRequestId` / `reportToMonitoring` would never run — which also\n // hands anyone who opens-and-aborts a way to drive framework-level error\n // logging at will. This function sits OUTSIDE `dispatch`'s own catch, so\n // nothing else would have caught it.\n safeWarn({ err }, 'content-api: acquiring a concurrency slot failed')\n return errorResponse('Internal server error', 'INTERNAL_ERROR', 500, guardHeaders)\n }\n\n try {\n // The deadline's signal is THREADED IN, not dropped. `runWithDeadline`\n // aborts it when the wall clock fires, and `dispatch` checks it at the one\n // point worth checking — after the gates, before the expensive work — so\n // an expired request stops rather than running to completion with nobody\n // waiting for the answer.\n //\n // Stated honestly, because the alternative is a claim that cannot fail:\n // this does NOT cancel an in-flight database round trip. Nothing on the\n // read path takes an `AbortSignal` today. What bounds THAT is PR 2's 5s\n // `statement_timeout` on the read-only pool, which is below this 10s\n // clock, so on the common path Postgres ends the query first. The gap this\n // leaves is host-supplied resolution hooks (`resolveMedia`,\n // `resolveBlocks`, `resolveEntityHref`), which are not query work and are\n // not covered by either bound.\n return await guard.runGuarded((signal) =>\n dispatch(req, ctx, requestId, facts, corsHeaders, signal),\n )\n } catch (err) {\n if (err instanceof RequestDeadlineError) {\n safeWarn(\n { limit: 'deadline', requestTimeoutMs: guard.limits.requestTimeoutMs },\n 'content-api: request exceeded the wall clock',\n )\n // Same code as a shed, deliberately. D031's partition rule is that a\n // code names what the client should do NEXT, and \"back off, retry later,\n // this was not about your request\" is the same next action for both. A\n // third code would name which internal mechanism fired, which is exactly\n // what the rule forbids.\n return serviceOverloadedResponse(SHED_RETRY_AFTER_SECONDS, guardHeaders)\n }\n throw err\n } finally {\n guard.release()\n }\n }\n\n async function dispatch(\n req: Request,\n ctx: RouteContext,\n requestId: string,\n facts: DispatchFacts,\n baseCorsHeaders: Record<string, string>,\n deadlineSignal?: AbortSignal,\n ): Promise<Response> {\n // What a refusal or fault raised from INSIDE a handler is answered with.\n // Starts as the pre-site headers and is narrowed to the request's site once\n // that is known, so the catch below never allows an origin the site does\n // not own. (The guard's own refusals and the pre-principal 404s, which\n // fire before any site is known, keep the pre-site union: they carry a\n // fixed error body and nothing of any site's.)\n let refusalHeaders = baseCorsHeaders\n try {\n const rawSegments = ctx.path\n // PRE-PRINCIPAL REFUSALS keep the bare CORS headers. Their directive is\n // identical to every other refusal's (`no-store`, from `errorResponse`);\n // what they lack is the declared credential selectors in `Vary`, because\n // they fire before `resolvePrincipal` and nothing here needs them — an\n // unstorable response has no variant to key. The set is tiny and fixed:\n // no path, wrong version, version-only path.\n if (!rawSegments || rawSegments.length === 0) {\n return notFoundResponse(baseCorsHeaders)\n }\n\n // Consume the version segment. `/v1/` is the ONLY path served (D010), so\n // an unversioned path — and a well-formed `/v2/` this instance does not\n // mount — is a 404, not a 400: there is no unprefixed mount, and an\n // absent mount is an absence, which is shaped as absence (D006).\n //\n // Checked BEFORE role resolution and `loadPermissions()`, so a mistyped\n // or probing prefix costs nothing beyond a string compare.\n if (rawSegments[0] !== API_VERSION) {\n return notFoundResponse(baseCorsHeaders)\n }\n const segments = rawSegments.slice(1)\n if (segments.length === 0) {\n return notFoundResponse(baseCorsHeaders)\n }\n\n // No caller-chosen site, on any route (multisite D008).\n if (new URL(req.url).searchParams.has(FORBIDDEN_SITE_PARAM)) {\n return errorResponse(\n 'Invalid query parameters',\n 'VALIDATION_ERROR',\n 400,\n buildRefusalHeaders(baseCorsHeaders, authVary),\n )\n }\n\n // Resolve the PRINCIPAL + load permissions + establish request context.\n const principal = await resolvePrincipal(req)\n const role = principal.role\n\n // THE REQUEST'S SITE (multisite D008) — decided here, once, before\n // anything reads. Everything below (CORS, cache tags, locales, the\n // request context every read runs in) hangs off this one answer.\n const siteScope = await resolveRequestSite(req, principal)\n if (siteScope === null) {\n // No ACAO: the site is not served, so no origin is its origin.\n return notFoundResponse(buildRefusalHeaders(corsConfig ? { Vary: 'Origin' } : {}, authVary))\n }\n // CORS FOR THIS RESPONSE. On an app serving several sites, only the\n // request's own site's origins are allowed — another site's origin gets\n // no `Access-Control-Allow-Origin`, and a request with no site gets none\n // for any origin. The registry is the source; the mount's static list\n // only ever answers preflights there. A single-site install keeps the\n // mount's list, unchanged.\n const siteCorsHeaders = siteScope.multiSite\n ? buildCorsHeaders(req, {\n allowedOrigins: siteScope.record?.origins ?? [],\n ...(corsConfig?.maxAge !== undefined && { maxAge: corsConfig.maxAge }),\n })\n : baseCorsHeaders\n\n // THE FAIRNESS LIMIT (D025/D028/D029) — per-caller, and applied ONLY when\n // a distinguishing key exists.\n //\n // Still before `resolveWhitelistedEntity` and the `view` gate, for the\n // enumeration-oracle reason in `guardedDispatch`'s docblock. After\n // principal resolution because the FIRST key it tries is the\n // server-verified identity.\n //\n // `undefined` means SKIPPED, not allowed-by-a-shared-bucket, and the\n // difference is the whole of F011. `SearchMechanics.enforceRateLimit`\n // keys on `(userId, ip)` and its reasoning depends on both DISTINGUISHING;\n // on this surface, unauthenticated, both are constants. Two constant keys\n // is an aggregate cap on every visitor on Earth that fails CLOSED for all\n // of them at once — a one-caller global outage costing the attacker two\n // requests per second, which in logs looks exactly like a working limiter.\n // So when nothing distinguishes, no fairness limit is applied at all and\n // the KEYLESS survival guard above is what protects the surface.\n const fairness = guard.consumeFairness(req, {\n principalId: principal.hasIdentity ? principal.id : undefined,\n budgetPerInstance: principal.rateBudgetPerInstance,\n })\n // THE `Vary` EVERY REFUSAL BELOW THIS LINE CARRIES. It decides no cache\n // directive — `errorResponse` owns that, unconditionally (SD005), and is\n // the only place to look for it.\n //\n // All ~40 refusals below — the whitelist gate, the `view` gate, every\n // `VALIDATION_ERROR`, every handler's own 404 — receive these rather than\n // the bare CORS headers, so the declared credential selectors reach the\n // refusal path without 40 call sites remembering to add them. Threading\n // one shadowed binding rather than editing each site is what makes it\n // impossible for the 41st to forget.\n //\n // SAFE FOR SUCCESS RESPONSES, which also flow through here:\n // `buildResponseHeaders` and `buildPrincipalInvariantHeaders` both spread\n // these and then set their own `Cache-Control` and `Vary`, so a 200 is\n // unaffected by what rides along for the refusals.\n const corsHeaders = buildRefusalHeaders(siteCorsHeaders, authVary)\n refusalHeaders = corsHeaders\n\n // THE ONE TAG SET FOR THIS RESPONSE (D055).\n //\n // Created HERE, beside `corsHeaders`, for the same reason that binding is\n // shadowed here: threading one object beats asking each handler to build\n // its own, and a handler that forgot would emit an untagged-but-cacheable\n // response — the silent half of the failure, since nothing observable\n // distinguishes \"this response depends on nothing\" from \"somebody forgot\".\n //\n // It reaches a response ONLY through `buildResponseHeaders` /\n // `buildPrincipalInvariantHeaders`, which emit it only when the directive\n // they just computed is not `no-store`. `buildRefusalHeaders` — which is\n // what `corsHeaders` above IS — takes no tag argument, so none of the ~40\n // refusals below can carry one.\n const tags = createCacheTagSet(cacheTagsConfig, cacheTagSiteFor(siteScope))\n\n if (fairness && !fairness.decision.allowed) {\n // `safeWarn`, like the guard's other two refusal logs. This one sits\n // inside `dispatch`'s try so a throwing logger would be caught rather\n // than escaping — but it would still convert a correct 429 into a 500,\n // which is the logger explaining a refusal becoming the reason the\n // refusal is wrong. Same guard, same reason, all three places.\n safeWarn(\n { limit: 'fairness', keyClass: fairness.keyClass },\n 'content-api: caller exceeded its per-instance budget',\n )\n return rateLimitedResponse(fairness.decision.retryAfterSeconds, corsHeaders)\n }\n\n const checker = await getChecker()\n\n // The identity here is REAL, not a placeholder. It flows through\n // `runWithContextAsync` into `createClient`'s `contextResolver`, and from\n // there into `resolveAuthContext` on every QueryClient read — which is the\n // seam per-principal behaviour (owner-scoped rows, audit attribution) has\n // to arrive through. An anonymous caller still gets ANONYMOUS_PRINCIPAL_ID,\n // so the two are distinguishable rather than merged.\n //\n // `groups` carries the ROLE, and the role alone is what every gate on this\n // surface consults (`checker(role, entity, 'view')`). Adding an identity\n // does not add a second authorization input, and must not: one firewall,\n // one answer.\n //\n // `role ? [role] : []` is now belt-and-braces rather than a live branch:\n // `resolvePrincipal` rejects an empty role and the factory refuses an\n // empty `publicRole`, so `role` is a non-empty string on every path that\n // reaches here. Kept anyway, because the failure it guards is silent —\n // an empty `groups` makes the read path REFUSE, which is the direction a\n // guard should fail if a future edit reopens the hole.\n facts.principalId = principal.id\n // The site's languages (plan `multisite` phase 03): a `?locale=` the\n // request's site does not serve is refused for that site, and the site's\n // default stands in for the mount's.\n const requestSite = siteScope.site\n const siteLocales = await siteLocaleScope(requestSite, configDefaultLocale)\n const requestedLocale = new URL(req.url).searchParams.get('locale')\n if (\n requestedLocale !== null &&\n requestedLocale !== '' &&\n siteLocales.locales.length > 0 &&\n !siteLocales.locales.includes(requestedLocale)\n ) {\n return errorResponse(\n `Locale '${requestedLocale}' is not served by this site`,\n 'VALIDATION_ERROR',\n 400,\n corsHeaders,\n )\n }\n const requestContext: RequestContext = {\n user: {\n id: principal.id,\n groups: role ? [role] : [],\n ...(principal.name !== undefined && { name: principal.name }),\n ...(principal.email !== undefined && { email: principal.email }),\n },\n checker,\n // Re-stated because `runWithContextAsync` REPLACES the store rather\n // than merging into it — the outer correlation-only context does not\n // survive into this one on its own.\n requestId,\n ...(requestSite !== undefined && { site: requestSite }),\n ...(siteLocales.locales.length > 0 && { availableLocales: siteLocales.locales }),\n }\n if (siteLocales.defaultLocale !== undefined) {\n REQUEST_SITE_DEFAULT_LOCALES.set(requestContext, siteLocales.defaultLocale)\n }\n // The identity is now known, so the binding gains `principalId`. Bound\n // once, here; nothing downstream mentions either field again.\n bindRequestLogger(requestContext)\n\n const response = await runWithContextAsync(requestContext, async () => {\n // THE WALL CLOCK'S ONE OBSERVATION POINT.\n //\n // Placed after the gates and before any handler runs: everything above\n // is string comparison and an in-memory permission check, and\n // everything below can touch the database or a host resolution hook. If\n // the deadline has already fired there is nobody left waiting for this\n // answer, so producing it is pure cost.\n //\n // `runWithDeadline` has already rejected the outer promise by this\n // point, so what this actually saves is the WORK, not the response —\n // which is precisely why it belongs here rather than at the top.\n if (deadlineSignal?.aborted) {\n throw new RequestDeadlineError()\n }\n\n // /_schema — entity introspection (filtered by role)\n if (segments[0] === '_schema') {\n // Every field is DERIVED from this handler's live config — the\n // version it mounts, its EFFECTIVE (possibly host-lowered) ceilings,\n // and which optional providers were actually injected. Nothing is a\n // restated literal, so the published contract cannot drift from the\n // one being served.\n //\n // `menus` is passed as a BOOLEAN, never the provider: its `handles`\n // allowlist is the `_menu` endpoint's entire gate, so `_schema`\n // structurally cannot publish it.\n const schema = buildSchemaResponse({\n entities,\n checkPermission: (name) => hasViewPermission(checker, role, name),\n version: API_VERSION,\n maxLimit,\n maxOffset,\n maxResolutionsPerDocument: maxResolutions,\n limits: guard.limits,\n hasResolution: resolution !== undefined,\n hasMenus: menus !== undefined,\n hasChrome: chrome !== undefined,\n cacheTags: cacheTagsConfig,\n siteScopedTags: siteScope.multiSite,\n })\n return jsonResponse(schema, 200, {\n ...corsHeaders,\n 'Cache-Control': 'no-store',\n })\n }\n\n // /_resolve, /_paths, /_alternates — routing resolution/enumeration\n // projection (PR07). Each gates internally (see per-handler JSDoc):\n // published-only (draft/preview resolution is never wired here),\n // whitelist ∧ view, 404-not-403.\n if (segments[0] === '_resolve') {\n return handleResolve(req, principal, checker, corsHeaders, tags)\n }\n if (segments[0] === '_paths') {\n return handlePaths(req, principal, checker, corsHeaders, tags)\n }\n if (segments[0] === '_alternates') {\n return handleAlternates(req, principal, checker, corsHeaders, tags)\n }\n\n // /_menu — navigation projection (PR09). Gated on its own handle\n // allowlist rather than on an entity permission (see handleMenu's\n // JSDoc), which is why it takes no checker. It takes the principal for\n // ONE thing: whether the credential selected the site, in which case\n // the body varies by credential and the headers must say so.\n if (segments[0] === '_menu') {\n return handleMenu(req, principal, corsHeaders, tags)\n }\n\n // /_site — the calling site's public config (multisite D008).\n if (segments[0] === '_site') {\n return handleSite(req, principal, siteScope, siteLocales, corsHeaders, tags)\n }\n\n // /_chrome — site-chrome projection (phase 07). Unlike `_menu` this\n // one DOES take a principal and a checker: its body varies with which\n // documents the caller may see and with what is published for them, so\n // the role-aware cache split applies and the `view` gate on the chrome\n // entity is real. See handleChrome's JSDoc.\n if (segments[0] === '_chrome') {\n return handleChrome(req, principal, checker, corsHeaders, tags)\n }\n\n // Resolve entity from whitelist\n const entityName = segments[0]\n if (entityName === undefined) return notFoundResponse(corsHeaders)\n const entity = resolveWhitelistedEntity(entityName)\n\n // Entity not in whitelist OR role lacks view permission → 404\n if (!entity || !hasViewPermission(checker, role, entity.name)) {\n explainEntityRefusal(entityName, entity)\n return notFoundResponse(corsHeaders)\n }\n\n // The RESOLVED name, not `segments[0]`. Only a whitelisted entity gets\n // named to the monitoring seam, so a probe string cannot ride into a\n // monitoring payload that leaves the process.\n facts.entity = entity.name\n\n // A singleton is addressed AS a singleton. `kind` on the entity IS the\n // switch — there is no config option, deliberately: which entities are\n // exposed this way is an entity-definition decision, not a per-mount\n // one.\n //\n // Dispatched BEFORE the collection routes and closed by a whitelist,\n // not a blacklist: `/:entity` is the only path a singleton has, and\n // EVERYTHING deeper 404s. `/count`, `/slug/:slug` and `/:id` are\n // affordances a COLLECTION has — a resource addressed as a singleton\n // does not have them, so they are absences rather than refusals (D006).\n if (entity.kind === 'singleton') {\n if (segments.length === 1) {\n return handleSingleton(req, entity, principal, checker, corsHeaders, tags)\n }\n return notFoundResponse(corsHeaders)\n }\n\n // A TEMPLATE-DRAWN entity is addressed by id and nothing else\n // (block-library D009). Its rows cross narrowed to what their page\n // shows, so a list, a count or a slug lookup has nothing honest to\n // filter or sort on — a `?filter[x]=` over a column that is not served\n // would be an oracle for it. Absences, like a singleton's deeper\n // routes, not refusals (D006).\n if (isTemplateDrawn(entity)) {\n const rowId = segments[1]\n if (segments.length === 2 && rowId !== undefined) {\n return handleById(req, entity, rowId, principal, checker, corsHeaders, tags)\n }\n return notFoundResponse(corsHeaders)\n }\n\n // Route to handler. The read endpoints take the `checker` because\n // reference resolution re-runs the SAME `view` gate against each TARGET\n // entity — one firewall, consulted again one hop out, never a second\n // rule. `count` returns no rows, so it resolves nothing and needs none.\n if (segments.length === 1) {\n return handleList(req, entity, principal, checker, corsHeaders, tags)\n }\n if (segments[1] === 'count') {\n return handleCount(req, entity, principal, corsHeaders, tags)\n }\n if (segments[1] === 'slug' && segments[2]) {\n return handleBySlug(req, entity, segments[2], principal, checker, corsHeaders, tags)\n }\n if (segments.length === 2 && segments[1] !== undefined) {\n return handleById(req, entity, segments[1], principal, checker, corsHeaders, tags)\n }\n\n return notFoundResponse(corsHeaders)\n })\n\n // DROPS ARE LOGGED, NEVER SILENTLY TRUNCATED — the rule PR 5 set for the\n // resolution budget, applied to the tag set for the same reason: the log\n // is the only place the distinction survives. A dropped tag is a document\n // a future purge will silently miss, and nothing about the response says\n // so. Vercel does exactly this (\"any tags past the 128th in one call are\n // dropped… log a console warning\").\n //\n // `safeWarn`, like every other refusal log here: a throwing logger must\n // not convert a correct response into a 500.\n // ONLY when a tag header actually went out. `tags.dropped` counts refused\n // adds, which happen regardless of whether the response turned out to be\n // storable — so on a `no-store` response, or with `cache.tags.enabled:\n // false`, this line would otherwise fire and claim \"a purge for them will\n // not invalidate this response\" about a response nothing ever cached. A\n // warning whose own message is false where it fires is how a signal stops\n // being read, which is the same argument the dedupe path already makes.\n //\n // Asked of the RESPONSE rather than re-derived from the caller class and\n // the config: the response is the thing that either carries the header or\n // does not, and every branch that decides so lives in `cache.ts`.\n if (tags.dropped > 0 && response.headers.has(cacheTagsConfig.header)) {\n // `entity` and `path` are carried EXPLICITLY, because a bare count is\n // unattributable and the log's whole job is telling an operator WHERE\n // tags are being dropped. `facts.entity` is the resolved, whitelisted\n // name (never a probe string) and the path is truncated like every\n // other logged path.\n //\n // The correlation id still rides along and this line does NOT lose it —\n // an earlier version of this comment claimed otherwise and was wrong.\n // The inner `runWithContextAsync` has returned by now, but `dispatch`\n // is itself inside `handleGet`'s OUTER one, and `contextStore.run`\n // restores that store on exit — so `getContext()` still resolves and\n // `requestLog()` still returns the logger bound at correlation time.\n // What is genuinely absent is `principalId`, which was bound on the\n // inner context only.\n //\n // `dropped` counts refused ADDS, not distinct documents — see\n // `CacheTagSet.dropped`. Non-zero means this response depends on\n // something it does not name.\n safeWarn(\n {\n dropped: tags.dropped,\n emitted: tags.tags.length,\n // BOTH ceilings, because after the byte cap was added the count cap\n // is usually NOT the one that bound. On a large page the byte\n // budget fills first — that is the documented, deliberate\n // behaviour — so reporting `cap: 128` alone would show an operator\n // `emitted: 62, cap: 128` and invite the conclusion that no ceiling\n // was reached at all. A diagnostic that names the wrong limit is\n // worse than one that names none.\n maxTags: MAX_CACHE_TAGS_PER_RESPONSE,\n maxHeaderBytes: MAX_CACHE_TAG_HEADER_BYTES,\n ...(facts.entity !== undefined && { entity: facts.entity }),\n path: truncateForLog(new URL(req.url).pathname),\n },\n 'content-api: cache tags refused — this response depends on documents it does not name, so a purge for them will not invalidate it',\n )\n }\n return response\n } catch (err) {\n // The wall clock is NOT a handler error and must not be reported as one.\n //\n // The post-gate abort check throws `RequestDeadlineError` from inside this\n // try, so without this line it would be swallowed here, logged as\n // \"content-api handler error\" with a stack, and converted to a 500 — a\n // spurious error-level line plus a misleading status on every timed-out\n // request. `guardedDispatch` owns this error and answers 503.\n //\n // In practice `runWithDeadline` has already rejected the outer promise by\n // the time the flag is observable, so this response is discarded either\n // way. The point is the LOG, not the response: an operator debugging load\n // must not see timeouts filed as handler faults.\n if (err instanceof RequestDeadlineError) throw err\n\n // A site-bound read with no site (multisite D003/D008): the request named\n // none on an app serving several — an anonymous caller, or a key bound to\n // no site. The data layer refused before reading anything; that is an\n // ABSENCE on this surface, shaped as every other absence is (D006), not a\n // fault. Without this it fell through to the 500 below with an\n // error-level stack on every such request.\n if (isSiteRequiredError(err)) return notFoundResponse(refusalHeaders)\n\n const path = new URL(req.url).pathname\n // The operator gets the whole error — `err` reaches pino's serializer, so\n // the line carries a stack. The CALLER gets a fixed string, three lines\n // down. That split is C1's requirement and it is why the monitoring seam\n // (fired by `handleGet`, from derived facts) no longer receives\n // `err.message`: a driver-level message can carry query fragments and\n // values, and a monitoring payload leaves the process.\n //\n // Correlation and principal are NOT passed here — they are inherited from\n // the binding established at context establishment (D032).\n try {\n requestLog().error({ err, path: truncateForLog(path) }, 'content-api handler error')\n } catch {\n // App not yet bootstrapped (early failure path) — last-resort log.\n console.error('[content-api] handler error:', err)\n }\n // `refusalHeaders`, hoisted above the `try`: the pre-site headers until\n // the request's site is known, the site's own afterwards — so a fault\n // inside a handler never allows an origin that site does not own.\n // `errorResponse` stamps `no-store` either way.\n return errorResponse('Internal server error', 'INTERNAL_ERROR', 500, refusalHeaders)\n }\n }\n\n return {\n GET: (req: Request, ctx: RouteContext) => handleGet(req, ctx),\n OPTIONS: async (req: Request): Promise<Response> =>\n corsPreflightResponse(req, await preSiteCorsConfig(req), cacheConfig),\n /**\n * Invalidate the cached permission checker. Call this from the\n * role-management code path (after a role is created/edited/deleted)\n * so the next request re-runs `loadPermissions()` instead of waiting\n * up to 30 seconds for the TTL to expire.\n */\n invalidatePermissionCache,\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["blockTreeFieldsOf","sharedBlockTreeFieldsOf"],"sources":["../src/query-params.ts","../src/resolution-budget.ts","../src/blocks.ts","../src/cache-tags.ts","../src/detail-template.ts","../src/cache.ts","../src/cors.ts","../src/limits.ts","../src/media.ts","../src/references.ts","../src/response.ts","../src/search.ts","../src/schema-endpoint.ts","../src/handler.ts"],"sourcesContent":["/**\n * Query parameter validation — Zod schemas for all content API endpoints.\n *\n * All user input is validated before it touches QueryClient.\n * Pagination hard-capped at MAX_LIMIT / MAX_OFFSET — exceeding either is a 400.\n * NOTE for consumers of this published subpath: only `limit`'s cap is enforced\n * by the schema. `offset`'s is enforced by the HANDLER (`ceilingViolation`), so\n * that an over-large offset can carry the `?cursor=` remedy instead of a bare\n * \"Invalid query parameters\" — see the comment on the field. Parsing a request\n * with this schema alone therefore bounds `limit` and not `offset`,\n * never a clamp. Sort/filter restricted to queryable columns.\n */\n\nimport { escapeLikePattern, LOCALE_PATTERN } from '@murumets-ee/core'\nimport type { FieldConfig } from '@murumets-ee/entity'\nimport { z } from 'zod'\n\nexport const MAX_LIMIT = 100\n\n/**\n * Absolute ceiling on `?offset=`. Beyond it the answer is a 400\n * `VALIDATION_ERROR`, never a silent clamp — a clamp cannot be observed\n * failing, and it would leave a deep-paging client re-reading the same page\n * forever. `?cursor=` is the supported path past this point.\n *\n * The cost being bounded is a full traversal of the MATCHING set on every\n * request: `QueryClient` issues a plain SQL `OFFSET`, so the work is\n * `O(min(offset, matching rows))` — bounded by the result set, not by the\n * caller's integer. That makes a large offset cheap on a small collection and\n * real on a large one, from a caller who is unauthenticated by default with no\n * rate limit in front of them.\n */\nexport const MAX_OFFSET = 1000\n\nexport const MAX_FILTER_TEXT_LENGTH = 200\n\n/**\n * Page size applied when the caller sends no `?limit=`.\n *\n * Named rather than inlined because `GET /_schema` PUBLISHES it: a client that\n * reads the default off the wire and one that hard-codes it can never disagree,\n * and lowering it here lowers what the contract advertises in the same edit.\n * Same reasoning for every other cap in this block.\n */\nexport const DEFAULT_LIMIT = 20\n\n/** Offset applied when the caller sends no `?offset=`. */\nexport const DEFAULT_OFFSET = 0\n\n/** Longest `?search=` accepted — rejected as a 400 before any DB work. */\nexport const MAX_SEARCH_LENGTH = 200\n\n/**\n * Shortest `?search=` accepted, measured AFTER trim. Below it: `400\n * VALIDATION_ERROR` (D046).\n *\n * **Its direction is inverted from every other ceiling here, deliberately.** A\n * limit like `maxLimit` is protective when LOWERED, so a host may only lower it.\n * A minimum is protective when RAISED, so the package sets the floor and a host\n * may make it stricter and never looser. Same rule in both cases — the caller\n * cannot move it, and the host cannot move it toward more work.\n *\n * No new error code: D031's partition rule is that a code names what the client\n * should do next, never which parameter was wrong, and `VALIDATION_ERROR`\n * already means \"fix your request\". Rejecting rather than silently returning an\n * empty set is the other half — a silent empty result is unobservable, so the\n * check could never be SEEN to fail, and a frontend could not tell the user to\n * type more.\n *\n * **BE HONEST ABOUT WHAT THIS BUYS.** It removes the most degenerate case —\n * `%a%` matching essentially every row, forcing a full scan and returning an\n * arbitrary page — and removes nothing else; `%ab%` on a Latin-script corpus\n * still matches almost everything. THE BOUND ON SEARCH COST IS THE SURVIVAL\n * GUARD'S WALL CLOCK AND SEMAPHORE (see `limits.ts`), NOT THIS FLOOR, and it\n * must not be presented in `_schema`, in a docstring or in review as though it\n * were the protection.\n *\n * Nor is it peer convention: R15 verified that Algolia enforces no server-side\n * minimum (its `minLength` is a client-side InstantSearch affordance) and found\n * no documented one for Contentful or Sanity. The justification is local — an\n * unindexed `ILIKE '%q%'` OR'd with a correlated per-row `EXISTS` on the\n * translations table, with no trigram index anywhere in this repo.\n *\n * 2 matches `DEFAULT_MECHANICS.minQueryLength` in `@murumets-ee/search`.\n */\nexport const MIN_SEARCH_LENGTH = 2\n\n/**\n * Zero-width and word-joiner characters, stripped before the floor is measured.\n *\n * `String.prototype.trim()` removes Unicode `White_Space`, which does NOT\n * include U+200B ZERO WIDTH SPACE and friends. So `?search=a` trims to\n * length 2, clears the floor, and produces the `ILIKE '%a%'` full scan the\n * floor exists to refuse — while matching nothing, so the caller pays no penalty\n * for the trick. Stripping them costs one regex on a string already capped at\n * 200 characters.\n *\n * Not exhaustive against every homoglyph or combining sequence, and not meant to\n * be: per {@link MIN_SEARCH_LENGTH}, the floor is a cheap filter on the worst\n * input and the survival guard is the actual bound. This closes the one bypass\n * that costs nothing to close.\n */\n// Written as escapes, not literals: the characters are invisible in source, so\n// a literal class is unreviewable and un-greppable.\n// U+200B ZWSP, U+200C ZWNJ, U+200D ZWJ, U+2060 WORD JOINER, U+FEFF BOM.\nconst ZERO_WIDTH_RE = /[\\u200B-\\u200D\\u2060\\uFEFF]/g\n\n/** Trim, then discard zero-width padding, for length measurement only. */\nexport function normalizeSearchLengthInput(raw: string): string {\n return raw.replace(ZERO_WIDTH_RE, '').trim()\n}\n\n/** Longest `?cursor=` accepted. Opaque base64 JSON; the cap bounds the decode. */\nexport const MAX_CURSOR_LENGTH = 500\n\n/** Longest `?path=` accepted by `_resolve` — a generous URL-path cap. */\nexport const MAX_RESOLVE_PATH_LENGTH = 2048\n\n/** Longest `?entity=` accepted by `_paths` / `_alternates`. */\nexport const MAX_ENTITY_NAME_LENGTH = 100\n\n/**\n * Longest `?handle=` accepted by `_menu` — a defensive cap, deliberately NOT\n * tied to the menu entity's `handle` column width (`varchar(255)`). A handle is\n * a hand-authored navigation key, so 128 is far above any real one; the point is\n * only to bound the string before it reaches the allowlist check, never to\n * mirror the column. An over-long value is a 400 either way.\n */\nexport const MAX_MENU_HANDLE_LENGTH = 128\n\n/** Page size `_paths` applies when the caller sends no `?limit=`. */\nexport const DEFAULT_PATHS_LIMIT = 20\n\n/**\n * Most reference fields one request may `?populate=` (D013).\n *\n * Server-owned and non-raisable: full expansion replaces a fixed-size\n * projection with the target's whole public DTO, so each named field makes the\n * response's size a function of what it happens to point at. Exceeding this is\n * a 400 `VALIDATION_ERROR`, never a silent truncation — the same reject-never-\n * clamp posture D007 fixed for `?offset=`, and for the same reason: a clamp\n * answers 200 with something the caller did not ask for and cannot be observed\n * failing from the outside.\n *\n * Five, because it bounds a MULTIPLIER rather than a count. The number of rows\n * is already bounded by `maxLimit`, and the number of resolutions by the\n * per-document budget; this bounds how much each row can grow.\n */\nexport const MAX_POPULATE_FIELDS = 5\n\n/**\n * Longest `?populate=` string accepted, before it is split. Bounds the parse\n * itself, independently of the field-count ceiling — a caller must not be able\n * to hand us a megabyte to `split(',')`.\n */\nexport const MAX_POPULATE_LENGTH = 200\n\n/** List endpoint query params. */\n/**\n * `.strict()` on every schema in this module: an unrecognized parameter is a 400\n * `VALIDATION_ERROR`, not a silently-stripped key (D042).\n *\n * Zod's default is `strip`, so `?limit=20` and `?limit=20&anything=1` returned a\n * BYTE-IDENTICAL body under two distinct URLs — and so did the next million\n * values of `anything`. On a URL-keyed shared cache that is unbounded,\n * caller-controlled key fragmentation, worse than an ordinary miss in both\n * directions at once: every fabricated key MISSES (so it reaches the origin) and\n * OCCUPIES an entry (so it evicts the working set serving real visitors). No\n * credential required, a few bytes per request (F031).\n *\n * This is the whitelist rule reaching the last input surface still running\n * blacklist-shaped — filter keys, sort fields, cursor fields and `?fields=`\n * already go through `isInternalField`, and `config.entities` / `menus.handles`\n * are already deny-by-default allowlists.\n *\n * **What this does and does not stop, stated precisely.** The parse precedes\n * every query, so a fabricated-key request is refused BEFORE any database work —\n * that is the whole of the protection, and it is real: the flood costs a schema\n * parse rather than two queries. It is NOT absorbed at the edge. An earlier\n * revision of this docblock said it was, on the strength of D037 giving that 4xx\n * a cacheable directive; SD005 made every refusal `no-store`, so 100% of such a\n * flood reaches the origin and is metered by the survival guard and the fairness\n * limit instead (D025). Left corrected rather than deleted because a stale\n * security rationale is the kind of comment a later risk assessment leans on.\n *\n * Breaking-shaped and free: D010 records that no content-api consumer is live,\n * and D008's \"a client derives nothing\" means a conforming client reads its\n * parameter set from `_schema` and can never send one the server did not\n * publish.\n *\n * **What it does NOT bound: parameter VALUES.** `?search=` is a whitelisted name\n * with an unbounded value space inside its 200-character cap, so a fabricated\n * search value is still a guaranteed miss against the most expensive query on\n * the surface. That is F040, and it is the survival guard's and the fairness\n * limit's to meter, not this schema's.\n */\nexport const listParamsSchema = z\n .object({\n limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_LIMIT),\n // NO `.max(MAX_OFFSET)` here — deliberately, and it is not an oversight.\n //\n // The handler's `ceilingViolation` answers an over-large offset with the\n // remedy the caller actually needs: \"?offset=N exceeds the maximum of M. Use\n // ?cursor= for deep pagination.\" A zod `.max()` fires FIRST and flattens that\n // to a bare \"Invalid query parameters\", so the remedy was unreachable for the\n // package ceiling and only appeared for a host-lowered one — the deep-paging\n // caller, i.e. exactly the caller who needs the hint, never saw it.\n //\n // Nothing is unbounded by removing it: `maxOffset` is `Math.min(config ?? MAX_OFFSET,\n // MAX_OFFSET)`, so the effective ceiling is still capped at the package value,\n // and `ceilingViolation` runs before any DB work. `limit` keeps its `.max()`\n // because it has no equivalent remedy to lose.\n offset: z.coerce.number().int().min(0).default(DEFAULT_OFFSET),\n sort: z\n .string()\n .regex(/^-?[a-zA-Z_]+$/)\n .optional(),\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n /**\n * `?search=` — see {@link MIN_SEARCH_LENGTH} for why the floor exists and\n * what it does NOT buy. Whitespace-only is treated as absent, matching the\n * pre-existing `buildSearchCondition` behaviour; anything else below the floor\n * is a 400.\n */\n search: z\n .string()\n .max(MAX_SEARCH_LENGTH)\n .refine(\n (raw) => {\n const trimmed = normalizeSearchLengthInput(raw)\n return trimmed.length === 0 || trimmed.length >= MIN_SEARCH_LENGTH\n },\n { message: `search must be at least ${MIN_SEARCH_LENGTH} characters` },\n )\n .optional(),\n /** Opaque cursor for keyset pagination (base64-encoded JSON). */\n cursor: z.string().max(MAX_CURSOR_LENGTH).optional(),\n /**\n * Comma-separated reference fields to expand to their target's full public\n * DTO (D013). The LENGTH cap is here so a malformed value is a 400 before it\n * is split; the FIELD-COUNT ceiling is enforced by the handler so the 400 can\n * name the ceiling — same division of labour as `limit` vs `offset` above.\n */\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n })\n .strict()\n\n/**\n * The highest `?page=` a document read accepts — the page a PAGED collection in\n * its block trees shows (davatoidud-toolkit PR03). Equal to\n * `COLLECTION_MAX_PAGE` in `@murumets-ee/blocks` (a test there pins the two;\n * this package cannot import that one).\n */\nexport const MAX_COLLECTION_PAGE = 100\n\n/**\n * A page number in its ONE canonical spelling — digits, no leading zero, 1 to\n * {@link MAX_COLLECTION_PAGE}. Anything else is a 400, so `?page=02` is never a\n * second address of `?page=2` in a cache in front of this API.\n */\nexport const COLLECTION_PAGE_PATTERN = /^(?:[1-9][0-9]?|100)$/\n\n/** Single entity query params. */\nexport const singleParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n fields: z.string().optional(),\n populate: z.string().max(MAX_POPULATE_LENGTH).optional(),\n /** The page a paged collection in the document's block trees shows. */\n page: z.string().regex(COLLECTION_PAGE_PATTERN).transform(Number).optional(),\n })\n .strict()\n\n/** Count endpoint query params. */\nexport const countParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n })\n .strict()\n\n/**\n * Parameter NAMES this surface accepts dynamically rather than by fixed key.\n *\n * `filter[<field>]` is a published parameter family, read by\n * `buildFilterConditions` straight off `url.searchParams` in a pass separate\n * from the zod parse. It must be partitioned out before a `.strict()` parse or\n * every filtered request would 400 — see {@link toParamBag}.\n *\n * **ONE pattern, exported, used by BOTH the partition and the extraction.** They\n * were two copies of the same literal, and they had already drifted from what an\n * entity field name can actually be: the class excluded DIGITS, so\n * `filter[heading2]` silently matched neither. Before D042 that meant the filter\n * was quietly ignored; with `.strict()` in front it would have become a 400 on a\n * request that used to work — a regression manufactured by two copies\n * disagreeing about the same rule. Sharing the pattern makes that\n * unrepresentable rather than merely fixed.\n *\n * The capture group is the inner field name. It is whitelisted by a LATER layer\n * (`isInternalField` plus the entity's own field map), which is the right\n * division of labour: this bounds the cache-key space by parameter NAME, that\n * bounds which columns a filter may touch. A `filter[...]` whose inner name does\n * not match — `filter[a.b]`, `filter[]` — is NOT dynamic, falls through to the\n * strict parse, and is refused, which is the whitelist direction D042 asks for.\n */\nexport const FILTER_PARAM_PATTERN = /^filter\\[([a-zA-Z0-9_]+)\\]$/\n\n/**\n * Flatten a URL's query string into the object the param schemas parse, with\n * dynamically-named parameters removed.\n *\n * **This exists because `.strict()` cannot be applied to the raw bag (D042).**\n * Every endpoint parses `Object.fromEntries(url.searchParams)`, which mixes the\n * fixed keys with the dynamic `filter[...]` ones; a naive `.strict()` therefore\n * rejects every filtered list request. Verified by live repro against the pinned\n * `zod@3.25.76` (R15), which also confirmed zod v3 has no pattern-matched key\n * support — `.catchall()` accepts ANY extra key, which would defeat the purpose.\n * So the partition happens here, before the parse.\n *\n * Known and unchanged by this: a repeated parameter (`?limit=1&limit=2`)\n * collapses to the last value, because `Object.fromEntries` over\n * `URLSearchParams.entries()` is last-write-wins. That is a property of the\n * flattening step, not of strictness.\n */\nexport function toParamBag(url: URL, opts?: { allowFilters?: boolean }): Record<string, string> {\n const bag: Record<string, string> = {}\n for (const [key, value] of url.searchParams.entries()) {\n // The exemption is SCOPED to the one endpoint that reads these keys.\n // Stripping them everywhere made `_menu?handle=main&filter[x]=y` silently\n // ignore the filter, which is the blacklist-shaped behaviour D042 exists to\n // replace — and it is precisely the endpoint whose responses are the most\n // cacheable, so an ignored-but-accepted parameter is a free cache-buster\n // there. On every other route a `filter[...]` is simply unknown, and\n // unknown means 400.\n if (opts?.allowFilters === true && FILTER_PARAM_PATTERN.test(key)) continue\n bag[key] = value\n }\n return bag\n}\n\n// Re-export from core so existing imports from this module continue to work\nexport { escapeLikePattern }\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\n/**\n * ISO-8601 calendar date or full timestamp.\n * Accepts: 2024-01-31, 2024-01-31T12:34:56Z, 2024-01-31T12:34:56.789+02:00.\n * Note: shape only — calendar validity (e.g. month 1–12, valid day-of-month)\n * is enforced separately by `isValidIsoDate` so impossible-date values cannot\n * reach Postgres and trigger a 500 cast error.\n */\nconst ISO_DATE_RE =\n /^\\d{4}-\\d{2}-\\d{2}(?:[Tt ]\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:[Zz]|[+-]\\d{2}:?\\d{2})?)?$/\n\n/**\n * Validate ISO-8601 shape AND calendar validity.\n * `Date.parse('2024-13-01')` returns NaN; `Date.parse('2024-02-30')` rolls over\n * to 2024-03-01 in some hosts but produces a different YYYY-MM-DD prefix from\n * the input — comparing the round-tripped date prefix catches both cases.\n */\nexport function isValidIsoDate(raw: string): boolean {\n if (!ISO_DATE_RE.test(raw)) return false\n const ms = Date.parse(raw)\n if (Number.isNaN(ms)) return false\n // For bare YYYY-MM-DD, confirm the parsed date round-trips to the same prefix.\n if (raw.length === 10) {\n return new Date(ms).toISOString().slice(0, 10) === raw\n }\n return true\n}\n\n/** True if `s` parses as a finite number with no trailing junk. */\nfunction isNumericLiteral(s: string): boolean {\n if (s.trim() === '') return false\n const n = Number(s)\n return Number.isFinite(n)\n}\n\n/** True if `id` matches the canonical UUID format used by entity primary keys. */\nexport function isUuid(id: string): boolean {\n return UUID_RE.test(id)\n}\n\n/**\n * Validate a filter value against the field type.\n * Returns the validated value (possibly coerced) or null if invalid.\n *\n * For `date`, accepts a `gte:` / `lte:` prefix; the prefix itself is preserved\n * so the caller can route to the right comparison operator. The bare value\n * (with prefix stripped) must match ISO-8601.\n */\nexport function validateFilterValue(fieldConfig: FieldConfig, raw: string): unknown {\n switch (fieldConfig.type) {\n case 'boolean':\n if (raw === 'true') return true\n if (raw === 'false') return false\n return null\n\n case 'reference':\n case 'media':\n return UUID_RE.test(raw) ? raw : null\n\n case 'select':\n return fieldConfig.options.includes(raw) ? raw : null\n\n case 'date': {\n const bare = raw.startsWith('gte:') || raw.startsWith('lte:') ? raw.slice(4) : raw\n return isValidIsoDate(bare) ? raw : null\n }\n\n case 'number':\n return isNumericLiteral(raw) ? Number(raw) : null\n\n case 'text':\n return raw.length <= MAX_FILTER_TEXT_LENGTH ? raw : null\n\n default:\n return null\n }\n}\n\n/**\n * True when a field is off-limits on this public surface — server-managed\n * (INTERNAL) or ADMIN-ONLY — and therefore absent from rows and `_schema` and\n * refused as a caller-supplied field name (filter, sort, cursor, `?fields=`).\n *\n * The single predicate behind every caller-input boundary in this package, and\n * the two rules `shapeDto` applies to internal fields, plus a third:\n * 1. **Naming convention** — a `_`-prefixed name (`_scopeId`, `_versionId`,\n * `_workflowStatus`, …), checked even when the field isn't declared, so\n * generator-added infrastructure columns are covered too.\n * 2. **Explicit flag** — `internal: true`, which catches fields that don't\n * follow the underscore convention.\n * 3. **Admin-only** — `adminOnly: true` (`auditable()`'s `createdBy` /\n * `updatedBy`). `shapeDto` strips these by a separate gate, so `QueryClient`\n * never returns them; this rule keeps them out of filter/sort too (#1141).\n *\n * Rule 2 matters independently of rule 1: without it a field declared\n * `internal: true` under a plain name is filterable and sortable by any\n * anonymous caller, which is a blind oracle over a server-managed value even\n * though the field never appears in the response body.\n *\n * Since D024 removed `ContentApiConfig.exposeInternalFields`, this predicate is\n * the WHOLE of the rule rather than half of it: an internal field is neither\n * emitted in the read projection nor reachable as a filter, sort, cursor or\n * `?fields=` name — in any configuration, because there is no configuration.\n * That is what lets `_schema` describe the read projection exactly.\n */\nexport function isInternalField(name: string, config: FieldConfig | undefined): boolean {\n // `adminOnly` (e.g. `auditable()`'s `createdBy`/`updatedBy`, #1141) is not\n // internal to the admin, but on THIS surface it is exactly as off-limits:\n // `QueryClient` already never emits it, and without this rule it would stay\n // filterable and sortable — a blind oracle over which user edited what.\n return name.startsWith('_') || config?.internal === true || config?.adminOnly === true\n}\n\n/**\n * Parse field selection string into validated field names.\n * Unknown/internal field names are silently dropped (no info leakage).\n *\n * `undefined` (= \"no projection, return every public field\") is returned ONLY\n * when the caller supplied no `fields` param at all. A caller who DID ask to\n * narrow but named nothing valid gets `['id']`, not `undefined` — otherwise the\n * most restrictive request possible (`?fields=_overrides`) would widen to the\n * full row, which is the opposite of what was asked for.\n */\nexport function parseFieldSelection(\n fieldsParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): string[] | undefined {\n if (!fieldsParam) return undefined\n\n const requested = fieldsParam.split(',').map((f) => f.trim())\n const valid = requested.filter((name) => {\n // `Object.hasOwn` (not a bare lookup) so prototype-chain keys\n // (`constructor`, `toString`, …) can never resolve to a \"config\".\n if (!Object.hasOwn(allFields, name)) return false\n const config = allFields[name]\n return config !== undefined && !isInternalField(name, config) && config.type !== 'blockTree'\n })\n\n // Always include id — including when nothing else survived, so a narrowing\n // request can never widen back to the full row (see JSDoc).\n if (!valid.includes('id')) {\n valid.unshift('id')\n }\n\n return valid\n}\n\n/** Outcome of parsing `?populate=` — accepted field names plus what was refused. */\nexport interface ParsedPopulate {\n /** Reference fields to expand. De-duplicated, declaration order not preserved. */\n fields: string[]\n /** Names refused by the whitelist — logged server-side at debug, never on the wire. */\n dropped: string[]\n /**\n * How many DISTINCT names the caller asked for, before the whitelist ran.\n * The ceiling is checked against what was ASKED, not against what survived:\n * counting survivors would let a caller name 50 fields and get a 200 as long\n * as 45 of them were typos, so the ceiling would refuse a correct request and\n * wave through a malformed one.\n */\n requested: number\n}\n\n/**\n * Parse `?populate=a,b` into the reference fields to expand (D013).\n *\n * **Whitelist, not blacklist, and it is the SAME whitelist every other\n * caller-supplied field name passes** — `Object.hasOwn` against the declared\n * fields (so prototype-chain keys cannot resolve to a \"config\"), then\n * {@link isInternalField}, then a type check. A name that does not exist, is\n * internal, or is not a `reference` field is DROPPED rather than refused: on\n * the wire that is indistinguishable from an entity that has no such field,\n * which is the same 404-not-403 reasoning the entity gate uses one level up. An\n * over-large field COUNT is a different thing and is refused loudly by the\n * handler — the server never explains a missing target, but it always rejects a\n * malformed request.\n *\n * Note `dropped` carries the refused names for the operator log ONLY. Echoing\n * them back would turn `?populate=` into a field-existence oracle over every\n * entity a caller can view.\n */\nexport function parsePopulate(\n populateParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): ParsedPopulate {\n if (!populateParam) return { fields: [], dropped: [], requested: 0 }\n\n const requested = [\n ...new Set(\n populateParam\n .split(',')\n .map((f) => f.trim())\n .filter((f) => f.length > 0),\n ),\n ]\n\n const fields: string[] = []\n const dropped: string[] = []\n for (const name of requested) {\n if (!Object.hasOwn(allFields, name)) {\n dropped.push(name)\n continue\n }\n const config = allFields[name]\n if (!config || isInternalField(name, config) || config.type !== 'reference') {\n dropped.push(name)\n continue\n }\n fields.push(name)\n }\n\n return { fields, dropped, requested: requested.length }\n}\n","/**\n * The per-document resolution budget (plan/api phase 05).\n *\n * Rendering ONE document can trigger four independent kinds of backend work:\n * `query()`-field resolutions, link resolutions, media resolutions and entity-\n * reference projections. Every one of them scales with the document's own\n * content — which an EDITOR controls, not an operator — and all four compete\n * for the same connection pool.\n *\n * **One budget, spanning all four. Not four caps.** A per-kind cap lets a\n * single document sum four separate ceilings into one much larger pool draw,\n * which is the same \"it's only N today\" defence CLAUDE.md's No-Unbounded-Fan-Out\n * section refuses. The kinds are recorded so a DROP can be attributed; they are\n * never separately bounded.\n *\n * **This is the COUNT half of the rule.** The concurrency half is\n * `@murumets-ee/core`'s `runBounded`, which caps how many of the granted\n * resolutions are in flight at once. Both are needed: a bound on peak\n * concurrency still lets an unbounded list through, just more slowly, and a\n * bound on count still lets a bounded list arrive as one burst.\n *\n * **Nothing here is silent.** Whatever the ceiling refuses is counted per kind\n * and reported by {@link ResolutionBudget.dropSummary}, which the handler logs.\n * CLAUDE.md is explicit that silent truncation reads as \"covered everything\" —\n * and phase 05's failure semantics make an unresolved target INDISTINGUISHABLE\n * from a deleted, unpublished or unviewable one ON THE WIRE, deliberately, so\n * an editor asking \"why is my related-article link gone?\" can only ever be\n * answered from the operator-facing log. That makes this log load-bearing\n * rather than nice-to-have.\n */\n\n/**\n * The kinds of per-document resolution work this budget spans.\n *\n * The union is DERIVED from the array, not declared beside it. Declared\n * separately, a fifth member added to the union but forgotten in the array\n * would still be accepted by `claim` and counted in `drops`, then silently\n * omitted from `dropSummary` — the exact silent truncation this module exists\n * to prevent, and the compiler would not have caught it, because a shorter\n * `readonly ResolutionKind[]` is a perfectly valid value of that type.\n */\nconst RESOLUTION_KINDS = ['reference', 'populate', 'media', 'block'] as const\n\nexport type ResolutionKind = (typeof RESOLUTION_KINDS)[number]\n\n/**\n * Absolute ceiling on the total resolutions ONE document may trigger, across\n * every kind. A host may lower it (see\n * `ContentApiConfig.maxResolutionsPerDocument`); nothing can raise it.\n *\n * 200 is calibrated against the shape of the work rather than against a\n * measurement. Be precise about that shape, because an authoritative-sounding\n * cost model that is wrong is worse than none:\n *\n * - A unit is at most one row in a BATCHED, keyed read — N references to the\n * same entity cost one query, not N.\n * - A REFERENCE unit additionally costs one routing-seam lookup, which in this\n * repo is a single-row `toolkit_paths` read per resolved row. So the\n * worst-case query count is roughly 2× the granted unit count, not 1×.\n * - `runBounded` holds peak concurrency at a small constant, so those lookups\n * arrive paced rather than as one burst.\n * - Every read on this path is on the read-only pool, which carries a\n * `statement_timeout` (PR 2) — including the storage lookup behind media,\n * which is why `StorageClient` keeps a separate read-only table client.\n *\n * A document needing more than 200 resolved targets to render is not a page, it\n * is an export — and the honest answer for one is a truncated render plus an\n * operator log, not an unbounded pool draw from an unauthenticated caller.\n */\nexport const MAX_RESOLUTIONS_PER_DOCUMENT = 200\n\n/**\n * A claim-based counter. Callers ask for `n` units BEFORE doing the work and\n * receive how many they may actually do.\n *\n * Claim-before, never report-after: a budget checked afterwards has already\n * paid for the overrun, which is a check that cannot fail in the direction it\n * is supposed to.\n */\nexport interface ResolutionBudget {\n /**\n * Claim up to `n` units for `kind`. Returns how many were GRANTED — `0`\n * through `n`. Anything short of `n` is recorded as a drop for that kind.\n *\n * A non-positive or non-integer `n` grants 0 and records nothing; callers\n * derive `n` from array lengths, so a defensive floor here beats a\n * `Math.max` at every call site.\n */\n claim(kind: ResolutionKind, n: number): number\n /** Units still available. */\n readonly remaining: number\n /** Units granted so far, across every kind. */\n readonly granted: number\n /** True when anything at all was refused — the log trigger. */\n readonly hasDrops: boolean\n /**\n * Per-kind refused counts, for the operator log. Only kinds that actually\n * lost something appear, so a log line names what was cut rather than\n * printing four zeroes.\n */\n dropSummary(): Partial<Record<ResolutionKind, number>>\n /** The ceiling this budget was built with — published in `_schema` (D008). */\n readonly ceiling: number\n}\n\n/**\n * Build a budget for ONE document's response.\n *\n * Per RESPONSE, not per process: the bound being enforced is \"what one request\n * costs\", and a process-lifetime counter would instead be a rate limit, which\n * is phase 09's (PR 6) and a different mechanism. Neither substitutes for the\n * other — the same split D007 made between `?offset=`'s ceiling and arrival\n * rate.\n */\nexport function createResolutionBudget(ceiling: number): ResolutionBudget {\n if (!Number.isInteger(ceiling) || ceiling < 0) {\n throw new Error(\n `content-api: resolution budget ceiling must be a non-negative integer, received ${String(ceiling)}.`,\n )\n }\n\n let remaining = ceiling\n let granted = 0\n const drops = new Map<ResolutionKind, number>()\n\n return {\n claim(kind: ResolutionKind, n: number): number {\n if (!Number.isInteger(n) || n <= 0) return 0\n const give = Math.min(n, remaining)\n remaining -= give\n granted += give\n const short = n - give\n if (short > 0) drops.set(kind, (drops.get(kind) ?? 0) + short)\n return give\n },\n get remaining(): number {\n return remaining\n },\n get granted(): number {\n return granted\n },\n get hasDrops(): boolean {\n return drops.size > 0\n },\n dropSummary(): Partial<Record<ResolutionKind, number>> {\n const out: Partial<Record<ResolutionKind, number>> = {}\n for (const kind of RESOLUTION_KINDS) {\n const count = drops.get(kind)\n if (count !== undefined && count > 0) out[kind] = count\n }\n return out\n },\n ceiling,\n }\n}\n\n/**\n * Refuse a host ceiling that would invert the bound rather than tighten it —\n * the same posture `assertPositiveCeiling` takes for `maxLimit`/`maxOffset`,\n * and for the same reason: a config mistake whose only symptom is \"resolution\n * quietly stopped working\" must be attributable to the config.\n *\n * `0` is ACCEPTED and coherent: it means \"resolve nothing on this deployment\",\n * which is a real posture (every reference, media field and block link comes\n * back unresolved, loudly logged) — unlike `maxLimit: 0`, which would mean\n * \"return nothing\" and only ever reads as an unset environment variable.\n */\nexport function resolveMaxResolutions(configured: number | undefined): number {\n if (configured === undefined) return MAX_RESOLUTIONS_PER_DOCUMENT\n if (!Number.isInteger(configured) || configured < 0) {\n throw new Error(\n 'content-api: `maxResolutionsPerDocument` must be an integer >= 0, received ' +\n `${String(configured)}. If this came from an environment variable, it is probably unset — ` +\n `omit the option to get the package default (${MAX_RESOLUTIONS_PER_DOCUMENT}).`,\n )\n }\n return Math.min(configured, MAX_RESOLUTIONS_PER_DOCUMENT)\n}\n","/**\n * `blockTree()` field resolution on the public surface (plan/api phase 05).\n *\n * A block tree is the one field type whose CONTENT can itself trigger backend\n * work: it embeds internal links (`LinkValue`s resolved through the routing\n * seam), `query()` fields (stored, field-author-bound queries executed against\n * an entity), and media refs. All three already have server-side resolvers in\n * `@murumets-ee/blocks`; what phase 05 adds is a caller and a bound.\n *\n * **The caller is a seam, not an import.** `content-api` is a leaf and `blocks`\n * depends back on it, so the resolver reaches this surface as\n * `ContentApiConfig.resolveBlocks`, exactly like `resolution`, `menus`,\n * `chrome` and `resolveMedia`.\n *\n * **The bound is the response's ONE budget, handed to the provider.** Block\n * content is not a fifth kind of work with a ceiling of its own: a document's\n * links and query fields compete for the same connection pool as its\n * references and media. Handing the provider the budget rather than a number\n * is what makes that true rather than merely intended — the provider claims\n * from the same counter the reference and media passes drew on, and whatever it\n * is refused lands in the same operator log.\n *\n * **The resolved wire FORMAT is now settled, and this is where it lives.**\n * Phase 05 deliberately refused to settle it, on the grounds that deciding a\n * format \"inside an implementation… is exactly how a wire format ends up being\n * whatever the first caller happened to emit\". That refusal was honoured: the\n * requirements were filed upstream (`plan/api` findings 069 + 075), `plan/api`\n * closed without scheduling them, and the format was then decided deliberately\n * — with a real consumer's requirements in hand — by `plan/frontend-delivery`\n * D019. It is published HERE, as a type, beside `MediaProjection` and the\n * reference projection, because this package owns the wire shapes.\n *\n * See {@link ResolvedBlockTree} for the format and the reasoning behind each\n * half of it.\n */\n\nimport type { Entity } from '@murumets-ee/entity'\nimport {\n blockTreeTemplateOf,\n blockTreeFieldsOf as sharedBlockTreeFieldsOf,\n} from '@murumets-ee/entity'\nimport type { CacheTagSink } from './cache-tags.js'\nimport { MAX_COLLECTION_PAGE } from './query-params.js'\nimport { createResolutionBudget, type ResolutionBudget } from './resolution-budget.js'\n\n/**\n * One ref a resolver was asked for and could not deliver.\n *\n * **This channel is the whole reason in-place media resolution is safe here**\n * (D019, R022 §3). When a resolved value REPLACES a stored ref, \"I tried and\n * failed\" has nowhere left to go — every surveyed CMS that resolves in place\n * either goes silent (Sanity, Strapi), overloads `null` and documents it as a\n * hazard (Directus: \"you must treat `null` as both a value and an indication of\n * restricted permissions\"), or ships a type that is wrong in one of the two\n * states (Payload). Contentful is the only one that escapes it, and it escapes\n * by adding a SECOND, independent channel. This is that channel.\n *\n * `ref` is the media id for `kind: 'media'` and the `linkValueKey` for\n * `kind: 'link'`. Neither discloses anything new: both are values the consumer\n * already holds, because they came out of the stored tree it is being served.\n */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n /**\n * `'unresolvable'` — the target does not exist, is unpublished for this\n * caller, is trashed, or is not public. ONE bucket, several causes,\n * deliberately: phase 05's failure semantics do not let an anonymous consumer\n * learn which.\n *\n * `'budget'` — this response ran out of resolution budget before reaching it.\n * A fact about the RESPONSE, not about the target, so naming it separately\n * discloses nothing and is the difference between \"retry later\" and \"stop\n * asking\".\n */\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/**\n * A `blockTree` field, resolved — the format `plan/frontend-delivery` D019\n * settled after `plan/api` closed with findings 069 and 075 still open.\n *\n * **The envelope's PRESENCE is the marker** (F075 requirement 3). A stored tree\n * is an array; a resolved one is this object. `Array.isArray(value)` tells them\n * apart without inspecting contents, which is what stops \"resolution is not\n * wired on this deployment\" from looking like valid data. `resolved` versions\n * the format so later additions (`queries`, when something can render them) are\n * additive rather than breaking.\n *\n * **Nothing emits an empty envelope, with ONE named exception.** With no\n * provider wired the stored tree crosses unchanged — see\n * {@link resolveBlockTrees}. That is deliberate and it is Storyblok's\n * documented wart avoided: its responses carry `rels: []` even when resolution\n * was never requested, so empty-because-nothing-to-resolve is indistinguishable\n * from empty-because-not-asked.\n *\n * The exception is D010's third state: a provider that failed AND then failed\n * its I/O-free retry is served {@link EMPTY_RESOLVED_BLOCK_TREE}, loudly\n * logged. That is narrow by construction — by the time it is reached the\n * provider has failed a pure in-memory walk, so it is broken rather than\n * unlucky — and it does not reintroduce Storyblok's ambiguity, because the\n * ambiguity there was between \"not asked\" and \"nothing found\". This envelope\n * means neither: it means \"asked twice, answered neither time\", and the\n * operator log says so in those words.\n *\n * **Links are a keyed sidecar; media is replaced in place. The split is not\n * arbitrary — it is what each side of the contract already agreed on.**\n *\n * A `LinkValue` is an opaque pointer with no href field at all, and every\n * renderer already looks its href up by `linkValueKey` (`ResolvedLinksContext`,\n * `resolveLinkHref(link, resolved)`, `RichTextRenderer`'s `resolvedLinks`\n * prop). `blocks`' own `editor-state` route already serialises exactly this\n * `Record<string, string | null>` for its wire. Writing the href INTO the link\n * would also fight the security path: `asLinkValue` returns \"a fresh, minimal\n * `LinkValue` (extraneous keys dropped)\" and is the richtext sanitiser's\n * normaliser, so the href would be stripped by the code whose job is stripping.\n *\n * A media ref is NOT an opaque pointer: it is a whole `MediaRef` carrying a url\n * denormalised at write time, which every render reads directly as\n * `image.url`. That url has to be overwritten regardless — it is minted by\n * `StorageClient.getUrl`, which returns a SIGNED url for any non-public file,\n * and freezing one into content puts a bearer capability on a cacheable\n * anonymous response. So the sidecar's headline advantage (\"the stored tree\n * crosses byte-identical\") is not on offer for media, while its cost — every\n * render in every project stops reading `image.url` and consults a lookup —\n * very much is.\n */\n/**\n * One row a resolved block tree DEPENDS ON — named so a cache holding this\n * response can be evicted when that row changes.\n *\n * `entity` is `'media'` for an image the tree embeds, and the target's entity\n * name for an internal link. That is deliberately the SAME shape\n * `CacheTagSink.addDocument(entity, id)` takes, because the point of the field\n * is that neither consumer has to walk the tree again to learn what is in it.\n *\n * 🔒 **RESOLVED ROWS ONLY.** A ref appears here only if the provider actually\n * resolved it, which means it passed that provider's publish + permission gate.\n * An unpublished link target is reported through `unresolved` (which carries a\n * `linkValueKey`, a value the consumer already holds) and NEVER here — listing\n * it would disclose the id of a document this caller may not see. The same\n * rule `ResolveBlocks`' docblock puts on hrefs, applied to identity.\n *\n * There is no cap on the array. Both consumers of it are already bounded — the\n * `Cache-Tag` header by `MAX_CACHE_TAGS_PER_RESPONSE`, a frontend's own tag set\n * by its own cap — and it is bounded upstream anyway by the resolution budget\n * the provider claimed against.\n */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\nexport interface ResolvedBlockTree {\n /** Format marker and version. Its presence means resolution RAN. */\n readonly resolved: 1\n /**\n * The block tree, with each media ref replaced by a `MediaProjection` — or\n * by `null` when it did not resolve.\n *\n * **Replaced, never merged.** Falling back to the stored url for an id that\n * failed would preserve the leak exactly, because \"did not resolve\" and \"is\n * not public\" are the same branch in the shipped resolver. `null` is already\n * what every render treats as \"no image\".\n */\n readonly blocks: readonly unknown[]\n /**\n * `linkValueKey → href`. `null` means \"no usable link\" and the renderer emits\n * the text without an `<a>` — the contract `resolveLinkHref` already has.\n */\n readonly links: Readonly<Record<string, string | null>>\n /**\n * What could not be resolved. **Omitted entirely when nothing failed**, so\n * its presence is itself information (Contentful's rule for `errors[]`).\n */\n readonly unresolved?: readonly UnresolvedBlockRef[]\n /**\n * The rows this tree resolved against — see {@link BlockTreeRef}. **Omitted\n * when the tree references nothing**, matching `unresolved`.\n *\n * WHY IT EXISTS. Until this field, a resolved block tree was opaque to every\n * cache: `_schema` published `blockTreeContents` as an `uncovered`\n * dependency, meaning re-publishing an image did not evict the pages\n * embedding it — they served the old one until their TTL expired. The\n * resolver already knew every id (it had just looked each one up); the\n * information simply had nowhere to go. It goes here, and BOTH consumers read\n * the one field rather than each re-walking the tree: `content-api` mints its\n * own `Cache-Tag` entries from it, and a frontend mints whatever its own\n * purge vocabulary is.\n *\n * `resolved: 1` versions the format precisely so an addition like this is\n * additive; a consumer that does not read `refs` is unaffected.\n */\n readonly refs?: readonly BlockTreeRef[]\n /**\n * Collection block id → the rows it resolved to (editor PR53, D001's dynamic\n * half). **Omitted when the tree holds no collection**, matching `unresolved`\n * and `refs`.\n *\n * A keyed SIDECAR, for the reason `links` is one: a render reads rows by the\n * block's id (`CollectionRowsContext`), and writing them into the block's\n * props would put resolved, per-reader data into a value that otherwise\n * crosses as the author stored it. Each row carries only the slots the app\n * mapped — never the row — and exists because it passed the SAME whitelist ∧\n * `view` gate as any other read on this API ({@link ResolveBlocksOptions}).\n * Row ids are listed in `refs`; the entity is tagged as a collection\n * dependency by `resolveBlockTrees`.\n */\n readonly collections?: Readonly<Record<string, ResolvedCollection>>\n /**\n * What this document's live-bound Price fields resolved to (block-library\n * PR03 + F012) — structural mirror of `@murumets-ee/blocks`' `LiveValues`,\n * minus `asked` (canvas-merge bookkeeping with no reader on a one-shot\n * render). **Omitted when the site registers no live sources at all** —\n * matching `unresolved`/`refs`/`collections`'s own rule, and letting the\n * `Price` primitive tell \"this deployment wires no live values\" (absent)\n * from \"wired, but this source doesn't exist\" (`sources` present and\n * missing the key) exactly the way the canvas already does.\n *\n * `sources` is load-bearing on the SITE, not just the canvas: the `Price`\n * primitive checks `live.sources[declaredSource]` before it will draw\n * ANYTHING for a bound price (`rich-field-primitives-media.tsx`), so a\n * `live` sidecar carrying only `values` would make every live price on a\n * delivered page render as nothing.\n */\n readonly live?: ResolvedLiveValues\n /**\n * The site's currency (ISO 4217), from `blocksPlugin({ currency })`\n * (block-library F012). Independent of `live` — a plain, typed-in Price\n * needs it to show a currency symbol regardless of whether this tree binds\n * any live price — so it is present whenever the site configures one,\n * **never omitted on the \"nothing to resolve\" test** `unresolved`/`refs`\n * follow, because there is always something to say: draw it plain, or draw\n * it in this currency.\n */\n readonly currency?: string\n /**\n * The row this tree is drawn for, projected through its entity's detail\n * declaration (block-library PR16) — `blocksPlugin({ details })`. A price, a\n * list of facts, an HTML body the server has already sanitised (with its\n * headings), a third-party viewer's allow-listed reference, and the text\n * that stands in for the row's title and summary. Absent unless the app\n * declares a detail for the entity and the row has a value for a declared\n * part. Only DECLARED columns are projected — never the row.\n */\n readonly detail?: ResolvedDetail\n}\n\n/** One labelled value of a row's detail — structural mirror of a collection row's meta line. */\nexport interface ResolvedDetailFact {\n readonly key: string\n readonly label: string\n readonly value: string | number\n readonly format?: 'price'\n}\n\n/** See {@link ResolvedBlockTree.detail}. Structural mirror of `@murumets-ee/blocks`' `DocumentDetail`. */\nexport interface ResolvedDetail {\n readonly title?: string\n readonly text?: string\n readonly price?: { readonly amount: number; readonly compareAt?: number; readonly unit?: string }\n readonly facts?: readonly ResolvedDetailFact[]\n readonly body?: {\n readonly html: string\n readonly headings: readonly {\n readonly id: string\n readonly text: string\n readonly level: 2 | 3\n }[]\n }\n readonly embed?: { readonly provider: string; readonly reference: string }\n /** The trail from the site's top to this page: ancestors (with a path), then the page itself. */\n readonly breadcrumbs?: readonly { readonly label: string; readonly href?: string }[]\n}\n\n/** Upper bounds on a `detail` sidecar — the producer's own caps, checked again here. */\nconst MAX_WIRE_DETAIL_FACTS = 12\nconst MAX_WIRE_DETAIL_HEADINGS = 100\nconst MAX_WIRE_DETAIL_HTML = 500_000\nconst MAX_WIRE_DETAIL_TEXT = 2_000\nconst MAX_WIRE_BREADCRUMBS = 8\nconst MAX_WIRE_BREADCRUMB_PATH = 1_000\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Is `value` a well-formed `detail` sidecar? Shape and bounds only: whether\n * `body.html` is SAFE is the producer's job (it is the only thing that\n * sanitises) and the consuming render's tripwire — this guard cannot\n * re-sanitise without importing the sanitiser into a package that must not\n * depend on `@murumets-ee/blocks`.\n */\nfunction isResolvedDetail(value: unknown): value is ResolvedDetail {\n if (!isPlainObject(value)) return false\n const optionalText = (v: unknown): boolean =>\n v === undefined || (typeof v === 'string' && v.length <= MAX_WIRE_DETAIL_TEXT)\n const optionalAmount = (v: unknown): boolean =>\n v === undefined || (typeof v === 'number' && Number.isFinite(v) && v >= 0)\n if (!optionalText(value.title) || !optionalText(value.text)) return false\n const price = value.price\n if (price !== undefined) {\n if (!isPlainObject(price)) return false\n if (typeof price.amount !== 'number' || !Number.isFinite(price.amount) || price.amount < 0) {\n return false\n }\n if (!optionalAmount(price.compareAt) || !optionalText(price.unit)) return false\n }\n const facts = value.facts\n if (facts !== undefined) {\n if (!Array.isArray(facts) || facts.length > MAX_WIRE_DETAIL_FACTS) return false\n for (const fact of facts as readonly unknown[]) {\n if (!isPlainObject(fact)) return false\n if (typeof fact.key !== 'string' || typeof fact.label !== 'string') return false\n if (typeof fact.value !== 'string' && typeof fact.value !== 'number') return false\n if (fact.format !== undefined && fact.format !== 'price') return false\n }\n }\n const body = value.body\n if (body !== undefined) {\n if (!isPlainObject(body) || typeof body.html !== 'string') return false\n if (body.html.length > MAX_WIRE_DETAIL_HTML) return false\n if (!Array.isArray(body.headings) || body.headings.length > MAX_WIRE_DETAIL_HEADINGS) {\n return false\n }\n for (const heading of body.headings as readonly unknown[]) {\n if (!isPlainObject(heading)) return false\n if (typeof heading.id !== 'string' || typeof heading.text !== 'string') return false\n if (heading.level !== 2 && heading.level !== 3) return false\n }\n }\n const embed = value.embed\n if (embed !== undefined) {\n if (!isPlainObject(embed)) return false\n if (typeof embed.provider !== 'string' || typeof embed.reference !== 'string') return false\n }\n const breadcrumbs = value.breadcrumbs\n if (breadcrumbs !== undefined) {\n if (!Array.isArray(breadcrumbs) || breadcrumbs.length > MAX_WIRE_BREADCRUMBS) return false\n for (const step of breadcrumbs as readonly unknown[]) {\n if (!isPlainObject(step) || typeof step.label !== 'string') return false\n if (step.label.length > MAX_WIRE_DETAIL_TEXT) return false\n // A same-site path only: a step's `href` is drawn as a link, so a scheme or\n // a protocol-relative `//host` here would be an open redirect on a public page.\n if (step.href !== undefined) {\n if (typeof step.href !== 'string' || step.href.length > MAX_WIRE_BREADCRUMB_PATH) {\n return false\n }\n if (!step.href.startsWith('/') || step.href.startsWith('//')) return false\n // No backslash, space or control character: a browser strips tab and\n // newline from a URL, so `/<TAB>/host` would read as `//host`.\n for (const char of step.href) {\n const code = char.codePointAt(0) ?? 0\n if (code <= 0x20 || code === 0x7f || char === '\\\\') return false\n }\n }\n }\n }\n return true\n}\n\n/**\n * One live source's PUBLIC metadata — structural mirror of\n * `@murumets-ee/blocks`' `LiveSourceMeta`.\n */\nexport interface ResolvedLiveSourceMeta {\n readonly label: string\n readonly labelKey?: string\n /** The library fields the site granted this source, as `'<block>.<field>'`. */\n readonly fields?: readonly string[]\n /** What the source's values are — absent means `'price'` (block-library PR18, PR21). */\n readonly kind?: 'price' | 'text' | 'list' | 'facts'\n /** A `'facts'` source's values, words already in the page's locale (block-library PR21). */\n readonly facts?: readonly ResolvedLiveFact[]\n}\n\n/** One fact a `'facts'` source declares — structural mirror of `@murumets-ee/blocks`' `LiveFact`. */\nexport interface ResolvedLiveFact {\n readonly name: string\n readonly label: string\n readonly format: 'price' | 'number' | 'text'\n readonly unit?: string\n}\n\n/** One resolved live value — structural mirror of `@murumets-ee/blocks`' `LiveValue`. */\nexport interface ResolvedLiveValue {\n /**\n * Optional to MIRROR `@murumets-ee/blocks`' own `LiveValue` exactly (itself\n * `Pick<PriceValue, 'amount'|'compareAt'|'unit'>`), not because the wire\n * ever actually carries one without it: `readLiveAnswer` already drops any\n * answer with no amount before it reaches `LiveValues.values` at all. A\n * narrower (`amount: number`) type here would make the mirror a lie\n * `tsc` catches the moment a consumer assigns a real `LiveValues` to it.\n */\n readonly amount?: number\n readonly compareAt?: number\n readonly unit?: string\n /**\n * A live TEXT (block-library PR18) — one line of plain text a render draws\n * inside authored text. Text only: a consumer escapes it like any text and\n * never puts it in markup, a URL or an attribute.\n */\n readonly text?: string\n /** A live LIST (block-library PR18) — a few lines of plain text. */\n readonly items?: readonly string[]\n /**\n * Live FACTS (block-library PR21) — named numbers or one-line texts. Values\n * only: a consumer draws them as text, never as markup, a URL or an attribute.\n */\n readonly facts?: Readonly<Record<string, number | string>>\n}\n\n/** What a document's live-bound Price fields resolved to. See {@link ResolvedBlockTree.live}. */\nexport interface ResolvedLiveValues {\n readonly sources: Readonly<Record<string, ResolvedLiveSourceMeta>>\n readonly values: Readonly<Record<string, Readonly<Record<string, ResolvedLiveValue>>>>\n}\n\n/**\n * Upper bounds on a `live` sidecar, checked by the guard — generous against\n * the producer's own caps (`MAX_LIVE_SOURCES_PER_DOCUMENT` = 8,\n * `MAX_LIVE_KEYS_PER_SOURCE` = 50 in `@murumets-ee/blocks`, not imported here:\n * this package is the leaf the blocks package depends ON), matching\n * `MAX_WIRE_COLLECTIONS`'s own \"reject a MALFORMED provider, don't\n * second-guess a policy this package does not own\" reasoning.\n */\nconst MAX_WIRE_LIVE_SOURCES = 32\nconst MAX_WIRE_LIVE_KEYS_PER_SOURCE = 200\n/**\n * A live text or list on the wire — generous against the producer's own bounds\n * (`MAX_LIVE_TEXT_LENGTH` = 200, `MAX_LIVE_LIST_ITEMS` = 25,\n * `MAX_LIVE_LIST_ITEM_LENGTH` = 300), for the same reason as the two above.\n * Counted in UTF-16 units, so a 300-character line of astral characters still\n * fits.\n */\nconst MAX_WIRE_LIVE_TEXT_LENGTH = 1200\nconst MAX_WIRE_LIVE_LIST_ITEMS = 100\nconst MAX_WIRE_LIVE_FIELDS = 64\n/** Facts per source / per item — generous against `MAX_LIVE_FACTS_PER_SOURCE` = 12. */\nconst MAX_WIRE_LIVE_FACTS = 48\nconst WIRE_FACT_FORMATS: ReadonlySet<unknown> = new Set(['price', 'number', 'text'])\n\nfunction isWireLiveFact(value: unknown): boolean {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const f = value as Record<string, unknown>\n return (\n isWireLiveLine(f.name) &&\n isWireLiveLine(f.label) &&\n WIRE_FACT_FORMATS.has(f.format) &&\n (f.unit === undefined || isWireLiveLine(f.unit))\n )\n}\n\n/** A string a live text or list item may be: bounded, and one line. */\nfunction isWireLiveLine(value: unknown): value is string {\n if (typeof value !== 'string' || value.length > MAX_WIRE_LIVE_TEXT_LENGTH) return false\n // No line break or other C0 control: the producer strips them, so one here\n // means the provider is not the producer this guard was written against.\n for (let i = 0; i < value.length; i++) if (value.charCodeAt(i) < 0x20) return false\n return true\n}\n\nfunction isResolvedLiveValues(value: unknown): value is ResolvedLiveValues {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const { sources, values } = value as Record<string, unknown>\n if (typeof sources !== 'object' || sources === null || Array.isArray(sources)) return false\n if (typeof values !== 'object' || values === null || Array.isArray(values)) return false\n const sourceEntries = Object.entries(sources as Record<string, unknown>)\n if (sourceEntries.length > MAX_WIRE_LIVE_SOURCES) return false\n for (const [, meta] of sourceEntries) {\n if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return false\n const m = meta as Record<string, unknown>\n if (typeof m.label !== 'string') return false\n if (m.labelKey !== undefined && typeof m.labelKey !== 'string') return false\n if (\n m.kind !== undefined &&\n m.kind !== 'price' &&\n m.kind !== 'text' &&\n m.kind !== 'list' &&\n m.kind !== 'facts'\n ) {\n return false\n }\n if (m.facts !== undefined) {\n if (!Array.isArray(m.facts) || m.facts.length > MAX_WIRE_LIVE_FACTS) return false\n if (!(m.facts as readonly unknown[]).every(isWireLiveFact)) return false\n }\n if (m.fields !== undefined) {\n if (!Array.isArray(m.fields) || m.fields.length > MAX_WIRE_LIVE_FIELDS) return false\n if (!(m.fields as readonly unknown[]).every((f) => typeof f === 'string')) return false\n }\n }\n const valueEntries = Object.entries(values as Record<string, unknown>)\n if (valueEntries.length > MAX_WIRE_LIVE_SOURCES) return false\n for (const [, bySource] of valueEntries) {\n if (typeof bySource !== 'object' || bySource === null || Array.isArray(bySource)) return false\n const items = Object.entries(bySource as Record<string, unknown>)\n if (items.length > MAX_WIRE_LIVE_KEYS_PER_SOURCE) return false\n for (const [, item] of items) {\n if (typeof item !== 'object' || item === null || Array.isArray(item)) return false\n const v = item as Record<string, unknown>\n // `Number.isFinite`, not `typeof === 'number'`: NaN/Infinity pass a bare\n // typeof check and would reach the wire as a \"valid\" price.\n if (v.amount !== undefined && (typeof v.amount !== 'number' || !Number.isFinite(v.amount)))\n return false\n if (\n v.compareAt !== undefined &&\n (typeof v.compareAt !== 'number' || !Number.isFinite(v.compareAt))\n )\n return false\n if (v.unit !== undefined && typeof v.unit !== 'string') return false\n if (v.text !== undefined && !isWireLiveLine(v.text)) return false\n if (v.items !== undefined) {\n if (!Array.isArray(v.items) || v.items.length > MAX_WIRE_LIVE_LIST_ITEMS) return false\n if (!(v.items as readonly unknown[]).every(isWireLiveLine)) return false\n }\n if (v.facts !== undefined) {\n if (typeof v.facts !== 'object' || v.facts === null || Array.isArray(v.facts)) return false\n const facts = Object.entries(v.facts as Record<string, unknown>)\n if (facts.length > MAX_WIRE_LIVE_FACTS) return false\n for (const [, fact] of facts) {\n const ok = (typeof fact === 'number' && Number.isFinite(fact)) || isWireLiveLine(fact)\n if (!ok) return false\n }\n }\n }\n }\n return true\n}\n\n/** One resolved collection block — the structural mirror of `@murumets-ee/blocks`' `CollectionResult`. */\nexport interface ResolvedCollection {\n readonly status: 'ok' | 'unset' | 'unavailable' | 'over-budget' | 'needs-document'\n readonly entity: string | null\n readonly rows: readonly ResolvedCollectionRow[]\n /** A PAGED collection's position (davatoidud-toolkit PR03) — absent otherwise. */\n readonly page?: { readonly number: number; readonly hasNext: boolean } | undefined\n}\n\n/** One row, projected onto a card's slots — never the entity row itself. */\nexport interface ResolvedCollectionRow {\n readonly id: string\n readonly title: string | null\n readonly text: string | null\n readonly image: {\n readonly url: string\n readonly alt: string | null\n readonly width: number | null\n readonly height: number | null\n } | null\n readonly href: string | null\n}\n\n/**\n * Upper bounds on a `collections` sidecar, checked by the guard. Generous\n * against the producer's own caps (six collections of at most twelve rows), so\n * the guard rejects a MALFORMED provider rather than second-guessing a policy it\n * does not own.\n */\nconst MAX_WIRE_COLLECTIONS = 64\nconst MAX_WIRE_COLLECTION_ROWS = 100\nconst COLLECTION_STATUSES: ReadonlySet<string> = new Set([\n 'ok',\n 'unset',\n 'unavailable',\n 'over-budget',\n // davatoidud-toolkit PR03: a filter that reads the page's document, on a\n // page drawn for none (or for another entity). Names its entity, no rows.\n 'needs-document',\n])\n\nfunction isResolvedCollection(value: unknown): value is ResolvedCollection {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const entry = value as Record<string, unknown>\n if (typeof entry.status !== 'string' || !COLLECTION_STATUSES.has(entry.status)) return false\n // An empty name would mint a malformed `lumi:` cache tag — the refs guard\n // above refuses `''` for the same reason.\n if (entry.entity !== null && (typeof entry.entity !== 'string' || entry.entity === '')) {\n return false\n }\n if (!Array.isArray(entry.rows) || entry.rows.length > MAX_WIRE_COLLECTION_ROWS) return false\n if (entry.page !== undefined) {\n if (typeof entry.page !== 'object' || entry.page === null || Array.isArray(entry.page)) {\n return false\n }\n const page = entry.page as Record<string, unknown>\n if (\n typeof page.number !== 'number' ||\n !Number.isInteger(page.number) ||\n page.number < 1 ||\n page.number > MAX_COLLECTION_PAGE ||\n typeof page.hasNext !== 'boolean'\n ) {\n return false\n }\n }\n // Status-specific invariants, which the producer keeps and a host-injected\n // provider might not: an `ok` result names its entity (or its rows reach the\n // wire with no cache tag, and a new matching row leaves the page stale); any\n // other status carries no rows; `unset` and `unavailable` name no entity.\n if (entry.status === 'ok' && entry.entity === null) return false\n if (entry.status !== 'ok' && entry.rows.length > 0) return false\n if ((entry.status === 'unset' || entry.status === 'unavailable') && entry.entity !== null) {\n return false\n }\n for (const row of entry.rows as readonly unknown[]) {\n if (typeof row !== 'object' || row === null || Array.isArray(row)) return false\n const r = row as Record<string, unknown>\n if (typeof r.id !== 'string' || r.id === '') return false\n for (const slot of [r.title, r.text, r.href]) {\n if (slot !== null && typeof slot !== 'string') return false\n }\n if (r.image !== null) {\n if (typeof r.image !== 'object' || Array.isArray(r.image)) return false\n // Every member the wire type declares, not just the url: an `alt: 7`\n // would otherwise cross under a type that promises text.\n const image = r.image as Record<string, unknown>\n if (typeof image.url !== 'string') return false\n if (image.alt !== null && typeof image.alt !== 'string') return false\n for (const size of [image.width, image.height]) {\n if (size !== null && typeof size !== 'number') return false\n }\n }\n }\n return true\n}\n\n/**\n * Host-injected block-tree resolver.\n *\n * Receives the STORED tree and returns the {@link ResolvedBlockTree} that\n * should cross the wire in its place. Called once per `blockTree` field per\n * row, and once per `_chrome` region.\n *\n * **SECURITY (CLAUDE.md #1).** Every read the provider performs must be\n * PUBLISHED-only and must honour the caller's permissions — a `query()` field\n * resolved through an `AdminClient` would surface drafts on the anonymous\n * surface, and an internal link resolved without a publish filter would leak an\n * unpublished document's URL. `content-api` cannot enforce either (the resolver\n * is injected), so the WIRING must guarantee both — the same posture, for the\n * same reason, as `ContentApiResolution.resolvePath`.\n *\n * `budget` is the response's ONE resolution budget. Claim BEFORE doing work,\n * and resolve only what was granted; whatever is refused is reported by the\n * handler's existing drop log.\n */\nexport type ResolveBlocks = (\n blocks: unknown,\n opts: ResolveBlocksOptions,\n) => Promise<ResolvedBlockTree>\n\n/**\n * How many COLLECTION blocks one response may resolve, across every document in\n * it (editor PR53).\n *\n * The per-document cap (`MAX_COLLECTIONS_PER_DOCUMENT`, six) bounds a page. It\n * does not bound a LIST response: a hundred rows carrying six collections each\n * is six hundred row queries on an anonymous endpoint, and the shared\n * resolution budget — which collections also claim from — would admit its\n * whole ceiling of them. Twelve covers a document's own tree, and a chrome\n * response's header and footer at six each. A list's collection work becomes\n * a constant rather than a multiple of its page size.\n *\n * ⚠️ It CAN bind a single-document response, and fails closed when it does:\n * `?populate=` targets resolve their trees BEFORE the root row's (references\n * project first) and share this allowance, as does an entity with more than\n * two `blockTree` fields. The collections past it resolve `over-budget` and\n * render empty, and `reportBudget` logs the refusal — a truncated page, never\n * an unbounded one.\n */\nexport const MAX_COLLECTIONS_PER_RESPONSE = 12\n\n/** A per-response collection counter. Both members are detachable closures. */\nexport interface CollectionAllowance {\n /** Take one slot; `false` once the ceiling is spent. */\n readonly claim: () => boolean\n /** How many claims were refused — reported by the handler, never silent. */\n readonly refusedCount: () => number\n}\n\nexport function createCollectionAllowance(ceiling: number): CollectionAllowance {\n if (!Number.isInteger(ceiling) || ceiling < 0) {\n throw new Error(\n `content-api: collection allowance must be a non-negative integer, received ${String(ceiling)}.`,\n )\n }\n let remaining = ceiling\n let refused = 0\n return {\n claim: () => {\n if (remaining === 0) {\n refused += 1\n return false\n }\n remaining -= 1\n return true\n },\n refusedCount: () => refused,\n }\n}\n\n/** What a {@link ResolveBlocks} provider is called with. */\nexport interface ResolveBlocksOptions {\n locale?: string | undefined\n budget: ResolutionBudget\n entity: string\n field: string\n /**\n * May THIS caller read rows of `entityName`? The whitelist ∧ `view` gate the\n * handler applies to every entity read, handed to the provider so a\n * COLLECTION block (editor PR53) cannot list an entity this API withholds.\n *\n * Optional, and absence means NO: a provider that is not told who may read\n * what must resolve no collection rows at all, rather than guess.\n */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /**\n * Take one slot from THIS response's collection allowance\n * ({@link MAX_COLLECTIONS_PER_RESPONSE}). Absent means NO, like\n * `canReadEntity`: a provider not told how many collections the response may\n * resolve resolves none.\n */\n claimCollection?: (() => boolean) | undefined\n /**\n * Set when the field is a CODED TEMPLATE — `field.blockTree({ template })`\n * (editor D017) — and names the template's root block.\n *\n * A templated document has no stored tree: its layout is rebuilt from the\n * block registry on every load, and its content lives in entity COLUMNS the\n * template's blocks BIND. So the provider is handed the template name instead\n * of a tree, builds the tree itself (this package cannot — it has no idea\n * what a block looks like), and serves it as the envelope's `blocks`.\n *\n * Absent for an ordinary tree, and the provider then resolves `blocks` as it\n * always has.\n */\n template?: string | undefined\n /**\n * The row this document's BOUND blocks read (frontend-delivery F109) — handed\n * over for every tree, and always when {@link template} is set.\n *\n * A bound rich-text column's link marks live HERE, not in any tree, so the\n * envelope's `links` sidecar never carried them — an internal link in a\n * recipe's body rendered as plain text. The provider resolves the links in\n * exactly the columns the template binds, in the SAME call and against the\n * SAME budget as the tree's own, so a document costs one batch per linked\n * entity type however many columns carry links — never one request per link.\n *\n * Read-only: the provider must not write to it. The row is the response being\n * built, and its columns cross the wire as the handler already projected them.\n */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n /**\n * The tree is a STORED DETAIL TEMPLATE drawn for `boundValues` (block-library\n * D009), not that row's own document. The provider must then project the\n * row's detail and NOTHING else of it: in particular it must not walk the\n * row's columns for links on the say-so of a bound block in the tree, since\n * the tree is editor-authored and the row is not the editor's to expose.\n *\n * ⚠️ A contract the provider has to honour: this package cannot enforce it.\n * `@murumets-ee/content`'s resolver does; a host-supplied one must too.\n */\n rowIsForeign?: boolean | undefined\n /**\n * The page a PAGED collection in this tree shows (davatoidud-toolkit PR03) —\n * the request's `?page=`, already validated (`singleParamsSchema`). Absent\n * for page 1, and on every list read: a page number belongs to ONE document.\n */\n page?: number | undefined\n}\n\n/**\n * Is `value` a {@link ResolvedBlockTree}?\n *\n * A structural check on a host-injected return value, not a cast. It checks the\n * marker, the tree and the sidecar — and deliberately does NOT walk `blocks`\n * (that is the document's own content, of unbounded size, and validating it\n * here would duplicate the consumer-side validator that already exists for the\n * unresolved case).\n *\n * **The sidecar's VALUES are checked, and the unbounded-content argument above\n * does not excuse skipping them.** `links` is bounded by the link count the\n * provider already claimed against the budget, so walking it costs what the\n * response has already paid for. Without this a provider returning a nested\n * object or a number where an href belongs passes the guard and the value is\n * serialised straight onto the wire. The Astro decoder defends itself with its\n * own check, so this is defence in depth rather than a live hole — but it is\n * the one field this package can cheaply own, and owning it here means a\n * consumer that is NOT ours inherits the guarantee too.\n *\n * The same argument covers `unresolved`, which is bounded by the same claim.\n *\n * TYPE only, deliberately — not scheme policy. A host may legitimately emit any\n * scheme its own consumers accept, and `sanitizeHref` at the consumer's process\n * boundary is where that decision belongs. Two places deciding it is how they\n * come to disagree.\n */\nexport function isResolvedBlockTree(value: unknown): value is ResolvedBlockTree {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const candidate = value as Record<string, unknown>\n if (candidate.resolved !== 1) return false\n if (!Array.isArray(candidate.blocks)) return false\n const links = candidate.links\n if (typeof links !== 'object' || links === null || Array.isArray(links)) return false\n for (const href of Object.values(links as Record<string, unknown>)) {\n if (href !== null && typeof href !== 'string') return false\n }\n // `refs` gets the same treatment for the same reason, and it has one more:\n // its values are handed straight to a cache-tag minter, so a non-string\n // reaching that would be serialised into a header — or, on the frontend\n // side, into a purge key that can never match anything.\n // Collected as they are validated, so the `collections` check below can\n // require that every row it lists is named here too.\n const refKeys = new Set<string>()\n const refs = candidate.refs\n if (refs !== undefined) {\n if (!Array.isArray(refs)) return false\n for (const ref of refs as unknown[]) {\n // `Array.isArray` is not decoration: `typeof [] === 'object'` and an\n // array can carry `entity`/`id` as properties, so without it a\n // `[]`-with-attached-props passes here — and then JSON.stringify drops\n // those properties, putting `[]` on the wire. This end would have minted\n // `lumi:media:<id>` from the in-memory object while the consumer's own\n // `isRecord` check discarded the entry, so the two ends would silently\n // disagree about what the page references. `isUnresolvedBlockRef` two\n // functions down already guards it; this was the odd one out.\n if (typeof ref !== 'object' || ref === null || Array.isArray(ref)) return false\n const entry = ref as Record<string, unknown>\n if (typeof entry.entity !== 'string' || entry.entity === '') return false\n if (typeof entry.id !== 'string' || entry.id === '') return false\n refKeys.add(`${entry.entity}\\u0000${entry.id}`)\n }\n }\n // `unresolved` is bounded by the SAME claim `links` is, so the argument that\n // justified checking one justifies checking the other — leaving it out was an\n // inconsistency, not a policy. Unchecked, a provider returning\n // `unresolved: 'oops'` passed the guard and was serialised under a field the\n // published type declares as `readonly UnresolvedBlockRef[]`.\n // `collections` (editor PR53) — every entry checked, for the reason `refs`\n // is: its values are serialised straight onto the wire, and a slot that is not\n // a string would put something that is not text where a card renders text.\n const collections = candidate.collections\n if (collections !== undefined) {\n if (typeof collections !== 'object' || collections === null || Array.isArray(collections)) {\n return false\n }\n const entries = Object.entries(collections as Record<string, unknown>)\n if (entries.length > MAX_WIRE_COLLECTIONS) return false\n for (const [blockId, entry] of entries) {\n // Keyed by block id; an empty key is a result no block can find, so the\n // collection it was meant for would render empty in silence.\n if (blockId === '') return false\n if (!isResolvedCollection(entry)) return false\n // Every listed row must also be a REF. The collection tag covers \"a row\n // was added or removed\", not \"this row changed\" — so a row the envelope\n // lists without naming it in `refs` mints no document tag, and editing it\n // leaves the cached page as it was (CodeRabbit review, PR53).\n if (entry.status !== 'ok' || entry.entity === null) continue\n for (const row of entry.rows) {\n if (!refKeys.has(`${entry.entity}\\u0000${row.id}`)) return false\n }\n }\n }\n const unresolved = candidate.unresolved\n if (unresolved !== undefined) {\n if (!Array.isArray(unresolved)) return false\n for (const entry of unresolved as readonly unknown[]) {\n if (!isUnresolvedBlockRef(entry)) return false\n }\n }\n // block-library F012 — same reasoning as `collections`: a malformed `live`\n // sidecar would otherwise serialise a value where a `Price` primitive\n // expects `amount: number`.\n if (candidate.live !== undefined && !isResolvedLiveValues(candidate.live)) return false\n if (candidate.currency !== undefined && typeof candidate.currency !== 'string') return false\n // block-library PR16 — same reasoning: a malformed `detail` would put a\n // value of the wrong shape where a render expects a number or a string.\n if (candidate.detail !== undefined && !isResolvedDetail(candidate.detail)) return false\n return true\n}\n\nfunction isUnresolvedBlockRef(value: unknown): value is UnresolvedBlockRef {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false\n const entry = value as Record<string, unknown>\n if (entry.kind !== 'media' && entry.kind !== 'link') return false\n if (typeof entry.ref !== 'string') return false\n return entry.reason === 'unresolvable' || entry.reason === 'budget'\n}\n\n/**\n * The declared, non-internal columns of an entity that hold a BLOCK TREE.\n *\n * The public API must never project an internal column, so this narrows\n * `@murumets-ee/entity`'s shared lookup rather than re-deriving it — the\n * `internal` flag rides on each ref for exactly this caller.\n *\n * ⚠️ **Both kinds, deliberately.** Since `field.json({ blockTreeShape: true })`\n * existed this also returns those columns, so a JSON column holding a block\n * tree is block-reference-resolved on a public read where before it crossed\n * untouched. That is the wanted direction and not an accident of the shared\n * lookup: the resolver is what replaces author-time media URLs, and\n * `resolveBlockTrees`' own comment calls skipping it a security hole rather\n * than a saving. Chrome is exactly the content most likely to carry a logo.\n *\n * It is bounded by the same `ResolutionBudget` as every other field, and\n * `/v1/_chrome` is unaffected — that path goes through\n * `@murumets-ee/content`'s chrome provider, not this one.\n *\n * If a future column should be validated as a tree but NOT resolved, filter on\n * `ref.canvas` here; the discriminator exists for that.\n */\nexport function blockTreeFieldsOf(entity: Entity): string[] {\n return sharedBlockTreeFieldsOf(entity)\n .filter((ref) => !ref.internal)\n .map((ref) => ref.name)\n}\n\nexport interface BlockResolutionDeps {\n budget: ResolutionBudget\n locale?: string | undefined\n resolveBlocks?: ResolveBlocks | undefined\n /** Forwarded to the provider — see {@link ResolveBlocksOptions.canReadEntity}. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /**\n * ONE per response, shared by every tree it resolves — see\n * {@link MAX_COLLECTIONS_PER_RESPONSE}. Its `claim` is what the provider\n * receives as {@link ResolveBlocksOptions.claimCollection}.\n */\n collectionAllowance?: CollectionAllowance | undefined\n /** Forwarded to the provider — see {@link ResolveBlocksOptions.page}. */\n page?: number | undefined\n /**\n * Where a resolved tree's {@link BlockTreeRef}s are recorded. Optional so a\n * caller resolving trees outside a request (a test, a build step) need not\n * invent one; when it is absent the refs still cross on the envelope.\n */\n cacheTags?: CacheTagSink | undefined\n logger?:\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined\n}\n\n/**\n * The envelope served when a provider failed twice — D010's THIRD state.\n *\n * Reachable only after the I/O-free retry below has ALSO failed, which means\n * the provider could not complete a pure in-memory walk. That is a broken\n * provider rather than an unlucky one, and blanking a region is the\n * security-correct direction for a component that cannot be trusted to have\n * stripped anything.\n *\n * It carries no `unresolved` and no `refs`, and neither omission is laziness:\n * `ref` is defined as a real media id or `linkValueKey`, and enumerating those\n * means walking the tree — the exact work this package cannot do, by its own\n * comment below (\"it has no idea what a block looks like\"). A synthetic entry\n * would lie about a published contract, and an empty `unresolved: []` would\n * claim nothing failed. Saying nothing is the only honest option here; the\n * operator log carries what the wire cannot.\n *\n * FROZEN, and shared across responses on purpose. Every consumer of it either\n * serialises it or copies out of it (`handler.ts` rebuilds its region field by\n * field), so one value is safe — and freezing means a future consumer that\n * tries to mutate it fails loudly in a test rather than corrupting the next\n * response.\n */\nexport const EMPTY_RESOLVED_BLOCK_TREE: ResolvedBlockTree = Object.freeze({\n resolved: 1,\n blocks: Object.freeze([]),\n links: Object.freeze({}),\n})\n\n/** How one `resolveBlocks` call failed. */\nexport type BlockResolutionFailure = 'threw' | 'malformed'\n\n/**\n * What one field's (or one chrome region's) resolution ended up being, after\n * D010's retry policy ran.\n *\n * `envelope` is ALWAYS an envelope and never the stored tree — that is the\n * whole of F056. The two failure fields are for the operator log, and their\n * combination is what distinguishes the three outcomes:\n *\n * | `failure` | `retryFailure` | what happened |\n * |-----------|----------------|------------------------------------------------|\n * | absent | absent | resolved first time |\n * | set | absent | failed, then the I/O-free retry stripped it |\n * | set | set | failed twice — `envelope` is the empty one |\n */\nexport interface BlockResolutionOutcome {\n /** The envelope to serve. Never the stored tree. */\n readonly envelope: ResolvedBlockTree\n /** How the FIRST call failed. Absent when it succeeded. */\n readonly failure?: BlockResolutionFailure\n /** How the RETRY failed. Absent unless `envelope` is the empty one. */\n readonly retryFailure?: BlockResolutionFailure\n /** The first thrown value, for the operator log. */\n readonly error?: unknown\n}\n\n/**\n * An EXPLICIT boolean discriminant, not a presence check.\n *\n * `'envelope' in first` walks the prototype chain, so `Object.prototype.envelope`\n * pollution makes the failure variant read as a success and hands the polluted\n * value straight to `row[field]`, past `isResolvedBlockTree` — the one guard\n * between a host-injected value and the wire. `ok` is always an OWN property of\n * the literals below, so the prototype is never consulted. (`Object.hasOwn`\n * would be equally safe at runtime but narrows nothing, which is how this was\n * written as an `in` check in the first place.)\n */\ntype Attempt =\n | { readonly ok: true; readonly envelope: ResolvedBlockTree }\n | { readonly ok: false; readonly failure: BlockResolutionFailure; readonly error?: unknown }\n\nasync function attemptResolve(\n resolve: ResolveBlocks,\n stored: unknown,\n opts: ResolveBlocksOptions,\n): Promise<Attempt> {\n try {\n // Each attempt gets its OWN deep copy of the row. The provider is\n // host-injected: a nested write (a doc column's `content` array) through a\n // shallow copy would reach the row this handler serves, and the retry after\n // a failed first attempt would read what that attempt left behind. A DB row\n // is plain data, so the clone cannot throw on it; if it ever did, it throws\n // inside this `try` and degrades like any provider failure.\n const attemptOpts =\n opts.boundValues === undefined\n ? opts\n : { ...opts, boundValues: structuredClone(opts.boundValues) }\n const envelope = await resolve(stored, attemptOpts)\n // Shape-checked, not trusted. The provider is host-injected, so its return\n // value is not this package's data — a malformed one must be treated\n // exactly like a throw, and never put a shape on the wire that `_schema`\n // does not describe.\n if (!isResolvedBlockTree(envelope)) return { ok: false, failure: 'malformed' }\n return { ok: true, envelope }\n } catch (error) {\n return { ok: false, failure: 'threw', error }\n }\n}\n\n/**\n * Resolve ONE stored block tree into an envelope — never into the stored tree\n * (D010, closing F056).\n *\n * 🔴 **WHY A SECOND CALL RATHER THAN A FALLBACK.** Both failure branches used\n * to leave the STORED tree in place, on the reasoning that a broken link\n * resolver must degrade to unlinked text rather than to a 500 on every page\n * that has content. That reasoning is still right; the fallback it chose was\n * not. The stored tree is the value carrying the author-time media urls\n * `frontend-delivery` D019 exists to replace — signed urls, on a deployment\n * whose media is private, on a cacheable anonymous response. The comment four\n * frames down calls skipping the resolver on a SPENT budget \"a SECURITY hole,\n * not a saving\" for exactly that reason, and a throw does not make the served\n * bytes any safer than a skip did.\n *\n * The fix is not for this package to build a safe envelope: it cannot. `ref` is\n * a real media id or `linkValueKey`, and enumerating them means walking a tree\n * this package has no vocabulary for. So the work goes back to the component\n * that HAS the vocabulary — the provider — under a budget that makes the call\n * safe to repeat.\n *\n * **The retry is I/O-free, and that is a checked property of the shipped\n * provider rather than a hope** (R034). `resolveBlockTree` gates every read\n * behind a grant: `coveredMedia`/`coveredTargets` are empty slices at a zero\n * grant, `resolveMediaBatch` and `resolveEntityPaths` both return early on an\n * empty input, and the remaining `resolveLinks` pass is pure map lookups. What\n * still runs is the walk that REPLACES every media ref with `null` — which is\n * precisely the outcome wanted.\n *\n * ⚠️ **Be honest about WHOSE failures the middle state actually rescues.** The\n * obvious causes — a saturated read pool, a dropped connection, a throwing\n * media resolver — are exactly the ones the SHIPPED provider never surfaces\n * here: `resolveMediaBatch` and `resolveEntityPaths` each catch their own I/O\n * and return an empty result. What reaches this `catch` from\n * `@murumets-ee/content` is a DETERMINISTIC throw (a non-array stored column;\n * a failure constructing the app), which by definition recurs on the retry. So\n * with the shipped wiring the practical path is failure → blank, and the\n * middle state is for a THIRD-PARTY provider whose throws are conditional on\n * having work to do. That does not weaken the argument for retrying — the\n * retry costs one in-memory walk and is the difference between blanking a\n * field and blanking it while ALSO having tried — but a reader should not\n * expect \"images gone, page intact\" to be the common outcome on this\n * deployment. It is the uncommon one.\n *\n * **A FRESH zero-ceiling budget, not the request's.** `createResolutionBudget`\n * documents `0` as coherent (\"resolve nothing on this deployment\"). A fresh one\n * cannot draw down the response's real ceiling, which is what keeps this\n * bounded — and it keeps the retry's own refusals out of the response's\n * `dropSummary`, where they would read as \"a ceiling refused this\" and send an\n * operator to raise a limit that was never involved. `block-resolver.ts` fixed\n * exactly that mis-attribution once already for its no-media-resolver path.\n *\n * ⚠️ **That saving has a cost, and it lands on the WIRE.** The shipped provider\n * derives `UnresolvedBlockRef.reason` from its own grant, so on the retry every\n * LINK comes back `reason: 'budget'`, as does every media ref on a deployment\n * that wires a media resolver (it skips the claim entirely when it does not,\n * so those stay `'unresolvable'`) — while `dropSummary` shows no block drops\n * at all. The two ends of the contract then disagree, and `'budget'`\n * published to a consumer means \"retry later\", which is advice about a\n * response and not about a resolver that is broken. `'unresolvable'` would be\n * worse (it is a claim about the TARGET, and false), so this is the lesser of\n * two wrong answers rather than a right one. Reaching a right one means the\n * provider distinguishing a zero-CEILING budget from an exhausted shared one,\n * which is a change to a published enum's meaning and belongs in its own PR.\n *\n * **Bounded**, per CLAUDE.md's no-unbounded-fan-out rule: at most ONE extra\n * call per failing field, in memory, only on a path that has already failed.\n * Not a retry loop, and not a schedule.\n */\nexport async function resolveOneBlockTree(\n resolve: ResolveBlocks,\n stored: unknown,\n opts: ResolveBlocksOptions,\n): Promise<BlockResolutionOutcome> {\n const first = await attemptResolve(resolve, stored, opts)\n if (first.ok) return { envelope: first.envelope }\n\n const retry = await attemptResolve(resolve, stored, {\n ...opts,\n budget: createResolutionBudget(0),\n // The I/O-free retry lists nothing, so it must not spend the response's\n // collection slots on blocks that were never going to read.\n claimCollection: undefined,\n })\n if (retry.ok) {\n return { envelope: retry.envelope, failure: first.failure, error: first.error }\n }\n return {\n envelope: EMPTY_RESOLVED_BLOCK_TREE,\n failure: first.failure,\n retryFailure: retry.failure,\n error: first.error ?? retry.error,\n }\n}\n\n/**\n * Resolve every `blockTree` field on `rows`, IN PLACE.\n *\n * With no provider wired the stored tree crosses UNCHANGED — deliberately, and\n * unlike media. A media uuid is unusable to any consumer, so leaving it would\n * publish a value nobody can act on; a block tree is the document's actual\n * content and renders fine without its links resolved (an unresolved link is\n * what the in-process renderers already emit as plain text). Nulling it would\n * delete the page to avoid an unresolved href.\n *\n * 🔴 **That is the ONLY path on which the stored tree still crosses.** When a\n * provider IS wired and fails, the field is served an envelope — see\n * {@link resolveOneBlockTree} for why a second, I/O-free call is what produces\n * it. The distinction is load-bearing: \"no resolution on this deployment\" is a\n * deployment fact a consumer must be able to read off the wire shape, while \"a\n * resolver that is wired and broke\" must not be allowed to publish author-time\n * media urls just because it broke.\n *\n * **SERIAL over rows, deliberately — do not \"fix\" this into a `runBounded`\n * fan-out.** Each provider call claims from the shared budget as it works, so\n * running rows concurrently would let several of them claim past the ceiling\n * before any of them observed it, and which rows ended up truncated would\n * depend on scheduling. Serial makes exhaustion deterministic: rows resolve in\n * order until the budget runs out, and the remainder cross unresolved. The cost\n * is latency on a large list page, which is bounded by the same ceiling.\n */\nexport async function resolveBlockTrees(\n entity: Entity,\n rows: Record<string, unknown>[],\n deps: BlockResolutionDeps,\n): Promise<void> {\n if (rows.length === 0) return\n const resolve = deps.resolveBlocks\n if (!resolve) return\n const fields = blockTreeFieldsOf(entity)\n if (fields.length === 0) return\n\n // Counted here and reported ONCE at the end, not logged per field.\n //\n // The loop below is `rows × fields`, and `MAX_LIMIT` is 100 — while a\n // provider's failure modes (link index down, read pool saturated) are\n // persistent rather than transient, so they fail for EVERY row. A `warn` at\n // each one is 200 lines with a serialized error per request on an\n // unauthenticated endpoint whose only bound is arrival rate: an operator log\n // amplifier, which is the shape CLAUDE.md's no-unbounded-fan-out section is\n // about even when the resource is stdout rather than the pool.\n let threw = 0\n let malformed = 0\n let blanked = 0\n // TWO latches, one per warn that carries an `err`, and the pair is the point.\n //\n // A single latch was wrong twice over. Captured only under `threw`, a field\n // that returned a non-envelope first and THREW on the retry discarded the\n // only Error the request produced, and the blanked warn logged\n // `err: undefined`. Made unconditional, that same Error latches into the\n // THREW warn — whose `fields` list does not name that field, because it went\n // into `malformedFields`. An operator would then be handed an error\n // belonging to a field the line does not mention, in the log this file calls\n // the only place a degrade survives at all. The chrome twin has no such\n // problem because it logs per region rather than aggregating.\n let firstThrewError: unknown\n let firstBlankedError: unknown\n // The FAILING field names, not the entity's whole blockTree list. An entity\n // with two such fields where one provider call fails would otherwise name\n // both, sending an operator to inspect a field that resolved perfectly — in\n // the one log documented as the only place a degrade survives at all.\n const threwFields = new Set<string>()\n const malformedFields = new Set<string>()\n const blankedFields = new Set<string>()\n for (const row of rows) {\n for (const field of fields) {\n const stored = row[field]\n // A CODED TEMPLATE is resolved whatever is stored — and what is stored is\n // normally `null`, because the layout is code and the column is never\n // written. Skipping it on `null` (as an ordinary tree is skipped) is what\n // served a templated document with no layout and no link sidecar, so the\n // bound columns' links rendered as plain text (F109). The template wins\n // over a stray stored value for the same reason the canvas's does: the\n // layout is the registry's, not the row's.\n //\n // ⚠️ Only when the row CARRIES the key — a guard against adding a field to\n // a response that did not ask for it. Today `?fields=` cannot select a\n // `blockTree` column at all (`query-params.ts`), so this is what keeps a\n // future projection honest; an unprojected row carries the never-written\n // column as `null`, which is the case the template branch exists for.\n const template = blockTreeTemplateOf(entity, field)\n if (template !== undefined && !Object.hasOwn(row, field)) continue\n if (template === undefined && (stored === null || stored === undefined)) continue\n // Nothing is claimed HERE, and the provider is called even when the\n // budget is SPENT. Both halves matter.\n //\n // The provider claims per unit of work it actually performs, because only\n // it knows how many links and media refs the tree contains — a flat 1 per\n // field would be a ceiling that does not bound what it names.\n //\n // 🔴 And skipping the call on an exhausted budget was a SECURITY hole,\n // not a saving. An exhausted budget is the ordinary case on a list page\n // (references and media claim first, blocks resolve last), and skipping\n // meant the STORED tree crossed — carrying the author-time media urls\n // that `frontend-delivery` D019 exists to replace, on a cacheable\n // anonymous response. The provider handed a spent budget resolves nothing\n // and still strips every ref it could not resolve, which is exactly the\n // outcome wanted. What it costs is a pure in-memory walk per field.\n const outcome = await resolveOneBlockTree(resolve, template === undefined ? stored : [], {\n locale: deps.locale,\n budget: deps.budget,\n entity: entity.name,\n field,\n // A snapshot of the row, taken before this field is overwritten below:\n // the provider reads the bound columns and must see them as the handler\n // projected them, not as this loop leaves them. `attemptResolve` deep-\n // copies it again per attempt, so nothing a provider writes — nested\n // or not — reaches the served row or the retry. Handed over for\n // EVERY tree, not only a template's: a bound block can sit in an ordinary\n // composable tree too, and its column's links must resolve there as well.\n ...(template !== undefined && { template }),\n boundValues: { ...row },\n ...(deps.canReadEntity !== undefined && { canReadEntity: deps.canReadEntity }),\n ...(deps.collectionAllowance !== undefined && {\n claimCollection: deps.collectionAllowance.claim,\n }),\n ...(deps.page !== undefined && { page: deps.page }),\n })\n if (outcome.failure === 'threw') {\n threw += 1\n threwFields.add(field)\n firstThrewError ??= outcome.error\n } else if (outcome.failure === 'malformed') {\n malformed += 1\n malformedFields.add(field)\n }\n if (outcome.retryFailure !== undefined) {\n blanked += 1\n blankedFields.add(field)\n // Whichever attempt threw — the first or the retry — this field is one\n // of the ones `blankedFields` names, so the attribution holds.\n firstBlankedError ??= outcome.error\n // This response is now known to be missing content, so no cache may\n // keep it. See `CacheTagSink.markUncacheable` for why a STRIPPED\n // response deliberately stays storable and only a BLANKED one does not.\n //\n // ⚠️ **This moves availability in the wrong direction, and that is a\n // deliberate trade rather than an oversight.** A provider failure is\n // persistent, not transient, so \"blanked\" tends to mean EVERY document\n // — and every one of those responses then bypasses the CDN, putting\n // full uncached traffic on an origin whose resolver is already broken.\n // The alternative is worse and is the reason this exists: a cached\n // blank outlives the fix by a full TTL and nothing can purge it early,\n // because a blanked envelope carries no `refs`.\n //\n // It is also the SAME trade `apps/front-astro` already makes for a\n // `_chrome` 404 — an equally persistent failure, since that is the\n // shape of a missing `site_chrome:view` grant — and that precedent was\n // taken deliberately, in writing. Bounding it (a circuit breaker, a\n // \"blank at most N% before serving stale\") is a policy that would have\n // to cover both, so it is filed rather than invented here.\n deps.cacheTags?.markUncacheable()\n }\n // Assigned unconditionally, and that assignment IS the fix for F056. The\n // two failure branches used to `continue`, leaving `row[field]` as the\n // stored array.\n row[field] = outcome.envelope\n // The reason `blockTreeContents` is no longer an `uncovered`\n // dependency. Recorded from the ENVELOPE rather than by re-walking the\n // tree: the provider already resolved each of these ids against its own\n // publish gate, so this names exactly what the body actually contains.\n if (outcome.envelope.refs !== undefined) {\n for (const ref of outcome.envelope.refs) deps.cacheTags?.addDocument(ref.entity, ref.id)\n }\n // A COLLECTION depends on WHICH rows of its entity exist, not only on the\n // rows it happened to list — publishing a new recipe must evict a page\n // listing \"the latest three\". Row ids already arrived through `refs`.\n if (outcome.envelope.collections !== undefined) {\n for (const result of Object.values(outcome.envelope.collections)) {\n if (result.entity !== null) deps.cacheTags?.addCollection(result.entity)\n }\n }\n }\n }\n\n // WARN, not debug, and it says what the degrade cost.\n //\n // What it no longer says is that the response carried author-time urls,\n // because it no longer does: the retry re-walked the tree with a spent budget\n // and every media ref came back `null`. That is a CONTENT degrade — images\n // gone, links plain — which an operator still has to be able to see, and a\n // `debug` line cannot tell them. Neither can 200 identical warns.\n if (threw > 0) {\n deps.logger?.warn(\n {\n entity: entity.name,\n fields: [...threwFields],\n rows: rows.length,\n failures: threw,\n err: firstThrewError,\n },\n 'content-api: block resolver threw — no tree is served as stored; each was re-resolved ' +\n 'with an exhausted budget, so its media refs and link hrefs are stripped rather than ' +\n 'carrying the urls recorded when an editor picked them. Fields that failed that retry ' +\n 'as well are reported separately, as empty.',\n )\n }\n if (malformed > 0) {\n deps.logger?.warn(\n { entity: entity.name, fields: [...malformedFields], rows: rows.length, failures: malformed },\n 'content-api: block resolver returned a value that is not a ResolvedBlockTree — no tree ' +\n 'is served as stored; each was re-resolved with an exhausted budget, so its media refs ' +\n 'and link hrefs are stripped rather than carrying the urls recorded when an editor ' +\n 'picked them. Fields that failed that retry as well are reported separately, as empty.',\n )\n }\n // The THIRD state, and the loudest, because it is the one that costs a\n // visitor something they can see. Separate from the two above rather than\n // folded into them: those say \"the resolver is failing\", this says \"and the\n // page is now missing content\", which is a different call to action.\n if (blanked > 0) {\n deps.logger?.warn(\n {\n entity: entity.name,\n fields: [...blankedFields],\n rows: rows.length,\n failures: blanked,\n err: firstBlankedError,\n },\n 'content-api: block resolver failed TWICE, including on the in-memory retry — those ' +\n 'fields are served as an empty resolved tree, so that content is missing from the ' +\n 'response. Serving the stored tree instead would put the media urls recorded when an ' +\n 'editor picked them, which may be signed, on a cacheable anonymous response.',\n )\n }\n}\n","/**\n * Cache-tag emission — the header only, never a purge protocol (D055, plan/api\n * PR 11, resolves F73).\n *\n * **What this is for.** R05 surveyed how headless CMSes invalidate a CDN and\n * found one convergent pattern: stamp each cached response with tags naming the\n * documents that composed it, then purge by tag on write. R05 also recorded the\n * asymmetry that makes the two halves separable — **emitting the header costs\n * nothing at request time and needs no purge infrastructure to be useful, while\n * deferring it means a later migration touches every cached response.** This\n * module is the emission half. The purge half stays out for the reason phase 10\n * gave and SD006 left standing: `resolveQueryField` records a cache dependency\n * only under `renderContext === 'production-build'`, so a request-path purge\n * would have no dependency graph to consult.\n *\n * **Emitting a tag nobody purges on changes nothing about S6.** Until a\n * consumer wires purge-on-write, TTL expiry is still the entire reconciliation\n * mechanism between a shared cache and S6. What ships here is the thing that\n * makes that wiring possible later without a response-format change.\n *\n * # The format (D055)\n *\n * Two kinds, both namespaced, told apart by SEGMENT COUNT:\n *\n * | Kind | Shape | Means |\n * |---|---|---|\n * | Document | `lumi:<entity>:<id>` | this row's CONTENT is in the body |\n * | Collection | `lumi:<entity>` | this response depends on WHICH rows exist |\n *\n * **Both kinds are needed and a document tag alone is a silent failure.** A\n * per-document tag cannot invalidate a list when a BRAND-NEW row is created,\n * because no cached response ever carried that row's tag. Drupal answers this\n * with a first-class `node_list` tag; Vercel documents `cacheTag('products',\n * 'product-${id}')` as the recommended hierarchy; Stellate names the failure\n * verbatim. See R16.\n *\n * **Segment count rather than a suffix**, because Drupal's `node_list` shape\n * would collide with an entity genuinely named `article_list` — a legal entity\n * name here.\n *\n * **The `lumi:` prefix is not decoration.** The named consumer is an\n * application, not a CDN: `plan/frontend-delivery`'s Astro BFF reads these and\n * hands them to `Astro.cache.set({ tags })`, which merges them into ONE flat\n * tag space alongside the app's own tags and Astro's `astro-path:` tags. Four\n * bytes makes a collision structurally impossible.\n *\n * # The disclosure invariant — why raw ids are defensible here\n *\n * Akamai ships a knob to hide its tag header from clients, because \"cache tags\n * can include back-end information, or at least allow a malicious user to\n * decipher back-end information from them\". That is a real argument and it is\n * answered structurally rather than waved away:\n *\n * > **Every id a tag names is already visible to that same caller, in that same\n * > response, or was supplied by that caller in the request.**\n *\n * Row ids are in the body (`data.id`); resolved reference and media ids are in\n * the body (`row[field].id`); `_chrome`'s document id and `_alternates`' id came\n * from the caller's own query string; `_menu`'s handle is in the URL. The header\n * therefore discloses nothing the body does not — asserted in the tests, not\n * assumed. {@link CacheTagsConfig.enabled} exists anyway for an operator who\n * wants no ids in a header regardless; the invariant is why the DEFAULT can be\n * on, not a reason to skip the switch.\n *\n * # Bounds\n *\n * Emitted to the STRICT INTERSECTION of every CDN surveyed in R16, always,\n * whichever one is actually configured — so switching vendors can never\n * invalidate tags already emitted. THREE caps, and the third is not implied by\n * the other two (128 × 128 plus separators overshoots every vendor's header\n * limit — see {@link MAX_CACHE_TAG_HEADER_BYTES}):\n *\n * 1. {@link MAX_CACHE_TAG_LENGTH} characters per tag — Akamai's, the tightest.\n * 2. {@link MAX_CACHE_TAGS_PER_RESPONSE} tags — Akamai's and Vercel's.\n * 3. {@link MAX_CACHE_TAG_HEADER_BYTES} bytes for the assembled field, so the\n * whole response-header block stays inside a default proxy buffer.\n *\n * Plus the charset: no spaces, no commas, printable ASCII from an allowlist.\n *\n * A tag that fails the charset or the length cap is **DROPPED, never truncated\n * and never escaped**: a malformed tag is one a purge silently misses, which is\n * strictly worse than a tag that was never there. A tag that does not fit the\n * count or byte budget is dropped from the TAIL, so what survives is always the\n * first N added — which is what makes insertion order a degradation strategy\n * rather than an accident.\n */\n\n/**\n * The namespace every tag this package emits begins with.\n *\n * Lowercase and fixed. Cloudflare matches tags case-INSENSITIVELY while Akamai\n * matches case-sensitively, so a namespace whose case varied would mean\n * different things on two CDNs.\n */\nexport const CACHE_TAG_NAMESPACE = 'lumi'\n\n/**\n * Akamai's documented per-tag ceiling — \"If your tag is longer than 128\n * characters, the associated content won't be purged\" — and the tightest of the\n * four surveyed (Fastly 1024 bytes, Cloudflare 1024 chars, Vercel 256).\n */\nexport const MAX_CACHE_TAG_LENGTH = 128\n\n/**\n * Akamai's documented per-object ceiling (\"The maximum number of tags per\n * cached object is 128. Any additional tags are ignored\"), which Vercel matches\n * exactly.\n */\nexport const MAX_CACHE_TAGS_PER_RESPONSE = 128\n\n/**\n * The ceiling on the emitted header FIELD — `name: value` — in bytes. A THIRD\n * bound, which the count and length caps do not imply.\n *\n * 128 tags of 128 characters plus 127 separators is 16,511 bytes, which is 127\n * PAST Fastly's documented 16,384-byte header cap — where the documented\n * behaviour is to ignore \"the key currently being parsed and all keys following\n * it\", i.e. exactly the silent purge-miss this module refuses everywhere else.\n * Two caps that each looked safe multiplied into one that was not.\n *\n * **Why 3072, and why the first draft's 8192 was the wrong number for the very\n * reasons it cited.** That draft justified 8192 as \"Akamai's documented overall\n * maximum response-header size\" and as fitting \"the 8k `proxy_buffer_size` a\n * default nginx will forward\". Both numbers govern the WHOLE header block, not\n * one field — so capping a single field AT the block maximum guarantees the\n * block is exceeded whenever the cap binds. And nginx's default is documented\n * as \"equal to one memory page… 4K or 8K depending on the platform\", which is\n * 4096 on x86-64 Linux, and that one buffer must hold the entire block.\n *\n * So the budget is sized against a **4096-byte block**, leaving roughly a\n * kilobyte for the status line, `Content-Type`, `Cache-Control`, `Vary`, up to\n * four `Access-Control-*` headers and whatever the runtime adds. An intermediary\n * that refuses an oversized header turns a 200 into a 502 — an availability\n * failure caused by a caching hint, on a public surface, by default, which is\n * the direction CLAUDE.md's \"availability is a security property\" rules out.\n *\n * **It binds before the 128-tag cap on a large page, deliberately.** At ~49\n * bytes per tag (`lumi:article:` plus a uuid) this admits roughly 60 documents.\n * A default `?limit=20` page fits with room to spare; a caller who asks for the\n * `?limit=100` maximum will see tags dropped and logged. That is the right\n * trade because the degradation is SAFE: the collection tag is added first and\n * covers every row on the page, so a purge still invalidates the response — it\n * is precision that is lost, not correctness. Vercel's documented advice for\n * exactly this situation is to carry a coarser tag rather than enumerate.\n */\nexport const MAX_CACHE_TAG_HEADER_BYTES = 3072\n\n/**\n * The entity name media tags are emitted under.\n *\n * `content-api` is a leaf and never imports `@murumets-ee/media`, so the media\n * entity's name is not structurally available here. `media` is not a guess: it\n * is the permission RESOURCE this package already names in `media.ts`, whose\n * resolver \"THROWS when the caller's role lacks `media:view`\". The tag\n * namespace follows the permission resource, which is the name a purge caller\n * would reach for.\n */\nexport const MEDIA_TAG_ENTITY = 'media'\n\n/**\n * Characters a tag SEGMENT may contain — an ALLOWLIST, per CLAUDE.md's\n * deny-by-default rule.\n *\n * Excludes the space (Cloudflare forbids it outright; Fastly uses it as the key\n * SEPARATOR) and the comma (Cloudflare's and Akamai's separator).\n *\n * **And excludes `:`, which the first draft admitted.** That draft reasoned a\n * left-anchored parse stays unambiguous even if an id contained one — true for\n * the ID, and irrelevant, because the hazard is the ENTITY segment:\n * `addCollection('article:x')` would emit `lumi:article:x`, byte-identical to\n * the DOCUMENT tag for entity `article`, id `x`. One kind forging the other.\n *\n * That is not caller-reachable today — entity names come from the\n * `config.entities` whitelist and `chrome.entity`, both operator-controlled —\n * but it falsified D055's own discriminator, which is that the two kinds are\n * told apart by SEGMENT COUNT. A count only discriminates if a segment cannot\n * contain the delimiter. Excluding it makes the stated property true by\n * construction instead of true by nobody having named an entity that way.\n *\n * Nothing legitimate loses: entity names are identifiers, row ids are uuids,\n * and menu handles are identifiers. None can contain a colon.\n */\nconst CACHE_TAG_CHARSET = /^[A-Za-z0-9_.-]+$/\n\n/**\n * What fills the SITE slot of a tag for a row that belongs to no site — a\n * shared entity (media) on an app serving several sites (plan `multisite`\n * D008): `lumi:_:media:<id>`.\n *\n * A slot rather than an omission, for the reason {@link CACHE_TAG_CHARSET}\n * gives: the two tag kinds are told apart by SEGMENT COUNT. Were shared rows\n * to skip the site segment, a shared DOCUMENT tag (`lumi:media:<id>`) and a\n * site COLLECTION tag (`lumi:kulli:page`) would both have three segments. With\n * the slot always present, a multi-site deployment's document tags have four\n * and its collection tags three.\n *\n * `_` cannot be a site: a handle starts with a letter.\n */\nexport const SHARED_SITE_TAG_SEGMENT = '_'\n\n/**\n * Host configuration for cache-tag emission. Every field is optional; omitting\n * the whole object emits `Cache-Tag`, comma-separated.\n */\nexport interface CacheTagsConfig {\n /**\n * Emit the header at all. Defaults to `true`.\n *\n * Default-on is free for the reason D010 already established — no\n * `content-api` consumer is live, so there is nothing deployed to surprise —\n * and it is the point of R05's asymmetry argument: a header nobody reads yet\n * still costs nothing, while adding it later is a contract change across\n * every cached response.\n *\n * Set `false` for a deployment that does not want document ids in a response\n * header regardless of the disclosure invariant. See the module docblock.\n */\n enabled?: boolean\n /**\n * The header name. Defaults to `Cache-Tag`.\n *\n * `Cache-Tag` is Cloudflare's native name, is accepted bare by Netlify, and\n * is trivially parseable by the BFF that is the actual named consumer. A\n * Fastly deployment sets `Surrogate-Key` and `separator: ' '`; an Akamai one\n * sets `Edge-Cache-Tag`.\n *\n * Emitting SEVERAL vendor headers at once is deliberately not offered: R16\n * could find no product anywhere doing it, and it multiplies the disclosure\n * surface for a hedge nobody needs.\n */\n header?: string\n /**\n * What joins the tags. Defaults to `,`.\n *\n * Cloudflare and Akamai are comma-separated; Fastly is SPACE-separated. Only\n * these two are accepted, because they are the only two any surveyed vendor\n * parses — a third would produce one giant unparseable key rather than an\n * error.\n */\n separator?: ',' | ' '\n}\n\n/** {@link CacheTagsConfig} with every default resolved. */\nexport interface ResolvedCacheTagsConfig {\n readonly enabled: boolean\n readonly header: string\n readonly separator: ',' | ' '\n}\n\nconst DEFAULT_CACHE_TAGS: ResolvedCacheTagsConfig = {\n enabled: true,\n header: 'Cache-Tag',\n separator: ',',\n}\n\n/**\n * RFC 9110 §5.1 field-name grammar (`token`). A header name outside it cannot\n * be set on a `Response` at all — `new Headers()` throws — so validating at\n * construction converts a per-request throw with no CORS headers and no log\n * into one loud boot failure, exactly as `assertCacheConfig` does for the\n * window.\n */\nconst HEADER_NAME_TOKEN = /^[!#$%&'*+\\-.^_`|~0-9A-Za-z]+$/\n\n/**\n * Headers this package already writes on the responses the tag header joins.\n *\n * A token-valid name is not enough. The tag header is merged into the same map\n * as these, so `header: 'Cache-Control'` would silently REPLACE the directive on\n * every storable response — turning `public, max-age=60, …` into a tag list —\n * and `header: 'Vary'` would be silently discarded by the `Vary` written after\n * it. Both are operator error rather than caller-reachable, and both fail\n * SILENTLY at runtime, which is precisely the shape this module throws at\n * construction for everywhere else.\n */\nconst RESERVED_HEADER_NAMES = ['cache-control', 'vary', 'content-type']\n\n/**\n * Header FAMILIES this package writes on the same responses, denied by PREFIX.\n *\n * `buildCorsHeaders` contributes `Access-Control-Allow-Origin` (and, on the\n * preflight, `-Allow-Methods` / `-Allow-Headers` / `-Max-Age`), and the tag\n * header is spread AFTER those in `buildResponseHeaders`. So\n * `header: 'Access-Control-Allow-Origin'` — a perfectly valid RFC 9110 token —\n * would pass the name check and then silently REMOVE the CORS grant from every\n * cached success, at which point a browser blocks a response the server\n * considered a 200.\n *\n * A prefix rather than four literals, so a CORS header added later is covered\n * without anyone remembering to extend a list. Deny-by-default, per CLAUDE.md.\n */\nconst RESERVED_HEADER_PREFIXES = ['access-control-']\n\n/**\n * Validate the host's cache-tag configuration AT CONSTRUCTION.\n *\n * Same posture as {@link assertCacheConfig}: every host ceiling in this package\n * is checked when the handler is built, because a value that only throws\n * per-request produces a partial outage that is undetectable at boot.\n */\nexport function assertCacheTagsConfig(config: CacheTagsConfig | undefined): void {\n if (!config) return\n if (config.enabled !== undefined && typeof config.enabled !== 'boolean') {\n throw new Error(\n `content-api cache: \\`cache.tags.enabled\\` must be a boolean, received ${JSON.stringify(config.enabled)}.`,\n )\n }\n if (config.header !== undefined) {\n if (typeof config.header !== 'string' || !HEADER_NAME_TOKEN.test(config.header)) {\n throw new Error(\n `content-api cache: \\`cache.tags.header\\` must be a valid HTTP field name (RFC 9110 §5.1 token), received ${JSON.stringify(config.header)}. ` +\n 'Common values: `Cache-Tag` (Cloudflare/Netlify, the default), `Surrogate-Key` (Fastly), `Edge-Cache-Tag` (Akamai).',\n )\n }\n const lowered = config.header.toLowerCase()\n if (\n RESERVED_HEADER_NAMES.includes(lowered) ||\n RESERVED_HEADER_PREFIXES.some((prefix) => lowered.startsWith(prefix))\n ) {\n throw new Error(\n `content-api cache: \\`cache.tags.header\\` may not be ${JSON.stringify(config.header)} — this package writes that header itself ` +\n 'on the same responses, so the tags would either overwrite it or be overwritten, silently and on every request.',\n )\n }\n }\n if (config.separator !== undefined && config.separator !== ',' && config.separator !== ' ') {\n throw new Error(\n `content-api cache: \\`cache.tags.separator\\` must be ',' or ' ', received ${JSON.stringify(config.separator)}. ` +\n 'Cloudflare and Akamai parse comma-separated tags; Fastly parses space-separated ones. A third separator ' +\n 'would not error at the CDN — it would parse as one giant unmatched key, which is the silent failure this refuses.',\n )\n }\n}\n\n/** Apply {@link DEFAULT_CACHE_TAGS} to whatever the host declared. */\nexport function resolveCacheTagsConfig(\n config: CacheTagsConfig | undefined,\n): ResolvedCacheTagsConfig {\n return {\n enabled: config?.enabled ?? DEFAULT_CACHE_TAGS.enabled,\n header: config?.header ?? DEFAULT_CACHE_TAGS.header,\n separator: config?.separator ?? DEFAULT_CACHE_TAGS.separator,\n }\n}\n\n/**\n * The write half of a {@link CacheTagSet} — what the resolution layer receives.\n *\n * Narrowed deliberately: `references.ts` and `media.ts` may CONTRIBUTE ids and\n * may not read the accumulated set or decide anything about emission. Same\n * split of concerns the refusal path uses, where `errorResponse` owns \"is it\n * stored\" and `buildRefusalHeaders` owns only \"what does it vary on\".\n */\nexport interface CacheTagSink {\n /**\n * Record that `entityName`'s row `id` composed this response.\n *\n * `id` is `unknown` because a row's `id` column is `unknown` at every call\n * site; a non-string is DROPPED rather than coerced, since `String(id)` on an\n * object yields `[object Object]` — a tag that looks valid and matches\n * nothing.\n */\n addDocument(entityName: string, id: unknown): void\n /** Record that this response depends on WHICH rows of `entityName` exist. */\n addCollection(entityName: string): void\n /**\n * Declare that this response MUST NOT be stored by any cache.\n *\n * 🔴 It lives on the tag sink rather than on a seventh parameter to\n * {@link buildResponseHeaders}, and that is the whole point. This object is\n * already threaded to every resolution pass AND to every response site, so a\n * new endpoint inherits the behaviour instead of having to remember a flag —\n * the same argument `resolveRows` is built on (\"four hand-written copies is\n * four chances to add a fifth endpoint that forgets one\"). It is not a\n * category error either: this object already decides cacheability, since\n * `buildResponseHeaders` drops its tags entirely on an unstorable response.\n *\n * The one caller today is a block tree that reached editor D010's third\n * state — the provider failed, then failed its I/O-free retry, so the field\n * or region is served EMPTY. Caching that would hold content-less pages at\n * every CDN for a full TTL, long after the resolver recovered, and no purge\n * key could evict them: the blanked envelope carries no `refs`, so nothing\n * a media re-publish invalidates ever names it.\n *\n * ⚠️ **A STRIPPED response is deliberately still storable.** A tree the retry\n * rescued is complete, correct content with its media refs replaced by\n * `null` — which is precisely what an exhausted RESOLUTION BUDGET already\n * produces, and those responses are cached today by design. Making one\n * uncacheable and not the other would be an unannounced change to that\n * policy. Only \"we know this response is missing content\" gets `no-store`.\n */\n markUncacheable(): void\n}\n\n/** A request-scoped, bounded, de-duplicated set of cache tags. */\nexport interface CacheTagSet extends CacheTagSink {\n /** The accepted tags, in insertion order — which is value order (D055 §5). */\n readonly tags: readonly string[]\n /**\n * How many `add*` calls were REFUSED — malformed, over-long, or past the\n * count or byte cap. Non-zero is worth a log line: something this response\n * depends on is not named, so a purge for it will silently miss.\n *\n * A count of CALLS, not of distinct documents, and the difference is real\n * once a cap is reached: a repeat of an already-ACCEPTED tag is free, but a\n * repeat of one that never made it in is refused again and counted again, so\n * sixty rows referencing one over-cap author report sixty. It is a\n * severity-ish signal, not a ledger — the actionable facts are that drops\n * happened at all, and on which endpoint.\n */\n readonly dropped: number\n /** True once {@link CacheTagSink.markUncacheable} has been called. */\n readonly uncacheable: boolean\n /**\n * The header to merge into a STORABLE response, or `{}` when emission is\n * disabled or nothing was tagged.\n *\n * Callers must not consult this on an unstorable response — and structurally\n * cannot, because only the two builders that emit a non-`no-store`\n * `Cache-Control` are handed the set.\n */\n headers(): Record<string, string>\n}\n\n/** `true` when `segment` is safe to place in a tag. */\nfunction isTagSegment(value: unknown): value is string {\n return typeof value === 'string' && value !== '' && CACHE_TAG_CHARSET.test(value)\n}\n\n/**\n * Build a request-scoped tag set.\n *\n * **Per REQUEST, never shared** — for the same reason `createResolutionBudget`\n * is: the set describes what THIS response was composed from, and a\n * process-lifetime accumulator would describe nothing at all.\n *\n * Tags are collected even when emission is disabled. One code path rather than\n * two, and the cost is bounded at 128 short strings; a second \"collect nothing\"\n * path would be a branch no test naturally covers.\n */\nexport function createCacheTagSet(\n config: ResolvedCacheTagsConfig,\n /**\n * The site segment for `entityName`'s tags (plan `multisite` D008). The\n * handler supplies this only on an app serving several sites; it answers the\n * request's site for a site-bound entity and {@link SHARED_SITE_TAG_SEGMENT}\n * for a shared one, so EVERY tag of such a deployment has a site slot.\n * Omitted — every single-site install — the grammar is exactly the\n * pre-multisite one, byte for byte.\n */\n siteFor?: (entityName: string) => string,\n): CacheTagSet {\n /** `lumi` or `lumi:<site>` — the prefix every tag of `entityName` starts with. */\n function prefixFor(entityName: string): string | undefined {\n if (siteFor === undefined) return CACHE_TAG_NAMESPACE\n const site = siteFor(entityName)\n // A site that is not a tag segment would mint a tag no purge can name.\n return isTagSegment(site) ? `${CACHE_TAG_NAMESPACE}:${site}` : undefined\n }\n\n // A Set, because JS Sets iterate in INSERTION order — which is the property\n // D055 §5 leans on: collection and primary-row tags are added before resolved\n // reference and media tags, so a response that overflows degrades to \"every\n // row on this page, plus the collection\" rather than to an arbitrary 128.\n const tags = new Set<string>()\n let dropped = 0\n // Tracked incrementally rather than re-joined per add: the header is built\n // once, but the BUDGET has to be known on every add or the last tag accepted\n // could be the one that pushes the assembled field past the cap.\n //\n // Seeded with the FIELD overhead — `<name>: ` plus the trailing CRLF — because\n // the budget is a statement about the emitted header, and a deployment that\n // configures a longer name (`Edge-Cache-Tag`, `Netlify-Cache-Tag`) genuinely\n // has less room for tags. Budgeting the value alone would make the bound\n // depend on a name nobody accounted for.\n let uncacheable = false\n let bytes = config.header.length + 4\n // Latched once the byte budget refuses anything, so the emitted set stays a\n // PREFIX of insertion order.\n //\n // Without it the byte check is per tag, so a long tag is refused while a\n // SHORTER one offered afterwards still fits — the surviving set is then no\n // longer \"the first N added\", which is the degradation property `_schema`\n // publishes and which the whole insert-order-is-value-order design rests on.\n // Deterministic and slightly smaller beats maximal and order-dependent: which\n // documents a response names should not depend on how long their ids happen\n // to be.\n let byteBudgetSpent = false\n\n function add(tag: string): void {\n // Already present is NOT a drop. A list of 20 rows all referencing one\n // author yields one tag and zero drops; counting the 19 repeats would make\n // the drop log fire on the healthy case, which is how a signal stops being\n // read.\n if (tags.has(tag)) return\n if (tag.length > MAX_CACHE_TAG_LENGTH || tags.size >= MAX_CACHE_TAGS_PER_RESPONSE) {\n dropped += 1\n return\n }\n // Tags are restricted to single-byte ASCII by CACHE_TAG_CHARSET, so\n // `.length` IS the byte count — no `TextEncoder` needed, and the charset\n // check above is what makes that true rather than assumed.\n const cost = tag.length + (tags.size === 0 ? 0 : 1)\n if (byteBudgetSpent || bytes + cost > MAX_CACHE_TAG_HEADER_BYTES) {\n byteBudgetSpent = true\n dropped += 1\n return\n }\n bytes += cost\n tags.add(tag)\n }\n\n return {\n addDocument(entityName: string, id: unknown): void {\n if (!isTagSegment(entityName) || !isTagSegment(id)) {\n dropped += 1\n return\n }\n const prefix = prefixFor(entityName)\n if (prefix === undefined) {\n dropped += 1\n return\n }\n add(`${prefix}:${entityName}:${id}`)\n },\n addCollection(entityName: string): void {\n if (!isTagSegment(entityName)) {\n dropped += 1\n return\n }\n const prefix = prefixFor(entityName)\n if (prefix === undefined) {\n dropped += 1\n return\n }\n add(`${prefix}:${entityName}`)\n },\n get tags(): readonly string[] {\n return [...tags]\n },\n markUncacheable(): void {\n uncacheable = true\n },\n get dropped(): number {\n return dropped\n },\n get uncacheable(): boolean {\n return uncacheable\n },\n headers(): Record<string, string> {\n if (!config.enabled || tags.size === 0) return {}\n return { [config.header]: [...tags].join(config.separator) }\n },\n }\n}\n","/**\n * A row DRAWN BY A STORED TEMPLATE (block-library D009) — what the public API\n * serves for an entity that declares `frontend: { drawnBy: 'template' }`.\n *\n * Such an entity has no block-tree field: its rows are synced, not authored, so\n * the layout of its detail page is ONE stored template document per entity\n * type, edited in the admin. This module is what joins the two on the way out.\n *\n * ## 🔴 The row is NARROWED, and that is the security property\n *\n * A template-drawn entity is public because its detail page is, not because its\n * table is. So what crosses is:\n *\n * - `id`;\n * - `title` / `description`, IF the injected provider projects them — it does\n * so from the site's code-declared detail bindings, never from a request\n * parameter or a stored value;\n * - {@link DETAIL_TEMPLATE_FIELD}: the published template, resolved against\n * this row, whose `detail` sidecar carries the remaining bound values.\n *\n * Every other column of the row stays in this process. That holds wherever the\n * row is reached from — the by-id route and a `?populate=` expansion both come\n * through {@link narrowTemplateDrawnRow} — and it holds with NO provider wired,\n * in which case the row is `{ id }` (deny by default).\n *\n * The list, count and slug routes do not exist for such an entity (the handler\n * 404s them, as it does a singleton's): a filter or a sort over a column that\n * is not served would otherwise be an oracle for it.\n *\n * ## Fan-out\n *\n * One template read per BY-ID response, claimed against the response's one\n * resolution budget and skipped when it is spent. A populated row gets no\n * template — a reference to a room is drawn as a link, not as a page.\n */\n\nimport type { Entity } from '@murumets-ee/entity'\nimport { type BlockResolutionDeps, type ResolvedBlockTree, resolveOneBlockTree } from './blocks.js'\n\n/**\n * The key the resolved template crosses under. Underscore-led so it cannot be\n * mistaken for a column, and stable: a frontend lists it beside its own body\n * block fields.\n */\nexport const DETAIL_TEMPLATE_FIELD = '_template'\n\n/** Longest `title` / `description` served. Matches the detail sidecar's text cap. */\nconst MAX_PUBLIC_TEXT = 2_000\n\n/**\n * The detail-template provider. Injected, not imported, so this package stays a\n * leaf — `@murumets-ee/content`'s `createDetailTemplateProvider` is the\n * reference implementation, and its header states what each method may reach.\n */\nexport interface ContentApiDetailTemplates {\n /** The entity templates are stored in — named on the response's cache tags. */\n readonly entity: string\n /**\n * The PUBLISHED template for rows of `entity` at `locale`, or `null`.\n * Implementations must apply the publish filter themselves: this package\n * serves whatever comes back.\n */\n readTemplate(opts: {\n entity: string\n locale: string\n }): Promise<{ readonly id: string; readonly blocks: unknown[] } | null>\n /**\n * The row's public name and summary. Only string `title` and `description`\n * are read from the answer; anything else it returns is dropped here.\n */\n publicRow(opts: {\n entity: string\n row: Readonly<Record<string, unknown>>\n locale: string\n }): Promise<Record<string, unknown>>\n}\n\nexport interface TemplateDrawnDeps extends BlockResolutionDeps {\n detailTemplates?: ContentApiDetailTemplates | undefined\n /** The locale to read when the request named none. */\n defaultLocale?: string | undefined\n}\n\nfunction publicText(value: unknown): string | undefined {\n if (typeof value !== 'string') return undefined\n const trimmed = value.trim()\n return trimmed === '' ? undefined : trimmed.slice(0, MAX_PUBLIC_TEXT)\n}\n\n/**\n * Replace `row`'s contents, IN PLACE, with what a visitor may see of it — and,\n * when `attachTemplate`, the entity type's published template resolved against\n * the row as it was.\n *\n * In place because every caller holds the row inside a response it is still\n * building (`afterExpand` hands over the rows it will serve). The full row is\n * snapshotted first; nothing of it survives on `row` except `id`.\n *\n * Never throws for a provider failure: a detail page whose template could not\n * be read is served as the plain fallback and marked uncacheable, so the miss\n * does not outlive the fix.\n */\nexport async function narrowTemplateDrawnRow(\n entity: Entity,\n row: Record<string, unknown>,\n deps: TemplateDrawnDeps,\n attachTemplate: boolean,\n): Promise<void> {\n const full: Readonly<Record<string, unknown>> = { ...row }\n for (const key of Object.keys(row)) {\n if (key !== 'id') delete row[key]\n }\n\n const provider = deps.detailTemplates\n if (!provider) return\n const locale = deps.locale ?? deps.defaultLocale\n // No locale to project or read at: the row keeps its id and nothing else.\n if (locale === undefined) return\n\n try {\n const projected = await provider.publicRow({ entity: entity.name, row: full, locale })\n const title = publicText(projected.title)\n const description = publicText(projected.description)\n if (title !== undefined) row.title = title\n if (description !== undefined) row.description = description\n } catch (err) {\n deps.logger?.warn(\n { entity: entity.name, err },\n 'content-api: detail-template provider failed to project a row — served with its id only',\n )\n deps.cacheTags?.markUncacheable()\n }\n\n if (!attachTemplate) return\n const resolve = deps.resolveBlocks\n // No resolver, no template: a stored tree carries the media urls recorded\n // when an editor picked them, which may be signed, and this is a cacheable\n // anonymous response. An entity's OWN tree crosses unresolved in that case\n // for historical reasons; a new field does not inherit the exception.\n if (!resolve) return\n\n // Publishing (or unpublishing) the template changes this response whether or\n // not one was found just now, so the collection tag is recorded before the\n // read rather than after a hit.\n deps.cacheTags?.addCollection(provider.entity)\n\n if (deps.budget.claim('block', 1) < 1) return\n\n let template: { readonly id: string; readonly blocks: unknown[] } | null\n try {\n template = await provider.readTemplate({ entity: entity.name, locale })\n } catch (err) {\n deps.logger?.warn(\n { entity: entity.name, err },\n 'content-api: detail-template provider failed to read the template — the row is served ' +\n 'without one and the response is not cacheable',\n )\n deps.cacheTags?.markUncacheable()\n return\n }\n if (template === null || typeof template.id !== 'string' || !Array.isArray(template.blocks)) {\n return\n }\n deps.cacheTags?.addDocument(provider.entity, template.id)\n\n const outcome = await resolveOneBlockTree(resolve, template.blocks, {\n locale: deps.locale,\n budget: deps.budget,\n // The ROW's entity, not the template's: the provider projects the detail\n // sidecar for `entity` from `boundValues`, and that pairing is what makes\n // a bound block show this row.\n entity: entity.name,\n field: DETAIL_TEMPLATE_FIELD,\n boundValues: full,\n // A block in the stored tree must not get to choose which of the row's\n // columns are read: only the code-declared detail is projected from it.\n rowIsForeign: true,\n ...(deps.canReadEntity !== undefined && { canReadEntity: deps.canReadEntity }),\n ...(deps.collectionAllowance !== undefined && {\n claimCollection: deps.collectionAllowance.claim,\n }),\n ...(deps.page !== undefined && { page: deps.page }),\n })\n if (outcome.failure !== undefined) {\n deps.logger?.warn(\n {\n entity: entity.name,\n field: DETAIL_TEMPLATE_FIELD,\n failure: outcome.failure,\n retryFailure: outcome.retryFailure,\n err: outcome.error,\n },\n 'content-api: block resolver failed on a detail template — served re-resolved with an ' +\n 'exhausted budget, or empty if that failed as well',\n )\n }\n if (outcome.retryFailure !== undefined) deps.cacheTags?.markUncacheable()\n recordEnvelopeTags(outcome.envelope, deps)\n row[DETAIL_TEMPLATE_FIELD] = outcome.envelope\n}\n\n/** The same tag bookkeeping `resolveBlockTrees` does for an entity's own tree. */\nfunction recordEnvelopeTags(envelope: ResolvedBlockTree, deps: BlockResolutionDeps): void {\n if (envelope.refs !== undefined) {\n for (const ref of envelope.refs) deps.cacheTags?.addDocument(ref.entity, ref.id)\n }\n if (envelope.collections !== undefined) {\n for (const result of Object.values(envelope.collections)) {\n if (result.entity !== null) deps.cacheTags?.addCollection(result.entity)\n }\n }\n}\n","/**\n * Cache-Control header builders.\n *\n * **The configuration declares ONE number** — a total staleness window in\n * seconds — and this module derives the `max-age` / `stale-while-revalidate`\n * split from it (D036). So the sum of what is emitted always equals what the\n * operator declared; there is no way to write a configuration expressing a\n * window the deployment does not honour. The default window is 360, which\n * reproduces the historical `max-age=60, stale-while-revalidate=300`. A window\n * of `0` yields `no-store` out of the same field, for an operator who needs a\n * published edit visible on the very next request (S6 verbatim).\n *\n * **Principal-aware short-circuit, on SUCCESS responses.** When the caller is\n * anything other than the anonymous principal, {@link buildResponseHeaders}\n * forces `no-store`: the URL is the same for an anonymous and an editor caller\n * but the BODY is caller-specific, so a shared edge keyed on the URL alone would\n * serve one to the other.\n *\n * **REFUSALS ARE NEVER STORED, and nothing here decides that** (SD005). Every\n * 4xx and 5xx is `no-store`, unconditionally, for every caller — stamped by\n * `errorResponse`, which is the single owner of that question.\n * {@link buildRefusalHeaders} supplies only CORS and `Vary`.\n *\n * The value of the uniform rule is not the branch it deletes, it is the class of\n * question it deletes. With no per-caller variation on the refusal path, D006's\n * \"a denial reveals nothing\" costs nothing at runtime and cannot be reopened by\n * a future header — `RateLimit-*`, cache tags and `ETag` can all be added\n * without a byte-identity audit. Two earlier attempts in this PR made\n * storability depend first on the caller's role and then on `AuthVaryPolicy`;\n * the first was a credential-validity oracle, the second replayed a stored 404\n * to a principal who would have received a 200.\n */\n\nimport { assertCacheTagsConfig, type CacheTagSet } from './cache-tags.js'\nimport type { CacheConfig } from './types.js'\n\n/**\n * The default total staleness window, in seconds.\n *\n * 360 is not a new number: it is exactly what `defaultMaxAge: 60` plus\n * `defaultStaleWhileRevalidate: 300` already served on every deployment in this\n * repo. D036 changes what is DECLARED, not what is emitted by default.\n */\nexport const DEFAULT_MAX_STALE_SECONDS = 360\n\n/**\n * Split a declared window into the `max-age` / `stale-while-revalidate` pair.\n *\n * Package-owned and deliberately not tunable (D036). One sixth fresh, five\n * sixths revalidating, which reproduces the historical 60/300 at the historical\n * total of 360 — so this is a change of vocabulary, not of behaviour, for any\n * deployment that had not touched the old fields (which is all of them).\n *\n * The invariant that matters is `maxAge + swr === window`, asserted in the\n * tests: whatever the split, the sum of what is emitted equals what was\n * declared.\n */\nfunction splitWindow(windowSeconds: number): { maxAge: number; swr: number } {\n const maxAge = Math.ceil(windowSeconds / 6)\n return { maxAge, swr: windowSeconds - maxAge }\n}\n\n/** Fields D036 removed. Passing one is refused LOUDLY — see {@link assertCacheConfig}. */\nconst REMOVED_CACHE_FIELDS = [\n 'defaultMaxAge',\n 'defaultStaleWhileRevalidate',\n 'maxAge',\n 'staleWhileRevalidate',\n] as const\n\nfunction assertWindow(value: unknown, where: string): void {\n if (value === undefined) return\n if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n throw new Error(\n `content-api cache: \\`${where}.maxStaleSeconds\\` must be a non-negative integer, received ${JSON.stringify(value)}. ` +\n 'Use 0 for `no-store` if you need a published edit visible on the very next request.',\n )\n }\n}\n\nfunction assertNoRemovedFields(bag: Record<string, unknown>, where: string): void {\n for (const removed of REMOVED_CACHE_FIELDS) {\n if (!(removed in bag)) continue\n throw new Error(\n `content-api cache: \\`${where}.${removed}\\` was removed (D036). Use \\`maxStaleSeconds\\` — ONE number naming the ` +\n \"TOTAL staleness window in seconds. The old pair's real window was their SUM and nothing said so, which is \" +\n 'the defect D036 exists to close: `defaultMaxAge: 10` bought a 310-second window, not a 10-second one. ' +\n 'The max-age / stale-while-revalidate split is now derived, so what is emitted always sums to what you declared.',\n )\n }\n}\n\n/**\n * Validate the host's cache configuration AT CONSTRUCTION.\n *\n * Two failures this closes, both found in review:\n *\n * **A removed field was silently ignored.** `resolveWindow` reads only\n * `maxStaleSeconds`, so a host still passing `defaultMaxAge: 0` — or a JS host,\n * or any host mid-upgrade — got the 360-second DEFAULT instead. The direction is\n * MORE caching on a public surface, from configuration the operator wrote to\n * reduce it. This package already throws for exactly this shape one config field\n * over (`exposeInternalFields`), on the stated grounds that silently dropping a\n * field you configured is the failure worth throwing to prevent.\n *\n * **The window was the one ceiling validated lazily.** Every other host ceiling\n * (`maxLimit`, `maxOffset`, `publicRole`, `credentialHeaders`, the resolution\n * budget, the availability limits) is checked when the handler is built. A\n * `maxStaleSeconds: -1` used to construct fine and throw PER REQUEST — and since\n * the preflight now builds cache headers too, that throw had no `try` above it\n * on the `OPTIONS` path: every CORS preflight would reject with no response, no\n * CORS headers and no log. A per-entity bad value produced a partial outage\n * visible only for that entity. All of it undetectable at boot.\n */\nexport function assertCacheConfig(config: CacheConfig | undefined): void {\n if (!config) return\n assertNoRemovedFields(config as unknown as Record<string, unknown>, 'cache')\n assertWindow(config.maxStaleSeconds, 'cache')\n // Same reason as the window: a bad header name only throws when a `Response`\n // is constructed, which on the `OPTIONS` path has no `try` above it.\n assertCacheTagsConfig(config.tags)\n for (const [entityName, entityConfig] of Object.entries(config.entities ?? {})) {\n if (!entityConfig) continue\n const where = `cache.entities.${entityName}`\n assertNoRemovedFields(entityConfig as unknown as Record<string, unknown>, where)\n assertWindow(entityConfig.maxStaleSeconds, where)\n }\n}\n\nfunction resolveWindow(entityName: string, config: CacheConfig | undefined): number {\n const entityConfig = config?.entities?.[entityName]\n if (entityConfig?.cacheable === false) return 0\n // Validated at construction by `assertCacheConfig`, so this is a read, not a\n // check — a bad value cannot reach here.\n return entityConfig?.maxStaleSeconds ?? config?.maxStaleSeconds ?? DEFAULT_MAX_STALE_SECONDS\n}\n\nexport function buildCacheHeaders(\n entityName: string,\n config: CacheConfig | undefined,\n): Record<string, string> {\n const windowSeconds = resolveWindow(entityName, config)\n\n // A zero window is `no-store`, out of the SAME field — the operator who needs\n // S6 verbatim does not reach for a second knob (D036).\n if (windowSeconds === 0) {\n return { 'Cache-Control': 'no-store' }\n }\n\n const { maxAge, swr } = splitWindow(windowSeconds)\n return {\n 'Cache-Control': `public, max-age=${maxAge}, stale-while-revalidate=${swr}`,\n }\n}\n\n/**\n * Which shared-cache class a caller's responses belong to.\n *\n * `'anonymous'` is the ONE caller whose response body cannot vary per principal:\n * no identity was resolved and the role is the configured public one, so every\n * such caller gets the same bytes and a shared cache may reuse them.\n *\n * `'identified'` is everything else — a non-public role (preview/editor) OR a\n * resolved identity, INDEPENDENTLY. The identity half is not redundant: a\n * logged-in visitor who has been granted no extra role sits on `publicRole`\n * while still carrying a real `user.id` into `RequestContext`, which is exactly\n * the seam D016 adds owner-scoped rows through. A rule keyed on role alone would\n * mark that caller publicly cacheable while its body varies per principal.\n */\nexport type CallerCacheClass = 'anonymous' | 'site-keyed' | 'identified'\n\n/**\n * `'site-keyed'` (plan `multisite` D008) — a caller with no identity and the\n * public role whose CREDENTIAL selected the site. The body is the same for\n * every holder of that key, so it is storable; but it differs from what the\n * next site's key gets for the SAME URL, so it must not sit in a cache shared\n * between them. It is emitted `private`, not `public`.\n *\n * `Vary: Authorization` already says this, and is still sent. It is not enough\n * on its own: real shared caches honour `Vary` unevenly (Cloudflare honours\n * only `Accept-Encoding`), and one that stores the response and ignores the\n * header would serve site A's pages, menu and `_site` to site B's frontend.\n * `private` is the directive every shared cache does honour. The consumer that\n * matters — the site's own frontend, which holds the key — is a private cache\n * in exactly HTTP's sense and may keep it.\n */\n\n/**\n * The request header EVERY response this module builds varies on, whatever else\n * it varies on.\n *\n * These builders merge `buildCorsHeaders`' output, and that output is a function\n * of the request's `Origin`: an allowed origin gets\n * `Access-Control-Allow-Origin`, everything else gets no CORS headers at all. So\n * the response headers vary on `Origin` even on the requests where the CORS\n * layer emitted nothing to vary — which is precisely the variant a `Vary`-less\n * entry would be replayed to.\n *\n * Deliberately NOT listed here: `Accept-Language` (locale is a query param, so\n * it is already part of the URL cache key) and ANY credential header. Which\n * header carries a credential is not knowable from inside this package — see\n * {@link AuthVaryPolicy}.\n */\nconst ALWAYS_VARY_ON = ['Origin'] as const\n\n/**\n * How `ContentApiConfig.authenticate` participates in shared-cache variant\n * selection — a per-deployment fact, DECLARED by the host rather than guessed\n * here.\n *\n * The hook is host-supplied and may read ANY request header. A fixed selector\n * list (`Cookie, Authorization` — what this module used to emit) is therefore\n * wrong in both directions at once:\n *\n * - **Too narrow.** A host whose hook reads `X-Preview-Token` gets an anonymous\n * response stored with nothing keyed on it, and the next request carrying\n * only that header matches the stored public variant — `authenticate` is\n * never called, from the edge. The direction is fail-SAFE (the public body is\n * the one replayed), but \"the CDN quietly ignores your credential\" is not a\n * property to leave to chance.\n * - **Too wide.** `Vary: Cookie` on the public branch is close to fatal for\n * caching in practice: Cloudflare honours no `Vary` value except\n * `Accept-Encoding` and will not serve a cached variant at all, while\n * Fastly/Varnish key on the RAW cookie header, so hit rate collapses for\n * every visitor carrying an analytics or consent cookie. A deployment with no\n * `authenticate` hook was paying that for a credential nothing parses.\n *\n * The three states are exhaustive, and `'undeclared'` is the fail-CLOSED one:\n * a hook is wired, so a credential can arrive, but nothing says what selects it\n * — there is no correct `Vary` to emit, so nothing is offered for storage.\n */\nexport type AuthVaryPolicy =\n /** No `authenticate` hook — no credential is parsed, so none selects. */\n | { kind: 'none' }\n /** A hook plus the request headers the host declared it reads. Non-empty. */\n | { kind: 'declared'; headers: readonly string[] }\n /** A hook whose selectors the host did not declare — forces `no-store`. */\n | { kind: 'undeclared' }\n\n/**\n * Union `existing` with {@link ALWAYS_VARY_ON} and `extra`, case-insensitively\n * and order-stably.\n *\n * A union rather than a concatenation because `buildCorsHeaders` already emits\n * `Vary: Origin` on an allowed origin: appending would produce `Origin, Origin,\n * …`, which is legal but makes two otherwise byte-identical responses differ.\n */\nfunction mergeVary(existing: string | undefined, extra: readonly string[]): string {\n const seen = new Set<string>()\n const merged: string[] = []\n for (const raw of [...(existing?.split(',') ?? []), ...ALWAYS_VARY_ON, ...extra]) {\n const token = raw.trim()\n if (token === '') continue\n const key = token.toLowerCase()\n if (seen.has(key)) continue\n seen.add(key)\n merged.push(token)\n }\n return merged.join(', ')\n}\n\n/**\n * Combined response headers: CORS + Cache-Control + `Vary`.\n *\n * For an `'identified'` caller, returns `Cache-Control: no-store` regardless of\n * the per-key cache config — see file-level comment.\n *\n * **`Vary` goes on BOTH branches, and it is the `'anonymous'` one that needs\n * it.** RFC 9111 §4.1: a stored response with no `Vary` matches ANY subsequent\n * request for the same URL. The cacheable branch is the only response a shared\n * cache can actually store, so without a `Vary` the `public, max-age=…` body a\n * CDN stored for an anonymous caller is eligible to be replayed to the next\n * credentialed one — which silently defeats preview/authenticated reads at the\n * edge. (The direction is fail-safe: the replayed body is the PUBLIC one, so\n * there is no over-disclosure — but \"the edge quietly ignores your credential\"\n * is not a property to leave undeclared.) It was previously emitted on the\n * `no-store` branch ALONE, where it is inert: a response that must not be\n * stored has no stored variant to key.\n *\n * It is kept on the `no-store` branch too, identical, for two reasons: an\n * intermediary configured to cache regardless of `no-store` at least keys\n * correctly, and one expression for both branches removes the \"which branch\n * carries the header\" question that produced the original defect.\n *\n * **`authVary` decides WHICH selectors that `Vary` names, and whether the\n * cacheable branch is reachable at all** — see {@link AuthVaryPolicy}. An\n * `'undeclared'` policy collapses both branches onto `no-store`: a credential\n * can arrive and nothing says what selects it, so there is no correct variant\n * key and nothing is offered for storage.\n *\n * `cacheKey` is the entity name — whatever `CacheConfig.entities` is keyed by.\n * `GET /_menu` does NOT come through here; its body is principal-invariant, so\n * it builds its headers with {@link buildPrincipalInvariantHeaders} under the\n * reserved `_menu` key instead.\n *\n * **`tags` rides on the SAME decision that produced the directive** (D055 §6).\n * A cache tag is a promise that purging it invalidates this entry; a response\n * no cache may store has no entry to invalidate, so the header would be dead\n * weight at best and a second, unaudited channel of ids at worst. Making one\n * function own both answers is why \"does a refusal carry a tag\" is not a rule\n * ~40 call sites have to remember — {@link buildRefusalHeaders} takes no tag\n * argument at all, so a refusal structurally cannot carry one.\n *\n * Note the test is on the RESULT, not on `storable`: an anonymous caller still\n * gets `no-store` when the window is `0` or the entity is `cacheable: false`,\n * and those responses must not be tagged either.\n */\nexport function buildResponseHeaders(\n cacheKey: string,\n corsHeaders: Record<string, string>,\n caller: CallerCacheClass,\n cacheConfig: CacheConfig | undefined,\n authVary: AuthVaryPolicy,\n tags?: CacheTagSet,\n): Record<string, string> {\n // 🔴 Three conditions, and the third is not about the CALLER. The first two\n // ask \"may a shared cache hold a response shaped like this?\"; `uncacheable`\n // asks \"is THIS response one we already know is wrong to keep?\" A block tree\n // that reached editor D010's third state is served empty, and storing it\n // holds content-less pages at every CDN for a full TTL — with no purge key\n // that can evict them, because a blanked envelope carries no `refs`.\n //\n // Read off the tag set rather than taken as a parameter so a new endpoint\n // cannot forget it: that object is already threaded to every resolution pass\n // and every response site. See `CacheTagSink.markUncacheable`.\n const storable =\n caller !== 'identified' && authVary.kind !== 'undeclared' && tags?.uncacheable !== true\n const built = storable\n ? buildCacheHeaders(cacheKey, cacheConfig)\n : { 'Cache-Control': 'no-store' }\n // See `CallerCacheClass`: a site-keyed response is never offered to a cache\n // that two sites share.\n const directive = built['Cache-Control'] ?? 'no-store'\n const cacheControl = {\n 'Cache-Control':\n caller === 'site-keyed' ? directive.replace(/^public\\b/, 'private') : directive,\n }\n\n return {\n ...corsHeaders,\n ...cacheControl,\n ...(cacheControl['Cache-Control'] === 'no-store' ? {} : tags?.headers()),\n Vary: mergeVary(corsHeaders.Vary, authVary.kind === 'declared' ? authVary.headers : []),\n }\n}\n\n/**\n * Headers for a response whose body is the same for EVERY caller — today, `GET\n * /_menu` alone.\n *\n * A sibling of {@link buildResponseHeaders} rather than a call into it, because\n * the caller is deliberately not an input here: `_menu`'s resolver receives\n * `(handle, { locale, currentPath })` and nothing principal-derived, so there is\n * no caller-specific body for a shared cache to leak and no credential to select\n * on. Uniform headers are also what stops the `Cache-Control` shape from\n * advertising that a credential verified on an endpoint with no permission gate\n * (see `handleMenu`'s JSDoc).\n *\n * **It still needs `Vary`, and that is why this exists.** It was previously the\n * one cacheable response emitting none. An SSR frontend fetches `_menu`\n * server-side with no `Origin`, so `buildCorsHeaders` contributes nothing and a\n * CDN stores `public, max-age=…` with NO selector; per RFC 9111 §4.1 that entry\n * then matches a browser's request from an allowed origin and is replayed\n * WITHOUT the `Access-Control-Allow-Origin` that request needed, so the browser\n * blocks a response the server considered a success.\n */\nexport function buildPrincipalInvariantHeaders(\n cacheKey: string,\n corsHeaders: Record<string, string>,\n cacheConfig: CacheConfig | undefined,\n tags?: CacheTagSet,\n): Record<string, string> {\n // The SIBLING has to honour `uncacheable` too, and the reason is the whole\n // argument for putting the flag on the tag set rather than in a parameter: a\n // new endpoint inherits it. Half the builders inheriting it is not that.\n //\n // `_menu` cannot serve a block tree today — `handleMenu` never reaches\n // `resolveRows` — so this is a latent case rather than a live one. That is\n // exactly why it is worth closing now: the next principal-invariant endpoint\n // that resolves blocks would otherwise store a blanked body in silence, and\n // nothing about writing it would prompt anyone to look here.\n const cacheControl =\n tags?.uncacheable === true\n ? { 'Cache-Control': 'no-store' }\n : buildCacheHeaders(cacheKey, cacheConfig)\n return {\n ...corsHeaders,\n ...cacheControl,\n // Same rule, same reason as `buildResponseHeaders`: tags only on a response\n // that is actually stored. This branch is reachable — `maxStaleSeconds: 0`\n // or `cacheable: false` on the reserved `_menu` key yields `no-store` here\n // too, as does an `uncacheable` set.\n ...(cacheControl['Cache-Control'] === 'no-store' ? {} : tags?.headers()),\n // `Vary` is SET, not merged from the input — and that distinction is\n // load-bearing rather than stylistic.\n //\n // This used to be `mergeVary(corsHeaders.Vary, [])`, which inherits whatever\n // the caller happened to pass. That was harmless while the only input was\n // `buildCorsHeaders`' own `Vary: Origin` — and stopped being harmless the\n // moment `dispatch` began shadowing its headers with the D037 refusal set,\n // which carries the host's declared credential selectors. `_menu` then\n // started emitting `Vary: Origin, Authorization` for a body that provably\n // does not vary by `Authorization`, fragmenting the cache per credential on\n // the one endpoint whose whole design is to be absorbed by a CDN.\n //\n // Not a disclosure — the body and the directive stayed identical for every\n // caller — but a silent, compounding cache-hit-rate loss, and the kind of\n // defect that only appears when two correct-looking changes meet.\n //\n // Setting it makes the contract structural: this builder emits exactly the\n // variance its own name promises, whatever it is handed. A future caller\n // cannot widen it by accident.\n Vary: mergeVary(undefined, []),\n }\n}\n\n/* (A refusal is never stored, so it is priced under no cache key at all — SD005.) */\n\n/**\n * The CORS and `Vary` half of a refusal's headers. It deliberately does NOT\n * decide storability — `errorResponse` owns that, unconditionally (SD005).\n *\n * One owner for \"is it stored\", one for \"what does it vary on\". That division is\n * the point: this function previously computed a directive too, and the two\n * concerns drifting apart is how a refusal's storability came to depend on\n * `AuthVaryPolicy`, a host-config axis with nothing to do with refusals. See\n * `REFUSAL_CACHE_CONTROL` for the full history and why uniform won.\n *\n * `Vary` still matters on an unstorable response, for the same two reasons\n * {@link buildResponseHeaders} keeps it on its own `no-store` branch: an\n * intermediary configured to store regardless of `no-store` at least keys\n * correctly, and one expression for every branch removes the \"which branch\n * carries the header\" question that produced F030 in the first place.\n *\n * It is also what closes F030 on the refusal path specifically:\n * `buildCorsHeaders` contributes NOTHING when the request carries no `Origin` —\n * the normal case for an API whose callers phase 01 defines as long-lived server\n * processes — so a refusal previously carried no selector at all.\n */\nexport function buildRefusalHeaders(\n corsHeaders: Record<string, string>,\n authVary: AuthVaryPolicy,\n): Record<string, string> {\n return {\n ...corsHeaders,\n Vary: mergeVary(corsHeaders.Vary, authVary.kind === 'declared' ? authVary.headers : []),\n }\n}\n","/**\n * CORS — Origin allowlist with exact match.\n *\n * No wildcards, no regex. This is a CMS content API — you know which\n * frontends consume it. Vary: Origin ensures CDNs cache per-origin.\n */\n\nimport { buildCacheHeaders } from './cache.js'\nimport type { CacheConfig, CorsConfig } from './types.js'\n\n/**\n * `Vary` describes what the ENDPOINT varies on — a property of its\n * configuration — not a report on what THIS request happened to send (D040).\n *\n * This function used to return `{}` on three of its four paths (no CORS config,\n * no `Origin` header, or an `Origin` off the allowlist) and emit `Vary: Origin`\n * only on the fourth. So one cacheable URL produced responses with two different\n * `Vary` sets, and RFC 9111 §4.1 makes the STORED response's `Vary` govern\n * reuse: whichever request populated the entry decided the keying for everyone\n * after it. Populate it from a server-side fetch — no `Origin`, and phase 01\n * defines this API's callers as long-lived server processes, so that is the\n * NORMAL case — and a later browser request from an allowlisted origin matches\n * that selector-less entry and is blocked for want of an\n * `Access-Control-Allow-Origin` (F030).\n *\n * Calibrated: this fails CLOSED. The reverse direction was impossible, because a\n * response minted for origin A always carried `Vary: Origin` and so could never\n * be reused for origin B. The defect is availability and nondeterminism — whether\n * a browser consumer works depended on cache-population order, which is the\n * class of bug that reproduces in production and not in CI.\n *\n * When no CORS config exists the endpoint provably does NOT vary by `Origin`, so\n * nothing is emitted: adding a key dimension for an axis the response does not\n * vary on would fragment the key for nothing, and key fragmentation on this\n * surface is not free (D042).\n */\nexport function buildCorsHeaders(\n req: Request,\n config: CorsConfig | undefined,\n): Record<string, string> {\n if (!config) return {}\n\n const origin = req.headers.get('Origin')\n // Exact match only — no regex, no wildcards.\n const allowed = origin !== null && config.allowedOrigins.includes(origin)\n\n if (!allowed) {\n // The endpoint still varies by Origin; this request just does not get the\n // allow header. Declaring the variance is the whole point of D040.\n return { Vary: 'Origin' }\n }\n\n return {\n 'Access-Control-Allow-Origin': origin,\n 'Access-Control-Allow-Methods': 'GET, OPTIONS',\n 'Access-Control-Allow-Headers': 'Content-Type, Accept, Authorization',\n 'Access-Control-Max-Age': String(config.maxAge ?? 3600),\n Vary: 'Origin',\n }\n}\n\n/**\n * The 204 preflight.\n *\n * Gets the declared staleness window like every other response (D037 part 3):\n * its content is a function of the `Origin` and a fixed method/header set and\n * varies on nothing else. It previously carried CORS headers alone and NO\n * `Cache-Control` at all — `Access-Control-Max-Age` governs the BROWSER's\n * preflight cache, not a shared one, so a shared cache was left to a vendor\n * heuristic here too.\n *\n * Priced under the reserved `_preflight` key so a host cannot make it diverge\n * from the surface window per entity — a preflight has no entity.\n */\nexport function corsPreflightResponse(\n req: Request,\n config: CorsConfig | undefined,\n cacheConfig: CacheConfig | undefined,\n): Response {\n const headers = {\n ...buildCorsHeaders(req, config),\n ...buildCacheHeaders('_preflight', cacheConfig),\n }\n return new Response(null, { status: 204, headers })\n}\n","/**\n * Availability guard for the public content surface — plan/api PR 6.\n *\n * TWO MECHANISMS WITH DIFFERENT TRIGGER CONDITIONS (D025), not one limiter.\n * The obvious move was to lift `@murumets-ee/search`'s `SearchMechanics` onto\n * this surface — CLAUDE.md names it as the reference implementation. Doing that\n * unchanged would have shipped an amplifier: `enforceRateLimit(userId, ip)`\n * requires BOTH keys to pass, and its reasoning depends entirely on those keys\n * DISTINGUISHING callers. On an anonymous public surface both are constants —\n * `getClientIp` is optional and falls back to a literal, and every\n * unauthenticated caller resolves to the same public principal. Two constant\n * keys is not a limiter with its per-IP half disabled; it is an aggregate cap on\n * every visitor on Earth, which fails CLOSED for all of them simultaneously at\n * whatever rate one attacker chooses (F011).\n *\n * So:\n *\n * - {@link AvailabilityGuard.acquire} / {@link AvailabilityGuard.runGuarded} —\n * SURVIVAL. Bounded concurrency, a bounded wait queue, and a per-request wall\n * clock. Keyless BY CONSTRUCTION: there is no configuration field whose\n * absence disables them, so no host can lose them by omission.\n * - {@link AvailabilityGuard.consumeFairness} — FAIRNESS. Per-caller, and\n * applied ONLY when a distinguishing key exists. When none does it is SKIPPED\n * (returns `undefined`) and never degrades into a shared bucket. That looks\n * like a weakening and is the opposite: the protection anonymous traffic\n * actually needs is the keyless one, and a shared bucket buys nothing except\n * a cheaper way to take the site down.\n *\n * WHAT THE CONCURRENCY CEILING BOUNDS, because the name is load-bearing\n * (SD004). `maxConcurrentRequests` counts IN-FLIGHT REQUESTS, not database\n * connections. D025 originally pinned it below the `db.readOnly` pool size (10)\n * on the premise that \"there is no `statement_timeout` anywhere in the repo, so\n * ten slow requests saturate the pool\". PR 2 of this plan shipped exactly that\n * `statement_timeout` (5s default), so the premise is gone — and D025's own\n * ordering constraint (below) forces the slot to be held for the WHOLE request\n * rather than just the query, at which point a ceiling of 8 would cap this\n * surface at ~145 rps against the ~540 rps this plan measured. A field pinned to\n * the pool size would invite the belief that one slot equals one connection, and\n * that belief is false the moment a request does per-document resolution work.\n *\n * What bounds the CONNECTION layer is stated rather than implied by this number:\n * postgres-js's own pool queue, plus PR 2's `statement_timeout`. This guard's\n * job is to bound unbounded in-flight pile-up and to SHED rather than queue\n * forever. Those are different jobs.\n *\n * ORDERING IS A SECURITY CONSTRAINT, NOT A PERFORMANCE ONE (D025). Both\n * mechanisms are evaluated BEFORE the entity-whitelist and `view` gates. A limit\n * that only fired for entities that exist would let a caller distinguish \"not in\n * the whitelist\" from \"whitelisted but throttled\" by observing which paths can\n * be made to 429 — an enumeration oracle over the served set, delivered through\n * the abuse-resistance layer, defeating the 404-not-403 rule D006 exists to\n * enforce.\n *\n * PER-INSTANCE, AND THE FIELD NAMES SAY SO (D028). An in-memory limiter on N\n * replicas enforces N x the configured value. The defect is not the `Map` — it\n * is the unstated claim: a field spelled `tokens: 120` invites every reader to\n * believe 120 is what the deployment enforces. No distributed limiter ships in\n * v1; {@link ContentApiLimits.store} is the seam for one, and\n * `@murumets-ee/merit`'s `rate-governor.ts` is the worked reference.\n *\n * NOT A FIFTH COPY (D033). Every primitive here comes from\n * `@murumets-ee/core/limits`.\n */\n\nimport {\n InMemoryRateLimitStore,\n type RateLimitDecision,\n type RateLimitStore,\n runWithDeadline,\n Semaphore,\n} from '@murumets-ee/core/limits'\n\n/**\n * Thrown by {@link AvailabilityGuard.acquire} when both the concurrency slots\n * and the wait queue are full. Distinct from a fairness refusal: this one is\n * not about the caller.\n */\nexport class ServiceOverloadedError extends Error {\n constructor() {\n super('Service overloaded')\n this.name = 'ServiceOverloadedError'\n }\n}\n\n/** Thrown by {@link AvailabilityGuard.runGuarded} when the wall clock fires. */\nexport class RequestDeadlineError extends Error {\n constructor() {\n super('Request deadline exceeded')\n this.name = 'RequestDeadlineError'\n }\n}\n\n/**\n * In-flight request ceiling. Sized ABOVE the measured healthy operating point\n * (~540 rps at p95 55ms implies ~30 concurrent by Little's law) so the guard\n * never sheds on healthy traffic — shedding at the normal operating point is\n * the failure this guard exists to prevent, arriving from the other side.\n */\nconst DEFAULT_MAX_CONCURRENT_REQUESTS = 64\n\n/** Burst absorber. Sustained arrival above service capacity still sheds. */\nconst DEFAULT_MAX_QUEUED_REQUESTS = 128\n\n/**\n * Wall clock. Deliberately well above PR 2's 5s `statement_timeout`: a request\n * makes at most two queries plus bounded resolution work, so a request that has\n * burned 10s is not slow, it is stuck.\n */\nconst DEFAULT_REQUEST_TIMEOUT_MS = 10_000\n\n/** Default fairness budget for traffic carrying a distinguishing key. */\nconst DEFAULT_ANONYMOUS_TOKENS = 120\nconst DEFAULT_INTERVAL_MS = 60_000\n\n/**\n * Server-owned ceiling on any credential's budget (D029, C5). A credential's\n * budget is set when it is ISSUED — an operator action — and clamped here. It\n * is never raisable by a request parameter.\n */\nconst DEFAULT_MAX_BUDGET_PER_INSTANCE = 6_000\n\nexport interface ContentApiLimits {\n /**\n * Max requests IN FLIGHT at once, per instance. NOT a connection ceiling —\n * see the module docblock. Default {@link DEFAULT_MAX_CONCURRENT_REQUESTS}.\n */\n maxConcurrentRequests?: number\n\n /**\n * Max requests queued waiting for a slot before the guard sheds 503, per\n * instance. Default {@link DEFAULT_MAX_QUEUED_REQUESTS}.\n */\n maxQueuedRequests?: number\n\n /** Per-request wall clock in ms. Default {@link DEFAULT_REQUEST_TIMEOUT_MS}. */\n requestTimeoutMs?: number\n\n /**\n * Fairness budget applied to UNCREDENTIALED traffic, PER INSTANCE.\n *\n * Only consumed when a distinguishing key exists — which for anonymous\n * traffic means {@link getClientIp} is wired. Without it the fairness limit is\n * skipped entirely rather than collapsed onto one shared bucket (F011).\n */\n anonymousTokensPerInstance?: number\n\n /**\n * Interval for every fairness budget on this surface, in ms. Default 60_000.\n */\n intervalMs?: number\n\n /**\n * Ceiling on any credential's own budget, PER INSTANCE. A credential\n * presenting a larger `rateBudgetPerInstance` is clamped to this. C5: the\n * ceiling moves by operator action, never by request parameter.\n */\n maxBudgetPerInstance?: number\n\n /**\n * How the host extracts a trustworthy client IP — the SECOND fairness key,\n * used when no server-verified principal identity exists.\n *\n * Deliberately optional and deliberately host-supplied: only the deployment\n * knows whether it sits behind a proxy it trusts, and a package-side default\n * would either trust a forgeable header or invent a constant. Returning\n * `undefined` (or omitting this) means anonymous traffic has NO distinguishing\n * key, so the fairness limit is SKIPPED for it. That is the correct outcome,\n * not a degraded one.\n */\n getClientIp?: (req: Request) => string | undefined\n\n /**\n * The fairness counter's backing store (D028 part 3). Defaults to an\n * in-memory, per-instance one. Supply a shared implementation only if a global\n * quota is genuinely needed; see the module docblock.\n */\n store?: RateLimitStore\n}\n\n/** Why a fairness decision came out the way it did — for the log, not the wire. */\nexport type FairnessKeyClass = 'principal' | 'client-ip' | 'none'\n\nexport interface FairnessOutcome {\n readonly decision: RateLimitDecision\n readonly keyClass: Exclude<FairnessKeyClass, 'none'>\n}\n\nexport interface ResolvedLimits {\n readonly maxConcurrentRequests: number\n readonly maxQueuedRequests: number\n readonly requestTimeoutMs: number\n readonly anonymousTokensPerInstance: number\n readonly intervalMs: number\n readonly maxBudgetPerInstance: number\n}\n\nfunction nonNegativeIntOr(value: number | undefined, fallback: number, field: string): number {\n if (value === undefined) return fallback\n if (!Number.isInteger(value) || value < 0) {\n throw new Error(\n `content-api limits: \\`${field}\\` must be a non-negative integer (got ${value})`,\n )\n }\n return value\n}\n\nfunction positiveIntOr(value: number | undefined, fallback: number, field: string): number {\n if (value === undefined) return fallback\n if (!Number.isInteger(value) || value < 1) {\n throw new Error(`content-api limits: \\`${field}\\` must be a positive integer (got ${value})`)\n }\n return value\n}\n\nexport function resolveLimits(limits: ContentApiLimits | undefined): ResolvedLimits {\n return {\n maxConcurrentRequests: positiveIntOr(\n limits?.maxConcurrentRequests,\n DEFAULT_MAX_CONCURRENT_REQUESTS,\n 'maxConcurrentRequests',\n ),\n // ZERO IS ALLOWED here and nowhere else in this block: it names a real,\n // coherent posture — \"shed immediately, never queue\" — which an operator who\n // prefers a fast 503 over a slow 200 has no other way to express.\n // `Semaphore` already accepts `maxWaiters === 0`; rejecting it here would\n // have been the config layer withholding a capability the mechanism has.\n // Every other field below is a ceiling whose zero value disables a bound\n // rather than tightening one, which is why they stay strictly positive.\n maxQueuedRequests: nonNegativeIntOr(\n limits?.maxQueuedRequests,\n DEFAULT_MAX_QUEUED_REQUESTS,\n 'maxQueuedRequests',\n ),\n requestTimeoutMs: positiveIntOr(\n limits?.requestTimeoutMs,\n DEFAULT_REQUEST_TIMEOUT_MS,\n 'requestTimeoutMs',\n ),\n anonymousTokensPerInstance: positiveIntOr(\n limits?.anonymousTokensPerInstance,\n DEFAULT_ANONYMOUS_TOKENS,\n 'anonymousTokensPerInstance',\n ),\n intervalMs: positiveIntOr(limits?.intervalMs, DEFAULT_INTERVAL_MS, 'intervalMs'),\n maxBudgetPerInstance: positiveIntOr(\n limits?.maxBudgetPerInstance,\n DEFAULT_MAX_BUDGET_PER_INSTANCE,\n 'maxBudgetPerInstance',\n ),\n }\n}\n\n/**\n * The identity inputs the fairness limit keys on. Supplied by the caller rather\n * than read from ambient state, so the guard stays testable (phase 09's S4\n * requirement: a guard that reaches for its own inputs is a guard nobody can\n * watch fail).\n */\nexport interface FairnessSubject {\n /** The resolved principal id, or `undefined` when the caller is anonymous. */\n readonly principalId: string | undefined\n /** That principal's own per-instance budget, if the credential carries one. */\n readonly budgetPerInstance: number | undefined\n}\n\nexport class AvailabilityGuard {\n private readonly semaphore: Semaphore\n private readonly store: RateLimitStore\n readonly limits: ResolvedLimits\n\n private readonly getClientIp: ((req: Request) => string | undefined) | undefined\n\n constructor(limits: ContentApiLimits | undefined) {\n // Read off `limits` rather than taken as a second argument. It was both,\n // which is two sources for one value: a future call site passing only\n // `limits` would get `getClientIp: undefined`, so `resolveKey` would return\n // undefined for every anonymous caller and the fairness limit would silently\n // stop applying — a security-relevant regression with no compile error and\n // no failing test, because \"no key means no limit\" is the DESIGNED\n // behaviour (D025) and is indistinguishable from this bug at the boundary.\n this.getClientIp = limits?.getClientIp\n this.limits = resolveLimits(limits)\n this.semaphore = new Semaphore(this.limits.maxConcurrentRequests, this.limits.maxQueuedRequests)\n // ONE store for every key, because the ceiling travels with the CALL rather\n // than with the store (D029): a credentialed SSR frontend and an anonymous\n // visitor share this map and not their budgets.\n this.store = limits?.store ?? new InMemoryRateLimitStore()\n }\n\n /**\n * Take an in-flight slot, or throw {@link ServiceOverloadedError} when both\n * the slots and the queue are full. Pair every RESOLVED call with exactly one\n * {@link release} in a `finally`.\n *\n * `signal` makes a QUEUED acquire abortable, so a caller who disconnects while\n * waiting leaves the queue instead of waking to take a slot and run work whose\n * response is already gone.\n */\n async acquire(signal?: AbortSignal): Promise<void> {\n await this.semaphore.acquire(() => new ServiceOverloadedError(), signal)\n }\n\n release(): void {\n this.semaphore.release()\n }\n\n /**\n * Run `work` under the wall clock.\n *\n * `work` is a PARAMETER rather than something this class reaches for, which is\n * what makes the clock observable: a test hands it a promise that never\n * resolves and watches the deadline end the request. A guard that closed over\n * `getApp().db` could not be tested at all, and an untestable guard is one\n * nobody will ever watch fail.\n */\n async runGuarded<T>(work: (signal: AbortSignal) => Promise<T>): Promise<T> {\n return await runWithDeadline(\n work,\n this.limits.requestTimeoutMs,\n () => new RequestDeadlineError(),\n )\n }\n\n /**\n * Resolve the fairness key and consume one token, or return `undefined` when\n * NO distinguishing key exists.\n *\n * `undefined` means \"not applied\", never \"allowed by a shared bucket\" (D025,\n * F011). The precedence is: a server-verified principal first, a host-declared\n * client-IP source second, nothing third.\n *\n * Counting is FLAT — one request, one token (D029). What this surface governs\n * spans 0 to 2 queries and is flat in collection size, and both genuinely\n * variable shapes (search, per-document resolution) are bounded by other\n * mechanisms. A weighting table would price a spread that has already been\n * collapsed, and a new endpoint added without a weight would silently cost 1 —\n * a check that cannot fail.\n */\n consumeFairness(\n req: Request,\n subject: FairnessSubject,\n now?: number,\n ): FairnessOutcome | undefined {\n const resolved = this.resolveKey(req, subject)\n if (!resolved) return undefined\n const decision = this.store.consume(\n resolved.key,\n { tokens: this.budgetFor(subject), intervalMs: this.limits.intervalMs },\n now,\n )\n return { decision, keyClass: resolved.keyClass }\n }\n\n private budgetFor(subject: FairnessSubject): number {\n const declared = subject.budgetPerInstance ?? this.limits.anonymousTokensPerInstance\n // C5: clamped server-side. A credential cannot raise its own ceiling past\n // what the surface allows, and the clamp is silent by design — a credential\n // issued with a too-large budget is an operator error, not a caller's.\n return Math.min(declared, this.limits.maxBudgetPerInstance)\n }\n\n private resolveKey(\n req: Request,\n subject: FairnessSubject,\n ): { key: string; keyClass: Exclude<FairnessKeyClass, 'none'> } | undefined {\n if (subject.principalId !== undefined) {\n return { key: `principal:${subject.principalId}`, keyClass: 'principal' }\n }\n const ip = this.getClientIp?.(req)\n if (ip !== undefined && ip !== '') {\n return { key: `ip:${ip}`, keyClass: 'client-ip' }\n }\n return undefined\n }\n}\n","/**\n * `media()` field resolution on the public surface (plan/api phase 06, D021).\n *\n * A media field stores a bare uuid, and G1's most concrete gap is that a\n * database-less frontend cannot render CMS images at all: there is no\n * media-id→URL endpoint anywhere in the toolkit, and the only resolver requires\n * the database. This closes it by resolving the field to a projection whose URL\n * is derived at read time.\n *\n * **Everything that decides the URL lives OUTSIDE this package**, injected as\n * `ContentApiConfig.resolveMedia` — `content-api` is a leaf and cannot import\n * `@murumets-ee/media` or `@murumets-ee/storage`. What this module owns is the\n * wire shape, the budget claim, and the rule that an unresolved media field is\n * an absence like any other.\n *\n * **Why the shape is uniform even when nothing is wired.** With no provider,\n * every media field resolves to `null` rather than falling back to the stored\n * uuid. A uuid is not a weaker answer here, it is an unusable one — no endpoint\n * accepts it — and letting the wire TYPE vary per deployment is precisely the\n * unfalsifiable claim `_schema` exists to prevent. One rule, no per-deployment\n * variance.\n */\n\nimport type { Entity, FieldConfig, MediaField } from '@murumets-ee/entity'\nimport { type CacheTagSink, MEDIA_TAG_ENTITY } from './cache-tags.js'\nimport { isInternalField } from './query-params.js'\nimport type { ResolutionBudget } from './resolution-budget.js'\n\n/**\n * One generated image file — a `srcset` candidate. `width`/`height` are the\n * file's own, never the ladder rung that was requested (images D003).\n */\nexport interface MediaSource {\n url: string\n width: number\n height: number\n}\n\n/**\n * One media item as it reaches the wire.\n *\n * Structurally identical to `@murumets-ee/media`'s `PublicMediaProjection`; the\n * app that wires the two is where TypeScript checks they agree, exactly as for\n * `ContentApiResolution` and `ContentApiMenus`.\n */\nexport interface MediaProjection {\n id: string\n /** A PUBLIC url. Never signed — see {@link ContentApiConfig.resolveMedia}. */\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n /** Translatable on the Media entity — this projection is locale-dependent. */\n alt: string | null\n /** Translatable on the Media entity — this projection is locale-dependent. */\n title: string | null\n /** Variant urls by image-style name, inlined as data (D008: a client derives nothing). */\n variants: Record<string, string>\n /**\n * Every generated file by SHAPE name (`natural` = the photograph's own ratio),\n * ascending by width, each with the width and height the FILE actually has —\n * what a `srcset` is built from (images D007, lumi-cms#1160). A shape the\n * photograph has no files for yet is absent. Bounded by configuration, like\n * `variants`.\n */\n sources: Record<string, MediaSource[]>\n /**\n * The AVIF copy of `sources`, by the same shape names (images PR12), for a\n * `<picture><source type=\"image/avif\">`. ABSENT — never `{}` — when there is\n * none: AVIF is off (the default) or the catch-up has not reached the\n * photograph, so a site that has not turned it on sends what it did before.\n * A shape is listed only when its AVIF files have exactly the widths of its\n * `sources` list. Never an AVIF file in `sources` itself.\n */\n avifSources?: Record<string, MediaSource[]>\n /**\n * Where the subject is, as `{ x, y }` fractions of the image (images D002).\n * ALWAYS present — `{ x: 0.5, y: 0.5 }` for a row that has none — so no\n * consumer branches on its absence (D010). A shaped slot crops around it; the\n * `object-position` it needs depends on the slot's own ratio, so it is\n * computed at render and never published here (`@murumets-ee/media/crop`).\n */\n focalPoint: { x: number; y: number }\n /**\n * The blurred preview a render draws while the photograph loads — a\n * `data:image/webp` or `data:image/png` base64 URL of a tiny image of it, or\n * `null` when there is none (images PR11). Validated by the provider before\n * it gets here; a consumer that writes it into a `style` validates again\n * (`@murumets-ee/blocks` does).\n */\n blurDataUrl: string | null\n}\n\n/**\n * Host-injected media resolver. Ids that do not exist, are unpublished for this\n * caller, or whose file is not public must be ABSENT from the returned map —\n * one absence, several causes, per phase 05's failure semantics.\n */\nexport type ResolveMedia = (\n ids: readonly string[],\n opts?: { locale?: string | undefined },\n) => Promise<Map<string, MediaProjection>>\n\n/** The declared, non-internal `media` fields of an entity. */\nexport function mediaFieldsOf(entity: Entity): Array<[string, MediaField]> {\n const out: Array<[string, MediaField]> = []\n for (const [name, config] of Object.entries(entity.allFields as Record<string, FieldConfig>)) {\n if (config.type !== 'media' || isInternalField(name, config)) continue\n out.push([name, config])\n }\n return out\n}\n\nexport interface MediaResolutionDeps {\n /** The SAME budget references and blocks draw on — not a fifth ceiling. */\n budget: ResolutionBudget\n locale?: string | undefined\n resolveMedia?: ResolveMedia | undefined\n /**\n * Where resolved media ids are recorded for cache-tag emission (D055), under\n * the {@link MEDIA_TAG_ENTITY} namespace. Write half only.\n *\n * Only ids the resolver actually RETURNED are recorded: an id that does not\n * exist, is unpublished for this caller, or whose file is not public is\n * absent from the map and did not compose anything, so tagging it would name\n * a row this response does not depend on.\n */\n cacheTags?: CacheTagSink | undefined\n logger?:\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined\n}\n\n/**\n * Replace every `media` field on `rows` with its projection, IN PLACE.\n *\n * `field.media()` has no cardinality option — it is single-valued — so there is\n * no one-versus-many split to design here.\n *\n * One provider call for the whole batch, after ONE budget claim covering every\n * distinct id: N rows referencing the same image cost one resolution, and a\n * page that blows the budget renders with the excess images unresolved rather\n * than draining the pool.\n */\nexport async function projectMedia(\n entity: Entity,\n rows: Record<string, unknown>[],\n deps: MediaResolutionDeps,\n): Promise<void> {\n if (rows.length === 0) return\n const fields = mediaFieldsOf(entity)\n if (fields.length === 0) return\n\n const ids = new Set<string>()\n for (const row of rows) {\n for (const [name] of fields) {\n // Absent means the caller excluded it via `?fields=` — leave it absent.\n // See the same guard in `projectReferences`: without it, a narrowing\n // request comes back WIDER than it asked for, with `cover: null` reading\n // as \"this row has no cover image\".\n if (!Object.hasOwn(row, name)) continue\n const value = row[name]\n if (typeof value === 'string' && value !== '') ids.add(value)\n }\n }\n\n let resolved = new Map<string, MediaProjection>()\n if (ids.size > 0 && deps.resolveMedia) {\n const wanted = [...ids]\n const granted = deps.budget.claim('media', wanted.length)\n const covered = wanted.slice(0, granted)\n if (covered.length > 0) {\n try {\n resolved = await deps.resolveMedia(covered, { locale: deps.locale })\n } catch (err) {\n // A broken media backend degrades every image on the page to absent —\n // it must not 500 the document. The operator gets the only signal, so\n // it is `warn`, not `debug`: the most likely cause is not a broken\n // backend at all but a MISSING PERMISSION. The shipped resolver reads\n // through `QueryClient`, whose `resolveAuthContext` THROWS when the\n // caller's role lacks `media:view` — so a deployment that granted\n // `article:view` and stopped there has every image on the site resolve\n // to null, and a signal below the default level cannot arrive to say\n // so.\n deps.logger?.warn(\n { entity: entity.name, err },\n 'content-api: media resolver threw — media fields resolve to null ' +\n '(if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n }\n }\n } else if (ids.size > 0) {\n // No provider wired. NOT counted as a budget drop — the budget reports work\n // the server DECLINED, and there was no work to decline here; conflating\n // the two would make \"you configured nothing\" read as \"you hit a ceiling\"\n // in the one log an operator uses to tell them apart. The dedicated line\n // below is the signal.\n deps.logger?.debug(\n { entity: entity.name, mediaFields: fields.map(([name]) => name) },\n 'content-api: no `resolveMedia` provider wired — media fields resolve to null',\n )\n }\n\n // REQUESTED, RESOLVED, AND SELF-CONSISTENT — all three, because `resolveMedia`\n // is host-injected and its returned map is not this package's data.\n //\n // The disclosure invariant (D055 §3) is that every id in the header is in the\n // body. Two different ways a misbehaving provider could break it, and the key\n // check alone only closes the first:\n //\n // - It returns an id nothing on this page asked for. The key check closes\n // that. `ids` (not `covered`) is the right set — an id outside the budget\n // grant was never fetched, so it cannot legitimately come back either.\n // - It returns the requested key mapped to a projection whose OWN `id` is\n // something else. The body then shows `projection.id` while the tag would\n // name the key — an id in the header appearing nowhere in the body, which\n // is the exact invariant, against the exact threat model, that the key\n // check was added for.\n //\n // So the tag is emitted only when the key the caller's row referenced and the\n // id the body will display are the SAME value. A provider that disagrees with\n // itself gets no tag rather than a misleading one. `handlePaths` refuses a\n // host-supplied `entityType` for the same reason; this is that discipline on\n // the id side.\n // `projection?.id`, not `projection.id`. The type says `MediaProjection`, but\n // this map came from a host-injected resolver — the same reason the two checks\n // above exist — and a JS host, or one building the map dynamically, can put a\n // null or undefined value in it. Reading `.id` off that throws, and this loop\n // sits AFTER the `try` that guards the resolver call, so the throw escapes to\n // the handler's catch and turns every media-bearing page into a 500.\n //\n // Distrusting the provider's keys while trusting its values was the\n // inconsistency: the write-back loop three lines down has always defended\n // itself with `resolved.get(value) ?? null`.\n for (const [id, projection] of resolved) {\n if (ids.has(id) && projection?.id === id) deps.cacheTags?.addDocument(MEDIA_TAG_ENTITY, id)\n }\n\n for (const row of rows) {\n for (const [name] of fields) {\n if (!Object.hasOwn(row, name)) continue\n const value = row[name]\n row[name] = typeof value === 'string' ? (resolved.get(value) ?? null) : null\n }\n }\n}\n","/**\n * Entity `reference()` resolution (plan/api phase 05, D013).\n *\n * Of the four things that reach the wire as an unusable identifier, this is the\n * only one with no existing resolver — and the only one that CANNOT be pushed\n * to a consumer. A `query()` field names its entity, a link carries\n * `{kind:'entity', entity, id}`, a media ref names its media; a reference field\n * carries an id and nothing else, with its target knowable solely from the\n * FIELD DEFINITION. A consumer without the schema cannot know what to fetch.\n *\n * **A projection by default: `{ id, entityType, title, href }`.** Not the\n * target's full DTO. The decisive property is structural rather than economic:\n * the projection holds only scalars, so there is nothing at depth 2 to expand\n * and unbounded recursion is impossible BY CONSTRUCTION rather than prevented\n * by a depth counter. A counter is a check that has to work; a shape with no\n * references in it is a property that cannot fail. `?populate=` opts into full\n * expansion at depth 1, where an expanded target's own references resolve to\n * projections — so the same property terminates the recursion one level down.\n *\n * **`href` comes from the routing seam, not from a slug.** The same\n * host-injected `resolveEntityHref` `resolveLinks` uses, so a reference and a\n * link to the same document produce the same URL by construction rather than by\n * two implementations agreeing.\n *\n * **Every failure looks identical (phase 05 §failure semantics).** Deleted,\n * unpublished-for-this-principal, not viewable, entity not whitelisted, or\n * simply beyond the resolution budget — all five yield the same absence, with\n * no error and no distinguishing marker. Splitting them would hand an anonymous\n * caller an existence oracle, and a reference field is a far cheaper and more\n * repeatable probe than guessing document URLs. The operator log is the only\n * place the distinction survives.\n */\n\nimport { runBounded } from '@murumets-ee/core'\nimport type { Entity, FieldConfig, ReferenceField } from '@murumets-ee/entity'\nimport { getEntityTitleField, isTemplateDrawn } from '@murumets-ee/entity'\nimport type { CacheTagSink } from './cache-tags.js'\nimport { isInternalField } from './query-params.js'\nimport type { ResolutionBudget } from './resolution-budget.js'\n\n/**\n * What a reference resolves to by default.\n *\n * `title` and `href` are nullable INDEPENDENTLY of the projection itself: the\n * target resolved (so `id`/`entityType` are real), but the entity may have no\n * usable title field, or may not be routable, in which case there is no href to\n * give. That is a different outcome from \"the target did not resolve\", which is\n * an absent projection — see {@link projectReferences}.\n */\nexport interface ReferenceProjection {\n id: string\n entityType: string\n title: string | null\n href: string | null\n}\n\n/**\n * Host-injected routing seam — a STRUCTURAL mirror of\n * `@murumets-ee/blocks`' `ResolveEntityHref`. Redeclared rather than imported\n * for exactly the reason spelled out on `ContentResolveResult`: `content-api`\n * is a leaf and `blocks` depends back on it, so the edge is a build cycle. The\n * app wires ONE implementation into both.\n *\n * **This one does not need a publish filter, and that is a property rather than\n * an oversight.** It is only ever asked about ids that already came back from a\n * published-only `QueryClient` read, so an unpublished target never reaches it.\n * The same seam wired into `resolveLinks` — which resolves arbitrary\n * editor-authored link targets — DOES need one, because nothing upstream of it\n * has proven the target visible.\n */\nexport type ResolveEntityHref = (target: {\n entity: string\n id: string\n locale?: string\n}) => string | null | Promise<string | null>\n\n/** Reads rows by id through the caller's published-only query path. */\nexport type ReadRowsByIds = (\n entity: Entity,\n ids: readonly string[],\n select: readonly string[] | undefined,\n) => Promise<Record<string, unknown>[]>\n\nexport interface ReferenceResolutionDeps {\n /** The ONE budget spanning references, populate, media and blocks. */\n budget: ResolutionBudget\n /** Resolved content locale for this request, if any. */\n locale?: string | undefined\n /**\n * Whitelist ∧ `view` for the CALLER. Returns the target entity only when this\n * principal may reach it; `undefined` otherwise, which makes an unreachable\n * target indistinguishable from a missing one.\n */\n reachableEntity: (name: string) => Entity | undefined\n readRowsByIds: ReadRowsByIds\n resolveEntityHref?: ResolveEntityHref | undefined\n /**\n * The DECLARED title of a row drawn by a stored template (block-library\n * D009) — the same value its by-id read serves as `title`. Absent, or\n * answering `null`, such a reference carries no title: the entity's admin\n * title column is never read for one.\n */\n templateDrawnTitle?:\n | ((target: Entity, row: Readonly<Record<string, unknown>>) => Promise<string | null>)\n | undefined\n /**\n * Where resolved reference ids are recorded for cache-tag emission (D055).\n *\n * The WRITE half only — this module contributes ids and decides nothing about\n * whether a tag is emitted. It is recorded here rather than re-derived at the\n * response site because this is the only place that knows both halves of the\n * pair: the group's target entity name and the ids that actually came back.\n * Walking the finished body instead would mean re-reading each row against\n * its field schema to recover what was already in hand.\n */\n cacheTags?: CacheTagSink | undefined\n /** Structured operator log — the only place a dropped resolution survives. */\n logger?:\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined\n /**\n * Run after a `?populate=` expansion has produced the target's full DTOs, so\n * whatever else the response cycle does to a row happens to expanded rows too\n * — media projection, today.\n *\n * A HOOK rather than a direct call, so this module stays unaware of media:\n * the two resolutions share a budget, not a dependency. Without it an\n * expanded author's avatar would come back as a bare uuid while the same\n * field on the root document came back as a projection, which is a wire shape\n * that varies by how the row was reached.\n */\n afterExpand?: ((entity: Entity, rows: Record<string, unknown>[]) => Promise<void>) | undefined\n}\n\n/** The declared, non-internal `reference` fields of an entity. */\nexport function referenceFieldsOf(entity: Entity): Array<[string, ReferenceField]> {\n const out: Array<[string, ReferenceField]> = []\n for (const [name, config] of Object.entries(entity.allFields as Record<string, FieldConfig>)) {\n // Internal fields never reach the projection at all, so resolving one would\n // be work whose result is discarded — and, if that ever changed, a way to\n // read a server-managed reference. Same predicate as every other boundary.\n if (config.type !== 'reference' || isInternalField(name, config)) continue\n out.push([name, config])\n }\n return out\n}\n\n/** True when a value is a plausible stored reference id. */\nfunction isId(value: unknown): value is string {\n return typeof value === 'string' && value.length > 0\n}\n\n/**\n * Read the stored ids out of one row's reference field.\n *\n * `cardinality: 'many'` is a `uuid[]` column and `'one'` a bare `uuid`, so this\n * normalises both onto a list and remembers which it was.\n */\nfunction idsOf(value: unknown, cardinality: 'one' | 'many'): string[] {\n if (cardinality === 'many') {\n if (!Array.isArray(value)) return []\n return value.filter(isId)\n }\n return isId(value) ? [value] : []\n}\n\n/**\n * Resolve every reference field on `rows` IN PLACE.\n *\n * `populate` names the fields to expand fully instead of projecting; the\n * expanded DTOs' own references are then projected by a nested call with an\n * EMPTY populate set, which is what makes depth-1 terminate structurally.\n *\n * **What replaces the stored value.**\n * - `cardinality: 'one'` — the projection (or expanded DTO), or `null` when\n * the target did not resolve.\n * - `cardinality: 'many'` — an array with the unresolved entries REMOVED, not\n * nulled. A consumer rendering a list of related documents wants the ones it\n * can render; a `null` hole would force every consumer to filter, and the\n * positions carry no meaning the caller could act on anyway.\n */\nexport async function projectReferences(\n entity: Entity,\n rows: Record<string, unknown>[],\n populate: ReadonlySet<string>,\n deps: ReferenceResolutionDeps,\n /**\n * Depth guard for the `?populate=` recursion. Depth 1 is the ONLY expansion\n * level (D013); this exists so a future edit that reuses this function\n * cannot silently open a second one, not because the current call graph could\n * reach it — the nested call passes an empty `populate`, which produces\n * projections, which contain no references.\n */\n depth = 0,\n): Promise<void> {\n if (rows.length === 0) return\n const fields = referenceFieldsOf(entity)\n if (fields.length === 0) return\n\n // Group every (target entity, id) pair across every row and field, so N rows\n // pointing at the same target cost ONE read rather than N. The group key is\n // the target entity plus whether it is being expanded, because those two\n // shapes read different columns.\n interface Group {\n target: Entity\n expand: boolean\n ids: Set<string>\n }\n const groups = new Map<string, Group>()\n /** Per (row, field) the ids to look up, so the write-back stays positional. */\n const perCell: Array<{\n row: Record<string, unknown>\n field: string\n cardinality: 'one' | 'many'\n groupKey: string | null\n ids: string[]\n }> = []\n\n for (const row of rows) {\n for (const [name, config] of fields) {\n // A field the caller excluded via `?fields=` is ABSENT from the shaped\n // row, and must stay absent. Without this guard the write-back below\n // resurrects it as `null` / `[]`, so `?fields=id,title` answers\n // `{id, title, author: null, tags: []}` — a consumer reads `author: null`\n // as \"this article has no author\" when it simply was not requested. That\n // also contradicts `parseFieldSelection`'s own invariant that a narrowing\n // request can never widen back to the full row.\n if (!Object.hasOwn(row, name)) continue\n const ids = idsOf(row[name], config.cardinality)\n const expand = depth === 0 && populate.has(name)\n // Whitelist ∧ view for the CALLER, not for the server. An unreachable\n // target resolves to nothing at all — same as deleted (D006).\n const target = deps.reachableEntity(config.entity)\n if (!target || ids.length === 0) {\n perCell.push({ row, field: name, cardinality: config.cardinality, groupKey: null, ids })\n continue\n }\n const groupKey = `${target.name}::${expand ? 'full' : 'projection'}`\n let group = groups.get(groupKey)\n if (!group) {\n group = { target, expand, ids: new Set() }\n groups.set(groupKey, group)\n }\n for (const id of ids) group.ids.add(id)\n perCell.push({ row, field: name, cardinality: config.cardinality, groupKey, ids })\n }\n }\n\n // One resolved-row map per group, filled below. A group whose budget claim\n // came up short simply has fewer entries — every id it could not cover then\n // resolves to absence, like any other unresolvable target.\n const resolved = new Map<string, Map<string, ReferenceProjection | Record<string, unknown>>>()\n\n for (const [groupKey, group] of groups) {\n const ids = [...group.ids]\n // Claim BEFORE the read, never after: a budget checked afterwards has\n // already paid for the overrun.\n const granted = deps.budget.claim(group.expand ? 'populate' : 'reference', ids.length)\n const covered = ids.slice(0, granted)\n if (covered.length === 0) {\n resolved.set(groupKey, new Map())\n continue\n }\n\n const titleField = getEntityTitleField(group.target)\n // A row DRAWN BY A STORED TEMPLATE (block-library D009) crosses with the\n // values its site's detail bindings name and no other column — and the\n // admin title field is not one of them (a room's is its `code`). So that\n // column is never read for one: its title is the DECLARED one, projected by\n // the host's provider from the whole row (the same value `/:id` serves), or\n // `null` when no provider is wired.\n const templateDrawn = isTemplateDrawn(group.target)\n const declaredTitle = templateDrawn ? deps.templateDrawnTitle : undefined\n // A projection needs two columns; an expansion needs the whole public row —\n // and so does projecting a declared title, which reads columns this module\n // cannot name.\n const select =\n group.expand || declaredTitle !== undefined\n ? undefined\n : templateDrawn\n ? ['id']\n : dedupe(['id', titleField])\n const found = await deps.readRowsByIds(group.target, covered, select)\n\n const byId = new Map<string, ReferenceProjection | Record<string, unknown>>()\n if (group.expand) {\n for (const row of found) {\n const id = row.id\n if (isId(id)) byId.set(id, row)\n }\n // Depth 1: the expanded DTOs' OWN references become projections. Empty\n // populate set → projections only → no references inside them → nothing\n // left to expand. The recursion terminates on the SHAPE, not on `depth`.\n await projectReferences(group.target, found, new Set(), deps, depth + 1)\n // ...and whatever else the response does to a row, so an expanded row is\n // shaped like a root one rather than like a half-processed database read.\n await deps.afterExpand?.(group.target, found)\n } else {\n const hrefs = await resolveHrefs(group.target.name, found, deps)\n for (const [index, row] of found.entries()) {\n const id = row.id\n if (!isId(id)) continue\n const rawTitle = templateDrawn\n ? ((await declaredTitle?.(group.target, row)) ?? null)\n : row[titleField]\n byId.set(id, {\n id,\n entityType: group.target.name,\n title: typeof rawTitle === 'string' ? rawTitle : null,\n href: hrefs[index] ?? null,\n })\n }\n }\n // Every id in `byId` came back from the read AND is referenced by at least\n // one cell (the group's id set was built from the cells), so these are\n // exactly the target rows that compose the body — no more, no less. Added\n // AFTER the primary rows the handler tagged, which is what makes an\n // over-budget response degrade to \"the page's own rows plus the\n // collection\" rather than to an arbitrary 128 (D055 §5).\n for (const id of byId.keys()) deps.cacheTags?.addDocument(group.target.name, id)\n resolved.set(groupKey, byId)\n }\n\n for (const cell of perCell) {\n const byId = cell.groupKey === null ? undefined : resolved.get(cell.groupKey)\n if (cell.cardinality === 'many') {\n // Unresolved entries are REMOVED, not nulled — see the JSDoc.\n cell.row[cell.field] = cell.ids\n .map((id) => byId?.get(id))\n .filter(\n (value): value is ReferenceProjection | Record<string, unknown> => value !== undefined,\n )\n continue\n }\n const first = cell.ids[0]\n cell.row[cell.field] = first === undefined ? null : (byId?.get(first) ?? null)\n }\n}\n\n/** `[...new Set(xs)]`, kept local so the intent reads at the call site. */\nfunction dedupe(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * Resolve one batch of rows to hrefs through the injected seam, at bounded peak\n * concurrency (the seam is an independent DB lookup per call — the same\n * amplification shape F009 records for `resolveLinks`).\n *\n * A seam that throws yields `null` for that row rather than failing the\n * request: a broken routing lookup must degrade to an unlinked reference, not\n * to a 500 on every page that carries one.\n */\nasync function resolveHrefs(\n entityName: string,\n rows: readonly Record<string, unknown>[],\n deps: ReferenceResolutionDeps,\n): Promise<Array<string | null>> {\n const seam = deps.resolveEntityHref\n if (!seam) return rows.map(() => null)\n return runBounded(rows, async (row) => {\n const id = row.id\n if (!isId(id)) return null\n try {\n return (\n (await seam({\n entity: entityName,\n id,\n ...(deps.locale !== undefined && { locale: deps.locale }),\n })) ?? null\n )\n } catch (err) {\n deps.logger?.debug(\n { entity: entityName, id, err },\n 'content-api: resolveEntityHref threw — reference resolves without an href',\n )\n return null\n }\n })\n}\n","/**\n * Response helpers — JSON envelope builders.\n */\n\n/**\n * The COMPLETE machine-readable error-code set this API can emit.\n *\n * Declared ONCE, here, beside the only function that can produce one: the\n * compiler refuses a code that isn't in this tuple, and `GET /_schema` publishes\n * the tuple verbatim. So a new code cannot be introduced without landing in both\n * the type system and the contract in the same edit — there is no second,\n * hand-kept list to forget (the drift shape D031 makes a hard requirement, and\n * the mechanism behind the SDK that drifted 4.5 months undetected).\n *\n * `code` is machine contract; `message` is not, and is free to be specific.\n */\nexport const ERROR_CODES = [\n 'NOT_FOUND',\n 'VALIDATION_ERROR',\n 'INTERNAL_ERROR',\n // plan/api PR 6 (D025). TWO codes rather than the one D031 anticipated, and\n // the split earns its keep: a code names what the client should do NEXT.\n // `RATE_LIMITED` says \"retry after your window resets, with the same\n // request\"; `SERVICE_OVERLOADED` says \"the origin is shedding load, back off\n // and expect this to clear independently of anything you did\". A client that\n // conflated them would retry a saturated origin on a per-caller schedule,\n // which is the wrong behaviour.\n 'RATE_LIMITED',\n 'SERVICE_OVERLOADED',\n] as const\n\n/** One of {@link ERROR_CODES}. */\nexport type ContentApiErrorCode = (typeof ERROR_CODES)[number]\n\nexport function jsonResponse(\n data: unknown,\n status = 200,\n extraHeaders?: Record<string, string>,\n): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: {\n 'Content-Type': 'application/json',\n ...extraHeaders,\n },\n })\n}\n\n/**\n * EVERY refusal this API emits is unstorable. Unconditionally, for every caller,\n * on every path (SD005).\n *\n * A 404 with no explicit freshness is HEURISTICALLY cacheable (RFC 9111 §4.2.2;\n * RFC 9110 §15.1 lists 404 among the twelve heuristically-cacheable codes,\n * verified verbatim in R15), and a 404 is exactly how this surface refuses: an\n * entity outside the whitelist, a role without `view`, an unlisted menu handle\n * and a row that does not exist are ONE answer (D006). So emitting no directive\n * would not withhold a policy — it would delegate the policy to whichever\n * intermediary happens to be deployed. Stating one is not optional.\n *\n * **Why UNIFORM, which is SD005 amending D037.** D037 asked for 4xx to get \"the\n * same role-aware treatment as 2xx\", on the argument that a public-role 404 is\n * the anonymous 404 and so is safely shareable. This PR tried that, and then\n * tried a caller-independent cacheable variant, before arriving here. Both\n * earlier positions were defensible; both cost more than the benefit was worth:\n *\n * - Role-aware storability made a valid-but-unpermitted credential's 404\n * distinguishable from an anonymous one, which is a credential-validity\n * oracle and breaks D006 — where *indistinguishable* means status, body AND\n * headers.\n * - Caller-independent storability fixed the oracle and left a subtler hole: a\n * refusal's BODY does not vary, but whether you get a refusal AT ALL does\n * (`hasViewPermission` is per-role). A stored 404 keyed without a credential\n * selector is replayed to the principal who would have received a 200, whose\n * request then never reaches the origin at all.\n *\n * **What the uniform rule buys is not the deleted branch — it is the deleted\n * class of question.** With no per-caller variation here, D006's byte-identity\n * requirement costs nothing at runtime and cannot be reintroduced by a future\n * header. `RateLimit-*` (which genuinely varies per caller and genuinely belongs\n * on a 429), cache tags, and `ETag` can all be added without a byte-identity\n * audit. A refusal is identical across callers by CONSTRUCTION rather than by a\n * check somebody has to keep right.\n *\n * **The cost, stated rather than discovered:** a 404 flood is 100% origin\n * traffic rather than absorbed at the edge. That is affordable because the same\n * attack is now metered twice over by mechanisms D037 could not assume when it\n * was written — the keyless survival guard and the fairness limit bound arrival,\n * and D042 makes a fabricated-key request a 400 decided before any database\n * work. Edge absorption is re-addable later by widening exactly one function.\n *\n * Written AFTER `extraHeaders` so no call site can weaken it. One honest\n * qualification: the spread only overwrites an identically-CASED key, and\n * `Headers` would append rather than replace a differently-cased one\n * (`cache-control` + `Cache-Control` yields both, comma-joined). Not reachable\n * today — every `extraHeaders` producer in this package uses exact casing and no\n * host-supplied key ever enters that map — and noted rather than defended\n * against, because the guard would be a check on a caller that does not exist.\n */\nconst REFUSAL_CACHE_CONTROL = { 'Cache-Control': 'no-store' } as const\n\n/**\n * The declared code of every error response this module built, keyed by the\n * response itself.\n *\n * Dispatch has to report `code` to the monitoring seam (D035), and it holds the\n * `Response` rather than the arguments that produced it — the body is a stream\n * by then, so reading the code back off the wire would mean cloning and parsing\n * it. Recording the fact at the ONE chokepoint every refusal passes through\n * makes \"the seam fires with the right code on every error response\" a property\n * of construction, instead of a rule each of the ~30 call sites has to\n * remember. The alternative shapes were both worse: threading a reporter\n * through every call site is the per-call-site enrichment D032 rejected, and a\n * marker header would put an internal detail on the wire.\n *\n * `WeakMap`, so an entry dies with the response it describes.\n */\nconst ERROR_CODE_BY_RESPONSE = new WeakMap<Response, ContentApiErrorCode>()\n\n/**\n * The code {@link errorResponse} stamped on `res`, or `undefined` if `res` did\n * not come from this module.\n */\nexport function errorCodeOf(res: Response): ContentApiErrorCode | undefined {\n return ERROR_CODE_BY_RESPONSE.get(res)\n}\n\n/**\n * `message` is for a human reading the response; `code` is the machine\n * contract. **A message may never be built from caller input or from a caught\n * exception** — every one is a literal today, and the 500 path is the model:\n * the real detail goes to the logger, the fixed string goes on the wire.\n */\nexport function errorResponse(\n message: string,\n code: ContentApiErrorCode,\n status: number,\n extraHeaders?: Record<string, string>,\n): Response {\n // SD005: the directive is UNCONDITIONAL and is written AFTER `extraHeaders`,\n // so no call site can weaken it — including the three pre-principal refusals\n // and the ~40 that pass bare CORS headers. This function is the ONE owner of\n // \"is a refusal stored\"; `buildRefusalHeaders` owns only what it varies on.\n const res = jsonResponse({ error: { status, message, code } }, status, {\n ...extraHeaders,\n ...REFUSAL_CACHE_CONTROL,\n })\n ERROR_CODE_BY_RESPONSE.set(res, code)\n return res\n}\n\nexport function notFoundResponse(extraHeaders?: Record<string, string>): Response {\n return errorResponse('Not found', 'NOT_FOUND', 404, extraHeaders)\n}\n\n/**\n * `429 RATE_LIMITED` — you specifically have spent your budget (D025).\n *\n * `Retry-After` is sanctioned for 429 by RFC 6585 §4 (\"MAY include a Retry-After\n * header\"), the document that DEFINES 429 — not by RFC 9110 §10.2.3, which\n * scopes the field normatively to 503 and 3xx. Both citations are legitimate and\n * they are different documents; R15 verified this because attributing it to 9110\n * is the easy mistake.\n */\nexport function rateLimitedResponse(\n retryAfterSeconds: number,\n extraHeaders?: Record<string, string>,\n): Response {\n return errorResponse('Rate limit exceeded', 'RATE_LIMITED', 429, {\n ...extraHeaders,\n 'Retry-After': String(retryAfterSeconds),\n })\n}\n\n/**\n * `503 SERVICE_OVERLOADED` — the server is at capacity, this is not about you\n * (D025).\n *\n * Distinct from 429 because the correct client behaviour differs: a 429 clears\n * when the caller's own window resets, a 503 clears when the origin recovers,\n * independently of anything the caller did.\n */\nexport function serviceOverloadedResponse(\n retryAfterSeconds: number,\n extraHeaders?: Record<string, string>,\n): Response {\n return errorResponse('Service overloaded', 'SERVICE_OVERLOADED', 503, {\n ...extraHeaders,\n 'Retry-After': String(retryAfterSeconds),\n })\n}\n","/**\n * Visitor search — `GET /_search` (davatoidud-toolkit D001).\n *\n * ONE ranked query across the entity types a SITE chose to offer, so a search\n * box on a public site needs no site-specific code and a frontend never merges\n * per-entity `?search=` calls itself (which loses cross-type ranking and costs\n * N requests per query).\n *\n * This module holds the parts that are pure: the parameter schema, which types\n * a request may search, how per-type hits merge into one ranked list, the wire\n * shape, and the per-visitor limit. The handler (`handler.ts` →\n * `handleSearch`) owns the database reads, because those must run through the\n * same `QueryClient` + site partition + publish filter every other read does.\n *\n * ## What bounds the work\n *\n * - The TYPE count: at most `MAX_SITE_SEARCH_TYPES` (8, core's site-registry\n * cap) — one bounded query per type, run sequentially.\n * - The ROW count: at most {@link MAX_SEARCH_RESULTS} per type and in total.\n * - The visitor's RATE: {@link SearchRateLimiter}, keyed per visitor.\n * - Everything the rest of the surface has: the keyless survival guard (its\n * semaphore and wall clock) wraps this route like every other.\n */\n\nimport { LOCALE_PATTERN } from '@murumets-ee/core'\nimport {\n createClientIpResolver,\n InMemoryRateLimitStore,\n type RateLimitDecision,\n type RateLimitStore,\n} from '@murumets-ee/core/limits'\nimport type { Entity } from '@murumets-ee/entity'\nimport { getSearchableConfig, isTemplateDrawn } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport type { MediaProjection } from './media.js'\nimport { mediaFieldsOf } from './media.js'\nimport { MAX_SEARCH_LENGTH, MIN_SEARCH_LENGTH, normalizeSearchLengthInput } from './query-params.js'\n\n/** Results returned when the caller sends no `?limit=`. */\nexport const DEFAULT_SEARCH_RESULTS = 20\n\n/** Most results one search returns — in total, and per type. A 400 above it, never a clamp. */\nexport const MAX_SEARCH_RESULTS = 50\n\n/** Longest `?types=` accepted: eight 100-character names and their commas. */\nexport const MAX_SEARCH_TYPES_PARAM_LENGTH = 8 * 101\n\n/** Longest summary served — a results page shows a line or two, not a body. */\nexport const MAX_SEARCH_SUMMARY_LENGTH = 300\n\n/** Longest title served. */\nconst MAX_SEARCH_TITLE_LENGTH = 300\n\n/**\n * `GET /_search` parameters. Strict: any other key is a 400, like every other\n * route here. `q` follows `?search=`'s length rules exactly (D046) except that\n * it is REQUIRED — whitespace alone is \"no query\", and a search with no query\n * is a malformed request, not a request for everything.\n */\nexport const searchParamsSchema = z\n .object({\n q: z\n .string()\n .max(MAX_SEARCH_LENGTH)\n .refine((raw) => normalizeSearchLengthInput(raw).length >= MIN_SEARCH_LENGTH, {\n message: `q must be at least ${MIN_SEARCH_LENGTH} characters`,\n }),\n types: z.string().max(MAX_SEARCH_TYPES_PARAM_LENGTH).optional(),\n limit: z.coerce.number().int().min(1).default(DEFAULT_SEARCH_RESULTS),\n locale: z.string().regex(LOCALE_PATTERN).optional(),\n })\n .strict()\n\n/** One search result, as it crosses the wire. */\nexport interface SearchHit {\n /** The entity type — a name a frontend may map to its own words. */\n type: string\n /** The type's English singular label — the fallback for a frontend with no words of its own. */\n typeLabel: string\n id: string\n title: string\n summary: string | null\n image: MediaProjection | null\n /** Locale-relative canonical path, or `null` when the document has none. */\n path: string | null\n}\n\n/**\n * Can `entity` be searched by a visitor at all, independent of who asks?\n *\n * - It must declare `searchable()` — search is explicit per entity (CLAUDE.md\n * \"Entity Search\"), and there is no implicit fallback here either.\n * - It must NOT be drawn by a template (block-library D009). Such an entity is\n * served narrowed to what its page draws, and its list route does not exist,\n * because matching over a column that is not served is an oracle for that\n * column. A search is a match, so the same reasoning closes it here.\n * - It must not be a singleton: one row is a page, not a result set.\n */\nexport function isVisitorSearchable(entity: Entity): boolean {\n if (getSearchableConfig(entity) === undefined) return false\n if (isTemplateDrawn(entity)) return false\n return entity.kind !== 'singleton'\n}\n\n/**\n * The types this request searches: the site's configured list, narrowed by\n * `?types=` when given. A requested type the site does not offer is REFUSED —\n * the caller gets `null` and the handler answers 404, exactly as it would for\n * an entity it may not read, so `?types=` can never widen what a site searches\n * nor tell a caller which entities exist.\n *\n * Order follows the SITE's list, not the request's, so two requests naming the\n * same set in a different order are the same query.\n */\nexport function selectSearchTypes(\n siteTypes: readonly string[],\n requested: string | undefined,\n): string[] | null {\n if (requested === undefined) return [...siteTypes]\n const names = requested\n .split(',')\n .map((name) => name.trim())\n .filter((name) => name !== '')\n if (names.length === 0) return null\n for (const name of names) if (!siteTypes.includes(name)) return null\n return siteTypes.filter((name) => names.includes(name))\n}\n\n/**\n * The field a result's image comes from: the entity's first image-accepting\n * `media` field, in declaration order. `null` when it has none.\n */\nexport function searchImageField(entity: Entity): string | null {\n for (const [name, config] of mediaFieldsOf(entity)) {\n const accept = config.accept\n if (accept === undefined || accept.length === 0) return name\n if (accept.some((mime) => mime === '*/*' || mime.startsWith('image/'))) return name\n }\n return null\n}\n\n/** A value `projectMedia` left in place of a media id — a projection, or `null` for none. */\nexport function isMediaProjection(value: unknown): value is MediaProjection {\n if (typeof value !== 'object' || value === null) return false\n const v = value as { id?: unknown; url?: unknown }\n return typeof v.id === 'string' && typeof v.url === 'string'\n}\n\n/**\n * The type's label for the wire — `admin.labelSingular`, else the entity name\n * read as words (`site_page` → `Site page`). English either way: it is the\n * FALLBACK a frontend shows when it has no word of its own for the type.\n */\nexport function searchTypeLabel(entity: Entity): string {\n const label = entity.admin?.labelSingular\n if (typeof label === 'string' && label.trim() !== '') return label.trim()\n const words = entity.name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/[_-]+/g, ' ')\n .trim()\n .toLowerCase()\n return words === '' ? entity.name : `${words.charAt(0).toUpperCase()}${words.slice(1)}`\n}\n\nfunction clip(value: unknown, max: number): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.replace(/\\s+/g, ' ').trim()\n if (trimmed === '') return null\n return trimmed.length > max ? `${trimmed.slice(0, max - 1).trimEnd()}…` : trimmed\n}\n\n/**\n * Conventional summary fields, in priority order — the field a visitor would\n * call \"the short description\". Read before the entity's `searchable()`\n * projection, whose `description` is written for the ADMIN's result list and\n * is often an identifier there (a slug, an email).\n */\nexport const SUMMARY_FIELD_CANDIDATES = [\n 'summary',\n 'excerpt',\n 'description',\n 'intro',\n 'lead',\n] as const\n\n/** Field types whose value is plain text a results page can show as-is (not richtext JSON). */\nconst SUMMARY_FIELD_TYPES: ReadonlySet<string> = new Set(['text'])\n\n/**\n * Title and summary of one public row.\n *\n * The title is the entity's row-title field (`getEntityTitleField`, read off\n * the locale-merged row). The summary is the first conventional summary field\n * ({@link SUMMARY_FIELD_CANDIDATES}) the entity declares as plain text, else\n * its `searchable()` projection's `description`. Either way it is read from\n * the PUBLIC row — internal fields are already stripped by the read — so\n * whatever comes back is public data. A projection that throws loses the\n * summary, never the result.\n */\nexport function describeSearchRow(\n entity: Entity,\n row: Readonly<Record<string, unknown>>,\n titleField: string,\n): { title: string; summary: string | null } {\n let summary: string | null = null\n const fields = entity.allFields as Record<string, { type: string } | undefined>\n for (const name of SUMMARY_FIELD_CANDIDATES) {\n const config = fields[name]\n if (config === undefined || !SUMMARY_FIELD_TYPES.has(config.type)) continue\n summary = clip(row[name], MAX_SEARCH_SUMMARY_LENGTH)\n if (summary !== null) break\n }\n let fallbackTitle: string | null = null\n const config = getSearchableConfig(entity)\n if (config) {\n try {\n const projected = config.projection({ ...row })\n summary ??= clip(projected.description, MAX_SEARCH_SUMMARY_LENGTH)\n fallbackTitle = clip(projected.label, MAX_SEARCH_TITLE_LENGTH)\n } catch {\n // An author's projection that throws on a row costs that row its\n // projected summary (and the label fallback) — the title still comes\n // from the title field.\n }\n }\n const title =\n clip(row[titleField], MAX_SEARCH_TITLE_LENGTH) ??\n fallbackTitle ??\n (typeof row.id === 'string' ? row.id : '')\n return { title, summary }\n}\n\n/** One type's hits, in that type's own rank order, each marked title hit or not. */\nexport interface RankedTypeHits<T = SearchHit> {\n readonly hits: ReadonlyArray<{ readonly hit: T; readonly titleHit: boolean }>\n}\n\n/**\n * Merge per-type rankings into one list of at most `limit`.\n *\n * Every title hit ranks above every body hit (D001: \"title hits above body\n * hits\"). Within each tier the types are INTERLEAVED by their own rank —\n * first of each type, then second of each — because the per-type ranks are\n * not comparable numbers (one type ranks by `ts_rank`, another has no\n * tsvector at all), so a sort on them would be a sort on noise. Interleaving\n * by position is the honest merge of incomparable orders, and it keeps one\n * type with many weak matches from burying another's best.\n */\nexport function mergeSearchHits<T>(perType: readonly RankedTypeHits<T>[], limit: number): T[] {\n const out: T[] = []\n for (const tier of [true, false]) {\n const queues = perType.map((t) =>\n t.hits.filter((entry) => entry.titleHit === tier).map((entry) => entry.hit),\n )\n for (let position = 0; out.length < limit; position++) {\n let any = false\n for (const queue of queues) {\n const hit = queue[position]\n if (hit === undefined) continue\n any = true\n out.push(hit)\n if (out.length === limit) break\n }\n if (!any) break\n }\n }\n return out\n}\n\n/** Is `query` a case-insensitive substring of `title`? The title-tier test, in JS, matching the SQL `ILIKE`. */\nexport function isTitleHit(title: string, query: string): boolean {\n const needle = normalizeSearchLengthInput(query).toLocaleLowerCase()\n return needle !== '' && title.toLocaleLowerCase().includes(needle)\n}\n\n// ---------------------------------------------------------------------------\n// Per-visitor limit\n// ---------------------------------------------------------------------------\n\n/**\n * The header a site's own frontend forwards its VISITOR's address on.\n *\n * Honoured ONLY from a caller whose credential is bound to a site — the site\n * key a frontend holds server-side. That is what makes it more than obscurity\n * (`core/limits/client-ip.ts` rule 7): the guarantee that \"nobody reaches this\n * without passing through what writes it\" is not a deploy-topology claim here,\n * it is the credential. An anonymous caller sending it is ignored.\n */\nexport const VISITOR_ADDRESS_HEADER = 'x-lumi-visitor-address'\n\n/** Searches one visitor may run per window, per instance. */\nconst DEFAULT_SEARCHES_PER_VISITOR = 30\nconst DEFAULT_SEARCH_INTERVAL_MS = 60_000\n\n/** Tuning for {@link SearchRateLimiter}. Every field optional. */\nexport interface ContentApiSearchConfig {\n /** Searches one visitor may run per window, PER INSTANCE. Default 30. */\n searchesPerVisitorPerInstance?: number\n /** The window, in ms. Default 60 000. */\n intervalMs?: number\n /** The counter's store. Defaults to an in-memory, per-instance one. */\n store?: RateLimitStore\n /**\n * The client address of an ANONYMOUS caller — a browser fetching `_search`\n * itself, or a site that sends no key. Overrides `limits.getClientIp` for\n * the search budget only, so a mount can bound searches per address without\n * also turning on the surface-wide fairness limit. Use the toolkit's one\n * derivation (`proxyClientIp` from `@murumets-ee/search/admin`, or\n * `createClientIpResolver` from `@murumets-ee/core/limits`).\n */\n getClientIp?: (req: Request) => string | undefined\n}\n\n/** Who a search is counted against — for the log, never the wire. */\nexport type SearchKeyClass = 'principal' | 'visitor' | 'client-ip'\n\nfunction positiveInt(value: number | undefined, fallback: number, field: string): number {\n if (value === undefined) return fallback\n if (!Number.isSafeInteger(value) || value < 1) {\n throw new Error(`content-api search: \\`${field}\\` must be a positive integer (got ${value})`)\n }\n return value\n}\n\n/**\n * The visitor search's own budget — tighter than the surface's general fairness\n * budget, because one search is up to eight queries where a list is two.\n *\n * Keyed, in order, on: a server-verified principal; the visitor address a\n * SITE-KEYED caller forwards ({@link VISITOR_ADDRESS_HEADER}); the client\n * address the host declared (`limits.getClientIp`). When none exists the limit\n * is SKIPPED, never collapsed onto one shared bucket — the same rule, and the\n * same reason (F011), as the surface's fairness limit: a shared bucket is a\n * one-request outage for every visitor at once. The survival guard still binds.\n *\n * Built on `@murumets-ee/core/limits` — the shared store and the ONE\n * client-address derivation — rather than a private limiter (C3).\n */\nexport class SearchRateLimiter {\n private readonly store: RateLimitStore\n private readonly tokens: number\n private readonly intervalMs: number\n private readonly readVisitor = createClientIpResolver({\n header: VISITOR_ADDRESS_HEADER,\n // The frontend writes exactly ONE value; there is no chain to count.\n trustedProxyHops: 1,\n surface: 'content-api visitor search',\n })\n\n constructor(\n config: ContentApiSearchConfig | undefined,\n private readonly getClientIp: ((req: Request) => string | undefined) | undefined,\n ) {\n this.tokens = positiveInt(\n config?.searchesPerVisitorPerInstance,\n DEFAULT_SEARCHES_PER_VISITOR,\n 'searchesPerVisitorPerInstance',\n )\n this.intervalMs = positiveInt(config?.intervalMs, DEFAULT_SEARCH_INTERVAL_MS, 'intervalMs')\n this.store = config?.store ?? new InMemoryRateLimitStore()\n }\n\n /** Consume one search, or `undefined` when nothing distinguishes the caller. */\n consume(\n req: Request,\n caller: { principalId: string | undefined; site: string | undefined },\n now?: number,\n ): { decision: RateLimitDecision; keyClass: SearchKeyClass } | undefined {\n const key = this.keyFor(req, caller)\n if (key === undefined) return undefined\n return {\n decision: this.store.consume(\n `search:${key.key}`,\n { tokens: this.tokens, intervalMs: this.intervalMs },\n now,\n ),\n keyClass: key.keyClass,\n }\n }\n\n private keyFor(\n req: Request,\n caller: { principalId: string | undefined; site: string | undefined },\n ): { key: string; keyClass: SearchKeyClass } | undefined {\n if (caller.principalId !== undefined) {\n return { key: `principal:${caller.principalId}`, keyClass: 'principal' }\n }\n if (caller.site !== undefined) {\n const visitor = this.readVisitor(req.headers)\n if (visitor !== null) return { key: `visitor:${caller.site}:${visitor}`, keyClass: 'visitor' }\n }\n // A SITE-KEYED caller is a frontend server: its own address names the\n // server, not the visitor, so keying on it would put every visitor of the\n // site in one bucket — the outage F011 forbids. With no forwarded visitor\n // it is skipped. The client address keys only a caller with no site key: a\n // browser fetching `_search` itself.\n if (caller.site !== undefined) return undefined\n const ip = this.getClientIp?.(req)\n if (ip !== undefined && ip !== '') return { key: `ip:${ip}`, keyClass: 'client-ip' }\n return undefined\n }\n}\n","/**\n * Schema introspection endpoint — GET /_schema\n *\n * Describes the SERVED SURFACE, not only the entities (D008): the version this\n * instance mounts, the endpoint families it actually serves with their\n * server-owned ceilings, the response envelopes and the complete error-code set,\n * and the role-filtered entity metadata.\n *\n * Everything is DERIVED from the handler's live config rather than restated as\n * literals. That is the entire point: a `surface` advertising `_menu` when no\n * `menus` provider was injected, or a ceiling copy-pasted as `100` when the host\n * lowered it to 25, is worse than not publishing it at all — it is an\n * unfalsifiable claim, which is the mechanism (not merely the symptom) behind an\n * SDK generator that can drift from its server for months undetected.\n *\n * **The response is role-filtered and `no-store`, and every field inherits\n * both.** Only entities the caller's role can `view` are included, the surface\n * names endpoint FAMILIES rather than entities, the menu handle allowlist is\n * structurally unreachable from here (see {@link SchemaBuildInput.hasMenus}),\n * and a reference target is named only when the caller could reach it (F053).\n * An introspection endpoint that let a caller enumerate past the gate protecting\n * the endpoints it describes would be worse than no introspection at all.\n */\n\nimport {\n type Entity,\n type FieldConfig,\n getSearchableConfig,\n isTemplateDrawn,\n} from '@murumets-ee/entity'\nimport {\n CACHE_TAG_NAMESPACE,\n MAX_CACHE_TAG_HEADER_BYTES,\n MAX_CACHE_TAG_LENGTH,\n MAX_CACHE_TAGS_PER_RESPONSE,\n type ResolvedCacheTagsConfig,\n SHARED_SITE_TAG_SEGMENT,\n} from './cache-tags.js'\nimport type { ResolvedLimits } from './limits.js'\nimport type { MediaProjection } from './media.js'\nimport {\n DEFAULT_LIMIT,\n DEFAULT_OFFSET,\n DEFAULT_PATHS_LIMIT,\n isInternalField,\n MAX_CURSOR_LENGTH,\n MAX_ENTITY_NAME_LENGTH,\n MAX_MENU_HANDLE_LENGTH,\n MAX_POPULATE_FIELDS,\n MAX_POPULATE_LENGTH,\n MAX_RESOLVE_PATH_LENGTH,\n MAX_SEARCH_LENGTH,\n MIN_SEARCH_LENGTH,\n} from './query-params.js'\nimport type { ReferenceProjection } from './references.js'\nimport { ERROR_CODES } from './response.js'\nimport {\n DEFAULT_SEARCH_RESULTS,\n MAX_SEARCH_RESULTS,\n MAX_SEARCH_TYPES_PARAM_LENGTH,\n} from './search.js'\nimport type {\n SchemaCacheTags,\n SchemaEndpoint,\n SchemaEntity,\n SchemaField,\n SchemaLimits,\n SchemaParam,\n SchemaProjection,\n SchemaResponse,\n} from './types.js'\n\n/**\n * The wire shapes `SchemaField.wire` names (plan/api PR 5 — D008/D013/D021).\n *\n * A CONSTANT derived from the projection interfaces it describes, kept beside\n * nothing else so the one place to edit when a projection gains a key is\n * obvious. `ReferenceProjection` and `MediaProjection` are the source of truth;\n * this publishes them.\n */\nconst PROJECTIONS: {\n referenceProjection: SchemaProjection<ReferenceProjection>\n mediaProjection: SchemaProjection<MediaProjection>\n} = {\n referenceProjection: {\n fields: { id: 'string', entityType: 'string', title: 'string', href: 'string' },\n // The target resolved, but may have no usable title field and may not be\n // routable — those are different from \"did not resolve\".\n nullableFields: ['title', 'href'],\n nullable: true,\n arrayForCardinalityMany: true,\n },\n mediaProjection: {\n fields: {\n id: 'string',\n url: 'string',\n mimeType: 'string',\n width: 'number',\n height: 'number',\n alt: 'string',\n title: 'string',\n variants: 'object',\n sources: 'object',\n avifSources: 'object',\n focalPoint: 'object',\n blurDataUrl: 'string',\n },\n nullableFields: ['mimeType', 'width', 'height', 'alt', 'title', 'blurDataUrl'],\n optionalFields: ['avifSources'],\n nullable: true,\n // `field.media()` has no cardinality option — it is single-valued.\n arrayForCardinalityMany: false,\n },\n}\n\n/**\n * True when the named entity is reachable by the CALLING role — whitelisted in\n * `config.entities` AND passing the same `view` predicate that filters the\n * response's `entities` array.\n *\n * Threaded down rather than precomputed because visibility is PER-CALLER: the\n * answer depends on the requesting role, so it cannot be baked into an\n * entity-shaped conversion that has no caller context.\n */\ntype EntityVisibility = (entityName: string) => boolean\n\n/** Convert a FieldConfig to the public-safe SchemaField subset. */\nfunction toSchemaField(config: FieldConfig, isEntityVisible: EntityVisibility): SchemaField {\n const field: SchemaField = { type: config.type }\n\n if (config.required) {\n field.required = true\n }\n\n // Which fields actually respond to `?locale=` — unknowable from the outside\n // without it (F005).\n if (config.translatable) {\n field.translatable = true\n }\n\n if (config.type === 'select') {\n field.options = config.options\n }\n\n if (config.type === 'blockTree') {\n // `parseFieldSelection` drops it, silently, like every other refused name.\n field.selectable = false\n }\n\n if (config.type === 'media') {\n // Stores a uuid, emits a projection (or null). Without this a generated\n // client types `cover` as `string`.\n field.wire = 'mediaProjection'\n }\n\n if (config.type === 'reference') {\n field.cardinality = config.cardinality\n field.wire = 'referenceProjection'\n // The TARGET, but ONLY when this caller could actually reach it (F053).\n //\n // Naively emitting it hands any caller who can view `article` the names\n // `category` and `tag` — entities the whitelist withholds on purpose, whose\n // every route 404s. The data gate would hold and the introspection endpoint\n // would give the names up anyway. The same hole exists on the ROLE\n // dimension (entity A viewable, its target B not), not just the whitelist\n // one, and `isEntityVisible` closes both because it is derived from the\n // very set the `entities` array publishes.\n //\n // Nothing real is lost: an expansion can only expand a reference the caller\n // may fetch, so a target that 404s everywhere is not expandable and\n // publishing it buys a generator nothing. Omitted — not `null`, not a\n // sentinel — so an absent key stays indistinguishable from \"no target\n // declared\" (D006's denial-looks-like-absence shape).\n if (isEntityVisible(config.entity)) {\n field.entity = config.entity\n // `?populate=` can only expand a target the caller may fetch, so this\n // rides the SAME visibility gate as the target name rather than being a\n // second, independently-maintained condition.\n field.populatable = true\n }\n }\n\n return field\n}\n\n/** Convert an Entity to public-safe schema metadata. */\nexport function toSchemaEntity(entity: Entity, isEntityVisible: EntityVisibility): SchemaEntity {\n const allFields = entity.allFields as Record<string, FieldConfig>\n const fields: Record<string, SchemaField> = {}\n const templateDrawn = isTemplateDrawn(entity)\n\n for (const [name, config] of Object.entries(allFields)) {\n // Skip internal fields — via the shared predicate, so the schema advertises\n // exactly what the read path returns. The bare underscore check this\n // replaced missed fields marked `internal: true` under a plain name\n // (`path` / `depth` from `hierarchical()`, `deletedAt` / `deletedBy` from\n // `trashable()`), which `shapeDto` has always stripped — so `_schema`\n // promised an SDK consumer columns no response ever carried.\n if (isInternalField(name, config)) continue\n // A template-drawn entity's rows cross narrowed (block-library D009): of\n // its own columns only `id` is ever served, so only `id` is advertised.\n if (templateDrawn && name !== 'id') continue\n fields[name] = toSchemaField(config, isEntityVisible)\n }\n\n // D048 — publish WHICH fields `?search=` actually matches, per entity.\n //\n // The public search predicate is defined by what it INCLUDES: the non-internal\n // subset of `searchable().fields`, plus the main-table tsvector when one\n // exists, plus the locale-constrained translations `EXISTS`. Publishing that\n // set is what lets `searchable().defaultFilters` and `includes` be absent\n // because they were never in the definition, rather than because two branches\n // were left unwritten.\n //\n // It also closes F039's operator-facing half: an entity with no `searchable()`\n // declares no searchable fields here, so a consumer knows `?search=` will not\n // narrow rather than discovering it from a full unfiltered list that looks\n // like a corpus in which everything matched.\n //\n // Filtered through the SAME `isInternalField` predicate the search builder\n // applies, so the published set cannot name a field the predicate would drop\n // or the response body would hide.\n const searchableConfig = getSearchableConfig(entity)\n for (const fieldName of searchableConfig?.fields ?? []) {\n const field = fields[fieldName]\n if (field) field.searchable = true\n }\n\n return {\n name: entity.name,\n pluralName: `${entity.name}s`,\n // `Entity.kind` is optional; `'collection'` is its documented default.\n // Resolved here rather than passed through so a client never has to know\n // the default to read the contract.\n kind: entity.kind ?? 'collection',\n // The published contract must not disagree with the surface (D008): a\n // generated client reading `kind: 'collection'` alone would call routes\n // that 404 for this entity.\n ...(templateDrawn && { drawnBy: 'template' as const }),\n fields,\n }\n}\n\n// ---------------------------------------------------------------------------\n// Surface\n// ---------------------------------------------------------------------------\n\n/** `?locale=` — accepted by every read endpoint. */\nconst LOCALE_PARAM: SchemaParam = { type: 'string' }\n/** `?fields=` — comma-separated projection. */\nconst FIELDS_PARAM: SchemaParam = { type: 'string' }\n/**\n * `?populate=` — comma-separated reference fields to expand (D013).\n *\n * BOTH caps are published because they answer different questions: `maxLength`\n * bounds the raw string so it can be split at all, `maxItems` bounds how many\n * fields may be named. A client that read only one of them would still hit a\n * 400 it could not have predicted — which is the trial-and-400 discovery this\n * whole document exists to replace.\n */\nconst POPULATE_PARAM: SchemaParam = {\n type: 'string',\n maxLength: MAX_POPULATE_LENGTH,\n maxItems: MAX_POPULATE_FIELDS,\n}\n\n/**\n * The endpoint families this instance serves, with their EFFECTIVE ceilings.\n *\n * Families, never instances: `/:entity` describes every whitelisted entity's\n * list route and the surface never names one. Which entities exist is the\n * role-filtered `entities` array's job, and repeating it here would be a second\n * enumeration of the same set — an ungated one.\n *\n * `_schema` is deliberately absent. It is the bootstrap document: the one\n * response a client reads before it knows what the envelopes are, and therefore\n * the one endpoint that cannot describe itself through the contract.\n */\nfunction buildSurface(input: SchemaBuildInput): SchemaEndpoint[] {\n const { maxLimit, maxOffset, hasResolution, hasMenus, hasChrome } = input\n\n const endpoints: SchemaEndpoint[] = [\n {\n id: 'list',\n path: '/:entity',\n data: 'array',\n entityKind: 'collection',\n params: {\n // The default is published CLAMPED, exactly as the handler applies it.\n // Publishing the bare `DEFAULT_LIMIT` beside a host-lowered `max` emits\n // a self-contradicting object (`max: 10, default: 20`) describing a\n // value the server does not use — and a generator that seeds its own\n // param from `default` would send 20 and take a 400. This is the\n // copy-pasted-ceiling failure this file exists to prevent, in the one\n // field that was still a literal.\n limit: {\n type: 'number',\n min: 1,\n max: maxLimit,\n default: Math.min(DEFAULT_LIMIT, maxLimit),\n },\n offset: { type: 'number', min: 0, max: maxOffset, default: DEFAULT_OFFSET },\n sort: { type: 'string' },\n locale: LOCALE_PARAM,\n fields: FIELDS_PARAM,\n search: {\n type: 'string',\n maxLength: MAX_SEARCH_LENGTH,\n // D046. Published so a consumer disables its own search input below\n // the floor rather than discovering it by rejection.\n minLength: MIN_SEARCH_LENGTH,\n },\n cursor: { type: 'string', maxLength: MAX_CURSOR_LENGTH },\n populate: POPULATE_PARAM,\n // A template, like `:entity` in a path — `<field>` stands for any\n // non-internal field name on the entity being listed.\n 'filter[<field>]': { type: 'string' },\n },\n meta: {\n // `number | null` (D047) — `null` means \"not counted on this request\",\n // and `suppressedBy` names exactly which parameters cause it, so a\n // client READS the condition instead of inferring it from a null it did\n // not expect (D008: a client derives nothing).\n //\n // A searched list is the second endpoint on this side of a line phase 01\n // already drew — `_paths` omits `total` because its generator source has\n // no cheap count — and the rule was stated generally there: a collection\n // endpoint promises `limit` and `hasMore`; `total` is promised only where\n // a count is affordable.\n total: { type: 'number', nullable: true, suppressedBy: ['search'] },\n limit: { type: 'number' },\n offset: { type: 'number' },\n hasMore: { type: 'boolean' },\n nextCursor: { type: 'string', optional: true },\n },\n },\n {\n // A singleton IS the resource at `/:entity`. Same path as `list`,\n // discriminated by `entityKind`; no pagination, so no `meta`.\n id: 'singleton',\n path: '/:entity',\n data: 'object',\n entityKind: 'singleton',\n params: { locale: LOCALE_PARAM, fields: FIELDS_PARAM, populate: POPULATE_PARAM },\n },\n {\n id: 'count',\n path: '/:entity/count',\n data: 'object',\n entityKind: 'collection',\n params: { locale: LOCALE_PARAM },\n },\n {\n id: 'byId',\n path: '/:entity/:id',\n data: 'object',\n entityKind: 'collection',\n params: { locale: LOCALE_PARAM, fields: FIELDS_PARAM, populate: POPULATE_PARAM },\n },\n {\n id: 'bySlug',\n path: '/:entity/slug/:slug',\n data: 'object',\n entityKind: 'collection',\n params: { locale: LOCALE_PARAM, fields: FIELDS_PARAM, populate: POPULATE_PARAM },\n },\n ]\n\n // Per-DEPLOYMENT, not per-version: these 404 unless the app injected a\n // `resolution` provider, so two deployments of identical code serve\n // different surfaces.\n if (hasResolution) {\n endpoints.push(\n {\n id: 'resolve',\n path: '/_resolve',\n data: 'object',\n params: {\n path: { type: 'string', required: true, maxLength: MAX_RESOLVE_PATH_LENGTH },\n locale: { type: 'string', required: true },\n },\n },\n {\n id: 'paths',\n path: '/_paths',\n data: 'array',\n params: {\n locale: { type: 'string', required: true },\n entity: { type: 'string', required: true, maxLength: MAX_ENTITY_NAME_LENGTH },\n cursor: { type: 'string' },\n // Clamped for the same reason as `list.limit` above.\n limit: {\n type: 'number',\n min: 1,\n max: maxLimit,\n default: Math.min(DEFAULT_PATHS_LIMIT, maxLimit),\n },\n },\n // NO `total` — deliberately. The generator source has no cheap count,\n // and modelling that divergence as DATA means a client reads it rather\n // than having to know it as an exception.\n meta: {\n limit: { type: 'number' },\n hasMore: { type: 'boolean' },\n nextCursor: { type: 'string', optional: true },\n },\n },\n {\n id: 'alternates',\n path: '/_alternates',\n data: 'array',\n params: {\n entity: { type: 'string', required: true, maxLength: MAX_ENTITY_NAME_LENGTH },\n id: { type: 'string', required: true },\n },\n },\n )\n }\n\n // Always present: it needs no provider. It answers 404 for a request that\n // has no site (an anonymous caller on an app serving several).\n endpoints.push({\n id: 'site',\n path: '/_site',\n data: 'object',\n // No parameters — and in particular no `site`: the site is the\n // credential's (multisite D008).\n params: {},\n })\n\n // Always present, like `_site`: it needs no provider. It answers 404 for a\n // site that offers no visitor search (an empty `searchTypes`), so the TYPES\n // a site searches are deliberately not published here.\n endpoints.push({\n id: 'search',\n path: '/_search',\n data: 'array',\n params: {\n q: {\n type: 'string',\n required: true,\n minLength: MIN_SEARCH_LENGTH,\n maxLength: MAX_SEARCH_LENGTH,\n },\n types: { type: 'string', maxLength: MAX_SEARCH_TYPES_PARAM_LENGTH },\n limit: { type: 'number', default: DEFAULT_SEARCH_RESULTS, min: 1, max: MAX_SEARCH_RESULTS },\n locale: { type: 'string' },\n },\n meta: {\n limit: { type: 'number' },\n },\n })\n\n if (hasMenus) {\n endpoints.push({\n id: 'menu',\n path: '/_menu',\n data: 'object',\n params: {\n // The param NAME only. The handle ALLOWLIST is never published — see\n // `SchemaBuildInput.hasMenus`.\n handle: { type: 'string', required: true, maxLength: MAX_MENU_HANDLE_LENGTH },\n locale: { type: 'string', required: true },\n currentPath: { type: 'string', maxLength: MAX_RESOLVE_PATH_LENGTH },\n },\n })\n }\n\n if (hasChrome) {\n endpoints.push({\n id: 'chrome',\n path: '/_chrome',\n data: 'object',\n params: {\n locale: { type: 'string', required: true },\n // `entity` + `id` name the requesting document and are optional — but\n // only TOGETHER. `required` is per-param and cannot express that pairing,\n // so it stays false on both and the handler answers 400 for half a\n // reference. Noted here rather than left as a surprise.\n entity: { type: 'string', maxLength: MAX_ENTITY_NAME_LENGTH },\n id: { type: 'string' },\n },\n })\n }\n\n return endpoints\n}\n\n// ---------------------------------------------------------------------------\n// Response\n// ---------------------------------------------------------------------------\n\n/**\n * Everything `GET /_schema` needs, taken from the handler's LIVE config so\n * nothing here is a restated literal.\n */\nexport interface SchemaBuildInput {\n /** The whitelisted entities, BEFORE role filtering. */\n entities: Entity[]\n /** `view` predicate for the calling role. Gates both the entity list and F053. */\n checkPermission: (entityName: string) => boolean\n /** The API version this instance mounts. */\n version: string\n /** EFFECTIVE `?limit=` ceiling, including a host-lowered one. */\n maxLimit: number\n /** EFFECTIVE `?offset=` ceiling, including a host-lowered one. */\n maxOffset: number\n /**\n * EFFECTIVE per-response resolution ceiling, including a host-lowered one.\n * Published under `limits` rather than under a param, because no parameter\n * names it — see {@link SchemaLimits}.\n */\n maxResolutionsPerDocument: number\n /** True when a `resolution` provider was injected (`_resolve`/`_paths`/`_alternates`). */\n hasResolution: boolean\n /**\n * True when a `menus` provider was injected (`_menu`).\n *\n * A BOOLEAN, deliberately — not the provider. `ContentApiMenus.handles` is the\n * endpoint's entire gate, so publishing it would hand every caller the\n * allowlist that protects it. Taking only the boolean makes that leak\n * structurally impossible here rather than merely avoided by care.\n */\n hasMenus: boolean\n /** True when a `chrome` provider was injected (`_chrome`). */\n hasChrome: boolean\n\n /**\n * The availability guard's RESOLVED numbers (D025/D028/D029) — what this\n * instance actually enforces, defaults included, not what the host wrote.\n */\n limits: ResolvedLimits\n\n /**\n * The RESOLVED cache-tag configuration (D055) — defaults applied, exactly\n * what the emitter uses. Same rule as `limits`: published from the resolved\n * value, never restated, so the contract cannot disagree with the wire.\n */\n cacheTags: ResolvedCacheTagsConfig\n\n /**\n * True on an app serving several sites (plan `multisite` D008): every tag\n * then carries a site slot, and the published templates say so. Derived from\n * the same fact the emitter keys on.\n */\n siteScopedTags?: boolean\n}\n\n/**\n * Publish the cache-tag grammar (D055), or `null` when emission is off.\n *\n * The templates are BUILT from {@link CACHE_TAG_NAMESPACE} rather than written\n * out, so changing the namespace changes the contract in the same edit. That is\n * the same derivation rule `errorCodes` follows — a second, hand-kept list is\n * exactly the drift D031 and D008 exist to make impossible.\n */\nfunction buildCacheTagsContract(\n config: ResolvedCacheTagsConfig,\n siteScoped: boolean,\n): SchemaCacheTags | null {\n if (!config.enabled) return null\n const prefix = siteScoped ? `${CACHE_TAG_NAMESPACE}:{site}` : CACHE_TAG_NAMESPACE\n return {\n header: config.header,\n separator: config.separator,\n document: `${prefix}:{entity}:{id}`,\n collection: `${prefix}:{entity}`,\n sharedSite: siteScoped ? SHARED_SITE_TAG_SEGMENT : null,\n maxTagLength: MAX_CACHE_TAG_LENGTH,\n maxTagsPerResponse: MAX_CACHE_TAGS_PER_RESPONSE,\n maxHeaderBytes: MAX_CACHE_TAG_HEADER_BYTES,\n // Always the same list — every entry is a property of this package's\n // structure, not of a deployment. A resolved menu item carries no entity\n // identity; a `variants` key set is decided entirely outside this package\n // by the injected `resolveMedia`; an href entirely outside it by the\n // injected `resolveEntityHref`. Every deployment that wires those seams has\n // all four gaps, so none of them is per-deployment variance.\n //\n // `blockTreeContents` is deliberately NOT here any more: a resolved tree\n // now names the rows it resolved against (`ResolvedBlockTree.refs`), and\n // `resolveBlockTrees` records each into the same sink references and media\n // use. `mediaVariantStyles` and `outOfBandPathChanges` were the two\n // untraced candidates in #852 — traced, both real, both added.\n uncovered: [\n 'menuTargets',\n 'crossEntityPathChanges',\n 'mediaVariantStyles',\n 'outOfBandPathChanges',\n ],\n }\n}\n\n/**\n * Build the full /_schema response.\n * Only includes entities that pass the permission check.\n */\nexport function buildSchemaResponse(input: SchemaBuildInput): SchemaResponse {\n const { entities, checkPermission, version } = input\n\n // Every number here is DERIVED from the guard's own resolved configuration,\n // never restated — a published ceiling that disagrees with the enforced one is\n // an unfalsifiable claim, which is the mechanism behind the SDK that drifted\n // for months undetected.\n const limits: SchemaLimits = {\n maxResolutionsPerDocument: input.maxResolutionsPerDocument,\n anonymousRequestsPerInstance: input.limits.anonymousTokensPerInstance,\n // MILLISECONDS, published as configured. Dividing by 1000 and rounding was\n // lossy in both directions — a 1500ms window published as 2s, a 400ms one as\n // 0 — so `_schema` could state a ceiling the surface does not enforce. That\n // is precisely the drift D008 exists to prevent, and a published number that\n // disagrees with the enforced one is worse than not publishing it: it is an\n // unfalsifiable claim, the mechanism behind the SDK that drifted for months.\n rateLimitWindowMs: input.limits.intervalMs,\n maxRequestsPerInstance: input.limits.maxBudgetPerInstance,\n maxConcurrentRequests: input.limits.maxConcurrentRequests,\n requestTimeoutMs: input.limits.requestTimeoutMs,\n }\n\n const visible = entities.filter((e) => checkPermission(e.name))\n\n // Reachable ⇔ whitelisted AND viewable — exactly the set the `entities` array\n // below publishes. Derived from that same set (rather than recomputed) so the\n // two can never disagree about what this caller may see.\n const visibleNames = new Set(visible.map((e) => e.name))\n const isEntityVisible: EntityVisibility = (name) => visibleNames.has(name)\n\n return {\n version,\n surface: buildSurface(input),\n envelopes: {\n dataKey: 'data',\n metaKey: 'meta',\n errorKey: 'error',\n errorCodes: ERROR_CODES,\n },\n limits,\n projections: PROJECTIONS,\n cacheTags: buildCacheTagsContract(input.cacheTags, input.siteScopedTags === true),\n entities: visible.map((e) => toSchemaEntity(e, isEntityVisible)),\n }\n}\n","/**\n * Content API handler factory\n *\n * Creates a read-only catch-all route handler for public content delivery.\n * Mirrors the `createAdminApiHandler` pattern but with:\n * - Every route served under the API version segment and only under it —\n * `/v1/article`, `/v1/_schema` (see {@link API_VERSION}). The version is\n * consumed by the handler, so the mount stays a plain catch-all.\n * - No mutations (GET + OPTIONS only)\n * - Permission-gated (public role starts with 0 permissions)\n * - Published content only (QueryClient auto-filters)\n * - CORS + Cache-Control headers\n *\n * @example\n * ```typescript\n * // app/api/content/[...path]/route.ts — serves GET /api/content/v1/*\n * import { createContentApiHandler } from '@murumets-ee/content-api'\n * import { Article, Page } from '@/entities'\n *\n * const handler = createContentApiHandler({\n * entities: [Article, Page],\n * loadPermissions: async () => {\n * const roles = await loadRoles(getApp())\n * return buildPermissionChecker(roles)\n * },\n * cors: { allowedOrigins: ['https://www.example.com'] },\n * })\n *\n * // `OPTIONS` takes only the Request, so it mounts directly. `GET` takes a\n * // framework-neutral `RouteContext` (`{ path: string[] }`), so the host's own\n * // shape is adapted at the MOUNT — under Next 15+ that means awaiting\n * // `params`, which is the only Next-specific line in the whole surface.\n * export const { OPTIONS } = handler\n * export async function GET(req: Request, ctx: { params: Promise<{ path?: string[] }> }) {\n * return handler.GET(req, { path: (await ctx.params).path ?? [] })\n * }\n * ```\n */\n\nimport type {\n PermissionChecker,\n RequestContext,\n RouteContext,\n SiteRecordView,\n} from '@murumets-ee/core'\nimport {\n getApp,\n getAppOrUndefined,\n getContext,\n getCurrentSite,\n LOCALE_PATTERN,\n REQUEST_ID_HEADER,\n readSiteOriginIndex,\n readSiteRecord,\n requireSiteFor,\n resolveImplicitSite,\n resolveRequestId,\n runBounded,\n runWithContextAsync,\n siteSecurityFields,\n} from '@murumets-ee/core'\nimport { schemaRegistry } from '@murumets-ee/db'\nimport type { CursorInput, Entity, FieldConfig, SlugField } from '@murumets-ee/entity'\nimport {\n buildTranslationsSearchExists,\n CountCache,\n decodeCursor,\n encodeCursor,\n filterTranslatableFields,\n getEntityTitleField,\n getSearchableConfig,\n isSiteBound,\n isSortOnlyInternalField,\n isTemplateDrawn,\n isValidSiteHandle,\n SITE_COLUMN,\n SiteRequiredError,\n} from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport {\n and,\n asc,\n desc,\n eq,\n getTableColumns,\n gte,\n ilike,\n inArray,\n lte,\n or,\n type SQL,\n sql,\n} from 'drizzle-orm'\nimport type { PgColumn, PgTable } from 'drizzle-orm/pg-core'\nimport { z } from 'zod'\nimport {\n type BlockResolutionDeps,\n createCollectionAllowance,\n MAX_COLLECTIONS_PER_RESPONSE,\n resolveBlockTrees,\n resolveOneBlockTree,\n} from './blocks.js'\nimport {\n type AuthVaryPolicy,\n assertCacheConfig,\n buildPrincipalInvariantHeaders,\n buildRefusalHeaders,\n buildResponseHeaders,\n type CallerCacheClass,\n} from './cache.js'\nimport {\n type CacheTagSet,\n type CacheTagSink,\n createCacheTagSet,\n MAX_CACHE_TAG_HEADER_BYTES,\n MAX_CACHE_TAGS_PER_RESPONSE,\n resolveCacheTagsConfig,\n SHARED_SITE_TAG_SEGMENT,\n} from './cache-tags.js'\nimport { buildCorsHeaders, corsPreflightResponse } from './cors.js'\nimport { narrowTemplateDrawnRow, type TemplateDrawnDeps } from './detail-template.js'\nimport { AvailabilityGuard, RequestDeadlineError, ServiceOverloadedError } from './limits.js'\nimport { type MediaResolutionDeps, projectMedia } from './media.js'\nimport {\n countParamsSchema,\n DEFAULT_PATHS_LIMIT,\n escapeLikePattern,\n FILTER_PARAM_PATTERN,\n isInternalField,\n isUuid,\n listParamsSchema,\n MAX_ENTITY_NAME_LENGTH,\n MAX_LIMIT,\n MAX_MENU_HANDLE_LENGTH,\n MAX_OFFSET,\n MAX_POPULATE_FIELDS,\n MAX_RESOLVE_PATH_LENGTH,\n parseFieldSelection,\n parsePopulate,\n singleParamsSchema,\n toParamBag,\n validateFilterValue,\n} from './query-params.js'\nimport { projectReferences, type ReferenceResolutionDeps } from './references.js'\nimport { createResolutionBudget, resolveMaxResolutions } from './resolution-budget.js'\nimport {\n errorCodeOf,\n errorResponse,\n jsonResponse,\n notFoundResponse,\n rateLimitedResponse,\n serviceOverloadedResponse,\n} from './response.js'\nimport { buildSchemaResponse } from './schema-endpoint.js'\nimport {\n describeSearchRow,\n isMediaProjection,\n isTitleHit,\n isVisitorSearchable,\n MAX_SEARCH_RESULTS,\n mergeSearchHits,\n type RankedTypeHits,\n type SearchHit,\n SearchRateLimiter,\n searchImageField,\n searchParamsSchema,\n searchTypeLabel,\n selectSearchTypes,\n} from './search.js'\nimport type {\n ContentApiChromeRegion,\n ContentApiChromeRegions,\n ContentApiConfig,\n ContentApiPrincipal,\n ContentApiSite,\n ContentPathEntry,\n CorsConfig,\n} from './types.js'\n\n// ---------------------------------------------------------------------------\n// Principal\n// ---------------------------------------------------------------------------\n\n/**\n * The identity every UNAUTHENTICATED request resolves to.\n *\n * Deliberately the same literal `@murumets-ee/core`'s `runAsPublicReader` uses\n * for the SSG/build-time reader, so the two anonymous readers of this codebase\n * are one identity rather than two look-alikes — and so consolidating this\n * handler onto that primitive stays a pure deletion.\n *\n * It is a stable identity, not a permission: what an anonymous caller may reach\n * is decided entirely by `checker(publicRole, entity, 'view')`, which grants\n * nothing unless a role explicitly says so.\n */\nconst ANONYMOUS_PRINCIPAL_ID = 'public'\n\n/** Shared empty set — avoids allocating one per non-FTS search. */\nconst EMPTY_FIELD_SET: ReadonlySet<string> = new Set<string>()\n\n/**\n * `Retry-After` on a survival refusal (503) and on a wall-clock termination.\n *\n * A fixed small number rather than a computed one, because unlike a fairness\n * refusal there is no window to read it off: a shed clears when the origin\n * recovers, which this process cannot predict. One second is short enough to be\n * useful to a well-behaved client and is a floor, not a promise.\n */\nconst SHED_RETRY_AFTER_SECONDS = 1\n\n/**\n * A principal after resolution — every optional field on\n * {@link ContentApiPrincipal} has been defaulted, so the dispatch path never\n * re-derives them and cannot forget to.\n */\ninterface ResolvedPrincipal {\n /** Always present: the host's id, else {@link ANONYMOUS_PRINCIPAL_ID}. */\n id: string\n /** Always present: the host's role, else the configured `publicRole`. */\n role: string\n name?: string\n email?: string\n /**\n * This credential's own fairness budget (D029), passed through unchanged from\n * {@link ContentApiPrincipal}. Left OPTIONAL rather than defaulted here,\n * because \"the credential declared no budget\" and \"the credential declared the\n * surface default\" must stay distinguishable: the clamp against the\n * server-owned maximum is applied where the budget is CONSUMED, so defaulting\n * it here would launder a host-supplied number past that clamp.\n */\n rateBudgetPerInstance?: number\n /**\n * Did the HOST resolve an identity for this caller?\n *\n * Carried explicitly rather than inferred from `id ===\n * ANONYMOUS_PRINCIPAL_ID`, because that literal is `'public'` — an ordinary\n * string a real user id could equal. Under the inferred form such a user, on\n * the default role, would be classified `'anonymous'` and served a\n * publicly-cacheable body that varies on their identity. Contrived, but the\n * collision is free to remove and a cache-class decision is the wrong place to\n * carry a \"surely not\" in.\n *\n * `false` for the synthesised anonymous principal AND for the role-only hook\n * shape (`{ role }` with no `id`), which asserts no identity — see\n * `resolvePrincipal`.\n */\n hasIdentity: boolean\n\n /**\n * The ONE site this caller's credential is bound to (plan `multisite` D008),\n * already validated as a handle. The request's site is derived from it — see\n * `resolveRequestSite`.\n */\n site?: string\n}\n\n/**\n * The site ONE request reads (plan `multisite` D008), decided once in\n * `dispatch` before any handler runs.\n *\n * `site` is what goes on `RequestContext.site`: the site the request NAMED —\n * through its credential, an ambient `runInSite`, or the mount's Host map. On\n * a single-site install that names none it stays `undefined` and the data\n * layer answers with the install's one site, exactly as before; `record` is\n * then that site's row.\n */\ninterface RequestSiteScope {\n site?: string\n /** The registry row of the request's site, when a booted app resolved one. */\n record?: SiteRecordView\n /** The app declares `sites.multiple` — it never guesses a site. */\n multiSite: boolean\n}\n\n/**\n * Runtime guard for a principal field that must carry a value.\n *\n * `''` is rejected alongside the wrong types because an empty string is not a\n * weaker version of a value here, it is a DIFFERENT KIND of thing: an empty role\n * matches no permission yet compares unequal to `publicRole`, and an empty id\n * identifies nobody yet compares unequal to {@link ANONYMOUS_PRINCIPAL_ID}. Both\n * therefore read as \"not the anonymous caller\" to every check downstream while\n * carrying nothing that could satisfy one. `??` does not catch either.\n */\nfunction isNonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value !== ''\n}\n\n/**\n * `Array.isArray` narrows to `any[]` — its lib signature is\n * `(arg: any) => arg is any[]` — so using it directly on a declared\n * `readonly string[]` turns every element back into `any` and costs the package\n * its 100% type-coverage floor. This guard narrows to `readonly unknown[]`\n * instead, so each element still has to be proven a string by\n * {@link isNonEmptyString} before it is used.\n *\n * The runtime check is not redundant with the static type: `ContentApiConfig`\n * is host-supplied and reaches us from plain JavaScript, where the declared\n * type is a promise rather than a guarantee.\n */\nfunction isUnknownArray(value: unknown): value is readonly unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * An HTTP field-name token (RFC 9110 §5.1 `tchar`). Used to validate\n * {@link ContentApiConfig.credentialHeaders} — those values are emitted verbatim\n * into a response header, so anything that is not a bare token (whitespace, a\n * comma, a colon, a CR/LF) must never reach it.\n */\nconst HTTP_FIELD_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/\n\n/**\n * Resolve, and VALIDATE, how `authenticate` participates in shared-cache\n * variant selection — see {@link AuthVaryPolicy} for why the answer has to come\n * from the host.\n *\n * Run once at construction: a malformed selector is a config error, and this\n * package's posture for those is to throw where the mistake is attributable\n * rather than to let it surface as \"caching just behaves oddly\" months later.\n */\nfunction resolveAuthVaryPolicy(config: ContentApiConfig): AuthVaryPolicy {\n const declared = config.credentialHeaders\n if (declared !== undefined) {\n if (!isUnknownArray(declared)) {\n throw new Error(\n 'content-api: `credentialHeaders` must be an array of HTTP header names, received ' +\n `${JSON.stringify(declared)}.`,\n )\n }\n for (const header of declared) {\n if (!isNonEmptyString(header) || header === '*' || !HTTP_FIELD_NAME_RE.test(header)) {\n throw new Error(\n `content-api: \\`credentialHeaders\\` entry ${JSON.stringify(header)} is not a valid ` +\n 'HTTP header name. Each entry is emitted verbatim in `Vary`, so it must be a bare ' +\n 'field-name token (`Cookie`, `Authorization`, `X-Preview-Token`). `*` is refused: it ' +\n 'means \"never reuse this response\", which is `Cache-Control: no-store` spelled ' +\n 'obscurely — omit the option instead, which fails closed to exactly that.',\n )\n }\n }\n }\n\n if (!config.authenticate) {\n // Selectors with no hook to select for. Harmless to serve, but it can only\n // fragment a shared cache for a credential nothing here parses — which is\n // half of the defect this option exists to fix, reintroduced by hand.\n if (declared !== undefined && declared.length > 0) {\n throw new Error(\n 'content-api: `credentialHeaders` was declared without an `authenticate` hook. ' +\n 'This package parses no credential itself, so nothing reads those headers and ' +\n 'listing them in `Vary` can only fragment a shared cache. Remove the option, or ' +\n 'wire the `authenticate` hook it describes.',\n )\n }\n return { kind: 'none' }\n }\n\n // Fail closed. A credential can arrive and nothing declares what selects it,\n // so `buildResponseHeaders` will refuse to offer any entity response for\n // storage rather than store one under a variant key that is known to be wrong.\n if (declared === undefined || declared.length === 0) return { kind: 'undeclared' }\n return { kind: 'declared', headers: declared }\n}\n\n// ---------------------------------------------------------------------------\n// Resolution / enumeration query-param schemas (routing PR07 — `_resolve`,\n// `_paths`, `_alternates`). Kept PRIVATE to the handler: these three endpoints\n// are internal to `createContentApiHandler`'s single export, not part of the\n// `./query-params` public subpath. Validation STYLE mirrors query-params.ts\n// (zod, capped limit via `MAX_LIMIT`, `isUuid` for UUID-typed inputs, the\n// shared `LOCALE_PATTERN`), and the CAPS themselves live there — one literal\n// each, read by both the zod schema below and the `GET /_schema` surface\n// descriptor that publishes it.\n// ---------------------------------------------------------------------------\n\n/** `GET /_resolve?path=&locale=` params — both required. */\nconst resolveParamsSchema = z\n .object({\n path: z.string().min(1).max(MAX_RESOLVE_PATH_LENGTH),\n locale: z.string().regex(LOCALE_PATTERN),\n })\n .strict()\n\n/** `GET /_paths?locale=&entity=&cursor=&limit=` params — locale + entity required. */\nconst pathsParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n // Opaque keyset cursor — a document id (a UUID; the `id` of a prior page's\n // last `_paths` entry). A malformed value is a 4xx here, never a Postgres\n // cast 500 downstream.\n cursor: z\n .string()\n .refine((v) => isUuid(v), 'Invalid cursor')\n .optional(),\n limit: z.coerce.number().int().min(1).max(MAX_LIMIT).default(DEFAULT_PATHS_LIMIT),\n })\n .strict()\n\n/** `GET /_alternates?entity=&id=` params — both required; id is a UUID. */\nconst alternatesParamsSchema = z\n .object({\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n id: z.string().refine((v) => isUuid(v), 'Invalid id'),\n })\n .strict()\n\n/**\n * `GET /_menu?handle=&locale=¤tPath=` params — handle + locale required\n * (routing PR09). `currentPath` is optional: SSG frontends typically fetch\n * WITHOUT it once per build and compute active state per page from the returned\n * hrefs (`markActiveTrail`).\n */\n/**\n * `GET /_chrome?locale=&entity=&id=` params (plan/api phase 07, D026).\n *\n * `locale` is required — chrome is per-locale and there is no sensible default\n * for \"which site header\". `entity` + `id` name the requesting DOCUMENT and are\n * OPTIONAL, but only TOGETHER: half a reference is a malformed request, not a\n * request for the site default, and answering 200 for it would silently ignore\n * a caller who believes they asked for overrides.\n *\n * A malformed value is a 400; a WELL-FORMED reference to something that does\n * not exist, is outside the whitelist, is not viewable, or is unpublished for\n * this principal yields the site default plus a debug log (D026 part 3). That\n * split — always reject a malformed request, never explain a missing target —\n * is what keeps the parameter from becoming an existence oracle over document\n * ids.\n */\n/**\n * The chrome regions, in the order they are resolved.\n *\n * Declared as data so the resolution loop cannot silently become concurrent or\n * reordered — the budget claims are serial by construction, and a comment\n * asserting that is not the same as code enforcing it. Mirrors\n * `ContentApiChromeRegions`' own key order; `satisfies` makes a drift between\n * the two a compile error rather than a region that never resolves.\n */\nconst CHROME_REGION_FIELDS = ['header', 'footer'] as const satisfies ReadonlyArray<\n keyof ContentApiChromeRegions\n>\n\nconst chromeParamsSchema = z\n .object({\n locale: z.string().regex(LOCALE_PATTERN),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH).optional(),\n id: z\n .string()\n .refine((v) => isUuid(v), 'Invalid id')\n .optional(),\n })\n .strict()\n .refine(\n (v) => (v.entity === undefined) === (v.id === undefined),\n 'Both ?entity= and ?id= are required to name a document, or neither.',\n )\n\n/**\n * `GET /_site` takes NO parameters — in particular no `site` (plan `multisite`\n * D008): the site is the credential's. Strict, so naming one is a 400.\n */\nconst siteParamsSchema = z.object({}).strict()\n\n/**\n * The query parameter this API will never have (plan `multisite` D008). The\n * request's site is derived from its credential; a site the CALLER names is an\n * authorization decision made by the party being authorized (Craft CMS shipped\n * that as CVE-2026-79990). Refused on every route — including `_schema`, which\n * parses no parameters and would otherwise ignore it — so it cannot become a\n * silently-ignored parameter that a later change starts reading.\n */\nconst FORBIDDEN_SITE_PARAM = 'site'\n\n/**\n * Is `err` the data layer's \"this site-bound operation names no site\" refusal?\n *\n * 🔴 By NAME as well as by class, and the name is the half that matters.\n * `@murumets-ee/entity` builds each entry point separately, so its index, its\n * `/query` entry and its `/admin` entry each carry their OWN copy of\n * `SiteRequiredError`. The class imported here is the index's; the one a\n * `QueryClient` throws is `/query`'s — a different constructor, so\n * `instanceof` is false for exactly the error this exists to catch. With\n * `instanceof` alone, every list, count, slug, id, `_chrome` and `_menu` read\n * answered 500 while `_resolve` (whose error comes from the index copy)\n * answered 404, and a unit test that threw the index class passed.\n * `admin-ui`'s api-handler matches the same way for the same reason.\n */\nfunction isSiteRequiredError(err: unknown): boolean {\n return (\n err instanceof SiteRequiredError || (err instanceof Error && err.name === 'SiteRequiredError')\n )\n}\n\n/** What `_site` reports for a site whose registry row declares no URL strategy. */\nconst DEFAULT_SITE_ROUTING_STRATEGY = 'no-prefix-default'\n\nconst menuParamsSchema = z\n .object({\n handle: z.string().min(1).max(MAX_MENU_HANDLE_LENGTH),\n locale: z.string().regex(LOCALE_PATTERN),\n currentPath: z.string().min(1).max(MAX_RESOLVE_PATH_LENGTH).optional(),\n })\n .strict()\n\n// ---------------------------------------------------------------------------\n// Entity resolution (whitelist-based, same pattern as admin handler)\n// ---------------------------------------------------------------------------\n\n/**\n * Path segments dispatched BEFORE entity-whitelist resolution (`_schema`\n * pre-dates PR07; `_resolve`/`_paths`/`_alternates` came with it; `_menu` with\n * PR09). An entity whose singular or plural name matches one would be\n * permanently unreachable via the generic `/:entity` routes — silently\n * shadowed, not 404'd for a diagnosable reason. Rejected at construction time\n * (fail fast, boot-time config error) rather than left as a confusing runtime\n * dead end.\n */\nconst RESERVED_ROUTE_SEGMENTS = new Set([\n '_schema',\n '_site',\n '_resolve',\n '_paths',\n '_alternates',\n '_menu',\n '_chrome',\n '_search',\n])\n\n/**\n * The API version this instance mounts. Every route is served under it and\n * ONLY under it — `/v1/article`, `/v1/_schema`. There is no unprefixed mount,\n * no deprecation marker, no `Link` successor header (D010): no consumer\n * predates the prefix, so there is nothing to transition.\n *\n * **What `v1` means.** Adding an endpoint, an entity, an error code, a `meta`\n * field, or an OPTIONAL query parameter is additive and stays inside v1.\n * CHANGING or REMOVING any of them — a field's type, an endpoint's shape, a\n * default, a previously-optional param becoming required — is a v2. `GET\n * /v1/_schema` publishes the version this instance mounts, so a client can\n * assert against it rather than infer it from the URL it happened to be given.\n *\n * The version is consumed by the HANDLER, not by the mount. The app mounts one\n * catch-all whose only job is to hand over an ordered array of path segments,\n * so a consuming app needs no edit at all, and the package that owns the\n * contract is the one that enforces and introspects it — rather than whichever\n * framework the app happens to route with.\n */\nexport const API_VERSION = 'v1'\n\n/**\n * The version segment is reserved as a PATTERN (`v` followed by digits), not as\n * the literal {@link API_VERSION}. An entity named `v2` must be refused TODAY,\n * years before a `/v2/` exists — otherwise whoever ships v2 finds the segment\n * already taken by an entity added in the meantime, and every `/v2/...` request\n * silently resolves to that entity instead of to the new contract.\n *\n * Exact by construction: `video` is not `v`+digits, so ordinary words are\n * unaffected.\n */\nconst VERSION_SEGMENT_RE = /^v\\d+$/\n\nfunction buildEntityMap(entities: Entity[]): Map<string, Entity> {\n const map = new Map<string, Entity>()\n for (const entity of entities) {\n const pluralName = `${entity.name}s`\n if (RESERVED_ROUTE_SEGMENTS.has(entity.name) || RESERVED_ROUTE_SEGMENTS.has(pluralName)) {\n throw new Error(\n `@murumets-ee/content-api: entity name \"${entity.name}\" collides with a reserved ` +\n `route segment (${[...RESERVED_ROUTE_SEGMENTS].join(', ')}). Rename the entity — ` +\n `its API surface would otherwise be permanently unreachable, shadowed by the ` +\n `special dispatch that runs before entity-whitelist resolution.`,\n )\n }\n if (VERSION_SEGMENT_RE.test(entity.name) || VERSION_SEGMENT_RE.test(pluralName)) {\n throw new Error(\n `@murumets-ee/content-api: entity name \"${entity.name}\" is a reserved API-version ` +\n `segment. Every route is served under a version prefix (this instance mounts ` +\n `/${API_VERSION}/), and the prefix is matched as a PATTERN (\"v\" followed by digits) ` +\n `so that a future /v2/ can never be shadowed by an entity added in the meantime. ` +\n `Rename the entity.`,\n )\n }\n // A non-global-scoped entity is refused OUTRIGHT, at construction.\n //\n // `QueryClient` derives its scope filter from `RequestContext.scope`, and\n // reads pass `strictScope: false` — so when no scope is in context,\n // `resolveAuthContext` returns `scopeId: undefined`, `buildScopeCondition`\n // is skipped, and the query returns EVERY tenant's rows. Nothing on this\n // surface populates a scope (`ContentApiPrincipal` carries an identity and a\n // role, deliberately: tenant-scoped authorization is out of scope for this\n // plan). So the only shapes available today are \"correctly refused\" and\n // \"silently cross-tenant\", and the failure is invisible — a 200 with more\n // rows than the caller should see reads exactly like a working endpoint.\n //\n // Refusing here makes it fail closed AND loud, at boot rather than in\n // production, and this is the deliberate place to open the door: whoever\n // populates `RequestContext.scope` from a principal lifts this throw in the\n // same change, with the negative test that proves the filter actually\n // applies. `scope: 'global'` and an absent `scope` are both fine — those are\n // the only entities the read path can currently serve correctly.\n if (entity.scope !== undefined && entity.scope !== 'global') {\n throw new Error(\n `@murumets-ee/content-api: entity \"${entity.name}\" declares scope ` +\n `\"${entity.scope}\", which this surface cannot serve. The public read path ` +\n `resolves no scope for a caller, so QueryClient would apply NO scope filter ` +\n `and return every tenant's rows to every caller. Remove it from the ` +\n `content-api whitelist, or serve it through a surface that establishes a ` +\n `scope in the request context.`,\n )\n }\n\n map.set(entity.name, entity)\n map.set(pluralName, entity)\n }\n return map\n}\n\n// ---------------------------------------------------------------------------\n// Table-column helpers (typed via drizzle's getTableColumns)\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the registered Drizzle table + its column map for an entity by name.\n * Returns undefined if the entity isn't registered (caller decides whether\n * that's a 404 or a no-op).\n */\n/** A bound entity's `_site` column — throws rather than silently widening. */\nfunction requireSiteColumn(cols: Record<string, PgColumn>, entityName: string): PgColumn {\n const col = cols[SITE_COLUMN]\n if (!col) throw new Error(`site-bound '${entityName}' translations have no ${SITE_COLUMN} column`)\n return col\n}\n\nfunction resolveTableCols(\n name: string,\n): { table: PgTable; cols: Record<string, PgColumn> } | undefined {\n const table = schemaRegistry.get(name)\n if (!table) return undefined\n return { table, cols: getTableColumns(table) as Record<string, PgColumn> }\n}\n\n// ---------------------------------------------------------------------------\n// Query helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Build WHERE condition for `?search=q` against a public entity.\n *\n * Reads `searchable()` declaration (when present):\n * - `fts: {...}` → `_search_tsv @@ websearch_to_tsquery(language, q)`\n * - no fts → OR-combined ILIKE across `searchable().fields`\n *\n * Returns `undefined` when the entity has no `searchable()` (search\n * param ignored — public consumers won't get filtered results without\n * the entity opting in).\n *\n * **Differences from admin-ui's `buildSearchableWhere`:**\n * - This helper does NOT apply `searchable().defaultFilters`. Defaults\n * are an admin-side UX feature (\"hide closed tickets from the\n * inbox by default\"); public consumers already get publish-status\n * filtering from `publishable()` and don't need a second-layer\n * default-filter mechanism.\n * - This helper does NOT honour `searchable().includes`. Public\n * cross-entity OR-search would expose join structure that the\n * public API contract doesn't promise. Admin path supports it.\n *\n * **Translated content (#543):** translatable `fields` additionally match\n * via a correlated EXISTS on `<entity>_translations`, constrained to the\n * request's resolved locale — a frontend listing `?locale=et&search=Meist`\n * matches the et title even though it lives in the translation row. When\n * NO locale resolves (no `?locale` param and the host configured no\n * `defaultLocale`), the EXISTS is skipped entirely: base-table search only.\n * The admin surfaces deliberately match any locale in that case; the public\n * API stays conservative — returned rows carry base-locale values, so an\n * any-locale match would surface rows that don't visibly contain the term.\n */\n/** Field types whose column holds TEXT, and so can take `ILIKE` — admin-ui's `ILIKE_FIELD_TYPES`. */\nconst ILIKE_FIELD_TYPES: ReadonlySet<string> = new Set(['text', 'richtext', 'slug', 'select'])\n\nfunction buildSearchCondition(entity: Entity, search: string, locale?: string): SQL | undefined {\n const config = getSearchableConfig(entity)\n if (!config) return undefined\n const trimmed = search.trim()\n if (trimmed.length === 0) return undefined\n\n const resolved = resolveTableCols(entity.name)\n if (!resolved) return undefined\n const { cols } = resolved\n\n const pieces: SQL[] = []\n const pattern = `%${escapeLikePattern(trimmed)}%`\n\n // `searchable().fields` is entity-author-declared, not caller input, but it is\n // NOT validated against the internal rule anywhere — so\n // `searchable({ fields: ['_workflowStatus'] })` would hand an anonymous caller\n // an ILIKE oracle over a column the response body hides, and `ilike()` on a\n // non-text internal column (`_version`) is a 500. Same predicate as every\n // other boundary; the author keeps the field searchable in the admin surface,\n // it just stops being probe-able from the public one.\n const allFields = entity.allFields as Record<string, FieldConfig>\n // TEXT columns only (davatoidud-toolkit PR01, F-filed there). `fields` is the\n // FILTER whitelist as well as the search set, so a boolean or a number belongs\n // in it legitimately (`article.featured`, for a collection's \"featured only\")\n // — and `ILIKE` on one is a Postgres \"operator does not exist: boolean ~~*\n // unknown\", which made every public `?search=` on such an entity a 500.\n // admin-ui's `buildIlikeOr` filters on the same set for the same reason.\n const searchableFields = config.fields.filter((fieldName) => {\n const fieldConfig = Object.hasOwn(allFields, fieldName) ? allFields[fieldName] : undefined\n return (\n fieldConfig !== undefined &&\n ILIKE_FIELD_TYPES.has(fieldConfig.type) &&\n !isInternalField(fieldName, fieldConfig)\n )\n })\n\n // The same rule has to cover FTS, not just the ILIKE fallback above.\n // `_search_tsv` is a PRECOMPUTED tsvector: whatever `fts.fields` names is\n // baked into that column at write time by `searchable()`'s DDL. Filtering\n // `config.fields` therefore does nothing for it — an entity declaring\n // `fts: { fields: ['_workflowStatus'] }` would still let `?search=` probe\n // that text through `websearch_to_tsquery`, which is precisely the oracle\n // the ILIKE filter above exists to close. Skip the tsvector entirely in\n // that case and fall through to the already-filtered ILIKE path: slower for\n // a misconfigured entity, but never probe-able.\n //\n // This is a guard at the QUERY boundary. The internal field's text is still\n // physically present in `_search_tsv`; keeping it out of the column in the\n // first place belongs in `searchable()`'s DDL in `@murumets-ee/entity` and\n // is schema-affecting, so it is deliberately not done here.\n const ftsExposesInternal =\n config.fts?.fields.some((fieldName) => isInternalField(fieldName, allFields[fieldName])) ??\n false\n\n // D049 — the tsvector match and the ILIKE over the NON-FTS remainder are OR'd,\n // never alternatives.\n //\n // This used to run the ILIKE loop only `if (pieces.length === 0)`, so whenever\n // a main-table `_search_tsv` existed, every declared field outside\n // `fts.fields` was dropped from public search — and `fts.fields` is capped at\n // four by `searchable()`'s setweight partition. That contradicts\n // `searchable()`'s own contract, which is that `fields` is ALWAYS the\n // searchable set and `fts.fields` is the ranking-eligible SUBSET, and it\n // contradicts it in the direction of returning FEWER results (F036).\n //\n // admin-ui's `buildSearchableWhere` composes it the correct way and its\n // comment records what the mistake cost the last time: \"silently dropped\n // requesterEmail/Name from search and broke the inbox UX — typing a sender\n // email returned nothing.\"\n //\n // Calibrated: this was LATENT, not live. Both entities the one production\n // mount whitelists declare all their FTS fields translatable, so the tsvector\n // lands on `<entity>_translations`, the main table has no `_search_tsv`, and\n // both already took the full ILIKE path. The defect fires on the first content\n // entity with a non-translatable FTS field plus any additional searchable\n // field — so the fix is free in measured cost today.\n //\n // Safe by the predicate that already guards the path: `searchableFields` is\n // filtered through `isInternalField`, so widening the match cannot reach a\n // field the response body hides, and `QueryClient`'s publish filter is\n // untouched.\n //\n // ONE COST, NAMED SO THE FIRST ENTITY TO PAY IT IS NOT SURPRISED. Where a\n // main-table tsvector exists, the predicate used to be a pure `@@\n // websearch_to_tsquery(...)` — index-backed by the GIN index `searchable({\n // fts })` emits. OR-ing an unindexable `ILIKE` beside it forces a sequential\n // scan of the collection. That is free today (no whitelisted entity has a\n // non-translatable FTS field, so none takes this branch) and is not free for\n // the first entity that does, on the most expensive query of an always-on\n // unauthenticated surface. D047 offsets part of it by removing the count from\n // a searched list, and the survival guard bounds the rest. The alternative —\n // keeping the index and silently searching four fields when the author\n // declared ten — is the defect (F036), not a trade.\n let ftsFieldsMatched: ReadonlySet<string> = EMPTY_FIELD_SET\n if (config.fts && !ftsExposesInternal) {\n const tsvCol = cols._search_tsv\n if (tsvCol) {\n const language = config.fts.language ?? 'simple'\n pieces.push(sql`${tsvCol} @@ websearch_to_tsquery(${language}, ${trimmed})`)\n ftsFieldsMatched = new Set(config.fts.fields)\n }\n // Translatable-only FTS — no main-table tsvector, so every declared field\n // goes through ILIKE below.\n }\n\n for (const fieldName of searchableFields) {\n // Already covered by the tsvector predicate above — ILIKE'ing it again would\n // be a redundant OR term on every row.\n if (ftsFieldsMatched.has(fieldName)) continue\n const column = cols[fieldName]\n if (!column) continue\n pieces.push(ilike(column, pattern))\n }\n\n // Translated content (#543) — correlated EXISTS on the translations table\n // for the translatable subset of the declared fields. Public API only\n // searches translations when a locale actually resolved (see JSDoc).\n const translatableFields = filterTranslatableFields(\n entity.allFields as Record<string, { translatable?: boolean }>,\n searchableFields,\n )\n const idColumn = cols.id\n if (locale !== undefined && translatableFields.length > 0 && idColumn) {\n const translationsTable = schemaRegistry.get(`${entity.name}_translations`)\n if (translationsTable) {\n const translationsExists = buildTranslationsSearchExists({\n idColumn,\n translationsTable,\n fields: translatableFields,\n locale,\n buildCondition: (column) => ilike(column, pattern),\n })\n if (translationsExists) pieces.push(translationsExists)\n }\n }\n\n if (pieces.length === 0) return undefined\n const first = pieces[0]\n if (!first) return undefined\n if (pieces.length === 1) return first\n const tail = pieces.slice(1)\n return or(first, ...tail)\n}\n\n/**\n * ORDER BY for a visitor search over `entity` (davatoidud-toolkit D001):\n * rows whose TITLE matches first, then — where the entity keeps a main-table\n * tsvector — by `ts_rank`, then newest first so the order is stable.\n *\n * The title test is the same `ILIKE` the match uses, on the base column and,\n * for a translatable title, on the request locale's translation row — so a\n * title hit means what a visitor sees as the title. It is an ORDER, never a\n * filter: it cannot widen what {@link buildSearchCondition} matched.\n */\nfunction buildSearchRankOrder(entity: Entity, search: string, locale?: string): SQL[] {\n const resolved = resolveTableCols(entity.name)\n if (!resolved) return []\n const { cols } = resolved\n const allFields = entity.allFields as Record<string, FieldConfig>\n const trimmed = search.trim()\n const pattern = `%${escapeLikePattern(trimmed)}%`\n const order: SQL[] = []\n\n // The title test mirrors what a visitor SEES as the title: the request\n // locale's translation where one exists, else the base column — exactly how\n // `QueryClient` merges the row (a non-null translation overlays the base).\n // Text columns only: `admin.titleField` may name any field, and `ILIKE` on\n // a number or date is a Postgres error (see ILIKE_FIELD_TYPES).\n const titleField = getEntityTitleField(entity)\n const titleConfig = Object.hasOwn(allFields, titleField) ? allFields[titleField] : undefined\n let titleHit: SQL | undefined\n const titleColumn = cols[titleField]\n if (\n titleColumn &&\n titleConfig !== undefined &&\n ILIKE_FIELD_TYPES.has(titleConfig.type) &&\n !isInternalField(titleField, titleConfig)\n ) {\n const translations =\n locale !== undefined && titleConfig.translatable === true\n ? resolveTableCols(`${entity.name}_translations`)\n : undefined\n const translatedTitle = translations?.cols[titleField]\n const translationEntity = translations?.cols.entityId\n const translationLocale = translations?.cols.locale\n const idColumn = cols.id\n titleHit =\n translations &&\n translatedTitle &&\n translationEntity &&\n translationLocale &&\n idColumn &&\n locale !== undefined\n ? sql`COALESCE((SELECT ${translatedTitle} FROM ${translations.table} WHERE ${translationEntity} = ${idColumn} AND ${translationLocale} = ${locale} LIMIT 1), ${titleColumn}) ILIKE ${pattern}`\n : ilike(titleColumn, pattern)\n }\n if (titleHit) order.push(sql`CASE WHEN ${titleHit} THEN 0 ELSE 1 END`)\n\n const config = getSearchableConfig(entity)\n const ftsExposesInternal =\n config?.fts?.fields.some((fieldName) => isInternalField(fieldName, allFields[fieldName])) ??\n false\n const tsvCol = cols._search_tsv\n if (config?.fts && !ftsExposesInternal && tsvCol) {\n const language = config.fts.language ?? 'simple'\n order.push(desc(sql`ts_rank(${tsvCol}, websearch_to_tsquery(${language}, ${trimmed}))`))\n }\n const updatedAt = cols.updatedAt\n if (updatedAt) order.push(desc(updatedAt))\n const id = cols.id\n if (id) order.push(asc(id))\n return order\n}\n\n/**\n * Result of resolving `?sort=`. `field` is the ACCEPTED sort field — set only\n * when `orderBy` was actually built — so callers never re-derive it from the\n * raw param (which would let a rejected sort leak back into e.g. the cursor).\n * `dropped` carries the rejected param for logging.\n */\ninterface ResolvedOrderBy {\n orderBy: SQL | undefined\n field?: string | undefined\n dropped?: string | undefined\n}\n\nfunction buildOrderBy(\n entity: Entity,\n sortParam: string | undefined,\n allFields: Record<string, FieldConfig>,\n): ResolvedOrderBy {\n const entityName = entity.name\n if (!sortParam) return { orderBy: undefined }\n\n const descending = sortParam.startsWith('-')\n const fieldName = descending ? sortParam.slice(1) : sortParam\n const drop = { orderBy: undefined, dropped: sortParam }\n\n // Reject prototype-chain keys (`constructor`, `toString`, …) — a bare lookup\n // would resolve those to a truthy non-FieldConfig and hand a junk column ref\n // to drizzle.\n if (!Object.hasOwn(allFields, fieldName)) return drop\n\n const fieldConfig = allFields[fieldName]\n if (!fieldConfig || fieldConfig.type === 'blockTree') return drop\n // Reject internal fields. ORDER BY on `_workflowStatus` / `_version` is a\n // blind oracle over a server-managed value for any caller who can page the\n // list — the same leak `buildFilterConditions` closes, through the other\n // door. Note this gate is now the WHOLE story: since D024 removed the\n // internal-field exposure allowlist, an internal field is neither readable\n // nor queryable, in any configuration.\n //\n // ONE declared exception: an `orderable()` entity's order key may be sorted\n // by (editor-curation D001), so a listing can follow the manual order. The\n // ORDER is what the listing shows anyway; the key's VALUE stays out of rows,\n // filters, `?fields=` and cursors — `nextCursor` is minted from the shaped\n // row, which never carries it, so paging that sort is offset-only.\n if (isInternalField(fieldName, fieldConfig) && !isSortOnlyInternalField(entity, fieldName)) {\n return drop\n }\n\n const resolved = resolveTableCols(entityName)\n if (!resolved) return drop\n const column = resolved.cols[fieldName]\n if (!column) return drop\n\n return { orderBy: descending ? desc(column) : asc(column), field: fieldName }\n}\n\n/** Accepted filter conditions plus the `filter[…]` keys that were rejected. */\ninterface ResolvedFilters {\n conditions: SQL[]\n dropped: string[]\n}\n\nfunction buildFilterConditions(\n entityName: string,\n url: URL,\n allFields: Record<string, FieldConfig>,\n): ResolvedFilters {\n const conditions: SQL[] = []\n const dropped: string[] = []\n const resolved = resolveTableCols(entityName)\n if (!resolved) return { conditions, dropped }\n const { cols } = resolved\n\n for (const [key, raw] of url.searchParams.entries()) {\n const match = key.match(FILTER_PARAM_PATTERN)\n if (!match) continue\n\n const fieldName = match[1]\n if (fieldName === undefined) continue\n // Reject prototype-chain keys (`constructor`, `toString`, …).\n if (!Object.hasOwn(allFields, fieldName)) {\n dropped.push(fieldName)\n continue\n }\n\n const fieldConfig = allFields[fieldName]\n if (!fieldConfig || fieldConfig.type === 'blockTree') {\n dropped.push(fieldName)\n continue\n }\n // Reject internal fields (`_scopeId`, `_versionId`, `internal: true`, …) —\n // the shared `isInternalField` predicate every caller-input boundary uses.\n if (isInternalField(fieldName, fieldConfig)) {\n dropped.push(fieldName)\n continue\n }\n\n const column = cols[fieldName]\n if (!column) {\n dropped.push(fieldName)\n continue\n }\n\n const validated = validateFilterValue(fieldConfig, raw)\n if (validated === null) {\n dropped.push(fieldName)\n continue\n }\n\n // Date range filters: gte: / lte: prefix is preserved by validateFilterValue.\n if (fieldConfig.type === 'date' && typeof validated === 'string') {\n if (validated.startsWith('gte:')) {\n conditions.push(gte(column, validated.slice(4)))\n continue\n }\n if (validated.startsWith('lte:')) {\n conditions.push(lte(column, validated.slice(4)))\n continue\n }\n }\n conditions.push(eq(column, validated))\n }\n\n return { conditions, dropped }\n}\n\n/**\n * Enforce a pagination ceiling LOUDLY — returns the 400 to short-circuit with,\n * or `undefined` when the value is within bounds (D007).\n *\n * Never a clamp. A clamp answers 200 with a page the caller did not ask for:\n * it cannot be observed failing from the outside, and it leaves a deep-paging\n * client silently re-reading the same page forever. The zod schemas enforce the\n * PACKAGE ceilings (`MAX_LIMIT`/`MAX_OFFSET`) on the way in; this covers the\n * host-lowered ones, which zod cannot see.\n *\n * `code` stays `VALIDATION_ERROR` — it is machine contract, and an over-large\n * page is not a new class of failure. `message` is not contract, so it names\n * the ceiling and, for `offset`, the supported way past it.\n */\nfunction ceilingViolation(\n param: 'limit' | 'offset',\n value: number,\n ceiling: number,\n corsHeaders: Record<string, string>,\n /**\n * Whether the CALLER supplied this parameter. D007's \"reject, never clamp\"\n * governs what a caller ASKED FOR — it says nothing about our own default,\n * because a request that omitted the parameter expressed no intent to\n * violate anything.\n *\n * Without this distinction a host that lowers `maxLimit` below\n * `DEFAULT_LIMIT` gets `400 \"?limit=20 exceeds the maximum of 10.\"` on a\n * request carrying NO `?limit=` at all — an endpoint unusable for that host,\n * failing with a message naming a parameter the caller never sent. The\n * caller-supplied value is still rejected loudly; only the default is\n * clamped, by `resolveLimit` below.\n *\n * `offset` needs no such treatment: `DEFAULT_OFFSET` is 0 and every ceiling\n * is ≥ 0, so the default can never exceed it.\n */\n supplied = true,\n): Response | undefined {\n if (!supplied || value <= ceiling) return undefined\n const remedy = param === 'offset' ? ' Use ?cursor= for deep pagination.' : ''\n return errorResponse(\n `?${param}=${value} exceeds the maximum of ${ceiling}.${remedy}`,\n 'VALIDATION_ERROR',\n 400,\n corsHeaders,\n )\n}\n\n/**\n * The effective `limit` once a host ceiling below `DEFAULT_LIMIT` is accounted\n * for. Pair with `ceilingViolation(..., supplied)`: that rejects what the caller\n * asked for, this clamps only the default we supplied ourselves.\n */\nfunction resolveLimit(parsed: number, ceiling: number, supplied: boolean): number {\n return supplied ? parsed : Math.min(parsed, ceiling)\n}\n\n/**\n * Refuse a host ceiling that would invert the bound rather than tighten it.\n * `undefined` is fine (the package default applies); anything else must be a\n * finite integer, and > 0 unless the caller opts into `allowZero` (`maxOffset:\n * 0` is a coherent \"no offset paging at all\" — `maxLimit: 0` is not, it means\n * \"return nothing\", which no host wants and which reads as an unset env var).\n */\nfunction assertPositiveCeiling(\n name: 'maxLimit' | 'maxOffset',\n value: number | undefined,\n { allowZero = false }: { allowZero?: boolean } = {},\n): void {\n if (value === undefined) return\n const floor = allowZero ? 0 : 1\n if (!Number.isInteger(value) || value < floor) {\n throw new Error(\n `content-api: \\`${name}\\` must be an integer >= ${floor}, received ${String(value)}. ` +\n 'A ceiling below that does not tighten the bound, it removes it: the limit reaches ' +\n 'the query layer falsy, no LIMIT clause is emitted, and the whole table is returned ' +\n 'on a public route. If this came from an environment variable, it is probably unset.',\n )\n }\n}\n\n// ---------------------------------------------------------------------------\n// Bounds on what caller-controlled text may reach a log line or a monitoring\n// payload (D032, D035)\n// ---------------------------------------------------------------------------\n\n/**\n * Longest caller-supplied string copied into a diagnostic or a monitoring\n * report.\n *\n * Availability is a security property, and both destinations are per-request:\n * a log line is a write an operator pays for, and a monitoring report leaves\n * the process. Neither may be sized by the caller. Generous enough that a real\n * `?sort=` or a real path is never touched.\n */\nconst MAX_LOGGED_VALUE_LEN = 256\n\n/** Most distinct rejected filter keys named in one diagnostic. */\nconst MAX_REPORTED_DROPPED_KEYS = 20\n\n/** Cap a caller-controlled string, marking the cut so it cannot read as the whole value. */\nfunction truncateForLog(value: string): string {\n return value.length <= MAX_LOGGED_VALUE_LEN\n ? value\n : `${value.slice(0, MAX_LOGGED_VALUE_LEN)}…[truncated]`\n}\n\n// ---------------------------------------------------------------------------\n// Request-bound logging (D032)\n// ---------------------------------------------------------------------------\n\n/**\n * The child logger bound for a request, keyed by that request's\n * {@link RequestContext}.\n *\n * The binding — correlation id and principal id — is established ONCE, where\n * the context is, and every later log line on that request inherits it. No call\n * site passes `requestId`; no call site passes the principal. That is the whole\n * point: per-call-site enrichment is a check every future author has to\n * remember, and its failure is silent (a line missing its correlation id looks\n * exactly like a line), whereas a binding cannot be forgotten by code that does\n * not mention it. Same asymmetry as the projection with nothing at depth 2 to\n * expand and the credential-less principal with nothing to authenticate\n * against.\n *\n * Keyed off the context OBJECT rather than an id string, so two concurrent\n * requests cannot collide and an entry dies with the request.\n */\nconst REQUEST_LOGGERS = new WeakMap<RequestContext, ToolkitLogger>()\n\n/**\n * The site's declared default locale per request (plan `multisite` phase 03) —\n * the fallback for a request with no `?locale=`. Kept beside the context\n * rather than ON it: `RequestContext.defaultLocale` means the STORAGE default\n * everywhere else, which is install-wide.\n */\nconst REQUEST_SITE_DEFAULT_LOCALES = new WeakMap<RequestContext, string>()\n\ntype ToolkitLogger = ReturnType<typeof getApp>['logger']\n\n/** Bind the request's identity to a child logger, once. */\nfunction bindRequestLogger(ctx: RequestContext): void {\n if (!ctx.requestId) return\n try {\n REQUEST_LOGGERS.set(\n ctx,\n getApp().logger.child({\n requestId: ctx.requestId,\n ...(ctx.user?.id !== undefined && { principalId: ctx.user.id }),\n }),\n )\n } catch {\n // App not yet bootstrapped. The request will still be served; only the\n // binding is lost, and `requestLog()` falls back to the unbound logger.\n }\n}\n\n/**\n * The logger for the request in flight — bound per {@link bindRequestLogger},\n * or the app logger when there is no context (pre-bootstrap paths only).\n *\n * Throws exactly where `getApp()` does; callers that can run before bootstrap\n * keep their existing `try`/`console` fallback.\n */\n/**\n * The locales the request's SITE serves and its default (plan `multisite`\n * phase 03, D007). `locales` empty = the site does not narrow the install's\n * set. Resolved per request (the registry read is cached briefly by core); a\n * request that names no site on an app serving several gets no narrowing here\n * — every site-bound read it makes refuses on its own.\n */\nasync function siteLocaleScope(\n site: string | undefined,\n mountDefault: string | undefined,\n): Promise<{ locales: string[]; defaultLocale?: string }> {\n // No booted app (a harness mounting the handler alone): nothing to narrow by.\n const app = getAppOrUndefined()\n if (!app) return { locales: [] }\n // A failed implicit-site lookup is \"no implicit site\" (the reads below then\n // refuse on their own); a failed REGISTRY read propagates to the 500 path —\n // it must not silently lift the site's locale restriction.\n const handle = site ?? (await resolveImplicitSite(app).catch(() => undefined))\n if (handle === undefined) return { locales: [] }\n const record = await readSiteRecord(app, handle)\n if (!record) return { locales: [] }\n // The record's default is the site's only on an app serving several sites;\n // a single-site install keeps the mount's default (the record holds a\n // one-time copy of the i18n setting, which the Languages editor still owns).\n const serves = (locale: string | undefined): locale is string =>\n locale !== undefined && (record.locales.length === 0 || record.locales.includes(locale))\n const declared = app.config.sites?.multiple === true ? record.defaultLocale : undefined\n const defaultLocale = serves(declared)\n ? declared\n : serves(mountDefault)\n ? mountDefault\n : record.locales[0]\n return { locales: record.locales, ...(defaultLocale !== undefined && { defaultLocale }) }\n}\n\nfunction requestLog(): ToolkitLogger {\n const ctx = getContext()\n const bound = ctx ? REQUEST_LOGGERS.get(ctx) : undefined\n return bound ?? getApp().logger\n}\n\n// ---------------------------------------------------------------------------\n// Handler factory\n// ---------------------------------------------------------------------------\n\nexport function createContentApiHandler(config: ContentApiConfig) {\n const {\n entities,\n defaultLocale: configDefaultLocale,\n publicRole = 'public',\n cors: corsConfig,\n cache: cacheConfig,\n onError,\n resolution,\n menus,\n chrome,\n site: siteProvider,\n } = config\n\n // The opt-in Host→site map (multisite D008), validated ONCE: a value that is\n // not a handle would put an unusable site on every request from that host,\n // which reads as \"the site has no content\" months later.\n const hostSites = new Map<string, string>()\n for (const [host, handle] of Object.entries(config.siteForHost ?? {})) {\n if (!isValidSiteHandle(handle)) {\n throw new Error(\n `content-api: \\`siteForHost['${host}']\\` is ${JSON.stringify(handle)}, which is not a site handle.`,\n )\n }\n hostSites.set(host.trim().toLowerCase(), handle)\n }\n\n /**\n * The site slot of this request's cache tags (multisite D008), or `undefined`\n * on a single-site install — whose tags stay exactly as they were.\n */\n function cacheTagSiteFor(\n siteScope: RequestSiteScope,\n ): ((entityName: string) => string) | undefined {\n if (!siteScope.multiSite) return undefined\n const registry = getAppOrUndefined()?.entities\n return (entityName) => {\n const entity = registry?.get(entityName)\n // A shared row is one row on every site: one tag, named once by a purge.\n // Anything else — a bound entity, or a reserved projection name such as\n // `_menu`, which is per site — carries the request's site.\n if (entity !== undefined && !isSiteBound(entity)) return SHARED_SITE_TAG_SEGMENT\n return siteScope.site ?? SHARED_SITE_TAG_SEGMENT\n }\n }\n\n /**\n * The locale an UNLOCALISED request reads: its site's declared default (plan\n * `multisite` phase 03) when there is one, else the mount's default.\n *\n * ⚠️ Only the fallback for a missing `?locale=`. The STORAGE default — which\n * locale a row's base columns hold, passed to every read as `defaultLocale` —\n * stays the mount's configured one: it is install-wide (shared rows are\n * written from every site) and never varies by site.\n */\n const requestLocaleFallback = (): string | undefined => {\n const ctx = getContext()\n return (ctx ? REQUEST_SITE_DEFAULT_LOCALES.get(ctx) : undefined) ?? configDefaultLocale\n }\n // Both ceilings are configurable DOWNWARD only — a host can tighten them,\n // never raise them past the package cap. Exceeding either is a 400\n // (see `enforceCeiling`), never a clamp.\n //\n // The FLOOR is enforced by throwing, because without it the most restrictive\n // configuration produces the LEAST restrictive query — a ceiling that inverts\n // rather than tightens:\n //\n // - `maxLimit: 0` (or `NaN`, e.g. `Number(process.env.X)` with X unset)\n // clamps the default limit to 0, and `QueryClient.findMany` guards its\n // limit with `if (options?.limit)` — 0 is FALSY, so no `LIMIT` clause is\n // emitted at all and an unauthenticated `GET /v1/articles` returns the\n // whole table.\n // - The same value makes `_paths` drain its async iterable to exhaustion\n // (`if (data.length === limit) break` never fires), contradicting that\n // handler's own \"never to exhaustion\" contract and CLAUDE.md's\n // No-Unbounded-Fan-Out rule on an always-on public path.\n // - `maxOffset: -1` makes `ceilingViolation` reject EVERY request, including\n // ones carrying no `?offset=` at all.\n //\n // This package's posture for config mistakes is to throw where the mistake is\n // attributable, on the grounds that a silent one looks like \"the feature just\n // doesn't work\" months later. A config mistake that silently removes a BOUND\n // deserves it more than most.\n assertPositiveCeiling('maxLimit', config.maxLimit)\n assertPositiveCeiling('maxOffset', config.maxOffset, { allowZero: true })\n // Same posture, one config field over: a removed cache field must not be\n // silently ignored (it would mean MORE caching than the operator asked for),\n // and the staleness window must not be the one ceiling validated per-request\n // — the preflight path builds cache headers with no `try` above it.\n assertCacheConfig(cacheConfig)\n // Resolved ONCE at construction — the header name and separator are a\n // deployment fact, not a per-request one. Only the tag SET is per-request.\n const cacheTagsConfig = resolveCacheTagsConfig(cacheConfig?.tags)\n // The same \"empty is a different kind of thing\" hazard as `isNonEmptyString`,\n // one layer up. `publicRole: ''` (an unset env var read straight into config)\n // is NOT a tighter default: it gives every anonymous caller `groups: []`,\n // which `resolveAuthContext` refuses — so the entire public surface answers\n // 500 rather than 404, and every request confirms it was received. Thrown at\n // construction for the same reason the ceilings are: a config mistake whose\n // only symptom is \"the surface doesn't work\" must be attributable to the\n // config, not rediscovered from a stack trace on a live route.\n if (!isNonEmptyString(publicRole)) {\n throw new Error(\n `content-api: \\`publicRole\\` must be a non-empty string, received ${JSON.stringify(publicRole)}. ` +\n 'An empty public role matches no permission yet is not the anonymous role either, so every ' +\n 'unauthenticated request reaches the read path with no groups and fails as a 500. If this ' +\n 'came from an environment variable, it is probably unset — omit the option to get \"public\".',\n )\n }\n const maxLimit = Math.min(config.maxLimit ?? MAX_LIMIT, MAX_LIMIT)\n const maxOffset = Math.min(config.maxOffset ?? MAX_OFFSET, MAX_OFFSET)\n // Which request headers a shared cache must key on to tell one caller's body\n // from another's — DECLARED by the host, because only the host knows what its\n // `authenticate` hook reads. Resolved + validated once; see\n // `resolveAuthVaryPolicy` and `ContentApiConfig.credentialHeaders`.\n const authVary: AuthVaryPolicy = resolveAuthVaryPolicy(config)\n\n // The availability guard (D025). Constructed here rather than per request, so\n // its state is per PROCESS — which is the honest scope for an in-memory\n // limiter and is what `perInstance`/`...PerInstance` in the config field names\n // is telling the reader (D028).\n //\n // No `if (config.limits)` around this. The survival half is keyless BY\n // CONSTRUCTION and there is deliberately no configuration field whose absence\n // disables it: a host cannot lose the semaphore or the wall clock by\n // forgetting something, which is the property that makes them safe to rely on.\n // `limits` only TUNES numbers that already have defaults.\n const guard = new AvailabilityGuard(config.limits)\n // The visitor search's own, tighter budget (davatoidud-toolkit D001) — one\n // search is up to eight queries where a list is two. Same key precedence and\n // the same never-a-shared-bucket rule as the guard's fairness limit; see\n // `SearchRateLimiter`.\n const searchLimiter = new SearchRateLimiter(\n config.search,\n config.search?.getClientIp ?? config.limits?.getClientIp,\n )\n // Validated NOW so a bad whitelist still fails at boot, not at the first request.\n buildEntityMap(entities)\n\n // The whitelist is the route's list of NAMES; the DEFINITION served is the\n // app's registered one (block-library PR20, fixes F066). A plugin may\n // contribute a variant of an entity (`yhikasSync({ detailPages: { drawnBy:\n // 'site' } })` registers a copy with `frontend.drawnBy: 'site'`) while the\n // route module imports the package's own object — serving that object made\n // the content API judge narrowing from a different definition than the app\n // registered. Resolved ONCE, at the first request (the app is not booted when\n // the route module loads), never per request. It never ADDS an entity: the\n // route's list is the exposure decision.\n let resolvedEntities: { list: Entity[]; map: Map<string, Entity> } | undefined\n function resolveEntities(): { list: Entity[]; map: Map<string, Entity> } {\n if (resolvedEntities) return resolvedEntities\n const app = getAppOrUndefined()\n let list = entities\n if (app) {\n list = []\n for (const listed of entities) {\n const registered = app.entities.get(listed.name)\n if (registered === undefined) {\n app.logger.error(\n `content-api: \"${listed.name}\" is in createContentApiHandler({ entities }) but the app does not register an entity of that name — it is NOT served. Register it through lumi.config (entities or a plugin) or remove it from the whitelist.`,\n )\n continue\n }\n list.push(registered)\n }\n }\n const resolved = { list, map: buildEntityMap(list) }\n // Pin only a booted-app answer; a pre-boot call must not freeze the unregistered objects.\n if (app) resolvedEntities = resolved\n return resolved\n }\n\n // D024 — `exposeInternalFields` is GONE, and its absence is the property. The\n // read projection is one rule with no per-deployment variance (every declared\n // non-internal field, always), so `_schema` describes exactly what the\n // surface emits.\n //\n // It is refused LOUDLY rather than ignored. TypeScript rejects the key, but a\n // JS host — or one upgrading across this change — would otherwise keep the\n // option in its config and silently stop getting the field, which is the\n // \"feature just doesn't work\" failure this package throws at construction to\n // prevent everywhere else. The option was never a footgun; it was a careful\n // per-entity, per-field allowlist validated at construction. What removed it\n // was that D011 forbade its ONLY sanctioned use (`_overrides` no longer\n // reaches the wire in any configuration), leaving a config key whose entire\n // documentation was a warning — and a hole in the drift gate, since `_schema`\n // deliberately never described what it published.\n if (Object.hasOwn(config, 'exposeInternalFields')) {\n throw new Error(\n 'content-api: `exposeInternalFields` has been removed (plan/api D024). Its only ' +\n 'sanctioned use was publishing `_overrides`, which D011 replaced by composing site ' +\n 'chrome SERVER-side — so the field no longer needs to reach the wire, and no other ' +\n 'internal field was ever safe to list (`_workflowStatus` and `_workflowAssignee` ' +\n 'describe an editorial process and name a user; `_version`/`_versionedAt` expose ' +\n 'revision cadence). Fetch composed chrome from `GET /_chrome` instead. Remove the ' +\n 'option: it is refused rather than ignored, because silently dropping a field you ' +\n 'configured is exactly the failure this package throws at construction to prevent.',\n )\n }\n\n // The `_menu` allowlist, resolved once at construction. A Set (not the raw\n // array) so the deny-by-default check is O(1) and can't be turned into a scan\n // by a long config list.\n const menuHandles = new Set(menus?.handles ?? [])\n\n // ONE per-response resolution ceiling spanning references, `?populate=`\n // expansions, media and block-tree resolutions — not four independent caps,\n // which a single document could sum into a much larger pool draw. Validated\n // at construction like every other ceiling; see `resolveMaxResolutions`.\n const maxResolutions = resolveMaxResolutions(config.maxResolutionsPerDocument)\n\n // Shared count cache — reduces COUNT(*) queries on paginated list pages.\n const countCache = new CountCache(5000)\n\n // Lazy-loaded permission checker — cached with 30s TTL.\n // Hosts that mutate roles at runtime should call invalidatePermissionCache()\n // immediately after the change so the next request reloads.\n //\n // ⚠️ INVARIANT — THIS CACHE HOLDS THE CHECKER, NEVER ITS ANSWERS.\n //\n // A `PermissionChecker` is a pure function of `(role, entity, action)`, so one\n // instance is correct for every caller and memoising it is safe no matter how\n // many principals share the process. Memoising what it RETURNED would not be:\n // the key would have to include the principal, and the first miss that got the\n // key wrong would serve one caller's answer to another. Now that callers are\n // distinguishable principals rather than a single anonymous role, that is a\n // live foot-gun rather than a theoretical one — the adjacent admin surface has\n // a recorded defect of exactly this shape (an app-level checker cache that\n // never expires, so revocations never take effect and never cross processes).\n //\n // Two properties keep this one honest and must both survive any edit here:\n // 1. Nothing principal-derived may enter this cache — no id, no role, no\n // per-(role, entity) memo layered on top.\n // 2. The TTL must stay bounded and `invalidatePermissionCache()` must stay\n // reachable, so this layer alone can never hold a stale checker for\n // longer than the TTL.\n //\n // ⚠️ WHAT THIS TTL DOES *NOT* BUY YOU. It bounds THIS layer, per process, and\n // that is the whole of the guarantee:\n //\n // - It is per-process. `loadPermissions` is re-run by whichever process\n // expires first; nothing invalidates the others. Under any multi-instance\n // deployment a revocation is visible only after each instance's own TTL.\n // - `invalidatePermissionCache()` is EXPORTED BUT UNCALLED — zero call sites\n // repo-wide as of this commit (the one live wiring, `apps/admin`'s content\n // route, never calls it; the identically-named call in that app's ADMIN\n // route belongs to `createAdminApiHandler`, a different handler). So the\n // TTL is the only expiry that actually runs today.\n // - Hosts may add their own cache in FRONT of this one, and the only shipped\n // consumer does: `apps/admin/app/api/content/[...path]/route.ts` memoises\n // its checker for 30s inside `loadPermissions`. The two TTLs compose\n // additively, so the real worst case there is ~60s, not 30 — and calling\n // `invalidatePermissionCache()` would not fix it, because the next\n // `loadPermissions` would be served from the app's own cache. That\n // app-level cache is a known finding owned by `plan/admin-api-hardening`\n // (F001); it is deliberately NOT changed from here.\n //\n // Read the bound as \"≤ TTL for this layer, in this process\" — not as an\n // end-to-end revocation latency, which this package cannot promise.\n const PERMISSION_CACHE_TTL_MS = 30_000\n let permissionChecker: PermissionChecker | null = null\n let permissionCacheTime = 0\n\n async function getChecker(): Promise<PermissionChecker> {\n const now = Date.now()\n if (!permissionChecker || now - permissionCacheTime > PERMISSION_CACHE_TTL_MS) {\n permissionChecker = await config.loadPermissions()\n permissionCacheTime = now\n }\n return permissionChecker\n }\n\n function invalidatePermissionCache(): void {\n permissionChecker = null\n permissionCacheTime = 0\n }\n\n // -- Principal resolution --------------------------------------------------\n\n /** The anonymous principal, rebuilt per call so no caller can mutate a shared one. */\n function anonymousPrincipal(): ResolvedPrincipal {\n return { id: ANONYMOUS_PRINCIPAL_ID, role: publicRole, hasIdentity: false }\n }\n\n /**\n * Record a thrown `authenticate` hook, server-side, at `warn`.\n *\n * Serving the public response is the right answer (a 500 would confirm to the\n * caller that the attempt was noticed) — but discarding the error entirely\n * means a hook broken in production degrades the WHOLE surface to anonymous\n * with no symptom except \"authenticated callers stopped seeing more data\".\n * This is the only signal that a credential path is down.\n *\n * **The line carries the error and nothing else.** No URL, no headers, no\n * principal — the input to a failed authentication is by definition\n * unverified, and half of it is the credential itself. The log is not part of\n * the response, so it cannot become the oracle D006 forbids.\n *\n * The logger call is guarded because `getApp()` throws before the app is\n * bootstrapped: a degraded authentication must not become a crash on the way\n * to reporting itself.\n */\n function logAuthenticateFailure(err: unknown): void {\n try {\n requestLog().warn(\n { err },\n 'content-api: `authenticate` hook threw — serving the anonymous principal',\n )\n } catch {\n // App not yet bootstrapped (or no logger) — last-resort, same fallback the\n // dispatch-level error path uses.\n console.warn('[content-api] `authenticate` hook threw:', err)\n }\n }\n\n /**\n * Resolve the caller into a {@link ResolvedPrincipal}. NEVER throws and never\n * returns a partial principal: absent hook, `null`, or a thrown error all\n * produce the anonymous principal on `publicRole`.\n *\n * That uniformity is the point. A credential this instance cannot verify must\n * leave the caller with EXACTLY the reach an anonymous visitor has, rather\n * than with a role and no identity (which would authorize a caller nothing\n * could later attribute) or an identity and no role (which `resolveAuthContext`\n * would reject downstream as a 500 — an error shape that distinguishes \"your\n * credential was seen\" from \"you sent none\", which is the oracle D006 exists\n * to prevent).\n *\n * There are therefore exactly TWO outcomes, never three: the principal the\n * host resolved, or the anonymous one. In particular an EMPTY-STRING role is\n * not a third state — it passes a bare `typeof` check, then satisfies neither\n * branch coherently (`'' !== publicRole` marks the response authenticated\n * while `groups: []` makes the read path throw), so it collapses like any\n * other malformed value.\n *\n * No 401 is produced here, deliberately: this package parses no credential, so\n * it cannot tell a malformed one from an absent one. If a host later wants the\n * malformed-credential 401 that phase 03 allows, it belongs in the hook, which\n * is the only code that has seen the credential.\n */\n async function resolvePrincipal(req: Request): Promise<ResolvedPrincipal> {\n if (!config.authenticate) return anonymousPrincipal()\n\n let result: ContentApiPrincipal | null\n try {\n result = await config.authenticate(req)\n } catch (err) {\n // A hook that throws is a FAILED authentication. The RESPONSE is the\n // public one rather than a 500 that would confirm the attempt was noticed\n // — but the failure is recorded server-side, where it is invisible to the\n // caller and is the only way an operator learns the credential path is\n // down. See `logAuthenticateFailure`.\n logAuthenticateFailure(err)\n return anonymousPrincipal()\n }\n if (!result) return anonymousPrincipal()\n\n // EVERY field is runtime-validated, not just `role`. The hook is a trust\n // boundary crossed by a host we do not compile — a JS caller, a `JSON.parse`\n // of a token payload, a mis-shaped adapter — so the static type is a\n // statement of intent, not a guarantee about the value that arrives. An\n // unvalidated `id: 123` lands in `RequestContext.user.id`, which is typed\n // `string` and consumed by everything downstream as one.\n //\n // A malformed ANY field collapses the WHOLE principal to anonymous rather\n // than being dropped in isolation, because a hook that got one field wrong\n // has not demonstrated that the others are right — keeping the role while\n // discarding a bad id would authorize a caller nothing could later\n // attribute, which is the exact half-trusted state this function exists to\n // make unrepresentable.\n if (!isNonEmptyString(result.role)) return anonymousPrincipal()\n if (result.id !== undefined && !isNonEmptyString(result.id)) return anonymousPrincipal()\n if (result.name !== undefined && typeof result.name !== 'string') return anonymousPrincipal()\n if (result.email !== undefined && typeof result.email !== 'string') return anonymousPrincipal()\n // Part of what the credential IS, not a quota attached to it: a hook that\n // returned a site that is not a handle has not shown which site it meant,\n // and dropping the field alone would serve the single-site install's one\n // site to a credential minted for another.\n if (\n result.site !== undefined &&\n !(typeof result.site === 'string' && isValidSiteHandle(result.site))\n ) {\n return anonymousPrincipal()\n }\n // A malformed budget DROPS THE BUDGET, keeping the principal. It must NOT\n // collapse to anonymous, which is what this did in an earlier revision and\n // is strictly weaker than doing nothing at all.\n //\n // Trace why. `anonymousPrincipal()` sets `hasIdentity: false`, so `dispatch`\n // passes `principalId: undefined` to the guard, so `resolveKey` falls\n // through to the host's client-IP source — and if the host did not wire one,\n // there is NO distinguishing key, so the fairness limit is SKIPPED ENTIRELY\n // (D025). A credential whose issuer fat-fingered its budget would end up\n // rate-limited by nothing whatsoever. The comment this replaces claimed to\n // be avoiding exactly that outcome (\"silently ignoring the field would grant\n // the surface default\"), and produced something weaker than the thing it was\n // avoiding.\n //\n // Keeping the identity and discarding only the bad number is tighter in\n // every dimension: the fairness limit still applies, still keyed on this\n // principal, at the uncredentialed default. That default is the FLOOR of\n // what any credential can be granted, so a malformed budget can only ever\n // narrow a caller's throughput, never widen it.\n //\n // Unlike `role` / `id` / `name` / `email` above, this field is not part of\n // the principal's IDENTITY — it is a quota attached to it — so discarding it\n // leaves nothing half-trusted to reason about.\n const budgetIsUsable =\n result.rateBudgetPerInstance !== undefined &&\n Number.isSafeInteger(result.rateBudgetPerInstance) &&\n result.rateBudgetPerInstance >= 1\n\n return {\n // Fail-closed on a role-only hook (the pre-existing `{ role }` shape):\n // the host resolved no identity, so we assert none rather than invent one\n // — and say so explicitly, rather than leaving `cacheClassOf` to infer it\n // by comparing against a literal a real id could equal.\n id: result.id ?? ANONYMOUS_PRINCIPAL_ID,\n hasIdentity: result.id !== undefined,\n role: result.role,\n ...(result.name !== undefined && { name: result.name }),\n ...(result.email !== undefined && { email: result.email }),\n ...(budgetIsUsable && { rateBudgetPerInstance: result.rateBudgetPerInstance }),\n ...(result.site !== undefined && { site: result.site }),\n }\n }\n\n // -- Site resolution (plan `multisite` D008) --------------------------------\n\n /** The mount's opt-in Host→site answer for this request, if it has one. */\n function siteForHost(req: Request): string | undefined {\n if (hostSites.size === 0) return undefined\n const host = (req.headers.get('host') ?? new URL(req.url).host).trim().toLowerCase()\n return hostSites.get(host)\n }\n\n /**\n * Decide the request's site — ONCE, from server-side facts only — or refuse.\n *\n * Sources, in order: the credential's binding; an ambient site (a mount\n * wrapped in `runInSite`); the mount's opt-in Host→site map; and, on a\n * single-site install, the one site (left implicit, as before). No request\n * parameter, header or body field is consulted — see\n * {@link FORBIDDEN_SITE_PARAM}.\n *\n * `null` is a refusal, answered 404 like every other absence here (D006):\n * - a credential bound to one site inside a mount running as another;\n * - a NAMED site the registry does not hold, or holds as `disabled` — a\n * disabled site is not served (`lumi site:disable`). The registry read is\n * cached briefly by core, so a disable takes effect within that window.\n *\n * A request that names no site on an app serving several is NOT refused\n * here: shared entities (media) are still its to read. Every site-bound read\n * it attempts refuses in the data layer and is answered 404 in `dispatch`.\n */\n async function resolveRequestSite(\n req: Request,\n principal: ResolvedPrincipal,\n ): Promise<RequestSiteScope | null> {\n const ambient = getCurrentSite()\n if (principal.site !== undefined && ambient !== undefined && ambient !== principal.site) {\n safeWarn(\n { credentialSite: principal.site, mountSite: ambient },\n 'content-api: a credential bound to one site was presented to a mount running as another',\n )\n return null\n }\n const named = principal.site ?? ambient ?? siteForHost(req)\n // No booted app (a harness mounting the handler alone): no registry to\n // check against, so the named site is taken as given.\n const app = getAppOrUndefined()\n if (!app) return { ...(named !== undefined && { site: named }), multiSite: false }\n const multiSite = app.config.sites?.multiple === true\n // A failed implicit-site lookup is \"no implicit site\" — the bound reads\n // below then refuse on their own. A failed REGISTRY read propagates to the\n // 500 path: it must not be mistaken for \"no such site\".\n const handle = named ?? (await resolveImplicitSite(app).catch(() => undefined))\n if (handle === undefined) return { multiSite }\n const record = await readSiteRecord(app, handle)\n if (named !== undefined && (record === undefined || record.status !== 'active')) return null\n return {\n ...(named !== undefined && { site: named }),\n ...(record !== undefined && { record }),\n multiSite,\n }\n }\n\n /**\n * The CORS allowlist for a response that precedes the request's site: a\n * preflight (which carries no credential), and the guard's own refusals.\n *\n * On an app serving several sites that is the union of every active site's\n * origins — the only thing a credential-less request can be checked against.\n * It is never used for a response that carries content: that one is allowed\n * for the request's own site's origins only (see `dispatch`).\n */\n async function preSiteCorsConfig(req: Request): Promise<CorsConfig | undefined> {\n // No `Origin`, nothing to allow — and that is the NORMAL request here: a\n // site frontend fetches server-side. Skipping the registry read for it\n // keeps this (which runs before the concurrency guard) off the hot path.\n const app = getAppOrUndefined()\n const multiSite = app?.config.sites?.multiple === true\n if (!app || !multiSite) return corsConfig\n // An empty list rather than `undefined` when the mount configured none: on\n // an app serving several sites the response DOES vary by `Origin` (the\n // registry decides), so `Vary: Origin` must be declared even on the\n // answer that allows nothing.\n if (!req.headers.has('Origin')) return corsConfig ?? { allowedOrigins: [] }\n try {\n const index = await readSiteOriginIndex(app)\n if (index.truncated) {\n // Never silent: past the cap, the later sites' origins are missing\n // from this list and their preflights are refused.\n safeWarnOnce(\n 'site-origin-index-truncated',\n { listed: index.byOrigin.size },\n 'content-api: the site registry holds more active sites than the origin index reads — some sites’ CORS preflights will be refused',\n )\n }\n return {\n allowedOrigins: [\n ...new Set([...(corsConfig?.allowedOrigins ?? []), ...index.byOrigin.keys()]),\n ],\n ...(corsConfig?.maxAge !== undefined && { maxAge: corsConfig.maxAge }),\n }\n } catch (err) {\n // The registry is unreadable. Falling back to the mount's own list keeps\n // preflights answering; the request that follows fails on its own.\n safeWarn({ err }, 'content-api: could not read site origins for CORS')\n return corsConfig\n }\n }\n\n /**\n * The shared-cache class of this caller — see {@link CallerCacheClass}.\n *\n * Keyed on the IDENTITY as well as the role, deliberately. `role !==\n * publicRole` alone misses the ordinary \"logged-in visitor who was granted no\n * extra role\" case: `{ id: 'usr_x', role: publicRole }` is a perfectly legal\n * principal whose `user.id` reaches every `QueryClient` read, so the response\n * may vary per caller while a role-only rule stamps it `public, max-age=…`.\n * Nothing on the CURRENT read path varies on `user.id`, which is what makes\n * this latent rather than live — but D016's stated destination (owner-scoped\n * rows) activates it, and a cache rule that is only correct until the next\n * feature lands is a leak with a delay on it.\n *\n * Keyed on `hasIdentity` rather than on `id === ANONYMOUS_PRINCIPAL_ID`: that\n * literal is `'public'`, so a real principal whose id happened to equal it\n * would classify as anonymous and be served a publicly-cacheable body that\n * varies on their identity. See {@link ResolvedPrincipal.hasIdentity}.\n */\n function cacheClassOf(principal: ResolvedPrincipal): CallerCacheClass {\n if (principal.hasIdentity || principal.role !== publicRole) return 'identified'\n // The credential selected the site: storable by its holder, never by a\n // cache two sites share — see `CallerCacheClass`.\n return principal.site !== undefined ? 'site-keyed' : 'anonymous'\n }\n\n // -- Permission check (returns 404 on deny to avoid info leakage) ----------\n\n function hasViewPermission(\n checker: PermissionChecker,\n role: string,\n entityName: string,\n ): boolean {\n return checker(role, entityName, 'view')\n }\n\n // -- Create QueryClient from entity ----------------------------------------\n\n function createClient(entity: Entity): QueryClient {\n const app = getApp()\n return new QueryClient({\n entity,\n db: app.db.readOnly,\n // Derived from the REQUEST-bound logger, so a QueryClient diagnostic\n // inherits the correlation id and principal without `QueryClient` having\n // any notion of either (D032).\n logger: requestLog().child({ entity: entity.name }),\n countCache,\n contextResolver: () => {\n const ctx = getContext()\n if (!ctx?.user || !ctx?.checker) return undefined\n return {\n user: ctx.user,\n checker: ctx.checker,\n ...(ctx.scope !== undefined && { scope: ctx.scope }),\n // multisite D003 — the request's site (or the single-site answer).\n ...siteSecurityFields(ctx, app),\n }\n },\n })\n }\n\n // -- Resolution (phase 05 / D013) ------------------------------------------\n\n /**\n * Everything one response needs in order to turn stored identifiers into\n * something a database-less frontend can render, plus the ONE budget that\n * bounds the whole lot.\n *\n * Built per REQUEST, never shared: the budget is a statement about what THIS\n * response may cost, and a process-lifetime counter would be a rate limit\n * instead — a different mechanism, owned by phase 09, that neither\n * substitutes for nor is substituted by this one.\n */\n function buildResolutionDeps(\n checker: PermissionChecker,\n role: string,\n locale: string | undefined,\n cacheTags: CacheTagSink,\n ): ReferenceResolutionDeps & MediaResolutionDeps & BlockResolutionDeps {\n // Named, because `afterExpand` closes over the very object it belongs to —\n // the hook must see the SAME budget, not a copy.\n const deps: ReferenceResolutionDeps & MediaResolutionDeps & BlockResolutionDeps = {\n // ONE budget object, shared by every kind of resolution this response\n // performs. Handing each kind its own would be four ceilings wearing one\n // name.\n budget: createResolutionBudget(maxResolutions),\n // The SAME request-scoped set the handler will hand to the header\n // builder. Reference and media resolution are the only places that know\n // which target rows actually came back, so they record them here rather\n // than the response site re-deriving them from the finished body (D055).\n // Blocks contribute too, as of editor PR24. D055 §8 stated the boundary\n // as structural — the block provider returned an opaque `unknown`, so\n // what a tree embedded was unknowable here — and that stopped being true\n // when `frontend-delivery` D019 gave it a typed `ResolvedBlockTree`. The\n // remaining objection was that the invalidation sender did not speak the\n // vocabulary; `frontend-delivery` PR 9 settled it, and the frontend now\n // mints a document tag per `ResolvedBlockTree.refs` entry. So\n // `resolveBlockTrees` records them here, and `blockTreeContents` is no\n // longer published as an `uncovered` dependency.\n cacheTags,\n resolveMedia: config.resolveMedia,\n resolveBlocks: config.resolveBlocks,\n // Expanded `?populate=` targets get the same treatment root rows do, so a\n // media field's wire shape does not depend on how the row was reached.\n afterExpand: async (target, rows) => {\n // A populated row of a TEMPLATE-DRAWN entity is narrowed exactly as its\n // by-id read is — otherwise a reference field on any other entity would\n // be the way to read the columns `/:id` withholds. No template: a\n // reference to a room is a link, not a page.\n if (isTemplateDrawn(target)) {\n for (const row of rows)\n await narrowTemplateDrawnRow(target, row, templateDeps(deps), false)\n return\n }\n await projectMedia(target, rows, deps)\n await resolveBlockTrees(target, rows, deps)\n },\n locale,\n // Whitelist ∧ `view`, for THIS caller. A target the caller may not reach\n // resolves to absence — byte-identical to a deleted one, so a reference\n // field cannot become a cheap, repeatable existence oracle (D006).\n //\n // Singular names only: `ReferenceField.entity` is an entity name, never a\n // plural route alias, and accepting the alias here would let the two\n // spellings mean different things.\n reachableEntity: (name) => {\n const target = resolveEntities().map.get(name)\n if (!target || target.name !== name) return undefined\n return hasViewPermission(checker, role, target.name) ? target : undefined\n },\n // The SAME gate, for a collection block's rows (editor PR53): whitelist\n // ∧ `view`, for THIS caller, singular names only. A page must not become a\n // side door around the entity whitelist — a collection over an entity\n // this API withholds lists nothing, byte-identical to one that matched\n // nothing.\n canReadEntity: (name) => {\n const target = resolveEntities().map.get(name)\n return (\n target !== undefined && target.name === name && hasViewPermission(checker, role, name)\n )\n },\n // Per RESPONSE, not per document: a list endpoint resolves up to its\n // page size of trees, each allowed six collections of its own.\n collectionAllowance: createCollectionAllowance(MAX_COLLECTIONS_PER_RESPONSE),\n // The declared title of a template-drawn reference target — through the\n // SAME provider projection its by-id read uses, so a reference and the\n // row it points at cannot name it differently.\n templateDrawnTitle: async (target, row) => {\n const provider = config.detailTemplates\n const titleLocale = locale ?? configDefaultLocale\n if (!provider || titleLocale === undefined) return null\n try {\n const projected = await provider.publicRow({\n entity: target.name,\n row,\n locale: titleLocale,\n })\n const title = typeof projected.title === 'string' ? projected.title.trim() : ''\n return title === '' ? null : title.slice(0, 2_000)\n } catch (err) {\n deps.logger?.warn(\n { entity: target.name, err },\n 'content-api: detail-template provider failed to project a reference title',\n )\n // As `narrowTemplateDrawnRow` does for the same failure: a cached\n // `title: null` would outlive the fix by a full TTL.\n cacheTags.markUncacheable()\n return null\n }\n },\n readRowsByIds: async (target, ids, select) => {\n const resolved = resolveTableCols(target.name)\n const idColumn = resolved?.cols.id\n // No registered table → no rows. Absence, not an error: a target whose\n // table is unregistered is exactly as unresolvable as a deleted one.\n if (!idColumn) return []\n // Nothing to look up, and — decisively — never a query with `limit: 0`.\n // `QueryClient.findMany` guards its limit with `if (options?.limit)`, so\n // a falsy limit emits NO `LIMIT` clause and returns the whole table. At\n // `maxResolutionsPerDocument: 0` the clamp below is exactly 0, so the\n // most restrictive configuration would otherwise produce the least\n // restrictive query — the same inversion `assertPositiveCeiling` throws\n // for. Callers already skip a zero grant; this is the backstop.\n if (ids.length === 0 || maxResolutions === 0) return []\n // Reads through the SAME `createClient` path every endpoint uses, so\n // the publish filter, locale merge and `view` gate apply identically —\n // an unpublished target simply does not come back, which is how it\n // becomes indistinguishable from a missing one at no extra cost.\n const client = createClient(target)\n const rows = await client.findMany({\n where: inArray(idColumn, [...ids]),\n // `ids` is already bounded by the resolution budget upstream; the\n // limit is belt-and-braces so a future caller cannot widen the read\n // by widening the id list alone.\n limit: Math.min(ids.length, maxResolutions),\n ...(select !== undefined && { select: [...select] }),\n ...(locale !== undefined && { locale }),\n defaultLocale: configDefaultLocale,\n })\n return rows as Record<string, unknown>[]\n },\n resolveEntityHref: config.resolveEntityHref,\n logger: resolutionLogger(),\n }\n return deps\n }\n\n /**\n * The structured logger the resolution layer drops diagnostics into, or\n * `undefined` before the app is bootstrapped.\n *\n * Request-BOUND (D032), so a resolution diagnostic joins the same correlation\n * id as everything else on the request without the resolution layer knowing\n * that correlation exists.\n *\n * Guarded because `getApp()` throws pre-bootstrap: a degraded resolution must\n * not become a crash on the way to reporting itself — the same shape\n * `logAuthenticateFailure` uses.\n */\n function resolutionLogger():\n | { debug: (obj: object, msg: string) => void; warn: (obj: object, msg: string) => void }\n | undefined {\n try {\n return requestLog()\n } catch {\n return undefined\n }\n }\n\n /**\n * Report whatever the budget refused.\n *\n * **Load-bearing, not diagnostics-for-completeness.** Phase 05 makes an\n * unresolved target indistinguishable from a deleted, unpublished or\n * unviewable one ON THE WIRE — deliberately, because splitting them is an\n * existence oracle. The consequence is that the operator log is the ONLY\n * place the distinction survives, so an editor asking \"why did my\n * related-article link disappear?\" can only be answered from here. Silent\n * truncation would read as \"covered everything\" (CLAUDE.md).\n *\n * `warn`, not `debug`: a rejected `filter[…]` key is a caller's typo, but a\n * budget drop is the SERVER declining work it was asked to do, and a signal\n * below the default level cannot arrive (F023).\n */\n function reportBudget(\n deps: ReferenceResolutionDeps & Pick<BlockResolutionDeps, 'collectionAllowance'>,\n entityName: string,\n path: string,\n ): void {\n const refusedCollections = deps.collectionAllowance?.refusedCount() ?? 0\n if (refusedCollections > 0) {\n try {\n requestLog().warn(\n {\n entity: entityName,\n path: truncateForLog(path),\n cap: MAX_COLLECTIONS_PER_RESPONSE,\n refused: refusedCollections,\n },\n 'content-api: per-response collection cap reached — the collection blocks past it rendered empty',\n )\n } catch {\n // Pre-bootstrap; the response is still correct, only the signal is lost.\n console.warn('[content-api] collection cap reached for', entityName, truncateForLog(path))\n }\n }\n if (!deps.budget.hasDrops) return\n try {\n requestLog().warn(\n {\n entity: entityName,\n // Capped like every other caller-controlled string that reaches a\n // log line — this one is a request pathname.\n path: truncateForLog(path),\n ceiling: deps.budget.ceiling,\n resolved: deps.budget.granted,\n dropped: deps.budget.dropSummary(),\n },\n 'content-api: per-document resolution budget exhausted — response rendered with unresolved targets',\n )\n } catch {\n // Pre-bootstrap; the response is still correct, only the signal is lost.\n // Bounded like the structured line above: the pathname is caller-supplied.\n console.warn(\n '[content-api] resolution budget exhausted for',\n entityName,\n truncateForLog(path),\n )\n }\n }\n\n /**\n * Parse `?populate=` and enforce its ceiling.\n *\n * Returns the accepted field set, or a `Response` to short-circuit with.\n * The ceiling REFUSES (400) rather than truncating — D007's posture, and for\n * its reason: a silent truncation answers 200 with an expansion the caller\n * did not get and cannot detect. Names refused by the whitelist are DROPPED\n * instead, because on the wire a dropped name is indistinguishable from an\n * entity that has no such field. The server never explains a missing target;\n * it always rejects a malformed request.\n */\n function resolvePopulate(\n populateParam: string | undefined,\n entity: Entity,\n allFields: Record<string, FieldConfig>,\n corsHeaders: Record<string, string>,\n ): { fields: ReadonlySet<string> } | { response: Response } {\n const parsed = parsePopulate(populateParam, allFields)\n // Counted against what was ASKED, not what survived: counting survivors\n // would refuse a correct request of six and wave through a malformed one of\n // fifty whose typos happened to be dropped first.\n if (parsed.requested > MAX_POPULATE_FIELDS) {\n return {\n response: errorResponse(\n `?populate= names ${parsed.requested} fields, which exceeds the maximum of ${MAX_POPULATE_FIELDS}.`,\n 'VALIDATION_ERROR',\n 400,\n corsHeaders,\n ),\n }\n }\n if (parsed.dropped.length > 0) {\n resolutionLogger()?.debug(\n { entity: entity.name, droppedPopulate: parsed.dropped },\n 'content-api: rejected ?populate= fields (unknown, internal, or not a reference)',\n )\n }\n return { fields: new Set(parsed.fields) }\n }\n\n /**\n * The ONE resolution pass every read endpoint runs, in the ONE order it must\n * run in.\n *\n * Extracted because the order is load-bearing and the steps are\n * security-relevant: an endpoint that ran three of the four would emit a bare\n * uuid where a projection belongs, or lose the only record of a budget drop.\n * Four hand-written copies is four chances to add a fifth endpoint that\n * forgets one.\n *\n * Note what is NOT in here: `handleList` mints its `nextCursor` BEFORE\n * calling this, because `?sort=` accepts reference fields and projecting\n * first would encode a projection object where the keyset WHERE expects the\n * stored uuid.\n */\n /** `deps` plus what a template-drawn row's narrowing needs — see `detail-template.ts`. */\n function templateDeps(deps: BlockResolutionDeps): TemplateDrawnDeps {\n return { ...deps, detailTemplates: config.detailTemplates, defaultLocale: configDefaultLocale }\n }\n\n async function resolveRows(\n entity: Entity,\n rows: Record<string, unknown>[],\n populate: ReadonlySet<string>,\n deps: ReferenceResolutionDeps & MediaResolutionDeps & BlockResolutionDeps,\n path: string,\n ): Promise<void> {\n await projectReferences(entity, rows, populate, deps)\n await projectMedia(entity, rows, deps)\n await resolveBlockTrees(entity, rows, deps)\n reportBudget(deps, entity.name, path)\n }\n\n // -- Endpoint handlers -----------------------------------------------------\n\n async function handleList(\n req: Request,\n entity: Entity,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = listParamsSchema.safeParse(toParamBag(url, { allowFilters: true }))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n const {\n limit: parsedLimit,\n offset,\n sort,\n locale,\n fields,\n search,\n cursor: cursorParam,\n populate: populateParam,\n } = params.data\n\n // Ceilings reject, never clamp (D007). Checked before any DB work.\n // `supplied` splits \"the caller asked for too much\" (400) from \"our own\n // default exceeds this host's ceiling\" (clamp) — see `ceilingViolation`.\n const limitSupplied = url.searchParams.has('limit')\n const overLimit = ceilingViolation('limit', parsedLimit, maxLimit, corsHeaders, limitSupplied)\n if (overLimit) return overLimit\n const overOffset = ceilingViolation('offset', offset, maxOffset, corsHeaders)\n if (overOffset) return overOffset\n const limit = resolveLimit(parsedLimit, maxLimit, limitSupplied)\n\n const allFields = entity.allFields as Record<string, FieldConfig>\n\n // `?populate=` is validated BEFORE any DB work — a ceiling violation must\n // not cost a query first.\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n // Cursor-based pagination (opt-in, replaces OFFSET when provided)\n let cursor: CursorInput | undefined\n if (cursorParam) {\n const decoded = decodeCursor(cursorParam)\n if (!decoded) {\n return errorResponse('Invalid cursor', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n // Whitelist via `Object.hasOwn` so prototype-chain keys (`__proto__`,\n // `constructor`, …) cannot pass.\n if (!Object.hasOwn(allFields, decoded.field) && decoded.field !== 'id') {\n return errorResponse('Invalid cursor field', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n // A cursor is caller-supplied (`decodeCursor` just base64-decodes it), and\n // it becomes a keyset WHERE — so an internal cursor field is a filter by\n // another name, and would let a caller binary-search `_workflowStatus` or\n // `_version` from the outside. Same gate as filter/sort.\n if (decoded.field !== 'id' && isInternalField(decoded.field, allFields[decoded.field])) {\n return errorResponse('Invalid cursor field', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n cursor = decoded\n }\n\n const where: SQL[] = []\n /** Did `?search=` actually add a predicate? See {@link isSearched} below. */\n let searchNarrowed = false\n if (search) {\n const searchCondition = buildSearchCondition(\n entity,\n search,\n locale ?? requestLocaleFallback(),\n )\n if (searchCondition) {\n where.push(searchCondition)\n searchNarrowed = true\n }\n }\n\n const filters = buildFilterConditions(entity.name, url, allFields)\n where.push(...filters.conditions)\n\n const sortResult = buildOrderBy(entity, sort, allFields)\n const orderBy = sortResult.orderBy\n // The manual-order sort (editor-curation D001) is OFFSET-only: no cursor is\n // minted for it (the key never leaves the server), and a caller-made cursor\n // on any other field would set a keyset boundary that does not follow the\n // result order — pages would skip or repeat rows. Refused, not ignored.\n if (\n cursor !== undefined &&\n sortResult.field !== undefined &&\n isSortOnlyInternalField(entity, sortResult.field)\n ) {\n return errorResponse('Invalid cursor for this sort', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const select = parseFieldSelection(fields, allFields)\n\n // Rejected filter/sort keys are dropped SILENTLY on the wire — deliberately,\n // so the response can't be used to probe which field names exist or which\n // are internal (the same 404-not-403 reasoning the entity gate uses). That\n // leaves an operator with nothing to grep when a frontend's `filter[path]`\n // quietly stops matching, so it is recorded server-side. Mirrors\n // `@murumets-ee/blocks`' where-builder, which reports its own `dropped` keys\n // the same way.\n //\n // `info`, NOT `debug`, and that is the whole of F023: `createApp` defaults\n // the app logger to `info` and both real mounts configure `info`, so this\n // record was CONSTRUCTED AND DISCARDED in every default deployment — the\n // response silent by design and the log silent by configuration, which is\n // indistinguishable from the feature working. A record that exists to\n // explain a deliberate silence belongs at a level a default deployment\n // collects; `debug` stays right for volume-heavy internals nobody is\n // expected to be reading, of which this surface has none.\n //\n // BOUNDED, and promoting the level is what made that mandatory. The payload\n // is entirely caller-chosen — `filters.dropped` gains an entry per matching\n // `filter[…]` param with duplicates counted separately, and `droppedSort`\n // is the raw `sort` value — so at `debug` under an `info` default it was\n // discarded, and at `info` it becomes a per-request, attacker-sized write\n // an unauthenticated caller controls. Deduped, capped, and truncated, with\n // the overflow COUNTED rather than dropped silently: a diagnostic that\n // hides how much it hid is worse than one that says \"and 400 more\".\n if (filters.dropped.length > 0 || sortResult.dropped !== undefined) {\n // Each KEY is capped too, not only the count: `filter[…]`'s own pattern\n // is `[a-zA-Z_]+`, unbounded, so twenty keys of eight kilobytes each\n // would clear the count cap and still be the same per-request write.\n const distinctDropped = [...new Set(filters.dropped)]\n const shown = distinctDropped.slice(0, MAX_REPORTED_DROPPED_KEYS).map(truncateForLog)\n requestLog().info(\n {\n entity: entity.name,\n ...(filters.dropped.length > 0 && {\n droppedFilters: shown,\n ...(distinctDropped.length > shown.length && {\n droppedFiltersOmitted: distinctDropped.length - shown.length,\n }),\n }),\n ...(sortResult.dropped !== undefined && {\n droppedSort: truncateForLog(sortResult.dropped),\n }),\n },\n 'content-api: rejected query params (unknown, internal, or invalid)',\n )\n }\n\n const client = createClient(entity)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n // D047 — A SEARCHED LIST DOES NOT COUNT.\n //\n // `findMany` stops at `limit` rows; `count` cannot. Against an unindexed\n // `ILIKE '%q%'` OR'd with a correlated per-row `EXISTS` on the translations\n // table, the count evaluates the predicate against EVERY row in the\n // collection, on every request — strictly more expensive than the page it\n // accompanies, and the half nobody asked for.\n //\n // And the `CountCache` cannot absorb it: `buildCountCacheKey` serializes the\n // WHERE including its bind parameters (deliberately — its docblock records\n // the bug where a `?search=foo` count was served for `?search=bar`), so the\n // search term makes every key fresh. The miss is not even free: the cache is\n // a plain `Map` with no max-entries bound whose entries are removed only by\n // a later `get()` on the same key, which for a one-off search term never\n // comes. Every distinct term deposited a permanent entry on the always-on\n // public read path (F035).\n //\n // So: fetch `limit + 1` rows, slice to `limit`, and derive `hasMore` from\n // what came back. Exact, one extra row, no count query — and no cache key,\n // so there is nothing to bound rather than a bound that has to be right.\n //\n // A second property falls out, which phase 01 flagged the absence of: the\n // old `hasMore` was `offset + limit < total`, so it INHERITED the count's\n // freshness and a stale or locale-mismatched `total` made it lie. The\n // `limit + 1` form observes the rows themselves and cannot.\n //\n // F035 is WIDER than search — `filter[...]` values are caller-controlled and\n // key the same cache the same way. Bounding `CountCache` is a\n // `@murumets-ee/entity` change affecting both surfaces and both client\n // types. This removes the widest door structurally; it does not close the\n // room.\n // Derived from whether a search predicate was actually ADDED, not from the\n // presence of the parameter.\n //\n // `buildSearchCondition` returns `undefined` for an entity that declares no\n // `searchable()`, in which case `?search=` narrows nothing and the response\n // is the full unfiltered list. Keying the count-suppression off the raw\n // parameter would have made such a request pay the `limit + 1` fetch AND\n // report `total: null` — i.e. silently drop the count for a list that was\n // never searched, which is not what D047 suppresses and not what `_schema`\n // publishes. `SchemaField.searchable` tells a consumer this entity matches\n // nothing; the response should not additionally lie about the count.\n const isSearched = searchNarrowed\n const queryOpts = {\n where: where.length > 0 ? and(...where) : undefined,\n limit: isSearched ? limit + 1 : limit,\n offset,\n orderBy,\n select,\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n cursor,\n }\n\n const [fetched, total] = await Promise.all([\n client.findMany(queryOpts),\n // Locale threads into count's publish filter so `total` agrees with\n // the per-locale effective statuses the list itself applies — a\n // base-published/locale-draft row must not inflate total past\n // items.length (hasMore would be wrongly true).\n isSearched\n ? Promise.resolve(null)\n : client.count({\n where: where.length > 0 ? and(...where) : undefined,\n locale: resolvedLocale,\n }),\n ])\n\n const items = isSearched && fetched.length > limit ? fetched.slice(0, limit) : fetched\n const hasMore = isSearched ? fetched.length > limit : offset + limit < (total ?? 0)\n\n // Build nextCursor from last item for keyset pagination.\n //\n // The field comes from the ACCEPTED sort (`sortResult.field`), never the raw\n // `?sort=` param: a rejected sort produces no ORDER BY, so minting a cursor\n // for it would hand the client a token that the cursor gate 400s on the very\n // next request. It closes by never naming a field the sort layer refused.\n let nextCursor: string | undefined\n if (items.length > 0 && items.length === limit) {\n const lastItem = items[items.length - 1] as Record<string, unknown>\n const cursorField = cursor?.field ?? sortResult.field\n const cursorDirection: 'asc' | 'desc' =\n cursor?.direction ?? (sort?.startsWith('-') ? 'desc' : 'asc')\n if (cursorField && lastItem[cursorField] !== undefined) {\n nextCursor = encodeCursor(lastItem, cursorField, cursorDirection)\n }\n }\n\n // AFTER the cursor is minted, deliberately. `?sort=` accepts any\n // non-internal, non-blockTree field, `reference` included — so projecting\n // first would hand `encodeCursor` a projection OBJECT where the keyset\n // WHERE expects the stored uuid, and the next page would 400 or silently\n // return the wrong rows. The cursor is a statement about STORED values; the\n // projection is a statement about the wire.\n //\n // Stored reference ids become projections (or, for `?populate=`d fields,\n // full DTOs at depth 1) — the whole page under ONE budget, because every\n // row's references compete for the same pool. A 50-row page whose rows each\n // point at 3 documents is 150 resolutions from one unauthenticated request,\n // which is exactly the amplification the budget exists to bound.\n // TAGGED BEFORE RESOLUTION, and the order is the design (D055 §5).\n //\n // A list is set-shaped: a BRAND-NEW row changes page 1 while carrying no\n // tag any cached response ever held, so the per-document tags alone would\n // silently never invalidate this response on create. The collection tag is\n // what covers that, and it is the half R05's \"tag the composing documents\"\n // summary did not reach — see R16.\n //\n // Adding the page's own rows here rather than after `resolveRows` puts them\n // ahead of every resolved reference and media id in the insertion order, so\n // a page that overflows the 128-tag cap degrades to \"this page's rows plus\n // the collection\" rather than to an arbitrary 128 of the two mixed.\n tags.addCollection(entity.name)\n for (const item of items as Record<string, unknown>[]) {\n tags.addDocument(entity.name, item.id)\n }\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, resolvedLocale, tags)\n await resolveRows(\n entity,\n items as Record<string, unknown>[],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n\n return jsonResponse(\n {\n data: items,\n // `total` is `number | null` in the v1 collection contract, where\n // `null` means \"not counted on this request\" (D047). `_schema`\n // publishes WHICH parameters suppress it, so a client reads that rather\n // than inferring it.\n //\n // Phase 01 already drew this line and already had an endpoint on the\n // other side of it — `_paths` omits `total` because its generator source\n // has no cheap count — and stated the rule generally: a collection\n // endpoint promises `limit` and `hasMore`; `total` is promised only\n // where a count is affordable. A searched list is the second place it is\n // not. This applies the existing rule rather than inventing one.\n //\n // Free now, expensive later: widening `number` to `number | null` breaks\n // a client doing arithmetic on it, and D010 records that no content-api\n // consumer is live.\n meta: { total, limit, offset, hasMore, nextCursor },\n },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleCount(\n req: Request,\n entity: Entity,\n principal: ResolvedPrincipal,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = countParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n // ?countMode=estimate is only safe for entities that don't apply\n // publish/scope filters at query time — otherwise the pg_class row count\n // leaks unpublished or out-of-scope rows. For publishable entities we\n // always run the filtered count instead.\n //\n // We fall through to the filtered count even when the public client asked\n // for an estimate; the response shape stays compatible (`{count}`).\n const client = createClient(entity)\n // Locale threads into the publish filter (per-locale effective status)\n // so the count endpoint agrees with what a locale-filtered list returns.\n const count = await client.count({ locale: params.data.locale ?? requestLocaleFallback() })\n\n // THE COLLECTION TAG ALONE, and this endpoint is the honest negative\n // control for the whole scheme (D055).\n //\n // A count body names no row, so there is no document to tag — and it is\n // set-shaped in the purest way: the number changes when ANY row is created\n // or deleted and when no row it could name was touched. It is storable, it\n // goes through the same builder as every other success, and it must carry\n // zero `lumi:<entity>:<id>` tags. `_schema` carrying none proves less, since\n // `_schema` is `no-store` and would carry none for that reason alone.\n tags.addCollection(entity.name)\n\n return jsonResponse(\n { data: { count } },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleById(\n req: Request,\n entity: Entity,\n id: string,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n // Entity primary keys are UUID-typed throughout the toolkit. Reject\n // non-UUID inputs as 404 rather than letting Postgres throw a cast error\n // that would surface as a 500 (and side-channel \"valid format but missing\"\n // vs \"bogus format\").\n if (!isUuid(id)) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = singleParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n const { locale, fields, populate: populateParam, page } = params.data\n const allFields = entity.allFields as Record<string, FieldConfig>\n // A template-drawn row is read WHOLE: its bound blocks are fed from columns\n // the caller cannot name, and what crosses is narrowed below regardless.\n const select = isTemplateDrawn(entity) ? undefined : parseFieldSelection(fields, allFields)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n const client = createClient(entity)\n const item = await client.findById(id, {\n ...(select !== undefined && { select }),\n ...(resolvedLocale !== undefined && { locale: resolvedLocale }),\n defaultLocale: configDefaultLocale,\n })\n if (!item) return notFoundResponse(corsHeaders)\n\n // NO collection tag, and the argument is specific rather than an omission\n // (D055 §1). The set of rows answering `/:id` cannot change without WRITING\n // the row already named here; and had no row answered, the response would\n // be a 404, which is unconditionally `no-store` (SD005) and so has no\n // stored entry to go stale.\n tags.addDocument(entity.name, id)\n\n // `page` (davatoidud-toolkit PR03) reaches only THIS document's trees — the\n // page a paged collection in it shows. Page 1 is the absence of one.\n const resolutionDeps = {\n ...buildResolutionDeps(checker, principal.role, resolvedLocale, tags),\n ...(page !== undefined && page > 1 && { page }),\n }\n if (isTemplateDrawn(entity)) {\n // A row drawn by a stored template (block-library D009): narrowed to what\n // its page shows, with the entity type's published template resolved\n // against it. `?fields=` and `?populate=` name columns this response does\n // not carry, so neither is applied. See `detail-template.ts`.\n await narrowTemplateDrawnRow(\n entity,\n item as Record<string, unknown>,\n templateDeps(resolutionDeps),\n true,\n )\n reportBudget(resolutionDeps, entity.name, url.pathname)\n } else {\n await resolveRows(\n entity,\n [item as Record<string, unknown>],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n }\n\n return jsonResponse(\n { data: item },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleBySlug(\n req: Request,\n entity: Entity,\n slug: string,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = singleParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n // Check entity has a slug field\n const allFields = entity.allFields as Record<string, FieldConfig>\n if (!allFields.slug) return notFoundResponse(corsHeaders)\n\n const { locale, fields, populate: populateParam, page } = params.data\n const select = parseFieldSelection(fields, allFields)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n const resolved = resolveTableCols(entity.name)\n if (!resolved) return notFoundResponse(corsHeaders)\n const tableSlug = resolved.cols.slug\n const tableId = resolved.cols.id\n if (!tableSlug || !tableId) return notFoundResponse(corsHeaders)\n\n const client = createClient(entity)\n const slugConfig = allFields.slug as SlugField\n\n // For translatable slugs, also search the translation table.\n // A non-default locale's slug may live in {entity}_translations, not the main table.\n let whereClause: SQL = eq(tableSlug, slug)\n\n if (slugConfig.translatable && resolvedLocale) {\n const translationTableName = `${entity.name}_translations`\n const translationResolved = resolveTableCols(translationTableName)\n if (translationResolved) {\n const translationSlug = translationResolved.cols.slug\n const translationLocale = translationResolved.cols.locale\n const translationEntityId = translationResolved.cols.entityId\n if (translationSlug && translationLocale && translationEntityId) {\n // Find entity IDs whose translated slug matches (for the requested locale).\n // QueryClient applies publish + scope filters on the follow-up findMany,\n // so unpublished/out-of-scope translated slugs cannot leak through here.\n const app = getApp()\n // Confined to the request's site for a site-bound entity (multisite\n // D003): another site's translated slug must not even change which\n // follow-up query runs (a timing/existence oracle), let alone match.\n const site = await requireSiteFor(entity, app)\n const bySlug = and(eq(translationSlug, slug), eq(translationLocale, resolvedLocale))\n const translationMatches = await app.db.readOnly\n .select({ entityId: translationEntityId })\n .from(translationResolved.table)\n .where(\n site !== undefined\n ? and(bySlug, eq(requireSiteColumn(translationResolved.cols, entity.name), site))\n : bySlug,\n )\n\n if (translationMatches.length > 0) {\n const translatedIds = translationMatches.map((r) => r.entityId as string)\n // Match by translated slug OR by main table slug (fallback).\n // `or(...)` returns SQL when given two non-undefined inputs; the\n // `?? eq(...)` keeps the type system honest without a non-null\n // assertion.\n whereClause =\n or(inArray(tableId, translatedIds), eq(tableSlug, slug)) ?? eq(tableSlug, slug)\n }\n }\n }\n }\n\n const results = await client.findMany({\n where: whereClause,\n limit: 1,\n select,\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n })\n\n if (results.length === 0) return notFoundResponse(corsHeaders)\n\n // COLLECTION TAG, because a slug is not a key — and the first version of\n // this comment claimed otherwise, which review falsified with a concrete\n // sequence rather than a doubt.\n //\n // The WHERE above is deliberately an OR: `id IN (rows whose TRANSLATED slug\n // matches) OR main.slug = <slug>`. Two different rows can therefore match,\n // and `findMany` runs with `limit: 1` and NO `orderBy`, so which one answers\n // is unordered. Concretely: article A has main slug `foo`, article B has\n // main slug `bar` and a French translated slug `foo`. `?locale=fr` matches\n // both; the response is cached carrying whichever came back. Editing the\n // OTHER one purges a tag this response does not hold, the origin's\n // unordered pick can flip, and the entry serves the wrong row for the whole\n // window.\n //\n // The collection tag is what closes that, and it is why only `handleById` —\n // the one read addressed by PRIMARY KEY — omits it.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, (results[0] as Record<string, unknown> | undefined)?.id)\n\n // See `handleById`: the page a paged collection in THIS document shows.\n const resolutionDeps = {\n ...buildResolutionDeps(checker, principal.role, resolvedLocale, tags),\n ...(page !== undefined && page > 1 && { page }),\n }\n await resolveRows(\n entity,\n results as Record<string, unknown>[],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n\n return jsonResponse(\n { data: results[0] },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /:entity` for an entity declaring `kind: 'singleton'` — the resource\n * IS the collection, so the path addresses it directly and the response is\n * the SINGLE-resource envelope `{ data: row }`.\n *\n * Before this existed, a singleton was served as a one-row collection and the\n * client was expected to know, out of band, to take `data[0]`. `_schema` now\n * publishes {@link SchemaEntity.kind} so that is discoverable rather than\n * folklore.\n *\n * **No `meta`** — a singleton has no pagination, which is also why\n * `singleParamsSchema` (not `listParamsSchema`) is the right validator here:\n * `?locale=` and `?fields=` are the only params that mean anything.\n *\n * **No row → 404** (D006). A singleton with no row is an ABSENCE, and absence\n * is shaped as absence — the same 404 every other missing resource returns,\n * never a 200 carrying an empty body a client would have to special-case.\n *\n * Reads through the same `createClient(entity)` path as every other endpoint\n * — no new query primitive — threading `locale`/`defaultLocale` and\n * `locale`/`defaultLocale` exactly as {@link handleBySlug} does.\n */\n async function handleSingleton(\n req: Request,\n entity: Entity,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const url = new URL(req.url)\n const params = singleParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n\n const { locale, fields, populate: populateParam, page } = params.data\n const allFields = entity.allFields as Record<string, FieldConfig>\n const select = parseFieldSelection(fields, allFields)\n const resolvedLocale = locale ?? requestLocaleFallback()\n\n const populate = resolvePopulate(populateParam, entity, allFields, corsHeaders)\n if ('response' in populate) return populate.response\n\n // NO `orderBy` — deliberately. `QueryClient.findMany` already falls back to\n // `singletonReadOrder()` for a `kind: 'singleton'` entity, which orders\n // `[asc(createdAt), asc(id)]` (D011). Passing one here would WIN over that\n // fallback (`options?.orderBy ?? singletonReadOrder(...)`) and drop the\n // `createdAt` term — so on an entity that wrongly has two rows, this\n // endpoint would return the lowest UUID while every other read path in the\n // toolkit (admin edit screen, chrome renderer, live-site composition — all\n // `findMany({ limit: 1 })` with no order) returns the oldest row. The public\n // API and the rendered site would disagree about which row IS the\n // singleton, and a caching frontend would pin the disagreement.\n const client = createClient(entity)\n const results = await client.findMany({\n limit: 1,\n select,\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n })\n\n const row = results[0]\n if (!row) return notFoundResponse(corsHeaders)\n\n // COLLECTION TAG, for the reason this function's own comment above already\n // states: `kind: 'singleton'` is not yet ENFORCED (D011), so \"an entity that\n // wrongly has two rows\" is a live state, and the read is an ordered pick\n // over a set rather than a keyed lookup. Rows A (older, draft) and B (newer,\n // published): the publish filter drops A, so B answers and is tagged.\n // Publishing A makes A win the `asc(createdAt)` order — and purging A's\n // document tag misses an entry that only ever named B.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, (row as Record<string, unknown>).id)\n\n // See `handleById`: the page a paged collection in THIS document shows.\n const resolutionDeps = {\n ...buildResolutionDeps(checker, principal.role, resolvedLocale, tags),\n ...(page !== undefined && page > 1 && { page }),\n }\n await resolveRows(\n entity,\n [row as Record<string, unknown>],\n populate.fields,\n resolutionDeps,\n url.pathname,\n )\n\n return jsonResponse(\n { data: row },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n // -- Resolution / enumeration endpoints (routing PR07) ---------------------\n\n /**\n * Resolve an entity from the whitelist by singular or plural name. Deny-by-\n * default: returns undefined for anything not explicitly registered.\n */\n function resolveWhitelistedEntity(name: string): Entity | undefined {\n return (\n resolveEntities().map.get(name) ??\n resolveEntities().map.get(name.endsWith('s') ? name.slice(0, -1) : name)\n )\n }\n\n /**\n * Say — to the SERVER LOG, in development only — which of the two\n * deny-by-default gates refused. The response is untouched.\n *\n * ## Why this exists\n *\n * Reaching a public page passes two gates that a plugin-contributed entity\n * fails for two completely different reasons:\n *\n * 1. `createContentApiHandler({ entities })` — the whitelist. An unlisted\n * entity is not served at all. Fixed by editing the route file.\n * 2. The persisted role map. `buildInitialRoleDefinitions()` seeds `public`\n * with the entities the SCAFFOLD ships and cannot know about entities a\n * plugin contributes later. Fixed by granting `view`, in the Permission\n * Matrix or a seeder.\n *\n * Both refuse as a 404, never a 403 (D006) — deliberately, so an outsider\n * cannot tell \"you may not\" from \"it does not exist\". That property is worth\n * keeping and is NOT weakened here: the status, headers and body are\n * identical, and nothing is added to the response.\n *\n * But the same ambiguity is aimed at the OPERATOR, who is not an outsider,\n * and for whom it is pure cost. `entity.access.view` does not help — it is\n * advisory metadata the firewall never reads, and both yhikas entities\n * declare `view: 'group.admin'` while needing a `public` grant, which reads\n * as the exact opposite of the fix. So the honest signal is a server-side\n * line naming which gate it was.\n *\n * Development-only, deliberately, and as an ALLOWLIST rather than a\n * not-production denylist: this is per-request work on an anonymous,\n * cacheable path, so an unauthenticated caller can drive log volume simply\n * by asking for names that do not exist. `NODE_ENV` is not a two-valued\n * flag — a staging or preview deployment commonly runs with `staging`, and a\n * container that never sets it at all leaves it `undefined`. Both are\n * public, and both would have been opted IN by a `!== 'production'` guard.\n */\n function explainEntityRefusal(requestedName: string, entity: Entity | undefined): void {\n if (process.env.NODE_ENV !== 'development') return\n // 🔴 The requested name is CALLER-CONTROLLED at three of the four call\n // sites — a percent-decoded url segment, or a Zod string bounded only in\n // LENGTH — so it reaches here with newlines intact and would otherwise let\n // anyone forge a `[content-api]` line into the log. `JSON.stringify`\n // escapes the control characters and quotes the value, and the slice bounds\n // it. This is the same rule stated a few lines below at the monitoring\n // seam (\"only a whitelisted entity gets named … so a probe string cannot\n // ride into a monitoring payload\"); the diagnostic must not reintroduce\n // what that comment exists to keep out.\n const safeName = JSON.stringify(requestedName.slice(0, MAX_ENTITY_NAME_LENGTH))\n const detail =\n entity === undefined\n ? `${safeName} is not in the content-api whitelist — add it to ` +\n `createContentApiHandler({ entities: [...] }) in your content route.`\n : `\"${entity.name}\" is whitelisted but the requesting role lacks 'view' — grant it in ` +\n `the Permission Matrix, or seed it. Note that entity.access.view is advisory ` +\n `metadata the firewall never reads, so a restrictive value there is not the cause.`\n console.warn(\n `[content-api] 404 for ${safeName}: ${detail} ` +\n `(Both refusals are 404 by design — this line is the server-side disambiguation, ` +\n `and the response is unchanged.)`,\n )\n }\n\n /**\n * `GET /_resolve?path=&locale=` — project a `(path, locale)` onto the routing\n * resolution result (document / redirect / none) via the injected\n * PUBLISHED-only resolver (`resolution.resolvePath`). The wiring must pass the\n * published resolver, never a draft/preview one that skips publish checks — see\n * {@link ContentApiResolution}. The result is GATED:\n * - `none` → 404.\n * - `redirect` → returned as data (no entity involved; the frontend decides).\n * - `document` → the resolved `entityType` must be in THIS instance's\n * whitelist AND viewable by the caller's role, else 404 (indistinguishable\n * from \"path doesn't exist\"). The entity payload is NOT inlined — the\n * frontend follows up with `GET /:entity/:id` so caching/perms stay\n * per-entity.\n *\n * 404s when no `resolution` is wired (opt-in; deny-by-default).\n */\n async function handleResolve(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!resolution) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = resolveParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { path, locale } = params.data\n\n const result = await resolution.resolvePath(path, locale)\n\n if (result.kind === 'none') {\n return notFoundResponse(corsHeaders)\n }\n\n if (result.kind === 'redirect') {\n // A redirect projection carries no entity, so no whitelist/view gate\n // applies. `no-store` mirrors the other non-entity special endpoint\n // (`_schema`): the target changes on rename/unpublish and is cheap to\n // recompute, and it keeps the invariant \"no per-entity cache without an\n // entity\" simple.\n return jsonResponse({ data: result }, 200, { ...corsHeaders, 'Cache-Control': 'no-store' })\n }\n\n // result.kind === 'document'\n const entity = resolveWhitelistedEntity(result.entityType)\n if (!entity || !hasViewPermission(checker, principal.role, entity.name)) {\n explainEntityRefusal(result.entityType, entity)\n return notFoundResponse(corsHeaders)\n }\n\n // COLLECTION TAG TOO — this is a routing-layer answer, not a keyed read.\n // Which document owns a path is a property of the path index: another row\n // of this entity can take the path over without the row named here being\n // written, and then only the collection tag catches it.\n //\n // ONE RESIDUAL, and it is published rather than left implicit: a takeover\n // by a document of a DIFFERENT entity is not covered, because the tag can\n // only name the entity that answered. `_schema.cacheTags.uncovered` carries\n // `crossEntityPathChanges` for exactly this.\n //\n // `entity.name`, not `result.entityType`: the two are equal here, and using\n // the RESOLVED name is the same discipline `facts.entity` follows two\n // functions over — only a whitelisted name reaches a payload that leaves\n // the process. The redirect branch above is `no-store` and carries nothing.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, result.id)\n\n return jsonResponse(\n { data: result },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_paths?locale=&entity=&cursor=&limit=` — paginated enumeration of a\n * single entity type's PUBLISHED canonical paths via `resolution.enumeratePaths`.\n * `entity` is REQUIRED and gated (whitelist ∧ view) UP FRONT — unknown/\n * unpermitted → 404, no enumeration attempted (deny-by-default; never per-type\n * mid-stream gating).\n *\n * Pagination reuses the response-envelope shape (not `handleList`'s cursor\n * idiom): we consume at most `limit` entries off the async iterable (never to\n * exhaustion), and `nextCursor` is the last consumed entry's `id` (an opaque\n * DOCUMENT id the provider round-trips back through `afterDocumentId`). `hasMore` is\n * true iff we consumed exactly `limit` (there might be more) — `handleList`'s\n * convention. No `total` (the generator source has no cheap count).\n *\n * 404s when no `resolution` is wired (opt-in; deny-by-default).\n */\n async function handlePaths(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!resolution) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = pathsParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { locale, entity: entityName, cursor, limit: parsedLimit } = params.data\n\n // Same loud ceiling as `handleList` — `_paths` is the shape to copy, not an\n // exception. No OFFSET ceiling because it has no offset: it is cursor-only\n // by construction. Same supplied-vs-default split, for the same reason.\n const limitSupplied = url.searchParams.has('limit')\n const overLimit = ceilingViolation('limit', parsedLimit, maxLimit, corsHeaders, limitSupplied)\n if (overLimit) return overLimit\n const limit = resolveLimit(parsedLimit, maxLimit, limitSupplied)\n\n const entity = resolveWhitelistedEntity(entityName)\n if (!entity || !hasViewPermission(checker, principal.role, entity.name)) {\n explainEntityRefusal(entityName, entity)\n return notFoundResponse(corsHeaders)\n }\n\n const iterable = resolution.enumeratePaths({\n locale,\n entityType: entity.name,\n ...(cursor !== undefined && { afterDocumentId: cursor }),\n })\n\n const data: ContentPathEntry[] = []\n for await (const entry of iterable) {\n data.push(entry)\n if (data.length === limit) break\n }\n\n // hasMore ⇔ we consumed exactly `limit` (there MIGHT be more) — the same\n // convention `handleList` uses. We never run the iterable to exhaustion.\n const hasMore = data.length === limit\n const nextCursor = hasMore ? data[data.length - 1]?.id : undefined\n\n // Set-shaped: a new document adds a path this response does not list, so\n // the collection tag is load-bearing exactly as it is for `handleList`.\n //\n // `entity.name` rather than each entry's own `entityType`: the endpoint\n // gates on a single `?entity=`, so they are the same value — and the gated\n // one is the only one that has been through the whitelist. An entry's\n // `entityType` comes from an injected host provider, and a host-supplied\n // string must not reach a response header unchecked.\n tags.addCollection(entity.name)\n for (const entry of data) tags.addDocument(entity.name, entry.id)\n\n return jsonResponse(\n { data, meta: { limit, hasMore, nextCursor } },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_alternates?entity=&id=` — the published per-locale canonical URLs for\n * one document (the hreflang set) via `resolution.getAlternates`. `entity` +\n * `id` REQUIRED; `entity` gated (whitelist ∧ view) UP FRONT — unknown/\n * unpermitted → 404 before any lookup.\n *\n * 404s when no `resolution` is wired (opt-in; deny-by-default).\n */\n async function handleAlternates(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!resolution) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = alternatesParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { entity: entityName, id } = params.data\n\n const entity = resolveWhitelistedEntity(entityName)\n if (!entity || !hasViewPermission(checker, principal.role, entity.name)) {\n explainEntityRefusal(entityName, entity)\n return notFoundResponse(corsHeaders)\n }\n\n // The site's languages only (multisite D007). The path index holds a row\n // per INSTALL locale, so the provider returns one for a language this site\n // does not serve — and a frontend would advertise it as an `hreflang`\n // pointing at a URL the site answers 404 for. `availableLocales` is set on\n // the request context only when the site narrows the install's set.\n const served = getContext()?.availableLocales\n const all = await resolution.getAlternates(entity.name, id)\n const alternates = served ? all.filter((alternate) => served.includes(alternate.locale)) : all\n\n // The body is `[{locale, path}]` and names no id — but every entry is a\n // per-locale path OF this one document, so the document is what composed\n // it. The id is the caller's own `?id=`, which keeps the disclosure\n // invariant (D055 §3) trivially true here: it is echoing back what the\n // caller sent.\n //\n // COLLECTION TAG TOO, same reason as `_resolve`: a path is not only a\n // function of its own row. Where paths nest, renaming an ANCESTOR rewrites\n // this document's paths without writing this document — so the document tag\n // alone would leave the entry stale.\n tags.addCollection(entity.name)\n tags.addDocument(entity.name, id)\n\n return jsonResponse(\n { data: alternates },\n 200,\n buildResponseHeaders(\n entity.name,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_menu?handle=&locale=¤tPath=` — a render-ready navigation menu\n * for one locale via the injected `menus.getMenu` (routing PR09, phase 11 §3).\n *\n * **Gate: UP FRONT, on the handle allowlist** — `handle` is the required\n * identifying param, so it is checked against `menus.handles` BEFORE\n * `getMenu` is ever called, exactly like `_paths`/`_alternates` gate their\n * required `entity`. Deny-by-default; an unlisted handle is indistinguishable\n * from a non-existent one (404, never 403).\n *\n * **No per-entity `view` check, deliberately.** A resolved item carries no\n * entity identity to check (phase 11 §2's shape has no `entityType`), and a\n * menu is not an entity in the whitelist sense — its `handles` allowlist IS\n * the operator's explicit \"this navigation is public\" decision, the same\n * shape of opt-in as `config.entities`. This handler therefore takes NO\n * `checker`: it structurally cannot consult a permission. See\n * {@link ContentApiMenus}, which documents exactly what that exposes.\n *\n * **Cache-Control: the public policy for every caller — with ONE exception,\n * stated at the end of this comment — and this endpoint alone gets to do\n * that.** The response is cacheable\n * (`max-age`/`stale-while-revalidate` from `CacheConfig`) for EVERY caller,\n * not just the anonymous one. Two independent reasons, and both are needed:\n *\n * 1. The body cannot vary per principal. `getMenu` receives `(handle, {\n * locale, currentPath })` and nothing else — no request, no role, no\n * identity — so there is no caller-specific body for a shared cache to\n * leak. The `cacheClassOf` split every entity route applies exists to\n * protect a body that varies; here there is none to protect.\n * 2. Applying the split anyway would make CREDENTIAL VALIDITY observable.\n * This endpoint has no entity permission to gate on, so a credentialed\n * caller holding zero permissions still reaches it — and would receive\n * `no-store` + a credential selector in `Vary` where an anonymous caller\n * gets `public, max-age=…`. A token-guesser would learn \"this credential\n * verified, and resolved something other than the public principal\" from\n * an endpoint that required no permission at all. Uniform headers make\n * the two answers byte-identical.\n *\n * **Uniform across CALLERS is not uniform across ORIGINS, and this response\n * still carries `Vary`.** It was the one cacheable response emitting none. An\n * SSR frontend fetches it server-side with NO `Origin`, so `buildCorsHeaders`\n * contributes nothing; a CDN stores `public, max-age=…` with no selector, and\n * per RFC 9111 §4.1 that entry then matches a browser request from an allowed\n * origin and is replayed WITHOUT `Access-Control-Allow-Origin` — so the\n * browser blocks a response the server treated as a success.\n * `buildPrincipalInvariantHeaders` adds `Vary: Origin` and NOTHING else: a\n * credential selector would fragment the cache for a variance that, by reason\n * 1, does not exist.\n *\n * Cacheability also matters more here than for a single row: one menu render\n * costs several batched DB round-trips and a public SSR frontend needs it on\n * EVERY uncached page view, so an unconditional `no-store` — the other way to\n * make the headers uniform — would make it un-absorbable by a CDN. `_menu` is\n * a RESERVED route segment, so its cache key can never collide with a real\n * entity name; an app that wants a different menu TTL sets\n * `cache.entities['_menu']`, otherwise the global defaults apply.\n *\n * This handler therefore takes no `checker`: it structurally cannot consult\n * a permission. **If `getMenu` ever gains a principal-derived input, reason 1\n * dies and this MUST move to `buildResponseHeaders(..., cacheClassOf(…),\n * authVary)` — accepting the reason-2 oracle, or closing it with a real\n * gate.**\n *\n * **THE EXCEPTION, and it is that clause coming true (multisite D008).** A\n * menu belongs to a site, and a site-bound key selects the site — so for a\n * caller whose credential carries a site, the body DOES vary by credential:\n * reason 1 is dead for that caller. It takes the principal for that one\n * purpose, and such a response goes through `buildResponseHeaders`\n * (`private`, `Vary: Origin, Authorization`). The reason-2 oracle is\n * ACCEPTED there, knowingly: a caller presenting a site-bound key can read\n * off the headers that the key verified. What it learns is that its own key\n * works, which it already knows from getting a menu at all — on an app\n * serving several sites a caller with no site gets a 404 here.\n *\n * 404s when no `menus` provider is wired (opt-in; deny-by-default).\n */\n async function handleMenu(\n req: Request,\n principal: ResolvedPrincipal,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!menus) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = menuParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { handle, locale, currentPath } = params.data\n\n if (!menuHandles.has(handle)) return notFoundResponse(corsHeaders)\n\n const menu = await menus.getMenu(handle, {\n locale,\n ...(currentPath !== undefined && { currentPath }),\n })\n // Unknown or unpublished menu — same 404 as an unlisted handle.\n if (!menu) return notFoundResponse(corsHeaders)\n\n // ONE TAG, UNDER THE RESERVED `_menu` NAME — the same reserved key this\n // response's cache config is already keyed by, so the two agree.\n //\n // **The boundary, stated rather than left to be discovered (D055 §8).** A\n // resolved menu item carries an `id` but deliberately NO `entityType`\n // (`ContentApiResolvedMenuItem`) — which is also why this endpoint has no\n // per-entity `view` gate. So document tags are structurally impossible\n // here, and the honest consequence is: editing the MENU purges this\n // response; renaming a page the menu links to does NOT, and falls back to\n // the declared staleness window like everything else this scheme cannot\n // see.\n tags.addDocument('_menu', handle)\n\n // `buildPrincipalInvariantHeaders`, NOT `buildResponseHeaders` — the caller\n // is deliberately not an input here. See this function's JSDoc. It still\n // emits `Vary`: a stored variant is shared across CALLERS, never across\n // ORIGINS, and the CORS layer answers differently per origin.\n //\n // 🔴 THE ONE EXCEPTION (multisite D008): a caller whose CREDENTIAL selects\n // its site. A menu belongs to a site, so two site-bound keys get two\n // different bodies for the same URL — the body now DOES vary by credential,\n // and a principal-invariant `Vary: Origin` would let a shared cache replay\n // site A's menu to site B's frontend. Such a response goes through the\n // caller-aware builder, which keys it on the declared credential headers\n // (or refuses to store it when none are declared).\n return jsonResponse(\n { data: menu },\n 200,\n principal.site !== undefined\n ? buildResponseHeaders(\n '_menu',\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n )\n : buildPrincipalInvariantHeaders('_menu', corsHeaders, cacheConfig, tags),\n )\n }\n\n /**\n * `GET /_site` — the public config of the site this request reads (plan\n * `multisite` D007/D008): its handle and name, the locales it serves with\n * its default and URL strategy, and its public origins. A frontend reads its\n * languages from here rather than from a list compiled into it.\n *\n * Takes no parameters: the site is the credential's (or, on a single-site\n * install, the one site). 404 when the request has no site — an anonymous\n * caller on an app serving several — so the endpoint cannot be used to\n * enumerate sites.\n *\n * Nothing here is secret (every field is visible on the site's own pages),\n * so there is no `view` gate; what protects it is that a caller only ever\n * sees its OWN site's row.\n */\n async function handleSite(\n req: Request,\n principal: ResolvedPrincipal,\n siteScope: RequestSiteScope,\n siteLocales: { locales: string[]; defaultLocale?: string },\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n const params = siteParamsSchema.safeParse(toParamBag(new URL(req.url)))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const record = siteScope.record\n if (!record) return notFoundResponse(corsHeaders)\n\n // The host's resolver knows the install's enabled-locale registry and the\n // single-site install's i18n setting; this package knows neither. Without\n // one, the registry row and the mount's default are the answer.\n const provided = await siteProvider?.getI18n(record.handle)\n if ((provided?.locales ?? record.locales).length === 0) {\n // An empty list on the registry row means \"every locale the install has\n // enabled\" — and this package does not know which those are. Answering\n // with the one locale it CAN name would tell a frontend its site has a\n // single language, and that frontend would then 404 every other one.\n // An absence is the honest answer; the log names the fix.\n // Once per SITE, not once in all: one of the two fixes is per site, so\n // fixing the first must not silence the second. Bounded by the registry.\n safeWarnOnce(\n `site-i18n-unwired:${record.handle}`,\n { site: record.handle },\n 'content-api: GET /_site cannot answer — this site serves every enabled locale and the ' +\n 'mount wires no `site.getI18n` to say which those are. Wire it (see the content route ' +\n 'the admin app ships), or give the site an explicit locale list.',\n )\n return notFoundResponse(corsHeaders)\n }\n const locales = provided?.locales ?? record.locales\n const defaultLocale = provided?.defaultLocale ?? siteLocales.defaultLocale ?? locales[0]\n if (defaultLocale === undefined) return notFoundResponse(corsHeaders)\n const data: ContentApiSite = {\n handle: record.handle,\n name: record.name,\n locales: locales.length > 0 ? [...locales] : [defaultLocale],\n defaultLocale,\n routingStrategy:\n provided?.routingStrategy ?? record.routingStrategy ?? DEFAULT_SITE_ROUTING_STRATEGY,\n origins: [...record.origins],\n }\n\n // Under the reserved `_site` name, like `_menu`: editing the registry row\n // purges this response.\n tags.addDocument('_site', record.handle)\n return jsonResponse(\n { data },\n 200,\n buildResponseHeaders(\n '_site',\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n /**\n * `GET /_chrome?locale=&entity=&id=` — the site's composed header and\n * footer for one locale, optionally overridden by one document\n * (plan/api phase 07, D026/D027).\n *\n * **A projection, not an entity.** Overrides are per-PAGE: `_overrides` lives\n * on the page and is applied to the CHROME document, so the same stored row\n * yields different bytes depending on who asks. That is the definition of a\n * projection, and it is precisely what a singleton entity read is not.\n *\n * **Gated like `_menu`'s absent provider and like an entity's `view`, both.**\n * No provider → 404. Role without `view` on the chrome entity → 404. Never\n * 403 (D006), so a denial is indistinguishable from an absence — and\n * `_schema`'s `surface` advertises the endpoint's presence, so a consumer\n * learns chrome is unavailable from DATA rather than from a 404 it cannot\n * interpret.\n *\n * **Both forms ship together and the override path is exercised.** Shipping\n * only the default form would leave the override branch unwatched, which is\n * D016's objection restated: a path nobody has seen work is untested, and one\n * nobody has seen REFUSE is unverified. This matters more here than usual —\n * `composeMultiScopeDocument` is DELETED (PR30) and `composeChromeRegion` has\n * exactly one (the editor canvas), so the override feature has never run end\n * to end anywhere (F014).\n *\n * **The two forms collapse structurally, not by heuristic.** `applyOverrides`\n * returns `composed.slice()` when the map is empty or nothing in it applies,\n * so a document that overrides nothing produces output byte-identical to the\n * no-document form — a property of shipped code rather than a cache rule\n * someone maintains. Today that is EVERY site, which is why a design making\n * the per-document form expensive would have made the whole site pay for a\n * path nothing uses.\n *\n * The provider is handed the response's ONE resolution budget. Chrome is not\n * a fifth kind of work with a ceiling of its own — it competes for the same\n * connection pool as the document's own references and media.\n *\n * That now covers the blocks INSIDE the regions too. The chrome provider\n * claims for its own two reads; this handler then runs each region through\n * the same injected `resolveBlocks` provider a document's own `blockTree`\n * field goes through, drawing on the SAME budget, and merges the resulting\n * envelope into the region. So chrome and layout carry one format, which is\n * `plan/api` finding 075 requirement 5. With no provider wired, both still\n * cross unresolved — see {@link ResolvedBlockTree} for why nothing emits an\n * empty envelope.\n */\n /**\n * `GET /_search?q=&types=&limit=&locale=` — the calling SITE's visitor search\n * (davatoidud-toolkit D001).\n *\n * Which types are searched is the site's own `searchTypes` (registry row,\n * edited on the Sites page) — never a request's: `?types=` may only NARROW\n * it, and naming a type outside it is a 404, like naming an entity the\n * caller may not read. Each remaining type must also pass the gates every\n * other read passes (whitelist ∧ `view` for this caller) and be\n * visitor-searchable (`searchable()`, not template-drawn, not a singleton).\n * A site that offers no search answers 404.\n *\n * Every row is read through `createClient`, so the SITE PARTITION and the\n * publish filter apply exactly as on a list: this endpoint cannot return a\n * row of another site, because the query that finds rows is the one that\n * refuses them (multisite C1).\n *\n * Bounded: ≤ 8 types (core's `MAX_SITE_SEARCH_TYPES`), run one after\n * another; ≤ {@link MAX_SEARCH_RESULTS} rows per type and in total; hrefs at\n * bounded concurrency; one media batch per type, under the response's one\n * resolution budget.\n */\n async function handleSearch(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n siteScope: RequestSiteScope,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n // The per-visitor budget first — a refused search costs no parsing and no\n // query, and the route is a fixed reserved name, so a 429 here reveals\n // nothing about which entities exist.\n const limited = searchLimiter.consume(req, {\n principalId: principal.hasIdentity ? principal.id : undefined,\n site: principal.site,\n })\n if (limited && !limited.decision.allowed) {\n safeWarn(\n { limit: 'search', keyClass: limited.keyClass },\n 'content-api: caller exceeded its visitor-search budget',\n )\n return rateLimitedResponse(limited.decision.retryAfterSeconds, corsHeaders)\n }\n\n const url = new URL(req.url)\n const params = searchParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { q, types: typesParam, limit, locale } = params.data\n const overLimit = ceilingViolation('limit', limit, MAX_SEARCH_RESULTS, corsHeaders)\n if (overLimit) return overLimit\n\n const selected = selectSearchTypes(siteScope.record?.searchTypes ?? [], typesParam)\n if (selected === null || selected.length === 0) return notFoundResponse(corsHeaders)\n\n const searchable: Entity[] = []\n for (const name of selected) {\n // Exact name only — a plural alias is a route spelling, not a type.\n const entity = resolveEntities().map.get(name)\n const usable =\n entity !== undefined &&\n entity.name === name &&\n hasViewPermission(checker, principal.role, name) &&\n isVisitorSearchable(entity)\n if (usable) {\n searchable.push(entity)\n continue\n }\n // A type the caller NAMED that it cannot search is a 404 — the same\n // answer as an entity it may not read. One the SITE named is skipped:\n // the site's list is config, and a stale entry must not take the whole\n // search down (it is logged, in development, like any refusal).\n explainEntityRefusal(name, entity)\n if (typesParam !== undefined) return notFoundResponse(corsHeaders)\n // The OPERATOR's signal, in every environment and once per type: the\n // Sites page names a type this API cannot search for this role (not\n // whitelisted, no public `view`, or no longer searchable), so visitors\n // silently get fewer results. The name is the site's own config, never\n // caller input.\n safeWarnOnce(\n `search-type:${siteScope.site ?? ''}:${name}`,\n { site: siteScope.site, type: name },\n \"content-api: a type in the site's searchTypes cannot be searched by this caller — it is skipped\",\n )\n }\n if (searchable.length === 0) return notFoundResponse(corsHeaders)\n\n const resolvedLocale = locale ?? requestLocaleFallback()\n /** A ranked row before resolution — the merge decides which few get resolved. */\n interface Candidate {\n readonly entity: Entity\n readonly row: Record<string, unknown>\n readonly id: string\n readonly title: string\n readonly summary: string | null\n }\n const perType: RankedTypeHits<Candidate>[] = []\n\n for (const entity of searchable) {\n // Set-shaped, like a list: a newly published row changes the answer\n // while carrying no tag this response held, so the collection tag is\n // what invalidates it. Added before the query, for every searched type\n // — a search that found nothing in a type still depends on it.\n tags.addCollection(entity.name)\n const where = buildSearchCondition(entity, q, resolvedLocale)\n if (!where) continue\n const rows = (await createClient(entity).findMany({\n where,\n limit,\n orderBy: buildSearchRankOrder(entity, q, resolvedLocale),\n locale: resolvedLocale,\n defaultLocale: configDefaultLocale,\n })) as Record<string, unknown>[]\n const titleField = getEntityTitleField(entity)\n // The same gate the SQL order applies: a title that cannot take ILIKE\n // never ranked first there, so it is never a title hit here either.\n const titleConfig = Object.hasOwn(entity.allFields, titleField)\n ? (entity.allFields as Record<string, FieldConfig>)[titleField]\n : undefined\n const titleSearchable = titleConfig !== undefined && ILIKE_FIELD_TYPES.has(titleConfig.type)\n const hits: Array<{ hit: Candidate; titleHit: boolean }> = []\n for (const row of rows) {\n const id = row.id\n if (typeof id !== 'string') continue\n const { title, summary } = describeSearchRow(entity, row, titleField)\n // Tested on the row's own (unclipped, locale-merged) title — the\n // same value the SQL order tested, so the two tiers agree.\n const rawTitle = row[titleField]\n hits.push({\n hit: { entity, row, id, title, summary },\n titleHit: titleSearchable && typeof rawTitle === 'string' && isTitleHit(rawTitle, q),\n })\n }\n if (hits.length > 0) perType.push({ hits })\n }\n\n // Resolution happens AFTER the merge, for the ≤ `limit` winners only:\n // eight types × fifty candidates would otherwise be four hundred path\n // lookups and media ids to serve fifty results — and the losers would\n // spend the response's one resolution budget and its tag cap.\n const winners = mergeSearchHits(perType, limit)\n const deps = buildResolutionDeps(checker, principal.role, resolvedLocale, tags)\n // Every winner's document tag BEFORE any media tag (D055 §5): if the tag\n // cap is reached, what is lost is a media tag, never a result row's.\n for (const winner of winners) tags.addDocument(winner.entity.name, winner.id)\n const images = new Map<Candidate, unknown>()\n for (const entity of searchable) {\n const mine = winners.filter((winner) => winner.entity === entity)\n if (mine.length === 0) continue\n const imageField = searchImageField(entity)\n if (imageField === null) continue\n // Only the image field is projected — the row's other media are not\n // part of a result and must not draw on the budget.\n const imageRows = mine.map((winner) => ({\n id: winner.id,\n [imageField]: winner.row[imageField],\n }))\n await projectMedia(entity, imageRows, deps)\n mine.forEach((winner, index) => {\n images.set(winner, imageRows[index]?.[imageField] ?? null)\n })\n }\n const seam = config.resolveEntityHref\n const paths = await runBounded(winners, async (winner) => {\n if (!seam) return null\n try {\n return (\n (await seam({\n entity: winner.entity.name,\n id: winner.id,\n ...(resolvedLocale !== undefined && { locale: resolvedLocale }),\n })) ?? null\n )\n } catch (err) {\n requestLog().debug(\n { entity: winner.entity.name, id: winner.id, err },\n 'content-api: resolveEntityHref threw — search result served without a path',\n )\n return null\n }\n })\n reportBudget(deps, '_search', url.pathname)\n\n const data: SearchHit[] = winners.map((winner, index) => {\n const image = images.get(winner)\n return {\n type: winner.entity.name,\n typeLabel: searchTypeLabel(winner.entity),\n id: winner.id,\n title: winner.title,\n summary: winner.summary,\n image: isMediaProjection(image) ? image : null,\n path: paths[index] ?? null,\n }\n })\n return jsonResponse(\n {\n data,\n meta: { limit },\n },\n 200,\n buildResponseHeaders(\n '_search',\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n async function handleChrome(\n req: Request,\n principal: ResolvedPrincipal,\n checker: PermissionChecker,\n corsHeaders: Record<string, string>,\n tags: CacheTagSet,\n ): Promise<Response> {\n if (!chrome) return notFoundResponse(corsHeaders)\n\n const url = new URL(req.url)\n const params = chromeParamsSchema.safeParse(toParamBag(url))\n if (!params.success) {\n return errorResponse('Invalid query parameters', 'VALIDATION_ERROR', 400, corsHeaders)\n }\n const { locale, entity: documentEntity, id: documentId } = params.data\n\n // The read gate: `view` on the entity that BACKS chrome. Note this is the\n // roles table, never the entity's own `access` map — that is advisory\n // (#616, D020), and the chrome entity's docblock says so rather than\n // claiming otherwise (F013).\n //\n // Since PR30 the gate and the SOURCE are the same entity: a region reads\n // the `site_chrome` document whose `placement` matches it. Before that,\n // this gated a standalone layout entity while the blocks came from two\n // other entities, so one grant here plus a missing grant there produced a\n // 200 with an absent header — a partial answer that read as an\n // unprovisioned site.\n if (!hasViewPermission(checker, principal.role, chrome.entity)) {\n return notFoundResponse(corsHeaders)\n }\n\n // A named document is honoured only when the CALLER may reach it. Every\n // way it can fail — unknown entity, outside the whitelist, no `view` —\n // collapses to \"serve the site default\", never to an error, so the\n // parameter cannot be used to probe which documents exist.\n let document: { entityType: string; id: string } | undefined\n if (documentEntity !== undefined && documentId !== undefined) {\n const target = resolveWhitelistedEntity(documentEntity)\n if (target && hasViewPermission(checker, principal.role, target.name)) {\n document = { entityType: target.name, id: documentId }\n } else {\n resolutionLogger()?.debug(\n { entity: documentEntity, locale },\n 'content-api: _chrome document is unreachable for this caller — serving the site default',\n )\n }\n }\n\n // CHROME IS THE TWO-SIDED CASE F73 NAMED, and both sides are tagged.\n //\n // The chrome ROW gets a COLLECTION tag, not a document tag, and that is a\n // structural limit rather than a choice: `chrome.getChrome` is host-injected\n // and returns `ContentApiChromeRegions`, which carries no row id on any\n // region — the chrome document's id is never in scope in this package at\n // all. `lumi:<chrome.entity>` is therefore the finest tag expressible, and\n // it costs almost nothing to be coarse here: chrome is effectively a\n // singleton, so its collection and its document are very nearly the same\n // set.\n //\n // The REQUESTING DOCUMENT gets a document tag even though its id never\n // appears in the response body. It composed the body all the same —\n // `_overrides` live on the page and are applied to the chrome document\n // inside the provider (D026/D027) — so editing that page's overrides must\n // invalidate this entry. It is also the one place the disclosure invariant\n // needs stating out loud: the id came from the caller's own query string,\n // and it is only tagged on the branch where the caller was already granted\n // `view` on it, so the header echoes nothing the caller did not send and\n // was not entitled to.\n tags.addCollection(chrome.entity)\n if (document !== undefined) tags.addDocument(document.entityType, document.id)\n\n const resolutionDeps = buildResolutionDeps(checker, principal.role, locale, tags)\n const regions = await chrome.getChrome({\n locale,\n ...(document !== undefined && { document }),\n budget: resolutionDeps.budget,\n })\n\n // A provider `null` means NO CHROME DOCUMENT EXISTS — every region is\n // `null`, which a consumer reads as \"render your own fallback\". It is NOT\n // an empty region: an empty `blocks` array means the page hid everything\n // and the correct render is nothing at all. Conflating them would turn an\n // unseeded CMS into a site rendering a default header it was told to\n // suppress.\n //\n // PROJECTED onto exactly the four named regions AND onto each region's two\n // keys, never pass-through. The provider is host-supplied, so serialising\n // its return value verbatim would make D011's \"`_overrides` never reaches\n // the wire, in any configuration\" conditional on every provider's\n // discipline — and the shipped provider is the one component that\n // legitimately READS the override map, so it holds the exact value D011\n // bans a few frames down the stack.\n //\n // Projecting only the four outer KEYS was not enough: a region object\n // carrying a stray `_overrides` beside `blocks` would still have crossed.\n // `blocks` itself is `unknown[]` and inherently unprojectable here — this\n // package cannot know a block's shape — so that layer's guarantee is\n // D027's `stripScopeTags` in the provider, asserted in\n // `@murumets-ee/content`'s own suite. What IS this package's own is\n // everything above the block array.\n // COERCED, not just picked. `getChrome` is a host-supplied hook, which\n // makes its return value a trust boundary in exactly the way\n // `authenticate`'s is — and that one is validated field by field. A region\n // that is present but not an object would otherwise yield\n // `{ blocks: undefined, overridden: undefined }`, which `JSON.stringify`\n // drops entirely, so the response omits `blocks` and a consumer reading\n // `region.blocks.length` throws on data the server called a success.\n const projectRegion = (\n region: ContentApiChromeRegion | null | undefined,\n ): ContentApiChromeRegion | null =>\n region && typeof region === 'object'\n ? {\n blocks: Array.isArray(region.blocks) ? region.blocks : [],\n overridden: region.overridden === true,\n }\n : null\n\n /**\n * Resolve one region's blocks through the SAME injected provider a\n * document's own `blockTree` field goes through, and MERGE the envelope\n * into the region.\n *\n * 🔴 This lives here rather than inside `createChromeProvider`, where an\n * earlier comment anticipated it, and the reason is the one CLAUDE.md's\n * declarative-contributions section gives: a host that wires\n * `resolveBlocks` ONCE must not also have to remember to wire it into a\n * second place. Two wiring points is how a contribution gets silently\n * skipped. Running it here also makes \"chrome and layout use one format\"\n * (finding 075 requirement 5) true by construction rather than by two\n * implementations agreeing.\n *\n * SERIAL across regions, on purpose, for the same reason\n * `resolveBlockTrees` is serial across rows: each call claims from the\n * shared budget as it works, so running the regions concurrently would\n * let several claim past the ceiling before any observed it, and WHICH\n * region ended up truncated would depend on scheduling.\n *\n * 🔴 A throw or a malformed return NEVER degrades to the stored blocks —\n * it goes through {@link resolveOneBlockTree}'s retry, exactly as a\n * document's own field does (D010, closing F056). Both branches used to\n * `return region`, and this is the HIGHER-risk of the two sites that did:\n * `blockTreeFieldsOf`'s own comment says chrome \"is exactly the content\n * most likely to carry a logo\", and a logo is a media ref carrying the url\n * an editor recorded — signed, wherever media is private. The old comment\n * here said a broken resolver must cost a page its hrefs and never its\n * header. It still does: the retry strips the refs and keeps the blocks, so\n * the header renders without its images rather than vanishing.\n *\n * ONE call to the shared helper rather than a second hand-written copy of\n * the policy, because two implementations of the same two branches is how\n * the two sites came to disagree in the first place — this one was missed\n * entirely when the entity path was reviewed.\n */\n const resolveRegion = async (\n field: (typeof CHROME_REGION_FIELDS)[number],\n region: ContentApiChromeRegion | null,\n ): Promise<ContentApiChromeRegion | null> => {\n if (region === null || !config.resolveBlocks || region.blocks.length === 0) return region\n const outcome = await resolveOneBlockTree(config.resolveBlocks, region.blocks, {\n locale,\n budget: resolutionDeps.budget,\n entity: chrome.entity,\n field,\n // Chrome is a page's header and footer; a collection there gets the\n // same whitelist ∧ `view` gate a document's own tree does.\n ...(resolutionDeps.canReadEntity !== undefined && {\n canReadEntity: resolutionDeps.canReadEntity,\n }),\n ...(resolutionDeps.collectionAllowance !== undefined && {\n claimCollection: resolutionDeps.collectionAllowance.claim,\n }),\n })\n // WARN for the same reason the entity path warns, and it matters MORE\n // here: chrome runs on every page render, under the same cache policy. At\n // most FOUR lines per request — two regions, and a blanked one emits\n // both its cause and its consequence — so there is still no flood to\n // trade against and no aggregation to do.\n // 🔴 The CAUSE and the CONSEQUENCE are separate lines, and a blanked\n // region emits BOTH — mutually-exclusive branches meant the one log an\n // operator gets for the worst outcome never said HOW the provider\n // failed. This is deliberately the same emit shape the entity path\n // aggregates to, because \"both sites behave identically\" is the property\n // this whole helper exists to hold.\n if (outcome.failure === 'threw') {\n resolutionLogger()?.warn(\n { entity: chrome.entity, region: field, err: outcome.error },\n 'content-api: block resolver threw — this chrome region is not served as stored; it ' +\n 'was re-resolved with an exhausted budget, so its media refs and link hrefs are ' +\n 'stripped rather than carrying the urls recorded when an editor picked them',\n )\n } else if (outcome.failure === 'malformed') {\n resolutionLogger()?.warn(\n { entity: chrome.entity, region: field },\n 'content-api: block resolver returned a value that is not a ResolvedBlockTree — this ' +\n 'chrome region is not served as stored; it was re-resolved with an exhausted ' +\n 'budget, so its media refs and link hrefs are stripped rather than carrying the ' +\n 'urls recorded when an editor picked them',\n )\n }\n if (outcome.retryFailure !== undefined) {\n // Chrome is where this matters most: a cached blank header is a\n // headerless SITE, not a headerless page, for the whole TTL.\n tags.markUncacheable()\n resolutionLogger()?.warn(\n { entity: chrome.entity, region: field, err: outcome.error },\n 'content-api: block resolver failed TWICE, including on the in-memory retry — ' +\n 'serving this chrome region EMPTY, so whatever it held is missing from every page ' +\n 'render. Serving the stored region instead would put the media urls recorded when ' +\n 'an editor picked them, which may be signed, on a cacheable anonymous response',\n )\n }\n const envelope = outcome.envelope\n // 🔴 EVERY field the envelope carries has to be named here, and the\n // region is rebuilt field by field rather than spread — so an addition\n // to `ResolvedBlockTree` that nobody adds to this literal is dropped in\n // SILENCE, on the one surface where it matters most. `refs` was exactly\n // that: a logo in the header resolved fine, the frontend's ref loop had\n // an empty array to iterate, and no tag ever named the asset every page\n // on the site renders.\n if (envelope.refs !== undefined) {\n for (const ref of envelope.refs) tags.addDocument(ref.entity, ref.id)\n }\n // `collections` was the next field this literal dropped (editor PR53):\n // a footer's \"latest recipes\" resolved, spent its reads, and reached the\n // frontend as nothing. Its tag names the ENTITY, not the rows — a newly\n // published recipe changes which rows it lists.\n if (envelope.collections !== undefined) {\n for (const result of Object.values(envelope.collections)) {\n if (result.entity !== null) tags.addCollection(result.entity)\n }\n }\n return {\n blocks: [...envelope.blocks],\n overridden: region.overridden,\n resolved: envelope.resolved,\n links: envelope.links,\n ...(envelope.unresolved !== undefined &&\n envelope.unresolved.length > 0 && { unresolved: envelope.unresolved }),\n ...(envelope.refs !== undefined && envelope.refs.length > 0 && { refs: envelope.refs }),\n ...(envelope.collections !== undefined && { collections: envelope.collections }),\n // block-library F012 — a site header is exactly where a live-bound\n // price (a room rate, say) is as likely to live as in a page body.\n ...(envelope.live !== undefined && { live: envelope.live }),\n ...(envelope.currency !== undefined && { currency: envelope.currency }),\n }\n }\n\n // 🔴 A LOOP, not one await per region in an object literal, and the\n // difference is that the invariant now lives in the code rather than in a\n // comment above it. Object-literal properties DO evaluate in source order, so the literal\n // form was correct — but a later edit that reordered the keys or reached for\n // `Promise.all` would break the budget-claim ordering with no test failing,\n // because two regions rarely reach a ceiling in a suite. An explicit\n // sequence puts the constraint where it would be broken.\n const resolvedRegions: Partial<ContentApiChromeRegions> = {}\n for (const field of CHROME_REGION_FIELDS) {\n resolvedRegions[field] = await resolveRegion(field, projectRegion(regions?.[field]))\n }\n const data: ContentApiChromeRegions = {\n header: resolvedRegions.header ?? null,\n footer: resolvedRegions.footer ?? null,\n }\n\n // AFTER the block pass, not before it: `reportBudget` is the only operator\n // signal that something was refused, and running it before the resolution\n // it is supposed to report made every block-tree drop invisible.\n reportBudget(resolutionDeps, chrome.entity, url.pathname)\n\n return jsonResponse(\n { data },\n 200,\n // Keyed on the chrome ENTITY, so an app can give chrome its own TTL, and\n // routed through the same role-aware split every entity response uses:\n // the body varies with the caller (which documents they may see, which\n // content is published for them), so it is not principal-invariant the\n // way `_menu` is.\n buildResponseHeaders(\n chrome.entity,\n corsHeaders,\n cacheClassOf(principal),\n cacheConfig,\n authVary,\n tags,\n ),\n )\n }\n\n // -- Central dispatch -------------------------------------------------------\n\n /**\n * The two request facts the monitoring seam needs that only DISPATCH can\n * know: which entity the request resolved to, and which principal it\n * resolved as. Filled in as the request gets far enough to have them; both\n * stay absent on a request that 404s before either.\n *\n * A per-invocation object rather than a closure variable on the factory —\n * `createContentApiHandler` is called once per process and serves every\n * concurrent request.\n */\n interface DispatchFacts {\n entity?: string\n principalId?: string\n }\n\n async function handleGet(req: Request, ctx: RouteContext): Promise<Response> {\n // Resolved FIRST, before anything can fail, so the id exists on every path\n // out of here — including the ones that never reach a request context.\n // Absent or malformed, the server mints one: an optional correlation id is\n // missing precisely on the requests an operator most wants to trace (D032).\n const requestId = resolveRequestId(req)\n const facts: DispatchFacts = {}\n\n // Correlation is bound around the WHOLE dispatch, including principal\n // resolution and the 500 catch — both of which run outside the\n // authenticated context, and both of which log. The inner context replaces\n // this one (`runWithContextAsync` replaces rather than merges) and\n // re-states `requestId`.\n // A mount that runs inside a site (`runInSite`) keeps it on both contexts\n // (multisite phase 03) — the replacements below would otherwise drop it.\n const site = getCurrentSite()\n const outerContext: RequestContext = { requestId, ...(site !== undefined && { site }) }\n bindRequestLogger(outerContext)\n return await runWithContextAsync(outerContext, async () => {\n const res = await guardedDispatch(req, ctx, requestId, facts)\n echoRequestId(res, requestId)\n // INSIDE the context, deliberately: `reportMonitoringFailure` logs, and\n // outside it that one line — the only evidence a monitoring backend is\n // down — would be the one line missing its correlation id. A host hook\n // calling `getContext()` also sees a real context this way.\n reportToMonitoring(res, req, requestId, facts)\n return res\n })\n }\n\n /**\n * Echo the correlation id, EXCEPT on a response a shared cache may store.\n *\n * A per-request id in a `public, max-age=…` response is replayed: caller A's\n * id is stored with the body and handed to every anonymous caller for the\n * lifetime of the entry, for requests that never reached this process and\n * have no log line. An operator grepping the quoted id then lands on a\n * different request — worse than no id, because it is confidently wrong.\n *\n * `Vary: x-request-id` would be literally correct and practically fatal: every\n * request carries a distinct id, so the variant key would be unique per\n * request and the cacheable branch would never hit. Not echoing is the honest\n * trade — the id is still minted, bound, and logged.\n *\n * The test is the emitted `Cache-Control` rather than a flag threaded down\n * from each handler, because that header IS the storability decision; a flag\n * would be a second copy of it, free to disagree.\n */\n function echoRequestId(res: Response, requestId: string): void {\n const cacheControl = res.headers.get('Cache-Control') ?? ''\n if (!cacheControl.includes('no-store')) return\n res.headers.set(REQUEST_ID_HEADER, requestId)\n }\n\n /**\n * Fire the monitoring seam for any error response, exactly once, with derived\n * facts only.\n *\n * Reads `code` from the side table `errorResponse` stamps, so this cannot\n * drift from what actually went on the wire, and cannot be forgotten by a new\n * refusal call site.\n */\n function reportToMonitoring(\n res: Response,\n req: Request,\n requestId: string,\n facts: DispatchFacts,\n ): void {\n if (!onError || res.status < 400) return\n const code = errorCodeOf(res)\n if (code === undefined) return\n try {\n // Not awaited — but the rejection IS contained. Both shipped mounts pass\n // an `async` function into what used to be a `=> void` slot, so a throw\n // inside the hook escaped as an unhandled rejection. A failing monitoring\n // backend must not be able to affect the response, in either direction.\n void Promise.resolve(\n onError({\n status: res.status,\n code,\n // CAPPED. `path` is the one caller-controlled field left in this\n // payload, and the hook now fires on every 404 — which is\n // unauthenticated and repeatable at whatever rate the caller likes.\n // `message` was removed for being exception-derived; leaving `path`\n // uncapped would have replaced one unbounded field with another,\n // this one sized entirely by the attacker. Bounding ARRIVALS is\n // phase 09's rate limiter; bounding each report's SIZE is this\n // function's job and costs nothing.\n path: truncateForLog(new URL(req.url).pathname),\n ...(facts.entity !== undefined && { entity: facts.entity }),\n requestId,\n ...(facts.principalId !== undefined && { principalId: facts.principalId }),\n }),\n ).catch(reportMonitoringFailure)\n } catch (err) {\n reportMonitoringFailure(err)\n }\n }\n\n /**\n * `requestLog().warn`, but it cannot itself become the failure.\n *\n * `requestLog()` falls through to `getApp().logger`, which THROWS before\n * bootstrap. The guard's warnings sit outside `dispatch`'s try, so an\n * unguarded one would turn a load-shed into an uncaught throw — the logger\n * that exists to explain a refusal becoming the reason there is no refusal to\n * explain. Every other error-path logger call in this file is wrapped for the\n * same reason.\n */\n /** Keys {@link safeWarnOnce} has already logged. Bounded by its callers' fixed keys. */\n const warnedOnce = new Set<string>()\n\n /** {@link safeWarn}, at most once per `key` for the life of the handler. */\n function safeWarnOnce(key: string, fields: Record<string, unknown>, message: string): void {\n if (warnedOnce.has(key)) return\n warnedOnce.add(key)\n safeWarn(fields, message)\n }\n\n function safeWarn(fields: Record<string, unknown>, message: string): void {\n try {\n requestLog().warn(fields, message)\n } catch {\n // Pre-bootstrap, or the logger itself is what is broken — the one case\n // this guard exists for. A SILENT catch here would defeat the obligation\n // the callers state: without a line, \"nobody is hitting the limit\" and\n // \"the limiter was never wired\" produce identical evidence. So fall back\n // to the console, as `logAuthenticateFailure` and `reportBudget` do.\n try {\n console.warn('[content-api]', message, fields)\n } catch {\n // Nothing left to try. The response is unaffected either way, which is\n // the whole point of guarding here.\n }\n }\n }\n\n function reportMonitoringFailure(err: unknown): void {\n try {\n requestLog().warn({ err }, 'content-api: `onError` monitoring hook failed')\n } catch {\n // Pre-bootstrap, or the logger itself is the thing that is broken. The\n // response has already been produced and is unaffected.\n }\n }\n\n /**\n * The KEYLESS survival guard (D025), wrapped around every request.\n *\n * Two mechanisms, both always on and neither keyed on caller identity:\n * a bounded-concurrency semaphore with a bounded wait queue, and a per-request\n * wall clock. They sit HERE — outside `dispatch` entirely — for two reasons.\n *\n * **Ordering is a security constraint, not a performance one.** Both fire\n * before `resolveWhitelistedEntity` and the `view` gate, and before principal\n * resolution. A limit that only fired for entities that exist would let a\n * caller distinguish \"not in the whitelist\" from \"whitelisted but throttled\"\n * by observing which paths can be made to shed — an enumeration oracle over\n * the served set, delivered through the abuse-resistance layer, defeating the\n * 404-not-403 rule D006 exists to enforce. Wrapping the whole of `dispatch`\n * makes that ordering structural rather than a line someone has to keep at the\n * top of a 200-line function.\n *\n * **The 503 must not become a 500.** `dispatch`'s own catch turns anything\n * that escapes into `INTERNAL_ERROR`, so a shed handled inside it would be\n * reported as a server fault — wrong on the wire and wrong in the monitoring\n * seam.\n *\n * The slot is held for the WHOLE request, which is why the ceiling counts\n * requests rather than connections (SD004) — see `limits.ts`.\n */\n async function guardedDispatch(\n req: Request,\n ctx: RouteContext,\n requestId: string,\n facts: DispatchFacts,\n ): Promise<Response> {\n const corsHeaders = buildCorsHeaders(req, await preSiteCorsConfig(req))\n // CORS and `Vary` for the guard's own refusals. Storability is not in\n // question for these or for any other refusal — `errorResponse` stamps\n // `no-store` on every one (SD005). Worth stating that a 503 and a 429 would\n // each deserve it independently even if the rule were narrower: a shed is a\n // fact about this instance at this instant, and a 429 is a fact about ONE\n // caller, which a shared cache would hand to everyone else.\n const guardHeaders = buildRefusalHeaders(corsHeaders, authVary)\n\n try {\n // `req.signal` makes a QUEUED acquire abortable: a caller who disconnects\n // while waiting leaves the queue rather than waking to take a slot and run\n // work whose response is already gone.\n await guard.acquire(req.signal)\n } catch (err) {\n if (err instanceof ServiceOverloadedError) {\n // LOGGED, and that is an obligation rather than a nicety. Without it,\n // \"nobody is hitting the limit\" and \"the limiter was never wired\"\n // produce identical evidence in production — F001's defect, relocated\n // from CI to operations.\n safeWarn(\n {\n limit: 'survival',\n maxConcurrentRequests: guard.limits.maxConcurrentRequests,\n maxQueuedRequests: guard.limits.maxQueuedRequests,\n },\n 'content-api: shedding load — concurrency slots and wait queue both full',\n )\n return serviceOverloadedResponse(SHED_RETRY_AFTER_SECONDS, guardHeaders)\n }\n // NOT a shed — an aborted acquire (the caller disconnected while queued),\n // or something unforeseen. Answered as a 500 rather than rethrown.\n //\n // Rethrowing would escape `handleGet` entirely, so the host framework\n // would receive a rejected promise instead of a `Response`, and\n // `echoRequestId` / `reportToMonitoring` would never run — which also\n // hands anyone who opens-and-aborts a way to drive framework-level error\n // logging at will. This function sits OUTSIDE `dispatch`'s own catch, so\n // nothing else would have caught it.\n safeWarn({ err }, 'content-api: acquiring a concurrency slot failed')\n return errorResponse('Internal server error', 'INTERNAL_ERROR', 500, guardHeaders)\n }\n\n try {\n // The deadline's signal is THREADED IN, not dropped. `runWithDeadline`\n // aborts it when the wall clock fires, and `dispatch` checks it at the one\n // point worth checking — after the gates, before the expensive work — so\n // an expired request stops rather than running to completion with nobody\n // waiting for the answer.\n //\n // Stated honestly, because the alternative is a claim that cannot fail:\n // this does NOT cancel an in-flight database round trip. Nothing on the\n // read path takes an `AbortSignal` today. What bounds THAT is PR 2's 5s\n // `statement_timeout` on the read-only pool, which is below this 10s\n // clock, so on the common path Postgres ends the query first. The gap this\n // leaves is host-supplied resolution hooks (`resolveMedia`,\n // `resolveBlocks`, `resolveEntityHref`), which are not query work and are\n // not covered by either bound.\n return await guard.runGuarded((signal) =>\n dispatch(req, ctx, requestId, facts, corsHeaders, signal),\n )\n } catch (err) {\n if (err instanceof RequestDeadlineError) {\n safeWarn(\n { limit: 'deadline', requestTimeoutMs: guard.limits.requestTimeoutMs },\n 'content-api: request exceeded the wall clock',\n )\n // Same code as a shed, deliberately. D031's partition rule is that a\n // code names what the client should do NEXT, and \"back off, retry later,\n // this was not about your request\" is the same next action for both. A\n // third code would name which internal mechanism fired, which is exactly\n // what the rule forbids.\n return serviceOverloadedResponse(SHED_RETRY_AFTER_SECONDS, guardHeaders)\n }\n throw err\n } finally {\n guard.release()\n }\n }\n\n async function dispatch(\n req: Request,\n ctx: RouteContext,\n requestId: string,\n facts: DispatchFacts,\n baseCorsHeaders: Record<string, string>,\n deadlineSignal?: AbortSignal,\n ): Promise<Response> {\n // What a refusal or fault raised from INSIDE a handler is answered with.\n // Starts as the pre-site headers and is narrowed to the request's site once\n // that is known, so the catch below never allows an origin the site does\n // not own. (The guard's own refusals and the pre-principal 404s, which\n // fire before any site is known, keep the pre-site union: they carry a\n // fixed error body and nothing of any site's.)\n let refusalHeaders = baseCorsHeaders\n try {\n const rawSegments = ctx.path\n // PRE-PRINCIPAL REFUSALS keep the bare CORS headers. Their directive is\n // identical to every other refusal's (`no-store`, from `errorResponse`);\n // what they lack is the declared credential selectors in `Vary`, because\n // they fire before `resolvePrincipal` and nothing here needs them — an\n // unstorable response has no variant to key. The set is tiny and fixed:\n // no path, wrong version, version-only path.\n if (!rawSegments || rawSegments.length === 0) {\n return notFoundResponse(baseCorsHeaders)\n }\n\n // Consume the version segment. `/v1/` is the ONLY path served (D010), so\n // an unversioned path — and a well-formed `/v2/` this instance does not\n // mount — is a 404, not a 400: there is no unprefixed mount, and an\n // absent mount is an absence, which is shaped as absence (D006).\n //\n // Checked BEFORE role resolution and `loadPermissions()`, so a mistyped\n // or probing prefix costs nothing beyond a string compare.\n if (rawSegments[0] !== API_VERSION) {\n return notFoundResponse(baseCorsHeaders)\n }\n const segments = rawSegments.slice(1)\n if (segments.length === 0) {\n return notFoundResponse(baseCorsHeaders)\n }\n\n // No caller-chosen site, on any route (multisite D008).\n if (new URL(req.url).searchParams.has(FORBIDDEN_SITE_PARAM)) {\n return errorResponse(\n 'Invalid query parameters',\n 'VALIDATION_ERROR',\n 400,\n buildRefusalHeaders(baseCorsHeaders, authVary),\n )\n }\n\n // Resolve the PRINCIPAL + load permissions + establish request context.\n const principal = await resolvePrincipal(req)\n const role = principal.role\n\n // THE REQUEST'S SITE (multisite D008) — decided here, once, before\n // anything reads. Everything below (CORS, cache tags, locales, the\n // request context every read runs in) hangs off this one answer.\n const siteScope = await resolveRequestSite(req, principal)\n if (siteScope === null) {\n // No ACAO: the site is not served, so no origin is its origin.\n return notFoundResponse(buildRefusalHeaders(corsConfig ? { Vary: 'Origin' } : {}, authVary))\n }\n // CORS FOR THIS RESPONSE. On an app serving several sites, only the\n // request's own site's origins are allowed — another site's origin gets\n // no `Access-Control-Allow-Origin`, and a request with no site gets none\n // for any origin. The registry is the source; the mount's static list\n // only ever answers preflights there. A single-site install keeps the\n // mount's list, unchanged.\n const siteCorsHeaders = siteScope.multiSite\n ? buildCorsHeaders(req, {\n allowedOrigins: siteScope.record?.origins ?? [],\n ...(corsConfig?.maxAge !== undefined && { maxAge: corsConfig.maxAge }),\n })\n : baseCorsHeaders\n\n // THE FAIRNESS LIMIT (D025/D028/D029) — per-caller, and applied ONLY when\n // a distinguishing key exists.\n //\n // Still before `resolveWhitelistedEntity` and the `view` gate, for the\n // enumeration-oracle reason in `guardedDispatch`'s docblock. After\n // principal resolution because the FIRST key it tries is the\n // server-verified identity.\n //\n // `undefined` means SKIPPED, not allowed-by-a-shared-bucket, and the\n // difference is the whole of F011. `SearchMechanics.enforceRateLimit`\n // keys on `(userId, ip)` and its reasoning depends on both DISTINGUISHING;\n // on this surface, unauthenticated, both are constants. Two constant keys\n // is an aggregate cap on every visitor on Earth that fails CLOSED for all\n // of them at once — a one-caller global outage costing the attacker two\n // requests per second, which in logs looks exactly like a working limiter.\n // So when nothing distinguishes, no fairness limit is applied at all and\n // the KEYLESS survival guard above is what protects the surface.\n const fairness = guard.consumeFairness(req, {\n principalId: principal.hasIdentity ? principal.id : undefined,\n budgetPerInstance: principal.rateBudgetPerInstance,\n })\n // THE `Vary` EVERY REFUSAL BELOW THIS LINE CARRIES. It decides no cache\n // directive — `errorResponse` owns that, unconditionally (SD005), and is\n // the only place to look for it.\n //\n // All ~40 refusals below — the whitelist gate, the `view` gate, every\n // `VALIDATION_ERROR`, every handler's own 404 — receive these rather than\n // the bare CORS headers, so the declared credential selectors reach the\n // refusal path without 40 call sites remembering to add them. Threading\n // one shadowed binding rather than editing each site is what makes it\n // impossible for the 41st to forget.\n //\n // SAFE FOR SUCCESS RESPONSES, which also flow through here:\n // `buildResponseHeaders` and `buildPrincipalInvariantHeaders` both spread\n // these and then set their own `Cache-Control` and `Vary`, so a 200 is\n // unaffected by what rides along for the refusals.\n const corsHeaders = buildRefusalHeaders(siteCorsHeaders, authVary)\n refusalHeaders = corsHeaders\n\n // THE ONE TAG SET FOR THIS RESPONSE (D055).\n //\n // Created HERE, beside `corsHeaders`, for the same reason that binding is\n // shadowed here: threading one object beats asking each handler to build\n // its own, and a handler that forgot would emit an untagged-but-cacheable\n // response — the silent half of the failure, since nothing observable\n // distinguishes \"this response depends on nothing\" from \"somebody forgot\".\n //\n // It reaches a response ONLY through `buildResponseHeaders` /\n // `buildPrincipalInvariantHeaders`, which emit it only when the directive\n // they just computed is not `no-store`. `buildRefusalHeaders` — which is\n // what `corsHeaders` above IS — takes no tag argument, so none of the ~40\n // refusals below can carry one.\n const tags = createCacheTagSet(cacheTagsConfig, cacheTagSiteFor(siteScope))\n\n if (fairness && !fairness.decision.allowed) {\n // `safeWarn`, like the guard's other two refusal logs. This one sits\n // inside `dispatch`'s try so a throwing logger would be caught rather\n // than escaping — but it would still convert a correct 429 into a 500,\n // which is the logger explaining a refusal becoming the reason the\n // refusal is wrong. Same guard, same reason, all three places.\n safeWarn(\n { limit: 'fairness', keyClass: fairness.keyClass },\n 'content-api: caller exceeded its per-instance budget',\n )\n return rateLimitedResponse(fairness.decision.retryAfterSeconds, corsHeaders)\n }\n\n const checker = await getChecker()\n\n // The identity here is REAL, not a placeholder. It flows through\n // `runWithContextAsync` into `createClient`'s `contextResolver`, and from\n // there into `resolveAuthContext` on every QueryClient read — which is the\n // seam per-principal behaviour (owner-scoped rows, audit attribution) has\n // to arrive through. An anonymous caller still gets ANONYMOUS_PRINCIPAL_ID,\n // so the two are distinguishable rather than merged.\n //\n // `groups` carries the ROLE, and the role alone is what every gate on this\n // surface consults (`checker(role, entity, 'view')`). Adding an identity\n // does not add a second authorization input, and must not: one firewall,\n // one answer.\n //\n // `role ? [role] : []` is now belt-and-braces rather than a live branch:\n // `resolvePrincipal` rejects an empty role and the factory refuses an\n // empty `publicRole`, so `role` is a non-empty string on every path that\n // reaches here. Kept anyway, because the failure it guards is silent —\n // an empty `groups` makes the read path REFUSE, which is the direction a\n // guard should fail if a future edit reopens the hole.\n facts.principalId = principal.id\n // The site's languages (plan `multisite` phase 03): a `?locale=` the\n // request's site does not serve is refused for that site, and the site's\n // default stands in for the mount's.\n const requestSite = siteScope.site\n const siteLocales = await siteLocaleScope(requestSite, configDefaultLocale)\n const requestedLocale = new URL(req.url).searchParams.get('locale')\n if (\n requestedLocale !== null &&\n requestedLocale !== '' &&\n siteLocales.locales.length > 0 &&\n !siteLocales.locales.includes(requestedLocale)\n ) {\n return errorResponse(\n `Locale '${requestedLocale}' is not served by this site`,\n 'VALIDATION_ERROR',\n 400,\n corsHeaders,\n )\n }\n const requestContext: RequestContext = {\n user: {\n id: principal.id,\n groups: role ? [role] : [],\n ...(principal.name !== undefined && { name: principal.name }),\n ...(principal.email !== undefined && { email: principal.email }),\n },\n checker,\n // Re-stated because `runWithContextAsync` REPLACES the store rather\n // than merging into it — the outer correlation-only context does not\n // survive into this one on its own.\n requestId,\n ...(requestSite !== undefined && { site: requestSite }),\n ...(siteLocales.locales.length > 0 && { availableLocales: siteLocales.locales }),\n }\n if (siteLocales.defaultLocale !== undefined) {\n REQUEST_SITE_DEFAULT_LOCALES.set(requestContext, siteLocales.defaultLocale)\n }\n // The identity is now known, so the binding gains `principalId`. Bound\n // once, here; nothing downstream mentions either field again.\n bindRequestLogger(requestContext)\n\n const response = await runWithContextAsync(requestContext, async () => {\n // THE WALL CLOCK'S ONE OBSERVATION POINT.\n //\n // Placed after the gates and before any handler runs: everything above\n // is string comparison and an in-memory permission check, and\n // everything below can touch the database or a host resolution hook. If\n // the deadline has already fired there is nobody left waiting for this\n // answer, so producing it is pure cost.\n //\n // `runWithDeadline` has already rejected the outer promise by this\n // point, so what this actually saves is the WORK, not the response —\n // which is precisely why it belongs here rather than at the top.\n if (deadlineSignal?.aborted) {\n throw new RequestDeadlineError()\n }\n\n // /_schema — entity introspection (filtered by role)\n if (segments[0] === '_schema') {\n // Every field is DERIVED from this handler's live config — the\n // version it mounts, its EFFECTIVE (possibly host-lowered) ceilings,\n // and which optional providers were actually injected. Nothing is a\n // restated literal, so the published contract cannot drift from the\n // one being served.\n //\n // `menus` is passed as a BOOLEAN, never the provider: its `handles`\n // allowlist is the `_menu` endpoint's entire gate, so `_schema`\n // structurally cannot publish it.\n const schema = buildSchemaResponse({\n entities: resolveEntities().list,\n checkPermission: (name) => hasViewPermission(checker, role, name),\n version: API_VERSION,\n maxLimit,\n maxOffset,\n maxResolutionsPerDocument: maxResolutions,\n limits: guard.limits,\n hasResolution: resolution !== undefined,\n hasMenus: menus !== undefined,\n hasChrome: chrome !== undefined,\n cacheTags: cacheTagsConfig,\n siteScopedTags: siteScope.multiSite,\n })\n return jsonResponse(schema, 200, {\n ...corsHeaders,\n 'Cache-Control': 'no-store',\n })\n }\n\n // /_resolve, /_paths, /_alternates — routing resolution/enumeration\n // projection (PR07). Each gates internally (see per-handler JSDoc):\n // published-only (draft/preview resolution is never wired here),\n // whitelist ∧ view, 404-not-403.\n if (segments[0] === '_resolve') {\n return handleResolve(req, principal, checker, corsHeaders, tags)\n }\n if (segments[0] === '_paths') {\n return handlePaths(req, principal, checker, corsHeaders, tags)\n }\n if (segments[0] === '_alternates') {\n return handleAlternates(req, principal, checker, corsHeaders, tags)\n }\n\n // /_menu — navigation projection (PR09). Gated on its own handle\n // allowlist rather than on an entity permission (see handleMenu's\n // JSDoc), which is why it takes no checker. It takes the principal for\n // ONE thing: whether the credential selected the site, in which case\n // the body varies by credential and the headers must say so.\n if (segments[0] === '_menu') {\n return handleMenu(req, principal, corsHeaders, tags)\n }\n\n // /_site — the calling site's public config (multisite D008).\n if (segments[0] === '_site') {\n return handleSite(req, principal, siteScope, siteLocales, corsHeaders, tags)\n }\n\n // /_chrome — site-chrome projection (phase 07). Unlike `_menu` this\n // one DOES take a principal and a checker: its body varies with which\n // documents the caller may see and with what is published for them, so\n // the role-aware cache split applies and the `view` gate on the chrome\n // entity is real. See handleChrome's JSDoc.\n if (segments[0] === '_chrome') {\n return handleChrome(req, principal, checker, corsHeaders, tags)\n }\n\n // /_search — the request's SITE's visitor search (davatoidud-toolkit\n // D001). Takes the site scope for the site's `searchTypes`, and the\n // checker because every type it reads is gated like a list.\n if (segments[0] === '_search' && segments.length === 1) {\n return handleSearch(req, principal, checker, siteScope, corsHeaders, tags)\n }\n\n // Resolve entity from whitelist\n const entityName = segments[0]\n if (entityName === undefined) return notFoundResponse(corsHeaders)\n const entity = resolveWhitelistedEntity(entityName)\n\n // Entity not in whitelist OR role lacks view permission → 404\n if (!entity || !hasViewPermission(checker, role, entity.name)) {\n explainEntityRefusal(entityName, entity)\n return notFoundResponse(corsHeaders)\n }\n\n // The RESOLVED name, not `segments[0]`. Only a whitelisted entity gets\n // named to the monitoring seam, so a probe string cannot ride into a\n // monitoring payload that leaves the process.\n facts.entity = entity.name\n\n // A singleton is addressed AS a singleton. `kind` on the entity IS the\n // switch — there is no config option, deliberately: which entities are\n // exposed this way is an entity-definition decision, not a per-mount\n // one.\n //\n // Dispatched BEFORE the collection routes and closed by a whitelist,\n // not a blacklist: `/:entity` is the only path a singleton has, and\n // EVERYTHING deeper 404s. `/count`, `/slug/:slug` and `/:id` are\n // affordances a COLLECTION has — a resource addressed as a singleton\n // does not have them, so they are absences rather than refusals (D006).\n if (entity.kind === 'singleton') {\n if (segments.length === 1) {\n return handleSingleton(req, entity, principal, checker, corsHeaders, tags)\n }\n return notFoundResponse(corsHeaders)\n }\n\n // A TEMPLATE-DRAWN entity is addressed by id and nothing else\n // (block-library D009). Its rows cross narrowed to what their page\n // shows, so a list, a count or a slug lookup has nothing honest to\n // filter or sort on — a `?filter[x]=` over a column that is not served\n // would be an oracle for it. Absences, like a singleton's deeper\n // routes, not refusals (D006).\n if (isTemplateDrawn(entity)) {\n const rowId = segments[1]\n if (segments.length === 2 && rowId !== undefined) {\n return handleById(req, entity, rowId, principal, checker, corsHeaders, tags)\n }\n return notFoundResponse(corsHeaders)\n }\n\n // Route to handler. The read endpoints take the `checker` because\n // reference resolution re-runs the SAME `view` gate against each TARGET\n // entity — one firewall, consulted again one hop out, never a second\n // rule. `count` returns no rows, so it resolves nothing and needs none.\n if (segments.length === 1) {\n return handleList(req, entity, principal, checker, corsHeaders, tags)\n }\n if (segments[1] === 'count') {\n return handleCount(req, entity, principal, corsHeaders, tags)\n }\n if (segments[1] === 'slug' && segments[2]) {\n return handleBySlug(req, entity, segments[2], principal, checker, corsHeaders, tags)\n }\n if (segments.length === 2 && segments[1] !== undefined) {\n return handleById(req, entity, segments[1], principal, checker, corsHeaders, tags)\n }\n\n return notFoundResponse(corsHeaders)\n })\n\n // DROPS ARE LOGGED, NEVER SILENTLY TRUNCATED — the rule PR 5 set for the\n // resolution budget, applied to the tag set for the same reason: the log\n // is the only place the distinction survives. A dropped tag is a document\n // a future purge will silently miss, and nothing about the response says\n // so. Vercel does exactly this (\"any tags past the 128th in one call are\n // dropped… log a console warning\").\n //\n // `safeWarn`, like every other refusal log here: a throwing logger must\n // not convert a correct response into a 500.\n // ONLY when a tag header actually went out. `tags.dropped` counts refused\n // adds, which happen regardless of whether the response turned out to be\n // storable — so on a `no-store` response, or with `cache.tags.enabled:\n // false`, this line would otherwise fire and claim \"a purge for them will\n // not invalidate this response\" about a response nothing ever cached. A\n // warning whose own message is false where it fires is how a signal stops\n // being read, which is the same argument the dedupe path already makes.\n //\n // Asked of the RESPONSE rather than re-derived from the caller class and\n // the config: the response is the thing that either carries the header or\n // does not, and every branch that decides so lives in `cache.ts`.\n if (tags.dropped > 0 && response.headers.has(cacheTagsConfig.header)) {\n // `entity` and `path` are carried EXPLICITLY, because a bare count is\n // unattributable and the log's whole job is telling an operator WHERE\n // tags are being dropped. `facts.entity` is the resolved, whitelisted\n // name (never a probe string) and the path is truncated like every\n // other logged path.\n //\n // The correlation id still rides along and this line does NOT lose it —\n // an earlier version of this comment claimed otherwise and was wrong.\n // The inner `runWithContextAsync` has returned by now, but `dispatch`\n // is itself inside `handleGet`'s OUTER one, and `contextStore.run`\n // restores that store on exit — so `getContext()` still resolves and\n // `requestLog()` still returns the logger bound at correlation time.\n // What is genuinely absent is `principalId`, which was bound on the\n // inner context only.\n //\n // `dropped` counts refused ADDS, not distinct documents — see\n // `CacheTagSet.dropped`. Non-zero means this response depends on\n // something it does not name.\n safeWarn(\n {\n dropped: tags.dropped,\n emitted: tags.tags.length,\n // BOTH ceilings, because after the byte cap was added the count cap\n // is usually NOT the one that bound. On a large page the byte\n // budget fills first — that is the documented, deliberate\n // behaviour — so reporting `cap: 128` alone would show an operator\n // `emitted: 62, cap: 128` and invite the conclusion that no ceiling\n // was reached at all. A diagnostic that names the wrong limit is\n // worse than one that names none.\n maxTags: MAX_CACHE_TAGS_PER_RESPONSE,\n maxHeaderBytes: MAX_CACHE_TAG_HEADER_BYTES,\n ...(facts.entity !== undefined && { entity: facts.entity }),\n path: truncateForLog(new URL(req.url).pathname),\n },\n 'content-api: cache tags refused — this response depends on documents it does not name, so a purge for them will not invalidate it',\n )\n }\n return response\n } catch (err) {\n // The wall clock is NOT a handler error and must not be reported as one.\n //\n // The post-gate abort check throws `RequestDeadlineError` from inside this\n // try, so without this line it would be swallowed here, logged as\n // \"content-api handler error\" with a stack, and converted to a 500 — a\n // spurious error-level line plus a misleading status on every timed-out\n // request. `guardedDispatch` owns this error and answers 503.\n //\n // In practice `runWithDeadline` has already rejected the outer promise by\n // the time the flag is observable, so this response is discarded either\n // way. The point is the LOG, not the response: an operator debugging load\n // must not see timeouts filed as handler faults.\n if (err instanceof RequestDeadlineError) throw err\n\n // A site-bound read with no site (multisite D003/D008): the request named\n // none on an app serving several — an anonymous caller, or a key bound to\n // no site. The data layer refused before reading anything; that is an\n // ABSENCE on this surface, shaped as every other absence is (D006), not a\n // fault. Without this it fell through to the 500 below with an\n // error-level stack on every such request.\n if (isSiteRequiredError(err)) return notFoundResponse(refusalHeaders)\n\n const path = new URL(req.url).pathname\n // The operator gets the whole error — `err` reaches pino's serializer, so\n // the line carries a stack. The CALLER gets a fixed string, three lines\n // down. That split is C1's requirement and it is why the monitoring seam\n // (fired by `handleGet`, from derived facts) no longer receives\n // `err.message`: a driver-level message can carry query fragments and\n // values, and a monitoring payload leaves the process.\n //\n // Correlation and principal are NOT passed here — they are inherited from\n // the binding established at context establishment (D032).\n try {\n requestLog().error({ err, path: truncateForLog(path) }, 'content-api handler error')\n } catch {\n // App not yet bootstrapped (early failure path) — last-resort log.\n console.error('[content-api] handler error:', err)\n }\n // `refusalHeaders`, hoisted above the `try`: the pre-site headers until\n // the request's site is known, the site's own afterwards — so a fault\n // inside a handler never allows an origin that site does not own.\n // `errorResponse` stamps `no-store` either way.\n return errorResponse('Internal server error', 'INTERNAL_ERROR', 500, refusalHeaders)\n }\n }\n\n return {\n GET: (req: Request, ctx: RouteContext) => handleGet(req, ctx),\n OPTIONS: async (req: Request): Promise<Response> =>\n corsPreflightResponse(req, await preSiteCorsConfig(req), cacheConfig),\n /**\n * Invalidate the cached permission checker. Call this from the\n * role-management code path (after a role is created/edited/deleted)\n * so the next request re-runs `loadPermissions()` instead of waiting\n * up to 30 seconds for the TTL to expire.\n */\n invalidatePermissionCache,\n }\n}\n"],"mappings":"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"}
|