@murumets-ee/content-api 0.0.0-preview-20261014110621

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.
@@ -0,0 +1,2321 @@
1
+ import { Entity } from "@murumets-ee/entity";
2
+ import { PermissionChecker, RouteContext } from "@murumets-ee/core";
3
+ import { z } from "zod";
4
+ import { RateLimitStore } from "@murumets-ee/core/limits";
5
+
6
+ //#region src/cache-tags.d.ts
7
+ /**
8
+ * Cache-tag emission — the header only, never a purge protocol (D055, plan/api
9
+ * PR 11, resolves F73).
10
+ *
11
+ * **What this is for.** R05 surveyed how headless CMSes invalidate a CDN and
12
+ * found one convergent pattern: stamp each cached response with tags naming the
13
+ * documents that composed it, then purge by tag on write. R05 also recorded the
14
+ * asymmetry that makes the two halves separable — **emitting the header costs
15
+ * nothing at request time and needs no purge infrastructure to be useful, while
16
+ * deferring it means a later migration touches every cached response.** This
17
+ * module is the emission half. The purge half stays out for the reason phase 10
18
+ * gave and SD006 left standing: `resolveQueryField` records a cache dependency
19
+ * only under `renderContext === 'production-build'`, so a request-path purge
20
+ * would have no dependency graph to consult.
21
+ *
22
+ * **Emitting a tag nobody purges on changes nothing about S6.** Until a
23
+ * consumer wires purge-on-write, TTL expiry is still the entire reconciliation
24
+ * mechanism between a shared cache and S6. What ships here is the thing that
25
+ * makes that wiring possible later without a response-format change.
26
+ *
27
+ * # The format (D055)
28
+ *
29
+ * Two kinds, both namespaced, told apart by SEGMENT COUNT:
30
+ *
31
+ * | Kind | Shape | Means |
32
+ * |---|---|---|
33
+ * | Document | `lumi:<entity>:<id>` | this row's CONTENT is in the body |
34
+ * | Collection | `lumi:<entity>` | this response depends on WHICH rows exist |
35
+ *
36
+ * **Both kinds are needed and a document tag alone is a silent failure.** A
37
+ * per-document tag cannot invalidate a list when a BRAND-NEW row is created,
38
+ * because no cached response ever carried that row's tag. Drupal answers this
39
+ * with a first-class `node_list` tag; Vercel documents `cacheTag('products',
40
+ * 'product-${id}')` as the recommended hierarchy; Stellate names the failure
41
+ * verbatim. See R16.
42
+ *
43
+ * **Segment count rather than a suffix**, because Drupal's `node_list` shape
44
+ * would collide with an entity genuinely named `article_list` — a legal entity
45
+ * name here.
46
+ *
47
+ * **The `lumi:` prefix is not decoration.** The named consumer is an
48
+ * application, not a CDN: `plan/frontend-delivery`'s Astro BFF reads these and
49
+ * hands them to `Astro.cache.set({ tags })`, which merges them into ONE flat
50
+ * tag space alongside the app's own tags and Astro's `astro-path:` tags. Four
51
+ * bytes makes a collision structurally impossible.
52
+ *
53
+ * # The disclosure invariant — why raw ids are defensible here
54
+ *
55
+ * Akamai ships a knob to hide its tag header from clients, because "cache tags
56
+ * can include back-end information, or at least allow a malicious user to
57
+ * decipher back-end information from them". That is a real argument and it is
58
+ * answered structurally rather than waved away:
59
+ *
60
+ * > **Every id a tag names is already visible to that same caller, in that same
61
+ * > response, or was supplied by that caller in the request.**
62
+ *
63
+ * Row ids are in the body (`data.id`); resolved reference and media ids are in
64
+ * the body (`row[field].id`); `_chrome`'s document id and `_alternates`' id came
65
+ * from the caller's own query string; `_menu`'s handle is in the URL. The header
66
+ * therefore discloses nothing the body does not — asserted in the tests, not
67
+ * assumed. {@link CacheTagsConfig.enabled} exists anyway for an operator who
68
+ * wants no ids in a header regardless; the invariant is why the DEFAULT can be
69
+ * on, not a reason to skip the switch.
70
+ *
71
+ * # Bounds
72
+ *
73
+ * Emitted to the STRICT INTERSECTION of every CDN surveyed in R16, always,
74
+ * whichever one is actually configured — so switching vendors can never
75
+ * invalidate tags already emitted. THREE caps, and the third is not implied by
76
+ * the other two (128 × 128 plus separators overshoots every vendor's header
77
+ * limit — see {@link MAX_CACHE_TAG_HEADER_BYTES}):
78
+ *
79
+ * 1. {@link MAX_CACHE_TAG_LENGTH} characters per tag — Akamai's, the tightest.
80
+ * 2. {@link MAX_CACHE_TAGS_PER_RESPONSE} tags — Akamai's and Vercel's.
81
+ * 3. {@link MAX_CACHE_TAG_HEADER_BYTES} bytes for the assembled field, so the
82
+ * whole response-header block stays inside a default proxy buffer.
83
+ *
84
+ * Plus the charset: no spaces, no commas, printable ASCII from an allowlist.
85
+ *
86
+ * A tag that fails the charset or the length cap is **DROPPED, never truncated
87
+ * and never escaped**: a malformed tag is one a purge silently misses, which is
88
+ * strictly worse than a tag that was never there. A tag that does not fit the
89
+ * count or byte budget is dropped from the TAIL, so what survives is always the
90
+ * first N added — which is what makes insertion order a degradation strategy
91
+ * rather than an accident.
92
+ */
93
+ /**
94
+ * The namespace every tag this package emits begins with.
95
+ *
96
+ * Lowercase and fixed. Cloudflare matches tags case-INSENSITIVELY while Akamai
97
+ * matches case-sensitively, so a namespace whose case varied would mean
98
+ * different things on two CDNs.
99
+ */
100
+ declare const CACHE_TAG_NAMESPACE = "lumi";
101
+ /**
102
+ * Akamai's documented per-tag ceiling — "If your tag is longer than 128
103
+ * characters, the associated content won't be purged" — and the tightest of the
104
+ * four surveyed (Fastly 1024 bytes, Cloudflare 1024 chars, Vercel 256).
105
+ */
106
+ declare const MAX_CACHE_TAG_LENGTH = 128;
107
+ /**
108
+ * Akamai's documented per-object ceiling ("The maximum number of tags per
109
+ * cached object is 128. Any additional tags are ignored"), which Vercel matches
110
+ * exactly.
111
+ */
112
+ declare const MAX_CACHE_TAGS_PER_RESPONSE = 128;
113
+ /**
114
+ * The ceiling on the emitted header FIELD — `name: value` — in bytes. A THIRD
115
+ * bound, which the count and length caps do not imply.
116
+ *
117
+ * 128 tags of 128 characters plus 127 separators is 16,511 bytes, which is 127
118
+ * PAST Fastly's documented 16,384-byte header cap — where the documented
119
+ * behaviour is to ignore "the key currently being parsed and all keys following
120
+ * it", i.e. exactly the silent purge-miss this module refuses everywhere else.
121
+ * Two caps that each looked safe multiplied into one that was not.
122
+ *
123
+ * **Why 3072, and why the first draft's 8192 was the wrong number for the very
124
+ * reasons it cited.** That draft justified 8192 as "Akamai's documented overall
125
+ * maximum response-header size" and as fitting "the 8k `proxy_buffer_size` a
126
+ * default nginx will forward". Both numbers govern the WHOLE header block, not
127
+ * one field — so capping a single field AT the block maximum guarantees the
128
+ * block is exceeded whenever the cap binds. And nginx's default is documented
129
+ * as "equal to one memory page… 4K or 8K depending on the platform", which is
130
+ * 4096 on x86-64 Linux, and that one buffer must hold the entire block.
131
+ *
132
+ * So the budget is sized against a **4096-byte block**, leaving roughly a
133
+ * kilobyte for the status line, `Content-Type`, `Cache-Control`, `Vary`, up to
134
+ * four `Access-Control-*` headers and whatever the runtime adds. An intermediary
135
+ * that refuses an oversized header turns a 200 into a 502 — an availability
136
+ * failure caused by a caching hint, on a public surface, by default, which is
137
+ * the direction CLAUDE.md's "availability is a security property" rules out.
138
+ *
139
+ * **It binds before the 128-tag cap on a large page, deliberately.** At ~49
140
+ * bytes per tag (`lumi:article:` plus a uuid) this admits roughly 60 documents.
141
+ * A default `?limit=20` page fits with room to spare; a caller who asks for the
142
+ * `?limit=100` maximum will see tags dropped and logged. That is the right
143
+ * trade because the degradation is SAFE: the collection tag is added first and
144
+ * covers every row on the page, so a purge still invalidates the response — it
145
+ * is precision that is lost, not correctness. Vercel's documented advice for
146
+ * exactly this situation is to carry a coarser tag rather than enumerate.
147
+ */
148
+ declare const MAX_CACHE_TAG_HEADER_BYTES = 3072;
149
+ /**
150
+ * The entity name media tags are emitted under.
151
+ *
152
+ * `content-api` is a leaf and never imports `@murumets-ee/media`, so the media
153
+ * entity's name is not structurally available here. `media` is not a guess: it
154
+ * is the permission RESOURCE this package already names in `media.ts`, whose
155
+ * resolver "THROWS when the caller's role lacks `media:view`". The tag
156
+ * namespace follows the permission resource, which is the name a purge caller
157
+ * would reach for.
158
+ */
159
+ declare const MEDIA_TAG_ENTITY = "media";
160
+ /**
161
+ * What fills the SITE slot of a tag for a row that belongs to no site — a
162
+ * shared entity (media) on an app serving several sites (plan `multisite`
163
+ * D008): `lumi:_:media:<id>`.
164
+ *
165
+ * A slot rather than an omission, for the reason {@link CACHE_TAG_CHARSET}
166
+ * gives: the two tag kinds are told apart by SEGMENT COUNT. Were shared rows
167
+ * to skip the site segment, a shared DOCUMENT tag (`lumi:media:<id>`) and a
168
+ * site COLLECTION tag (`lumi:kulli:page`) would both have three segments. With
169
+ * the slot always present, a multi-site deployment's document tags have four
170
+ * and its collection tags three.
171
+ *
172
+ * `_` cannot be a site: a handle starts with a letter.
173
+ */
174
+ declare const SHARED_SITE_TAG_SEGMENT = "_";
175
+ /**
176
+ * Host configuration for cache-tag emission. Every field is optional; omitting
177
+ * the whole object emits `Cache-Tag`, comma-separated.
178
+ */
179
+ interface CacheTagsConfig {
180
+ /**
181
+ * Emit the header at all. Defaults to `true`.
182
+ *
183
+ * Default-on is free for the reason D010 already established — no
184
+ * `content-api` consumer is live, so there is nothing deployed to surprise —
185
+ * and it is the point of R05's asymmetry argument: a header nobody reads yet
186
+ * still costs nothing, while adding it later is a contract change across
187
+ * every cached response.
188
+ *
189
+ * Set `false` for a deployment that does not want document ids in a response
190
+ * header regardless of the disclosure invariant. See the module docblock.
191
+ */
192
+ enabled?: boolean;
193
+ /**
194
+ * The header name. Defaults to `Cache-Tag`.
195
+ *
196
+ * `Cache-Tag` is Cloudflare's native name, is accepted bare by Netlify, and
197
+ * is trivially parseable by the BFF that is the actual named consumer. A
198
+ * Fastly deployment sets `Surrogate-Key` and `separator: ' '`; an Akamai one
199
+ * sets `Edge-Cache-Tag`.
200
+ *
201
+ * Emitting SEVERAL vendor headers at once is deliberately not offered: R16
202
+ * could find no product anywhere doing it, and it multiplies the disclosure
203
+ * surface for a hedge nobody needs.
204
+ */
205
+ header?: string;
206
+ /**
207
+ * What joins the tags. Defaults to `,`.
208
+ *
209
+ * Cloudflare and Akamai are comma-separated; Fastly is SPACE-separated. Only
210
+ * these two are accepted, because they are the only two any surveyed vendor
211
+ * parses — a third would produce one giant unparseable key rather than an
212
+ * error.
213
+ */
214
+ separator?: ',' | ' ';
215
+ }
216
+ //#endregion
217
+ //#region src/resolution-budget.d.ts
218
+ /**
219
+ * The per-document resolution budget (plan/api phase 05).
220
+ *
221
+ * Rendering ONE document can trigger four independent kinds of backend work:
222
+ * `query()`-field resolutions, link resolutions, media resolutions and entity-
223
+ * reference projections. Every one of them scales with the document's own
224
+ * content — which an EDITOR controls, not an operator — and all four compete
225
+ * for the same connection pool.
226
+ *
227
+ * **One budget, spanning all four. Not four caps.** A per-kind cap lets a
228
+ * single document sum four separate ceilings into one much larger pool draw,
229
+ * which is the same "it's only N today" defence CLAUDE.md's No-Unbounded-Fan-Out
230
+ * section refuses. The kinds are recorded so a DROP can be attributed; they are
231
+ * never separately bounded.
232
+ *
233
+ * **This is the COUNT half of the rule.** The concurrency half is
234
+ * `@murumets-ee/core`'s `runBounded`, which caps how many of the granted
235
+ * resolutions are in flight at once. Both are needed: a bound on peak
236
+ * concurrency still lets an unbounded list through, just more slowly, and a
237
+ * bound on count still lets a bounded list arrive as one burst.
238
+ *
239
+ * **Nothing here is silent.** Whatever the ceiling refuses is counted per kind
240
+ * and reported by {@link ResolutionBudget.dropSummary}, which the handler logs.
241
+ * CLAUDE.md is explicit that silent truncation reads as "covered everything" —
242
+ * and phase 05's failure semantics make an unresolved target INDISTINGUISHABLE
243
+ * from a deleted, unpublished or unviewable one ON THE WIRE, deliberately, so
244
+ * an editor asking "why is my related-article link gone?" can only ever be
245
+ * answered from the operator-facing log. That makes this log load-bearing
246
+ * rather than nice-to-have.
247
+ */
248
+ /**
249
+ * The kinds of per-document resolution work this budget spans.
250
+ *
251
+ * The union is DERIVED from the array, not declared beside it. Declared
252
+ * separately, a fifth member added to the union but forgotten in the array
253
+ * would still be accepted by `claim` and counted in `drops`, then silently
254
+ * omitted from `dropSummary` — the exact silent truncation this module exists
255
+ * to prevent, and the compiler would not have caught it, because a shorter
256
+ * `readonly ResolutionKind[]` is a perfectly valid value of that type.
257
+ */
258
+ declare const RESOLUTION_KINDS: readonly ["reference", "populate", "media", "block"];
259
+ type ResolutionKind = (typeof RESOLUTION_KINDS)[number];
260
+ /**
261
+ * Absolute ceiling on the total resolutions ONE document may trigger, across
262
+ * every kind. A host may lower it (see
263
+ * `ContentApiConfig.maxResolutionsPerDocument`); nothing can raise it.
264
+ *
265
+ * 200 is calibrated against the shape of the work rather than against a
266
+ * measurement. Be precise about that shape, because an authoritative-sounding
267
+ * cost model that is wrong is worse than none:
268
+ *
269
+ * - A unit is at most one row in a BATCHED, keyed read — N references to the
270
+ * same entity cost one query, not N.
271
+ * - A REFERENCE unit additionally costs one routing-seam lookup, which in this
272
+ * repo is a single-row `toolkit_paths` read per resolved row. So the
273
+ * worst-case query count is roughly 2× the granted unit count, not 1×.
274
+ * - `runBounded` holds peak concurrency at a small constant, so those lookups
275
+ * arrive paced rather than as one burst.
276
+ * - Every read on this path is on the read-only pool, which carries a
277
+ * `statement_timeout` (PR 2) — including the storage lookup behind media,
278
+ * which is why `StorageClient` keeps a separate read-only table client.
279
+ *
280
+ * A document needing more than 200 resolved targets to render is not a page, it
281
+ * is an export — and the honest answer for one is a truncated render plus an
282
+ * operator log, not an unbounded pool draw from an unauthenticated caller.
283
+ */
284
+ declare const MAX_RESOLUTIONS_PER_DOCUMENT = 200;
285
+ /**
286
+ * A claim-based counter. Callers ask for `n` units BEFORE doing the work and
287
+ * receive how many they may actually do.
288
+ *
289
+ * Claim-before, never report-after: a budget checked afterwards has already
290
+ * paid for the overrun, which is a check that cannot fail in the direction it
291
+ * is supposed to.
292
+ */
293
+ interface ResolutionBudget {
294
+ /**
295
+ * Claim up to `n` units for `kind`. Returns how many were GRANTED — `0`
296
+ * through `n`. Anything short of `n` is recorded as a drop for that kind.
297
+ *
298
+ * A non-positive or non-integer `n` grants 0 and records nothing; callers
299
+ * derive `n` from array lengths, so a defensive floor here beats a
300
+ * `Math.max` at every call site.
301
+ */
302
+ claim(kind: ResolutionKind, n: number): number;
303
+ /** Units still available. */
304
+ readonly remaining: number;
305
+ /** Units granted so far, across every kind. */
306
+ readonly granted: number;
307
+ /** True when anything at all was refused — the log trigger. */
308
+ readonly hasDrops: boolean;
309
+ /**
310
+ * Per-kind refused counts, for the operator log. Only kinds that actually
311
+ * lost something appear, so a log line names what was cut rather than
312
+ * printing four zeroes.
313
+ */
314
+ dropSummary(): Partial<Record<ResolutionKind, number>>;
315
+ /** The ceiling this budget was built with — published in `_schema` (D008). */
316
+ readonly ceiling: number;
317
+ }
318
+ //#endregion
319
+ //#region src/blocks.d.ts
320
+ /**
321
+ * One ref a resolver was asked for and could not deliver.
322
+ *
323
+ * **This channel is the whole reason in-place media resolution is safe here**
324
+ * (D019, R022 §3). When a resolved value REPLACES a stored ref, "I tried and
325
+ * failed" has nowhere left to go — every surveyed CMS that resolves in place
326
+ * either goes silent (Sanity, Strapi), overloads `null` and documents it as a
327
+ * hazard (Directus: "you must treat `null` as both a value and an indication of
328
+ * restricted permissions"), or ships a type that is wrong in one of the two
329
+ * states (Payload). Contentful is the only one that escapes it, and it escapes
330
+ * by adding a SECOND, independent channel. This is that channel.
331
+ *
332
+ * `ref` is the media id for `kind: 'media'` and the `linkValueKey` for
333
+ * `kind: 'link'`. Neither discloses anything new: both are values the consumer
334
+ * already holds, because they came out of the stored tree it is being served.
335
+ */
336
+ interface UnresolvedBlockRef {
337
+ readonly kind: 'media' | 'link';
338
+ readonly ref: string;
339
+ /**
340
+ * `'unresolvable'` — the target does not exist, is unpublished for this
341
+ * caller, is trashed, or is not public. ONE bucket, several causes,
342
+ * deliberately: phase 05's failure semantics do not let an anonymous consumer
343
+ * learn which.
344
+ *
345
+ * `'budget'` — this response ran out of resolution budget before reaching it.
346
+ * A fact about the RESPONSE, not about the target, so naming it separately
347
+ * discloses nothing and is the difference between "retry later" and "stop
348
+ * asking".
349
+ */
350
+ readonly reason: 'unresolvable' | 'budget';
351
+ }
352
+ /**
353
+ * A `blockTree` field, resolved — the format `plan/frontend-delivery` D019
354
+ * settled after `plan/api` closed with findings 069 and 075 still open.
355
+ *
356
+ * **The envelope's PRESENCE is the marker** (F075 requirement 3). A stored tree
357
+ * is an array; a resolved one is this object. `Array.isArray(value)` tells them
358
+ * apart without inspecting contents, which is what stops "resolution is not
359
+ * wired on this deployment" from looking like valid data. `resolved` versions
360
+ * the format so later additions (`queries`, when something can render them) are
361
+ * additive rather than breaking.
362
+ *
363
+ * **Nothing emits an empty envelope, with ONE named exception.** With no
364
+ * provider wired the stored tree crosses unchanged — see
365
+ * {@link resolveBlockTrees}. That is deliberate and it is Storyblok's
366
+ * documented wart avoided: its responses carry `rels: []` even when resolution
367
+ * was never requested, so empty-because-nothing-to-resolve is indistinguishable
368
+ * from empty-because-not-asked.
369
+ *
370
+ * The exception is D010's third state: a provider that failed AND then failed
371
+ * its I/O-free retry is served {@link EMPTY_RESOLVED_BLOCK_TREE}, loudly
372
+ * logged. That is narrow by construction — by the time it is reached the
373
+ * provider has failed a pure in-memory walk, so it is broken rather than
374
+ * unlucky — and it does not reintroduce Storyblok's ambiguity, because the
375
+ * ambiguity there was between "not asked" and "nothing found". This envelope
376
+ * means neither: it means "asked twice, answered neither time", and the
377
+ * operator log says so in those words.
378
+ *
379
+ * **Links are a keyed sidecar; media is replaced in place. The split is not
380
+ * arbitrary — it is what each side of the contract already agreed on.**
381
+ *
382
+ * A `LinkValue` is an opaque pointer with no href field at all, and every
383
+ * renderer already looks its href up by `linkValueKey` (`ResolvedLinksContext`,
384
+ * `resolveLinkHref(link, resolved)`, `RichTextRenderer`'s `resolvedLinks`
385
+ * prop). `blocks`' own `editor-state` route already serialises exactly this
386
+ * `Record<string, string | null>` for its wire. Writing the href INTO the link
387
+ * would also fight the security path: `asLinkValue` returns "a fresh, minimal
388
+ * `LinkValue` (extraneous keys dropped)" and is the richtext sanitiser's
389
+ * normaliser, so the href would be stripped by the code whose job is stripping.
390
+ *
391
+ * A media ref is NOT an opaque pointer: it is a whole `MediaRef` carrying a url
392
+ * denormalised at write time, which every render reads directly as
393
+ * `image.url`. That url has to be overwritten regardless — it is minted by
394
+ * `StorageClient.getUrl`, which returns a SIGNED url for any non-public file,
395
+ * and freezing one into content puts a bearer capability on a cacheable
396
+ * anonymous response. So the sidecar's headline advantage ("the stored tree
397
+ * crosses byte-identical") is not on offer for media, while its cost — every
398
+ * render in every project stops reading `image.url` and consults a lookup —
399
+ * very much is.
400
+ */
401
+ /**
402
+ * One row a resolved block tree DEPENDS ON — named so a cache holding this
403
+ * response can be evicted when that row changes.
404
+ *
405
+ * `entity` is `'media'` for an image the tree embeds, and the target's entity
406
+ * name for an internal link. That is deliberately the SAME shape
407
+ * `CacheTagSink.addDocument(entity, id)` takes, because the point of the field
408
+ * is that neither consumer has to walk the tree again to learn what is in it.
409
+ *
410
+ * 🔒 **RESOLVED ROWS ONLY.** A ref appears here only if the provider actually
411
+ * resolved it, which means it passed that provider's publish + permission gate.
412
+ * An unpublished link target is reported through `unresolved` (which carries a
413
+ * `linkValueKey`, a value the consumer already holds) and NEVER here — listing
414
+ * it would disclose the id of a document this caller may not see. The same
415
+ * rule `ResolveBlocks`' docblock puts on hrefs, applied to identity.
416
+ *
417
+ * There is no cap on the array. Both consumers of it are already bounded — the
418
+ * `Cache-Tag` header by `MAX_CACHE_TAGS_PER_RESPONSE`, a frontend's own tag set
419
+ * by its own cap — and it is bounded upstream anyway by the resolution budget
420
+ * the provider claimed against.
421
+ */
422
+ interface BlockTreeRef {
423
+ readonly entity: string;
424
+ readonly id: string;
425
+ }
426
+ interface ResolvedBlockTree {
427
+ /** Format marker and version. Its presence means resolution RAN. */
428
+ readonly resolved: 1;
429
+ /**
430
+ * The block tree, with each media ref replaced by a `MediaProjection` — or
431
+ * by `null` when it did not resolve.
432
+ *
433
+ * **Replaced, never merged.** Falling back to the stored url for an id that
434
+ * failed would preserve the leak exactly, because "did not resolve" and "is
435
+ * not public" are the same branch in the shipped resolver. `null` is already
436
+ * what every render treats as "no image".
437
+ */
438
+ readonly blocks: readonly unknown[];
439
+ /**
440
+ * `linkValueKey → href`. `null` means "no usable link" and the renderer emits
441
+ * the text without an `<a>` — the contract `resolveLinkHref` already has.
442
+ */
443
+ readonly links: Readonly<Record<string, string | null>>;
444
+ /**
445
+ * What could not be resolved. **Omitted entirely when nothing failed**, so
446
+ * its presence is itself information (Contentful's rule for `errors[]`).
447
+ */
448
+ readonly unresolved?: readonly UnresolvedBlockRef[];
449
+ /**
450
+ * The rows this tree resolved against — see {@link BlockTreeRef}. **Omitted
451
+ * when the tree references nothing**, matching `unresolved`.
452
+ *
453
+ * WHY IT EXISTS. Until this field, a resolved block tree was opaque to every
454
+ * cache: `_schema` published `blockTreeContents` as an `uncovered`
455
+ * dependency, meaning re-publishing an image did not evict the pages
456
+ * embedding it — they served the old one until their TTL expired. The
457
+ * resolver already knew every id (it had just looked each one up); the
458
+ * information simply had nowhere to go. It goes here, and BOTH consumers read
459
+ * the one field rather than each re-walking the tree: `content-api` mints its
460
+ * own `Cache-Tag` entries from it, and a frontend mints whatever its own
461
+ * purge vocabulary is.
462
+ *
463
+ * `resolved: 1` versions the format precisely so an addition like this is
464
+ * additive; a consumer that does not read `refs` is unaffected.
465
+ */
466
+ readonly refs?: readonly BlockTreeRef[];
467
+ /**
468
+ * Collection block id → the rows it resolved to (editor PR53, D001's dynamic
469
+ * half). **Omitted when the tree holds no collection**, matching `unresolved`
470
+ * and `refs`.
471
+ *
472
+ * A keyed SIDECAR, for the reason `links` is one: a render reads rows by the
473
+ * block's id (`CollectionRowsContext`), and writing them into the block's
474
+ * props would put resolved, per-reader data into a value that otherwise
475
+ * crosses as the author stored it. Each row carries only the slots the app
476
+ * mapped — never the row — and exists because it passed the SAME whitelist ∧
477
+ * `view` gate as any other read on this API ({@link ResolveBlocksOptions}).
478
+ * Row ids are listed in `refs`; the entity is tagged as a collection
479
+ * dependency by `resolveBlockTrees`.
480
+ */
481
+ readonly collections?: Readonly<Record<string, ResolvedCollection>>;
482
+ /**
483
+ * What this document's live-bound Price fields resolved to (block-library
484
+ * PR03 + F012) — structural mirror of `@murumets-ee/blocks`' `LiveValues`,
485
+ * minus `asked` (canvas-merge bookkeeping with no reader on a one-shot
486
+ * render). **Omitted when the site registers no live sources at all** —
487
+ * matching `unresolved`/`refs`/`collections`'s own rule, and letting the
488
+ * `Price` primitive tell "this deployment wires no live values" (absent)
489
+ * from "wired, but this source doesn't exist" (`sources` present and
490
+ * missing the key) exactly the way the canvas already does.
491
+ *
492
+ * `sources` is load-bearing on the SITE, not just the canvas: the `Price`
493
+ * primitive checks `live.sources[declaredSource]` before it will draw
494
+ * ANYTHING for a bound price (`rich-field-primitives-media.tsx`), so a
495
+ * `live` sidecar carrying only `values` would make every live price on a
496
+ * delivered page render as nothing.
497
+ */
498
+ readonly live?: ResolvedLiveValues;
499
+ /**
500
+ * The site's currency (ISO 4217), from `blocksPlugin({ currency })`
501
+ * (block-library F012). Independent of `live` — a plain, typed-in Price
502
+ * needs it to show a currency symbol regardless of whether this tree binds
503
+ * any live price — so it is present whenever the site configures one,
504
+ * **never omitted on the "nothing to resolve" test** `unresolved`/`refs`
505
+ * follow, because there is always something to say: draw it plain, or draw
506
+ * it in this currency.
507
+ */
508
+ readonly currency?: string;
509
+ /**
510
+ * The row this tree is drawn for, projected through its entity's detail
511
+ * declaration (block-library PR16) — `blocksPlugin({ details })`. A price, a
512
+ * list of facts, an HTML body the server has already sanitised (with its
513
+ * headings), a third-party viewer's allow-listed reference, and the text
514
+ * that stands in for the row's title and summary. Absent unless the app
515
+ * declares a detail for the entity and the row has a value for a declared
516
+ * part. Only DECLARED columns are projected — never the row.
517
+ */
518
+ readonly detail?: ResolvedDetail;
519
+ /**
520
+ * Block type → the data a plugin's provider resolved for it
521
+ * (davatoidud-toolkit PR04, `blocks.dataProviders`). Structural mirror of
522
+ * `@murumets-ee/content`'s field; each value is validated by the block that
523
+ * draws it, on the consumer's side of the process boundary.
524
+ */
525
+ readonly blockData?: Readonly<Record<string, unknown>>;
526
+ }
527
+ /** One labelled value of a row's detail — structural mirror of a collection row's meta line. */
528
+ interface ResolvedDetailFact {
529
+ readonly key: string;
530
+ readonly label: string;
531
+ readonly value: string | number;
532
+ readonly format?: 'price';
533
+ }
534
+ /** See {@link ResolvedBlockTree.detail}. Structural mirror of `@murumets-ee/blocks`' `DocumentDetail`. */
535
+ interface ResolvedDetail {
536
+ readonly title?: string;
537
+ readonly text?: string;
538
+ readonly price?: {
539
+ readonly amount: number;
540
+ readonly compareAt?: number;
541
+ readonly unit?: string;
542
+ };
543
+ readonly facts?: readonly ResolvedDetailFact[];
544
+ readonly body?: {
545
+ readonly html: string;
546
+ readonly headings: readonly {
547
+ readonly id: string;
548
+ readonly text: string;
549
+ readonly level: 2 | 3;
550
+ }[];
551
+ };
552
+ readonly embed?: {
553
+ readonly provider: string;
554
+ readonly reference: string;
555
+ };
556
+ /** The trail from the site's top to this page: ancestors (with a path), then the page itself. */
557
+ readonly breadcrumbs?: readonly {
558
+ readonly label: string;
559
+ readonly href?: string;
560
+ }[];
561
+ }
562
+ /**
563
+ * One live source's PUBLIC metadata — structural mirror of
564
+ * `@murumets-ee/blocks`' `LiveSourceMeta`.
565
+ */
566
+ interface ResolvedLiveSourceMeta {
567
+ readonly label: string;
568
+ readonly labelKey?: string;
569
+ /** The library fields the site granted this source, as `'<block>.<field>'`. */
570
+ readonly fields?: readonly string[];
571
+ /** What the source's values are — absent means `'price'` (block-library PR18, PR21). */
572
+ readonly kind?: 'price' | 'text' | 'list' | 'facts';
573
+ /** A `'facts'` source's values, words already in the page's locale (block-library PR21). */
574
+ readonly facts?: readonly ResolvedLiveFact[];
575
+ }
576
+ /** One fact a `'facts'` source declares — structural mirror of `@murumets-ee/blocks`' `LiveFact`. */
577
+ interface ResolvedLiveFact {
578
+ readonly name: string;
579
+ readonly label: string;
580
+ readonly format: 'price' | 'number' | 'text';
581
+ readonly unit?: string;
582
+ }
583
+ /** One resolved live value — structural mirror of `@murumets-ee/blocks`' `LiveValue`. */
584
+ interface ResolvedLiveValue {
585
+ /**
586
+ * Optional to MIRROR `@murumets-ee/blocks`' own `LiveValue` exactly (itself
587
+ * `Pick<PriceValue, 'amount'|'compareAt'|'unit'>`), not because the wire
588
+ * ever actually carries one without it: `readLiveAnswer` already drops any
589
+ * answer with no amount before it reaches `LiveValues.values` at all. A
590
+ * narrower (`amount: number`) type here would make the mirror a lie
591
+ * `tsc` catches the moment a consumer assigns a real `LiveValues` to it.
592
+ */
593
+ readonly amount?: number;
594
+ readonly compareAt?: number;
595
+ readonly unit?: string;
596
+ /**
597
+ * A live TEXT (block-library PR18) — one line of plain text a render draws
598
+ * inside authored text. Text only: a consumer escapes it like any text and
599
+ * never puts it in markup, a URL or an attribute.
600
+ */
601
+ readonly text?: string;
602
+ /** A live LIST (block-library PR18) — a few lines of plain text. */
603
+ readonly items?: readonly string[];
604
+ /**
605
+ * Live FACTS (block-library PR21) — named numbers or one-line texts. Values
606
+ * only: a consumer draws them as text, never as markup, a URL or an attribute.
607
+ */
608
+ readonly facts?: Readonly<Record<string, number | string>>;
609
+ }
610
+ /** What a document's live-bound Price fields resolved to. See {@link ResolvedBlockTree.live}. */
611
+ interface ResolvedLiveValues {
612
+ readonly sources: Readonly<Record<string, ResolvedLiveSourceMeta>>;
613
+ readonly values: Readonly<Record<string, Readonly<Record<string, ResolvedLiveValue>>>>;
614
+ }
615
+ /** One resolved collection block — the structural mirror of `@murumets-ee/blocks`' `CollectionResult`. */
616
+ interface ResolvedCollection {
617
+ readonly status: 'ok' | 'unset' | 'unavailable' | 'over-budget' | 'needs-document';
618
+ readonly entity: string | null;
619
+ readonly rows: readonly ResolvedCollectionRow[];
620
+ /** A PAGED collection's position (davatoidud-toolkit PR03) — absent otherwise. */
621
+ readonly page?: {
622
+ readonly number: number;
623
+ readonly hasNext: boolean;
624
+ } | undefined;
625
+ }
626
+ /** One row, projected onto a card's slots — never the entity row itself. */
627
+ interface ResolvedCollectionRow {
628
+ readonly id: string;
629
+ readonly title: string | null;
630
+ readonly text: string | null;
631
+ readonly image: {
632
+ readonly url: string;
633
+ readonly alt: string | null;
634
+ readonly width: number | null;
635
+ readonly height: number | null;
636
+ } | null;
637
+ readonly href: string | null;
638
+ }
639
+ /**
640
+ * Host-injected block-tree resolver.
641
+ *
642
+ * Receives the STORED tree and returns the {@link ResolvedBlockTree} that
643
+ * should cross the wire in its place. Called once per `blockTree` field per
644
+ * row, and once per `_chrome` region.
645
+ *
646
+ * **SECURITY (CLAUDE.md #1).** Every read the provider performs must be
647
+ * PUBLISHED-only and must honour the caller's permissions — a `query()` field
648
+ * resolved through an `AdminClient` would surface drafts on the anonymous
649
+ * surface, and an internal link resolved without a publish filter would leak an
650
+ * unpublished document's URL. `content-api` cannot enforce either (the resolver
651
+ * is injected), so the WIRING must guarantee both — the same posture, for the
652
+ * same reason, as `ContentApiResolution.resolvePath`.
653
+ *
654
+ * `budget` is the response's ONE resolution budget. Claim BEFORE doing work,
655
+ * and resolve only what was granted; whatever is refused is reported by the
656
+ * handler's existing drop log.
657
+ */
658
+ type ResolveBlocks = (blocks: unknown, opts: ResolveBlocksOptions) => Promise<ResolvedBlockTree>;
659
+ /** What a {@link ResolveBlocks} provider is called with. */
660
+ interface ResolveBlocksOptions {
661
+ locale?: string | undefined;
662
+ budget: ResolutionBudget;
663
+ entity: string;
664
+ field: string;
665
+ /**
666
+ * May THIS caller read rows of `entityName`? The whitelist ∧ `view` gate the
667
+ * handler applies to every entity read, handed to the provider so a
668
+ * COLLECTION block (editor PR53) cannot list an entity this API withholds.
669
+ *
670
+ * Optional, and absence means NO: a provider that is not told who may read
671
+ * what must resolve no collection rows at all, rather than guess.
672
+ */
673
+ canReadEntity?: ((entityName: string) => boolean) | undefined;
674
+ /**
675
+ * Take one slot from THIS response's collection allowance
676
+ * ({@link MAX_COLLECTIONS_PER_RESPONSE}). Absent means NO, like
677
+ * `canReadEntity`: a provider not told how many collections the response may
678
+ * resolve resolves none.
679
+ */
680
+ claimCollection?: (() => boolean) | undefined;
681
+ /**
682
+ * Set when the field is a CODED TEMPLATE — `field.blockTree({ template })`
683
+ * (editor D017) — and names the template's root block.
684
+ *
685
+ * A templated document has no stored tree: its layout is rebuilt from the
686
+ * block registry on every load, and its content lives in entity COLUMNS the
687
+ * template's blocks BIND. So the provider is handed the template name instead
688
+ * of a tree, builds the tree itself (this package cannot — it has no idea
689
+ * what a block looks like), and serves it as the envelope's `blocks`.
690
+ *
691
+ * Absent for an ordinary tree, and the provider then resolves `blocks` as it
692
+ * always has.
693
+ */
694
+ template?: string | undefined;
695
+ /**
696
+ * The row this document's BOUND blocks read (frontend-delivery F109) — handed
697
+ * over for every tree, and always when {@link template} is set.
698
+ *
699
+ * A bound rich-text column's link marks live HERE, not in any tree, so the
700
+ * envelope's `links` sidecar never carried them — an internal link in a
701
+ * recipe's body rendered as plain text. The provider resolves the links in
702
+ * exactly the columns the template binds, in the SAME call and against the
703
+ * SAME budget as the tree's own, so a document costs one batch per linked
704
+ * entity type however many columns carry links — never one request per link.
705
+ *
706
+ * Read-only: the provider must not write to it. The row is the response being
707
+ * built, and its columns cross the wire as the handler already projected them.
708
+ */
709
+ boundValues?: Readonly<Record<string, unknown>> | undefined;
710
+ /**
711
+ * The tree is a STORED DETAIL TEMPLATE drawn for `boundValues` (block-library
712
+ * D009), not that row's own document. The provider must then project the
713
+ * row's detail and NOTHING else of it: in particular it must not walk the
714
+ * row's columns for links on the say-so of a bound block in the tree, since
715
+ * the tree is editor-authored and the row is not the editor's to expose.
716
+ *
717
+ * ⚠️ A contract the provider has to honour: this package cannot enforce it.
718
+ * `@murumets-ee/content`'s resolver does; a host-supplied one must too.
719
+ */
720
+ rowIsForeign?: boolean | undefined;
721
+ /**
722
+ * The page a PAGED collection in this tree shows (davatoidud-toolkit PR03) —
723
+ * the request's `?page=`, already validated (`singleParamsSchema`). Absent
724
+ * for page 1, and on every list read: a page number belongs to ONE document.
725
+ */
726
+ page?: number | undefined;
727
+ }
728
+ /**
729
+ * Is `value` a {@link ResolvedBlockTree}?
730
+ *
731
+ * A structural check on a host-injected return value, not a cast. It checks the
732
+ * marker, the tree and the sidecar — and deliberately does NOT walk `blocks`
733
+ * (that is the document's own content, of unbounded size, and validating it
734
+ * here would duplicate the consumer-side validator that already exists for the
735
+ * unresolved case).
736
+ *
737
+ * **The sidecar's VALUES are checked, and the unbounded-content argument above
738
+ * does not excuse skipping them.** `links` is bounded by the link count the
739
+ * provider already claimed against the budget, so walking it costs what the
740
+ * response has already paid for. Without this a provider returning a nested
741
+ * object or a number where an href belongs passes the guard and the value is
742
+ * serialised straight onto the wire. The Astro decoder defends itself with its
743
+ * own check, so this is defence in depth rather than a live hole — but it is
744
+ * the one field this package can cheaply own, and owning it here means a
745
+ * consumer that is NOT ours inherits the guarantee too.
746
+ *
747
+ * The same argument covers `unresolved`, which is bounded by the same claim.
748
+ *
749
+ * TYPE only, deliberately — not scheme policy. A host may legitimately emit any
750
+ * scheme its own consumers accept, and `sanitizeHref` at the consumer's process
751
+ * boundary is where that decision belongs. Two places deciding it is how they
752
+ * come to disagree.
753
+ */
754
+ declare function isResolvedBlockTree(value: unknown): value is ResolvedBlockTree;
755
+ //#endregion
756
+ //#region src/consent.d.ts
757
+ /**
758
+ * `GET /_consent` and `POST /_consent` — a site's consent settings, and the
759
+ * record of one visitor's choice (the second write this read-only API accepts,
760
+ * beside form submissions).
761
+ *
762
+ * A visitor answers the consent banner on the site's own front; the front
763
+ * forwards the choice here with its site key, and sets the signed cookie this
764
+ * answer carries on its OWN origin. So both routes answer only a caller
765
+ * carrying a verified site key — the site comes from that KEY and nothing
766
+ * else, never from a query parameter or the body — and a caller without one
767
+ * gets the same 404 as a route that does not exist. A browser never reaches
768
+ * them directly. (The same rule as the form routes, `forms.ts`.)
769
+ *
770
+ * What this module owns is the HTTP edge: the key requirement, the locale, the
771
+ * body cap, parsing, and turning the provider's verdict into a response. Every
772
+ * decision about CONSENT — which categories exist, whether a choice is
773
+ * well-formed, the policy version, storage, signing the cookie — is the
774
+ * injected `consent` provider's (`@murumets-ee/consent/server`), because this
775
+ * package is a leaf and does not import it.
776
+ *
777
+ * Every response here is `no-store`: a recorded choice is one visitor's, and
778
+ * the settings feed is memoised by the front rather than by a shared cache
779
+ * (tagging and purging it belongs to a later change). Nothing from a posted
780
+ * body is logged.
781
+ */
782
+ /** The decision as the provider built it. Passed through to the caller untouched. */
783
+ interface ContentApiConsentState {
784
+ readonly categories: Readonly<Record<string, boolean>>;
785
+ readonly policyVersion: number;
786
+ readonly decidedAt: string;
787
+ readonly source: string;
788
+ }
789
+ /** The signed cookie the FRONT sets on its own origin (the provider signs; the front never holds the secret). */
790
+ interface ContentApiConsentCookie {
791
+ readonly name: string;
792
+ readonly value: string;
793
+ /** Seconds. */
794
+ readonly maxAge: number;
795
+ }
796
+ /** The provider's verdict on one posted choice. */
797
+ type ContentApiConsentRecordResult = {
798
+ readonly kind: 'recorded';
799
+ readonly state: ContentApiConsentState;
800
+ readonly cookie: ContentApiConsentCookie;
801
+ } | {
802
+ readonly kind: 'invalid';
803
+ } | {
804
+ readonly kind: 'unavailable';
805
+ };
806
+ /**
807
+ * The consent provider. Omit it and both routes are 404 (opt-in,
808
+ * deny-by-default). Wire `createConsentContentApi` from
809
+ * `@murumets-ee/consent/server`.
810
+ */
811
+ interface ContentApiConsent {
812
+ /** The site's public consent config for one locale, as a JSON object; `null` = this site has none. */
813
+ getConfig(input: {
814
+ site: string;
815
+ locale: string;
816
+ }): Promise<Readonly<Record<string, unknown>> | null>;
817
+ record(input: {
818
+ site: string;
819
+ body: unknown;
820
+ }): Promise<ContentApiConsentRecordResult>;
821
+ }
822
+ /**
823
+ * Largest consent body accepted. A choice is a handful of category flags and a
824
+ * version number; this is generous room for that and far short of anything
825
+ * worth parsing for a flood.
826
+ */
827
+ declare const MAX_CONSENT_BODY_BYTES: number;
828
+ //#endregion
829
+ //#region src/detail-template.d.ts
830
+ /**
831
+ * The key the resolved template crosses under. Underscore-led so it cannot be
832
+ * mistaken for a column, and stable: a frontend lists it beside its own body
833
+ * block fields.
834
+ */
835
+ declare const DETAIL_TEMPLATE_FIELD = "_template";
836
+ /**
837
+ * The detail-template provider. Injected, not imported, so this package stays a
838
+ * leaf — `@murumets-ee/content`'s `createDetailTemplateProvider` is the
839
+ * reference implementation, and its header states what each method may reach.
840
+ */
841
+ interface ContentApiDetailTemplates {
842
+ /** The entity templates are stored in — named on the response's cache tags. */
843
+ readonly entity: string;
844
+ /**
845
+ * The PUBLISHED template for rows of `entity` at `locale`, or `null`.
846
+ * Implementations must apply the publish filter themselves: this package
847
+ * serves whatever comes back.
848
+ */
849
+ readTemplate(opts: {
850
+ entity: string;
851
+ locale: string;
852
+ }): Promise<{
853
+ readonly id: string;
854
+ readonly blocks: unknown[];
855
+ } | null>;
856
+ /**
857
+ * The row's public name and summary. Only string `title` and `description`
858
+ * are read from the answer; anything else it returns is dropped here.
859
+ */
860
+ publicRow(opts: {
861
+ entity: string;
862
+ row: Readonly<Record<string, unknown>>;
863
+ locale: string;
864
+ }): Promise<Record<string, unknown>>;
865
+ }
866
+ //#endregion
867
+ //#region src/forms.d.ts
868
+ /**
869
+ * `/_forms/:id/token` and `POST /_forms/:id/submissions` — one of the two
870
+ * places this read-only API accepts a write (forms plan D003; the other is
871
+ * `POST /_consent`, `consent.ts`).
872
+ *
873
+ * A form on a site is posted by the VISITOR to the site's own front (same
874
+ * origin, works without JavaScript); the front forwards it here with its site
875
+ * key and the visitor's address. So both routes answer only a caller carrying a
876
+ * verified site key — the site comes from that key and nothing else — and a
877
+ * caller without one gets the same 404 as a route that does not exist. A
878
+ * browser never reaches them directly.
879
+ *
880
+ * What this module owns is the HTTP edge: the key requirement, the body cap,
881
+ * parsing, the visitor address, and turning the provider's verdict into a
882
+ * response. Every decision about the FORM — which form, spam, validation,
883
+ * rate limits, the extra check, storage — is the injected `forms` provider's
884
+ * (`@murumets-ee/forms/server`), because this package is a leaf (D032).
885
+ *
886
+ * Every response here is `no-store`: a token is minted for one visitor's
887
+ * ask, and a submission's answer is about one post (C5 — nothing from a
888
+ * submission in a cache).
889
+ */
890
+ /** What a rendered form needs: a fresh token, its trap's name, its extra check. */
891
+ interface ContentApiFormTicket {
892
+ readonly token: string;
893
+ readonly honeypot: string;
894
+ readonly challenge: {
895
+ readonly provider: 'turnstile' | 'recaptcha';
896
+ readonly siteKey: string;
897
+ } | null;
898
+ }
899
+ /**
900
+ * The provider's verdict on one post. `accepted` is ALSO the answer to spam —
901
+ * a bot is told it succeeded (forms D004) — so this module cannot tell the two
902
+ * apart, and the response it builds is the same bytes for both.
903
+ */
904
+ type ContentApiFormSubmitResult = {
905
+ readonly kind: 'accepted';
906
+ } | {
907
+ readonly kind: 'invalid';
908
+ readonly fields: Readonly<Record<string, string>>;
909
+ } | {
910
+ readonly kind: 'rate_limited';
911
+ readonly retryAfterSeconds: number;
912
+ } | {
913
+ readonly kind: 'challenge_failed';
914
+ } | {
915
+ readonly kind: 'unavailable';
916
+ } | {
917
+ readonly kind: 'not_found';
918
+ };
919
+ /**
920
+ * The forms provider. Omit it and both routes are 404 (opt-in,
921
+ * deny-by-default). Wire `createFormsContentApi(getToolkitApp)` from
922
+ * `@murumets-ee/forms/server`.
923
+ */
924
+ interface ContentApiForms {
925
+ issueToken(input: {
926
+ site: string;
927
+ formId: string;
928
+ purpose: 'render' | 'visitor';
929
+ visitorAddress: string | null;
930
+ }): Promise<ContentApiFormTicket | null | 'rate_limited'>;
931
+ submit(input: {
932
+ site: string;
933
+ formId: string;
934
+ body: unknown;
935
+ visitorAddress: string | null;
936
+ }): Promise<ContentApiFormSubmitResult>;
937
+ }
938
+ /**
939
+ * Largest submission body accepted. A form has at most 100 fields and its
940
+ * longest answer is 5,000 characters; this is room for a long message and a
941
+ * CAPTCHA token, and well short of anything worth parsing for a flood.
942
+ */
943
+ declare const MAX_FORM_BODY_BYTES: number;
944
+ //#endregion
945
+ //#region src/limits.d.ts
946
+ interface ContentApiLimits {
947
+ /**
948
+ * Max requests IN FLIGHT at once, per instance. NOT a connection ceiling —
949
+ * see the module docblock. Default {@link DEFAULT_MAX_CONCURRENT_REQUESTS}.
950
+ */
951
+ maxConcurrentRequests?: number;
952
+ /**
953
+ * Max requests queued waiting for a slot before the guard sheds 503, per
954
+ * instance. Default {@link DEFAULT_MAX_QUEUED_REQUESTS}.
955
+ */
956
+ maxQueuedRequests?: number;
957
+ /** Per-request wall clock in ms. Default {@link DEFAULT_REQUEST_TIMEOUT_MS}. */
958
+ requestTimeoutMs?: number;
959
+ /**
960
+ * Fairness budget applied to UNCREDENTIALED traffic, PER INSTANCE.
961
+ *
962
+ * Only consumed when a distinguishing key exists — which for anonymous
963
+ * traffic means {@link getClientIp} is wired. Without it the fairness limit is
964
+ * skipped entirely rather than collapsed onto one shared bucket (F011).
965
+ */
966
+ anonymousTokensPerInstance?: number;
967
+ /**
968
+ * Interval for every fairness budget on this surface, in ms. Default 60_000.
969
+ */
970
+ intervalMs?: number;
971
+ /**
972
+ * Ceiling on any credential's own budget, PER INSTANCE. A credential
973
+ * presenting a larger `rateBudgetPerInstance` is clamped to this. C5: the
974
+ * ceiling moves by operator action, never by request parameter.
975
+ */
976
+ maxBudgetPerInstance?: number;
977
+ /**
978
+ * How the host extracts a trustworthy client IP — the SECOND fairness key,
979
+ * used when no server-verified principal identity exists.
980
+ *
981
+ * Deliberately optional and deliberately host-supplied: only the deployment
982
+ * knows whether it sits behind a proxy it trusts, and a package-side default
983
+ * would either trust a forgeable header or invent a constant. Returning
984
+ * `undefined` (or omitting this) means anonymous traffic has NO distinguishing
985
+ * key, so the fairness limit is SKIPPED for it. That is the correct outcome,
986
+ * not a degraded one.
987
+ */
988
+ getClientIp?: (req: Request) => string | undefined;
989
+ /**
990
+ * The fairness counter's backing store (D028 part 3). Defaults to an
991
+ * in-memory, per-instance one. Supply a shared implementation only if a global
992
+ * quota is genuinely needed; see the module docblock.
993
+ */
994
+ store?: RateLimitStore;
995
+ }
996
+ //#endregion
997
+ //#region src/media.d.ts
998
+ /**
999
+ * One generated image file — a `srcset` candidate. `width`/`height` are the
1000
+ * file's own, never the ladder rung that was requested (images D003).
1001
+ */
1002
+ interface MediaSource {
1003
+ url: string;
1004
+ width: number;
1005
+ height: number;
1006
+ }
1007
+ /**
1008
+ * One media item as it reaches the wire.
1009
+ *
1010
+ * Structurally identical to `@murumets-ee/media`'s `PublicMediaProjection`; the
1011
+ * app that wires the two is where TypeScript checks they agree, exactly as for
1012
+ * `ContentApiResolution` and `ContentApiMenus`.
1013
+ */
1014
+ interface MediaProjection {
1015
+ id: string;
1016
+ /** A PUBLIC url. Never signed — see {@link ContentApiConfig.resolveMedia}. */
1017
+ url: string;
1018
+ mimeType: string | null;
1019
+ width: number | null;
1020
+ height: number | null;
1021
+ /** Translatable on the Media entity — this projection is locale-dependent. */
1022
+ alt: string | null;
1023
+ /** Translatable on the Media entity — this projection is locale-dependent. */
1024
+ title: string | null;
1025
+ /** Variant urls by image-style name, inlined as data (D008: a client derives nothing). */
1026
+ variants: Record<string, string>;
1027
+ /**
1028
+ * Every generated file by SHAPE name (`natural` = the photograph's own ratio),
1029
+ * ascending by width, each with the width and height the FILE actually has —
1030
+ * what a `srcset` is built from (images D007, lumi-cms#1160). A shape the
1031
+ * photograph has no files for yet is absent. Bounded by configuration, like
1032
+ * `variants`.
1033
+ */
1034
+ sources: Record<string, MediaSource[]>;
1035
+ /**
1036
+ * The AVIF copy of `sources`, by the same shape names (images PR12), for a
1037
+ * `<picture><source type="image/avif">`. ABSENT — never `{}` — when there is
1038
+ * none: AVIF is off (the default) or the catch-up has not reached the
1039
+ * photograph, so a site that has not turned it on sends what it did before.
1040
+ * A shape is listed only when its AVIF files have exactly the widths of its
1041
+ * `sources` list. Never an AVIF file in `sources` itself.
1042
+ */
1043
+ avifSources?: Record<string, MediaSource[]>;
1044
+ /**
1045
+ * Where the subject is, as `{ x, y }` fractions of the image (images D002).
1046
+ * ALWAYS present — `{ x: 0.5, y: 0.5 }` for a row that has none — so no
1047
+ * consumer branches on its absence (D010). A shaped slot crops around it; the
1048
+ * `object-position` it needs depends on the slot's own ratio, so it is
1049
+ * computed at render and never published here (`@murumets-ee/media/crop`).
1050
+ */
1051
+ focalPoint: {
1052
+ x: number;
1053
+ y: number;
1054
+ };
1055
+ /**
1056
+ * The blurred preview a render draws while the photograph loads — a
1057
+ * `data:image/webp` or `data:image/png` base64 URL of a tiny image of it, or
1058
+ * `null` when there is none (images PR11). Validated by the provider before
1059
+ * it gets here; a consumer that writes it into a `style` validates again
1060
+ * (`@murumets-ee/blocks` does).
1061
+ */
1062
+ blurDataUrl: string | null;
1063
+ }
1064
+ /**
1065
+ * Host-injected media resolver. Ids that do not exist, are unpublished for this
1066
+ * caller, or whose file is not public must be ABSENT from the returned map —
1067
+ * one absence, several causes, per phase 05's failure semantics.
1068
+ */
1069
+ type ResolveMedia = (ids: readonly string[], opts?: {
1070
+ locale?: string | undefined;
1071
+ }) => Promise<Map<string, MediaProjection>>;
1072
+ //#endregion
1073
+ //#region src/references.d.ts
1074
+ /**
1075
+ * What a reference resolves to by default.
1076
+ *
1077
+ * `title` and `href` are nullable INDEPENDENTLY of the projection itself: the
1078
+ * target resolved (so `id`/`entityType` are real), but the entity may have no
1079
+ * usable title field, or may not be routable, in which case there is no href to
1080
+ * give. That is a different outcome from "the target did not resolve", which is
1081
+ * an absent projection — see {@link projectReferences}.
1082
+ */
1083
+ interface ReferenceProjection {
1084
+ id: string;
1085
+ entityType: string;
1086
+ title: string | null;
1087
+ href: string | null;
1088
+ }
1089
+ /**
1090
+ * Host-injected routing seam — a STRUCTURAL mirror of
1091
+ * `@murumets-ee/blocks`' `ResolveEntityHref`. Redeclared rather than imported
1092
+ * for exactly the reason spelled out on `ContentResolveResult`: `content-api`
1093
+ * is a leaf and `blocks` depends back on it, so the edge is a build cycle. The
1094
+ * app wires ONE implementation into both.
1095
+ *
1096
+ * **This one does not need a publish filter, and that is a property rather than
1097
+ * an oversight.** It is only ever asked about ids that already came back from a
1098
+ * published-only `QueryClient` read, so an unpublished target never reaches it.
1099
+ * The same seam wired into `resolveLinks` — which resolves arbitrary
1100
+ * editor-authored link targets — DOES need one, because nothing upstream of it
1101
+ * has proven the target visible.
1102
+ */
1103
+ type ResolveEntityHref = (target: {
1104
+ entity: string;
1105
+ id: string;
1106
+ locale?: string;
1107
+ }) => string | null | Promise<string | null>;
1108
+ //#endregion
1109
+ //#region src/response.d.ts
1110
+ /**
1111
+ * Response helpers — JSON envelope builders.
1112
+ */
1113
+ /**
1114
+ * The COMPLETE machine-readable error-code set this API can emit.
1115
+ *
1116
+ * Declared ONCE, here, beside the only function that can produce one: the
1117
+ * compiler refuses a code that isn't in this tuple, and `GET /_schema` publishes
1118
+ * the tuple verbatim. So a new code cannot be introduced without landing in both
1119
+ * the type system and the contract in the same edit — there is no second,
1120
+ * hand-kept list to forget (the drift shape D031 makes a hard requirement, and
1121
+ * the mechanism behind the SDK that drifted 4.5 months undetected).
1122
+ *
1123
+ * `code` is machine contract; `message` is not, and is free to be specific.
1124
+ */
1125
+ declare const ERROR_CODES: readonly ["NOT_FOUND", "VALIDATION_ERROR", "INTERNAL_ERROR", "RATE_LIMITED", "SERVICE_OVERLOADED", "FORM_INVALID", "CHALLENGE_FAILED", "PAYLOAD_TOO_LARGE"];
1126
+ /** One of {@link ERROR_CODES}. */
1127
+ type ContentApiErrorCode = (typeof ERROR_CODES)[number];
1128
+ //#endregion
1129
+ //#region src/search.d.ts
1130
+ /** Results returned when the caller sends no `?limit=`. */
1131
+ declare const DEFAULT_SEARCH_RESULTS = 20;
1132
+ /** Most results one search returns — in total, and per type. A 400 above it, never a clamp. */
1133
+ declare const MAX_SEARCH_RESULTS = 50;
1134
+ /** One search result, as it crosses the wire. */
1135
+ interface SearchHit {
1136
+ /** The entity type — a name a frontend may map to its own words. */
1137
+ type: string;
1138
+ /** The type's English singular label — the fallback for a frontend with no words of its own. */
1139
+ typeLabel: string;
1140
+ id: string;
1141
+ title: string;
1142
+ summary: string | null;
1143
+ image: MediaProjection | null;
1144
+ /** Locale-relative canonical path, or `null` when the document has none. */
1145
+ path: string | null;
1146
+ }
1147
+ /**
1148
+ * The header a site's own frontend forwards its VISITOR's address on.
1149
+ *
1150
+ * Honoured ONLY from a caller whose credential is bound to a site — the site
1151
+ * key a frontend holds server-side. That is what makes it more than obscurity
1152
+ * (`core/limits/client-ip.ts` rule 7): the guarantee that "nobody reaches this
1153
+ * without passing through what writes it" is not a deploy-topology claim here,
1154
+ * it is the credential. An anonymous caller sending it is ignored.
1155
+ */
1156
+ declare const VISITOR_ADDRESS_HEADER = "x-lumi-visitor-address";
1157
+ /** Tuning for {@link SearchRateLimiter}. Every field optional. */
1158
+ interface ContentApiSearchConfig {
1159
+ /** Searches one visitor may run per window, PER INSTANCE. Default 30. */
1160
+ searchesPerVisitorPerInstance?: number;
1161
+ /** The window, in ms. Default 60 000. */
1162
+ intervalMs?: number;
1163
+ /** The counter's store. Defaults to an in-memory, per-instance one. */
1164
+ store?: RateLimitStore;
1165
+ /**
1166
+ * The client address of an ANONYMOUS caller — a browser fetching `_search`
1167
+ * itself, or a site that sends no key. Overrides `limits.getClientIp` for
1168
+ * the search budget only, so a mount can bound searches per address without
1169
+ * also turning on the surface-wide fairness limit. Use the toolkit's one
1170
+ * derivation (`proxyClientIp` from `@murumets-ee/search/admin`, or
1171
+ * `createClientIpResolver` from `@murumets-ee/core/limits`).
1172
+ */
1173
+ getClientIp?: (req: Request) => string | undefined;
1174
+ }
1175
+ //#endregion
1176
+ //#region src/types.d.ts
1177
+ /** CORS configuration — deny-by-default, explicit origin allowlist. */
1178
+ interface CorsConfig {
1179
+ /** Allowed origins. Each matched exactly — no regex, no wildcards. */
1180
+ allowedOrigins: string[];
1181
+ /** Preflight cache max age (seconds). Default: 3600 */
1182
+ maxAge?: number;
1183
+ }
1184
+ /** Per-entity cache override. */
1185
+ interface EntityCacheConfig {
1186
+ /**
1187
+ * This entity's total staleness window, in seconds — see
1188
+ * {@link CacheConfig.maxStaleSeconds}. Overrides the surface default.
1189
+ */
1190
+ maxStaleSeconds?: number;
1191
+ /** Whether this entity is cacheable. Default: true. Set false → no-store. */
1192
+ cacheable?: boolean;
1193
+ }
1194
+ /** Global cache configuration. */
1195
+ interface CacheConfig {
1196
+ /**
1197
+ * The TOTAL staleness window, in seconds: the worst case between a publish
1198
+ * and a visitor seeing it, with a shared cache in front. Default 360.
1199
+ *
1200
+ * **ONE number, because the sum is the number that is true (D036).** This
1201
+ * field replaced a pair — `defaultMaxAge` (60) and
1202
+ * `defaultStaleWhileRevalidate` (300) — whose real window was their SUM, and
1203
+ * nothing in the names, the type, the JSDoc or the emitted header said so. An
1204
+ * operator who set `defaultMaxAge: 10` believing they had bought a ten-second
1205
+ * window had bought 310, because the other field was untouched at its default.
1206
+ * Neither live mount passed a `cache` config at all, so 360 was what every
1207
+ * deployment in this repo served, chosen by nobody (F032).
1208
+ *
1209
+ * The `max-age` / `stale-while-revalidate` split is now a package-owned
1210
+ * derivation the operator does not tune, so **the sum of what is emitted
1211
+ * always equals what was declared** — there is no way to write this
1212
+ * configuration that expresses a window the deployment does not honour. Same
1213
+ * move D028 made renaming a rate budget `perInstance`: prefer the shape that
1214
+ * cannot be wrong over the check that has to be right.
1215
+ *
1216
+ * `stale-while-revalidate` extends freshness ADDITIVELY beyond `max-age` (RFC
1217
+ * 5861 §3, whose own worked example is `max-age=600, stale-while-revalidate=600`
1218
+ * → "up to 20 minutes"). R15 verified this rather than assuming it.
1219
+ *
1220
+ * **S6 is an ORIGIN invariant.** The origin serves a published edit on the very
1221
+ * next request; any shared cache converts that into "visible within this
1222
+ * window". An operator who needs S6 verbatim sets this to `0` and gets
1223
+ * `no-store` out of the same field — no second knob, no code change, no
1224
+ * per-entity opt-out to remember.
1225
+ */
1226
+ maxStaleSeconds?: number;
1227
+ /** Per-entity overrides, keyed by entity name. */
1228
+ entities?: Record<string, EntityCacheConfig>;
1229
+ /**
1230
+ * Cache-tag emission (D055, PR 11) — the header only, never a purge protocol.
1231
+ *
1232
+ * Every STORABLE response carries tags naming what composed it:
1233
+ * `lumi:<entity>:<id>` for a row whose content is in the body, and
1234
+ * `lumi:<entity>` for a response that depends on WHICH rows exist. A consumer
1235
+ * purges by tag on write; nothing in this repo does yet, so **TTL expiry
1236
+ * ({@link maxStaleSeconds}) remains the entire reconciliation mechanism with
1237
+ * S6 until one does.** What this buys today is that wiring it later needs no
1238
+ * response-format change (R05's asymmetry).
1239
+ *
1240
+ * Omit for the default: emit, under `Cache-Tag`, comma-separated. See
1241
+ * `cache-tags.ts` for the format, its bounds and the disclosure invariant
1242
+ * that makes raw ids defensible on a public header.
1243
+ */
1244
+ tags?: CacheTagsConfig;
1245
+ }
1246
+ /**
1247
+ * Routing resolution result — a STRUCTURAL mirror of `@murumets-ee/content`'s
1248
+ * `ResolveResult`. Redeclared locally (like the wire-format envelopes above) so
1249
+ * `content-api` stays a ZERO-`@murumets-ee/content` leaf: a static dep on
1250
+ * `@murumets-ee/content` is IMPOSSIBLE here — `content` transitively depends
1251
+ * back on `content-api` (`content → blocks → content-api/query-params`), so
1252
+ * the edge forms a hard `turbo` build cycle. The resolution functions are
1253
+ * therefore INJECTED by the app (which already depends on `content`), never
1254
+ * imported — mirroring the entity-package leaf rule
1255
+ * (CLAUDE.md: "receives everything cross-package via constructor parameters").
1256
+ */
1257
+ type ContentResolveResult = {
1258
+ kind: 'document';
1259
+ entityType: string;
1260
+ id: string;
1261
+ } | {
1262
+ kind: 'redirect';
1263
+ to: string;
1264
+ status: 301 | 302;
1265
+ } | {
1266
+ kind: 'none';
1267
+ };
1268
+ /** One enumerated canonical path — structural mirror of `content`'s `PathEntry`. */
1269
+ interface ContentPathEntry {
1270
+ /** Locale-relative canonical path (leading '/', no trailing '/'). */
1271
+ path: string;
1272
+ /** The routable entity type name. */
1273
+ entityType: string;
1274
+ /** The DOCUMENT id (entity primary key). Also the opaque `_paths` cursor token. */
1275
+ id: string;
1276
+ /** Last-modified timestamp (sitemap `<lastmod>` etc.). */
1277
+ updatedAt: Date;
1278
+ }
1279
+ /**
1280
+ * Injected routing-resolution providers (routing PR07). Supplied by the APP when
1281
+ * it wants `content-api` to serve `GET /_resolve`, `/_paths`, `/_alternates`;
1282
+ * omit and those three endpoints stay 404 (opt-in, deny-by-default). The app
1283
+ * wires these from `@murumets-ee/content/resolve` — e.g.
1284
+ * `resolvePath(path, locale, buildResolveDeps(getApp()))` — because it can depend
1285
+ * on `@murumets-ee/content` where this leaf package cannot (see
1286
+ * {@link ContentResolveResult} for the cycle rationale).
1287
+ *
1288
+ * **SECURITY (CLAUDE.md #1).** `resolvePath` MUST be the PUBLISHED-only resolver
1289
+ * — NEVER a draft/preview resolver that skips publish checks, which would expose
1290
+ * unpublished documents by URL. This package cannot enforce the choice (it never
1291
+ * imports the resolution core — DI), so the WIRING must guarantee it.
1292
+ */
1293
+ interface ContentApiResolution {
1294
+ /** Resolve `(path, locale)` → document / redirect / none. PUBLISHED-only. */
1295
+ resolvePath: (path: string, locale: string) => Promise<ContentResolveResult>;
1296
+ /**
1297
+ * Stream ONE entity type's PUBLISHED canonical paths for `locale`, keyset-
1298
+ * resumable. `afterDocumentId` is a {@link ContentPathEntry.id} (a DOCUMENT
1299
+ * id) — the stream resumes strictly AFTER it. Named distinctly from content's
1300
+ * own `EnumeratePathsOptions.afterId` (a `toolkit_paths` ROW PK, a DIFFERENT
1301
+ * UUID domain) so the required translation is glaring at the provider's own
1302
+ * call site — the provider owns mapping this document id onto that row-PK
1303
+ * keyset, once per page, never per row.
1304
+ */
1305
+ enumeratePaths: (opts: {
1306
+ locale: string;
1307
+ entityType: string;
1308
+ afterDocumentId?: string;
1309
+ }) => AsyncIterable<ContentPathEntry>;
1310
+ /** Published per-locale canonical URLs for a document (the hreflang set). */
1311
+ getAlternates: (entityType: string, id: string) => Promise<Array<{
1312
+ locale: string;
1313
+ path: string;
1314
+ }>>;
1315
+ }
1316
+ /**
1317
+ * One resolved menu item — a STRUCTURAL mirror of `@murumets-ee/menus`'
1318
+ * `ResolvedMenuItem`. Redeclared rather than imported for exactly the reason
1319
+ * spelled out on {@link ContentResolveResult}: `content-api` is a leaf, and the
1320
+ * resolver is INJECTED by the app (which can depend on `@murumets-ee/menus`)
1321
+ * rather than imported here.
1322
+ *
1323
+ * The resolver has already applied every filter (unpublished / deleted targets
1324
+ * dropped, per-locale visibility, empty headers pruned, depth clamped), so this
1325
+ * is a render-ready projection — there is nothing left for the handler to gate
1326
+ * per item, and deliberately no `entityType` on the wire.
1327
+ */
1328
+ interface ContentApiResolvedMenuItem {
1329
+ id: string;
1330
+ kind: 'link' | 'header';
1331
+ /** Already resolved for the requested locale (override, else live title). */
1332
+ label: string;
1333
+ /** Canonical path (+ `#anchor`), external target verbatim, or `null` for headers. */
1334
+ href: string | null;
1335
+ newTab: boolean;
1336
+ isActive: boolean;
1337
+ inActiveTrail: boolean;
1338
+ children: ContentApiResolvedMenuItem[];
1339
+ }
1340
+ /** A resolved menu — structural mirror of `@murumets-ee/menus`' `ResolvedMenu`. */
1341
+ interface ContentApiResolvedMenu {
1342
+ handle: string;
1343
+ items: ContentApiResolvedMenuItem[];
1344
+ }
1345
+ /**
1346
+ * Injected menu provider (routing PR09). Supplied by the APP when it wants
1347
+ * `content-api` to serve `GET /_menu`; omit and that endpoint stays 404 (opt-in
1348
+ * — deny-by-default). The app wires this from `@murumets-ee/menus/resolve` —
1349
+ * e.g. `getMenu(handle, opts, buildMenuResolveDeps(getApp()))`.
1350
+ *
1351
+ * **SECURITY (CLAUDE.md #1).** {@link ContentApiMenus.handles} IS the gate, and
1352
+ * it is the ONLY one: a handle not on the list 404s before `getMenu` is ever
1353
+ * called, and a listed handle is served to any caller the CORS/auth layer let
1354
+ * through — there is no per-entity `view` check, because a resolved menu item
1355
+ * carries no entity identity to check against (see
1356
+ * {@link ContentApiResolvedMenuItem}). Listing a handle here is therefore an
1357
+ * explicit "this navigation is public" decision, structurally the same opt-in
1358
+ * as putting an entity in {@link ContentApiConfig.entities}.
1359
+ *
1360
+ * **What a listed handle exposes — read this before listing one.** The TITLES +
1361
+ * canonical paths of any PUBLISHED, ROUTABLE document the menu's items resolve
1362
+ * to, REGARDLESS of whether that document's entity type appears in
1363
+ * {@link ContentApiConfig.entities}. The per-entity whitelist governs the
1364
+ * `/:entity` routes; it does NOT govern `_menu`'s title resolution, which reads
1365
+ * live titles from the app's routable-entity registry. So an app whose routable
1366
+ * set is broader than its content-api whitelist should be aware that `_menu` can
1367
+ * surface titles from types every other endpoint 404s. Note this is strictly
1368
+ * more than a public sitemap enumerates — a sitemap carries `loc` + `lastmod`,
1369
+ * never titles. Unpublished, trashed and unroutable targets are still dropped by
1370
+ * the resolver, and per-locale-hidden items never appear. Do not list a handle
1371
+ * whose labels or linked titles are themselves sensitive. (Narrowing this
1372
+ * structurally — a per-handle `requires` permission — is recorded as a deferred
1373
+ * option in plan/routing's F041; today the handle list is the whole gate.)
1374
+ *
1375
+ * `getMenu` MUST be the published-only resolver — it must never be wired to a
1376
+ * draft/preview variant, which would leak unpublished documents by URL. This
1377
+ * package cannot enforce that (it never imports the resolver — DI), so the
1378
+ * WIRING must guarantee it.
1379
+ *
1380
+ * **`getMenu`'s inputs are `(handle, { locale, currentPath })` and nothing
1381
+ * principal-derived — keep it that way.** Because the body cannot vary per
1382
+ * caller, `_menu` answers every caller with the SAME cacheable headers, which is
1383
+ * what stops the header shape from advertising that a credential verified on an
1384
+ * endpoint with no permission gate. A provider that started varying its output
1385
+ * by caller would silently break that; see `handleMenu`'s JSDoc for what must
1386
+ * change first if it ever does.
1387
+ */
1388
+ interface ContentApiMenus {
1389
+ /**
1390
+ * Menu handles this instance serves. Deny-by-default — an unlisted handle
1391
+ * 404s before {@link ContentApiMenus.getMenu} is called.
1392
+ */
1393
+ handles: readonly string[];
1394
+ /** Resolve one menu for a locale. `null` → unknown or unpublished menu. */
1395
+ getMenu: (handle: string, opts: {
1396
+ locale: string;
1397
+ currentPath?: string;
1398
+ }) => Promise<ContentApiResolvedMenu | null>;
1399
+ }
1400
+ /**
1401
+ * One composed chrome region.
1402
+ *
1403
+ * `blocks` is `BlockInstance[]` — the same block shape a page's own `layout`
1404
+ * tree reaches the wire as, so a consumer needs ONE renderer rather than two.
1405
+ * It is typed `unknown[]` here for the leaf reason that governs this whole
1406
+ * file: `content-api` cannot import `@murumets-ee/blocks` (blocks depends back
1407
+ * on it, so the edge is a build cycle).
1408
+ *
1409
+ * **The provider MUST have stripped the composition's scope tags** (D027).
1410
+ * `composeChromeRegion` tags every top-level block with `scope`, `scopeDocId`
1411
+ * and `scopeBlockId` because `applyOverrides` REQUIRES the tagging as a
1412
+ * precondition — hand it an untagged document and it returns the blocks
1413
+ * unchanged with no error and zero overrides applied. So the pipeline is tag →
1414
+ * apply → strip, and the strip happens in the provider, through the existing
1415
+ * `stripScopeTags` rather than a second implementation.
1416
+ *
1417
+ * This is not a data leak and should not be described as one: `scopeBlockId` is
1418
+ * the block's own id, `scopeDocId` the chrome row id, `scope` the literal
1419
+ * `'site'`. The cost is to the CONTRACT — three fields `_schema` cannot
1420
+ * describe, and `scopeBlockId` is the key `_overrides` is written under, so
1421
+ * serialising it hands a consumer the override map's key space immediately
1422
+ * after D011 banned the map.
1423
+ */
1424
+ interface ContentApiChromeRegion {
1425
+ /** Composed, override-applied, scope-tag-stripped block instances. */
1426
+ blocks: unknown[];
1427
+ /**
1428
+ * Does this document's chrome differ from the site default?
1429
+ *
1430
+ * Rides along because it already exists and already answers the one question
1431
+ * a consumer has that the blocks themselves do not. It is a boolean about
1432
+ * LAYOUT, not editorial vocabulary, so it does not reopen D011.
1433
+ */
1434
+ overridden: boolean;
1435
+ /**
1436
+ * The three {@link ResolvedBlockTree} fields, MERGED in rather than nested,
1437
+ * and present only when a `resolveBlocks` provider ran over this region.
1438
+ *
1439
+ * Merged so that a chrome region and a document's own `blockTree` field are
1440
+ * read by ONE consumer-side decoder — `frontend-delivery` D019's answer to
1441
+ * `plan/api` finding 075 requirement 5 ("`_chrome` and document layout should
1442
+ * use one format, not two"). Nesting would have made `blocks` stop being an
1443
+ * array on the resolved path, which is a second shape for the same idea.
1444
+ *
1445
+ * Absent — not empty — when nothing resolved them, for the same reason
1446
+ * {@link ResolvedBlockTree} is not emitted empty: an unconditional empty
1447
+ * container cannot be told apart from "resolution never ran".
1448
+ *
1449
+ * ⚠️ **One case emits `resolved: 1` with an EMPTY `blocks`** — editor D010's
1450
+ * third state, where a provider failed and then failed its I/O-free retry, so
1451
+ * the region is blanked rather than served as stored. A consumer that treats
1452
+ * an empty region as "the page deliberately hid this" must check `resolved`
1453
+ * to tell the two apart: a genuinely hidden region never carries it, because
1454
+ * `resolveRegion` returns before calling the provider when a region has no
1455
+ * blocks to walk. It is the only discriminator available, and it exists only
1456
+ * here — a document's own `blockTree` field has no equivalent, because an
1457
+ * empty stored tree IS handed to the provider and comes back as an envelope.
1458
+ *
1459
+ * Be precise about what it rests on. The argument above is airtight for a
1460
+ * genuinely HIDDEN region. It does not bind a wired provider that answers a
1461
+ * NON-empty region with an empty `blocks` array: `isResolvedBlockTree` only
1462
+ * requires an array, so that passes, and the rebuilt region is byte-identical
1463
+ * to a blanked one. The shipped resolver never does it (it returns the array
1464
+ * it walked), and a provider that deletes a region's content is broken in a
1465
+ * way "blanked" describes fairly — but a consumer reading this should know
1466
+ * the discriminator is a convention the provider keeps, not a structure the
1467
+ * server enforces.
1468
+ */
1469
+ resolved?: ResolvedBlockTree['resolved'];
1470
+ links?: ResolvedBlockTree['links'];
1471
+ unresolved?: ResolvedBlockTree['unresolved'];
1472
+ /**
1473
+ * The rows this region's blocks resolved against — see
1474
+ * {@link ResolvedBlockTree.refs}.
1475
+ *
1476
+ * 🔴 It is here because leaving it out made the CHROME half of block-tag
1477
+ * coverage dead code, silently. `resolveRegion` rebuilds the region field by
1478
+ * field rather than spreading the envelope, so a field this type does not
1479
+ * name is a field the wire does not carry — and the case that matters most is
1480
+ * exactly a chrome one: a LOGO, which every page renders and which no tag
1481
+ * named. Nothing failed; the frontend's ref loop simply had an empty array to
1482
+ * iterate, forever.
1483
+ */
1484
+ refs?: ResolvedBlockTree['refs'];
1485
+ /**
1486
+ * The rows this region's COLLECTION blocks resolved to — see
1487
+ * {@link ResolvedBlockTree.collections} (editor PR53). Named here for the
1488
+ * reason `refs` is: a field this type omits is a field `resolveRegion` drops,
1489
+ * and a collection in a footer would otherwise resolve, cost its reads, and
1490
+ * reach the frontend as nothing.
1491
+ */
1492
+ collections?: ResolvedBlockTree['collections'];
1493
+ /**
1494
+ * What this region's live-bound Price fields resolved to, and the site's
1495
+ * currency — see {@link ResolvedBlockTree.live} / {@link
1496
+ * ResolvedBlockTree.currency} (block-library F012). Named here for the same
1497
+ * reason `refs`/`collections` are: a field this type omits is a field
1498
+ * `resolveRegion` drops, and a header's live-bound price (a site header can
1499
+ * carry one as readily as a page body) would otherwise resolve, cost its
1500
+ * read, and reach the frontend as nothing.
1501
+ */
1502
+ live?: ResolvedBlockTree['live'];
1503
+ currency?: ResolvedBlockTree['currency'];
1504
+ }
1505
+ /**
1506
+ * The named regions. Each key IS a chrome document's `placement` value — the
1507
+ * same string, so the provider maps one to the other by identity rather than
1508
+ * through a lookup table, and a reader of either can predict the other.
1509
+ *
1510
+ * That is what stops them drifting, and it replaces a three-way mirror (these
1511
+ * keys, the legacy layout entity's four columns, and `SiteLayoutSnapshot`'s
1512
+ * four arrays in the since-deleted multi-scope composer) that required a reader
1513
+ * to keep three lists in their head.
1514
+ *
1515
+ * 🔴 **This is a WIRE BREAK as of PR30.** Two more keys — a before-content and
1516
+ * an after-content region — were part of this shape and are gone. Nothing could
1517
+ * ever fill them — no document form, no editor, no writer outside one test
1518
+ * fixture — and both customer sites left them empty. A consumer built against
1519
+ * the four-key shape must drop them: `apps/front-astro` THROWS on a region key
1520
+ * it asked for and did not get (`src/lib/content-api.ts`), so a stale front
1521
+ * against a new server fails every page render rather than degrading.
1522
+ *
1523
+ * **`null` and `[]` are DIFFERENT ANSWERS and must not be conflated.** `null`
1524
+ * means "no chrome document — render your own fallback"; an empty `blocks`
1525
+ * array means "the page hid everything — render nothing at all". Collapsing
1526
+ * both to `[]` would silently turn an unseeded CMS into a site that renders a
1527
+ * default header it was told to suppress.
1528
+ */
1529
+ interface ContentApiChromeRegions {
1530
+ header: ContentApiChromeRegion | null;
1531
+ footer: ContentApiChromeRegion | null;
1532
+ }
1533
+ /**
1534
+ * Injected site-chrome provider (plan/api phase 07). When present the handler
1535
+ * serves `GET /_chrome`; omit and it stays 404 — opt-in, deny-by-default, the
1536
+ * same shape as {@link ContentApiResolution} and {@link ContentApiMenus}.
1537
+ *
1538
+ * **Chrome is a PROJECTION, not an entity (D026).** Overrides are per-PAGE:
1539
+ * `_overrides` lives on the page and is applied to the CHROME document, so "the
1540
+ * chrome for this document" is a function of (chrome row, requesting document,
1541
+ * locale) — the same stored row yields different bytes depending on who asks.
1542
+ * Serving it at `/v1/site_chrome` would either hand back an uncomposed row
1543
+ * (which D011 forbids) or grow the entity route a document parameter, making
1544
+ * one route two things.
1545
+ *
1546
+ * **SECURITY (CLAUDE.md #1).** Two gates, both operator actions, both
1547
+ * deny-by-default, and they are DIFFERENT decisions:
1548
+ * 1. Injecting this provider makes the endpoint MOUNTABLE.
1549
+ * 2. Granting `view` on {@link ContentApiChrome.entity} to the requesting role
1550
+ * makes it READABLE. The handler runs `checker(role, entity, 'view')` and
1551
+ * 404s otherwise — never 403 (D006).
1552
+ *
1553
+ * Note the entity's own `access` map is NOT the gate. Entity-level `access` is
1554
+ * advisory (#616, D020), and the roles table is what `buildPermissionChecker`
1555
+ * reads — so an operator who lowers `viewRole` and stops there gets 404s with
1556
+ * no explanation. Grant `site_chrome:view` in the Permission Matrix.
1557
+ *
1558
+ * Since PR30 that is also the ONLY grant this endpoint needs. It used to be
1559
+ * three: a coarse gate on a standalone layout entity plus a `view` on each
1560
+ * document a region actually read from, so granting the coarse one alone
1561
+ * produced a response with an absent header — indistinguishable from a site
1562
+ * with no chrome. The gate and the source are now the same entity.
1563
+ *
1564
+ * `getChrome` MUST read through the published-only, locale-merging client path
1565
+ * — never a bespoke reader that special-cases the region columns. Two
1566
+ * implementations of "what does a missing translation mean" is how the answer
1567
+ * comes to differ in two places.
1568
+ */
1569
+ interface ContentApiChrome {
1570
+ /**
1571
+ * The entity backing chrome (`'site_chrome'`). Its `view` permission IS the
1572
+ * read gate — see the security note above. Declared rather than assumed so
1573
+ * this package can run the same one firewall it runs everywhere else.
1574
+ */
1575
+ entity: string;
1576
+ /**
1577
+ * Compose the chrome for a locale, optionally overridden by one document.
1578
+ *
1579
+ * `null` means NO CHROME DOCUMENT EXISTS — the handler renders every region
1580
+ * as `null`, which a consumer reads as "fall back to your own chrome". It
1581
+ * does NOT mean "empty".
1582
+ *
1583
+ * `document` is absent whenever the caller named none, OR named one that does
1584
+ * not exist / is outside the whitelist / is not viewable / is unpublished for
1585
+ * this principal. All four collapse to the site default plus a debug log —
1586
+ * that is what stops the parameter becoming a cheap existence oracle over
1587
+ * document ids.
1588
+ *
1589
+ * `budget` is the response's ONE resolution budget. Chrome's blocks are not a
1590
+ * fifth kind of work and do not get a ceiling of their own: they compete for
1591
+ * the same connection pool as the document's own references, media and links.
1592
+ */
1593
+ getChrome: (opts: {
1594
+ locale: string;
1595
+ document?: {
1596
+ entityType: string;
1597
+ id: string;
1598
+ };
1599
+ budget: ResolutionBudget;
1600
+ }) => Promise<ContentApiChromeRegions | null>;
1601
+ }
1602
+ /**
1603
+ * The caller behind a request — an IDENTITY plus the role that governs what it
1604
+ * may reach. Returned by {@link ContentApiConfig.authenticate}.
1605
+ *
1606
+ * **Why this is not just a role (D016).** The handler runs every read inside a
1607
+ * `RequestContext` whose `user` flows into `QueryClient`'s `contextResolver`,
1608
+ * and from there into `resolveAuthContext` on every `findMany`/`findById`/
1609
+ * `count`. Until this type existed the hook could only resolve a role, so the
1610
+ * handler hardcoded `user: { id: 'public' }` for EVERY caller: two
1611
+ * authenticated principals were indistinguishable from each other and from
1612
+ * anonymous, and nothing keyed on identity — per-principal scoping,
1613
+ * owner-scoped rows, `tokenId` audit threading — could function at all.
1614
+ *
1615
+ * **`role` is what authorizes; `id` is what identifies.** Every gate on this
1616
+ * surface is `checker(role, entity, 'view')` — the id is never consulted for
1617
+ * access decisions here, and adding one must not change that: authorization
1618
+ * belongs to the one shared firewall, not to a second per-identity rule.
1619
+ *
1620
+ * **Resolving this is the HOST's job, and it is a trust boundary.** This
1621
+ * package never parses a credential; it calls the hook and believes the answer.
1622
+ * The hook must therefore verify the credential itself (signature/hash,
1623
+ * expiry, revocation) and return `null` — never a partially-trusted principal —
1624
+ * whenever verification fails.
1625
+ */
1626
+ interface ContentApiPrincipal {
1627
+ /**
1628
+ * The role every permission check runs against. Required: a principal with
1629
+ * no role can reach nothing, which is a refusal, and a refusal is spelled
1630
+ * `null`.
1631
+ */
1632
+ role: string;
1633
+ /**
1634
+ * Stable identity of the caller — a user id, or a service principal's id.
1635
+ *
1636
+ * OPTIONAL, and its absence is deliberately fail-closed: a hook that resolves
1637
+ * a role but no identity yields the ANONYMOUS identity rather than a
1638
+ * synthesised one, so anything keyed on `user.id` sees "not this principal"
1639
+ * instead of a plausible-looking guess. That is also what keeps the
1640
+ * pre-existing `{ role }`-only hook shape compiling and behaving unchanged.
1641
+ *
1642
+ * Must be the id of the principal the credential BELONGS to. A service
1643
+ * integration presents its own service-principal id, never the id of whoever
1644
+ * happened to mint its token — otherwise the token outlives its owner's
1645
+ * account and every action it takes is attributed to a person who did not
1646
+ * take it.
1647
+ */
1648
+ id?: string;
1649
+ /** Display name, if the host resolved one. Never used for authorization. */
1650
+ name?: string;
1651
+ /** Email, if the host resolved one. Never used for authorization. */
1652
+ email?: string;
1653
+ /**
1654
+ * This credential's own fairness budget: requests per window, PER INSTANCE.
1655
+ *
1656
+ * **The budget is a property of the CREDENTIAL, not of the surface (D029).**
1657
+ * D001 fixes a page at ~4 requests and D016 puts authenticated access in the
1658
+ * first `/v1/` release, so this plan's primary consumer is a FAN-IN POINT: one
1659
+ * credentialed SSR frontend serving an entire public website, where every
1660
+ * visitor becomes ~4 requests attributed to a SINGLE principal. A limit sane
1661
+ * for a human poking at the API throttles that frontend into uselessness at a
1662
+ * few dozen page views a minute; a limit sized for the frontend constrains
1663
+ * nobody. There is no value that is both — so the number travels with the
1664
+ * credential and is set when the credential is ISSUED.
1665
+ *
1666
+ * Clamped server-side to `ContentApiLimits.maxBudgetPerInstance` (C5): the
1667
+ * ceiling moves by operator action, never by a request parameter, and a
1668
+ * credential cannot raise its own.
1669
+ *
1670
+ * Omitted → the surface's uncredentialed default applies. Note this is the
1671
+ * budget only; whether a fairness limit is applied AT ALL still depends on a
1672
+ * distinguishing key existing (D025) — for a credentialed caller it does,
1673
+ * because `id` is server-verified.
1674
+ */
1675
+ rateBudgetPerInstance?: number;
1676
+ /**
1677
+ * The ONE site this credential reads (plan `multisite` D008) — a site
1678
+ * handle. Every endpoint then answers for that site only: its documents,
1679
+ * paths, menus and chrome, its origins for CORS, its segment in cache tags.
1680
+ *
1681
+ * **This is the only way a request gets a site on an app serving several**
1682
+ * (short of an ambient `runInSite` or the opt-in
1683
+ * {@link ContentApiConfig.siteForHost}). There is no `site` query parameter
1684
+ * and there will not be one — it is refused with a 400 on every route.
1685
+ *
1686
+ * The hook must take it from the VERIFIED credential (where it was written
1687
+ * at mint), never from the request. `@murumets-ee/auth`'s
1688
+ * `createContentApiAuthenticator` does. A value that is not a handle
1689
+ * collapses the whole principal to anonymous.
1690
+ *
1691
+ * A site-bound frontend key should be returned as `{ role: publicRole, site }`
1692
+ * with NO `id`: it then reads what an anonymous visitor of that site reads.
1693
+ * Its responses are cacheable by the HOLDER of the key only — they are sent
1694
+ * `Cache-Control: private` (and `Vary` on the declared
1695
+ * {@link ContentApiConfig.credentialHeaders}), so a cache shared between two
1696
+ * sites never stores them. Do not expect CDN hits in front of this API for
1697
+ * site-keyed traffic; the site's own frontend is where they are cached.
1698
+ */
1699
+ site?: string;
1700
+ }
1701
+ /** The body of `GET /_site` — see {@link ContentApiConfig.site}. */
1702
+ interface ContentApiSite {
1703
+ /** The site's handle — stable, the key everything per-site is filed under. */
1704
+ handle: string;
1705
+ /** Display name. */
1706
+ name: string;
1707
+ /** Locale codes this site serves. Never empty. */
1708
+ locales: string[];
1709
+ /** The locale an unprefixed / unlocalised request reads. One of `locales`. */
1710
+ defaultLocale: string;
1711
+ /** How locales appear in URLs: `prefix-all`, `no-prefix-default` or `cookie`. */
1712
+ routingStrategy: string;
1713
+ /** The site's public origins (`URL.origin` strings). */
1714
+ origins: string[];
1715
+ }
1716
+ /**
1717
+ * Host-supplied i18n answer for `GET /_site` (plan `multisite` D007). Injected
1718
+ * (not imported) so this stays a leaf package: the enabled-locale registry and
1719
+ * the single-site install's i18n setting live in `@murumets-ee/content`, which
1720
+ * wires this from `resolveSiteI18n`.
1721
+ */
1722
+ interface ContentApiSiteProvider {
1723
+ /**
1724
+ * The locales `site` serves, its default and its URL strategy. Called with
1725
+ * the handle the HANDLER resolved — never a caller-supplied one.
1726
+ */
1727
+ getI18n: (site: string) => Promise<{
1728
+ locales: readonly string[];
1729
+ defaultLocale: string;
1730
+ routingStrategy: string;
1731
+ }>;
1732
+ }
1733
+ /** Content API handler configuration. */
1734
+ interface ContentApiConfig {
1735
+ /** Entity whitelist — ONLY these entities are queryable. Deny-by-default. */
1736
+ entities: Entity[];
1737
+ /**
1738
+ * Permission checker — REQUIRED. Same pattern as admin API handler.
1739
+ * Called lazily on first request, result cached with 30s TTL.
1740
+ * Checks: `checker(role, entityName, 'view')` → boolean.
1741
+ *
1742
+ * To force a reload after a role change, call
1743
+ * `handler.invalidatePermissionCache()` from your role-management code path.
1744
+ */
1745
+ loadPermissions: () => Promise<PermissionChecker>;
1746
+ /**
1747
+ * Optional auth. Resolves the calling {@link ContentApiPrincipal}, or `null`
1748
+ * for anonymous. Absent hook, `null`, or a THROWN error all resolve to the
1749
+ * anonymous principal on `publicRole` — failure is never partial trust.
1750
+ *
1751
+ * Enables preview mode (e.g. bearer token → editor sees more entities) and,
1752
+ * because the principal carries an identity, anything that must vary per
1753
+ * caller rather than merely per role.
1754
+ *
1755
+ * **A principal that carries an `id`, OR a role other than `publicRole`,
1756
+ * forces `Cache-Control: no-store` on every entity route** so a shared CDN
1757
+ * cannot serve one principal's response to a subsequent anonymous visitor.
1758
+ * The two conditions are INDEPENDENT — either alone is enough — which is the
1759
+ * part a role-keyed rule gets wrong: `{ id, role: publicRole }`, a logged-in
1760
+ * visitor granted no extra role, is a legal principal whose `user.id` reaches
1761
+ * every read. That is what makes a principal-varying body safe to emit at all.
1762
+ *
1763
+ * The converse is the exact boundary, and it is deliberate: a return of
1764
+ * `{ role: publicRole }` with NO `id` has asserted neither an identity nor an
1765
+ * extra grant, so nothing about the response can vary per caller and it stays
1766
+ * publicly cacheable. Returning that shape is not a way to opt a caller into
1767
+ * `no-store`.
1768
+ *
1769
+ * **Setting this makes {@link ContentApiConfig.credentialHeaders} load-bearing.**
1770
+ * A shared cache has to know which request headers select between those two
1771
+ * bodies, and only the host knows which ones this hook reads. Leaving them
1772
+ * undeclared forces `no-store` on every entity response rather than storing
1773
+ * one under an unknown key.
1774
+ *
1775
+ * `GET /_menu` is the one exception, and deliberately so: its resolver takes
1776
+ * nothing principal-derived, so its body cannot vary per caller and it stays
1777
+ * cacheable for everyone. Applying the split there would have leaked
1778
+ * credential validity through the header shape on an endpoint with no
1779
+ * permission gate. See `handleMenu`'s JSDoc.
1780
+ *
1781
+ * **Denial is not this hook's job.** A credential that verifies but whose
1782
+ * role lacks `view` on the target is refused by the ENTITY firewall as a 404,
1783
+ * byte-identical to the refusal a caller who presented nothing receives
1784
+ * (D006) — so a credential can never become an oracle for which entities
1785
+ * exist. Stated exactly, because the property is narrower than "every
1786
+ * response": byte-identity holds for the REFUSALS on the entity routes and on
1787
+ * `_resolve`/`_paths`/`_alternates`, which share one 404 carrying a CONSTANT
1788
+ * `Cache-Control: no-store` and a per-request `x-request-id`. Neither weakens
1789
+ * the property, and the distinction is worth stating because both look like
1790
+ * they might. The directive is the same string for every caller, so it cannot
1791
+ * signal whether a credential verified. The correlation id varies per
1792
+ * REQUEST, not per principal — two refusals from the same caller differ in it
1793
+ * too — so it carries no information about who asked, and it is either the
1794
+ * caller's own value or random. `_menu` is never refused on a permission and answers every
1795
+ * caller alike, headers included. `_schema` is never refused either; it
1796
+ * publishes the ROLE-FILTERED view — identical to the anonymous one for a
1797
+ * role holding no `view`, deliberately larger for a role that holds some —
1798
+ * always under `no-store`. Return `null` for "I could not verify this
1799
+ * caller"; do not attempt to express "verified but unauthorized".
1800
+ */
1801
+ authenticate?: (req: Request) => Promise<ContentApiPrincipal | null>;
1802
+ /**
1803
+ * The request headers {@link ContentApiConfig.authenticate} READS — i.e. the
1804
+ * ones a shared cache must key on to tell one caller's stored response from
1805
+ * another's. Emitted verbatim in `Vary` beside `Origin`.
1806
+ *
1807
+ * ```ts
1808
+ * authenticate: async (req) => verify(req.headers.get('X-Preview-Token')),
1809
+ * credentialHeaders: ['X-Preview-Token'],
1810
+ * ```
1811
+ *
1812
+ * **Declared, because it cannot be inferred.** The hook is host-supplied and
1813
+ * may read any header; this package parses no credential and never sees which
1814
+ * one mattered. Hardcoding a guess (`Cookie, Authorization`) is wrong in both
1815
+ * directions: a host reading `X-Preview-Token` gets a public response stored
1816
+ * with nothing keyed on it, so the next request carrying only that header
1817
+ * matches the stored variant and **`authenticate` is never called** — the CDN
1818
+ * answers instead. (Fail-safe in disclosure terms — the PUBLIC body is what
1819
+ * gets replayed — but "the edge silently ignores your credential" is not a
1820
+ * property to leave to chance.)
1821
+ *
1822
+ * **Omitting it when `authenticate` IS set forces `Cache-Control: no-store`
1823
+ * on every entity response.** Deliberately fail-closed: a credential can
1824
+ * arrive, nothing says what selects it, so there is no correct variant key —
1825
+ * and an unkeyed cacheable response is exactly the replay above. Declare the
1826
+ * headers to get caching back.
1827
+ *
1828
+ * Setting it with NO `authenticate` hook throws at construction: nothing reads
1829
+ * a credential, so the only effect would be to fragment the cache.
1830
+ *
1831
+ * **The trade-off you are choosing.** `Vary` values are honoured unevenly by
1832
+ * real CDNs. Cloudflare honours only `Accept-Encoding` and will not serve a
1833
+ * cached variant for anything else; Fastly/Varnish key on the RAW header, so
1834
+ * `'Cookie'` collapses the hit rate for every visitor carrying an analytics or
1835
+ * consent cookie. If your hook can read a dedicated header instead of `Cookie`
1836
+ * — a bearer `Authorization`, or a purpose-made `X-…` — the public surface
1837
+ * stays far more cacheable. A deployment with no `authenticate` hook declares
1838
+ * nothing here and keeps a clean `Vary: Origin`.
1839
+ *
1840
+ * Each entry must be a non-empty HTTP field-name token; `*` is refused (it
1841
+ * means "never reuse", which is `no-store` spelled obscurely). Matching is
1842
+ * case-insensitive, per HTTP.
1843
+ */
1844
+ credentialHeaders?: readonly string[];
1845
+ /**
1846
+ * Role for unauthenticated requests. Default: `'public'`.
1847
+ * Must have explicit 'view' permission per entity to see anything.
1848
+ *
1849
+ * Must be a NON-EMPTY string — `''` throws at construction. It is not a
1850
+ * tighter default: an empty role reaches the read path as `groups: []`, which
1851
+ * `resolveAuthContext` refuses, so the whole public surface would answer 500.
1852
+ * Omit the option rather than passing a possibly-unset environment variable.
1853
+ */
1854
+ publicRole?: string;
1855
+ /** Default content locale for translation merging. */
1856
+ defaultLocale?: string;
1857
+ /** CORS configuration. Required when serving cross-origin. */
1858
+ cors?: CorsConfig;
1859
+ /**
1860
+ * Cache-Control header configuration. Applied to ANONYMOUS-caller responses
1861
+ * only — a resolved principal short-circuits to `no-store` and ignores this
1862
+ * entirely. `GET /_menu` is the exception: its body is principal-invariant, so
1863
+ * it applies for every caller (under the reserved `_menu` key).
1864
+ */
1865
+ cache?: CacheConfig;
1866
+ /**
1867
+ * Max pagination limit. Default: 100. Hard-capped at 100.
1868
+ *
1869
+ * Configurable DOWNWARD only. A `?limit=` above the effective ceiling is a
1870
+ * 400 `VALIDATION_ERROR`, never a silent clamp (D007).
1871
+ */
1872
+ maxLimit?: number;
1873
+ /**
1874
+ * Max pagination offset. Default: 1000. Hard-capped at 1000.
1875
+ *
1876
+ * Configurable DOWNWARD only, exactly like {@link maxLimit}. A `?offset=`
1877
+ * above the effective ceiling is a 400 `VALIDATION_ERROR` naming `?cursor=`
1878
+ * as the supported deep-paging path — never a silent clamp, which would
1879
+ * leave a deep-paging client re-reading the same page forever (D007).
1880
+ *
1881
+ * Bounds a full traversal of the MATCHING set per request: the read path
1882
+ * issues a plain SQL `OFFSET`, so the work is `O(min(offset, matching rows))`
1883
+ * — cheap on a small collection, real on a large one, from a caller who is
1884
+ * unauthenticated by default.
1885
+ */
1886
+ maxOffset?: number;
1887
+ /**
1888
+ * Ceiling on the total resolutions ONE response may trigger, spanning
1889
+ * reference projections, `?populate=` expansions, media and block-tree
1890
+ * resolution (plan/api phase 05).
1891
+ *
1892
+ * Default and hard cap: `MAX_RESOLUTIONS_PER_DOCUMENT` (200). Configurable
1893
+ * DOWNWARD only, like {@link maxLimit} / {@link maxOffset}.
1894
+ *
1895
+ * **ONE budget, not one per kind.** All four kinds of work scale with the
1896
+ * DOCUMENT's own content — which an editor controls, not an operator — and
1897
+ * all four draw on the same connection pool. Four separate ceilings would let
1898
+ * a single document sum them into a much larger draw, which is the "it's only
1899
+ * N today" defence CLAUDE.md's No-Unbounded-Fan-Out section refuses.
1900
+ *
1901
+ * **Exceeding it is not an error.** The response renders with the excess
1902
+ * targets unresolved — indistinguishable on the wire from deleted or
1903
+ * unviewable ones, per phase 05's failure semantics — and the drop is logged
1904
+ * at `warn` with a per-kind breakdown. That log is the ONLY place the
1905
+ * distinction survives, which is what makes it load-bearing rather than
1906
+ * diagnostic.
1907
+ *
1908
+ * `0` is accepted and coherent: "resolve nothing on this deployment". A
1909
+ * negative or non-integer value throws at construction.
1910
+ */
1911
+ maxResolutionsPerDocument?: number;
1912
+ /**
1913
+ * Host-injected routing seam: an entity id → its current canonical href.
1914
+ *
1915
+ * Supplies the `href` on every reference projection (D013). Omit and
1916
+ * references still resolve — they just carry `href: null`, so a consumer gets
1917
+ * a label it cannot link.
1918
+ *
1919
+ * **Wire the SAME implementation you give `resolveLinks`.** D013's reason for
1920
+ * routing both through one seam is that a reference and a link to the same
1921
+ * document must produce the same URL BY CONSTRUCTION, rather than by two
1922
+ * implementations agreeing. In this repo that implementation reads the
1923
+ * materialized `toolkit_paths` index (`getCanonicalPath`), so a href survives
1924
+ * slug and parent renames.
1925
+ *
1926
+ * **This seam needs no publish filter, and that is a property rather than an
1927
+ * omission.** It is only ever called with ids that already came back from a
1928
+ * published-only `QueryClient` read, so an unpublished target never reaches
1929
+ * it. The same seam wired into `resolveLinks` resolves arbitrary
1930
+ * editor-authored targets and DOES need one.
1931
+ *
1932
+ * A throw is caught and degrades that one reference to `href: null` — a
1933
+ * broken routing lookup must not 500 every page carrying a reference.
1934
+ */
1935
+ resolveEntityHref?: ResolveEntityHref;
1936
+ /**
1937
+ * Host-injected media resolver: media ids → {@link MediaProjection}s
1938
+ * (plan/api phase 06, D021).
1939
+ *
1940
+ * A `media()` field stores a bare uuid, and no endpoint anywhere in the
1941
+ * toolkit accepts one — so without this, CMS images cannot be rendered by a
1942
+ * database-less frontend at all. Wire
1943
+ * `createPublicMediaResolver()` from `@murumets-ee/media/public`; injected
1944
+ * rather than imported because `content-api` is a leaf.
1945
+ *
1946
+ * **SECURITY (CLAUDE.md #1) — the provider MUST NEVER return a signed URL.**
1947
+ * A signed URL is a bearer capability: it grants access to whoever holds it,
1948
+ * for its lifetime, regardless of who they are. Public-role responses on this
1949
+ * surface are deliberately cacheable (`public, max-age=60,
1950
+ * stale-while-revalidate=300`), so one embedded in a response is stored by any
1951
+ * shared CDN and replayed verbatim to every later anonymous visitor. The cache
1952
+ * would be behaving correctly; the payload was wrong.
1953
+ *
1954
+ * That is why the shipped resolver ASKS storage for a public url
1955
+ * (`getPublicUrls`) rather than asking for "a url" and inspecting what came
1956
+ * back — the second shape produces a signed URL on the happy path and leans
1957
+ * on a later check to suppress it. Storage also defaults files to `private`,
1958
+ * so the safe behaviour has to be the one that happens when nobody configures
1959
+ * anything. If you write your own provider, preserve that property; this
1960
+ * package cannot enforce it.
1961
+ *
1962
+ * **The read must be PUBLISHED-only** — a `QueryClient` read, never an
1963
+ * `AdminClient` one, for the same reason `ContentApiResolution.resolvePath`
1964
+ * must be the published resolver.
1965
+ *
1966
+ * **OPERATOR NOTE — this needs a THIRD grant, and it is easy to miss.**
1967
+ * Reading through `QueryClient` runs the entity firewall, so the requesting
1968
+ * role needs `media:view` — even though `Media` is deliberately NOT in
1969
+ * {@link ContentApiConfig.entities} (serving it as a plain entity would
1970
+ * expose `fileKey`, which is exactly what this projection avoids). Without
1971
+ * the grant the read throws, every media field on the site resolves to
1972
+ * `null`, and the response cannot say why. Grant `media:view` to the public
1973
+ * role alongside whatever content entities you exposed; the handler logs the
1974
+ * failure at `warn` so it is greppable.
1975
+ *
1976
+ * Ids that do not exist, are unpublished for this caller, or whose file is
1977
+ * not public must be ABSENT from the returned map. The handler turns absence
1978
+ * into `null`, indistinguishable from every other unresolvable target.
1979
+ *
1980
+ * **Omitting this does not fall back to the stored uuid.** Every media field
1981
+ * resolves to `null` instead. A uuid is not a weaker answer here, it is an
1982
+ * unusable one, and letting the wire TYPE vary per deployment is exactly the
1983
+ * unfalsifiable claim `_schema` exists to prevent.
1984
+ */
1985
+ resolveMedia?: ResolveMedia;
1986
+ /**
1987
+ * Host-injected `blockTree()` resolver (plan/api phase 05).
1988
+ *
1989
+ * A block tree embeds internal links, `query()` fields and media refs — the
1990
+ * three resolution paths `@murumets-ee/blocks` already implements
1991
+ * server-side. This is the seam that runs them on the REQUEST path, under the
1992
+ * response's ONE resolution budget, which is handed to the provider so its
1993
+ * claims land in the same counter (and the same drop log) as the reference
1994
+ * and media passes.
1995
+ *
1996
+ * **SECURITY (CLAUDE.md #1).** Every read the provider performs must be
1997
+ * PUBLISHED-only and permission-honouring: a `query()` field resolved through
1998
+ * an `AdminClient` would surface drafts anonymously, and an internal link
1999
+ * resolved without a publish filter would leak an unpublished document's URL.
2000
+ * This package cannot enforce either — the resolver is injected — so the
2001
+ * WIRING must guarantee both, exactly as for
2002
+ * {@link ContentApiResolution.resolvePath}.
2003
+ *
2004
+ * **Omitting it leaves the stored tree UNCHANGED**, which is the opposite of
2005
+ * {@link resolveMedia}'s behaviour and deliberately so: a media uuid is
2006
+ * unusable to any consumer, while a block tree is the document's actual
2007
+ * content and renders perfectly well with its links unresolved — that is what
2008
+ * the in-process renderers already emit as plain text. Nulling it would
2009
+ * delete the page to avoid an unresolved href.
2010
+ */
2011
+ resolveBlocks?: ResolveBlocks;
2012
+ /**
2013
+ * Routing resolution providers (routing PR07). When present, the handler
2014
+ * serves `GET /_resolve`, `/_paths`, `/_alternates`; omit and those endpoints
2015
+ * stay 404 (opt-in — deny-by-default). Injected (not imported) so this stays a
2016
+ * leaf package — see {@link ContentApiResolution}.
2017
+ */
2018
+ resolution?: ContentApiResolution;
2019
+ /**
2020
+ * Menu provider (routing PR09). When present, the handler serves
2021
+ * `GET /_menu`; omit and it stays 404 (opt-in — deny-by-default). Injected
2022
+ * (not imported) so this stays a leaf package — see {@link ContentApiMenus},
2023
+ * which also documents why its `handles` allowlist is the endpoint's ONLY
2024
+ * gate.
2025
+ */
2026
+ menus?: ContentApiMenus;
2027
+ /**
2028
+ * Form submissions (forms plan D003/D004). When present, the handler serves
2029
+ * `GET /_forms/:id/token` and `POST /_forms/:id/submissions` — to a caller
2030
+ * whose site KEY names the site, and nobody else; omit and both stay 404
2031
+ * (opt-in — deny-by-default). Injected (not imported) so this stays a leaf
2032
+ * package — wire `createFormsContentApi` from `@murumets-ee/forms/server`.
2033
+ */
2034
+ forms?: ContentApiForms;
2035
+ /**
2036
+ * Visitor consent. When present, the handler serves `GET /_consent` (the
2037
+ * site's public consent config for one locale) and `POST /_consent` (records
2038
+ * one visitor's choice and answers with the signed cookie the front sets on
2039
+ * its own origin) — to a caller whose site KEY names the site, and nobody
2040
+ * else; omit and both stay 404 (opt-in — deny-by-default). Injected (not
2041
+ * imported) so this stays a leaf package — wire `createConsentContentApi`
2042
+ * from `@murumets-ee/consent/server`.
2043
+ */
2044
+ consent?: ContentApiConsent;
2045
+ /**
2046
+ * Site-chrome provider (plan/api phase 07). When present, the handler serves
2047
+ * `GET /_chrome`; omit and it stays 404 (opt-in — deny-by-default). Injected
2048
+ * (not imported) so this stays a leaf package — see {@link ContentApiChrome},
2049
+ * which documents the TWO independent gates and why the entity's own `access`
2050
+ * map is not one of them.
2051
+ */
2052
+ chrome?: ContentApiChrome;
2053
+ /**
2054
+ * i18n resolver for `GET /_site` (plan `multisite` D007). Optional: without
2055
+ * it the endpoint answers from the site's registry row and this mount's
2056
+ * `defaultLocale`. See {@link ContentApiSiteProvider}.
2057
+ */
2058
+ site?: ContentApiSiteProvider;
2059
+ /**
2060
+ * OPT-IN: the site an ANONYMOUS request reads, by the `Host` it was sent to
2061
+ * (plan `multisite` D008) — for a frontend that fetches from the browser and
2062
+ * so cannot hold a key. Keys are host names (`kulli.ee`, with a port if the
2063
+ * API is reached on one), values are site handles.
2064
+ *
2065
+ * Omitted — the default — a request with no site-bound credential has no
2066
+ * site on an app serving several, and every site-bound read answers 404.
2067
+ *
2068
+ * **Listing a host here publishes that site to anyone who can send that
2069
+ * `Host`.** That is the same audience its frontend already serves; it is not
2070
+ * a way to keep a site private. A credential's site always wins over it, and
2071
+ * the mapped site is still checked against the registry (a disabled site is
2072
+ * not served). A value that is not a site handle throws at construction.
2073
+ */
2074
+ siteForHost?: Readonly<Record<string, string>>;
2075
+ /**
2076
+ * Detail-template provider (block-library D009). An entity that declares
2077
+ * `frontend: { drawnBy: 'template' }` is served by `GET /:entity/:id` only,
2078
+ * NARROWED to its id plus what this provider projects, with the entity type's
2079
+ * published template resolved against the row under `_template`.
2080
+ *
2081
+ * Omit it and such a row crosses as `{ id }` — deny by default, never the
2082
+ * row's columns. Injected (not imported) so this stays a leaf package; see
2083
+ * {@link ContentApiDetailTemplates}.
2084
+ */
2085
+ detailTemplates?: ContentApiDetailTemplates;
2086
+ /**
2087
+ * The MONITORING seam — attach an error tracker or a metrics counter here.
2088
+ *
2089
+ * **It is not a logging seam, and wiring it as one is the mistake it used to
2090
+ * invite.** The handler already logs every error it produces, with the `err`
2091
+ * object, so pino's serializer emits a stack. Both production mounts used to
2092
+ * re-log the same event from here with strictly less information, because
2093
+ * nothing said what else the hook was for. It exists because
2094
+ * `@murumets-ee/logging`'s root logger deliberately ships NO pino transports
2095
+ * (they use worker threads that break webpack/turbopack), so a host has no
2096
+ * in-band way to attach Sentry / OTel / a counter to the log stream. This
2097
+ * hook is that route, and it is the only one.
2098
+ *
2099
+ * Fires for EVERY error response, not just 500 — a 400/404 rate is the point,
2100
+ * and the 404s especially: denial and absence are the same response (D006),
2101
+ * so without this an operator has no signal that a consumer is being refused
2102
+ * at all.
2103
+ *
2104
+ * Fire-and-forget: the handler does not await it, and a hook that throws or
2105
+ * rejects is contained rather than allowed to turn a 404 into a 500.
2106
+ *
2107
+ * **Keep it non-blocking and bounded.** It runs once per error response, so
2108
+ * an integration doing synchronous per-call I/O has put an unbounded fan-out
2109
+ * on the error path — exactly the shape CLAUDE.md forbids, and much cheaper
2110
+ * to state here than to discover under load.
2111
+ */
2112
+ onError?: (err: ContentApiErrorReport) => void | Promise<void>;
2113
+ /**
2114
+ * TUNING for the availability guard (D025). Optional in full.
2115
+ *
2116
+ * **Its absence does not disable anything.** The survival half — the
2117
+ * concurrency semaphore, its bounded wait queue, and the per-request wall
2118
+ * clock — is keyless by construction and always on, precisely so no host can
2119
+ * lose it by omission. This field only moves numbers that already have
2120
+ * defaults, plus supplies the one thing the package genuinely cannot know:
2121
+ * {@link ContentApiLimits.getClientIp}, without which anonymous traffic has no
2122
+ * distinguishing key and the FAIRNESS half is skipped rather than collapsed
2123
+ * onto one shared bucket.
2124
+ */
2125
+ limits?: ContentApiLimits;
2126
+ /**
2127
+ * TUNING for the visitor search's per-visitor budget (`GET /_search`,
2128
+ * davatoidud-toolkit D001). Optional in full — the limit is on by default.
2129
+ *
2130
+ * Which types a site searches is NOT here: it is the site's own
2131
+ * `searchTypes` (the Sites page), so a mount serving several sites cannot
2132
+ * give one site another's choice.
2133
+ */
2134
+ search?: ContentApiSearchConfig;
2135
+ }
2136
+ /**
2137
+ * What the monitoring seam receives: DERIVED FACTS ONLY.
2138
+ *
2139
+ * Never the raw error, never the `Request`, never a credential — and note the
2140
+ * absence of `message`, which this payload used to carry straight from a caught
2141
+ * exception. A driver-level message can contain query fragments and values, so
2142
+ * the log stream is more sensitive than the response (D034); a monitoring
2143
+ * payload LEAVES THE PROCESS, and the operator's log is where the detail
2144
+ * belongs.
2145
+ */
2146
+ interface ContentApiErrorReport {
2147
+ /** HTTP status of the response that was sent. */
2148
+ status: number;
2149
+ /** The machine-readable code from the error envelope — one of `ERROR_CODES`. */
2150
+ code: ContentApiErrorCode;
2151
+ /** Request pathname. */
2152
+ path: string;
2153
+ /** The RESOLVED entity name, when the request got far enough to name one. */
2154
+ entity?: string;
2155
+ /** Correlation id — the same value echoed to the caller and bound to the logs (D032). */
2156
+ requestId: string;
2157
+ /** The resolved principal's id, when the request got far enough to resolve one. */
2158
+ principalId?: string;
2159
+ }
2160
+ /**
2161
+ * The cache-tag contract (D055) — see {@link SchemaResponse.cacheTags}.
2162
+ *
2163
+ * Every value here is DERIVED from the same resolved configuration the emitter
2164
+ * uses, never restated: a published grammar that disagrees with the emitted one
2165
+ * would be an unfalsifiable claim, which is exactly what `_schema` exists to
2166
+ * prevent.
2167
+ */
2168
+ interface SchemaCacheTags {
2169
+ /** The response header carrying the tags — e.g. `Cache-Tag`. */
2170
+ header: string;
2171
+ /** What joins tags inside that header — `,` or a single space. */
2172
+ separator: string;
2173
+ /**
2174
+ * Template for a tag naming ONE row whose content is in the body.
2175
+ * `{entity}` and `{id}` are the substitutions.
2176
+ */
2177
+ document: string;
2178
+ /**
2179
+ * Template for a tag naming the SET of an entity's rows — emitted on every
2180
+ * read except the one addressed by primary key.
2181
+ *
2182
+ * **SEND THIS ON EVERY WRITE TO THE ENTITY, not only on create and delete.**
2183
+ * An earlier draft of this sentence said create/delete, on the reasoning that
2184
+ * only those change which rows exist. That is wrong for any filtered or
2185
+ * sorted list: publishing a draft is an UPDATE, and the row it updates was by
2186
+ * definition not on the cached `?filter[status]=published` page, so its
2187
+ * document tag was never on that response either. The safe rule is one rule —
2188
+ * a write to entity `E` purges `lumi:E` and `lumi:E:<id>` together.
2189
+ */
2190
+ collection: string;
2191
+ /**
2192
+ * What fills `{site}` for a row that belongs to NO site — a shared entity
2193
+ * such as media — on an app serving several sites (plan `multisite` D008);
2194
+ * `null` on a single-site install, whose templates have no `{site}` at all.
2195
+ *
2196
+ * A row of a site-bound entity carries the handle of the site the response
2197
+ * was served for. So a write to a bound row purges that site's tags only,
2198
+ * and a write to a shared row purges ONE tag that every site's responses
2199
+ * carry.
2200
+ */
2201
+ sharedSite: string | null;
2202
+ /** Per-tag character ceiling; a longer tag is dropped, never truncated. */
2203
+ maxTagLength: number;
2204
+ /**
2205
+ * Per-response tag ceiling. Beyond it tags are dropped from the TAIL and the
2206
+ * drop is logged — so a response at the ceiling may name fewer documents than
2207
+ * it actually composed.
2208
+ */
2209
+ maxTagsPerResponse: number;
2210
+ /**
2211
+ * Ceiling on the assembled header in bytes — a THIRD bound, which the other
2212
+ * two do not imply (128 × 128 plus separators overshoots it). Tags are
2213
+ * dropped from the tail to stay inside it.
2214
+ */
2215
+ maxHeaderBytes: number;
2216
+ /**
2217
+ * What the tags on a response are guaranteed NOT to cover, so a consumer can
2218
+ * size its own TTL for the gap rather than assume completeness (D055 §8).
2219
+ *
2220
+ * - `menuTargets` — a resolved menu item carries no entity identity, so
2221
+ * `_menu` names only the menu itself; a linked page's rename is invisible.
2222
+ * - `crossEntityPathChanges` — `_resolve` and `_alternates` tag the entity
2223
+ * that answered. A document of a DIFFERENT entity taking over a path is not
2224
+ * covered, because there is no tag on the response that names it.
2225
+ * - `mediaVariantStyles` — a `MediaProjection.variants` map is keyed by image
2226
+ * STYLE name, and the variant url embeds both that name and the style's
2227
+ * output format (`deriveVariantKey(fileKey, styleName, format)`). Image
2228
+ * styles are operator data in the settings DB, editable at runtime through
2229
+ * the admin UI, and renaming one or changing its format writes NO media
2230
+ * row — so `lumi:media:<id>` is never purged and a cached `variants` map
2231
+ * keeps keys and urls that no longer exist. See #852.
2232
+ * - `outOfBandPathChanges` — a document's canonical path lives in the
2233
+ * `toolkit_paths` index, not on its row, and three writers move it without
2234
+ * touching the row: the ancestor-rename cascade, `paths:rebuild`, and a
2235
+ * `frontend.path` pattern change. All three go through `applyCanonicalPath`
2236
+ * against a `TableClient`, and only `AdminClient` emits a write event — so
2237
+ * a `ReferenceProjection.href` can go stale with nothing on the response
2238
+ * naming the cause. The ancestor cascade is ordinary editorial activity,
2239
+ * not a deploy, which is what puts this in the same class as `menuTargets`.
2240
+ * See #852.
2241
+ *
2242
+ * `blockTreeContents` USED to be here and no longer is. It was structural at
2243
+ * the time — the block provider returned an opaque `unknown` — and stopped
2244
+ * being so once `ResolvedBlockTree` gained a `refs` list (`BlockTreeRef`,
2245
+ * exported from this package's root): every media id and link target a tree
2246
+ * resolved is now named on the response. Removing an entry NARROWS what a
2247
+ * consumer must widen its TTL for, so it is a behaviour improvement rather
2248
+ * than a breaking change to the grammar.
2249
+ *
2250
+ * 🔴 **Two things "covered" does NOT mean, stated because a consumer sizes a
2251
+ * TTL against this list and neither is visible from it.**
2252
+ *
2253
+ * 1. Block refs are subject to the SAME per-response ceiling as every other
2254
+ * tag ({@link SchemaCacheTags.maxTagsPerResponse}), and on a LIST read
2255
+ * they are added last — after the collection tag and one document tag per
2256
+ * item. A `?limit=100` page of documents that each embed an image spends
2257
+ * the budget before the refs are reached, so in practice a full list names
2258
+ * few of them or none. Drops are logged server-side; nothing on the wire
2259
+ * distinguishes "named everything" from "named what fitted".
2260
+ * 2. The tags name what the provider actually RESOLVED, and several classes
2261
+ * are deliberately not resolved and therefore not named — a value that
2262
+ * looks like a media ref but carries an unrecognised key or a mistyped
2263
+ * optional (left alone, with its stored url), a subtree below the
2264
+ * resolver's media walk bound, a link below its link walk bound, and
2265
+ * anything the response's resolution budget refused.
2266
+ *
2267
+ * 🔴 One of those is worth calling out, because the two indexes now
2268
+ * DISAGREE about it. The entity-side reference index deliberately matches
2269
+ * a media ref whose optional field is mistyped — under-matching there
2270
+ * deletes a live asset — while this resolver deliberately declines it,
2271
+ * because over-matching HERE replaces a project's own prop with `null`.
2272
+ * Both are right for their own failure direction, and the consequence is
2273
+ * real: such an asset is delete-protected and its page is not tagged with
2274
+ * it, so re-publishing that image evicts nothing.
2275
+ */
2276
+ uncovered: readonly ('menuTargets' | 'crossEntityPathChanges' | 'mediaVariantStyles' | 'outOfBandPathChanges')[];
2277
+ }
2278
+ //#endregion
2279
+ //#region src/handler.d.ts
2280
+ /**
2281
+ * The API version this instance mounts. Every route is served under it and
2282
+ * ONLY under it — `/v1/article`, `/v1/_schema`. There is no unprefixed mount,
2283
+ * no deprecation marker, no `Link` successor header (D010): no consumer
2284
+ * predates the prefix, so there is nothing to transition.
2285
+ *
2286
+ * **What `v1` means.** Adding an endpoint, an entity, an error code, a `meta`
2287
+ * field, or an OPTIONAL query parameter is additive and stays inside v1.
2288
+ * CHANGING or REMOVING any of them — a field's type, an endpoint's shape, a
2289
+ * default, a previously-optional param becoming required — is a v2. `GET
2290
+ * /v1/_schema` publishes the version this instance mounts, so a client can
2291
+ * assert against it rather than infer it from the URL it happened to be given.
2292
+ *
2293
+ * The version is consumed by the HANDLER, not by the mount. The app mounts one
2294
+ * catch-all whose only job is to hand over an ordered array of path segments,
2295
+ * so a consuming app needs no edit at all, and the package that owns the
2296
+ * contract is the one that enforces and introspects it — rather than whichever
2297
+ * framework the app happens to route with.
2298
+ */
2299
+ declare const API_VERSION = "v1";
2300
+ declare function createContentApiHandler(config: ContentApiConfig): {
2301
+ GET: (req: Request, ctx: RouteContext) => Promise<Response>;
2302
+ /**
2303
+ * The two write routes: `POST /_forms/:id/submissions` (forms D003) and
2304
+ * `POST /_consent` — a 404 for every other path, and for each of those too
2305
+ * unless its provider (`forms` / `consent`) is wired. A post goes through
2306
+ * the SAME dispatch as a read: the survival guard, the site from the key,
2307
+ * the fairness limit and the request context.
2308
+ */
2309
+ POST: (req: Request, ctx: RouteContext) => Promise<Response>;
2310
+ OPTIONS: (req: Request) => Promise<Response>;
2311
+ /**
2312
+ * Invalidate the cached permission checker. Call this from the
2313
+ * role-management code path (after a role is created/edited/deleted)
2314
+ * so the next request re-runs `loadPermissions()` instead of waiting
2315
+ * up to 30 seconds for the TTL to expire.
2316
+ */
2317
+ invalidatePermissionCache: () => void;
2318
+ };
2319
+ //#endregion
2320
+ export { API_VERSION, type BlockTreeRef, CACHE_TAG_NAMESPACE, type CacheTagsConfig, type ContentApiChrome, type ContentApiChromeRegion, type ContentApiChromeRegions, type ContentApiConfig, type ContentApiConsent, type ContentApiConsentCookie, type ContentApiConsentRecordResult, type ContentApiConsentState, type ContentApiDetailTemplates, type ContentApiErrorCode, type ContentApiErrorReport, type ContentApiFormSubmitResult, type ContentApiFormTicket, type ContentApiForms, type ContentApiMenus, type ContentApiPrincipal, type ContentApiResolution, type ContentApiResolvedMenu, type ContentApiResolvedMenuItem, type ContentApiSearchConfig, type ContentApiSite, type ContentApiSiteProvider, type ContentPathEntry, type ContentResolveResult, DEFAULT_SEARCH_RESULTS, DETAIL_TEMPLATE_FIELD, ERROR_CODES, MAX_CACHE_TAGS_PER_RESPONSE, MAX_CACHE_TAG_HEADER_BYTES, MAX_CACHE_TAG_LENGTH, MAX_CONSENT_BODY_BYTES, MAX_FORM_BODY_BYTES, MAX_RESOLUTIONS_PER_DOCUMENT, MAX_SEARCH_RESULTS, MEDIA_TAG_ENTITY, type MediaProjection, type MediaSource, type ReferenceProjection, type ResolutionBudget, type ResolutionKind, type ResolveBlocks, type ResolveEntityHref, type ResolveMedia, type ResolvedBlockTree, SHARED_SITE_TAG_SEGMENT, type SchemaCacheTags, type SearchHit, type UnresolvedBlockRef, VISITOR_ADDRESS_HEADER, createContentApiHandler, isResolvedBlockTree };
2321
+ //# sourceMappingURL=index.d.mts.map