@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/src/index.ts CHANGED
@@ -48,10 +48,12 @@ import {
48
48
  signToken,
49
49
  verifyToken,
50
50
  resolveSecret,
51
+ authorizeHandler,
51
52
  } from "@pramen/server";
52
53
  import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef, FieldDef, FieldType } from "@pramen/server";
53
54
  import type { EnvBag } from "@pramen/server";
54
55
  import { isSafeHref, normalizeHref } from "./href";
56
+ import { NAV_ORDER } from "./nav";
55
57
 
56
58
  // --- field schema DSL (the block-editor field language) ---------------------
57
59
 
@@ -109,6 +111,28 @@ export interface FieldDefinition {
109
111
  | "slug"
110
112
  | "media"
111
113
  | "select"
114
+ /**
115
+ * A pointer to a record that is NOT this row — another pramen row, or a record in a
116
+ * system the CMS does not own. Stored as an OPAQUE id (a string), so the same field
117
+ * links a `cms_pages` row, a collection row and an external CRM record alike.
118
+ *
119
+ * `optionsFrom` on a `select` is most of this already, and stays the ergonomic case for
120
+ * a short closed list: it fetches `{ value, label }[]` ONCE and renders a `<select>`.
121
+ * What it cannot do is scale — no search term, no paging, and no way to render the
122
+ * label of a value whose record is not in the first (only) page. Twenty campaigns are
123
+ * fine; a thousand records are a dropdown nobody can use and a stored id that renders
124
+ * as a uuid.
125
+ *
126
+ * So a reference declares `referenceFrom`, a query handler called with BOTH shapes:
127
+ *
128
+ * { search?: string; limit: number; offset: number } -> browse / search
129
+ * { ids: string[] } -> resolve stored values
130
+ *
131
+ * and returning `{ items: ReferenceOption[]; hasMore?: boolean }` either way. The
132
+ * second shape is what makes an already-stored value renderable without fetching the
133
+ * whole set — the reason this is a field type and not a wider `select`.
134
+ */
135
+ | "reference"
112
136
  | "repeater"
113
137
  | "group";
114
138
  required?: boolean;
@@ -125,6 +149,30 @@ export interface FieldDefinition {
125
149
  optionsFrom?: string;
126
150
  /** slug only — the sibling field this one is derived from (e.g. `"title"`). */
127
151
  from?: string;
152
+ /** reference only — the query handler that resolves this reference. See the `reference`
153
+ * type above for the two request shapes it must answer. */
154
+ referenceFrom?: string;
155
+ /** reference only — store a LIST of ids rather than one. A multiple reference is a
156
+ * `t.json()` column (an array), a single one is `t.text()`; `validateCollections`
157
+ * enforces the difference, because storing an array in a TEXT column is a raw driver
158
+ * error on the first write and nothing earlier. */
159
+ multiple?: boolean;
160
+ }
161
+
162
+ /** One option a `reference` field's `referenceFrom` handler returns. `hint` is secondary
163
+ * text (a date, an owner, a status) shown under the label — a picker over a thousand
164
+ * records usually needs more than a name to tell two rows apart. */
165
+ export interface ReferenceOption {
166
+ value: string;
167
+ label: string;
168
+ hint?: string;
169
+ }
170
+
171
+ /** What a `reference` field's `referenceFrom` handler returns, for BOTH request shapes.
172
+ * `hasMore` drives the picker's "Load more"; omitting it means "this is everything". */
173
+ export interface ReferenceResult {
174
+ items: ReferenceOption[];
175
+ hasMore?: boolean;
128
176
  }
129
177
 
130
178
  /** A named region on a content type; `allowedTypes` (block-type slugs) restricts what
@@ -195,6 +243,8 @@ export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" |
195
243
  ? boolean
196
244
  : D["type"] extends "media"
197
245
  ? ResolvedMedia | null
246
+ : D["type"] extends "reference"
247
+ ? (D extends { multiple: true } ? string[] : string)
198
248
  : D["type"] extends "group"
199
249
  ? InferBlockFields<NonNullable<D["fields"]>>
200
250
  : D["type"] extends "repeater"
@@ -234,6 +284,15 @@ export function defineBlockType<S extends string, F extends readonly FieldDefini
234
284
  fields: F,
235
285
  opts: { name?: string; description?: string; icon?: string; category?: string } = {},
236
286
  ): BlockTypeDef<S, F> {
287
+ // A PURE constructor: it returns exactly what it was given, so `fieldsSchema` really is
288
+ // `F` and `BlockFieldsOf<typeof def>` describes the array that will be stored. Validation
289
+ // deliberately does NOT live here — it lives in `cmsBootstrap`, the thing that writes.
290
+ // `BlockTypeDef` is a structural interface, so an object literal, a `.map` or a codegen
291
+ // step reaches the store without passing through this function at all; checking here would
292
+ // have guarded the convenient path and left the sink open. Canonicalizing here was worse
293
+ // still: `validateFieldSchema` REBUILDS every entry (trimming names, dropping type-inert
294
+ // keys), so the returned array stopped matching the const literal `F` is inferred from and
295
+ // the cast became a lie a component would follow into `fields[" title "] === undefined`.
237
296
  return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
238
297
  }
239
298
 
@@ -278,6 +337,7 @@ export function defineContentType(
278
337
  defaultBlocks?: readonly DefaultBlockDefinition[];
279
338
  },
280
339
  ): ContentTypeDef {
340
+ // Pure, for the same reason as `defineBlockType` — `cmsBootstrap` validates.
281
341
  return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
282
342
  }
283
343
 
@@ -285,27 +345,141 @@ export function defineContentType(
285
345
  * Db (ACL bypassed), so these calls are unrestricted; kept loose to avoid threading the
286
346
  * host app's schema generic through a library helper. */
287
347
  interface ReconcileDb {
288
- find(q: { from: string; where?: Record<string, unknown>; limit?: number }): Promise<Record<string, unknown>[]>;
348
+ find(q: { from: string; where?: Record<string, unknown>; limit?: number; select?: readonly string[] }): Promise<Record<string, unknown>[]>;
289
349
  insert(table: string, values: Record<string, unknown>): Promise<unknown>;
290
350
  update(table: string, id: string, patch: Record<string, unknown>): Promise<unknown>;
291
351
  }
292
352
 
293
353
  const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
294
354
 
355
+ /** The default `owner` — see {@link cmsBootstrap}. */
356
+ export const CMS_BOOTSTRAP_OWNER = "cms";
357
+
358
+ /**
359
+ * Validate + canonicalize what a `cmsBootstrap` will write, at FACTORY-call time.
360
+ *
361
+ * This lives here rather than in `defineBlockType`/`defineContentType` because those are
362
+ * optional conveniences: `BlockTypeDef`/`ContentTypeDef` are exported STRUCTURAL interfaces,
363
+ * so an object literal, a `.map` over a config file or a codegen step reaches `upsertBySlug`
364
+ * without passing through either helper. Checking in the helper guarded the convenient path
365
+ * and left the sink open — and the row it wrote was then locked `managedBy`, so an invalid
366
+ * schema could never be repaired through the product. `cmsBootstrap(defs)` is the one call
367
+ * every code-defined type goes through.
368
+ *
369
+ * Held to the SAME rules the editor's `createBlockType`/`createContentType` enforce
370
+ * (`normalizeFieldSchema`, `normalizeRegions`, `normalizeDefaultBlocks`), because the
371
+ * alternative is a code-declared type storing a schema the builder then refuses to save —
372
+ * the only surface reporting the problem being the one that cannot fix it.
373
+ *
374
+ * EVERY problem is reported together, not just the first: this throws at app construction
375
+ * (`app.ts` module scope, like `validateCollections` in `createCollectionHandlers` and
376
+ * `validateMigrations` in `createPramen`), where fixing them one deploy at a time is the
377
+ * difference between one round trip and six.
378
+ */
379
+ function validateCmsDefinitions(
380
+ defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] },
381
+ ): { blockTypes: Record<string, unknown>[]; contentTypes: Record<string, unknown>[] } {
382
+ const problems: string[] = [];
383
+ const at = (what: string, slug: unknown, e: unknown): void => {
384
+ problems.push(` ${what} ${JSON.stringify(slug)}: ${e instanceof Error ? e.message : String(e)}`);
385
+ };
386
+
387
+ const blockTypes: Record<string, unknown>[] = [];
388
+ const btSeen = new Set<string>();
389
+ for (const bt of defs.blockTypes ?? []) {
390
+ try {
391
+ const slug = assertRegistryKey(bt.slug, "block type slug");
392
+ // Last-wins on a repeated slug is how two feature modules both exporting a `cta` block
393
+ // type converge to whichever import order won, with nothing said about it.
394
+ if (btSeen.has(slug)) throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
395
+ btSeen.add(slug);
396
+ blockTypes.push({
397
+ name: assertLabel(bt.name ?? slug, "block type name"),
398
+ slug,
399
+ description: bt.description ?? null,
400
+ fieldsSchema: normalizeFieldSchema(bt.fieldsSchema, "fieldsSchema"),
401
+ icon: bt.icon ?? null,
402
+ category: bt.category ?? null,
403
+ });
404
+ } catch (e) {
405
+ at("block type", bt.slug, e);
406
+ }
407
+ }
408
+
409
+ const contentTypes: Record<string, unknown>[] = [];
410
+ const ctSeen = new Set<string>();
411
+ for (const ct of defs.contentTypes ?? []) {
412
+ try {
413
+ // The slug FIRST: it is what every other message names, and validating regions ahead
414
+ // of it produced an error that never mentioned the type whose slug was also wrong —
415
+ // fix the regions, redeploy, meet the second failure.
416
+ const slug = assertRegistryKey(ct.slug, "content type slug");
417
+ if (ctSeen.has(slug)) throw new BadRequest(`declared twice — the second declaration would silently overwrite the first`);
418
+ ctSeen.add(slug);
419
+ const regions = normalizeRegions(ct.regions);
420
+ contentTypes.push({
421
+ name: assertLabel(ct.name ?? slug, "content type name"),
422
+ slug,
423
+ description: ct.description ?? null,
424
+ fieldsSchema: normalizeFieldSchema(ct.fields, "fields"),
425
+ regions,
426
+ defaultBlocks: normalizeDefaultBlocks(ct.defaultBlocks, regions),
427
+ });
428
+ } catch (e) {
429
+ at("content type", ct.slug, e);
430
+ }
431
+ }
432
+
433
+ if (problems.length > 0) {
434
+ throw new Error(`cmsBootstrap: ${problems.length} invalid type definition(s)\n${problems.join("\n")}`);
435
+ }
436
+ return { blockTypes, contentTypes };
437
+ }
438
+
295
439
  /** Insert `values` if no row has this `slug`, else patch only the columns that drifted
296
- * (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op. */
297
- async function upsertBySlug(db: ReconcileDb, table: string, slug: string, values: Record<string, unknown>): Promise<void> {
440
+ * (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op.
441
+ *
442
+ * Returns false, having written NOTHING, when the existing row belongs to someone else —
443
+ * an editor-authored type (`managedBy` null) or another reconciler's. Adopting it was a
444
+ * silent takeover: name and schema replaced by the code literal, and then the row locked, so
445
+ * the editor could not even put back what it had just lost. `createBlockType` refuses this
446
+ * exact slug collision at the RPC edge; the reconciler used to win it without a word. */
447
+ async function upsertBySlug(db: ReconcileDb, table: string, owner: string, values: Record<string, unknown>): Promise<boolean> {
448
+ const slug = String(values.slug);
298
449
  const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
299
450
  if (!existing) {
300
- await db.insert(table, values);
301
- return;
451
+ await db.insert(table, { ...values, managedBy: owner });
452
+ return true;
302
453
  }
454
+ if (existing.managedBy !== owner) return false;
303
455
  const patch: Record<string, unknown> = {};
304
456
  for (const [k, v] of Object.entries(values)) {
305
457
  if (k === "slug") continue;
306
458
  if (!sameJson(existing[k], v)) patch[k] = v;
307
459
  }
308
460
  if (Object.keys(patch).length) await db.update(table, String(existing.id), patch);
461
+ return true;
462
+ }
463
+
464
+ /** Release the rows THIS owner wrote and no longer declares.
465
+ *
466
+ * A type dropped from the repo keeps its row — pages are still built out of it — but nothing
467
+ * converges it any more, so leaving it read-only in the builder would be a lock with nothing
468
+ * behind it, next to a note pointing at code that no longer mentions it.
469
+ *
470
+ * Scoped to `managedBy = owner`, which is what makes `cmsBootstrap` composable: a sweep
471
+ * cannot otherwise tell "not mine" from "no longer declared", so two reconcilers in one
472
+ * `app.bootstrap` released each other's rows on every boot and half the types silently fell
473
+ * back to editable. A table the call says nothing about (`blockTypes` absent — the KEY, not
474
+ * an empty array) is left alone rather than swept. */
475
+ async function releaseUndeclared(db: ReconcileDb, table: string, owner: string, declared: ReadonlySet<string>): Promise<void> {
476
+ // `select` because this runs on the boot critical path — inside `blockConcurrencyWhile` on
477
+ // a DO's first fetch, and at every isolate init on D1 where each statement is a round trip.
478
+ // Without it the read pulls and JSON-parses every row's whole field schema to look at two
479
+ // columns (the GitHub #22 shape).
480
+ for (const row of await db.find({ from: table, where: { managedBy: owner }, select: ["id", "slug"] })) {
481
+ if (!declared.has(String(row.slug))) await db.update(table, String(row.id), { managedBy: null });
482
+ }
309
483
  }
310
484
 
311
485
  /** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
@@ -316,30 +490,51 @@ async function upsertBySlug(db: ReconcileDb, table: string, slug: string, values
316
490
  * bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
317
491
  *
318
492
  * Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
319
- * code-declared types with no manual createContentType/createBlockType call. */
320
- export function cmsBootstrap(defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] }): BootstrapFn {
493
+ * code-declared types with no manual createContentType/createBlockType call.
494
+ *
495
+ * Every row it writes is stamped `managedBy: owner`, which makes the editor show it
496
+ * read-only — convergence and an editor pointed at the same rows are otherwise a silent
497
+ * data-loss pair (GitHub #48). A type that drops out of the declaration is released back to
498
+ * the editor; a row this owner did not write is never touched, so a second reconciler (a
499
+ * package shipping its own block types, say) composes as long as it passes its own `owner`.
500
+ *
501
+ * The definitions are validated HERE, when the app is constructed, and every problem is
502
+ * reported at once — see {@link validateCmsDefinitions}. */
503
+ export function cmsBootstrap(
504
+ defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] },
505
+ opts: { owner?: string } = {},
506
+ ): BootstrapFn {
507
+ const owner = opts.owner ?? CMS_BOOTSTRAP_OWNER;
508
+ const { blockTypes, contentTypes } = validateCmsDefinitions(defs);
509
+ // Presence of the KEY, not truthiness of the array: `blockTypes: []` is "I declare none",
510
+ // which must sweep, while an absent key is "I say nothing about block types", which must
511
+ // not. Truthiness read `[]` as the latter four lines after `?? []` read it as the former,
512
+ // and the difference was invisible at the call site — `features.flatMap(f => f.blockTypes)`
513
+ // on an empty list silently unlocked every code-defined type in every tenant.
514
+ const sweepBlockTypes = "blockTypes" in defs;
515
+ const sweepContentTypes = "contentTypes" in defs;
516
+
321
517
  return async ({ db }) => {
322
518
  const sys = db as unknown as ReconcileDb;
323
- for (const bt of defs.blockTypes ?? []) {
324
- await upsertBySlug(sys, "cms_block_types", bt.slug, {
325
- name: bt.name,
326
- slug: bt.slug,
327
- description: bt.description ?? null,
328
- fieldsSchema: bt.fieldsSchema ?? [],
329
- icon: bt.icon ?? null,
330
- category: bt.category ?? null,
331
- });
332
- }
333
- for (const ct of defs.contentTypes ?? []) {
334
- await upsertBySlug(sys, "cms_content_types", ct.slug, {
335
- name: ct.name,
336
- slug: ct.slug,
337
- description: ct.description ?? null,
338
- fieldsSchema: ct.fields ?? [],
339
- regions: ct.regions ?? [],
340
- defaultBlocks: ct.defaultBlocks ?? [],
341
- });
342
- }
519
+ // Per-definition, so one failure does not skip every later type AND both sweeps. The
520
+ // boot runner only logs a throwing reconciler, so an aborted pass leaves the store half
521
+ // converged for that isolate's whole lifetime with nothing to retry it. A UNIQUE
522
+ // violation is the expected instance: `app.bootstrap` has no lease, and on D1 two cold
523
+ // isolates can both find a slug missing and both insert it.
524
+ const reconcile = async (table: string, values: Record<string, unknown>): Promise<void> => {
525
+ try {
526
+ if (!(await upsertBySlug(sys, table, owner, values))) {
527
+ 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)`);
528
+ }
529
+ } catch (e) {
530
+ console.error(`@pramen/cms: failed to reconcile ${table}.${String(values.slug)}:`, e);
531
+ }
532
+ };
533
+
534
+ for (const bt of blockTypes) await reconcile("cms_block_types", bt);
535
+ if (sweepBlockTypes) await releaseUndeclared(sys, "cms_block_types", owner, new Set(blockTypes.map((bt) => String(bt.slug))));
536
+ for (const ct of contentTypes) await reconcile("cms_content_types", ct);
537
+ if (sweepContentTypes) await releaseUndeclared(sys, "cms_content_types", owner, new Set(contentTypes.map((ct) => String(ct.slug))));
343
538
  };
344
539
  }
345
540
 
@@ -371,6 +566,9 @@ function tsTypeOf(f: FieldDefinition): string {
371
566
  return "boolean";
372
567
  case "media":
373
568
  return "ResolvedMedia | null";
569
+ // An opaque id — the record it points at may not be ours to type.
570
+ case "reference":
571
+ return f.multiple ? "string[]" : "string";
374
572
  case "group":
375
573
  return `{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }`;
376
574
  case "repeater":
@@ -442,6 +640,123 @@ export function generateBlockTypes(blockTypes: Array<{ slug: string; fieldsSchem
442
640
 
443
641
  /** The block/page builder tables. All in the default partition (relations can't cross
444
642
  * partitions). Prefixed `cms_` to avoid colliding with your own entities. */
643
+ // --- site furniture: the shapes ----------------------------------------------
644
+ //
645
+ // Menus, taxonomies and widget areas are the site-level furniture a client expects to edit
646
+ // without a deploy. They are deliberately NOT modelled on pages: none of them has a slug, a
647
+ // status, a revision or a workflow, and bending them into `cms_pages` would put a null
648
+ // branch through every page read to serve something that shares no field with a page.
649
+
650
+ /** What a menu item points at.
651
+ *
652
+ * `custom` is a literal href. The rest are REFERENCES resolved at read time, which is the
653
+ * whole reason the kind exists: a menu that stored `/about` verbatim breaks silently the
654
+ * day the page's slug changes, and the redirect that covers it is a second thing to
655
+ * remember. A `page` item follows the page. */
656
+ export type MenuItemKind = "custom" | "page" | "term" | "collection";
657
+
658
+ /** One entry in a menu tree. */
659
+ export interface MenuItem {
660
+ /** Stable within the menu — the editor's list key and the only handle a reorder has. */
661
+ id: string;
662
+ label: string;
663
+ /** Defaults to `"custom"` (a literal `url`). */
664
+ kind?: MenuItemKind;
665
+ /** `page`: a `cms_pages` id · `term`: a `cms_terms` id · `collection`: a collection slug. */
666
+ ref?: string | null;
667
+ /** `custom`: the href, stored verbatim (and `isSafeHref`-checked on write). For the
668
+ * resolved kinds this is FILLED IN by `getMenu` and ignored on write. */
669
+ url?: string;
670
+ target?: string;
671
+ titleAttr?: string;
672
+ cssClasses?: string;
673
+ children?: MenuItem[];
674
+ }
675
+
676
+ /** A named navigation menu. Read whole with `getMenu(name)`. */
677
+ export interface Menu {
678
+ id: string;
679
+ name: string;
680
+ label: string;
681
+ items: MenuItem[];
682
+ }
683
+
684
+ /** How deep a menu tree may nest. Menus are stored as one document, so without a cap a
685
+ * client could post a tree deep enough to blow the stack in the resolver — and no real
686
+ * navigation is more than three levels anyway. */
687
+ export const MAX_MENU_DEPTH = 5;
688
+
689
+ /** How many items one menu may hold, at every level combined.
690
+ *
691
+ * Depth alone is not a bound: a FLAT list of 800 `page` items is legal under
692
+ * `MAX_MENU_DEPTH` and turns every anonymous `getMenu` — the read on every page render of
693
+ * the site — into a single `WHERE id IN (?×800)`, which the read engine emits with no
694
+ * chunking. Every other read added alongside this one is bounded (`MAX_TERMS`,
695
+ * `clampLimit`); this one was not, and it is the one on the hot path. */
696
+ export const MAX_MENU_ITEMS = 200;
697
+
698
+ /** A classification vocabulary — `category`, `tag`, `region`, whatever the site sorts by. */
699
+ export interface Taxonomy {
700
+ id: string;
701
+ slug: string;
702
+ label: string;
703
+ pluralLabel?: string | null;
704
+ description?: string | null;
705
+ /** Terms may declare a `parentId`. A flat vocabulary REJECTS one on write, rather than
706
+ * accepting it and rendering it nowhere. */
707
+ hierarchical: boolean;
708
+ }
709
+
710
+ /** One term in a vocabulary. `children` is present only on the tree read (`getTermTree`). */
711
+ export interface Term {
712
+ id: string;
713
+ taxonomyId: string;
714
+ slug: string;
715
+ label: string;
716
+ description?: string | null;
717
+ parentId?: string | null;
718
+ position: number;
719
+ children?: Term[];
720
+ }
721
+
722
+ /** How deep a term hierarchy may nest — the same argument as {@link MAX_MENU_DEPTH}, except
723
+ * here the tree is rows and the risk is a parent CYCLE, which `assertTermParent` refuses. */
724
+ export const MAX_TERM_DEPTH = 5;
725
+
726
+ /** What a widget renders. `component` is the escape hatch: the CMS stores an id + props and
727
+ * the front end maps the id to one of its own components, exactly as `BlockRenderer` maps a
728
+ * block type slug — so a widget area can hold something the CMS has no idea how to draw. */
729
+ export type WidgetType = "content" | "menu" | "component";
730
+
731
+ /** One widget in a widget area. */
732
+ export interface Widget {
733
+ id: string;
734
+ type: WidgetType;
735
+ title?: string | null;
736
+ /** `content`: a rich-text document (normalized on write like any other richtext value). */
737
+ content?: RichTextDoc;
738
+ /** `menu`: a `cms_menus.name`. `getWidgetArea` resolves it to `menu` alongside. */
739
+ menuName?: string;
740
+ /** `component`: the front end's own component id + its props. */
741
+ componentId?: string;
742
+ componentProps?: FieldValues;
743
+ }
744
+
745
+ /** A widget as READ back: a `menu` widget carries its resolved menu, so a layout renders a
746
+ * whole sidebar from one call rather than one call per widget. */
747
+ export interface ResolvedWidget extends Widget {
748
+ menu?: Menu | null;
749
+ }
750
+
751
+ /** A named template region an admin fills without touching code. */
752
+ export interface WidgetArea {
753
+ id: string;
754
+ name: string;
755
+ label: string;
756
+ description?: string | null;
757
+ widgets: ResolvedWidget[];
758
+ }
759
+
445
760
  export const cmsSchema = {
446
761
  cms_content_types: Entity((t) => ({
447
762
  id: primaryKey(generated(t.uuid())),
@@ -451,6 +766,8 @@ export const cmsSchema = {
451
766
  fieldsSchema: t.json(), // FieldDefinition[] for page-level fields
452
767
  regions: t.json(), // RegionDefinition[]
453
768
  defaultBlocks: t.json(), // DefaultBlockDefinition[]
769
+ // Which reconciler owns this row. See cms_block_types.managedBy.
770
+ managedBy: t.text(),
454
771
  createdAt: defaultTo(t.text(), expr.now()),
455
772
  })),
456
773
 
@@ -462,6 +779,21 @@ export const cmsSchema = {
462
779
  fieldsSchema: t.json(), // FieldDefinition[]
463
780
  icon: t.text(),
464
781
  category: t.text(),
782
+ // NON-NULL while this row is CODE-DEFINED, holding the OWNER id of the `cmsBootstrap`
783
+ // that declares it. The editor authors these rows too (GitHub #9), and the two were
784
+ // otherwise indistinguishable: an editor would add a field, get a 200, and lose it
785
+ // silently at the next cold start when `upsertBySlug` patched the column back to the
786
+ // literal in `app.ts`. So it is set by the reconciler, refused by `updateBlockType` /
787
+ // `updateContentType`, and rendered read-only in the builder. Cleared again when the
788
+ // definition leaves the repo — a lock with nothing behind it is worse than no lock.
789
+ //
790
+ // An OWNER id rather than a boolean because `app.bootstrap` is a composable array. With
791
+ // a flag, two `cmsBootstrap` calls each released the other's rows on every boot: the
792
+ // sweep cannot tell "this row is not mine" from "this row is no longer declared", so
793
+ // half the types silently fell back to editable and #48 came straight back for them. A
794
+ // reconciler now only releases what IT wrote, which is also what lets a package ship its
795
+ // own block types beside the app's.
796
+ managedBy: t.text(),
465
797
  createdAt: defaultTo(t.text(), expr.now()),
466
798
  })),
467
799
 
@@ -508,7 +840,8 @@ export const cmsSchema = {
508
840
  unpublishAt: t.text(),
509
841
  // The revision the public content API serves — set on publish. A direct pointer
510
842
  // (not "latest by timestamp") so selection is deterministic even when two publishes
511
- // land in the same second (expr.now() is second-precision).
843
+ // land in the same instant. `expr.now()` carries milliseconds now, which narrows the
844
+ // window without closing it — a pointer has no window at all.
512
845
  currentRevisionId: t.uuid(),
513
846
  // Soft delete: the epoch-ISO instant the page was trashed, NULL while it is live.
514
847
  // Every read scope AND-merges `deletedAt IS NULL` (see cmsPolicies), so a trashed
@@ -532,6 +865,10 @@ export const cmsSchema = {
532
865
  (r) => ({
533
866
  type: r.belongsTo("cms_content_types", "typeId"),
534
867
  placements: r.hasMany("cms_page_blocks", "pageId"),
868
+ // Taxonomy terms, through the explicit junction. `where: { terms: { slug: "news" } }`
869
+ // compiles to a nested subquery, so "pages in this category" is an ordinary query and
870
+ // not a second handler.
871
+ terms: r.manyToMany("cms_terms", { through: "cms_page_terms", sourceColumn: "pageId", targetColumn: "termId" }),
535
872
  }),
536
873
  { unique: [["slug", "locale"]] }, // a slug is unique per locale (DB-enforced)
537
874
  ),
@@ -597,11 +934,11 @@ export const cmsSchema = {
597
934
  collection: indexed(notNull(t.text())),
598
935
  rowId: indexed(notNull(t.text())),
599
936
  // A monotonic per-row counter, and the ONLY ordering key. Timestamps cannot do this
600
- // job: `expr.now()` is second-resolution and even an ISO ms stamp collides, because a
601
- // collection revision is written on EVERY edit and two writes land in the same
602
- // millisecond often enough to be reproducible. Ordering then falls to a uuid tiebreak,
603
- // which is deterministic but NOT insertion order so "restore the previous version"
604
- // could pick the wrong snapshot.
937
+ // job even at millisecond resolution: a collection revision is written on EVERY edit,
938
+ // and two writes land in the same millisecond often enough to be reproducible. Ordering
939
+ // then falls to a uuid tiebreak, which is deterministic but NOT insertion order — so
940
+ // "restore the previous version" could pick the wrong snapshot. (`expr.now()` was also
941
+ // second-resolution when this was written, which made the same point louder.)
605
942
  //
606
943
  // The read-then-increment in `snapshotRow` is serialized by the DO's single writer. On
607
944
  // the D1 store it is NOT — `D1Driver.transaction` is a no-op (D1 has no interactive
@@ -612,14 +949,147 @@ export const cmsSchema = {
612
949
  snapshot: t.json(),
613
950
  note: t.text(),
614
951
  actor: t.text(),
615
- // NO expr.now() default. `snapshotRow` is the only writer and stamps this itself with
616
- // ISO-8601 ms precision, because unlike cms_page_revisions (written only on publish) a
617
- // collection revision is written on EVERY edit an autosave followed immediately by a
618
- // publish lands two rows in the same second, and `datetime('now')` (second resolution)
619
- // would make "the previous version" an arbitrary pick between them.
952
+ // NO expr.now() default: `snapshotRow` is the only writer and stamps it. That is now a
953
+ // consistency choice rather than a precision one `expr.now()` carries milliseconds
954
+ // too — but `revision` above is what actually orders these rows, and a column no
955
+ // writer but `snapshotRow` touches cannot drift from it.
620
956
  createdAt: t.text(),
621
957
  }), undefined, { unique: [["collection", "rowId", "revision"]] }),
622
958
 
959
+ // --- site furniture: menus, redirects, taxonomies, widget areas ------------------
960
+ //
961
+ // The WordPress-parity furniture every client project reinvents by hand. All four are
962
+ // SITE-level, not page-level: they exist once per deployment and are read by the layout,
963
+ // not by a page's regions.
964
+
965
+ // A named navigation menu. `items` is a nested `MenuItem[]` document rather than a rows
966
+ // table, because a menu is edited and read WHOLE — every read is `getMenu("primary")`,
967
+ // and every write is "here is the new tree". Rows would buy per-item queries nobody makes
968
+ // and cost a recursive assemble on the one read that matters. The tree is depth-capped on
969
+ // write (`MAX_MENU_DEPTH`), which is the constraint a rows table would have got for free.
970
+ cms_menus: Entity((t) => ({
971
+ id: primaryKey(generated(t.uuid())),
972
+ // The key `getMenu(name)` resolves — stable, referenced from layout code, and so NOT
973
+ // renameable through `updateMenu` (the label is what an editor retitles).
974
+ name: unique(notNull(t.text())),
975
+ label: notNull(t.text()),
976
+ items: t.json(), // MenuItem[]
977
+ // Optimistic concurrency, as on cms_pages/cms_blocks. It matters MORE here, not less:
978
+ // `updateMenu` writes the whole `items` document, so two editors on one menu meant the
979
+ // second silently replaced the first's entire tree — where a page edit at least
980
+ // conflicts per field.
981
+ version: defaultTo(t.int(), 1),
982
+ createdAt: defaultTo(t.text(), expr.now()),
983
+ updatedAt: defaultTo(t.text(), expr.now()),
984
+ })),
985
+
986
+ // A URL redirect. Needed the moment a slug changes on a live site — which the `slug`
987
+ // field's own docs already flag ("silently rewriting a slug changes a live URL and breaks
988
+ // every link to it").
989
+ //
990
+ // `fromPath`/`toPath`, not `from`/`to`: `from` is a SQL keyword, and while the dialect
991
+ // quotes every identifier, a column named `from` also collides with the `find({ from })`
992
+ // query key — a `where: { from: ... }` reads as a table reference to anyone skimming.
993
+ cms_redirects: Entity((t) => ({
994
+ id: primaryKey(generated(t.uuid())),
995
+ // Unique because resolution is an exact lookup: two rows for one path is a coin flip
996
+ // over which redirect a visitor gets, and the DB is the only place that can refuse it.
997
+ fromPath: unique(notNull(t.text())),
998
+ toPath: notNull(t.text()),
999
+ // 301 (permanent) or 302 (temporary). INT, and constrained on write — a redirect status
1000
+ // is not free-form, and a typo here is a broken response, not a broken page.
1001
+ status: defaultTo(t.int(), 301),
1002
+ // Off-switch that keeps the row. A redirect is usually disabled to TEST whether it is
1003
+ // still needed; deleting it loses the record of what the old URL was.
1004
+ enabled: defaultTo(t.bool(), true),
1005
+ note: t.text(),
1006
+ createdAt: defaultTo(t.text(), expr.now()),
1007
+ updatedAt: defaultTo(t.text(), expr.now()),
1008
+ })),
1009
+ // NOTE: deliberately no hit counter. Counting would make `resolveRedirect` — the one
1010
+ // handler anonymous traffic calls on every 404 — a WRITE, which is an unauthenticated
1011
+ // row mutation on the hot path and, on the DO, a transaction per miss. Redirect usage
1012
+ // belongs in the edge's own logs.
1013
+
1014
+ // A classification vocabulary: `category` (hierarchical) and `tag` (flat) are just two
1015
+ // rows here, which is why there is no built-in of either — a deployment declares what it
1016
+ // classifies by, the same way it declares its content types.
1017
+ cms_taxonomies: Entity((t) => ({
1018
+ id: primaryKey(generated(t.uuid())),
1019
+ slug: unique(notNull(t.text())),
1020
+ label: notNull(t.text()),
1021
+ pluralLabel: t.text(),
1022
+ description: t.text(),
1023
+ // Hierarchical vocabularies allow `parentId` on their terms; flat ones reject it on
1024
+ // write. Enforced in the handler, not the schema — one term table serves both.
1025
+ hierarchical: defaultTo(t.bool(), false),
1026
+ createdAt: defaultTo(t.text(), expr.now()),
1027
+ })),
1028
+
1029
+ cms_terms: Entity(
1030
+ (t) => ({
1031
+ id: primaryKey(generated(t.uuid())),
1032
+ taxonomyId: indexed(notNull(t.uuid())),
1033
+ slug: notNull(t.text()),
1034
+ label: notNull(t.text()),
1035
+ description: t.text(),
1036
+ // Self-referential, and a REAL FK: deleting a parent term must not leave children
1037
+ // pointing at a row that is gone (the front end would render an orphan branch that
1038
+ // no listing can reach). `setNull` promotes them to the top level instead, which is
1039
+ // the only non-destructive answer — `cascade` would silently delete a subtree.
1040
+ parentId: t.uuid(),
1041
+ position: defaultTo(t.int(), 0),
1042
+ createdAt: defaultTo(t.text(), expr.now()),
1043
+ }),
1044
+ (r) => ({
1045
+ taxonomy: r.belongsTo("cms_taxonomies", "taxonomyId", { onDelete: "cascade" }),
1046
+ parent: r.belongsTo("cms_terms", "parentId", { onDelete: "setNull" }),
1047
+ pages: r.hasMany("cms_page_terms", "termId"),
1048
+ }),
1049
+ // A slug identifies a term WITHIN its vocabulary — `/category/news` and `/tag/news`
1050
+ // are two different terms, and both are legitimate.
1051
+ { unique: [["taxonomyId", "slug"]] },
1052
+ ),
1053
+
1054
+ // The term-assignment junction — an EXPLICIT entity, which is what `manyToMany` means
1055
+ // here: `ctx.db.insert("cms_page_terms", …)` links, `delete` unlinks, and `where`
1056
+ // traverses it as a nested subquery. No synthetic table, no write API to learn.
1057
+ cms_page_terms: Entity(
1058
+ (t) => ({
1059
+ id: primaryKey(generated(t.uuid())),
1060
+ pageId: indexed(notNull(t.uuid())),
1061
+ termId: indexed(notNull(t.uuid())),
1062
+ }),
1063
+ (r) => ({
1064
+ page: r.belongsTo("cms_pages", "pageId", { onDelete: "cascade" }),
1065
+ term: r.belongsTo("cms_terms", "termId", { onDelete: "cascade" }),
1066
+ }),
1067
+ // One assignment per (page, term). Without it a double-submit leaves a page tagged
1068
+ // twice and every `with: { terms: true }` renders the term twice.
1069
+ { unique: [["pageId", "termId"]] },
1070
+ ),
1071
+
1072
+ // A named template region an admin fills without touching code — the sidebar, the footer
1073
+ // column, the pre-footer strip.
1074
+ //
1075
+ // Kept as its own entity rather than a page-less `cms_blocks` region, which was the
1076
+ // tempting reuse. A block placement is `(pageId, region, position)` with `pageId` NOT
1077
+ // NULL: making it nullable to model "belongs to no page" would put a null branch through
1078
+ // every placement read, every region assemble and every page-scoped ACL clause, to model
1079
+ // something that shares no field with a page (no slug, no status, no revisions, no
1080
+ // workflow). A widget area is a small ordered document, and that is what it is stored as.
1081
+ cms_widget_areas: Entity((t) => ({
1082
+ id: primaryKey(generated(t.uuid())),
1083
+ name: unique(notNull(t.text())),
1084
+ label: notNull(t.text()),
1085
+ description: t.text(),
1086
+ widgets: t.json(), // Widget[]
1087
+ // Same argument as cms_menus.version — `updateWidgetArea` replaces the whole list.
1088
+ version: defaultTo(t.int(), 1),
1089
+ createdAt: defaultTo(t.text(), expr.now()),
1090
+ updatedAt: defaultTo(t.text(), expr.now()),
1091
+ })),
1092
+
623
1093
  // Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
624
1094
  // ctx.files + the Worker /files/* route. Block `fields` reference a media id.
625
1095
  cms_media: Entity((t) => ({
@@ -633,6 +1103,50 @@ export const cmsSchema = {
633
1103
  })),
634
1104
  };
635
1105
 
1106
+ /** Block Kit — custom admin pages, described as JSON and rendered by the editor. See
1107
+ * `./blockkit`. Re-exported so a host imports `adminPage` beside `collection`. */
1108
+ export {
1109
+ adminPage,
1110
+ createAdminPageHandlers,
1111
+ normalizeAdminResponse,
1112
+ validateAdminPages,
1113
+ MAX_ADMIN_BLOCK_DEPTH,
1114
+ } from "./blockkit";
1115
+ export type {
1116
+ AdminBlock,
1117
+ AdminButton,
1118
+ AdminElement,
1119
+ AdminInput,
1120
+ AdminInteractionType,
1121
+ AdminPageDef,
1122
+ AdminPageHandlerOpts,
1123
+ AdminPageInteraction,
1124
+ AdminPageMeta,
1125
+ AdminPageResponse,
1126
+ AdminText,
1127
+ } from "./blockkit";
1128
+
1129
+ /**
1130
+ * Columns this package wrote in the pre-ISO space form that the SCHEMA cannot identify.
1131
+ *
1132
+ * `isoTimestampBackfill()` finds every column whose DEFAULT is `expr.now()` on its own.
1133
+ * `cms_pages.publishedAt` has no default at all — `doPublish` stamped it from handler code,
1134
+ * in the same space form, to stay comparable with the `updatedAt` written beside it. There
1135
+ * is nothing on that column to find, so it is named here.
1136
+ *
1137
+ * Spread it into the migration if your store was ever written by a build older than this
1138
+ * one:
1139
+ *
1140
+ * ```ts
1141
+ * migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })]
1142
+ * ```
1143
+ *
1144
+ * Costs nothing on a store that was not — the UPDATE matches no rows.
1145
+ */
1146
+ export const CMS_LEGACY_TIMESTAMP_COLUMNS: Readonly<Record<string, readonly string[]>> = {
1147
+ cms_pages: ["publishedAt"],
1148
+ };
1149
+
636
1150
  // --- field validation --------------------------------------------------------
637
1151
 
638
1152
  export interface ValidateOpts {
@@ -730,6 +1244,19 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
730
1244
  // (collectMediaIds/resolveMediaFields only handle string ids).
731
1245
  if (typeof v !== "string") throw new BadRequest(`field '${at}' must be a media id (string)`);
732
1246
  break;
1247
+ // An OPAQUE id: the record may live in another table, or in a system we do not own,
1248
+ // so there is nothing to check it against here beyond its shape. The picker's
1249
+ // `referenceFrom` handler is the authority on which ids exist, and it runs under the
1250
+ // caller's own ACL — so validating against a list fetched here would be both a second
1251
+ // round trip and a weaker check than the one the storing handler already makes.
1252
+ case "reference":
1253
+ if (def.multiple) {
1254
+ if (!Array.isArray(v)) throw new BadRequest(`field '${at}' must be a list of ids`);
1255
+ if (v.some((id) => typeof id !== "string")) throw new BadRequest(`field '${at}' must be a list of ids (strings)`);
1256
+ } else if (typeof v !== "string") {
1257
+ throw new BadRequest(`field '${at}' must be an id (string)`);
1258
+ }
1259
+ break;
733
1260
  case "group": {
734
1261
  // The baseline MUST descend. Stopping at the top level meant a pre-migration
735
1262
  // richtext value nested in a group was rejected on every write that echoed the
@@ -767,6 +1294,197 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
767
1294
  }
768
1295
  }
769
1296
 
1297
+ // --- validating an AUTHORED field schema -------------------------------------
1298
+ //
1299
+ // `validateFields` above checks a VALUE against a schema. This checks the SCHEMA itself.
1300
+ //
1301
+ // It did not exist while the only way to create a block type was a developer writing
1302
+ // `defineBlockType(...)` in the repo, where tsc is the check. Now that the editor authors
1303
+ // types (GitHub #9), `fieldsSchema` arrives from a browser as free JSON into a `t.json()`
1304
+ // column — and a malformed one is not caught anywhere downstream: `FieldForm` renders
1305
+ // `null` for an unknown type, `validateFields` skips it ("lenient on unknown field types"),
1306
+ // and the block silently loses that field's content on every save. A duplicate `name` is
1307
+ // worse: two controls write the same key, so one of them can never be saved at all.
1308
+
1309
+ /** Every field type the runtime knows. Exported because the editor's type-builder offers
1310
+ * exactly this list — one definition, so a type added here appears there without a second
1311
+ * edit, and a type removed here cannot be authored. */
1312
+ export const FIELD_TYPES: readonly FieldDefinition["type"][] = [
1313
+ "text", "textarea", "richtext", "url", "number", "boolean", "date", "datetime",
1314
+ "publish", "slug", "media", "select", "reference", "repeater", "group",
1315
+ ];
1316
+
1317
+ /** Types whose `fields` nest a further schema. */
1318
+ const NESTING_TYPES: readonly FieldDefinition["type"][] = ["group", "repeater"];
1319
+
1320
+ /** How deep an authored field schema may nest. A schema is rendered by a recursive
1321
+ * component and validated by a recursive function, so the cap is what keeps both bounded
1322
+ * against a hand-posted document; nothing real nests past two or three. */
1323
+ export const MAX_FIELD_DEPTH = 5;
1324
+
1325
+ /** A field NAME is an object key in a `fields` bag and a property name in generated TS
1326
+ * (`generateBlockTypes`), so it is held to what can be both. */
1327
+ const FIELD_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1328
+
1329
+ /** A REGION name. Looser than a field name by one character, because the two are not the
1330
+ * same kind of thing: a field name is emitted as a TS property by `generateBlockTypes`, so
1331
+ * it must be an identifier, while a region name is only ever an object key on the assembled
1332
+ * page (`regions["main-content"]`). Held to `FIELD_NAME` it rejected hyphenated names that
1333
+ * pre-date this validation and are stored today — which would have made every future save
1334
+ * of such a content type fail, with the only fix being a rename that orphans its
1335
+ * placements. */
1336
+ const REGION_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
1337
+
1338
+ /**
1339
+ * Validate and canonicalize an authored `FieldDefinition[]`, throwing a 400 on the first
1340
+ * problem. Returns the CLEANED schema — each field rebuilt from the keys its type actually
1341
+ * uses, so a `select`'s stale `options` cannot ride along on a field someone switched to
1342
+ * `text` and reappear if they switch back.
1343
+ */
1344
+ export function validateFieldSchema(raw: unknown, path = "fieldsSchema", depth = 0): FieldDefinition[] {
1345
+ if (raw == null) return [];
1346
+ if (!Array.isArray(raw)) throw new BadRequest(`${path} must be a list of field definitions`);
1347
+ if (depth >= MAX_FIELD_DEPTH) throw new BadRequest(`${path} nests deeper than ${MAX_FIELD_DEPTH} levels`);
1348
+ const seen = new Set<string>();
1349
+ return raw.map((entry, i) => {
1350
+ const o = asObj(entry) as Record<string, unknown>;
1351
+ const at = `${path}[${i}]`;
1352
+ const name = typeof o.name === "string" ? o.name.trim() : "";
1353
+ if (!FIELD_NAME.test(name)) {
1354
+ throw new BadRequest(`${at}.name must be a field name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(o.name)}`);
1355
+ }
1356
+ // Siblings only — a nested `group` legitimately reuses a name from the outer level,
1357
+ // because it writes into its own bag.
1358
+ if (seen.has(name)) throw new BadRequest(`${path} declares '${name}' twice — two controls would write the same key and one could never be saved`);
1359
+ seen.add(name);
1360
+ const type = o.type as FieldDefinition["type"];
1361
+ if (!FIELD_TYPES.includes(type)) {
1362
+ throw new BadRequest(`${at}.type is '${String(o.type)}', which is not a field type (known: ${FIELD_TYPES.join(", ")})`);
1363
+ }
1364
+ const f: FieldDefinition = { name, type };
1365
+ if (typeof o.label === "string" && o.label.trim() !== "") f.label = o.label.trim();
1366
+ if (o.required === true) f.required = true;
1367
+ if (o.default !== undefined) f.default = o.default;
1368
+
1369
+ if (NESTING_TYPES.includes(type)) {
1370
+ f.fields = validateFieldSchema(o.fields, `${at}.fields`, depth + 1);
1371
+ // A `group` with no fields renders an empty box; a `repeater` with none renders rows
1372
+ // of nothing and an Add button. Both are the shape of a half-finished edit, and both
1373
+ // are silently useless rather than visibly wrong, so they are refused here.
1374
+ if (f.fields.length === 0) throw new BadRequest(`${at} is a '${type}' and needs at least one nested field`);
1375
+ if (type === "repeater") {
1376
+ if (typeof o.min === "number" && Number.isFinite(o.min)) f.min = Math.max(0, Math.trunc(o.min));
1377
+ if (typeof o.max === "number" && Number.isFinite(o.max)) f.max = Math.max(1, Math.trunc(o.max));
1378
+ if (f.min != null && f.max != null && f.min > f.max) throw new BadRequest(`${at} has min ${f.min} above max ${f.max}`);
1379
+ }
1380
+ } else if (type === "select") {
1381
+ // `optionsFrom` takes precedence at render time, so requiring options alongside it
1382
+ // would reject the live-data case the option exists for.
1383
+ if (typeof o.optionsFrom === "string" && o.optionsFrom.trim() !== "") {
1384
+ f.optionsFrom = assertHandlerName(o.optionsFrom, `${at}.optionsFrom`);
1385
+ } else {
1386
+ const options = Array.isArray(o.options) ? o.options.map((v) => String(v).trim()).filter((v) => v !== "") : [];
1387
+ if (options.length === 0) throw new BadRequest(`${at} is a 'select' and needs either \`options\` or an \`optionsFrom\` handler`);
1388
+ if (new Set(options).size !== options.length) throw new BadRequest(`${at} lists the same option twice`);
1389
+ f.options = options;
1390
+ }
1391
+ } else if (type === "slug") {
1392
+ // `from` is optional (a slug typed by hand is legitimate), but naming a field that is
1393
+ // not there is a control that silently never follows anything.
1394
+ if (typeof o.from === "string" && o.from.trim() !== "") f.from = o.from.trim();
1395
+ } else if (type === "reference") {
1396
+ if (typeof o.referenceFrom !== "string" || o.referenceFrom.trim() === "") {
1397
+ throw new BadRequest(`${at} is a 'reference' and needs a \`referenceFrom\` query handler`);
1398
+ }
1399
+ f.referenceFrom = assertHandlerName(o.referenceFrom, `${at}.referenceFrom`);
1400
+ if (o.multiple === true) f.multiple = true;
1401
+ }
1402
+ return f;
1403
+ });
1404
+ }
1405
+
1406
+ /** Resolve `slug` cross-references inside one schema, now that every sibling is known: a
1407
+ * `slug` field's `from` must name a field that exists AT THE SAME LEVEL (the editor reads
1408
+ * it out of the sibling bag) and holds text. Separate pass because forward references are
1409
+ * legitimate — a slug may precede the title it follows. */
1410
+ export function checkSlugSources(schema: readonly FieldDefinition[], path = "fieldsSchema"): void {
1411
+ const byName = new Map(schema.map((f) => [f.name, f]));
1412
+ schema.forEach((f, i) => {
1413
+ if (f.type === "slug" && f.from) {
1414
+ const src = byName.get(f.from);
1415
+ if (!src) throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is not a field alongside it`);
1416
+ if (!["text", "textarea", "select", "url"].includes(src.type)) {
1417
+ throw new BadRequest(`${path}[${i}] derives from '${f.from}', which is a '${src.type}' — a slug can only follow a text field`);
1418
+ }
1419
+ }
1420
+ if (f.fields) checkSlugSources(f.fields, `${path}[${i}].fields`);
1421
+ });
1422
+ }
1423
+
1424
+ /** Validate + canonicalize an authored field schema end to end. */
1425
+ export function normalizeFieldSchema(raw: unknown, path = "fieldsSchema"): FieldDefinition[] {
1426
+ const schema = validateFieldSchema(raw, path);
1427
+ checkSlugSources(schema, path);
1428
+ return schema;
1429
+ }
1430
+
1431
+ /**
1432
+ * Validate a content type's `regions`.
1433
+ *
1434
+ * A region NAME is the key `addBlock({ region })` resolves and the key of the assembled
1435
+ * `regions` object a front end reads, so it is held to the same shape as a field name. An
1436
+ * `allowedTypes` entry is a block-type SLUG; it is not checked against the block types that
1437
+ * exist, on purpose — a content type declaring a region for a block type that has not been
1438
+ * created yet is an ordinary order of work, and `assertRegionAllows` is what enforces the
1439
+ * list at placement time.
1440
+ */
1441
+ export function normalizeRegions(raw: unknown): RegionDefinition[] {
1442
+ if (!Array.isArray(raw) || raw.length === 0) throw new BadRequest("at least one region is required");
1443
+ const seen = new Set<string>();
1444
+ return raw.map((entry, i) => {
1445
+ const o = asObj(entry) as Record<string, unknown>;
1446
+ const name = typeof o.name === "string" ? o.name.trim() : "";
1447
+ if (!REGION_NAME.test(name)) 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)}`);
1448
+ if (seen.has(name)) throw new BadRequest(`regions declares '${name}' twice — the assembled page is keyed by region name, so one would overwrite the other`);
1449
+ seen.add(name);
1450
+ const region: RegionDefinition = { name };
1451
+ if (typeof o.label === "string" && o.label.trim() !== "") region.label = o.label.trim();
1452
+ // `null` and omitted both mean "any block type"; an empty ARRAY means "none", which is
1453
+ // a region nothing can ever be placed in. Almost always a half-finished edit, so it is
1454
+ // normalized to "any" rather than stored as a region that silently refuses everything.
1455
+ if (Array.isArray(o.allowedTypes)) {
1456
+ const allowed = o.allowedTypes.map((v) => String(v).trim()).filter((v) => v !== "");
1457
+ region.allowedTypes = allowed.length > 0 ? [...new Set(allowed)] : null;
1458
+ } else {
1459
+ region.allowedTypes = null;
1460
+ }
1461
+ return region;
1462
+ });
1463
+ }
1464
+
1465
+ /** Validate a content type's `defaultBlocks` against its own regions. A default block that
1466
+ * names a region the type does not declare is created into nowhere — `createPage` would
1467
+ * place it under a key no renderer reads. */
1468
+ export function normalizeDefaultBlocks(raw: unknown, regions: readonly RegionDefinition[]): DefaultBlockDefinition[] {
1469
+ if (raw == null) return [];
1470
+ if (!Array.isArray(raw)) throw new BadRequest("defaultBlocks must be a list");
1471
+ const names = new Set(regions.map((r) => r.name));
1472
+ return raw.map((entry, i) => {
1473
+ const o = asObj(entry) as Record<string, unknown>;
1474
+ const region = typeof o.region === "string" ? o.region.trim() : "";
1475
+ const blockTypeSlug = typeof o.blockTypeSlug === "string" ? o.blockTypeSlug.trim() : "";
1476
+ if (!names.has(region)) throw new BadRequest(`defaultBlocks[${i}] targets region '${region}', which this content type does not declare`);
1477
+ if (!blockTypeSlug) throw new BadRequest(`defaultBlocks[${i}] needs a blockTypeSlug`);
1478
+ const allowed = regions.find((r) => r.name === region)?.allowedTypes;
1479
+ if (allowed && !allowed.includes(blockTypeSlug)) {
1480
+ throw new BadRequest(`defaultBlocks[${i}] places '${blockTypeSlug}' into region '${region}', which does not allow it`);
1481
+ }
1482
+ const out: DefaultBlockDefinition = { region, blockTypeSlug };
1483
+ if (o.fields !== undefined) out.fields = asObj(o.fields);
1484
+ return out;
1485
+ });
1486
+ }
1487
+
770
1488
  // --- rich text: the structural allow-list (server-side — the real XSS boundary) ---
771
1489
  //
772
1490
  // A `richtext` value is a document TREE, so there is no HTML to scrub — the boundary is
@@ -1155,14 +1873,34 @@ const cdb = (ctx: HandlerContext): CmsDb => ctx.db as unknown as CmsDb;
1155
1873
 
1156
1874
  const notFound = (what: string) => new PramenError(`${what} not found`, 404, "not_found");
1157
1875
  const asObj = (v: unknown): FieldValues => (v && typeof v === "object" ? (v as FieldValues) : {});
1158
- // Timestamps in the SAME shape as the `expr.now()` column default (`datetime('now')`:
1159
- // "YYYY-MM-DD HH:MM:SS", UTC, second precision) so a column's insert-default and its
1160
- // handler-written updates stay lexically comparable (an ISO `T`/`Z` string sorts wrong).
1161
- const nowStamp = (): string => new Date().toISOString().slice(0, 19).replace("T", " ");
1162
- const isEditor = (ctx: HandlerContext, roles: readonly string[]): boolean => {
1163
- const held = ctx.identity?.roles ?? (ctx.identity?.role ? [ctx.identity.role] : []);
1164
- return (held as string[]).some((r) => roles.includes(r));
1165
- };
1876
+ /**
1877
+ * An ISO-8601 UTC instant the ONE format every timestamp this package writes by hand is
1878
+ * in, so it compares correctly against `$now()` and against the `expr.now()` column
1879
+ * defaults beside it.
1880
+ *
1881
+ * There used to be two of these. `expr.now()` emitted the `datetime('now')` space form, so
1882
+ * page-workflow stamps (`updatedAt`, `publishedAt`) matched THAT to stay lexically
1883
+ * comparable with their own column default, while collection managed timestamps minted ISO
1884
+ * to stay comparable with `$now()`. One column could not satisfy both, and the split was
1885
+ * the honest way to live with it — a trap documented at length on the `publish` field.
1886
+ *
1887
+ * `expr.now()` is ISO now, so the two requirements are the same requirement and there is
1888
+ * one helper. Existing rows written in the old shape are rewritten by
1889
+ * `isoTimestampBackfill()` (see `CMS_LEGACY_TIMESTAMP_COLUMNS`).
1890
+ */
1891
+ const isoStamp = (): string => new Date().toISOString();
1892
+
1893
+ /** Alias kept because the page-workflow call sites read as "stamp it now". Same function. */
1894
+ const nowStamp = isoStamp;
1895
+
1896
+ /** Does the caller hold one of these roles?
1897
+ *
1898
+ * Delegates to `authorizeHandler`, which is what the dispatcher uses to enforce a handler's
1899
+ * own `auth` — so "may call this handler" and "counts as an editor here" cannot answer
1900
+ * differently. The local copy took `roles` OR `role`, where the framework takes the UNION,
1901
+ * so an identity carrying both saw only one of them. */
1902
+ const isEditor = (ctx: HandlerContext, roles: readonly string[]): boolean =>
1903
+ authorizeHandler([...roles], ctx.identity ?? null);
1166
1904
 
1167
1905
  /** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
1168
1906
  * by position, merging each shared placement's `overrides` over its block's fields. */
@@ -1327,20 +2065,440 @@ async function assertRegionAllows(db: CmsDb, page: Record<string, unknown>, regi
1327
2065
  }
1328
2066
  }
1329
2067
 
1330
- // --- handlers ----------------------------------------------------------------
1331
-
1332
- // --- page preview links (signed capability urls) -----------------------------
1333
- //
1334
- // Preview used to be a ROLE check, so previewing a draft required an editor account —
1335
- // which excludes the person preview actually exists for: the stakeholder reviewing copy
1336
- // before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
1337
- // ONE page, carries its own expiry, and is verified in the Worker before any read happens.
1338
- // Minting stays editor-gated; redeeming needs no account at all.
1339
- //
1340
- // Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
1341
- // the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
1342
- // forgeable links.
1343
-
2068
+ // --- handlers ----------------------------------------------------------------
2069
+
2070
+ // --- page preview links (signed capability urls) -----------------------------
2071
+ //
2072
+ // Preview used to be a ROLE check, so previewing a draft required an editor account —
2073
+ // which excludes the person preview actually exists for: the stakeholder reviewing copy
2074
+ // before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
2075
+ // ONE page, carries its own expiry, and is verified in the Worker before any read happens.
2076
+ // Minting stays editor-gated; redeeming needs no account at all.
2077
+ //
2078
+ // Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
2079
+ // the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
2080
+ // forgeable links.
2081
+
2082
+ // --- site furniture: normalization + resolution helpers ----------------------
2083
+ //
2084
+ // All of this runs on the WRITE path. A menu, a term tree and a widget list are documents
2085
+ // the client posts whole, so "the editor wouldn't send that" is not a boundary — every one
2086
+ // of these shapes is reachable with a curl.
2087
+
2088
+ const MENU_ITEM_KINDS: readonly MenuItemKind[] = ["custom", "page", "term", "collection"];
2089
+
2090
+ /** A stable machine key — the string `getMenu(name)` / `getWidgetArea(name)` resolves, and
2091
+ * a taxonomy's URL segment. Same rule as a page slug, and for the same reason: it lands in
2092
+ * a route. */
2093
+ function assertKey(v: unknown, what: string): string {
2094
+ const s = typeof v === "string" ? v.trim() : "";
2095
+ if (!isSlugString(s)) throw new BadRequest(`${what} must be a key (lowercase letters, digits and single hyphens), got ${JSON.stringify(v)}`);
2096
+ return s;
2097
+ }
2098
+
2099
+ /** A REGISTRY key — a block type's slug. Looser than {@link assertKey} by one character:
2100
+ * underscores are admitted, because a block-type slug is not a URL segment. It is the key a
2101
+ * front end maps to a component (`{ rich_text: RichText }`), and `rich_text` is the
2102
+ * convention every existing schema and the shipped example already use. */
2103
+ function assertRegistryKey(v: unknown, what: string): string {
2104
+ const str = typeof v === "string" ? v.trim() : "";
2105
+ if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(str) || str.length > 80) {
2106
+ throw new BadRequest(`${what} must be a key (lowercase letters, digits, and single hyphens or underscores), got ${JSON.stringify(v)}`);
2107
+ }
2108
+ return str;
2109
+ }
2110
+
2111
+ /** Refuse an editor write to a CODE-DEFINED type.
2112
+ *
2113
+ * `cmsBootstrap` reconciles these rows on every boot, so a save here would return 200 and
2114
+ * then be reverted at the next cold start, taking any content authored against the added
2115
+ * field with it. A 409 is the honest answer: the row exists, the edit is well-formed, and
2116
+ * the conflict is with a definition that lives somewhere this request cannot reach.
2117
+ *
2118
+ * The editor renders a managed type read-only, so this is the curl / stale-tab half.
2119
+ *
2120
+ * The caller MUST have read `managedBy` explicitly (`select`), not taken it off a wide read.
2121
+ * Reads are column-projected against the caller's policy, so under a read policy with a
2122
+ * `fields` list the column is simply absent — and a guard written as "absent means editable"
2123
+ * disarms itself for exactly the deployments that restrict fields. `select` fails CLOSED
2124
+ * instead: an unreadable column is a 403 before this runs. */
2125
+ export function assertNotManaged(row: Record<string, unknown>, what: string, defineFn: string): void {
2126
+ if (!("managedBy" in row)) throw new Error(`assertNotManaged: 'managedBy' was not selected for ${what} — the guard would fail open`);
2127
+ if (row.managedBy == null) return;
2128
+ throw new Conflict(
2129
+ `${what} '${String(row.slug)}' is defined in code (cmsBootstrap owner '${String(row.managedBy)}') — edit its ` +
2130
+ `${defineFn}(...) declaration and redeploy. A change saved here would be reverted on the next boot.`,
2131
+ );
2132
+ }
2133
+
2134
+ /**
2135
+ * An RPC handler name, as an authored field schema may name one (`optionsFrom`,
2136
+ * `referenceFrom`).
2137
+ *
2138
+ * Shape-checked rather than merely non-empty, because the editor interpolates it straight
2139
+ * into a request path — `fetch(\`${base}/rpc/${name}\`)`. `"../admin/data"` normalizes to
2140
+ * `/admin/data`, so a stored string an EDITOR authored became an arbitrary same-origin
2141
+ * authenticated POST fired by whoever opened the block — including an admin, whose token
2142
+ * passes the `/admin/*` gate the editor role cannot. A handler name is an identifier;
2143
+ * nothing that can traverse a path is one.
2144
+ */
2145
+ function assertHandlerName(v: unknown, what: string): string {
2146
+ const str = typeof v === "string" ? v.trim() : "";
2147
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(str) || str.length > 80) {
2148
+ throw new BadRequest(`${what} must be a handler name (a letter or underscore, then letters/digits/underscores), got ${JSON.stringify(v)}`);
2149
+ }
2150
+ return str;
2151
+ }
2152
+
2153
+ /**
2154
+ * A menu item's `ref` for a non-`custom` kind.
2155
+ *
2156
+ * `menuHref` interpolates this into a path, so a ref starting with `/` produced
2157
+ * `//evil.example/` — protocol-relative, off-origin, in the site's primary nav on every
2158
+ * page — while the sibling `custom` branch three lines away ran the same string through
2159
+ * `isSafeHref`. A reference is an id or a slug: no slashes, no scheme, no dots.
2160
+ */
2161
+ function assertRef(v: unknown, label: string, kind: MenuItemKind): string {
2162
+ const str = typeof v === "string" ? v.trim() : "";
2163
+ if (!/^[A-Za-z0-9_-]{1,200}$/.test(str)) {
2164
+ 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)}`);
2165
+ }
2166
+ return str;
2167
+ }
2168
+
2169
+ function assertLabel(v: unknown, what: string): string {
2170
+ const s = typeof v === "string" ? v.trim() : "";
2171
+ if (!s) throw new BadRequest(`${what} must not be empty`);
2172
+ return s;
2173
+ }
2174
+
2175
+ /**
2176
+ * Validate + canonicalize a posted menu tree.
2177
+ *
2178
+ * Every item is rebuilt field by field rather than spread: `items` is a `t.json()` column,
2179
+ * so anything in the posted object would be stored verbatim and handed to a layout that
2180
+ * renders it. Rebuilding is what makes the stored document exactly the declared shape.
2181
+ */
2182
+ function normalizeMenuItems(raw: unknown, depth = 0, budget = { left: MAX_MENU_ITEMS }): MenuItem[] {
2183
+ if (!Array.isArray(raw)) {
2184
+ if (raw == null) return [];
2185
+ throw new BadRequest("menu items must be a list");
2186
+ }
2187
+ if (depth >= MAX_MENU_DEPTH) throw new BadRequest(`menu items may nest at most ${MAX_MENU_DEPTH} levels deep`);
2188
+ return raw.map((entry, i) => {
2189
+ // Counted across the WHOLE tree, not per level — the budget is threaded through the
2190
+ // recursion for that reason.
2191
+ if (--budget.left < 0) throw new BadRequest(`a menu may hold at most ${MAX_MENU_ITEMS} items`);
2192
+ const o = asObj(entry) as Record<string, unknown>;
2193
+ const label = assertLabel(o.label, `menu item [${i}] label`);
2194
+ const kind = (typeof o.kind === "string" ? o.kind : "custom") as MenuItemKind;
2195
+ if (!MENU_ITEM_KINDS.includes(kind)) {
2196
+ throw new BadRequest(`menu item '${label}' has unknown kind '${String(o.kind)}' (known: ${MENU_ITEM_KINDS.join(", ")})`);
2197
+ }
2198
+ // A stable id per item. Minted here when absent so a client that posts a tree without
2199
+ // ids still gets reorderable rows back, rather than a list React can only key by index.
2200
+ const item: MenuItem = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), label, kind };
2201
+ if (kind === "custom") {
2202
+ // The SAME allow-list a rich-text link mark goes through. A menu is rendered into an
2203
+ // `<a href>` on every page of the site, so `javascript:` here is exactly the hole
2204
+ // `isSafeHref` exists to close — and the editor is not the only writer.
2205
+ const url = normalizeHref(typeof o.url === "string" ? o.url : "");
2206
+ if (!isSafeHref(url)) throw new BadRequest(`menu item '${label}' needs a valid url (http(s), mailto:, tel:, a rooted path, or #anchor)`);
2207
+ item.url = url;
2208
+ } else {
2209
+ item.ref = assertRef(o.ref, label, kind);
2210
+ }
2211
+ // `target` is written into an anchor; anything but the four browsing-context keywords
2212
+ // is a named window, which is a way to reuse a tab the site does not own.
2213
+ if (typeof o.target === "string" && o.target !== "") {
2214
+ if (!["_self", "_blank", "_parent", "_top"].includes(o.target)) throw new BadRequest(`menu item '${label}' has an unsupported target '${o.target}'`);
2215
+ item.target = o.target;
2216
+ }
2217
+ if (typeof o.titleAttr === "string" && o.titleAttr !== "") item.titleAttr = o.titleAttr;
2218
+ if (typeof o.cssClasses === "string" && o.cssClasses !== "") item.cssClasses = o.cssClasses;
2219
+ const children = normalizeMenuItems(o.children, depth + 1, budget);
2220
+ if (children.length > 0) item.children = children;
2221
+ return item;
2222
+ });
2223
+ }
2224
+
2225
+ /** Every `page`/`term` ref in a tree, so resolution is two queries rather than one per item. */
2226
+ function collectMenuRefs(items: readonly MenuItem[], pages: Set<string>, terms: Set<string>): void {
2227
+ for (const it of items) {
2228
+ if (it.ref) {
2229
+ if (it.kind === "page") pages.add(it.ref);
2230
+ else if (it.kind === "term") terms.add(it.ref);
2231
+ }
2232
+ if (it.children) collectMenuRefs(it.children, pages, terms);
2233
+ }
2234
+ }
2235
+
2236
+ /** Where a resolved menu item points. Passed to `menuHref` so a deployment can route its
2237
+ * own way without the CMS guessing. */
2238
+ export type MenuHrefTarget =
2239
+ | { kind: "page"; slug: string; locale: string }
2240
+ | { kind: "term"; taxonomy: string; slug: string }
2241
+ | { kind: "collection"; slug: string };
2242
+
2243
+ /** A URL redirect's status. 301/308 are permanent (cached by browsers, and by search
2244
+ * engines as a canonical move); 302/307 are not. Anything else is not a redirect. */
2245
+ export const REDIRECT_STATUSES: readonly number[] = [301, 302, 307, 308];
2246
+
2247
+ /**
2248
+ * The stored form of a redirect's `fromPath`: a rooted, query-less, fragment-less path.
2249
+ *
2250
+ * Canonicalized rather than merely validated, because matching is an exact string lookup
2251
+ * against a UNIQUE column. `"/old"` and `"/old/"` are the same URL to a visitor and two
2252
+ * rows here, so the second one is dead the moment the first exists — and which one wins is
2253
+ * whichever the editor happened to type. Trailing slash off (except the root), fragment
2254
+ * and query dropped, percent-encoding left exactly as written (the parser's, and the
2255
+ * request's, canonical form).
2256
+ */
2257
+ export function normalizeRedirectPath(raw: unknown): string {
2258
+ const s = typeof raw === "string" ? normalizeHref(raw) : "";
2259
+ if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) {
2260
+ throw new BadRequest(`redirect path must be a rooted path like /old-url, got ${JSON.stringify(raw)}`);
2261
+ }
2262
+ const path = s.split("#")[0]!.split("?")[0]!;
2263
+ const trimmed = path.length > 1 ? path.replace(/\/+$/, "") || "/" : "/";
2264
+ // PERCENT-ENCODED, through the same parser the request goes through. A visitor's path
2265
+ // reaches `resolveRedirect` as `url.pathname`, which the WHATWG parser has already
2266
+ // encoded — so an editor typing `/o-nás` stored a string that the exact-match lookup
2267
+ // could never be handed, and the redirect silently never fired. On precisely the
2268
+ // non-English sites where slug changes are most common. Idempotent: an already-encoded
2269
+ // path parses back to itself.
2270
+ try {
2271
+ return new URL(trimmed, "https://pramen.invalid").pathname;
2272
+ } catch {
2273
+ throw new BadRequest(`redirect path is not a usable path: ${JSON.stringify(raw)}`);
2274
+ }
2275
+ }
2276
+
2277
+ /**
2278
+ * Is this redirect a loop — does its destination resolve back to its own source?
2279
+ *
2280
+ * Compared through `normalizeRedirectPath` on BOTH sides, which a raw `from === to` did
2281
+ * not do: `from: "/old", to: "/old/"` differ as strings, so the guard passed — and then a
2282
+ * visitor hitting `/old` was sent to `/old/`, whose 404 handler canonicalizes the trailing
2283
+ * slash back to `/old` and matches the same row. An infinite redirect, from the one pair
2284
+ * the guard exists to catch. (A test here even asserted this pair was fine, on the reading
2285
+ * that a trailing-slash redirect is a normal canonicalization — true in general, and not
2286
+ * true when the lookup canonicalizes the slash away again.)
2287
+ *
2288
+ * An absolute destination is never a loop with a rooted source: it names an origin, and
2289
+ * `resolveRedirect` is only ever handed a path.
2290
+ */
2291
+ function isSelfRedirect(fromPath: string, toPath: string): boolean {
2292
+ if (/^https?:\/\//i.test(toPath)) return false;
2293
+ try {
2294
+ return normalizeRedirectPath(toPath) === fromPath;
2295
+ } catch {
2296
+ return false;
2297
+ }
2298
+ }
2299
+
2300
+ /** A redirect's destination: a rooted path or an absolute http(s) url. `mailto:`/`tel:` are
2301
+ * refused — they are not somewhere a `Location` header can send a page request. */
2302
+ function normalizeRedirectTarget(raw: unknown): string {
2303
+ const s = typeof raw === "string" ? normalizeHref(raw) : "";
2304
+ const ok = /^https?:\/\//i.test(s) || (s.startsWith("/") && !s.startsWith("//") && !s.startsWith("/\\"));
2305
+ if (!ok) throw new BadRequest(`redirect target must be a rooted path or an absolute http(s) url, got ${JSON.stringify(raw)}`);
2306
+ return s;
2307
+ }
2308
+
2309
+ /** Assemble a flat term list into a forest, ordered by `position` then `label`.
2310
+ *
2311
+ * A term whose `parentId` names a row that is not in `rows` is treated as a ROOT rather
2312
+ * than dropped. That is the case where a parent was deleted mid-read (the FK sets children
2313
+ * to NULL, but a snapshot taken across the two states can see the old value) — and a term
2314
+ * that vanishes from a vocabulary listing is a worse answer than one that shows up a level
2315
+ * too high.
2316
+ */
2317
+ function buildTermTree(rows: readonly Term[]): Term[] {
2318
+ const byId = new Map<string, Term & { children: Term[] }>();
2319
+ for (const r of rows) byId.set(r.id, { ...r, children: [] });
2320
+ const roots: Term[] = [];
2321
+ for (const node of byId.values()) {
2322
+ const parent = node.parentId ? byId.get(node.parentId) : undefined;
2323
+ // `parent !== node` guards the one cycle a single row can make on its own; deeper
2324
+ // cycles are refused on write by `assertTermParent`, which is where a cycle is
2325
+ // actually preventable.
2326
+ if (parent && parent !== node) parent.children.push(node);
2327
+ else roots.push(node);
2328
+ }
2329
+ const sort = (list: Term[]): Term[] => {
2330
+ list.sort((a, b) => a.position - b.position || a.label.localeCompare(b.label));
2331
+ for (const t of list) if (t.children) sort(t.children);
2332
+ return list;
2333
+ };
2334
+ return sort(roots);
2335
+ }
2336
+
2337
+ const WIDGET_TYPES: readonly WidgetType[] = ["content", "menu", "component"];
2338
+
2339
+ /** A list limit from client input, clamped to what `listPages` already allows. Absent or
2340
+ * unusable falls back to the default rather than to "unbounded" — a request with no limit
2341
+ * on a store reached over RPC is the shape that made lists hang (GitHub #22). */
2342
+ function clampLimit(v: unknown): number {
2343
+ const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : PAGE_LIST_LIMIT;
2344
+ return Math.max(1, Math.min(n, PAGE_LIST_MAX_LIMIT));
2345
+ }
2346
+
2347
+ /** The most terms one vocabulary (or one page) may carry in a single read.
2348
+ *
2349
+ * A vocabulary is read WHOLE by `listTerms`/`getTermTree` — a tree cannot be paged without
2350
+ * either losing branches or fetching ancestors separately — so the cap is what keeps that
2351
+ * read bounded. Tags are the case that grows without anyone deciding to grow it. */
2352
+ const MAX_TERMS = 1000;
2353
+
2354
+ /** A redirect's editable fields, as posted. `requireEnds` is the CREATE case, where both
2355
+ * ends must be present; an update patches whichever keys it sends. */
2356
+ interface RedirectPatch {
2357
+ fromPath?: string;
2358
+ toPath?: string;
2359
+ status?: number;
2360
+ enabled?: boolean;
2361
+ note?: string | null;
2362
+ }
2363
+
2364
+ function redirectPatch(raw: unknown, requireEnds: boolean): RedirectPatch {
2365
+ const o = asObj(raw);
2366
+ const out: RedirectPatch = {};
2367
+ if (requireEnds || o.fromPath !== undefined) out.fromPath = normalizeRedirectPath(o.fromPath);
2368
+ if (requireEnds || o.toPath !== undefined) out.toPath = normalizeRedirectTarget(o.toPath);
2369
+ if (o.status !== undefined) {
2370
+ const status = typeof o.status === "number" ? Math.trunc(o.status) : NaN;
2371
+ if (!REDIRECT_STATUSES.includes(status)) throw new BadRequest(`redirect status must be one of ${REDIRECT_STATUSES.join(", ")}`);
2372
+ out.status = status;
2373
+ }
2374
+ if (o.enabled !== undefined) out.enabled = Boolean(o.enabled);
2375
+ if (o.note !== undefined) out.note = typeof o.note === "string" ? o.note : null;
2376
+ return out;
2377
+ }
2378
+
2379
+ /** A required row id from client input. */
2380
+ function requireId(raw: unknown, what = "id"): string {
2381
+ const v = (asObj(raw) as Record<string, unknown>)[what];
2382
+ if (typeof v !== "string" || v === "") throw new BadRequest(`${what} is required`);
2383
+ return v;
2384
+ }
2385
+
2386
+ /** Resolve a taxonomy by its slug. */
2387
+ async function taxonomyBySlug(db: CmsDb, slug: string): Promise<{ id: string; hierarchical: boolean } | null> {
2388
+ const rows = await db.find({ from: "cms_taxonomies", where: { slug }, select: ["id", "hierarchical"], limit: 1 });
2389
+ const row = rows[0];
2390
+ return row ? { id: String(row.id), hierarchical: Boolean(row.hierarchical) } : null;
2391
+ }
2392
+
2393
+ /**
2394
+ * Check a proposed `parentId` for a term: it exists, it is in the SAME vocabulary, the
2395
+ * vocabulary is hierarchical, the tree stays inside {@link MAX_TERM_DEPTH}, and — for an
2396
+ * update — the new parent is not the term itself or one of its own descendants.
2397
+ *
2398
+ * The cycle check is the one that matters. `ON DELETE SET NULL` keeps the FK honest but
2399
+ * says nothing about shape, so `A.parent = B; B.parent = A` is two perfectly legal writes
2400
+ * that together make `buildTermTree` produce a forest missing both, and any recursive
2401
+ * renderer loop forever. It is only preventable on write, which is here.
2402
+ */
2403
+ async function assertTermParent(db: CmsDb, tax: { id: string; hierarchical: boolean }, parentId: string | null, termId: string | null): Promise<void> {
2404
+ if (parentId === null) return;
2405
+ if (!tax.hierarchical) throw new BadRequest("this vocabulary is flat — its terms cannot have a parent");
2406
+ if (termId !== null && parentId === termId) throw new BadRequest("a term cannot be its own parent");
2407
+ // How many levels the MOVED term itself occupies. A leaf is 1; a term with children takes
2408
+ // its subtree with it, and a cap that ignored that admitted a 5-level tree grafted under a
2409
+ // 4-level parent. Zero cost on the create path, where there is no subtree yet.
2410
+ const moving = termId === null ? 1 : await subtreeHeight(db, tax.id, termId);
2411
+ let cursor: string | null = parentId;
2412
+ // `depth` counts ANCESTORS walked. The moved term sits at `ancestors + moving` levels, and
2413
+ // that is what the cap governs — counting ancestors alone admitted one level too many
2414
+ // (a chain of 5 put the new term at level 6 under a cap of 5).
2415
+ for (let depth = 0; cursor !== null; depth++) {
2416
+ // About to walk ancestor number `depth + 1`. The moved term would then sit at
2417
+ // `(depth + 1) + moving` levels, and THAT is what the cap governs.
2418
+ if (depth + 1 + moving > MAX_TERM_DEPTH) throw new BadRequest(`terms may nest at most ${MAX_TERM_DEPTH} levels deep`);
2419
+ const rows: Array<Record<string, unknown>> = await db.find({ from: "cms_terms", where: { id: cursor }, select: ["id", "taxonomyId", "parentId"], limit: 1 });
2420
+ const row = rows[0];
2421
+ if (!row) throw new BadRequest("parent term not found");
2422
+ if (String(row.taxonomyId) !== tax.id) throw new BadRequest("a term's parent must be in the same vocabulary");
2423
+ // Walking UP from the proposed parent: meeting the term being edited means the parent
2424
+ // is one of its own descendants, which is the cycle.
2425
+ if (termId !== null && String(row.id) === termId) throw new BadRequest("a term cannot be moved under one of its own descendants");
2426
+ cursor = row.parentId == null ? null : String(row.parentId);
2427
+ }
2428
+ }
2429
+
2430
+ /** How many levels a term's own subtree occupies (a leaf is 1).
2431
+ *
2432
+ * One query for the whole vocabulary rather than a walk per level: a vocabulary is already
2433
+ * read whole by `listTerms`/`getTermTree` and capped at `MAX_TERMS`, so this is the same
2434
+ * bounded read those make, not a new unbounded one. */
2435
+ async function subtreeHeight(db: CmsDb, taxonomyId: string, rootId: string): Promise<number> {
2436
+ const rows = await db.find({ from: "cms_terms", where: { taxonomyId }, select: ["id", "parentId"], limit: MAX_TERMS });
2437
+ const children = new Map<string, string[]>();
2438
+ for (const r of rows) {
2439
+ const parent = r.parentId == null ? null : String(r.parentId);
2440
+ if (parent) children.set(parent, [...(children.get(parent) ?? []), String(r.id)]);
2441
+ }
2442
+ // Iterative, and bounded by MAX_TERMS: a cycle already in the store (written before the
2443
+ // check that now prevents one) must not spin here.
2444
+ let level = 0;
2445
+ let frontier = [rootId];
2446
+ const seen = new Set<string>();
2447
+ while (frontier.length > 0 && level <= MAX_TERM_DEPTH + 1) {
2448
+ level++;
2449
+ const next: string[] = [];
2450
+ for (const id of frontier) {
2451
+ if (seen.has(id)) continue;
2452
+ seen.add(id);
2453
+ next.push(...(children.get(id) ?? []));
2454
+ }
2455
+ frontier = next;
2456
+ }
2457
+ return level;
2458
+ }
2459
+
2460
+ /**
2461
+ * Validate + canonicalize a posted widget list.
2462
+ *
2463
+ * Rebuilt field by field for the same reason a menu tree is: `widgets` is a `t.json()`
2464
+ * column handed straight to a layout, so whatever the client posts is what renders.
2465
+ * A `content` widget's rich text goes through the SAME `normalizeRichText` allow-list every
2466
+ * block field does — this is a second write path into the same renderer, and it must not be
2467
+ * a weaker one.
2468
+ */
2469
+ function normalizeWidgets(raw: unknown, rtSchema: RichTextSchema): Widget[] {
2470
+ if (!Array.isArray(raw)) {
2471
+ if (raw == null) return [];
2472
+ throw new BadRequest("widgets must be a list");
2473
+ }
2474
+ return raw.map((entry, i) => {
2475
+ const o = asObj(entry) as Record<string, unknown>;
2476
+ const type = (typeof o.type === "string" ? o.type : "") as WidgetType;
2477
+ if (!WIDGET_TYPES.includes(type)) throw new BadRequest(`widget [${i}] has unknown type '${String(o.type)}' (known: ${WIDGET_TYPES.join(", ")})`);
2478
+ const w: Widget = { id: typeof o.id === "string" && o.id.trim() !== "" ? o.id.trim() : crypto.randomUUID(), type };
2479
+ if (typeof o.title === "string" && o.title !== "") w.title = o.title;
2480
+ if (type === "content") {
2481
+ w.content = normalizeRichText(o.content, rtSchema);
2482
+ } else if (type === "menu") {
2483
+ w.menuName = assertKey(o.menuName, `widget [${i}] menuName`);
2484
+ } else {
2485
+ const id = typeof o.componentId === "string" ? o.componentId.trim() : "";
2486
+ if (!id) throw new BadRequest(`widget [${i}] is a component widget and needs a componentId`);
2487
+ w.componentId = id;
2488
+ // Props are opaque to the CMS — the front end's component owns their meaning — but
2489
+ // they must be a JSON OBJECT, not a bare array or scalar that a spread would silently
2490
+ // turn into indexed props.
2491
+ if (o.componentProps !== undefined) {
2492
+ if (o.componentProps === null || typeof o.componentProps !== "object" || Array.isArray(o.componentProps)) {
2493
+ throw new BadRequest(`widget [${i}] componentProps must be an object`);
2494
+ }
2495
+ w.componentProps = o.componentProps as FieldValues;
2496
+ }
2497
+ }
2498
+ return w;
2499
+ });
2500
+ }
2501
+
1344
2502
  /** What a preview link authorizes: one page, in one tenant, until `exp`. */
1345
2503
  export interface PreviewToken {
1346
2504
  /** tenant */ t: string;
@@ -1417,6 +2575,14 @@ export interface CmsHandlerOpts {
1417
2575
  * (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
1418
2576
  * a node type absent from the schema is DROPPED on write, not rejected. */
1419
2577
  richTextSchema?: RichTextSchema;
2578
+ /** Map a resolved menu reference to a site path. The CMS is headless, so it cannot know
2579
+ * how a deployment routes — this is the same seam `sitemapXml`'s `pageUrl` is.
2580
+ *
2581
+ * Defaults: a page is `/${slug}` on a monolingual deployment and `/${locale}/${slug}`
2582
+ * once `locales` declares more than one; a term is `/${taxonomy}/${term}`; a collection
2583
+ * is `/${slug}/`. Override it and `getMenu` follows — which is the point of storing a
2584
+ * REFERENCE rather than the href an editor typed: change the routing, not the menu. */
2585
+ menuHref?: (target: MenuHrefTarget) => string;
1420
2586
  }
1421
2587
 
1422
2588
  /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
@@ -1431,6 +2597,16 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1431
2597
  const reviewer = { auth: reviewerRoles };
1432
2598
  const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
1433
2599
  const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
2600
+ const menuHref = opts.menuHref ?? ((target: MenuHrefTarget): string => {
2601
+ switch (target.kind) {
2602
+ // The locale segment appears only where there is a choice to make. A monolingual site
2603
+ // getting `/en/about` is the sitemap default's known wart, and a menu is the one place
2604
+ // it would be visible in the site's own chrome.
2605
+ case "page": return locales.length > 1 ? `/${target.locale}/${target.slug}` : `/${target.slug}`;
2606
+ case "term": return `/${target.taxonomy}/${target.slug}`;
2607
+ case "collection": return `/${target.slug}/`;
2608
+ }
2609
+ });
1434
2610
  // Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
1435
2611
  // content type/blocks before approving). Read/preview handlers use this; writes stay editor.
1436
2612
  const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
@@ -1532,6 +2708,77 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1532
2708
  }
1533
2709
  };
1534
2710
 
2711
+ /**
2712
+ * One stored menu row, with its references resolved to hrefs.
2713
+ *
2714
+ * Shared by `getMenu` and `getWidgetArea` rather than inlined in the first: a `menu`
2715
+ * widget embeds a menu, and returning its RAW items there skipped every rule this
2716
+ * function exists to apply — a `page` item came back with no `url` at all (the layout
2717
+ * renders `href=undefined`) and an UNPUBLISHED page's label and id were served to
2718
+ * anonymous callers, which is precisely what the drop below prevents on the other path.
2719
+ */
2720
+ const resolveMenuRow = async (db: CmsDb, row: Record<string, unknown>): Promise<Menu> => {
2721
+ const items = Array.isArray(row.items) ? (row.items as MenuItem[]) : [];
2722
+
2723
+ // Two lookups for the whole tree, not one per item. Both go through `ctx.db`, so the
2724
+ // caller's own read scope applies: for an anonymous visitor that is the public policy
2725
+ // (published, not trashed), which is precisely the filter a menu needs — a link to a
2726
+ // page that has been unpublished must not render.
2727
+ const pageIds = new Set<string>();
2728
+ const termIds = new Set<string>();
2729
+ collectMenuRefs(items, pageIds, termIds);
2730
+ const pages = new Map<string, { slug: string; locale: string }>();
2731
+ if (pageIds.size > 0) {
2732
+ const found = await db.find({ from: "cms_pages", where: { id: { in: [...pageIds] } }, select: ["id", "slug", "locale"], limit: pageIds.size });
2733
+ for (const p of found) pages.set(String(p.id), { slug: String(p.slug), locale: String(p.locale ?? defaultLocale) });
2734
+ }
2735
+ const terms = new Map<string, { slug: string; taxonomy: string }>();
2736
+ if (termIds.size > 0) {
2737
+ const found = await db.find({ from: "cms_terms", where: { id: { in: [...termIds] } }, select: ["id", "slug", "taxonomyId"], limit: termIds.size });
2738
+ const taxIds = [...new Set(found.map((t) => String(t.taxonomyId)))];
2739
+ const taxa = taxIds.length > 0 ? await db.find({ from: "cms_taxonomies", where: { id: { in: taxIds } }, select: ["id", "slug"], limit: taxIds.length }) : [];
2740
+ const taxSlug = new Map(taxa.map((t) => [String(t.id), String(t.slug)]));
2741
+ for (const t of found) {
2742
+ const tax = taxSlug.get(String(t.taxonomyId));
2743
+ if (tax) terms.set(String(t.id), { slug: String(t.slug), taxonomy: tax });
2744
+ }
2745
+ }
2746
+
2747
+ // An item whose target no longer resolves is DROPPED, together with its subtree. A
2748
+ // nav entry that renders no href is a dead link on every page of the site, and
2749
+ // hoisting orphaned children would silently promote a third-level item into the top
2750
+ // bar. The editor's own `listMenus` returns the raw tree, so nothing is lost there.
2751
+ const resolve = (list: readonly MenuItem[]): MenuItem[] => {
2752
+ const out: MenuItem[] = [];
2753
+ for (const item of list) {
2754
+ let url = item.url;
2755
+ if (item.kind === "page") {
2756
+ const page = item.ref ? pages.get(item.ref) : undefined;
2757
+ if (!page) continue;
2758
+ url = menuHref({ kind: "page", slug: page.slug, locale: page.locale });
2759
+ } else if (item.kind === "term") {
2760
+ const term = item.ref ? terms.get(item.ref) : undefined;
2761
+ if (!term) continue;
2762
+ url = menuHref({ kind: "term", taxonomy: term.taxonomy, slug: term.slug });
2763
+ } else if (item.kind === "collection") {
2764
+ if (!item.ref) continue;
2765
+ url = menuHref({ kind: "collection", slug: item.ref });
2766
+ }
2767
+ // The MINTED url goes through the same allow-list the `custom` branch enforces on
2768
+ // write. A reference is interpolated into a path (`/${slug}/`), and a `ref` that
2769
+ // began with a slash produced `//evil.example/` — protocol-relative, off-origin,
2770
+ // in the site's primary nav on every page. `assertRef` refuses that shape on
2771
+ // write; this is the second half, because `menuHref` is host-supplied and a
2772
+ // deployment's own mapping can build an unsafe href out of a safe ref.
2773
+ if (!url || !isSafeHref(url)) continue;
2774
+ const children = item.children ? resolve(item.children) : [];
2775
+ out.push({ ...item, url, ...(children.length > 0 ? { children } : { children: undefined }) });
2776
+ }
2777
+ return out;
2778
+ };
2779
+ return { id: String(row.id), name: String(row.name), label: String(row.label), items: resolve(items) };
2780
+ };
2781
+
1535
2782
  const TASK_PUBLISH = "cms:publish";
1536
2783
  const TASK_UNPUBLISH = "cms:unpublish";
1537
2784
 
@@ -1540,7 +2787,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1540
2787
  listBlockTypes: query((ctx) => cdb(ctx).find({ from: "cms_block_types", orderBy: { column: "name" } })),
1541
2788
 
1542
2789
  createBlockType: mutation(async (ctx, input: { name: string; slug: string; fieldsSchema?: FieldDefinition[]; icon?: string; category?: string; description?: string }) => {
1543
- return cdb(ctx).insert("cms_block_types", {
2790
+ const db = cdb(ctx);
2791
+ // A clean 409 before the UNIQUE constraint fires. The editor authors these now, and a
2792
+ // raw constraint error reads as "the CMS broke" rather than "that slug is taken".
2793
+ const clash = await db.find({ from: "cms_block_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
2794
+ if (clash[0]) throw new Conflict(`block type '${input.slug}' already exists`);
2795
+ return db.insert("cms_block_types", {
1544
2796
  name: input.name,
1545
2797
  slug: input.slug,
1546
2798
  description: input.description ?? null,
@@ -1553,12 +2805,30 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1553
2805
  input: (raw): { name: string; slug: string; fieldsSchema?: FieldDefinition[]; icon?: string; category?: string; description?: string } => {
1554
2806
  const o = asObj(raw);
1555
2807
  if (typeof o.name !== "string" || typeof o.slug !== "string") throw new BadRequest("name and slug are required");
1556
- return o as never;
2808
+ if (o.name.trim() === "") throw new BadRequest("name must not be empty");
2809
+ return {
2810
+ name: o.name.trim(),
2811
+ // A block type's slug is a registry key: the editor's inserter, `assertRegionAllows`
2812
+ // and `@pramen/cms/react`'s component map all resolve it. Held to the same shape as
2813
+ // any other key rather than accepted as free text.
2814
+ slug: assertRegistryKey(o.slug, "block type slug"),
2815
+ description: typeof o.description === "string" ? o.description : undefined,
2816
+ icon: typeof o.icon === "string" ? o.icon : undefined,
2817
+ category: typeof o.category === "string" ? o.category : undefined,
2818
+ fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
2819
+ };
1557
2820
  },
1558
2821
  }),
1559
2822
 
1560
2823
  createContentType: mutation(async (ctx, input: { name: string; slug: string; regions: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] }) => {
1561
- return cdb(ctx).insert("cms_content_types", {
2824
+ const db = cdb(ctx);
2825
+ // The same pre-check `createBlockType` and every `create*` in this file already do.
2826
+ // It was the one create handler without it, so a duplicate slug surfaced as a raw
2827
+ // `UNIQUE constraint failed` with no status — a 500. Newly likely: an editor just told
2828
+ // a content type is code-defined and read-only will try to recreate it under that slug.
2829
+ const clash = await db.find({ from: "cms_content_types", where: { slug: input.slug }, select: ["id"], limit: 1 });
2830
+ if (clash[0]) throw new Conflict(`content type '${input.slug}' already exists`);
2831
+ return db.insert("cms_content_types", {
1562
2832
  name: input.name,
1563
2833
  slug: input.slug,
1564
2834
  regions: input.regions ?? [],
@@ -1574,9 +2844,19 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1574
2844
  // editor (`/types/:slug`) and the key `listPages({ contentType })` resolves. An
1575
2845
  // empty one builds `/types/` — a path the router drops the empty segment from, so
1576
2846
  // the type gets a tab that cannot be reached and a list that cannot be addressed.
1577
- if (o.name.trim() === "" || o.slug.trim() === "") throw new BadRequest("name and slug must not be empty");
1578
- if (!Array.isArray(o.regions) || o.regions.length === 0) throw new BadRequest("at least one region is required");
1579
- return o as never;
2847
+ if (o.name.trim() === "") throw new BadRequest("name must not be empty");
2848
+ const regions = normalizeRegions(o.regions);
2849
+ return {
2850
+ name: o.name.trim(),
2851
+ // The SAME rule a block-type slug follows. They were split — block types admitted
2852
+ // `_`, content types did not — for no reason that survives inspection: both are
2853
+ // registry keys, and an underscore is as legal in the `/types/:slug` segment as it
2854
+ // is anywhere else in a URL. The example itself ships `seeded_doc`.
2855
+ slug: assertRegistryKey(o.slug, "content type slug"),
2856
+ regions,
2857
+ fieldsSchema: normalizeFieldSchema(o.fieldsSchema),
2858
+ defaultBlocks: normalizeDefaultBlocks(o.defaultBlocks, regions),
2859
+ };
1580
2860
  },
1581
2861
  }),
1582
2862
 
@@ -1585,9 +2865,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1585
2865
  * what lets a schema evolve (e.g. a field text → date) without recreating the type. */
1586
2866
  updateBlockType: mutation(async (ctx, input: { id?: string; slug?: string; name?: string; fieldsSchema?: FieldDefinition[]; icon?: string | null; category?: string | null; description?: string | null }) => {
1587
2867
  const db = cdb(ctx);
1588
- const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
2868
+ // `select` for BOTH reasons the projection exists: the guard needs `managedBy` to be
2869
+ // present rather than projected away, and the lookup has no use for the wide
2870
+ // `fieldsSchema` blob it used to fetch and JSON-parse to read three columns.
2871
+ const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, select: ["id", "slug", "managedBy"], limit: 1 });
1589
2872
  const row = rows[0];
1590
2873
  if (!row) throw notFound("block type");
2874
+ assertNotManaged(row, "block type", "defineBlockType");
1591
2875
  const patch: Record<string, unknown> = {};
1592
2876
  for (const k of ["name", "fieldsSchema", "icon", "category", "description"] as const) {
1593
2877
  if (k in input) patch[k] = (input as Record<string, unknown>)[k];
@@ -1598,7 +2882,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1598
2882
  input: (raw): { id?: string; slug?: string; name?: string; fieldsSchema?: FieldDefinition[]; icon?: string | null; category?: string | null; description?: string | null } => {
1599
2883
  const o = asObj(raw);
1600
2884
  if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
1601
- return o as never;
2885
+ const out = { ...o } as Record<string, unknown>;
2886
+ // `name` is the label in the editor's inserter; blanking it leaves an unnamed entry
2887
+ // nobody can identify.
2888
+ if (o.name !== undefined) out.name = assertLabel(o.name, "block type name");
2889
+ if (o.fieldsSchema !== undefined) out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
2890
+ return out as never;
1602
2891
  },
1603
2892
  }),
1604
2893
 
@@ -1607,22 +2896,592 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1607
2896
  updateContentType: mutation(async (ctx, input: { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] }) => {
1608
2897
  const db = cdb(ctx);
1609
2898
  if ("regions" in input && (!Array.isArray(input.regions) || input.regions.length === 0)) throw new BadRequest("at least one region is required");
1610
- const rows = await db.find({ from: "cms_content_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
2899
+ // `regions` as well, because the `defaultBlocks`-only patch below checks against the
2900
+ // STORED regions. See the block-type lookup for why this is a `select` at all.
2901
+ 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 });
1611
2902
  const row = rows[0];
1612
2903
  if (!row) throw notFound("content type");
2904
+ assertNotManaged(row, "content type", "defineContentType");
1613
2905
  const patch: Record<string, unknown> = {};
1614
2906
  for (const k of ["name", "regions", "fieldsSchema", "defaultBlocks"] as const) {
1615
2907
  if (k in input) patch[k] = (input as Record<string, unknown>)[k];
1616
2908
  }
2909
+ // A `defaultBlocks` patch that does NOT also send regions is checked here against the
2910
+ // STORED ones — the input parser has no row to read, and a default block placed into a
2911
+ // region the type does not declare is created into a key no renderer looks at.
2912
+ if (patch.defaultBlocks !== undefined && patch.regions === undefined) {
2913
+ patch.defaultBlocks = normalizeDefaultBlocks(patch.defaultBlocks, (Array.isArray(row.regions) ? row.regions : []) as RegionDefinition[]);
2914
+ }
1617
2915
  return db.update("cms_content_types", String(row.id), patch);
1618
2916
  }, {
1619
2917
  ...editor,
1620
2918
  input: (raw): { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
1621
2919
  const o = asObj(raw);
1622
2920
  if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
2921
+ const out = { ...o } as Record<string, unknown>;
1623
2922
  // `name` is the editor's tab label; blanking it leaves an unlabelled tab.
1624
- if (typeof o.name === "string" && o.name.trim() === "") throw new BadRequest("name must not be empty");
1625
- return o as never;
2923
+ if (o.name !== undefined) out.name = assertLabel(o.name, "content type name");
2924
+ if (o.regions !== undefined) out.regions = normalizeRegions(o.regions);
2925
+ if (o.fieldsSchema !== undefined) out.fieldsSchema = normalizeFieldSchema(o.fieldsSchema);
2926
+ // Checked against the regions being SAVED where the same call sends both — patching
2927
+ // only `defaultBlocks` cannot see the stored regions from an input parser, and the
2928
+ // handler re-checks below.
2929
+ if (out.defaultBlocks !== undefined && out.regions !== undefined) {
2930
+ out.defaultBlocks = normalizeDefaultBlocks(out.defaultBlocks, out.regions as RegionDefinition[]);
2931
+ }
2932
+ return out as never;
2933
+ },
2934
+ }),
2935
+
2936
+ // ---- site furniture: menus ----------------------------------------------
2937
+ //
2938
+ // A menu is read WHOLE (`getMenu("primary")` on every page render) and written whole.
2939
+ // The public read RESOLVES references — that is what makes a `page` item follow its
2940
+ // page's slug instead of freezing the href an editor typed once.
2941
+
2942
+ /** One menu, references resolved to hrefs. PUBLIC — a menu is site chrome.
2943
+ *
2944
+ * Returns `null` for an unknown name rather than 404ing, because a layout asking for a
2945
+ * menu it has not created yet is the normal state of a site being built, and a thrown
2946
+ * error there takes down every page instead of rendering no nav. */
2947
+ getMenu: query(async (ctx, input: { name: string }): Promise<Menu | null> => {
2948
+ const rows = await cdb(ctx).find({ from: "cms_menus", where: { name: input.name }, limit: 1 });
2949
+ const row = rows[0];
2950
+ if (!row) return null;
2951
+ return resolveMenuRow(cdb(ctx), row);
2952
+ }, {
2953
+ input: (raw): { name: string } => ({ name: assertKey(asObj(raw).name, "menu name") }),
2954
+ }),
2955
+
2956
+ /** Every menu, RAW (references unresolved) — the editor's list.
2957
+ *
2958
+ * Capped like every other list here. A site-furniture table is small by nature, which is
2959
+ * an argument for the cap being generous, not for its absence: an unbounded SELECT of a
2960
+ * wide row over RPC is the D1 failure shape from GitHub #22, and "small by nature" is
2961
+ * not a property the query planner knows about. */
2962
+ listMenus: query((ctx) => cdb(ctx).find({ from: "cms_menus", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
2963
+
2964
+ createMenu: mutation(async (ctx, input: { name: string; label: string; items?: MenuItem[] }) => {
2965
+ const db = cdb(ctx);
2966
+ // A clean 409, as every sibling create handler gives. Without it a taken key surfaced
2967
+ // as the UNIQUE constraint's own 500 — and the e2e only asserts "not 200", so it
2968
+ // could not tell the two apart.
2969
+ const clash = await db.find({ from: "cms_menus", where: { name: input.name }, select: ["id"], limit: 1 });
2970
+ if (clash[0]) throw new Conflict(`menu '${input.name}' already exists`);
2971
+ return db.insert("cms_menus", { name: input.name, label: input.label, items: input.items ?? [] });
2972
+ }, {
2973
+ ...editor,
2974
+ input: (raw): { name: string; label: string; items?: MenuItem[] } => {
2975
+ const o = asObj(raw);
2976
+ return { name: assertKey(o.name, "menu name"), label: assertLabel(o.label, "menu label"), items: normalizeMenuItems(o.items) };
2977
+ },
2978
+ }),
2979
+
2980
+ /** Patch a menu found by `id` or `name`. `name` is the key layout code resolves and is
2981
+ * NOT mutable — retitling is what `label` is for. */
2982
+ updateMenu: mutation(async (ctx, input: { id?: string; name?: string; label?: string; items?: MenuItem[]; expectedVersion?: number }) => {
2983
+ const db = cdb(ctx);
2984
+ const rows = await db.find({ from: "cms_menus", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
2985
+ const row = rows[0];
2986
+ if (!row) throw notFound("menu");
2987
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this menu") };
2988
+ if (input.label !== undefined) patch.label = input.label;
2989
+ if (input.items !== undefined) patch.items = input.items;
2990
+ return db.update("cms_menus", String(row.id), patch);
2991
+ }, {
2992
+ ...editor,
2993
+ input: (raw): { id?: string; name?: string; label?: string; items?: MenuItem[]; expectedVersion?: number } => {
2994
+ const o = asObj(raw);
2995
+ const out: { id?: string; name?: string; label?: string; items?: MenuItem[]; expectedVersion?: number } = {};
2996
+ if (typeof o.id === "string") out.id = o.id;
2997
+ else if (typeof o.name === "string") out.name = assertKey(o.name, "menu name");
2998
+ else throw new BadRequest("id or name is required");
2999
+ if (o.label !== undefined) out.label = assertLabel(o.label, "menu label");
3000
+ if (o.items !== undefined) out.items = normalizeMenuItems(o.items);
3001
+ if (typeof o.expectedVersion === "number") out.expectedVersion = o.expectedVersion;
3002
+ return out;
3003
+ },
3004
+ }),
3005
+
3006
+ deleteMenu: mutation(async (ctx, input: { id: string }) => {
3007
+ const ok = await cdb(ctx).delete("cms_menus", input.id);
3008
+ if (!ok) throw notFound("menu");
3009
+ return { ok: true as const };
3010
+ }, {
3011
+ ...editor,
3012
+ input: (raw): { id: string } => {
3013
+ const id = asObj(raw).id;
3014
+ if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
3015
+ return { id };
3016
+ },
3017
+ }),
3018
+
3019
+ // ---- site furniture: redirects -------------------------------------------
3020
+
3021
+ /** Resolve a request path to its redirect, or `null`. PUBLIC and READ-ONLY: this is
3022
+ * what a front end calls on a 404, so it is anonymous traffic on the hot path.
3023
+ *
3024
+ * `enabled` is enforced by the public read POLICY, not by a `where` here, so a disabled
3025
+ * redirect is invisible to every anonymous read (this handler, a future listing, a
3026
+ * relation traversal) rather than to the one call that remembered to filter. */
3027
+ resolveRedirect: query(async (ctx, input: { path: string }): Promise<{ to: string; status: number } | null> => {
3028
+ const rows = await cdb(ctx).find({ from: "cms_redirects", where: { fromPath: input.path, enabled: true }, select: ["toPath", "status"], limit: 1 });
3029
+ const row = rows[0];
3030
+ return row ? { to: String(row.toPath), status: Number(row.status ?? 301) } : null;
3031
+ }, {
3032
+ input: (raw): { path: string } => ({ path: normalizeRedirectPath(asObj(raw).path) }),
3033
+ }),
3034
+
3035
+ listRedirects: query((ctx, input: { limit?: number; offset?: number }) => {
3036
+ return cdb(ctx).find({ from: "cms_redirects", orderBy: { column: "fromPath" }, limit: input.limit ?? PAGE_LIST_LIMIT, offset: input.offset ?? 0 });
3037
+ }, {
3038
+ ...viewer,
3039
+ input: (raw): { limit?: number; offset?: number } => {
3040
+ const o = asObj(raw);
3041
+ return { limit: clampLimit(o.limit), offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0 };
3042
+ },
3043
+ }),
3044
+
3045
+ createRedirect: mutation(async (ctx, input: RedirectPatch & { fromPath: string; toPath: string }) => {
3046
+ // A redirect to itself is an infinite loop the moment it is enabled, and the browser
3047
+ // is what discovers it. Cheap to refuse here; impossible to diagnose from the outside.
3048
+ if (isSelfRedirect(input.fromPath, input.toPath)) throw new BadRequest("a redirect cannot point at itself");
3049
+ const db = cdb(ctx);
3050
+ const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
3051
+ if (clash[0]) throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
3052
+ return db.insert("cms_redirects", {
3053
+ fromPath: input.fromPath,
3054
+ toPath: input.toPath,
3055
+ status: input.status ?? 301,
3056
+ enabled: input.enabled ?? true,
3057
+ note: input.note ?? null,
3058
+ });
3059
+ }, {
3060
+ ...editor,
3061
+ input: (raw): RedirectPatch & { fromPath: string; toPath: string } => redirectPatch(raw, true) as RedirectPatch & { fromPath: string; toPath: string },
3062
+ }),
3063
+
3064
+ updateRedirect: mutation(async (ctx, input: RedirectPatch & { id: string }) => {
3065
+ const db = cdb(ctx);
3066
+ const rows = await db.find({ from: "cms_redirects", where: { id: input.id }, limit: 1 });
3067
+ const row = rows[0];
3068
+ if (!row) throw notFound("redirect");
3069
+ const from = input.fromPath ?? String(row.fromPath);
3070
+ const to = input.toPath ?? String(row.toPath);
3071
+ if (isSelfRedirect(from, to)) throw new BadRequest("a redirect cannot point at itself");
3072
+ if (input.fromPath && input.fromPath !== row.fromPath) {
3073
+ const clash = await db.find({ from: "cms_redirects", where: { fromPath: input.fromPath }, select: ["id"], limit: 1 });
3074
+ if (clash[0]) throw new Conflict(`a redirect from '${input.fromPath}' already exists`);
3075
+ }
3076
+ const patch: Record<string, unknown> = { updatedAt: nowStamp() };
3077
+ for (const k of ["fromPath", "toPath", "status", "enabled", "note"] as const) {
3078
+ if (input[k] !== undefined) patch[k] = input[k];
3079
+ }
3080
+ return db.update("cms_redirects", input.id, patch);
3081
+ }, {
3082
+ ...editor,
3083
+ input: (raw): RedirectPatch & { id: string } => ({ id: requireId(raw), ...redirectPatch(raw, false) }),
3084
+ }),
3085
+
3086
+ deleteRedirect: mutation(async (ctx, input: { id: string }) => {
3087
+ const ok = await cdb(ctx).delete("cms_redirects", input.id);
3088
+ if (!ok) throw notFound("redirect");
3089
+ return { ok: true as const };
3090
+ }, {
3091
+ ...editor,
3092
+ input: (raw): { id: string } => {
3093
+ const id = asObj(raw).id;
3094
+ if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
3095
+ return { id };
3096
+ },
3097
+ }),
3098
+
3099
+ // ---- site furniture: taxonomies ------------------------------------------
3100
+ //
3101
+ // `category` and `tag` are not built in: they are two rows a deployment creates, the
3102
+ // same way it creates its content types. A vocabulary that is hierarchical allows
3103
+ // `parentId` on its terms; a flat one refuses it rather than storing something no
3104
+ // listing renders.
3105
+
3106
+ /** Every vocabulary. PUBLIC — like content types, a taxonomy's slug is structural (it
3107
+ * is a URL segment) and a front end routes on it. */
3108
+ listTaxonomies: query((ctx) => cdb(ctx).find({ from: "cms_taxonomies", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT })),
3109
+
3110
+ createTaxonomy: mutation(async (ctx, input: { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean }) => {
3111
+ const db = cdb(ctx);
3112
+ const clash = await db.find({ from: "cms_taxonomies", where: { slug: input.slug }, select: ["id"], limit: 1 });
3113
+ if (clash[0]) throw new Conflict(`taxonomy '${input.slug}' already exists`);
3114
+ return db.insert("cms_taxonomies", {
3115
+ slug: input.slug,
3116
+ label: input.label,
3117
+ pluralLabel: input.pluralLabel ?? null,
3118
+ description: input.description ?? null,
3119
+ hierarchical: input.hierarchical ?? false,
3120
+ });
3121
+ }, {
3122
+ ...editor,
3123
+ input: (raw): { slug: string; label: string; pluralLabel?: string; description?: string; hierarchical?: boolean } => {
3124
+ const o = asObj(raw);
3125
+ return {
3126
+ slug: assertKey(o.slug, "taxonomy slug"),
3127
+ label: assertLabel(o.label, "taxonomy label"),
3128
+ pluralLabel: typeof o.pluralLabel === "string" ? o.pluralLabel : undefined,
3129
+ description: typeof o.description === "string" ? o.description : undefined,
3130
+ hierarchical: typeof o.hierarchical === "boolean" ? o.hierarchical : undefined,
3131
+ };
3132
+ },
3133
+ }),
3134
+
3135
+ /** Patch a vocabulary. `slug` is a URL segment and the key `listTerms` resolves, so it
3136
+ * is not mutable — the same rule content types and block types already follow.
3137
+ *
3138
+ * Turning `hierarchical` OFF is refused while any term still has a parent. Allowing it
3139
+ * would leave a stored hierarchy that no reader renders and no writer can clear, and
3140
+ * flattening the terms silently is a destructive edit behind a checkbox. */
3141
+ updateTaxonomy: mutation(async (ctx, input: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean }) => {
3142
+ const db = cdb(ctx);
3143
+ const rows = await db.find({ from: "cms_taxonomies", where: { id: input.id }, limit: 1 });
3144
+ const row = rows[0];
3145
+ if (!row) throw notFound("taxonomy");
3146
+ if (input.hierarchical === false && row.hierarchical) {
3147
+ const nested = await db.find({ from: "cms_terms", where: { taxonomyId: input.id, parentId: { isNull: false } }, select: ["id"], limit: 1 });
3148
+ if (nested[0]) throw new BadRequest("this vocabulary still has nested terms — move them to the top level before making it flat");
3149
+ }
3150
+ const patch: Record<string, unknown> = {};
3151
+ for (const k of ["label", "pluralLabel", "description", "hierarchical"] as const) {
3152
+ if (input[k] !== undefined) patch[k] = input[k];
3153
+ }
3154
+ return db.update("cms_taxonomies", input.id, patch);
3155
+ }, {
3156
+ ...editor,
3157
+ input: (raw): { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean } => {
3158
+ const o = asObj(raw);
3159
+ if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
3160
+ const out: { id: string; label?: string; pluralLabel?: string | null; description?: string | null; hierarchical?: boolean } = { id: o.id };
3161
+ if (o.label !== undefined) out.label = assertLabel(o.label, "taxonomy label");
3162
+ if (o.pluralLabel !== undefined) out.pluralLabel = typeof o.pluralLabel === "string" ? o.pluralLabel : null;
3163
+ if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
3164
+ if (typeof o.hierarchical === "boolean") out.hierarchical = o.hierarchical;
3165
+ return out;
3166
+ },
3167
+ }),
3168
+
3169
+ /** Delete a vocabulary. Its terms go with it, and their page assignments with those —
3170
+ * both by real `ON DELETE CASCADE`, so the cleanup is the DB's and cannot be half-done
3171
+ * by a handler that threw between two writes. */
3172
+ deleteTaxonomy: mutation(async (ctx, input: { id: string }) => {
3173
+ const ok = await cdb(ctx).delete("cms_taxonomies", input.id);
3174
+ if (!ok) throw notFound("taxonomy");
3175
+ return { ok: true as const };
3176
+ }, {
3177
+ ...editor,
3178
+ input: (raw): { id: string } => {
3179
+ const id = asObj(raw).id;
3180
+ if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
3181
+ return { id };
3182
+ },
3183
+ }),
3184
+
3185
+ /** One vocabulary's terms, flat, ordered. PUBLIC. */
3186
+ listTerms: query(async (ctx, input: { taxonomy: string }): Promise<Term[]> => {
3187
+ const db = cdb(ctx);
3188
+ const tax = await taxonomyBySlug(db, input.taxonomy);
3189
+ if (!tax) return [];
3190
+ const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, orderBy: [{ column: "position" }, { column: "label" }], limit: MAX_TERMS });
3191
+ return rows as unknown as Term[];
3192
+ }, {
3193
+ input: (raw): { taxonomy: string } => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
3194
+ }),
3195
+
3196
+ /** One vocabulary's terms as a TREE. PUBLIC. Assembled here rather than by the caller
3197
+ * because a hierarchy is what the nav renders and every consumer would otherwise write
3198
+ * the same fold. */
3199
+ getTermTree: query(async (ctx, input: { taxonomy: string }): Promise<Term[]> => {
3200
+ const db = cdb(ctx);
3201
+ const tax = await taxonomyBySlug(db, input.taxonomy);
3202
+ if (!tax) return [];
3203
+ const rows = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id }, limit: MAX_TERMS });
3204
+ return buildTermTree(rows as unknown as Term[]);
3205
+ }, {
3206
+ input: (raw): { taxonomy: string } => ({ taxonomy: assertKey(asObj(raw).taxonomy, "taxonomy slug") }),
3207
+ }),
3208
+
3209
+ createTerm: mutation(async (ctx, input: { taxonomy: string; slug: string; label: string; description?: string; parentId?: string | null; position?: number }) => {
3210
+ const db = cdb(ctx);
3211
+ const tax = await taxonomyBySlug(db, input.taxonomy);
3212
+ if (!tax) throw notFound("taxonomy");
3213
+ await assertTermParent(db, tax, input.parentId ?? null, null);
3214
+ const clash = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.slug }, select: ["id"], limit: 1 });
3215
+ if (clash[0]) throw new Conflict(`term '${input.slug}' already exists in '${input.taxonomy}'`);
3216
+ return db.insert("cms_terms", {
3217
+ taxonomyId: tax.id,
3218
+ slug: input.slug,
3219
+ label: input.label,
3220
+ description: input.description ?? null,
3221
+ parentId: input.parentId ?? null,
3222
+ position: input.position ?? 0,
3223
+ });
3224
+ }, {
3225
+ ...editor,
3226
+ input: (raw): { taxonomy: string; slug: string; label: string; description?: string; parentId?: string | null; position?: number } => {
3227
+ const o = asObj(raw);
3228
+ return {
3229
+ taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
3230
+ slug: assertKey(o.slug, "term slug"),
3231
+ label: assertLabel(o.label, "term label"),
3232
+ description: typeof o.description === "string" ? o.description : undefined,
3233
+ parentId: typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null,
3234
+ position: typeof o.position === "number" ? Math.trunc(o.position) : undefined,
3235
+ };
3236
+ },
3237
+ }),
3238
+
3239
+ updateTerm: mutation(async (ctx, input: { id: string; slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number }) => {
3240
+ const db = cdb(ctx);
3241
+ const rows = await db.find({ from: "cms_terms", where: { id: input.id }, limit: 1 });
3242
+ const row = rows[0];
3243
+ if (!row) throw notFound("term");
3244
+ const taxRows = await db.find({ from: "cms_taxonomies", where: { id: row.taxonomyId }, limit: 1 });
3245
+ const tax = taxRows[0];
3246
+ if (!tax) throw notFound("taxonomy");
3247
+ if (input.parentId !== undefined) {
3248
+ await assertTermParent(db, { id: String(tax.id), hierarchical: Boolean(tax.hierarchical) }, input.parentId, input.id);
3249
+ }
3250
+ // A term's slug IS mutable, unlike a taxonomy's: it is the leaf of a URL, an editor
3251
+ // fixing a typo in one is routine, and the uniqueness that matters is enforced below
3252
+ // and by the composite index behind it.
3253
+ if (input.slug && input.slug !== row.slug) {
3254
+ const clash = await db.find({ from: "cms_terms", where: { taxonomyId: row.taxonomyId, slug: input.slug }, select: ["id"], limit: 1 });
3255
+ if (clash[0]) throw new Conflict(`term '${input.slug}' already exists in this vocabulary`);
3256
+ }
3257
+ const patch: Record<string, unknown> = {};
3258
+ for (const k of ["slug", "label", "description", "parentId", "position"] as const) {
3259
+ if (input[k] !== undefined) patch[k] = input[k];
3260
+ }
3261
+ return db.update("cms_terms", input.id, patch);
3262
+ }, {
3263
+ ...editor,
3264
+ input: (raw): { id: string; slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number } => {
3265
+ const o = asObj(raw);
3266
+ if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
3267
+ const out: { id: string; slug?: string; label?: string; description?: string | null; parentId?: string | null; position?: number } = { id: o.id };
3268
+ if (o.slug !== undefined) out.slug = assertKey(o.slug, "term slug");
3269
+ if (o.label !== undefined) out.label = assertLabel(o.label, "term label");
3270
+ if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
3271
+ if (o.parentId !== undefined) out.parentId = typeof o.parentId === "string" && o.parentId !== "" ? o.parentId : null;
3272
+ if (typeof o.position === "number") out.position = Math.trunc(o.position);
3273
+ return out;
3274
+ },
3275
+ }),
3276
+
3277
+ /** Delete a term. Children are promoted to the top level (`ON DELETE SET NULL`) and
3278
+ * page assignments are removed (`ON DELETE CASCADE`) — see `cms_terms.parentId`. */
3279
+ deleteTerm: mutation(async (ctx, input: { id: string }) => {
3280
+ const ok = await cdb(ctx).delete("cms_terms", input.id);
3281
+ if (!ok) throw notFound("term");
3282
+ return { ok: true as const };
3283
+ }, {
3284
+ ...editor,
3285
+ input: (raw): { id: string } => {
3286
+ const id = asObj(raw).id;
3287
+ if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
3288
+ return { id };
3289
+ },
3290
+ }),
3291
+
3292
+ /** A page's assigned terms. PUBLIC (a published page's classification is public). */
3293
+ listPageTerms: query(async (ctx, input: { pageId: string }): Promise<Term[]> => {
3294
+ const db = cdb(ctx);
3295
+ // Read the PAGE first, through `ctx.db`, so the caller's own page scope decides
3296
+ // whether this answers at all. Without it the handler never touched `cms_pages` — and
3297
+ // the public grants on the junction and on terms are unscoped `allow()` — so anyone
3298
+ // holding a page id could read a draft or trashed page's classification, and the
3299
+ // non-empty answer confirmed the page exists. `listPagesByTerm` was already safe for
3300
+ // the opposite reason: it traverses `where: { terms: … }`, so the page scope
3301
+ // AND-merges. This is the same rule, applied from the other end.
3302
+ const page = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
3303
+ if (!page[0]) return [];
3304
+ const links = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["termId"], limit: MAX_TERMS });
3305
+ const ids = links.map((l) => String(l.termId));
3306
+ if (ids.length === 0) return [];
3307
+ const rows = await db.find({ from: "cms_terms", where: { id: { in: ids } }, orderBy: [{ column: "position" }, { column: "label" }], limit: ids.length });
3308
+ return rows as unknown as Term[];
3309
+ }, {
3310
+ input: (raw): { pageId: string } => {
3311
+ const id = asObj(raw).pageId;
3312
+ if (typeof id !== "string" || id === "") throw new BadRequest("pageId is required");
3313
+ return { pageId: id };
3314
+ },
3315
+ }),
3316
+
3317
+ /** Replace a page's term assignments wholesale.
3318
+ *
3319
+ * Set semantics, not add/remove: the editor's panel holds the whole selection, and two
3320
+ * calls that each patch one end of it race into a state neither asked for. Existing
3321
+ * links that survive are LEFT ALONE rather than deleted and reinserted, so the junction
3322
+ * rows (and any future column on them) are stable across a save that changed nothing. */
3323
+ setPageTerms: mutation(async (ctx, input: { pageId: string; termIds: string[] }) => {
3324
+ const db = cdb(ctx);
3325
+ const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, select: ["id"], limit: 1 });
3326
+ if (!pages[0]) throw notFound("page");
3327
+ const wanted = new Set(input.termIds);
3328
+ if (wanted.size > 0) {
3329
+ // Every id must be a real term. Without this the junction happily stores a dangling
3330
+ // uuid — the FK would catch it, but as a driver error with no HTTP status.
3331
+ const found = await db.find({ from: "cms_terms", where: { id: { in: [...wanted] } }, select: ["id"], limit: wanted.size });
3332
+ if (found.length !== wanted.size) throw new BadRequest("one or more termIds are not terms");
3333
+ }
3334
+ const existing = await db.find({ from: "cms_page_terms", where: { pageId: input.pageId }, select: ["id", "termId"], limit: MAX_TERMS });
3335
+ const have = new Map(existing.map((l) => [String(l.termId), String(l.id)]));
3336
+ for (const [termId, linkId] of have) if (!wanted.has(termId)) await db.delete("cms_page_terms", linkId);
3337
+ for (const termId of wanted) if (!have.has(termId)) await db.insert("cms_page_terms", { pageId: input.pageId, termId });
3338
+ return { ok: true as const, count: wanted.size };
3339
+ }, {
3340
+ ...editor,
3341
+ input: (raw): { pageId: string; termIds: string[] } => {
3342
+ const o = asObj(raw);
3343
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
3344
+ if (!Array.isArray(o.termIds)) throw new BadRequest("termIds must be a list");
3345
+ const ids = o.termIds.map((v) => {
3346
+ if (typeof v !== "string" || v === "") throw new BadRequest("termIds must be a list of ids");
3347
+ return v;
3348
+ });
3349
+ if (ids.length > MAX_TERMS) throw new BadRequest(`a page may carry at most ${MAX_TERMS} terms`);
3350
+ return { pageId: o.pageId, termIds: ids };
3351
+ },
3352
+ }),
3353
+
3354
+ /** Published pages carrying a term. PUBLIC.
3355
+ *
3356
+ * A relation traversal (`where: { terms: { id } }`), so the page read scope is
3357
+ * AND-merged as it is anywhere else — traversal cannot widen access, and an anonymous
3358
+ * caller sees published pages only. */
3359
+ listPagesByTerm: query(async (ctx, input: { taxonomy: string; term: string; limit?: number; offset?: number }) => {
3360
+ const db = cdb(ctx);
3361
+ const tax = await taxonomyBySlug(db, input.taxonomy);
3362
+ if (!tax) return [];
3363
+ const terms = await db.find({ from: "cms_terms", where: { taxonomyId: tax.id, slug: input.term }, select: ["id"], limit: 1 });
3364
+ const term = terms[0];
3365
+ if (!term) return [];
3366
+ return db.find({
3367
+ from: "cms_pages",
3368
+ where: { terms: { id: String(term.id) } },
3369
+ orderBy: { column: "createdAt", dir: "desc" },
3370
+ select: ["id", "typeId", "title", "slug", "locale", "status", "publishedAt"],
3371
+ limit: input.limit ?? PAGE_LIST_LIMIT,
3372
+ offset: input.offset ?? 0,
3373
+ });
3374
+ }, {
3375
+ input: (raw): { taxonomy: string; term: string; limit?: number; offset?: number } => {
3376
+ const o = asObj(raw);
3377
+ return {
3378
+ taxonomy: assertKey(o.taxonomy, "taxonomy slug"),
3379
+ term: assertKey(o.term, "term slug"),
3380
+ limit: clampLimit(o.limit),
3381
+ offset: typeof o.offset === "number" && o.offset > 0 ? Math.floor(o.offset) : 0,
3382
+ };
3383
+ },
3384
+ }),
3385
+
3386
+ // ---- site furniture: widget areas ----------------------------------------
3387
+
3388
+ /** One widget area, with `menu` widgets resolved to their menus. PUBLIC.
3389
+ *
3390
+ * `null` for an unknown name, for the same reason `getMenu` returns null: a layout
3391
+ * asking for a sidebar nobody has filled in yet is the normal state of a site under
3392
+ * construction, and throwing there takes down the page. */
3393
+ getWidgetArea: query(async (ctx, input: { name: string }): Promise<WidgetArea | null> => {
3394
+ const db = cdb(ctx);
3395
+ const rows = await db.find({ from: "cms_widget_areas", where: { name: input.name }, limit: 1 });
3396
+ const row = rows[0];
3397
+ if (!row) return null;
3398
+ const widgets = Array.isArray(row.widgets) ? (row.widgets as Widget[]) : [];
3399
+ // Resolved inline so a layout renders a whole sidebar from ONE call. A menu widget
3400
+ // that names a menu which no longer exists keeps `menu: null` rather than being
3401
+ // dropped — unlike a menu ITEM, an empty widget is a visible hole an editor can see
3402
+ // and fix, where a silently missing one is not.
3403
+ const names = [...new Set(widgets.filter((w) => w.type === "menu" && w.menuName).map((w) => w.menuName!))];
3404
+ const menus = new Map<string, Menu>();
3405
+ if (names.length > 0) {
3406
+ const found = await db.find({ from: "cms_menus", where: { name: { in: names } }, limit: names.length });
3407
+ // RESOLVED, exactly as `getMenu` returns it. Embedding the raw row here served an
3408
+ // unpublished page's label and id to anonymous callers and handed the layout an
3409
+ // item with no `url` — the two things the resolver exists to prevent, skipped
3410
+ // because this path had its own one-line copy of "read the menu".
3411
+ for (const m of found) menus.set(String(m.name), await resolveMenuRow(db, m));
3412
+ }
3413
+ return {
3414
+ id: String(row.id),
3415
+ name: String(row.name),
3416
+ label: String(row.label),
3417
+ description: row.description == null ? null : String(row.description),
3418
+ widgets: widgets.map((w) => (w.type === "menu" && w.menuName ? { ...w, menu: menus.get(w.menuName) ?? null } : w)),
3419
+ };
3420
+ }, {
3421
+ input: (raw): { name: string } => ({ name: assertKey(asObj(raw).name, "widget area name") }),
3422
+ }),
3423
+
3424
+ listWidgetAreas: query((ctx) => cdb(ctx).find({ from: "cms_widget_areas", orderBy: { column: "label" }, limit: PAGE_LIST_MAX_LIMIT }), viewer),
3425
+
3426
+ createWidgetArea: mutation(async (ctx, input: { name: string; label: string; description?: string; widgets?: Widget[] }) => {
3427
+ const db = cdb(ctx);
3428
+ const clash = await db.find({ from: "cms_widget_areas", where: { name: input.name }, select: ["id"], limit: 1 });
3429
+ if (clash[0]) throw new Conflict(`widget area '${input.name}' already exists`);
3430
+ return db.insert("cms_widget_areas", {
3431
+ name: input.name,
3432
+ label: input.label,
3433
+ description: input.description ?? null,
3434
+ widgets: input.widgets ?? [],
3435
+ });
3436
+ }, {
3437
+ ...editor,
3438
+ input: (raw): { name: string; label: string; description?: string; widgets?: Widget[] } => {
3439
+ const o = asObj(raw);
3440
+ return {
3441
+ name: assertKey(o.name, "widget area name"),
3442
+ label: assertLabel(o.label, "widget area label"),
3443
+ description: typeof o.description === "string" ? o.description : undefined,
3444
+ widgets: normalizeWidgets(o.widgets, rtSchema),
3445
+ };
3446
+ },
3447
+ }),
3448
+
3449
+ updateWidgetArea: mutation(async (ctx, input: { id?: string; name?: string; label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number }) => {
3450
+ const db = cdb(ctx);
3451
+ const rows = await db.find({ from: "cms_widget_areas", where: input.id ? { id: input.id } : { name: input.name }, limit: 1 });
3452
+ const row = rows[0];
3453
+ if (!row) throw notFound("widget area");
3454
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(row, input.expectedVersion, "this widget area") };
3455
+ for (const k of ["label", "description", "widgets"] as const) {
3456
+ if (input[k] !== undefined) patch[k] = input[k];
3457
+ }
3458
+ return db.update("cms_widget_areas", String(row.id), patch);
3459
+ }, {
3460
+ ...editor,
3461
+ input: (raw): { id?: string; name?: string; label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number } => {
3462
+ const o = asObj(raw);
3463
+ const out: { id?: string; name?: string; label?: string; description?: string | null; widgets?: Widget[]; expectedVersion?: number } = {};
3464
+ if (typeof o.id === "string") out.id = o.id;
3465
+ else if (typeof o.name === "string") out.name = assertKey(o.name, "widget area name");
3466
+ else throw new BadRequest("id or name is required");
3467
+ if (o.label !== undefined) out.label = assertLabel(o.label, "widget area label");
3468
+ if (o.description !== undefined) out.description = typeof o.description === "string" ? o.description : null;
3469
+ if (o.widgets !== undefined) out.widgets = normalizeWidgets(o.widgets, rtSchema);
3470
+ if (typeof o.expectedVersion === "number") out.expectedVersion = o.expectedVersion;
3471
+ return out;
3472
+ },
3473
+ }),
3474
+
3475
+ deleteWidgetArea: mutation(async (ctx, input: { id: string }) => {
3476
+ const ok = await cdb(ctx).delete("cms_widget_areas", input.id);
3477
+ if (!ok) throw notFound("widget area");
3478
+ return { ok: true as const };
3479
+ }, {
3480
+ ...editor,
3481
+ input: (raw): { id: string } => {
3482
+ const id = asObj(raw).id;
3483
+ if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
3484
+ return { id };
1626
3485
  },
1627
3486
  }),
1628
3487
 
@@ -2089,7 +3948,30 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
2089
3948
  * feature would render N tabs all showing every type's pages under a heading claiming
2090
3949
  * otherwise — and "New page" from any of them would stamp that tab's type. Declared, not
2091
3950
  * inferred: fail closed on the pooled list rather than open on N lying ones. */
2092
- listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1, pagesByType: true as const }), viewer),
3951
+ listCmsCapabilities: query((ctx) => ({
3952
+ locales,
3953
+ defaultLocale,
3954
+ multilingual: locales.length > 1,
3955
+ pagesByType: true as const,
3956
+ // Same kind of declaration as `pagesByType`, for the same reason: an OLDER server has
3957
+ // no menu/redirect/taxonomy/widget handlers at all, and a nav section whose every
3958
+ // screen 404s is worse than one that is absent. Fails closed by being absent there.
3959
+ siteFurniture: true as const,
3960
+ // Whether `managedBy` means anything on this server. Declared for the same reason as
3961
+ // its neighbours: `@pramen/cms-editor` is a separate package with no dependency on
3962
+ // `@pramen/cms`, so a newer editor CAN run against an older server — where every row
3963
+ // reports no owner, nothing renders read-only, the save succeeds, and it is reverted at
3964
+ // the next cold start. That is GitHub #48 in the deployment that upgraded the editor to
3965
+ // fix it. Absent ⇒ the editor treats no type as code-defined, which is correct there.
3966
+ codeDefinedTypes: true as const,
3967
+ // PER-CALLER, unlike everything else here. `viewer` is `editorRoles ∪ reviewerRoles`,
3968
+ // so a reviewer-only session reaches this handler and every read handler — but every
3969
+ // WRITE is `editorRoles`. Without this the editor renders the authoring surfaces
3970
+ // (Types, Menus, Redirects, …) for a reviewer and each one 403s on its first save.
3971
+ // The editor cannot work it out for itself: it knows the caller's roles from `me` but
3972
+ // not which roles this deployment configured as `editorRoles`.
3973
+ canEdit: isEditor(ctx, editorRoles),
3974
+ }), viewer),
2093
3975
 
2094
3976
  /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
2095
3977
  * store — not configuration. `listCmsCapabilities().locales` is what the deployment
@@ -2448,11 +4330,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
2448
4330
  signPagePreview: query(async (ctx, input: { pageId: string; expiresIn?: number }) => {
2449
4331
  const secret = previewSecret(ctx.env);
2450
4332
  if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
2451
- // The redeem route always reaches a Durable Object (callPrivileged -> PRAMEN.get); it
2452
- // has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
2453
- // link that 404s forever while the editor reports success refuse instead of
2454
- // handing out a token that cannot work.
2455
-
4333
+ // This used to refuse on the D1 store: redemption goes through `ctx.callPrivileged`,
4334
+ // which only forwarded to a DO, so a link minted on D1 would have 404'd forever while
4335
+ // the editor reported success. `callPrivileged` now dispatches locally in the Worker
4336
+ // on D1, so both stores mint. The redeem route is a BROWSER request carrying no
4337
+ // `x-pramen-store`, so a D1 deployment still needs `PRAMEN_STORE=d1` to route it.
2456
4338
  const db = cdb(ctx);
2457
4339
  // Read the page through the ACL first: minting a link is granting access to it, so a
2458
4340
  // caller who cannot read the page must not be able to mint a link that can.
@@ -2693,7 +4575,12 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
2693
4575
  // any app handler, silently overriding the read+create grant `collectionPolicies` emits —
2694
4576
  // duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
2695
4577
  // The collection half owns that table's grant; see `collectionPolicies`.
2696
- const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"] as const;
4578
+ const tables = [
4579
+ "cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit",
4580
+ // Site furniture. Full CRUD for an editor, like every other cms_ table — the per-handler
4581
+ // `auth` gate is what separates editor from reviewer; this is the row scope.
4582
+ "cms_menus", "cms_redirects", "cms_taxonomies", "cms_terms", "cms_page_terms", "cms_widget_areas",
4583
+ ] as const;
2697
4584
  // Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
2698
4585
  // AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
2699
4586
  // API, the editor, relation traversals and eager-loads at once — where a per-handler
@@ -2733,6 +4620,23 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
2733
4620
  policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
2734
4621
  // Media metadata is public (the bytes are separately gated by signed urls).
2735
4622
  policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
4623
+ // --- site furniture ---
4624
+ // A menu is site chrome, rendered on every page. `getMenu` resolves its page
4625
+ // references THROUGH `ctx.db`, so the public page scope above is what decides whether
4626
+ // a link to an unpublished page renders — the menu grant does not widen it.
4627
+ policy(`${p}:public:menus:read`, "cms_menus", "read", allow()),
4628
+ // Only ENABLED redirects. `resolveRedirect` also filters, but the scope is the real
4629
+ // boundary: a disabled redirect is one an editor has deliberately taken out of
4630
+ // service, and it must stay invisible to every anonymous read, not just that one.
4631
+ policy(`${p}:public:redirects:read`, "cms_redirects", "read", { where: { enabled: true } }),
4632
+ // Taxonomies and terms are structural (they are URL segments a front end routes on),
4633
+ // exactly like content-type slugs. The junction is granted too, because
4634
+ // `where: { terms: … }` on a page compiles to a subquery THROUGH it — without the
4635
+ // grant the traversal matches nothing and "pages in this category" is silently empty.
4636
+ policy(`${p}:public:taxonomies:read`, "cms_taxonomies", "read", allow()),
4637
+ policy(`${p}:public:terms:read`, "cms_terms", "read", allow()),
4638
+ policy(`${p}:public:page-terms:read`, "cms_page_terms", "read", allow()),
4639
+ policy(`${p}:public:widget-areas:read`, "cms_widget_areas", "read", allow()),
2736
4640
  ],
2737
4641
  editor: editorPolicies,
2738
4642
  };
@@ -2755,6 +4659,12 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
2755
4659
  // table, and writes are whitelisted to declared fields — the client can't set columns the
2756
4660
  // collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
2757
4661
 
4662
+ /** Nav positions for the editor's built-in sections — see `./nav`. Re-exported here so a
4663
+ * host writing `app.ts` imports it from the same place as `collection()`. It LIVES in a leaf
4664
+ * module because `blockkit.ts` needs it too and must not depend on this one. */
4665
+ export { NAV_ORDER } from "./nav";
4666
+
4667
+
2758
4668
  /** Declares that a pramen entity is editable as a collection in the CMS editor. Both
2759
4669
  * halves live here: the runtime facts (entity, idField, validation via `fields`) and the
2760
4670
  * UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
@@ -2783,6 +4693,16 @@ export interface CollectionDef {
2783
4693
  readonly idField?: string;
2784
4694
  /** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
2785
4695
  readonly orderBy?: { column: string; dir?: "asc" | "desc" };
4696
+ /** Where this collection sits in the editor's primary nav. Lower comes first; ties keep
4697
+ * declaration order. Defaults to `NAV_ORDER.collections`, which is between Pages and
4698
+ * Media — where collections have always rendered.
4699
+ *
4700
+ * The point is not cosmetic. Before this the only extension seam was `extraNav`, which
4701
+ * renders dead LAST and (by default, and for good reason) opens a new tab — so a
4702
+ * project-specific section could only ever be a separate app bolted onto the end of the
4703
+ * admin. A section that belongs beside Pages can now say so. See {@link NAV_ORDER} for
4704
+ * the built-in positions to place against. */
4705
+ readonly navOrder?: number;
2786
4706
  /** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
2787
4707
  * backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
2788
4708
  * Validated against your schema at `createCollectionHandlers` time (which is why that call
@@ -2816,6 +4736,10 @@ export interface CollectionMeta {
2816
4736
  label: string;
2817
4737
  pluralLabel: string;
2818
4738
  icon?: string;
4739
+ /** Nav position — see {@link CollectionDef.navOrder}. Always present in the meta (the
4740
+ * default is filled here) so the editor sorts one list of numbers rather than deciding
4741
+ * per entry whether a default applies. */
4742
+ navOrder: number;
2819
4743
  fields: readonly FieldDefinition[];
2820
4744
  list: readonly string[];
2821
4745
  titleField: string;
@@ -2834,6 +4758,7 @@ function collectionMeta(c: CollectionDef): CollectionMeta {
2834
4758
  label: c.label,
2835
4759
  pluralLabel: c.pluralLabel ?? `${c.label}s`,
2836
4760
  icon: c.icon,
4761
+ navOrder: c.navOrder ?? NAV_ORDER.collections,
2837
4762
  fields: c.fields,
2838
4763
  list: c.list ?? [titleField],
2839
4764
  titleField,
@@ -2860,10 +4785,13 @@ function collectionMeta(c: CollectionDef): CollectionMeta {
2860
4785
  // TIMESTAMP FORMAT. Managed timestamps are minted as ISO-8601 UTC with a `Z`
2861
4786
  // (`2026-08-20T12:00:00.000Z`), in exactly one place (`isoStamp`). That is the format
2862
4787
  // `$now()` produces, and the published-read scope compares against it LEXICOGRAPHICALLY.
2863
- // It is deliberately NOT `nowStamp()` (`expr.now()`'s "YYYY-MM-DD HH:MM:SS"), which sorts
2864
- // against the ISO form as if hours apart. Minting in one place is what closes the trap
2865
- // documented on the `publish` field: there, `publish` and `datetime` wrote different
2866
- // formats into the same TEXT column and both passed validation.
4788
+ //
4789
+ // It used to be worth saying that this is NOT the shape `expr.now()` writes. It is now:
4790
+ // `expr.now()` emits the same ISO form, so a managed column and an `expr.now()` default
4791
+ // beside it are finally comparable, and `nowStamp` is an alias for `isoStamp` rather than a
4792
+ // second format. What still holds is the reason for minting in one place — the trap
4793
+ // documented on the `publish` field, where two controls wrote different formats into one
4794
+ // TEXT column and both passed validation.
2867
4795
 
2868
4796
  /** A workflow feature a collection can opt into.
2869
4797
  *
@@ -2918,11 +4846,6 @@ export const COLLECTION_PUBLISHED = "published";
2918
4846
  const DEFAULT_COLLECTION_LIST_LIMIT = 100;
2919
4847
  const MAX_COLLECTION_LIST_LIMIT = 500;
2920
4848
 
2921
- /** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
2922
- * in, so it compares correctly against `$now()`. See the note above on why this is not
2923
- * `nowStamp()`. */
2924
- const isoStamp = (): string => new Date().toISOString();
2925
-
2926
4849
  /** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
2927
4850
  *
2928
4851
  * `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
@@ -2962,10 +4885,22 @@ const COLLECTION_FIELD_COLUMN_TYPES: Readonly<Record<FieldDefinition["type"], re
2962
4885
  slug: ["text"],
2963
4886
  media: ["text", "uuid"],
2964
4887
  select: ["text"],
4888
+ // A SINGLE reference is one opaque id in a TEXT column. `multiple: true` stores an array
4889
+ // and needs `t.json()` — `referenceColumnTypes` below is what actually decides, because
4890
+ // this table is keyed by type alone and a reference is the one type whose storage depends
4891
+ // on a second flag.
4892
+ reference: ["text", "uuid"],
2965
4893
  repeater: ["json"],
2966
4894
  group: ["json"],
2967
4895
  };
2968
4896
 
4897
+ /** The column types a field can be stored in, including the one case the type alone does
4898
+ * not settle (`reference` + `multiple`). */
4899
+ function fieldColumnTypes(f: FieldDefinition): readonly FieldType[] | undefined {
4900
+ if (f.type === "reference" && f.multiple) return ["json"];
4901
+ return COLLECTION_FIELD_COLUMN_TYPES[f.type];
4902
+ }
4903
+
2969
4904
  /** Check a collection registry at BOOT: slugs and entities are unique, features are known
2970
4905
  * and have their prerequisites, every declared field maps to a column that can hold it, and
2971
4906
  * every managed column exists, has the shape the CMS writes, and is not also an editable
@@ -3057,7 +4992,7 @@ export function validateCollections(collections: readonly CollectionDef[], schem
3057
4992
  `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`,
3058
4993
  );
3059
4994
  }
3060
- const allowed = COLLECTION_FIELD_COLUMN_TYPES[f.type];
4995
+ const allowed = fieldColumnTypes(f);
3061
4996
  if (allowed && !allowed.includes(col.type)) {
3062
4997
  const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
3063
4998
  throw new Error(
@@ -3691,7 +5626,7 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
3691
5626
  // ---- preview ------------------------------------------------------------
3692
5627
 
3693
5628
  /** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
3694
- * Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
5629
+ * Mirrors `signPagePreview` — same secret, same TTL clamp, works on both stores, and the
3695
5630
  * same rule that the row is read through the ACL FIRST: minting a link is granting
3696
5631
  * access to the row, so a caller who cannot read it must not be able to mint one. */
3697
5632
  signCollectionPreview: query(async (ctx, input: { collection: string; id: string; expiresIn?: number }) => {