@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.
- package/LICENSE +94 -0
- package/README.md +15 -0
- package/dist/index.d.mts +2321 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/query-params.d.mts +364 -0
- package/dist/query-params.d.mts.map +1 -0
- package/dist/query-params.mjs +2 -0
- package/dist/query-params.mjs.map +1 -0
- package/package.json +40 -0
package/dist/index.d.mts
ADDED
|
@@ -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
|