@pramen/cms 0.0.59 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -24,8 +24,9 @@
24
24
  // const acl = [ role("anonymous", [...cmsPolicies().public]),
25
25
  // role("editor", [...cmsPolicies().editor]) ];
26
26
  // const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
27
- import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, $now, partitionOf, DEFAULT_PARTITION, BadRequest, Forbidden, PramenError, Conflict, signToken, verifyToken, resolveSecret, } from "@pramen/server";
27
+ import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, $now, partitionOf, DEFAULT_PARTITION, BadRequest, Forbidden, PramenError, Conflict, signToken, verifyToken, resolveSecret, authorizeHandler, } from "@pramen/server";
28
28
  import { isSafeHref, normalizeHref } from "./href";
29
+ import { NAV_ORDER } from "./nav";
29
30
  /** Declare a typed block type. Pass `fields as const` to preserve the literals so
30
31
  * `BlockFieldsOf<typeof def>` infers the field shape:
31
32
  *
@@ -38,6 +39,15 @@ import { isSafeHref, normalizeHref } from "./href";
38
39
  * Spread `hero` (minus fieldsSchema key naming) into `createBlockType`, and use
39
40
  * `BlockFieldsOf<typeof hero>` to type the block's React component. */
40
41
  export function defineBlockType(slug, fields, opts = {}) {
42
+ // A PURE constructor: it returns exactly what it was given, so `fieldsSchema` really is
43
+ // `F` and `BlockFieldsOf<typeof def>` describes the array that will be stored. Validation
44
+ // deliberately does NOT live here — it lives in `cmsBootstrap`, the thing that writes.
45
+ // `BlockTypeDef` is a structural interface, so an object literal, a `.map` or a codegen
46
+ // step reaches the store without passing through this function at all; checking here would
47
+ // have guarded the convenient path and left the sink open. Canonicalizing here was worse
48
+ // still: `validateFieldSchema` REBUILDS every entry (trimming names, dropping type-inert
49
+ // keys), so the returned array stopped matching the const literal `F` is inferred from and
50
+ // the cast became a lie a component would follow into `fields[" title "] === undefined`.
41
51
  return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
42
52
  }
43
53
  /** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
@@ -48,17 +58,108 @@ export function defineBlockType(slug, fields, opts = {}) {
48
58
  * regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
49
59
  * }); */
50
60
  export function defineContentType(slug, opts) {
61
+ // Pure, for the same reason as `defineBlockType` — `cmsBootstrap` validates.
51
62
  return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
52
63
  }
53
64
  const sameJson = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
65
+ /** The default `owner` — see {@link cmsBootstrap}. */
66
+ export const CMS_BOOTSTRAP_OWNER = "cms";
67
+ /**
68
+ * Validate + canonicalize what a `cmsBootstrap` will write, at FACTORY-call time.
69
+ *
70
+ * This lives here rather than in `defineBlockType`/`defineContentType` because those are
71
+ * optional conveniences: `BlockTypeDef`/`ContentTypeDef` are exported STRUCTURAL interfaces,
72
+ * so an object literal, a `.map` over a config file or a codegen step reaches `upsertBySlug`
73
+ * without passing through either helper. Checking in the helper guarded the convenient path
74
+ * and left the sink open — and the row it wrote was then locked `managedBy`, so an invalid
75
+ * schema could never be repaired through the product. `cmsBootstrap(defs)` is the one call
76
+ * every code-defined type goes through.
77
+ *
78
+ * Held to the SAME rules the editor's `createBlockType`/`createContentType` enforce
79
+ * (`normalizeFieldSchema`, `normalizeRegions`, `normalizeDefaultBlocks`), because the
80
+ * alternative is a code-declared type storing a schema the builder then refuses to save —
81
+ * the only surface reporting the problem being the one that cannot fix it.
82
+ *
83
+ * EVERY problem is reported together, not just the first: this throws at app construction
84
+ * (`app.ts` module scope, like `validateCollections` in `createCollectionHandlers` and
85
+ * `validateMigrations` in `createPramen`), where fixing them one deploy at a time is the
86
+ * difference between one round trip and six.
87
+ */
88
+ function validateCmsDefinitions(defs) {
89
+ const problems = [];
90
+ const at = (what, slug, e) => {
91
+ problems.push(` ${what} ${JSON.stringify(slug)}: ${e instanceof Error ? e.message : String(e)}`);
92
+ };
93
+ const blockTypes = [];
94
+ const btSeen = new Set();
95
+ for (const bt of defs.blockTypes ?? []) {
96
+ try {
97
+ const slug = assertRegistryKey(bt.slug, "block type slug");
98
+ // Last-wins on a repeated slug is how two feature modules both exporting a `cta` block
99
+ // type converge to whichever import order won, with nothing said about it.
100
+ if (btSeen.has(slug))
101
+ throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
102
+ btSeen.add(slug);
103
+ blockTypes.push({
104
+ name: assertLabel(bt.name ?? slug, "block type name"),
105
+ slug,
106
+ description: bt.description ?? null,
107
+ fieldsSchema: normalizeFieldSchema(bt.fieldsSchema, "fieldsSchema"),
108
+ icon: bt.icon ?? null,
109
+ category: bt.category ?? null,
110
+ });
111
+ }
112
+ catch (e) {
113
+ at("block type", bt.slug, e);
114
+ }
115
+ }
116
+ const contentTypes = [];
117
+ const ctSeen = new Set();
118
+ for (const ct of defs.contentTypes ?? []) {
119
+ try {
120
+ // The slug FIRST: it is what every other message names, and validating regions ahead
121
+ // of it produced an error that never mentioned the type whose slug was also wrong —
122
+ // fix the regions, redeploy, meet the second failure.
123
+ const slug = assertRegistryKey(ct.slug, "content type slug");
124
+ if (ctSeen.has(slug))
125
+ throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
126
+ ctSeen.add(slug);
127
+ const regions = normalizeRegions(ct.regions);
128
+ contentTypes.push({
129
+ name: assertLabel(ct.name ?? slug, "content type name"),
130
+ slug,
131
+ description: ct.description ?? null,
132
+ fieldsSchema: normalizeFieldSchema(ct.fields, "fields"),
133
+ regions,
134
+ defaultBlocks: normalizeDefaultBlocks(ct.defaultBlocks, regions),
135
+ });
136
+ }
137
+ catch (e) {
138
+ at("content type", ct.slug, e);
139
+ }
140
+ }
141
+ if (problems.length > 0) {
142
+ throw new Error(`cmsBootstrap: ${problems.length} invalid type definition(s)\n${problems.join("\n")}`);
143
+ }
144
+ return { blockTypes, contentTypes };
145
+ }
54
146
  /** Insert `values` if no row has this `slug`, else patch only the columns that drifted
55
- * (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op. */
56
- async function upsertBySlug(db, table, slug, values) {
147
+ * (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op.
148
+ *
149
+ * Returns false, having written NOTHING, when the existing row belongs to someone else —
150
+ * an editor-authored type (`managedBy` null) or another reconciler's. Adopting it was a
151
+ * silent takeover: name and schema replaced by the code literal, and then the row locked, so
152
+ * the editor could not even put back what it had just lost. `createBlockType` refuses this
153
+ * exact slug collision at the RPC edge; the reconciler used to win it without a word. */
154
+ async function upsertBySlug(db, table, owner, values) {
155
+ const slug = String(values.slug);
57
156
  const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
58
157
  if (!existing) {
59
- await db.insert(table, values);
60
- return;
158
+ await db.insert(table, { ...values, managedBy: owner });
159
+ return true;
61
160
  }
161
+ if (existing.managedBy !== owner)
162
+ return false;
62
163
  const patch = {};
63
164
  for (const [k, v] of Object.entries(values)) {
64
165
  if (k === "slug")
@@ -68,6 +169,28 @@ async function upsertBySlug(db, table, slug, values) {
68
169
  }
69
170
  if (Object.keys(patch).length)
70
171
  await db.update(table, String(existing.id), patch);
172
+ return true;
173
+ }
174
+ /** Release the rows THIS owner wrote and no longer declares.
175
+ *
176
+ * A type dropped from the repo keeps its row — pages are still built out of it — but nothing
177
+ * converges it any more, so leaving it read-only in the builder would be a lock with nothing
178
+ * behind it, next to a note pointing at code that no longer mentions it.
179
+ *
180
+ * Scoped to `managedBy = owner`, which is what makes `cmsBootstrap` composable: a sweep
181
+ * cannot otherwise tell "not mine" from "no longer declared", so two reconcilers in one
182
+ * `app.bootstrap` released each other's rows on every boot and half the types silently fell
183
+ * back to editable. A table the call says nothing about (`blockTypes` absent — the KEY, not
184
+ * an empty array) is left alone rather than swept. */
185
+ async function releaseUndeclared(db, table, owner, declared) {
186
+ // `select` because this runs on the boot critical path — inside `blockConcurrencyWhile` on
187
+ // a DO's first fetch, and at every isolate init on D1 where each statement is a round trip.
188
+ // Without it the read pulls and JSON-parses every row's whole field schema to look at two
189
+ // columns (the GitHub #22 shape).
190
+ for (const row of await db.find({ from: table, where: { managedBy: owner }, select: ["id", "slug"] })) {
191
+ if (!declared.has(String(row.slug)))
192
+ await db.update(table, String(row.id), { managedBy: null });
193
+ }
71
194
  }
72
195
  /** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
73
196
  * `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
@@ -77,30 +200,51 @@ async function upsertBySlug(db, table, slug, values) {
77
200
  * bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
78
201
  *
79
202
  * Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
80
- * code-declared types with no manual createContentType/createBlockType call. */
81
- export function cmsBootstrap(defs) {
203
+ * code-declared types with no manual createContentType/createBlockType call.
204
+ *
205
+ * Every row it writes is stamped `managedBy: owner`, which makes the editor show it
206
+ * read-only — convergence and an editor pointed at the same rows are otherwise a silent
207
+ * data-loss pair (GitHub #48). A type that drops out of the declaration is released back to
208
+ * the editor; a row this owner did not write is never touched, so a second reconciler (a
209
+ * package shipping its own block types, say) composes as long as it passes its own `owner`.
210
+ *
211
+ * The definitions are validated HERE, when the app is constructed, and every problem is
212
+ * reported at once — see {@link validateCmsDefinitions}. */
213
+ export function cmsBootstrap(defs, opts = {}) {
214
+ const owner = opts.owner ?? CMS_BOOTSTRAP_OWNER;
215
+ const { blockTypes, contentTypes } = validateCmsDefinitions(defs);
216
+ // Presence of the KEY, not truthiness of the array: `blockTypes: []` is "I declare none",
217
+ // which must sweep, while an absent key is "I say nothing about block types", which must
218
+ // not. Truthiness read `[]` as the latter four lines after `?? []` read it as the former,
219
+ // and the difference was invisible at the call site — `features.flatMap(f => f.blockTypes)`
220
+ // on an empty list silently unlocked every code-defined type in every tenant.
221
+ const sweepBlockTypes = "blockTypes" in defs;
222
+ const sweepContentTypes = "contentTypes" in defs;
82
223
  return async ({ db }) => {
83
224
  const sys = db;
84
- for (const bt of defs.blockTypes ?? []) {
85
- await upsertBySlug(sys, "cms_block_types", bt.slug, {
86
- name: bt.name,
87
- slug: bt.slug,
88
- description: bt.description ?? null,
89
- fieldsSchema: bt.fieldsSchema ?? [],
90
- icon: bt.icon ?? null,
91
- category: bt.category ?? null,
92
- });
93
- }
94
- for (const ct of defs.contentTypes ?? []) {
95
- await upsertBySlug(sys, "cms_content_types", ct.slug, {
96
- name: ct.name,
97
- slug: ct.slug,
98
- description: ct.description ?? null,
99
- fieldsSchema: ct.fields ?? [],
100
- regions: ct.regions ?? [],
101
- defaultBlocks: ct.defaultBlocks ?? [],
102
- });
103
- }
225
+ // Per-definition, so one failure does not skip every later type AND both sweeps. The
226
+ // boot runner only logs a throwing reconciler, so an aborted pass leaves the store half
227
+ // converged for that isolate's whole lifetime with nothing to retry it. A UNIQUE
228
+ // violation is the expected instance: `app.bootstrap` has no lease, and on D1 two cold
229
+ // isolates can both find a slug missing and both insert it.
230
+ const reconcile = async (table, values) => {
231
+ try {
232
+ if (!(await upsertBySlug(sys, table, owner, values))) {
233
+ console.warn(`@pramen/cms: ${table}.${String(values.slug)} already exists and is not owned by '${owner}' — leaving it alone (code-defined types cannot take over a row someone else authored)`);
234
+ }
235
+ }
236
+ catch (e) {
237
+ console.error(`@pramen/cms: failed to reconcile ${table}.${String(values.slug)}:`, e);
238
+ }
239
+ };
240
+ for (const bt of blockTypes)
241
+ await reconcile("cms_block_types", bt);
242
+ if (sweepBlockTypes)
243
+ await releaseUndeclared(sys, "cms_block_types", owner, new Set(blockTypes.map((bt) => String(bt.slug))));
244
+ for (const ct of contentTypes)
245
+ await reconcile("cms_content_types", ct);
246
+ if (sweepContentTypes)
247
+ await releaseUndeclared(sys, "cms_content_types", owner, new Set(contentTypes.map((ct) => String(ct.slug))));
104
248
  };
105
249
  }
106
250
  // --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
@@ -129,6 +273,9 @@ function tsTypeOf(f) {
129
273
  return "boolean";
130
274
  case "media":
131
275
  return "ResolvedMedia | null";
276
+ // An opaque id — the record it points at may not be ours to type.
277
+ case "reference":
278
+ return f.multiple ? "string[]" : "string";
132
279
  case "group":
133
280
  return `{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }`;
134
281
  case "repeater":
@@ -199,9 +346,21 @@ export function generateBlockTypes(blockTypes) {
199
346
  const importLine = used.length ? `import type { ${used.join(", ")} } from "@pramen/cms";\n\n` : "";
200
347
  return `// AUTO-GENERATED by @pramen/cms — do not edit.\n${importLine}${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`;
201
348
  }
202
- // --- schema fragment: spread into your defineSchema so the tables migrate --------
203
- /** The block/page builder tables. All in the default partition (relations can't cross
204
- * partitions). Prefixed `cms_` to avoid colliding with your own entities. */
349
+ /** How deep a menu tree may nest. Menus are stored as one document, so without a cap a
350
+ * client could post a tree deep enough to blow the stack in the resolver and no real
351
+ * navigation is more than three levels anyway. */
352
+ export const MAX_MENU_DEPTH = 5;
353
+ /** How many items one menu may hold, at every level combined.
354
+ *
355
+ * Depth alone is not a bound: a FLAT list of 800 `page` items is legal under
356
+ * `MAX_MENU_DEPTH` and turns every anonymous `getMenu` — the read on every page render of
357
+ * the site — into a single `WHERE id IN (?×800)`, which the read engine emits with no
358
+ * chunking. Every other read added alongside this one is bounded (`MAX_TERMS`,
359
+ * `clampLimit`); this one was not, and it is the one on the hot path. */
360
+ export const MAX_MENU_ITEMS = 200;
361
+ /** How deep a term hierarchy may nest — the same argument as {@link MAX_MENU_DEPTH}, except
362
+ * here the tree is rows and the risk is a parent CYCLE, which `assertTermParent` refuses. */
363
+ export const MAX_TERM_DEPTH = 5;
205
364
  export const cmsSchema = {
206
365
  cms_content_types: Entity((t) => ({
207
366
  id: primaryKey(generated(t.uuid())),
@@ -211,6 +370,8 @@ export const cmsSchema = {
211
370
  fieldsSchema: t.json(), // FieldDefinition[] for page-level fields
212
371
  regions: t.json(), // RegionDefinition[]
213
372
  defaultBlocks: t.json(), // DefaultBlockDefinition[]
373
+ // Which reconciler owns this row. See cms_block_types.managedBy.
374
+ managedBy: t.text(),
214
375
  createdAt: defaultTo(t.text(), expr.now()),
215
376
  })),
216
377
  cms_block_types: Entity((t) => ({
@@ -221,6 +382,21 @@ export const cmsSchema = {
221
382
  fieldsSchema: t.json(), // FieldDefinition[]
222
383
  icon: t.text(),
223
384
  category: t.text(),
385
+ // NON-NULL while this row is CODE-DEFINED, holding the OWNER id of the `cmsBootstrap`
386
+ // that declares it. The editor authors these rows too (GitHub #9), and the two were
387
+ // otherwise indistinguishable: an editor would add a field, get a 200, and lose it
388
+ // silently at the next cold start when `upsertBySlug` patched the column back to the
389
+ // literal in `app.ts`. So it is set by the reconciler, refused by `updateBlockType` /
390
+ // `updateContentType`, and rendered read-only in the builder. Cleared again when the
391
+ // definition leaves the repo — a lock with nothing behind it is worse than no lock.
392
+ //
393
+ // An OWNER id rather than a boolean because `app.bootstrap` is a composable array. With
394
+ // a flag, two `cmsBootstrap` calls each released the other's rows on every boot: the
395
+ // sweep cannot tell "this row is not mine" from "this row is no longer declared", so
396
+ // half the types silently fell back to editable and #48 came straight back for them. A
397
+ // reconciler now only releases what IT wrote, which is also what lets a package ship its
398
+ // own block types beside the app's.
399
+ managedBy: t.text(),
224
400
  createdAt: defaultTo(t.text(), expr.now()),
225
401
  })),
226
402
  cms_blocks: Entity((t) => ({
@@ -261,7 +437,8 @@ export const cmsSchema = {
261
437
  unpublishAt: t.text(),
262
438
  // The revision the public content API serves — set on publish. A direct pointer
263
439
  // (not "latest by timestamp") so selection is deterministic even when two publishes
264
- // land in the same second (expr.now() is second-precision).
440
+ // land in the same instant. `expr.now()` carries milliseconds now, which narrows the
441
+ // window without closing it — a pointer has no window at all.
265
442
  currentRevisionId: t.uuid(),
266
443
  // Soft delete: the epoch-ISO instant the page was trashed, NULL while it is live.
267
444
  // Every read scope AND-merges `deletedAt IS NULL` (see cmsPolicies), so a trashed
@@ -284,6 +461,10 @@ export const cmsSchema = {
284
461
  }), (r) => ({
285
462
  type: r.belongsTo("cms_content_types", "typeId"),
286
463
  placements: r.hasMany("cms_page_blocks", "pageId"),
464
+ // Taxonomy terms, through the explicit junction. `where: { terms: { slug: "news" } }`
465
+ // compiles to a nested subquery, so "pages in this category" is an ordinary query and
466
+ // not a second handler.
467
+ terms: r.manyToMany("cms_terms", { through: "cms_page_terms", sourceColumn: "pageId", targetColumn: "termId" }),
287
468
  }), { unique: [["slug", "locale"]] }),
288
469
  cms_page_blocks: Entity((t) => ({
289
470
  id: primaryKey(generated(t.uuid())),
@@ -337,11 +518,11 @@ export const cmsSchema = {
337
518
  collection: indexed(notNull(t.text())),
338
519
  rowId: indexed(notNull(t.text())),
339
520
  // A monotonic per-row counter, and the ONLY ordering key. Timestamps cannot do this
340
- // job: `expr.now()` is second-resolution and even an ISO ms stamp collides, because a
341
- // collection revision is written on EVERY edit and two writes land in the same
342
- // millisecond often enough to be reproducible. Ordering then falls to a uuid tiebreak,
343
- // which is deterministic but NOT insertion order so "restore the previous version"
344
- // could pick the wrong snapshot.
521
+ // job even at millisecond resolution: a collection revision is written on EVERY edit,
522
+ // and two writes land in the same millisecond often enough to be reproducible. Ordering
523
+ // then falls to a uuid tiebreak, which is deterministic but NOT insertion order — so
524
+ // "restore the previous version" could pick the wrong snapshot. (`expr.now()` was also
525
+ // second-resolution when this was written, which made the same point louder.)
345
526
  //
346
527
  // The read-then-increment in `snapshotRow` is serialized by the DO's single writer. On
347
528
  // the D1 store it is NOT — `D1Driver.transaction` is a no-op (D1 has no interactive
@@ -352,13 +533,133 @@ export const cmsSchema = {
352
533
  snapshot: t.json(),
353
534
  note: t.text(),
354
535
  actor: t.text(),
355
- // NO expr.now() default. `snapshotRow` is the only writer and stamps this itself with
356
- // ISO-8601 ms precision, because unlike cms_page_revisions (written only on publish) a
357
- // collection revision is written on EVERY edit an autosave followed immediately by a
358
- // publish lands two rows in the same second, and `datetime('now')` (second resolution)
359
- // would make "the previous version" an arbitrary pick between them.
536
+ // NO expr.now() default: `snapshotRow` is the only writer and stamps it. That is now a
537
+ // consistency choice rather than a precision one `expr.now()` carries milliseconds
538
+ // too — but `revision` above is what actually orders these rows, and a column no
539
+ // writer but `snapshotRow` touches cannot drift from it.
360
540
  createdAt: t.text(),
361
541
  }), undefined, { unique: [["collection", "rowId", "revision"]] }),
542
+ // --- site furniture: menus, redirects, taxonomies, widget areas ------------------
543
+ //
544
+ // The WordPress-parity furniture every client project reinvents by hand. All four are
545
+ // SITE-level, not page-level: they exist once per deployment and are read by the layout,
546
+ // not by a page's regions.
547
+ // A named navigation menu. `items` is a nested `MenuItem[]` document rather than a rows
548
+ // table, because a menu is edited and read WHOLE — every read is `getMenu("primary")`,
549
+ // and every write is "here is the new tree". Rows would buy per-item queries nobody makes
550
+ // and cost a recursive assemble on the one read that matters. The tree is depth-capped on
551
+ // write (`MAX_MENU_DEPTH`), which is the constraint a rows table would have got for free.
552
+ cms_menus: Entity((t) => ({
553
+ id: primaryKey(generated(t.uuid())),
554
+ // The key `getMenu(name)` resolves — stable, referenced from layout code, and so NOT
555
+ // renameable through `updateMenu` (the label is what an editor retitles).
556
+ name: unique(notNull(t.text())),
557
+ label: notNull(t.text()),
558
+ items: t.json(), // MenuItem[]
559
+ // Optimistic concurrency, as on cms_pages/cms_blocks. It matters MORE here, not less:
560
+ // `updateMenu` writes the whole `items` document, so two editors on one menu meant the
561
+ // second silently replaced the first's entire tree — where a page edit at least
562
+ // conflicts per field.
563
+ version: defaultTo(t.int(), 1),
564
+ createdAt: defaultTo(t.text(), expr.now()),
565
+ updatedAt: defaultTo(t.text(), expr.now()),
566
+ })),
567
+ // A URL redirect. Needed the moment a slug changes on a live site — which the `slug`
568
+ // field's own docs already flag ("silently rewriting a slug changes a live URL and breaks
569
+ // every link to it").
570
+ //
571
+ // `fromPath`/`toPath`, not `from`/`to`: `from` is a SQL keyword, and while the dialect
572
+ // quotes every identifier, a column named `from` also collides with the `find({ from })`
573
+ // query key — a `where: { from: ... }` reads as a table reference to anyone skimming.
574
+ cms_redirects: Entity((t) => ({
575
+ id: primaryKey(generated(t.uuid())),
576
+ // Unique because resolution is an exact lookup: two rows for one path is a coin flip
577
+ // over which redirect a visitor gets, and the DB is the only place that can refuse it.
578
+ fromPath: unique(notNull(t.text())),
579
+ toPath: notNull(t.text()),
580
+ // 301 (permanent) or 302 (temporary). INT, and constrained on write — a redirect status
581
+ // is not free-form, and a typo here is a broken response, not a broken page.
582
+ status: defaultTo(t.int(), 301),
583
+ // Off-switch that keeps the row. A redirect is usually disabled to TEST whether it is
584
+ // still needed; deleting it loses the record of what the old URL was.
585
+ enabled: defaultTo(t.bool(), true),
586
+ note: t.text(),
587
+ createdAt: defaultTo(t.text(), expr.now()),
588
+ updatedAt: defaultTo(t.text(), expr.now()),
589
+ })),
590
+ // NOTE: deliberately no hit counter. Counting would make `resolveRedirect` — the one
591
+ // handler anonymous traffic calls on every 404 — a WRITE, which is an unauthenticated
592
+ // row mutation on the hot path and, on the DO, a transaction per miss. Redirect usage
593
+ // belongs in the edge's own logs.
594
+ // A classification vocabulary: `category` (hierarchical) and `tag` (flat) are just two
595
+ // rows here, which is why there is no built-in of either — a deployment declares what it
596
+ // classifies by, the same way it declares its content types.
597
+ cms_taxonomies: Entity((t) => ({
598
+ id: primaryKey(generated(t.uuid())),
599
+ slug: unique(notNull(t.text())),
600
+ label: notNull(t.text()),
601
+ pluralLabel: t.text(),
602
+ description: t.text(),
603
+ // Hierarchical vocabularies allow `parentId` on their terms; flat ones reject it on
604
+ // write. Enforced in the handler, not the schema — one term table serves both.
605
+ hierarchical: defaultTo(t.bool(), false),
606
+ createdAt: defaultTo(t.text(), expr.now()),
607
+ })),
608
+ cms_terms: Entity((t) => ({
609
+ id: primaryKey(generated(t.uuid())),
610
+ taxonomyId: indexed(notNull(t.uuid())),
611
+ slug: notNull(t.text()),
612
+ label: notNull(t.text()),
613
+ description: t.text(),
614
+ // Self-referential, and a REAL FK: deleting a parent term must not leave children
615
+ // pointing at a row that is gone (the front end would render an orphan branch that
616
+ // no listing can reach). `setNull` promotes them to the top level instead, which is
617
+ // the only non-destructive answer — `cascade` would silently delete a subtree.
618
+ parentId: t.uuid(),
619
+ position: defaultTo(t.int(), 0),
620
+ createdAt: defaultTo(t.text(), expr.now()),
621
+ }), (r) => ({
622
+ taxonomy: r.belongsTo("cms_taxonomies", "taxonomyId", { onDelete: "cascade" }),
623
+ parent: r.belongsTo("cms_terms", "parentId", { onDelete: "setNull" }),
624
+ pages: r.hasMany("cms_page_terms", "termId"),
625
+ }),
626
+ // A slug identifies a term WITHIN its vocabulary — `/category/news` and `/tag/news`
627
+ // are two different terms, and both are legitimate.
628
+ { unique: [["taxonomyId", "slug"]] }),
629
+ // The term-assignment junction — an EXPLICIT entity, which is what `manyToMany` means
630
+ // here: `ctx.db.insert("cms_page_terms", …)` links, `delete` unlinks, and `where`
631
+ // traverses it as a nested subquery. No synthetic table, no write API to learn.
632
+ cms_page_terms: Entity((t) => ({
633
+ id: primaryKey(generated(t.uuid())),
634
+ pageId: indexed(notNull(t.uuid())),
635
+ termId: indexed(notNull(t.uuid())),
636
+ }), (r) => ({
637
+ page: r.belongsTo("cms_pages", "pageId", { onDelete: "cascade" }),
638
+ term: r.belongsTo("cms_terms", "termId", { onDelete: "cascade" }),
639
+ }),
640
+ // One assignment per (page, term). Without it a double-submit leaves a page tagged
641
+ // twice and every `with: { terms: true }` renders the term twice.
642
+ { unique: [["pageId", "termId"]] }),
643
+ // A named template region an admin fills without touching code — the sidebar, the footer
644
+ // column, the pre-footer strip.
645
+ //
646
+ // Kept as its own entity rather than a page-less `cms_blocks` region, which was the
647
+ // tempting reuse. A block placement is `(pageId, region, position)` with `pageId` NOT
648
+ // NULL: making it nullable to model "belongs to no page" would put a null branch through
649
+ // every placement read, every region assemble and every page-scoped ACL clause, to model
650
+ // something that shares no field with a page (no slug, no status, no revisions, no
651
+ // workflow). A widget area is a small ordered document, and that is what it is stored as.
652
+ cms_widget_areas: Entity((t) => ({
653
+ id: primaryKey(generated(t.uuid())),
654
+ name: unique(notNull(t.text())),
655
+ label: notNull(t.text()),
656
+ description: t.text(),
657
+ widgets: t.json(), // Widget[]
658
+ // Same argument as cms_menus.version — `updateWidgetArea` replaces the whole list.
659
+ version: defaultTo(t.int(), 1),
660
+ createdAt: defaultTo(t.text(), expr.now()),
661
+ updatedAt: defaultTo(t.text(), expr.now()),
662
+ })),
362
663
  // Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
363
664
  // ctx.files + the Worker /files/* route. Block `fields` reference a media id.
364
665
  cms_media: Entity((t) => ({
@@ -371,6 +672,29 @@ export const cmsSchema = {
371
672
  createdAt: defaultTo(t.text(), expr.now()),
372
673
  })),
373
674
  };
675
+ /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
676
+ * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
677
+ export { adminPage, createAdminPageHandlers, normalizeAdminResponse, validateAdminPages, MAX_ADMIN_BLOCK_DEPTH, } from "./blockkit";
678
+ /**
679
+ * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
680
+ *
681
+ * `isoTimestampBackfill()` finds every column whose DEFAULT is `expr.now()` on its own.
682
+ * `cms_pages.publishedAt` has no default at all — `doPublish` stamped it from handler code,
683
+ * in the same space form, to stay comparable with the `updatedAt` written beside it. There
684
+ * is nothing on that column to find, so it is named here.
685
+ *
686
+ * Spread it into the migration if your store was ever written by a build older than this
687
+ * one:
688
+ *
689
+ * ```ts
690
+ * migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })]
691
+ * ```
692
+ *
693
+ * Costs nothing on a store that was not — the UPDATE matches no rows.
694
+ */
695
+ export const CMS_LEGACY_TIMESTAMP_COLUMNS = {
696
+ cms_pages: ["publishedAt"],
697
+ };
374
698
  /** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
375
699
  * the first violation. Recursive (repeater/group). Lenient on unknown field types. */
376
700
  /** A calendar date, `YYYY-MM-DD` (what an <input type="date"> emits) — must also parse. */
@@ -459,6 +783,22 @@ export function validateFields(schema, values, path = "", opts = {}) {
459
783
  if (typeof v !== "string")
460
784
  throw new BadRequest(`field '${at}' must be a media id (string)`);
461
785
  break;
786
+ // An OPAQUE id: the record may live in another table, or in a system we do not own,
787
+ // so there is nothing to check it against here beyond its shape. The picker's
788
+ // `referenceFrom` handler is the authority on which ids exist, and it runs under the
789
+ // caller's own ACL — so validating against a list fetched here would be both a second
790
+ // round trip and a weaker check than the one the storing handler already makes.
791
+ case "reference":
792
+ if (def.multiple) {
793
+ if (!Array.isArray(v))
794
+ throw new BadRequest(`field '${at}' must be a list of ids`);
795
+ if (v.some((id) => typeof id !== "string"))
796
+ throw new BadRequest(`field '${at}' must be a list of ids (strings)`);
797
+ }
798
+ else if (typeof v !== "string") {
799
+ throw new BadRequest(`field '${at}' must be an id (string)`);
800
+ }
801
+ break;
462
802
  case "group": {
463
803
  // The baseline MUST descend. Stopping at the top level meant a pre-migration
464
804
  // richtext value nested in a group was rejected on every write that echoed the
@@ -498,6 +838,216 @@ export function validateFields(schema, values, path = "", opts = {}) {
498
838
  }
499
839
  }
500
840
  }
841
+ // --- validating an AUTHORED field schema -------------------------------------
842
+ //
843
+ // `validateFields` above checks a VALUE against a schema. This checks the SCHEMA itself.
844
+ //
845
+ // It did not exist while the only way to create a block type was a developer writing
846
+ // `defineBlockType(...)` in the repo, where tsc is the check. Now that the editor authors
847
+ // types (GitHub #9), `fieldsSchema` arrives from a browser as free JSON into a `t.json()`
848
+ // column — and a malformed one is not caught anywhere downstream: `FieldForm` renders
849
+ // `null` for an unknown type, `validateFields` skips it ("lenient on unknown field types"),
850
+ // and the block silently loses that field's content on every save. A duplicate `name` is
851
+ // worse: two controls write the same key, so one of them can never be saved at all.
852
+ /** Every field type the runtime knows. Exported because the editor's type-builder offers
853
+ * exactly this list — one definition, so a type added here appears there without a second
854
+ * edit, and a type removed here cannot be authored. */
855
+ export const FIELD_TYPES = [
856
+ "text", "textarea", "richtext", "url", "number", "boolean", "date", "datetime",
857
+ "publish", "slug", "media", "select", "reference", "repeater", "group",
858
+ ];
859
+ /** Types whose `fields` nest a further schema. */
860
+ const NESTING_TYPES = ["group", "repeater"];
861
+ /** How deep an authored field schema may nest. A schema is rendered by a recursive
862
+ * component and validated by a recursive function, so the cap is what keeps both bounded
863
+ * against a hand-posted document; nothing real nests past two or three. */
864
+ export const MAX_FIELD_DEPTH = 5;
865
+ /** A field NAME is an object key in a `fields` bag and a property name in generated TS
866
+ * (`generateBlockTypes`), so it is held to what can be both. */
867
+ const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
868
+ /** A REGION name. Looser than a field name by one character, because the two are not the
869
+ * same kind of thing: a field name is emitted as a TS property by `generateBlockTypes`, so
870
+ * it must be an identifier, while a region name is only ever an object key on the assembled
871
+ * page (`regions["main-content"]`). Held to `FIELD_NAME` it rejected hyphenated names that
872
+ * pre-date this validation and are stored today — which would have made every future save
873
+ * of such a content type fail, with the only fix being a rename that orphans its
874
+ * placements. */
875
+ const REGION_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
876
+ /**
877
+ * Validate and canonicalize an authored `FieldDefinition[]`, throwing a 400 on the first
878
+ * problem. Returns the CLEANED schema — each field rebuilt from the keys its type actually
879
+ * uses, so a `select`'s stale `options` cannot ride along on a field someone switched to
880
+ * `text` and reappear if they switch back.
881
+ */
882
+ export function validateFieldSchema(raw, path = "fieldsSchema", depth = 0) {
883
+ if (raw == null)
884
+ return [];
885
+ if (!Array.isArray(raw))
886
+ throw new BadRequest(`${path} must be a list of field definitions`);
887
+ if (depth >= MAX_FIELD_DEPTH)
888
+ throw new BadRequest(`${path} nests deeper than ${MAX_FIELD_DEPTH} levels`);
889
+ const seen = new Set();
890
+ return raw.map((entry, i) => {
891
+ const o = asObj(entry);
892
+ const at = `${path}[${i}]`;
893
+ const name = typeof o.name === "string" ? o.name.trim() : "";
894
+ if (!FIELD_NAME.test(name)) {
895
+ throw new BadRequest(`${at}.name must be a field name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(o.name)}`);
896
+ }
897
+ // Siblings only — a nested `group` legitimately reuses a name from the outer level,
898
+ // because it writes into its own bag.
899
+ if (seen.has(name))
900
+ throw new BadRequest(`${path} declares '${name}' twice — two controls would write the same key and one could never be saved`);
901
+ seen.add(name);
902
+ const type = o.type;
903
+ if (!FIELD_TYPES.includes(type)) {
904
+ throw new BadRequest(`${at}.type is '${String(o.type)}', which is not a field type (known: ${FIELD_TYPES.join(", ")})`);
905
+ }
906
+ const f = { name, type };
907
+ if (typeof o.label === "string" && o.label.trim() !== "")
908
+ f.label = o.label.trim();
909
+ if (o.required === true)
910
+ f.required = true;
911
+ if (o.default !== undefined)
912
+ f.default = o.default;
913
+ if (NESTING_TYPES.includes(type)) {
914
+ f.fields = validateFieldSchema(o.fields, `${at}.fields`, depth + 1);
915
+ // A `group` with no fields renders an empty box; a `repeater` with none renders rows
916
+ // of nothing and an Add button. Both are the shape of a half-finished edit, and both
917
+ // are silently useless rather than visibly wrong, so they are refused here.
918
+ if (f.fields.length === 0)
919
+ throw new BadRequest(`${at} is a '${type}' and needs at least one nested field`);
920
+ if (type === "repeater") {
921
+ if (typeof o.min === "number" && Number.isFinite(o.min))
922
+ f.min = Math.max(0, Math.trunc(o.min));
923
+ if (typeof o.max === "number" && Number.isFinite(o.max))
924
+ f.max = Math.max(1, Math.trunc(o.max));
925
+ if (f.min != null && f.max != null && f.min > f.max)
926
+ throw new BadRequest(`${at} has min ${f.min} above max ${f.max}`);
927
+ }
928
+ }
929
+ else if (type === "select") {
930
+ // `optionsFrom` takes precedence at render time, so requiring options alongside it
931
+ // would reject the live-data case the option exists for.
932
+ if (typeof o.optionsFrom === "string" && o.optionsFrom.trim() !== "") {
933
+ f.optionsFrom = assertHandlerName(o.optionsFrom, `${at}.optionsFrom`);
934
+ }
935
+ else {
936
+ const options = Array.isArray(o.options) ? o.options.map((v) => String(v).trim()).filter((v) => v !== "") : [];
937
+ if (options.length === 0)
938
+ throw new BadRequest(`${at} is a 'select' and needs either \`options\` or an \`optionsFrom\` handler`);
939
+ if (new Set(options).size !== options.length)
940
+ throw new BadRequest(`${at} lists the same option twice`);
941
+ f.options = options;
942
+ }
943
+ }
944
+ else if (type === "slug") {
945
+ // `from` is optional (a slug typed by hand is legitimate), but naming a field that is
946
+ // not there is a control that silently never follows anything.
947
+ if (typeof o.from === "string" && o.from.trim() !== "")
948
+ f.from = o.from.trim();
949
+ }
950
+ else if (type === "reference") {
951
+ if (typeof o.referenceFrom !== "string" || o.referenceFrom.trim() === "") {
952
+ throw new BadRequest(`${at} is a 'reference' and needs a \`referenceFrom\` query handler`);
953
+ }
954
+ f.referenceFrom = assertHandlerName(o.referenceFrom, `${at}.referenceFrom`);
955
+ if (o.multiple === true)
956
+ f.multiple = true;
957
+ }
958
+ return f;
959
+ });
960
+ }
961
+ /** Resolve `slug` cross-references inside one schema, now that every sibling is known: a
962
+ * `slug` field's `from` must name a field that exists AT THE SAME LEVEL (the editor reads
963
+ * it out of the sibling bag) and holds text. Separate pass because forward references are
964
+ * legitimate — a slug may precede the title it follows. */
965
+ export function checkSlugSources(schema, path = "fieldsSchema") {
966
+ const byName = new Map(schema.map((f) => [f.name, f]));
967
+ schema.forEach((f, i) => {
968
+ if (f.type === "slug" && f.from) {
969
+ const src = byName.get(f.from);
970
+ if (!src)
971
+ throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is not a field alongside it`);
972
+ if (!["text", "textarea", "select", "url"].includes(src.type)) {
973
+ throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is a '${src.type}' — a slug can only follow a text field`);
974
+ }
975
+ }
976
+ if (f.fields)
977
+ checkSlugSources(f.fields, `${path}[${i}].fields`);
978
+ });
979
+ }
980
+ /** Validate + canonicalize an authored field schema end to end. */
981
+ export function normalizeFieldSchema(raw, path = "fieldsSchema") {
982
+ const schema = validateFieldSchema(raw, path);
983
+ checkSlugSources(schema, path);
984
+ return schema;
985
+ }
986
+ /**
987
+ * Validate a content type's `regions`.
988
+ *
989
+ * A region NAME is the key `addBlock({ region })` resolves and the key of the assembled
990
+ * `regions` object a front end reads, so it is held to the same shape as a field name. An
991
+ * `allowedTypes` entry is a block-type SLUG; it is not checked against the block types that
992
+ * exist, on purpose — a content type declaring a region for a block type that has not been
993
+ * created yet is an ordinary order of work, and `assertRegionAllows` is what enforces the
994
+ * list at placement time.
995
+ */
996
+ export function normalizeRegions(raw) {
997
+ if (!Array.isArray(raw) || raw.length === 0)
998
+ throw new BadRequest("at least one region is required");
999
+ const seen = new Set();
1000
+ return raw.map((entry, i) => {
1001
+ const o = asObj(entry);
1002
+ const name = typeof o.name === "string" ? o.name.trim() : "";
1003
+ if (!REGION_NAME.test(name))
1004
+ throw new BadRequest(`regions[${i}].name must be a region name (a letter or underscore, then letters/digits/hyphens/underscores), got ${JSON.stringify(o.name)}`);
1005
+ if (seen.has(name))
1006
+ throw new BadRequest(`regions declares '${name}' twice — the assembled page is keyed by region name, so one would overwrite the other`);
1007
+ seen.add(name);
1008
+ const region = { name };
1009
+ if (typeof o.label === "string" && o.label.trim() !== "")
1010
+ region.label = o.label.trim();
1011
+ // `null` and omitted both mean "any block type"; an empty ARRAY means "none", which is
1012
+ // a region nothing can ever be placed in. Almost always a half-finished edit, so it is
1013
+ // normalized to "any" rather than stored as a region that silently refuses everything.
1014
+ if (Array.isArray(o.allowedTypes)) {
1015
+ const allowed = o.allowedTypes.map((v) => String(v).trim()).filter((v) => v !== "");
1016
+ region.allowedTypes = allowed.length > 0 ? [...new Set(allowed)] : null;
1017
+ }
1018
+ else {
1019
+ region.allowedTypes = null;
1020
+ }
1021
+ return region;
1022
+ });
1023
+ }
1024
+ /** Validate a content type's `defaultBlocks` against its own regions. A default block that
1025
+ * names a region the type does not declare is created into nowhere — `createPage` would
1026
+ * place it under a key no renderer reads. */
1027
+ export function normalizeDefaultBlocks(raw, regions) {
1028
+ if (raw == null)
1029
+ return [];
1030
+ if (!Array.isArray(raw))
1031
+ throw new BadRequest("defaultBlocks must be a list");
1032
+ const names = new Set(regions.map((r) => r.name));
1033
+ return raw.map((entry, i) => {
1034
+ const o = asObj(entry);
1035
+ const region = typeof o.region === "string" ? o.region.trim() : "";
1036
+ const blockTypeSlug = typeof o.blockTypeSlug === "string" ? o.blockTypeSlug.trim() : "";
1037
+ if (!names.has(region))
1038
+ throw new BadRequest(`defaultBlocks[${i}] targets region '${region}', which this content type does not declare`);
1039
+ if (!blockTypeSlug)
1040
+ throw new BadRequest(`defaultBlocks[${i}] needs a blockTypeSlug`);
1041
+ const allowed = regions.find((r) => r.name === region)?.allowedTypes;
1042
+ if (allowed && !allowed.includes(blockTypeSlug)) {
1043
+ throw new BadRequest(`defaultBlocks[${i}] places '${blockTypeSlug}' into region '${region}', which does not allow it`);
1044
+ }
1045
+ const out = { region, blockTypeSlug };
1046
+ if (o.fields !== undefined)
1047
+ out.fields = asObj(o.fields);
1048
+ return out;
1049
+ });
1050
+ }
501
1051
  /** What the shipped editor can actually produce (TipTap StarterKit + Highlight + TaskList,
502
1052
  * as configured by @podoba/react's BlockEditor). Pass your own to `normalizeFields` if your
503
1053
  * editor adds extensions — a node type absent from the schema is dropped on write. */
@@ -769,14 +1319,31 @@ function resolveMediaFields(fields, schema, mediaById) {
769
1319
  const cdb = (ctx) => ctx.db;
770
1320
  const notFound = (what) => new PramenError(`${what} not found`, 404, "not_found");
771
1321
  const asObj = (v) => (v && typeof v === "object" ? v : {});
772
- // Timestamps in the SAME shape as the `expr.now()` column default (`datetime('now')`:
773
- // "YYYY-MM-DD HH:MM:SS", UTC, second precision) so a column's insert-default and its
774
- // handler-written updates stay lexically comparable (an ISO `T`/`Z` string sorts wrong).
775
- const nowStamp = () => new Date().toISOString().slice(0, 19).replace("T", " ");
776
- const isEditor = (ctx, roles) => {
777
- const held = ctx.identity?.roles ?? (ctx.identity?.role ? [ctx.identity.role] : []);
778
- return held.some((r) => roles.includes(r));
779
- };
1322
+ /**
1323
+ * An ISO-8601 UTC instant the ONE format every timestamp this package writes by hand is
1324
+ * in, so it compares correctly against `$now()` and against the `expr.now()` column
1325
+ * defaults beside it.
1326
+ *
1327
+ * There used to be two of these. `expr.now()` emitted the `datetime('now')` space form, so
1328
+ * page-workflow stamps (`updatedAt`, `publishedAt`) matched THAT to stay lexically
1329
+ * comparable with their own column default, while collection managed timestamps minted ISO
1330
+ * to stay comparable with `$now()`. One column could not satisfy both, and the split was
1331
+ * the honest way to live with it — a trap documented at length on the `publish` field.
1332
+ *
1333
+ * `expr.now()` is ISO now, so the two requirements are the same requirement and there is
1334
+ * one helper. Existing rows written in the old shape are rewritten by
1335
+ * `isoTimestampBackfill()` (see `CMS_LEGACY_TIMESTAMP_COLUMNS`).
1336
+ */
1337
+ const isoStamp = () => new Date().toISOString();
1338
+ /** Alias kept because the page-workflow call sites read as "stamp it now". Same function. */
1339
+ const nowStamp = isoStamp;
1340
+ /** Does the caller hold one of these roles?
1341
+ *
1342
+ * Delegates to `authorizeHandler`, which is what the dispatcher uses to enforce a handler's
1343
+ * own `auth` — so "may call this handler" and "counts as an editor here" cannot answer
1344
+ * differently. The local copy took `roles` OR `role`, where the framework takes the UNION,
1345
+ * so an identity carrying both saw only one of them. */
1346
+ const isEditor = (ctx, roles) => authorizeHandler([...roles], ctx.identity ?? null);
780
1347
  /** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
781
1348
  * by position, merging each shared placement's `overrides` over its block's fields. */
782
1349
  /** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
@@ -933,6 +1500,441 @@ async function assertRegionAllows(db, page, region, blockTypeSlug) {
933
1500
  throw new BadRequest(`block type '${blockTypeSlug}' is not allowed in region '${region}'`);
934
1501
  }
935
1502
  }
1503
+ // --- handlers ----------------------------------------------------------------
1504
+ // --- page preview links (signed capability urls) -----------------------------
1505
+ //
1506
+ // Preview used to be a ROLE check, so previewing a draft required an editor account —
1507
+ // which excludes the person preview actually exists for: the stakeholder reviewing copy
1508
+ // before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
1509
+ // ONE page, carries its own expiry, and is verified in the Worker before any read happens.
1510
+ // Minting stays editor-gated; redeeming needs no account at all.
1511
+ //
1512
+ // Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
1513
+ // the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
1514
+ // forgeable links.
1515
+ // --- site furniture: normalization + resolution helpers ----------------------
1516
+ //
1517
+ // All of this runs on the WRITE path. A menu, a term tree and a widget list are documents
1518
+ // the client posts whole, so "the editor wouldn't send that" is not a boundary — every one
1519
+ // of these shapes is reachable with a curl.
1520
+ const MENU_ITEM_KINDS = ["custom", "page", "term", "collection"];
1521
+ /** A stable machine key — the string `getMenu(name)` / `getWidgetArea(name)` resolves, and
1522
+ * a taxonomy's URL segment. Same rule as a page slug, and for the same reason: it lands in
1523
+ * a route. */
1524
+ function assertKey(v, what) {
1525
+ const s = typeof v === "string" ? v.trim() : "";
1526
+ if (!isSlugString(s))
1527
+ throw new BadRequest(`${what} must be a key (lowercase letters, digits and single hyphens), got ${JSON.stringify(v)}`);
1528
+ return s;
1529
+ }
1530
+ /** A REGISTRY key — a block type's slug. Looser than {@link assertKey} by one character:
1531
+ * underscores are admitted, because a block-type slug is not a URL segment. It is the key a
1532
+ * front end maps to a component (`{ rich_text: RichText }`), and `rich_text` is the
1533
+ * convention every existing schema and the shipped example already use. */
1534
+ function assertRegistryKey(v, what) {
1535
+ const str = typeof v === "string" ? v.trim() : "";
1536
+ if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(str) || str.length > 80) {
1537
+ throw new BadRequest(`${what} must be a key (lowercase letters, digits, and single hyphens or underscores), got ${JSON.stringify(v)}`);
1538
+ }
1539
+ return str;
1540
+ }
1541
+ /** Refuse an editor write to a CODE-DEFINED type.
1542
+ *
1543
+ * `cmsBootstrap` reconciles these rows on every boot, so a save here would return 200 and
1544
+ * then be reverted at the next cold start, taking any content authored against the added
1545
+ * field with it. A 409 is the honest answer: the row exists, the edit is well-formed, and
1546
+ * the conflict is with a definition that lives somewhere this request cannot reach.
1547
+ *
1548
+ * The editor renders a managed type read-only, so this is the curl / stale-tab half.
1549
+ *
1550
+ * The caller MUST have read `managedBy` explicitly (`select`), not taken it off a wide read.
1551
+ * Reads are column-projected against the caller's policy, so under a read policy with a
1552
+ * `fields` list the column is simply absent — and a guard written as "absent means editable"
1553
+ * disarms itself for exactly the deployments that restrict fields. `select` fails CLOSED
1554
+ * instead: an unreadable column is a 403 before this runs. */
1555
+ export function assertNotManaged(row, what, defineFn) {
1556
+ if (!("managedBy" in row))
1557
+ throw new Error(`assertNotManaged: 'managedBy' was not selected for ${what} — the guard would fail open`);
1558
+ if (row.managedBy == null)
1559
+ return;
1560
+ throw new Conflict(`${what} '${String(row.slug)}' is defined in code (cmsBootstrap owner '${String(row.managedBy)}') — edit its ` +
1561
+ `${defineFn}(...) declaration and redeploy. A change saved here would be reverted on the next boot.`);
1562
+ }
1563
+ /**
1564
+ * An RPC handler name, as an authored field schema may name one (`optionsFrom`,
1565
+ * `referenceFrom`).
1566
+ *
1567
+ * Shape-checked rather than merely non-empty, because the editor interpolates it straight
1568
+ * into a request path — `fetch(\`${base}/rpc/${name}\`)`. `"../admin/data"` normalizes to
1569
+ * `/admin/data`, so a stored string an EDITOR authored became an arbitrary same-origin
1570
+ * authenticated POST fired by whoever opened the block — including an admin, whose token
1571
+ * passes the `/admin/*` gate the editor role cannot. A handler name is an identifier;
1572
+ * nothing that can traverse a path is one.
1573
+ */
1574
+ function assertHandlerName(v, what) {
1575
+ const str = typeof v === "string" ? v.trim() : "";
1576
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(str) || str.length > 80) {
1577
+ throw new BadRequest(`${what} must be a handler name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(v)}`);
1578
+ }
1579
+ return str;
1580
+ }
1581
+ /**
1582
+ * A menu item's `ref` for a non-`custom` kind.
1583
+ *
1584
+ * `menuHref` interpolates this into a path, so a ref starting with `/` produced
1585
+ * `//evil.example/` — protocol-relative, off-origin, in the site's primary nav on every
1586
+ * page — while the sibling `custom` branch three lines away ran the same string through
1587
+ * `isSafeHref`. A reference is an id or a slug: no slashes, no scheme, no dots.
1588
+ */
1589
+ function assertRef(v, label, kind) {
1590
+ const str = typeof v === "string" ? v.trim() : "";
1591
+ if (!/^[A-Za-z0-9_-]{1,200}$/.test(str)) {
1592
+ throw new BadRequest(`menu item '${label}' is a '${kind}' item, so its \`ref\` must be an id or slug (letters, digits, hyphens, underscores), got ${JSON.stringify(v)}`);
1593
+ }
1594
+ return str;
1595
+ }
1596
+ function assertLabel(v, what) {
1597
+ const s = typeof v === "string" ? v.trim() : "";
1598
+ if (!s)
1599
+ throw new BadRequest(`${what} must not be empty`);
1600
+ return s;
1601
+ }
1602
+ /**
1603
+ * Validate + canonicalize a posted menu tree.
1604
+ *
1605
+ * Every item is rebuilt field by field rather than spread: `items` is a `t.json()` column,
1606
+ * so anything in the posted object would be stored verbatim and handed to a layout that
1607
+ * renders it. Rebuilding is what makes the stored document exactly the declared shape.
1608
+ */
1609
+ function normalizeMenuItems(raw, depth = 0, budget = { left: MAX_MENU_ITEMS }) {
1610
+ if (!Array.isArray(raw)) {
1611
+ if (raw == null)
1612
+ return [];
1613
+ throw new BadRequest("menu items must be a list");
1614
+ }
1615
+ if (depth >= MAX_MENU_DEPTH)
1616
+ throw new BadRequest(`menu items may nest at most ${MAX_MENU_DEPTH} levels deep`);
1617
+ return raw.map((entry, i) => {
1618
+ // Counted across the WHOLE tree, not per level — the budget is threaded through the
1619
+ // recursion for that reason.
1620
+ if (--budget.left < 0)
1621
+ throw new BadRequest(`a menu may hold at most ${MAX_MENU_ITEMS} items`);
1622
+ const o = asObj(entry);
1623
+ const label = assertLabel(o.label, `menu item [${i}] label`);
1624
+ const kind = (typeof o.kind === "string" ? o.kind : "custom");
1625
+ if (!MENU_ITEM_KINDS.includes(kind)) {
1626
+ throw new BadRequest(`menu item '${label}' has unknown kind '${String(o.kind)}' (known: ${MENU_ITEM_KINDS.join(", ")})`);
1627
+ }
1628
+ // A stable id per item. Minted here when absent so a client that posts a tree without
1629
+ // ids still gets reorderable rows back, rather than a list React can only key by index.
1630
+ const item = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), label, kind };
1631
+ if (kind === "custom") {
1632
+ // The SAME allow-list a rich-text link mark goes through. A menu is rendered into an
1633
+ // `<a href>` on every page of the site, so `javascript:` here is exactly the hole
1634
+ // `isSafeHref` exists to close — and the editor is not the only writer.
1635
+ const url = normalizeHref(typeof o.url === "string" ? o.url : "");
1636
+ if (!isSafeHref(url))
1637
+ throw new BadRequest(`menu item '${label}' needs a valid url (http(s), mailto:, tel:, a rooted path, or #anchor)`);
1638
+ item.url = url;
1639
+ }
1640
+ else {
1641
+ item.ref = assertRef(o.ref, label, kind);
1642
+ }
1643
+ // `target` is written into an anchor; anything but the four browsing-context keywords
1644
+ // is a named window, which is a way to reuse a tab the site does not own.
1645
+ if (typeof o.target === "string" && o.target !== "") {
1646
+ if (!["_self", "_blank", "_parent", "_top"].includes(o.target))
1647
+ throw new BadRequest(`menu item '${label}' has an unsupported target '${o.target}'`);
1648
+ item.target = o.target;
1649
+ }
1650
+ if (typeof o.titleAttr === "string" && o.titleAttr !== "")
1651
+ item.titleAttr = o.titleAttr;
1652
+ if (typeof o.cssClasses === "string" && o.cssClasses !== "")
1653
+ item.cssClasses = o.cssClasses;
1654
+ const children = normalizeMenuItems(o.children, depth + 1, budget);
1655
+ if (children.length > 0)
1656
+ item.children = children;
1657
+ return item;
1658
+ });
1659
+ }
1660
+ /** Every `page`/`term` ref in a tree, so resolution is two queries rather than one per item. */
1661
+ function collectMenuRefs(items, pages, terms) {
1662
+ for (const it of items) {
1663
+ if (it.ref) {
1664
+ if (it.kind === "page")
1665
+ pages.add(it.ref);
1666
+ else if (it.kind === "term")
1667
+ terms.add(it.ref);
1668
+ }
1669
+ if (it.children)
1670
+ collectMenuRefs(it.children, pages, terms);
1671
+ }
1672
+ }
1673
+ /** A URL redirect's status. 301/308 are permanent (cached by browsers, and by search
1674
+ * engines as a canonical move); 302/307 are not. Anything else is not a redirect. */
1675
+ export const REDIRECT_STATUSES = [301, 302, 307, 308];
1676
+ /**
1677
+ * The stored form of a redirect's `fromPath`: a rooted, query-less, fragment-less path.
1678
+ *
1679
+ * Canonicalized rather than merely validated, because matching is an exact string lookup
1680
+ * against a UNIQUE column. `"/old"` and `"/old/"` are the same URL to a visitor and two
1681
+ * rows here, so the second one is dead the moment the first exists — and which one wins is
1682
+ * whichever the editor happened to type. Trailing slash off (except the root), fragment
1683
+ * and query dropped, percent-encoding left exactly as written (the parser's, and the
1684
+ * request's, canonical form).
1685
+ */
1686
+ export function normalizeRedirectPath(raw) {
1687
+ const s = typeof raw === "string" ? normalizeHref(raw) : "";
1688
+ if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) {
1689
+ throw new BadRequest(`redirect path must be a rooted path like /old-url, got ${JSON.stringify(raw)}`);
1690
+ }
1691
+ const path = s.split("#")[0].split("?")[0];
1692
+ const trimmed = path.length > 1 ? path.replace(/\/+$/, "") || "/" : "/";
1693
+ // PERCENT-ENCODED, through the same parser the request goes through. A visitor's path
1694
+ // reaches `resolveRedirect` as `url.pathname`, which the WHATWG parser has already
1695
+ // encoded — so an editor typing `/o-nás` stored a string that the exact-match lookup
1696
+ // could never be handed, and the redirect silently never fired. On precisely the
1697
+ // non-English sites where slug changes are most common. Idempotent: an already-encoded
1698
+ // path parses back to itself.
1699
+ try {
1700
+ return new URL(trimmed, "https://pramen.invalid").pathname;
1701
+ }
1702
+ catch {
1703
+ throw new BadRequest(`redirect path is not a usable path: ${JSON.stringify(raw)}`);
1704
+ }
1705
+ }
1706
+ /**
1707
+ * Is this redirect a loop — does its destination resolve back to its own source?
1708
+ *
1709
+ * Compared through `normalizeRedirectPath` on BOTH sides, which a raw `from === to` did
1710
+ * not do: `from: "/old", to: "/old/"` differ as strings, so the guard passed — and then a
1711
+ * visitor hitting `/old` was sent to `/old/`, whose 404 handler canonicalizes the trailing
1712
+ * slash back to `/old` and matches the same row. An infinite redirect, from the one pair
1713
+ * the guard exists to catch. (A test here even asserted this pair was fine, on the reading
1714
+ * that a trailing-slash redirect is a normal canonicalization — true in general, and not
1715
+ * true when the lookup canonicalizes the slash away again.)
1716
+ *
1717
+ * An absolute destination is never a loop with a rooted source: it names an origin, and
1718
+ * `resolveRedirect` is only ever handed a path.
1719
+ */
1720
+ function isSelfRedirect(fromPath, toPath) {
1721
+ if (/^https?:\/\//i.test(toPath))
1722
+ return false;
1723
+ try {
1724
+ return normalizeRedirectPath(toPath) === fromPath;
1725
+ }
1726
+ catch {
1727
+ return false;
1728
+ }
1729
+ }
1730
+ /** A redirect's destination: a rooted path or an absolute http(s) url. `mailto:`/`tel:` are
1731
+ * refused — they are not somewhere a `Location` header can send a page request. */
1732
+ function normalizeRedirectTarget(raw) {
1733
+ const s = typeof raw === "string" ? normalizeHref(raw) : "";
1734
+ const ok = /^https?:\/\//i.test(s) || (s.startsWith("/") && !s.startsWith("//") && !s.startsWith("/\\"));
1735
+ if (!ok)
1736
+ throw new BadRequest(`redirect target must be a rooted path or an absolute http(s) url, got ${JSON.stringify(raw)}`);
1737
+ return s;
1738
+ }
1739
+ /** Assemble a flat term list into a forest, ordered by `position` then `label`.
1740
+ *
1741
+ * A term whose `parentId` names a row that is not in `rows` is treated as a ROOT rather
1742
+ * than dropped. That is the case where a parent was deleted mid-read (the FK sets children
1743
+ * to NULL, but a snapshot taken across the two states can see the old value) — and a term
1744
+ * that vanishes from a vocabulary listing is a worse answer than one that shows up a level
1745
+ * too high.
1746
+ */
1747
+ function buildTermTree(rows) {
1748
+ const byId = new Map();
1749
+ for (const r of rows)
1750
+ byId.set(r.id, { ...r, children: [] });
1751
+ const roots = [];
1752
+ for (const node of byId.values()) {
1753
+ const parent = node.parentId ? byId.get(node.parentId) : undefined;
1754
+ // `parent !== node` guards the one cycle a single row can make on its own; deeper
1755
+ // cycles are refused on write by `assertTermParent`, which is where a cycle is
1756
+ // actually preventable.
1757
+ if (parent && parent !== node)
1758
+ parent.children.push(node);
1759
+ else
1760
+ roots.push(node);
1761
+ }
1762
+ const sort = (list) => {
1763
+ list.sort((a, b) => a.position - b.position || a.label.localeCompare(b.label));
1764
+ for (const t of list)
1765
+ if (t.children)
1766
+ sort(t.children);
1767
+ return list;
1768
+ };
1769
+ return sort(roots);
1770
+ }
1771
+ const WIDGET_TYPES = ["content", "menu", "component"];
1772
+ /** A list limit from client input, clamped to what `listPages` already allows. Absent or
1773
+ * unusable falls back to the default rather than to "unbounded" — a request with no limit
1774
+ * on a store reached over RPC is the shape that made lists hang (GitHub #22). */
1775
+ function clampLimit(v) {
1776
+ const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : PAGE_LIST_LIMIT;
1777
+ return Math.max(1, Math.min(n, PAGE_LIST_MAX_LIMIT));
1778
+ }
1779
+ /** The most terms one vocabulary (or one page) may carry in a single read.
1780
+ *
1781
+ * A vocabulary is read WHOLE by `listTerms`/`getTermTree` — a tree cannot be paged without
1782
+ * either losing branches or fetching ancestors separately — so the cap is what keeps that
1783
+ * read bounded. Tags are the case that grows without anyone deciding to grow it. */
1784
+ const MAX_TERMS = 1000;
1785
+ function redirectPatch(raw, requireEnds) {
1786
+ const o = asObj(raw);
1787
+ const out = {};
1788
+ if (requireEnds || o.fromPath !== undefined)
1789
+ out.fromPath = normalizeRedirectPath(o.fromPath);
1790
+ if (requireEnds || o.toPath !== undefined)
1791
+ out.toPath = normalizeRedirectTarget(o.toPath);
1792
+ if (o.status !== undefined) {
1793
+ const status = typeof o.status === "number" ? Math.trunc(o.status) : NaN;
1794
+ if (!REDIRECT_STATUSES.includes(status))
1795
+ throw new BadRequest(`redirect status must be one of ${REDIRECT_STATUSES.join(", ")}`);
1796
+ out.status = status;
1797
+ }
1798
+ if (o.enabled !== undefined)
1799
+ out.enabled = Boolean(o.enabled);
1800
+ if (o.note !== undefined)
1801
+ out.note = typeof o.note === "string" ? o.note : null;
1802
+ return out;
1803
+ }
1804
+ /** A required row id from client input. */
1805
+ function requireId(raw, what = "id") {
1806
+ const v = asObj(raw)[what];
1807
+ if (typeof v !== "string" || v === "")
1808
+ throw new BadRequest(`${what} is required`);
1809
+ return v;
1810
+ }
1811
+ /** Resolve a taxonomy by its slug. */
1812
+ async function taxonomyBySlug(db, slug) {
1813
+ const rows = await db.find({ from: "cms_taxonomies", where: { slug }, select: ["id", "hierarchical"], limit: 1 });
1814
+ const row = rows[0];
1815
+ return row ? { id: String(row.id), hierarchical: Boolean(row.hierarchical) } : null;
1816
+ }
1817
+ /**
1818
+ * Check a proposed `parentId` for a term: it exists, it is in the SAME vocabulary, the
1819
+ * vocabulary is hierarchical, the tree stays inside {@link MAX_TERM_DEPTH}, and — for an
1820
+ * update — the new parent is not the term itself or one of its own descendants.
1821
+ *
1822
+ * The cycle check is the one that matters. `ON DELETE SET NULL` keeps the FK honest but
1823
+ * says nothing about shape, so `A.parent = B; B.parent = A` is two perfectly legal writes
1824
+ * that together make `buildTermTree` produce a forest missing both, and any recursive
1825
+ * renderer loop forever. It is only preventable on write, which is here.
1826
+ */
1827
+ async function assertTermParent(db, tax, parentId, termId) {
1828
+ if (parentId === null)
1829
+ return;
1830
+ if (!tax.hierarchical)
1831
+ throw new BadRequest("this vocabulary is flat — its terms cannot have a parent");
1832
+ if (termId !== null && parentId === termId)
1833
+ throw new BadRequest("a term cannot be its own parent");
1834
+ // How many levels the MOVED term itself occupies. A leaf is 1; a term with children takes
1835
+ // its subtree with it, and a cap that ignored that admitted a 5-level tree grafted under a
1836
+ // 4-level parent. Zero cost on the create path, where there is no subtree yet.
1837
+ const moving = termId === null ? 1 : await subtreeHeight(db, tax.id, termId);
1838
+ let cursor = parentId;
1839
+ // `depth` counts ANCESTORS walked. The moved term sits at `ancestors + moving` levels, and
1840
+ // that is what the cap governs — counting ancestors alone admitted one level too many
1841
+ // (a chain of 5 put the new term at level 6 under a cap of 5).
1842
+ for (let depth = 0; cursor !== null; depth++) {
1843
+ // About to walk ancestor number `depth + 1`. The moved term would then sit at
1844
+ // `(depth + 1) + moving` levels, and THAT is what the cap governs.
1845
+ if (depth + 1 + moving > MAX_TERM_DEPTH)
1846
+ throw new BadRequest(`terms may nest at most ${MAX_TERM_DEPTH} levels deep`);
1847
+ const rows = await db.find({ from: "cms_terms", where: { id: cursor }, select: ["id", "taxonomyId", "parentId"], limit: 1 });
1848
+ const row = rows[0];
1849
+ if (!row)
1850
+ throw new BadRequest("parent term not found");
1851
+ if (String(row.taxonomyId) !== tax.id)
1852
+ throw new BadRequest("a term's parent must be in the same vocabulary");
1853
+ // Walking UP from the proposed parent: meeting the term being edited means the parent
1854
+ // is one of its own descendants, which is the cycle.
1855
+ if (termId !== null && String(row.id) === termId)
1856
+ throw new BadRequest("a term cannot be moved under one of its own descendants");
1857
+ cursor = row.parentId == null ? null : String(row.parentId);
1858
+ }
1859
+ }
1860
+ /** How many levels a term's own subtree occupies (a leaf is 1).
1861
+ *
1862
+ * One query for the whole vocabulary rather than a walk per level: a vocabulary is already
1863
+ * read whole by `listTerms`/`getTermTree` and capped at `MAX_TERMS`, so this is the same
1864
+ * bounded read those make, not a new unbounded one. */
1865
+ async function subtreeHeight(db, taxonomyId, rootId) {
1866
+ const rows = await db.find({ from: "cms_terms", where: { taxonomyId }, select: ["id", "parentId"], limit: MAX_TERMS });
1867
+ const children = new Map();
1868
+ for (const r of rows) {
1869
+ const parent = r.parentId == null ? null : String(r.parentId);
1870
+ if (parent)
1871
+ children.set(parent, [...(children.get(parent) ?? []), String(r.id)]);
1872
+ }
1873
+ // Iterative, and bounded by MAX_TERMS: a cycle already in the store (written before the
1874
+ // check that now prevents one) must not spin here.
1875
+ let level = 0;
1876
+ let frontier = [rootId];
1877
+ const seen = new Set();
1878
+ while (frontier.length > 0 && level <= MAX_TERM_DEPTH + 1) {
1879
+ level++;
1880
+ const next = [];
1881
+ for (const id of frontier) {
1882
+ if (seen.has(id))
1883
+ continue;
1884
+ seen.add(id);
1885
+ next.push(...(children.get(id) ?? []));
1886
+ }
1887
+ frontier = next;
1888
+ }
1889
+ return level;
1890
+ }
1891
+ /**
1892
+ * Validate + canonicalize a posted widget list.
1893
+ *
1894
+ * Rebuilt field by field for the same reason a menu tree is: `widgets` is a `t.json()`
1895
+ * column handed straight to a layout, so whatever the client posts is what renders.
1896
+ * A `content` widget's rich text goes through the SAME `normalizeRichText` allow-list every
1897
+ * block field does — this is a second write path into the same renderer, and it must not be
1898
+ * a weaker one.
1899
+ */
1900
+ function normalizeWidgets(raw, rtSchema) {
1901
+ if (!Array.isArray(raw)) {
1902
+ if (raw == null)
1903
+ return [];
1904
+ throw new BadRequest("widgets must be a list");
1905
+ }
1906
+ return raw.map((entry, i) => {
1907
+ const o = asObj(entry);
1908
+ const type = (typeof o.type === "string" ? o.type : "");
1909
+ if (!WIDGET_TYPES.includes(type))
1910
+ throw new BadRequest(`widget [${i}] has unknown type '${String(o.type)}' (known: ${WIDGET_TYPES.join(", ")})`);
1911
+ const w = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), type };
1912
+ if (typeof o.title === "string" && o.title !== "")
1913
+ w.title = o.title;
1914
+ if (type === "content") {
1915
+ w.content = normalizeRichText(o.content, rtSchema);
1916
+ }
1917
+ else if (type === "menu") {
1918
+ w.menuName = assertKey(o.menuName, `widget [${i}] menuName`);
1919
+ }
1920
+ else {
1921
+ const id = typeof o.componentId === "string" ? o.componentId.trim() : "";
1922
+ if (!id)
1923
+ throw new BadRequest(`widget [${i}] is a component widget and needs a componentId`);
1924
+ w.componentId = id;
1925
+ // Props are opaque to the CMS — the front end's component owns their meaning — but
1926
+ // they must be a JSON OBJECT, not a bare array or scalar that a spread would silently
1927
+ // turn into indexed props.
1928
+ if (o.componentProps !== undefined) {
1929
+ if (o.componentProps === null || typeof o.componentProps !== "object" || Array.isArray(o.componentProps)) {
1930
+ throw new BadRequest(`widget [${i}] componentProps must be an object`);
1931
+ }
1932
+ w.componentProps = o.componentProps;
1933
+ }
1934
+ }
1935
+ return w;
1936
+ });
1937
+ }
936
1938
  /** Secret preference order. `PREVIEW_SECRET` lets an operator rotate preview links without
937
1939
  * invalidating every signed file url, but falling back keeps the common case zero-config. */
938
1940
  export const PREVIEW_SECRET_NAMES = ["PREVIEW_SECRET", "FILES_SECRET", "AUTH_SECRET"];
@@ -977,6 +1979,16 @@ export function createCmsHandlers(opts = {}) {
977
1979
  const reviewer = { auth: reviewerRoles };
978
1980
  const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
979
1981
  const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
1982
+ const menuHref = opts.menuHref ?? ((target) => {
1983
+ switch (target.kind) {
1984
+ // The locale segment appears only where there is a choice to make. A monolingual site
1985
+ // getting `/en/about` is the sitemap default's known wart, and a menu is the one place
1986
+ // it would be visible in the site's own chrome.
1987
+ case "page": return locales.length > 1 ? `/${target.locale}/${target.slug}` : `/${target.slug}`;
1988
+ case "term": return `/${target.taxonomy}/${target.slug}`;
1989
+ case "collection": return `/${target.slug}/`;
1990
+ }
1991
+ });
980
1992
  // Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
981
1993
  // content type/blocks before approving). Read/preview handlers use this; writes stay editor.
982
1994
  const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
@@ -1071,13 +2083,95 @@ export function createCmsHandlers(opts = {}) {
1071
2083
  throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'${under} — slugs are unique across all content types`);
1072
2084
  }
1073
2085
  };
2086
+ /**
2087
+ * One stored menu row, with its references resolved to hrefs.
2088
+ *
2089
+ * Shared by `getMenu` and `getWidgetArea` rather than inlined in the first: a `menu`
2090
+ * widget embeds a menu, and returning its RAW items there skipped every rule this
2091
+ * function exists to apply — a `page` item came back with no `url` at all (the layout
2092
+ * renders `href=undefined`) and an UNPUBLISHED page's label and id were served to
2093
+ * anonymous callers, which is precisely what the drop below prevents on the other path.
2094
+ */
2095
+ const resolveMenuRow = async (db, row) => {
2096
+ const items = Array.isArray(row.items) ? row.items : [];
2097
+ // Two lookups for the whole tree, not one per item. Both go through `ctx.db`, so the
2098
+ // caller's own read scope applies: for an anonymous visitor that is the public policy
2099
+ // (published, not trashed), which is precisely the filter a menu needs — a link to a
2100
+ // page that has been unpublished must not render.
2101
+ const pageIds = new Set();
2102
+ const termIds = new Set();
2103
+ collectMenuRefs(items, pageIds, termIds);
2104
+ const pages = new Map();
2105
+ if (pageIds.size > 0) {
2106
+ const found = await db.find({ from: "cms_pages", where: { id: { in: [...pageIds] } }, select: ["id", "slug", "locale"], limit: pageIds.size });
2107
+ for (const p of found)
2108
+ pages.set(String(p.id), { slug: String(p.slug), locale: String(p.locale ?? defaultLocale) });
2109
+ }
2110
+ const terms = new Map();
2111
+ if (termIds.size > 0) {
2112
+ const found = await db.find({ from: "cms_terms", where: { id: { in: [...termIds] } }, select: ["id", "slug", "taxonomyId"], limit: termIds.size });
2113
+ const taxIds = [...new Set(found.map((t) => String(t.taxonomyId)))];
2114
+ const taxa = taxIds.length > 0 ? await db.find({ from: "cms_taxonomies", where: { id: { in: taxIds } }, select: ["id", "slug"], limit: taxIds.length }) : [];
2115
+ const taxSlug = new Map(taxa.map((t) => [String(t.id), String(t.slug)]));
2116
+ for (const t of found) {
2117
+ const tax = taxSlug.get(String(t.taxonomyId));
2118
+ if (tax)
2119
+ terms.set(String(t.id), { slug: String(t.slug), taxonomy: tax });
2120
+ }
2121
+ }
2122
+ // An item whose target no longer resolves is DROPPED, together with its subtree. A
2123
+ // nav entry that renders no href is a dead link on every page of the site, and
2124
+ // hoisting orphaned children would silently promote a third-level item into the top
2125
+ // bar. The editor's own `listMenus` returns the raw tree, so nothing is lost there.
2126
+ const resolve = (list) => {
2127
+ const out = [];
2128
+ for (const item of list) {
2129
+ let url = item.url;
2130
+ if (item.kind === "page") {
2131
+ const page = item.ref ? pages.get(item.ref) : undefined;
2132
+ if (!page)
2133
+ continue;
2134
+ url = menuHref({ kind: "page", slug: page.slug, locale: page.locale });
2135
+ }
2136
+ else if (item.kind === "term") {
2137
+ const term = item.ref ? terms.get(item.ref) : undefined;
2138
+ if (!term)
2139
+ continue;
2140
+ url = menuHref({ kind: "term", taxonomy: term.taxonomy, slug: term.slug });
2141
+ }
2142
+ else if (item.kind === "collection") {
2143
+ if (!item.ref)
2144
+ continue;
2145
+ url = menuHref({ kind: "collection", slug: item.ref });
2146
+ }
2147
+ // The MINTED url goes through the same allow-list the `custom` branch enforces on
2148
+ // write. A reference is interpolated into a path (`/${slug}/`), and a `ref` that
2149
+ // began with a slash produced `//evil.example/` — protocol-relative, off-origin,
2150
+ // in the site's primary nav on every page. `assertRef` refuses that shape on
2151
+ // write; this is the second half, because `menuHref` is host-supplied and a
2152
+ // deployment's own mapping can build an unsafe href out of a safe ref.
2153
+ if (!url || !isSafeHref(url))
2154
+ continue;
2155
+ const children = item.children ? resolve(item.children) : [];
2156
+ out.push({ ...item, url, ...(children.length > 0 ? { children } : { children: undefined }) });
2157
+ }
2158
+ return out;
2159
+ };
2160
+ return { id: String(row.id), name: String(row.name), label: String(row.label), items: resolve(items) };
2161
+ };
1074
2162
  const TASK_PUBLISH = "cms:publish";
1075
2163
  const TASK_UNPUBLISH = "cms:unpublish";
1076
2164
  return {
1077
2165
  // ---- block types & content types (data-driven definitions) ----
1078
2166
  listBlockTypes: query((ctx) => cdb(ctx).find({ from: "cms_block_types", orderBy: { column: "name" } })),
1079
2167
  createBlockType: mutation(async (ctx, input) => {
1080
- return cdb(ctx).insert("cms_block_types", {
2168
+ const db = cdb(ctx);
2169
+ // A clean 409 before the UNIQUE constraint fires. The editor authors these now, and a
2170
+ // raw constraint error reads as "the CMS broke" rather than "that slug is taken".
2171
+ const clash = await db.find({ from: "cms_block_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
2172
+ if (clash[0])
2173
+ throw new Conflict(`block type '${input.slug}' already exists`);
2174
+ return db.insert("cms_block_types", {
1081
2175
  name: input.name,
1082
2176
  slug: input.slug,
1083
2177
  description: input.description ?? null,
@@ -1091,11 +2185,31 @@ export function createCmsHandlers(opts = {}) {
1091
2185
  const o = asObj(raw);
1092
2186
  if (typeof o.name !== "string" || typeof o.slug !== "string")
1093
2187
  throw new BadRequest("name and slug are required");
1094
- return o;
2188
+ if (o.name.trim() === "")
2189
+ throw new BadRequest("name must not be empty");
2190
+ return {
2191
+ name: o.name.trim(),
2192
+ // A block type's slug is a registry key: the editor's inserter, `assertRegionAllows`
2193
+ // and `@pramen/cms/react`'s component map all resolve it. Held to the same shape as
2194
+ // any other key rather than accepted as free text.
2195
+ slug: assertRegistryKey(o.slug, "block type slug"),
2196
+ description: typeof o.description === "string" ? o.description : undefined,
2197
+ icon: typeof o.icon === "string" ? o.icon : undefined,
2198
+ category: typeof o.category === "string" ? o.category : undefined,
2199
+ fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
2200
+ };
1095
2201
  },
1096
2202
  }),
1097
2203
  createContentType: mutation(async (ctx, input) => {
1098
- return cdb(ctx).insert("cms_content_types", {
2204
+ const db = cdb(ctx);
2205
+ // The same pre-check `createBlockType` and every `create*` in this file already do.
2206
+ // It was the one create handler without it, so a duplicate slug surfaced as a raw
2207
+ // `UNIQUE constraint failed` with no status — a 500. Newly likely: an editor just told
2208
+ // a content type is code-defined and read-only will try to recreate it under that slug.
2209
+ const clash = await db.find({ from: "cms_content_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
2210
+ if (clash[0])
2211
+ throw new Conflict(`content type '${input.slug}' already exists`);
2212
+ return db.insert("cms_content_types", {
1099
2213
  name: input.name,
1100
2214
  slug: input.slug,
1101
2215
  regions: input.regions ?? [],
@@ -1112,11 +2226,20 @@ export function createCmsHandlers(opts = {}) {
1112
2226
  // editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
1113
2227
  // empty one builds `/types/` — a path the router drops the empty segment from, so
1114
2228
  // the type gets a tab that cannot be reached and a list that cannot be addressed.
1115
- if (o.name.trim() === "" || o.slug.trim() === "")
1116
- throw new BadRequest("name and slug must not be empty");
1117
- if (!Array.isArray(o.regions) || o.regions.length === 0)
1118
- throw new BadRequest("at least one region is required");
1119
- return o;
2229
+ if (o.name.trim() === "")
2230
+ throw new BadRequest("name must not be empty");
2231
+ const regions = normalizeRegions(o.regions);
2232
+ return {
2233
+ name: o.name.trim(),
2234
+ // The SAME rule a block-type slug follows. They were split — block types admitted
2235
+ // `_`, content types did not — for no reason that survives inspection: both are
2236
+ // registry keys, and an underscore is as legal in the `/types/:slug` segment as it
2237
+ // is anywhere else in a URL. The example itself ships `seeded_doc`.
2238
+ slug: assertRegistryKey(o.slug, "content type slug"),
2239
+ regions,
2240
+ fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
2241
+ defaultBlocks: normalizeDefaultBlocks(o.defaultBlocks, regions),
2242
+ };
1120
2243
  },
1121
2244
  }),
1122
2245
  /** Update a block type (found by `id` or `slug`). `slug` is the stable key and is
@@ -1124,10 +2247,14 @@ export function createCmsHandlers(opts = {}) {
1124
2247
  * what lets a schema evolve (e.g. a field text → date) without recreating the type. */
1125
2248
  updateBlockType: mutation(async (ctx, input) => {
1126
2249
  const db = cdb(ctx);
1127
- const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
2250
+ // `select` for BOTH reasons the projection exists: the guard needs `managedBy` to be
2251
+ // present rather than projected away, and the lookup has no use for the wide
2252
+ // `fieldsSchema` blob it used to fetch and JSON-parse to read three columns.
2253
+ const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, select: ["id", "slug", "managedBy"], limit: 1 });
1128
2254
  const row = rows[0];
1129
2255
  if (!row)
1130
2256
  throw notFound("block type");
2257
+ assertNotManaged(row, "block type", "defineBlockType");
1131
2258
  const patch = {};
1132
2259
  for (const k of ["name", "fieldsSchema", "icon", "category", "description"]) {
1133
2260
  if (k in input)
@@ -1140,7 +2267,14 @@ export function createCmsHandlers(opts = {}) {
1140
2267
  const o = asObj(raw);
1141
2268
  if (typeof o.id !== "string" && typeof o.slug !== "string")
1142
2269
  throw new BadRequest("id or slug is required");
1143
- return o;
2270
+ const out = { ...o };
2271
+ // `name` is the label in the editor's inserter; blanking it leaves an unnamed entry
2272
+ // nobody can identify.
2273
+ if (o.name !== undefined)
2274
+ out.name = assertLabel(o.name, "block type name");
2275
+ if (o.fieldsSchema !== undefined)
2276
+ out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
2277
+ return out;
1144
2278
  },
1145
2279
  }),
1146
2280
  /** Update a content type (found by `id` or `slug`). `slug` is the stable key and is
@@ -1149,15 +2283,24 @@ export function createCmsHandlers(opts = {}) {
1149
2283
  const db = cdb(ctx);
1150
2284
  if ("regions" in input && (!Array.isArray(input.regions) || input.regions.length === 0))
1151
2285
  throw new BadRequest("at least one region is required");
1152
- const rows = await db.find({ from: "cms_content_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
2286
+ // `regions` as well, because the `defaultBlocks`-only patch below checks against the
2287
+ // STORED regions. See the block-type lookup for why this is a `select` at all.
2288
+ const rows = await db.find({ from: "cms_content_types", where: input.id ? { id: input.id } : { slug: input.slug }, select: ["id", "slug", "managedBy", "regions"], limit: 1 });
1153
2289
  const row = rows[0];
1154
2290
  if (!row)
1155
2291
  throw notFound("content type");
2292
+ assertNotManaged(row, "content type", "defineContentType");
1156
2293
  const patch = {};
1157
2294
  for (const k of ["name", "regions", "fieldsSchema", "defaultBlocks"]) {
1158
2295
  if (k in input)
1159
2296
  patch[k] = input[k];
1160
2297
  }
2298
+ // A `defaultBlocks` patch that does NOT also send regions is checked here against the
2299
+ // STORED ones — the input parser has no row to read, and a default block placed into a
2300
+ // region the type does not declare is created into a key no renderer looks at.
2301
+ if (patch.defaultBlocks !== undefined && patch.regions === undefined) {
2302
+ patch.defaultBlocks = normalizeDefaultBlocks(patch.defaultBlocks, (Array.isArray(row.regions) ? row.regions : []));
2303
+ }
1161
2304
  return db.update("cms_content_types", String(row.id), patch);
1162
2305
  }, {
1163
2306
  ...editor,
@@ -1165,10 +2308,619 @@ export function createCmsHandlers(opts = {}) {
1165
2308
  const o = asObj(raw);
1166
2309
  if (typeof o.id !== "string" && typeof o.slug !== "string")
1167
2310
  throw new BadRequest("id or slug is required");
2311
+ const out = { ...o };
1168
2312
  // `name` is the editor's tab label; blanking it leaves an unlabelled tab.
1169
- if (typeof o.name === "string" && o.name.trim() === "")
1170
- throw new BadRequest("name must not be empty");
1171
- return o;
2313
+ if (o.name !== undefined)
2314
+ out.name = assertLabel(o.name, "content type name");
2315
+ if (o.regions !== undefined)
2316
+ out.regions = normalizeRegions(o.regions);
2317
+ if (o.fieldsSchema !== undefined)
2318
+ out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
2319
+ // Checked against the regions being SAVED where the same call sends both — patching
2320
+ // only `defaultBlocks` cannot see the stored regions from an input parser, and the
2321
+ // handler re-checks below.
2322
+ if (out.defaultBlocks !== undefined && out.regions !== undefined) {
2323
+ out.defaultBlocks = normalizeDefaultBlocks(out.defaultBlocks, out.regions);
2324
+ }
2325
+ return out;
2326
+ },
2327
+ }),
2328
+ // ---- site furniture: menus ----------------------------------------------
2329
+ //
2330
+ // A menu is read WHOLE (`getMenu("primary")` on every page render) and written whole.
2331
+ // The public read RESOLVES references — that is what makes a `page` item follow its
2332
+ // page's slug instead of freezing the href an editor typed once.
2333
+ /** One menu, references resolved to hrefs. PUBLIC — a menu is site chrome.
2334
+ *
2335
+ * Returns `null` for an unknown name rather than 404ing, because a layout asking for a
2336
+ * menu it has not created yet is the normal state of a site being built, and a thrown
2337
+ * error there takes down every page instead of rendering no nav. */
2338
+ getMenu: query(async (ctx, input) => {
2339
+ const rows = await cdb(ctx).find({ from: "cms_menus", where: { name: input.name }, limit: 1 });
2340
+ const row = rows[0];
2341
+ if (!row)
2342
+ return null;
2343
+ return resolveMenuRow(cdb(ctx), row);
2344
+ }, {
2345
+ input: (raw) => ({ name: assertKey(asObj(raw).name, "menu name") }),
2346
+ }),
2347
+ /** Every menu, RAW (references unresolved) — the editor's list.
2348
+ *
2349
+ * Capped like every other list here. A site-furniture table is small by nature, which is
2350
+ * an argument for the cap being generous, not for its absence: an unbounded SELECT of a
2351
+ * wide row over RPC is the D1 failure shape from GitHub #22, and "small by nature" is
2352
+ * not a property the query planner knows about. */
2353
+ listMenus: query((ctx) => cdb(ctx).find({ from: "cms_menus", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
2354
+ createMenu: mutation(async (ctx, input) => {
2355
+ const db = cdb(ctx);
2356
+ // A clean 409, as every sibling create handler gives. Without it a taken key surfaced
2357
+ // as the UNIQUE constraint's own 500 — and the e2e only asserts "not 200", so it
2358
+ // could not tell the two apart.
2359
+ const clash = await db.find({ from: "cms_menus", where: { name: input.name }, select: ["id"], limit: 1 });
2360
+ if (clash[0])
2361
+ throw new Conflict(`menu '${input.name}' already exists`);
2362
+ return db.insert("cms_menus", { name: input.name, label: input.label, items: input.items ?? [] });
2363
+ }, {
2364
+ ...editor,
2365
+ input: (raw) => {
2366
+ const o = asObj(raw);
2367
+ return { name: assertKey(o.name, "menu name"), label: assertLabel(o.label, "menu label"), items: normalizeMenuItems(o.items) };
2368
+ },
2369
+ }),
2370
+ /** Patch a menu found by `id` or `name`. `name` is the key layout code resolves and is
2371
+ * NOT mutable — retitling is what `label` is for. */
2372
+ updateMenu: mutation(async (ctx, input) => {
2373
+ const db = cdb(ctx);
2374
+ const rows = await db.find({ from: "cms_menus", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
2375
+ const row = rows[0];
2376
+ if (!row)
2377
+ throw notFound("menu");
2378
+ const patch = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this menu") };
2379
+ if (input.label !== undefined)
2380
+ patch.label = input.label;
2381
+ if (input.items !== undefined)
2382
+ patch.items = input.items;
2383
+ return db.update("cms_menus", String(row.id), patch);
2384
+ }, {
2385
+ ...editor,
2386
+ input: (raw) => {
2387
+ const o = asObj(raw);
2388
+ const out = {};
2389
+ if (typeof o.id === "string")
2390
+ out.id = o.id;
2391
+ else if (typeof o.name === "string")
2392
+ out.name = assertKey(o.name, "menu name");
2393
+ else
2394
+ throw new BadRequest("id or name is required");
2395
+ if (o.label !== undefined)
2396
+ out.label = assertLabel(o.label, "menu label");
2397
+ if (o.items !== undefined)
2398
+ out.items = normalizeMenuItems(o.items);
2399
+ if (typeof o.expectedVersion === "number")
2400
+ out.expectedVersion = o.expectedVersion;
2401
+ return out;
2402
+ },
2403
+ }),
2404
+ deleteMenu: mutation(async (ctx, input) => {
2405
+ const ok = await cdb(ctx).delete("cms_menus", input.id);
2406
+ if (!ok)
2407
+ throw notFound("menu");
2408
+ return { ok: true };
2409
+ }, {
2410
+ ...editor,
2411
+ input: (raw) => {
2412
+ const id = asObj(raw).id;
2413
+ if (typeof id !== "string" || id === "")
2414
+ throw new BadRequest("id is required");
2415
+ return { id };
2416
+ },
2417
+ }),
2418
+ // ---- site furniture: redirects -------------------------------------------
2419
+ /** Resolve a request path to its redirect, or `null`. PUBLIC and READ-ONLY: this is
2420
+ * what a front end calls on a 404, so it is anonymous traffic on the hot path.
2421
+ *
2422
+ * `enabled` is enforced by the public read POLICY, not by a `where` here, so a disabled
2423
+ * redirect is invisible to every anonymous read (this handler, a future listing, a
2424
+ * relation traversal) rather than to the one call that remembered to filter. */
2425
+ resolveRedirect: query(async (ctx, input) => {
2426
+ const rows = await cdb(ctx).find({ from: "cms_redirects", where: { fromPath: input.path, enabled: true }, select: ["toPath", "status"], limit: 1 });
2427
+ const row = rows[0];
2428
+ return row ? { to: String(row.toPath), status: Number(row.status ?? 301) } : null;
2429
+ }, {
2430
+ input: (raw) => ({ path: normalizeRedirectPath(asObj(raw).path) }),
2431
+ }),
2432
+ listRedirects: query((ctx, input) => {
2433
+ return cdb(ctx).find({ from: "cms_redirects", orderBy: { column: "fromPath" }, limit: input.limit ?? PAGE_LIST_LIMIT, offset: input.offset ?? 0 });
2434
+ }, {
2435
+ ...viewer,
2436
+ input: (raw) => {
2437
+ const o = asObj(raw);
2438
+ return { limit: clampLimit(o.limit), offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0 };
2439
+ },
2440
+ }),
2441
+ createRedirect: mutation(async (ctx, input) => {
2442
+ // A redirect to itself is an infinite loop the moment it is enabled, and the browser
2443
+ // is what discovers it. Cheap to refuse here; impossible to diagnose from the outside.
2444
+ if (isSelfRedirect(input.fromPath, input.toPath))
2445
+ throw new BadRequest("a redirect cannot point at itself");
2446
+ const db = cdb(ctx);
2447
+ const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
2448
+ if (clash[0])
2449
+ throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
2450
+ return db.insert("cms_redirects", {
2451
+ fromPath: input.fromPath,
2452
+ toPath: input.toPath,
2453
+ status: input.status ?? 301,
2454
+ enabled: input.enabled ?? true,
2455
+ note: input.note ?? null,
2456
+ });
2457
+ }, {
2458
+ ...editor,
2459
+ input: (raw) => redirectPatch(raw, true),
2460
+ }),
2461
+ updateRedirect: mutation(async (ctx, input) => {
2462
+ const db = cdb(ctx);
2463
+ const rows = await db.find({ from: "cms_redirects", where: { id: input.id }, limit: 1 });
2464
+ const row = rows[0];
2465
+ if (!row)
2466
+ throw notFound("redirect");
2467
+ const from = input.fromPath ?? String(row.fromPath);
2468
+ const to = input.toPath ?? String(row.toPath);
2469
+ if (isSelfRedirect(from, to))
2470
+ throw new BadRequest("a redirect cannot point at itself");
2471
+ if (input.fromPath && input.fromPath !== row.fromPath) {
2472
+ const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
2473
+ if (clash[0])
2474
+ throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
2475
+ }
2476
+ const patch = { updatedAt: nowStamp() };
2477
+ for (const k of ["fromPath", "toPath", "status", "enabled", "note"]) {
2478
+ if (input[k] !== undefined)
2479
+ patch[k] = input[k];
2480
+ }
2481
+ return db.update("cms_redirects", input.id, patch);
2482
+ }, {
2483
+ ...editor,
2484
+ input: (raw) => ({ id: requireId(raw), ...redirectPatch(raw, false) }),
2485
+ }),
2486
+ deleteRedirect: mutation(async (ctx, input) => {
2487
+ const ok = await cdb(ctx).delete("cms_redirects", input.id);
2488
+ if (!ok)
2489
+ throw notFound("redirect");
2490
+ return { ok: true };
2491
+ }, {
2492
+ ...editor,
2493
+ input: (raw) => {
2494
+ const id = asObj(raw).id;
2495
+ if (typeof id !== "string" || id === "")
2496
+ throw new BadRequest("id is required");
2497
+ return { id };
2498
+ },
2499
+ }),
2500
+ // ---- site furniture: taxonomies ------------------------------------------
2501
+ //
2502
+ // `category` and `tag` are not built in: they are two rows a deployment creates, the
2503
+ // same way it creates its content types. A vocabulary that is hierarchical allows
2504
+ // `parentId` on its terms; a flat one refuses it rather than storing something no
2505
+ // listing renders.
2506
+ /** Every vocabulary. PUBLIC — like content types, a taxonomy's slug is structural (it
2507
+ * is a URL segment) and a front end routes on it. */
2508
+ listTaxonomies: query((ctx) => cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT })),
2509
+ createTaxonomy: mutation(async (ctx, input) => {
2510
+ const db = cdb(ctx);
2511
+ const clash = await db.find({ from: "cms_taxonomies", where: { slug: input.slug }, select: ["id"], limit: 1 });
2512
+ if (clash[0])
2513
+ throw new Conflict(`taxonomy '${input.slug}' already exists`);
2514
+ return db.insert("cms_taxonomies", {
2515
+ slug: input.slug,
2516
+ label: input.label,
2517
+ pluralLabel: input.pluralLabel ?? null,
2518
+ description: input.description ?? null,
2519
+ hierarchical: input.hierarchical ?? false,
2520
+ });
2521
+ }, {
2522
+ ...editor,
2523
+ input: (raw) => {
2524
+ const o = asObj(raw);
2525
+ return {
2526
+ slug: assertKey(o.slug, "taxonomy slug"),
2527
+ label: assertLabel(o.label, "taxonomy label"),
2528
+ pluralLabel: typeof o.pluralLabel === "string" ? o.pluralLabel : undefined,
2529
+ description: typeof o.description === "string" ? o.description : undefined,
2530
+ hierarchical: typeof o.hierarchical === "boolean" ? o.hierarchical : undefined,
2531
+ };
2532
+ },
2533
+ }),
2534
+ /** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
2535
+ * is not mutable — the same rule content types and block types already follow.
2536
+ *
2537
+ * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
2538
+ * would leave a stored hierarchy that no reader renders and no writer can clear, and
2539
+ * flattening the terms silently is a destructive edit behind a checkbox. */
2540
+ updateTaxonomy: mutation(async (ctx, input) => {
2541
+ const db = cdb(ctx);
2542
+ const rows = await db.find({ from: "cms_taxonomies", where: { id: input.id }, limit: 1 });
2543
+ const row = rows[0];
2544
+ if (!row)
2545
+ throw notFound("taxonomy");
2546
+ if (input.hierarchical === false && row.hierarchical) {
2547
+ const nested = await db.find({ from: "cms_terms", where: { taxonomyId: input.id, parentId: { isNull: false } }, select: ["id"], limit: 1 });
2548
+ if (nested[0])
2549
+ throw new BadRequest("this vocabulary still has nested terms — move them to the top level before making it flat");
2550
+ }
2551
+ const patch = {};
2552
+ for (const k of ["label", "pluralLabel", "description", "hierarchical"]) {
2553
+ if (input[k] !== undefined)
2554
+ patch[k] = input[k];
2555
+ }
2556
+ return db.update("cms_taxonomies", input.id, patch);
2557
+ }, {
2558
+ ...editor,
2559
+ input: (raw) => {
2560
+ const o = asObj(raw);
2561
+ if (typeof o.id !== "string" || o.id === "")
2562
+ throw new BadRequest("id is required");
2563
+ const out = { id: o.id };
2564
+ if (o.label !== undefined)
2565
+ out.label = assertLabel(o.label, "taxonomy label");
2566
+ if (o.pluralLabel !== undefined)
2567
+ out.pluralLabel = typeof o.pluralLabel === "string" ? o.pluralLabel : null;
2568
+ if (o.description !== undefined)
2569
+ out.description = typeof o.description === "string" ? o.description : null;
2570
+ if (typeof o.hierarchical === "boolean")
2571
+ out.hierarchical = o.hierarchical;
2572
+ return out;
2573
+ },
2574
+ }),
2575
+ /** Delete a vocabulary. Its terms go with it, and their page assignments with those —
2576
+ * both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
2577
+ * by a handler that threw between two writes. */
2578
+ deleteTaxonomy: mutation(async (ctx, input) => {
2579
+ const ok = await cdb(ctx).delete("cms_taxonomies", input.id);
2580
+ if (!ok)
2581
+ throw notFound("taxonomy");
2582
+ return { ok: true };
2583
+ }, {
2584
+ ...editor,
2585
+ input: (raw) => {
2586
+ const id = asObj(raw).id;
2587
+ if (typeof id !== "string" || id === "")
2588
+ throw new BadRequest("id is required");
2589
+ return { id };
2590
+ },
2591
+ }),
2592
+ /** One vocabulary's terms, flat, ordered. PUBLIC. */
2593
+ listTerms: query(async (ctx, input) => {
2594
+ const db = cdb(ctx);
2595
+ const tax = await taxonomyBySlug(db, input.taxonomy);
2596
+ if (!tax)
2597
+ return [];
2598
+ const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, orderBy: [{ column: "position" }, { column: "label" }], limit: MAX_TERMS });
2599
+ return rows;
2600
+ }, {
2601
+ input: (raw) => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
2602
+ }),
2603
+ /** One vocabulary's terms as a TREE. PUBLIC. Assembled here rather than by the caller
2604
+ * because a hierarchy is what the nav renders and every consumer would otherwise write
2605
+ * the same fold. */
2606
+ getTermTree: query(async (ctx, input) => {
2607
+ const db = cdb(ctx);
2608
+ const tax = await taxonomyBySlug(db, input.taxonomy);
2609
+ if (!tax)
2610
+ return [];
2611
+ const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, limit: MAX_TERMS });
2612
+ return buildTermTree(rows);
2613
+ }, {
2614
+ input: (raw) => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
2615
+ }),
2616
+ createTerm: mutation(async (ctx, input) => {
2617
+ const db = cdb(ctx);
2618
+ const tax = await taxonomyBySlug(db, input.taxonomy);
2619
+ if (!tax)
2620
+ throw notFound("taxonomy");
2621
+ await assertTermParent(db, tax, input.parentId ?? null, null);
2622
+ const clash = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.slug }, select: ["id"], limit: 1 });
2623
+ if (clash[0])
2624
+ throw new Conflict(`term '${input.slug}' already exists in '${input.taxonomy}'`);
2625
+ return db.insert("cms_terms", {
2626
+ taxonomyId: tax.id,
2627
+ slug: input.slug,
2628
+ label: input.label,
2629
+ description: input.description ?? null,
2630
+ parentId: input.parentId ?? null,
2631
+ position: input.position ?? 0,
2632
+ });
2633
+ }, {
2634
+ ...editor,
2635
+ input: (raw) => {
2636
+ const o = asObj(raw);
2637
+ return {
2638
+ taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
2639
+ slug: assertKey(o.slug, "term slug"),
2640
+ label: assertLabel(o.label, "term label"),
2641
+ description: typeof o.description === "string" ? o.description : undefined,
2642
+ parentId: typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null,
2643
+ position: typeof o.position === "number" ? Math.trunc(o.position) : undefined,
2644
+ };
2645
+ },
2646
+ }),
2647
+ updateTerm: mutation(async (ctx, input) => {
2648
+ const db = cdb(ctx);
2649
+ const rows = await db.find({ from: "cms_terms", where: { id: input.id }, limit: 1 });
2650
+ const row = rows[0];
2651
+ if (!row)
2652
+ throw notFound("term");
2653
+ const taxRows = await db.find({ from: "cms_taxonomies", where: { id: row.taxonomyId }, limit: 1 });
2654
+ const tax = taxRows[0];
2655
+ if (!tax)
2656
+ throw notFound("taxonomy");
2657
+ if (input.parentId !== undefined) {
2658
+ await assertTermParent(db, { id: String(tax.id), hierarchical: Boolean(tax.hierarchical) }, input.parentId, input.id);
2659
+ }
2660
+ // A term's slug IS mutable, unlike a taxonomy's: it is the leaf of a URL, an editor
2661
+ // fixing a typo in one is routine, and the uniqueness that matters is enforced below
2662
+ // and by the composite index behind it.
2663
+ if (input.slug && input.slug !== row.slug) {
2664
+ const clash = await db.find({ from: "cms_terms", where: { taxonomyId: row.taxonomyId, slug: input.slug }, select: ["id"], limit: 1 });
2665
+ if (clash[0])
2666
+ throw new Conflict(`term '${input.slug}' already exists in this vocabulary`);
2667
+ }
2668
+ const patch = {};
2669
+ for (const k of ["slug", "label", "description", "parentId", "position"]) {
2670
+ if (input[k] !== undefined)
2671
+ patch[k] = input[k];
2672
+ }
2673
+ return db.update("cms_terms", input.id, patch);
2674
+ }, {
2675
+ ...editor,
2676
+ input: (raw) => {
2677
+ const o = asObj(raw);
2678
+ if (typeof o.id !== "string" || o.id === "")
2679
+ throw new BadRequest("id is required");
2680
+ const out = { id: o.id };
2681
+ if (o.slug !== undefined)
2682
+ out.slug = assertKey(o.slug, "term slug");
2683
+ if (o.label !== undefined)
2684
+ out.label = assertLabel(o.label, "term label");
2685
+ if (o.description !== undefined)
2686
+ out.description = typeof o.description === "string" ? o.description : null;
2687
+ if (o.parentId !== undefined)
2688
+ out.parentId = typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null;
2689
+ if (typeof o.position === "number")
2690
+ out.position = Math.trunc(o.position);
2691
+ return out;
2692
+ },
2693
+ }),
2694
+ /** Delete a term. Children are promoted to the top level (`ON DELETE SET NULL`) and
2695
+ * page assignments are removed (`ON DELETE CASCADE`) — see `cms_terms.parentId`. */
2696
+ deleteTerm: mutation(async (ctx, input) => {
2697
+ const ok = await cdb(ctx).delete("cms_terms", input.id);
2698
+ if (!ok)
2699
+ throw notFound("term");
2700
+ return { ok: true };
2701
+ }, {
2702
+ ...editor,
2703
+ input: (raw) => {
2704
+ const id = asObj(raw).id;
2705
+ if (typeof id !== "string" || id === "")
2706
+ throw new BadRequest("id is required");
2707
+ return { id };
2708
+ },
2709
+ }),
2710
+ /** A page's assigned terms. PUBLIC (a published page's classification is public). */
2711
+ listPageTerms: query(async (ctx, input) => {
2712
+ const db = cdb(ctx);
2713
+ // Read the PAGE first, through `ctx.db`, so the caller's own page scope decides
2714
+ // whether this answers at all. Without it the handler never touched `cms_pages` — and
2715
+ // the public grants on the junction and on terms are unscoped `allow()` — so anyone
2716
+ // holding a page id could read a draft or trashed page's classification, and the
2717
+ // non-empty answer confirmed the page exists. `listPagesByTerm` was already safe for
2718
+ // the opposite reason: it traverses `where: { terms: … }`, so the page scope
2719
+ // AND-merges. This is the same rule, applied from the other end.
2720
+ const page = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
2721
+ if (!page[0])
2722
+ return [];
2723
+ const links = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["termId"], limit: MAX_TERMS });
2724
+ const ids = links.map((l) => String(l.termId));
2725
+ if (ids.length === 0)
2726
+ return [];
2727
+ const rows = await db.find({ from: "cms_terms", where: { id: { in: ids } }, orderBy: [{ column: "position" }, { column: "label" }], limit: ids.length });
2728
+ return rows;
2729
+ }, {
2730
+ input: (raw) => {
2731
+ const id = asObj(raw).pageId;
2732
+ if (typeof id !== "string" || id === "")
2733
+ throw new BadRequest("pageId is required");
2734
+ return { pageId: id };
2735
+ },
2736
+ }),
2737
+ /** Replace a page's term assignments wholesale.
2738
+ *
2739
+ * Set semantics, not add/remove: the editor's panel holds the whole selection, and two
2740
+ * calls that each patch one end of it race into a state neither asked for. Existing
2741
+ * links that survive are LEFT ALONE rather than deleted and reinserted, so the junction
2742
+ * rows (and any future column on them) are stable across a save that changed nothing. */
2743
+ setPageTerms: mutation(async (ctx, input) => {
2744
+ const db = cdb(ctx);
2745
+ const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
2746
+ if (!pages[0])
2747
+ throw notFound("page");
2748
+ const wanted = new Set(input.termIds);
2749
+ if (wanted.size > 0) {
2750
+ // Every id must be a real term. Without this the junction happily stores a dangling
2751
+ // uuid — the FK would catch it, but as a driver error with no HTTP status.
2752
+ const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
2753
+ if (found.length !== wanted.size)
2754
+ throw new BadRequest("one or more termIds are not terms");
2755
+ }
2756
+ const existing = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["id", "termId"], limit: MAX_TERMS });
2757
+ const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
2758
+ for (const [termId, linkId] of have)
2759
+ if (!wanted.has(termId))
2760
+ await db.delete("cms_page_terms", linkId);
2761
+ for (const termId of wanted)
2762
+ if (!have.has(termId))
2763
+ await db.insert("cms_page_terms", { pageId: input.pageId, termId });
2764
+ return { ok: true, count: wanted.size };
2765
+ }, {
2766
+ ...editor,
2767
+ input: (raw) => {
2768
+ const o = asObj(raw);
2769
+ if (typeof o.pageId !== "string" || o.pageId === "")
2770
+ throw new BadRequest("pageId is required");
2771
+ if (!Array.isArray(o.termIds))
2772
+ throw new BadRequest("termIds must be a list");
2773
+ const ids = o.termIds.map((v) => {
2774
+ if (typeof v !== "string" || v === "")
2775
+ throw new BadRequest("termIds must be a list of ids");
2776
+ return v;
2777
+ });
2778
+ if (ids.length > MAX_TERMS)
2779
+ throw new BadRequest(`a page may carry at most ${MAX_TERMS} terms`);
2780
+ return { pageId: o.pageId, termIds: ids };
2781
+ },
2782
+ }),
2783
+ /** Published pages carrying a term. PUBLIC.
2784
+ *
2785
+ * A relation traversal (`where: { terms: { id } }`), so the page read scope is
2786
+ * AND-merged as it is anywhere else — traversal cannot widen access, and an anonymous
2787
+ * caller sees published pages only. */
2788
+ listPagesByTerm: query(async (ctx, input) => {
2789
+ const db = cdb(ctx);
2790
+ const tax = await taxonomyBySlug(db, input.taxonomy);
2791
+ if (!tax)
2792
+ return [];
2793
+ const terms = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.term }, select: ["id"], limit: 1 });
2794
+ const term = terms[0];
2795
+ if (!term)
2796
+ return [];
2797
+ return db.find({
2798
+ from: "cms_pages",
2799
+ where: { terms: { id: String(term.id) } },
2800
+ orderBy: { column: "createdAt", dir: "desc" },
2801
+ select: ["id", "typeId", "title", "slug", "locale", "status", "publishedAt"],
2802
+ limit: input.limit ?? PAGE_LIST_LIMIT,
2803
+ offset: input.offset ?? 0,
2804
+ });
2805
+ }, {
2806
+ input: (raw) => {
2807
+ const o = asObj(raw);
2808
+ return {
2809
+ taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
2810
+ term: assertKey(o.term, "term slug"),
2811
+ limit: clampLimit(o.limit),
2812
+ offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0,
2813
+ };
2814
+ },
2815
+ }),
2816
+ // ---- site furniture: widget areas ----------------------------------------
2817
+ /** One widget area, with `menu` widgets resolved to their menus. PUBLIC.
2818
+ *
2819
+ * `null` for an unknown name, for the same reason `getMenu` returns null: a layout
2820
+ * asking for a sidebar nobody has filled in yet is the normal state of a site under
2821
+ * construction, and throwing there takes down the page. */
2822
+ getWidgetArea: query(async (ctx, input) => {
2823
+ const db = cdb(ctx);
2824
+ const rows = await db.find({ from: "cms_widget_areas", where: { name: input.name }, limit: 1 });
2825
+ const row = rows[0];
2826
+ if (!row)
2827
+ return null;
2828
+ const widgets = Array.isArray(row.widgets) ? row.widgets : [];
2829
+ // Resolved inline so a layout renders a whole sidebar from ONE call. A menu widget
2830
+ // that names a menu which no longer exists keeps `menu: null` rather than being
2831
+ // dropped — unlike a menu ITEM, an empty widget is a visible hole an editor can see
2832
+ // and fix, where a silently missing one is not.
2833
+ const names = [...new Set(widgets.filter((w) => w.type === "menu" && w.menuName).map((w) => w.menuName))];
2834
+ const menus = new Map();
2835
+ if (names.length > 0) {
2836
+ const found = await db.find({ from: "cms_menus", where: { name: { in: names } }, limit: names.length });
2837
+ // RESOLVED, exactly as `getMenu` returns it. Embedding the raw row here served an
2838
+ // unpublished page's label and id to anonymous callers and handed the layout an
2839
+ // item with no `url` — the two things the resolver exists to prevent, skipped
2840
+ // because this path had its own one-line copy of "read the menu".
2841
+ for (const m of found)
2842
+ menus.set(String(m.name), await resolveMenuRow(db, m));
2843
+ }
2844
+ return {
2845
+ id: String(row.id),
2846
+ name: String(row.name),
2847
+ label: String(row.label),
2848
+ description: row.description == null ? null : String(row.description),
2849
+ widgets: widgets.map((w) => (w.type === "menu" && w.menuName ? { ...w, menu: menus.get(w.menuName) ?? null } : w)),
2850
+ };
2851
+ }, {
2852
+ input: (raw) => ({ name: assertKey(asObj(raw).name, "widget area name") }),
2853
+ }),
2854
+ listWidgetAreas: query((ctx) => cdb(ctx).find({ from: "cms_widget_areas", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
2855
+ createWidgetArea: mutation(async (ctx, input) => {
2856
+ const db = cdb(ctx);
2857
+ const clash = await db.find({ from: "cms_widget_areas", where: { name: input.name }, select: ["id"], limit: 1 });
2858
+ if (clash[0])
2859
+ throw new Conflict(`widget area '${input.name}' already exists`);
2860
+ return db.insert("cms_widget_areas", {
2861
+ name: input.name,
2862
+ label: input.label,
2863
+ description: input.description ?? null,
2864
+ widgets: input.widgets ?? [],
2865
+ });
2866
+ }, {
2867
+ ...editor,
2868
+ input: (raw) => {
2869
+ const o = asObj(raw);
2870
+ return {
2871
+ name: assertKey(o.name, "widget area name"),
2872
+ label: assertLabel(o.label, "widget area label"),
2873
+ description: typeof o.description === "string" ? o.description : undefined,
2874
+ widgets: normalizeWidgets(o.widgets, rtSchema),
2875
+ };
2876
+ },
2877
+ }),
2878
+ updateWidgetArea: mutation(async (ctx, input) => {
2879
+ const db = cdb(ctx);
2880
+ const rows = await db.find({ from: "cms_widget_areas", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
2881
+ const row = rows[0];
2882
+ if (!row)
2883
+ throw notFound("widget area");
2884
+ const patch = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this widget area") };
2885
+ for (const k of ["label", "description", "widgets"]) {
2886
+ if (input[k] !== undefined)
2887
+ patch[k] = input[k];
2888
+ }
2889
+ return db.update("cms_widget_areas", String(row.id), patch);
2890
+ }, {
2891
+ ...editor,
2892
+ input: (raw) => {
2893
+ const o = asObj(raw);
2894
+ const out = {};
2895
+ if (typeof o.id === "string")
2896
+ out.id = o.id;
2897
+ else if (typeof o.name === "string")
2898
+ out.name = assertKey(o.name, "widget area name");
2899
+ else
2900
+ throw new BadRequest("id or name is required");
2901
+ if (o.label !== undefined)
2902
+ out.label = assertLabel(o.label, "widget area label");
2903
+ if (o.description !== undefined)
2904
+ out.description = typeof o.description === "string" ? o.description : null;
2905
+ if (o.widgets !== undefined)
2906
+ out.widgets = normalizeWidgets(o.widgets, rtSchema);
2907
+ if (typeof o.expectedVersion === "number")
2908
+ out.expectedVersion = o.expectedVersion;
2909
+ return out;
2910
+ },
2911
+ }),
2912
+ deleteWidgetArea: mutation(async (ctx, input) => {
2913
+ const ok = await cdb(ctx).delete("cms_widget_areas", input.id);
2914
+ if (!ok)
2915
+ throw notFound("widget area");
2916
+ return { ok: true };
2917
+ }, {
2918
+ ...editor,
2919
+ input: (raw) => {
2920
+ const id = asObj(raw).id;
2921
+ if (typeof id !== "string" || id === "")
2922
+ throw new BadRequest("id is required");
2923
+ return { id };
1172
2924
  },
1173
2925
  }),
1174
2926
  // ---- media library ----
@@ -1652,7 +3404,30 @@ export function createCmsHandlers(opts = {}) {
1652
3404
  * feature would render N tabs all showing every type's pages under a heading claiming
1653
3405
  * otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
1654
3406
  * inferred: fail closed on the pooled list rather than open on N lying ones. */
1655
- listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1, pagesByType: true }), viewer),
3407
+ listCmsCapabilities: query((ctx) => ({
3408
+ locales,
3409
+ defaultLocale,
3410
+ multilingual: locales.length > 1,
3411
+ pagesByType: true,
3412
+ // Same kind of declaration as `pagesByType`, for the same reason: an OLDER server has
3413
+ // no menu/redirect/taxonomy/widget handlers at all, and a nav section whose every
3414
+ // screen 404s is worse than one that is absent. Fails closed by being absent there.
3415
+ siteFurniture: true,
3416
+ // Whether `managedBy` means anything on this server. Declared for the same reason as
3417
+ // its neighbours: `@pramen/cms-editor` is a separate package with no dependency on
3418
+ // `@pramen/cms`, so a newer editor CAN run against an older server — where every row
3419
+ // reports no owner, nothing renders read-only, the save succeeds, and it is reverted at
3420
+ // the next cold start. That is GitHub #48 in the deployment that upgraded the editor to
3421
+ // fix it. Absent ⇒ the editor treats no type as code-defined, which is correct there.
3422
+ codeDefinedTypes: true,
3423
+ // PER-CALLER, unlike everything else here. `viewer` is `editorRoles ∪ reviewerRoles`,
3424
+ // so a reviewer-only session reaches this handler and every read handler — but every
3425
+ // WRITE is `editorRoles`. Without this the editor renders the authoring surfaces
3426
+ // (Types, Menus, Redirects, …) for a reviewer and each one 403s on its first save.
3427
+ // The editor cannot work it out for itself: it knows the caller's roles from `me` but
3428
+ // not which roles this deployment configured as `editorRoles`.
3429
+ canEdit: isEditor(ctx, editorRoles),
3430
+ }), viewer),
1656
3431
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1657
3432
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1658
3433
  * declares; these two differ while a locale is declared but not yet authored. */
@@ -2024,10 +3799,11 @@ export function createCmsHandlers(opts = {}) {
2024
3799
  const secret = previewSecret(ctx.env);
2025
3800
  if (!secret)
2026
3801
  throw previewUnconfigured(); // fail closed — never mint a forgeable link
2027
- // The redeem route always reaches a Durable Object (callPrivileged -> PRAMEN.get); it
2028
- // has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
2029
- // link that 404s forever while the editor reports success refuse instead of
2030
- // handing out a token that cannot work.
3802
+ // This used to refuse on the D1 store: redemption goes through `ctx.callPrivileged`,
3803
+ // which only forwarded to a DO, so a link minted on D1 would have 404'd forever while
3804
+ // the editor reported success. `callPrivileged` now dispatches locally in the Worker
3805
+ // on D1, so both stores mint. The redeem route is a BROWSER request carrying no
3806
+ // `x-pramen-store`, so a D1 deployment still needs `PRAMEN_STORE=d1` to route it.
2031
3807
  const db = cdb(ctx);
2032
3808
  // Read the page through the ACL first: minting a link is granting access to it, so a
2033
3809
  // caller who cannot read the page must not be able to mint a link that can.
@@ -2255,7 +4031,12 @@ export function cmsPolicies(opts = {}) {
2255
4031
  // any app handler, silently overriding the read+create grant `collectionPolicies` emits —
2256
4032
  // duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
2257
4033
  // The collection half owns that table's grant; see `collectionPolicies`.
2258
- const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"];
4034
+ const tables = [
4035
+ "cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit",
4036
+ // Site furniture. Full CRUD for an editor, like every other cms_ table — the per-handler
4037
+ // `auth` gate is what separates editor from reviewer; this is the row scope.
4038
+ "cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_widget_areas",
4039
+ ];
2259
4040
  // Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
2260
4041
  // AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
2261
4042
  // API, the editor, relation traversals and eager-loads at once — where a per-handler
@@ -2295,10 +4076,47 @@ export function cmsPolicies(opts = {}) {
2295
4076
  policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
2296
4077
  // Media metadata is public (the bytes are separately gated by signed urls).
2297
4078
  policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
4079
+ // --- site furniture ---
4080
+ // A menu is site chrome, rendered on every page. `getMenu` resolves its page
4081
+ // references THROUGH `ctx.db`, so the public page scope above is what decides whether
4082
+ // a link to an unpublished page renders — the menu grant does not widen it.
4083
+ policy(`${p}:public:menus:read`, "cms_menus", "read", allow()),
4084
+ // Only ENABLED redirects. `resolveRedirect` also filters, but the scope is the real
4085
+ // boundary: a disabled redirect is one an editor has deliberately taken out of
4086
+ // service, and it must stay invisible to every anonymous read, not just that one.
4087
+ policy(`${p}:public:redirects:read`, "cms_redirects", "read", { where: { enabled: true } }),
4088
+ // Taxonomies and terms are structural (they are URL segments a front end routes on),
4089
+ // exactly like content-type slugs. The junction is granted too, because
4090
+ // `where: { terms: … }` on a page compiles to a subquery THROUGH it — without the
4091
+ // grant the traversal matches nothing and "pages in this category" is silently empty.
4092
+ policy(`${p}:public:taxonomies:read`, "cms_taxonomies", "read", allow()),
4093
+ policy(`${p}:public:terms:read`, "cms_terms", "read", allow()),
4094
+ policy(`${p}:public:page-terms:read`, "cms_page_terms", "read", allow()),
4095
+ policy(`${p}:public:widget-areas:read`, "cms_widget_areas", "read", allow()),
2298
4096
  ],
2299
4097
  editor: editorPolicies,
2300
4098
  };
2301
4099
  }
4100
+ // --- collections: edit arbitrary pramen entities in the CMS editor -----------
4101
+ //
4102
+ // The block/page model is one opinionated shape (a routable page with a mandatory slug +
4103
+ // regions of blocks). A COLLECTION is the generic escape hatch: it points the editor at
4104
+ // one of YOUR OWN pramen entities (spread into defineSchema alongside cmsSchema) and
4105
+ // describes how to edit it with the SAME field DSL that blocks use. So "Lectures" is a
4106
+ // first-class, queryable entity — real columns, relations, cell-ACL — that also gets a
4107
+ // list + form UI, without being bent into a cms_pages row.
4108
+ //
4109
+ // Column-mapped: each scalar FieldDefinition.name is a real column on the entity; a
4110
+ // repeater/group/richtext field maps to a t.json() column (the object↔JSON codec at the
4111
+ // Db chokepoint stores it transparently). `richtext` belongs with the latter group — its
4112
+ // value is a document tree, and a TEXT column would bind the object raw and be rejected. The generic handlers dispatch through a registry
4113
+ // keyed by `slug`, so `collection`/`entity` can never be spoofed to reach an arbitrary
4114
+ // table, and writes are whitelisted to declared fields — the client can't set columns the
4115
+ // collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
4116
+ /** Nav positions for the editor's built-in sections — see `./nav`. Re-exported here so a
4117
+ * host writing `app.ts` imports it from the same place as `collection()`. It LIVES in a leaf
4118
+ * module because `blockkit.ts` needs it too and must not depend on this one. */
4119
+ export { NAV_ORDER } from "./nav";
2302
4120
  /** Declare a collection. Spread the results into `createCollectionHandlers` +
2303
4121
  * `collectionPolicies`:
2304
4122
  *
@@ -2322,6 +4140,7 @@ function collectionMeta(c) {
2322
4140
  label: c.label,
2323
4141
  pluralLabel: c.pluralLabel ?? `${c.label}s`,
2324
4142
  icon: c.icon,
4143
+ navOrder: c.navOrder ?? NAV_ORDER.collections,
2325
4144
  fields: c.fields,
2326
4145
  list: c.list ?? [titleField],
2327
4146
  titleField,
@@ -2355,10 +4174,6 @@ export const COLLECTION_PUBLISHED = "published";
2355
4174
  * (GitHub #22), and `LIMIT -1` is SQLite for "no limit". */
2356
4175
  const DEFAULT_COLLECTION_LIST_LIMIT = 100;
2357
4176
  const MAX_COLLECTION_LIST_LIMIT = 500;
2358
- /** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
2359
- * in, so it compares correctly against `$now()`. See the note above on why this is not
2360
- * `nowStamp()`. */
2361
- const isoStamp = () => new Date().toISOString();
2362
4177
  /** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
2363
4178
  *
2364
4179
  * `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
@@ -2398,9 +4213,21 @@ const COLLECTION_FIELD_COLUMN_TYPES = {
2398
4213
  slug: ["text"],
2399
4214
  media: ["text", "uuid"],
2400
4215
  select: ["text"],
4216
+ // A SINGLE reference is one opaque id in a TEXT column. `multiple: true` stores an array
4217
+ // and needs `t.json()` — `referenceColumnTypes` below is what actually decides, because
4218
+ // this table is keyed by type alone and a reference is the one type whose storage depends
4219
+ // on a second flag.
4220
+ reference: ["text", "uuid"],
2401
4221
  repeater: ["json"],
2402
4222
  group: ["json"],
2403
4223
  };
4224
+ /** The column types a field can be stored in, including the one case the type alone does
4225
+ * not settle (`reference` + `multiple`). */
4226
+ function fieldColumnTypes(f) {
4227
+ if (f.type === "reference" && f.multiple)
4228
+ return ["json"];
4229
+ return COLLECTION_FIELD_COLUMN_TYPES[f.type];
4230
+ }
2404
4231
  /** Check a collection registry at BOOT: slugs and entities are unique, features are known
2405
4232
  * and have their prerequisites, every declared field maps to a column that can hold it, and
2406
4233
  * every managed column exists, has the shape the CMS writes, and is not also an editable
@@ -2485,7 +4312,7 @@ export function validateCollections(collections, schema) {
2485
4312
  if (!col) {
2486
4313
  throw new Error(`pramen/cms: collection '${c.slug}' declares a field '${f.name}', which is not a column on '${c.entity}' — a collection field is column-mapped, so every declared field needs its own column`);
2487
4314
  }
2488
- const allowed = COLLECTION_FIELD_COLUMN_TYPES[f.type];
4315
+ const allowed = fieldColumnTypes(f);
2489
4316
  if (allowed && !allowed.includes(col.type)) {
2490
4317
  const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
2491
4318
  throw new Error(`pramen/cms: collection '${c.slug}' declares '${f.name}' as '${f.type}', which is stored as ${allowed.join("/")}, but '${c.entity}.${f.name}' is ${col.type} — declare it as ${want}`);
@@ -3093,7 +4920,7 @@ export function createCollectionHandlers(collections, opts = {}) {
3093
4920
  }),
3094
4921
  // ---- preview ------------------------------------------------------------
3095
4922
  /** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
3096
- * Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
4923
+ * Mirrors `signPagePreview` — same secret, same TTL clamp, works on both stores, and the
3097
4924
  * same rule that the row is read through the ACL FIRST: minting a link is granting
3098
4925
  * access to the row, so a caller who cannot read it must not be able to mint one. */
3099
4926
  signCollectionPreview: query(async (ctx, input) => {