@pramen/cms 0.0.48 → 0.0.50

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
@@ -38,12 +38,20 @@ import {
38
38
  expr,
39
39
  policy,
40
40
  allow,
41
+ $now,
42
+ partitionOf,
43
+ DEFAULT_PARTITION,
41
44
  BadRequest,
42
45
  Forbidden,
43
46
  PramenError,
47
+ Conflict,
48
+ signToken,
49
+ verifyToken,
50
+ resolveSecret,
44
51
  } from "@pramen/server";
45
- import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
46
- import { filterXSS } from "xss";
52
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef, FieldDef, FieldType } from "@pramen/server";
53
+ import type { EnvBag } from "@pramen/server";
54
+ import { isSafeHref, normalizeHref } from "./href";
47
55
 
48
56
  // --- field schema DSL (the block-editor field language) ---------------------
49
57
 
@@ -131,7 +139,7 @@ export interface RegionDefinition {
131
139
  export interface DefaultBlockDefinition {
132
140
  region: string;
133
141
  blockTypeSlug: string;
134
- fields?: Record<string, unknown>;
142
+ fields?: FieldValues;
135
143
  }
136
144
 
137
145
  // --- hybrid typed blocks: compile-time inference over a const FieldDefinition[] --------
@@ -142,8 +150,38 @@ export interface DefaultBlockDefinition {
142
150
  // the schema, exactly like `typeof app.handlers` types the RPC client. (A `pramen cms codegen`
143
151
  // command that emits these types from DB-stored schemas is future work.)
144
152
 
145
- /** A rich-text value — a serialized editor document (or a plain string). */
146
- export type RichText = string | { type: string; content?: unknown[] };
153
+ /**
154
+ * A rich-text document the editor's structured JSON (a ProseMirror/TipTap doc tree).
155
+ *
156
+ * **Not an HTML string.** HTML never enters storage: the write path validates this tree
157
+ * against a node/mark allow-list (`normalizeRichText`) and the render side maps node types
158
+ * to components, so there is no `set:html` / `dangerouslySetInnerHTML` anywhere in the
159
+ * chain and nothing to scrub. The one attribute that can still carry script is a `link`
160
+ * mark's `href`, so that one IS checked — see `isSafeHref`.
161
+ */
162
+ export interface RichTextDoc {
163
+ type: "doc";
164
+ content?: RichTextNode[];
165
+ }
166
+
167
+ /** One node in a {@link RichTextDoc}. A leaf carries `text` (plus optional inline `marks`);
168
+ * a container carries `content`. */
169
+ export interface RichTextNode {
170
+ type: string;
171
+ content?: RichTextNode[];
172
+ text?: string;
173
+ marks?: RichTextMark[];
174
+ attrs?: Record<string, JsonValue>;
175
+ }
176
+
177
+ /** An inline mark on a text node — bold, link, highlight, … */
178
+ export interface RichTextMark {
179
+ type: string;
180
+ attrs?: Record<string, JsonValue>;
181
+ }
182
+
183
+ /** The value of a `richtext` field. */
184
+ export type RichText = RichTextDoc;
147
185
 
148
186
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
149
187
  * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
@@ -341,12 +379,45 @@ function tsTypeOf(f: FieldDefinition): string {
341
379
  return "unknown";
342
380
  }
343
381
  }
382
+ /** Which imported helper types a field schema actually needs, found by walking the tree
383
+ * (group/repeater nest, so a `richtext` three levels down still counts). Order is stable
384
+ * so the emitted import line does not churn between runs. */
385
+ function referencedHelperTypes(fields: readonly FieldDefinition[]): string[] {
386
+ const found = new Set<string>();
387
+ const walk = (defs: readonly FieldDefinition[]): void => {
388
+ for (const f of defs) {
389
+ if (f.type === "richtext") found.add("RichText");
390
+ else if (f.type === "media") found.add("ResolvedMedia");
391
+ else if (f.fields) walk(f.fields);
392
+ }
393
+ };
394
+ walk(fields);
395
+ return ["ResolvedMedia", "RichText"].filter((t) => found.has(t));
396
+ }
397
+
344
398
  const tsFieldLine = (f: FieldDefinition): string => `${JSON.stringify(f.name)}${f.required ? "" : "?"}: ${tsTypeOf(f)};`;
345
399
 
346
400
  /** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
347
401
  * DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
348
402
  * compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
349
403
  export function generateBlockTypes(blockTypes: Array<{ slug: string; fieldsSchema?: FieldDefinition[] | null }>): string {
404
+ // Slugs are webmaster-authored with no deploy, so they are not guaranteed to map to a
405
+ // valid or DISTINCT TypeScript identifier: "2-col" -> `interface 2ColFields` is a syntax
406
+ // error, and "rich-text"/"rich_text" both -> `RichTextFields`. Fail with the offending
407
+ // slugs rather than emit a file that does not parse.
408
+ const seen = new Map<string, string>();
409
+ const bad: string[] = [];
410
+ for (const bt of blockTypes) {
411
+ const name = pascal(bt.slug);
412
+ // Unicode-aware: `úvodní-blok` -> `úvodníBlok` IS a legal TypeScript identifier, and an
413
+ // ASCII-only test rejected it — aborting codegen for the WHOLE tenant over a slug that
414
+ // works, with no fix short of renaming production data.
415
+ if (!/^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u.test(name)) bad.push(`${bt.slug} (-> '${name}', not an identifier)`);
416
+ else if (seen.has(name)) bad.push(`${bt.slug} (-> '${name}', collides with '${seen.get(name)}')`);
417
+ else seen.set(name, bt.slug);
418
+ }
419
+ if (bad.length) throw new BadRequest(`cannot generate types for block type slug(s): ${bad.join("; ")}`);
420
+
350
421
  const interfaces = blockTypes
351
422
  .map((bt) => {
352
423
  const fields = Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [];
@@ -355,11 +426,16 @@ export function generateBlockTypes(blockTypes: Array<{ slug: string; fieldsSchem
355
426
  })
356
427
  .join("\n\n");
357
428
  const registry = blockTypes.map((bt) => ` ${JSON.stringify(bt.slug)}: ${pascal(bt.slug)}Fields;`).join("\n");
358
- return (
359
- `// AUTO-GENERATED by @pramen/cms do not edit.\n` +
360
- `import type { ResolvedMedia, RichText } from "@pramen/cms";\n\n` +
361
- `${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`
362
- );
429
+ // Import ONLY what the emitted interfaces reference: every tsconfig in this repo sets
430
+ // `noUnusedLocals`, so an unconditional import is a guaranteed TS6192 build break in the
431
+ // consumer's own project for a file they are told not to edit.
432
+ //
433
+ // Walk the SCHEMA rather than regexing the rendered text: field names are printed into
434
+ // the output, so a field literally named `RichText` matched a `\bRichText\b` scan and
435
+ // produced the unused import this exists to avoid.
436
+ const used = referencedHelperTypes(blockTypes.flatMap((bt) => (Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [])));
437
+ const importLine = used.length ? `import type { ${used.join(", ")} } from "@pramen/cms";\n\n` : "";
438
+ return `// AUTO-GENERATED by @pramen/cms — do not edit.\n${importLine}${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`;
363
439
  }
364
440
 
365
441
  // --- schema fragment: spread into your defineSchema so the tables migrate --------
@@ -396,6 +472,9 @@ export const cmsSchema = {
396
472
  title: t.text(),
397
473
  fields: t.json(), // content matching the block type's fieldsSchema
398
474
  isReusable: defaultTo(t.bool(), false),
475
+ // Optimistic concurrency: bumped on every edit. A caller may pass the version it
476
+ // read as `expectedVersion` and get a 409 instead of silently clobbering.
477
+ version: defaultTo(t.int(), 1),
399
478
  createdAt: defaultTo(t.text(), expr.now()),
400
479
  updatedAt: defaultTo(t.text(), expr.now()),
401
480
  }),
@@ -425,6 +504,11 @@ export const cmsSchema = {
425
504
  // (not "latest by timestamp") so selection is deterministic even when two publishes
426
505
  // land in the same second (expr.now() is second-precision).
427
506
  currentRevisionId: t.uuid(),
507
+ // Soft delete: the epoch-ISO instant the page was trashed, NULL while it is live.
508
+ // Every read scope AND-merges `deletedAt IS NULL` (see cmsPolicies), so a trashed
509
+ // page disappears from the public API and the editor alike without a single handler
510
+ // remembering to filter. `restorePage` clears it; `purgePage` removes the row.
511
+ deletedAt: indexed(t.text()),
428
512
  // SEO
429
513
  metaTitle: t.text(),
430
514
  metaDescription: t.text(),
@@ -434,6 +518,8 @@ export const cmsSchema = {
434
518
  ogDescription: t.text(),
435
519
  ogImage: t.uuid(), // a cms_media id, resolved to a URL at assemble time
436
520
  structuredData: t.json(), // JSON-LD, emitted as-is into <head>
521
+ // Optimistic concurrency — see cms_blocks.version.
522
+ version: defaultTo(t.int(), 1),
437
523
  createdAt: defaultTo(t.text(), expr.now()),
438
524
  updatedAt: defaultTo(t.text(), expr.now()),
439
525
  }),
@@ -490,12 +576,53 @@ export const cmsSchema = {
490
576
  createdAt: defaultTo(t.text(), expr.now()),
491
577
  })),
492
578
 
579
+ // Revision history for COLLECTION rows (`supports: ["revisions"]`). One shared table
580
+ // rather than one per collection: a collection targets an arbitrary app entity, so there
581
+ // is no place to hang a per-entity revisions table and no way to declare a real FK to a
582
+ // target that varies. `collection` + `rowId` identify the subject; `rowId` is TEXT
583
+ // because a collection's PK may be a uuid or a textId.
584
+ //
585
+ // A revision holds the row's state BEFORE the write that created it, projected to the
586
+ // collection's declared fields — so restoring one is a plain reversal, and a snapshot
587
+ // taken before a field was dropped from `fields` cannot resurrect that column (restore
588
+ // replays through the same write whitelist).
589
+ cms_collection_revisions: Entity((t) => ({
590
+ id: primaryKey(generated(t.uuid())),
591
+ collection: indexed(notNull(t.text())),
592
+ rowId: indexed(notNull(t.text())),
593
+ // A monotonic per-row counter, and the ONLY ordering key. Timestamps cannot do this
594
+ // job: `expr.now()` is second-resolution and even an ISO ms stamp collides, because a
595
+ // collection revision is written on EVERY edit and two writes land in the same
596
+ // millisecond often enough to be reproducible. Ordering then falls to a uuid tiebreak,
597
+ // which is deterministic but NOT insertion order — so "restore the previous version"
598
+ // could pick the wrong snapshot.
599
+ //
600
+ // The read-then-increment in `snapshotRow` is serialized by the DO's single writer. On
601
+ // the D1 store it is NOT — `D1Driver.transaction` is a no-op (D1 has no interactive
602
+ // transactions), so two concurrent updates in different isolates can read the same MAX.
603
+ // The composite unique below is what makes that a visible failure instead of a silent
604
+ // duplicate that quietly restores the ordering ambiguity this column exists to remove.
605
+ revision: notNull(t.int()),
606
+ snapshot: t.json(),
607
+ note: t.text(),
608
+ actor: t.text(),
609
+ // NO expr.now() default. `snapshotRow` is the only writer and stamps this itself with
610
+ // ISO-8601 ms precision, because unlike cms_page_revisions (written only on publish) a
611
+ // collection revision is written on EVERY edit — an autosave followed immediately by a
612
+ // publish lands two rows in the same second, and `datetime('now')` (second resolution)
613
+ // would make "the previous version" an arbitrary pick between them.
614
+ createdAt: t.text(),
615
+ }), undefined, { unique: [["collection", "rowId", "revision"]] }),
616
+
493
617
  // Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
494
618
  // ctx.files + the Worker /files/* route. Block `fields` reference a media id.
495
619
  cms_media: Entity((t) => ({
496
620
  id: primaryKey(generated(t.uuid())),
497
621
  file: t.fileRef(),
498
622
  alt: t.text(),
623
+ // Soft delete, as on cms_pages. The R2 OBJECT is deliberately kept while a media row
624
+ // is trashed — deleting the bytes would make restore a lie. `purgeMedia` drops both.
625
+ deletedAt: indexed(t.text()),
499
626
  createdAt: defaultTo(t.text(), expr.now()),
500
627
  })),
501
628
  };
@@ -507,6 +634,16 @@ export interface ValidateOpts {
507
634
  * writes (addBlock/updateBlock/createPage) pass `false` — a DRAFT block may be incomplete;
508
635
  * required is only mandatory when publishing. Type checks always run. */
509
636
  requireRequired?: boolean;
637
+ /** The row's CURRENTLY STORED field values. A legacy HTML-string `richtext` value is
638
+ * tolerated only when it is byte-identical to the stored one — i.e. the caller echoed
639
+ * back a pre-Portable-Text value it never authored (the editor autosaves the whole bag).
640
+ * Anything else is rejected.
641
+ *
642
+ * This must NOT be a plain boolean. The `xss` sanitizer is gone, and `normalizeFields`
643
+ * passes a tolerated string through untouched, so a blanket "allow strings" would let
644
+ * any caller store arbitrary unsanitized HTML — which every consumer still on the
645
+ * pre-migration `set:html` contract would then execute. */
646
+ legacyBaseline?: FieldValues;
510
647
  }
511
648
 
512
649
  /** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
@@ -529,7 +666,7 @@ function isDateTimeString(v: string): boolean {
529
666
  export function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path = "", opts: ValidateOpts = {}): void {
530
667
  const requireRequired = opts.requireRequired !== false;
531
668
  const defs = Array.isArray(schema) ? schema : [];
532
- const obj = (values ?? {}) as Record<string, unknown>;
669
+ const obj = (values ?? {}) as FieldValues;
533
670
  if (typeof obj !== "object" || Array.isArray(obj)) throw new BadRequest(`${path || "fields"} must be an object`);
534
671
  for (const def of defs) {
535
672
  const at = path ? `${path}.${def.name}` : def.name;
@@ -555,7 +692,18 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
555
692
  if (!isSlugString(v)) throw new BadRequest(`field '${at}' must be a slug (lowercase letters, digits and single hyphens)`);
556
693
  break;
557
694
  case "richtext":
558
- if (typeof v !== "string" && typeof v !== "object") throw new BadRequest(`field '${at}' must be rich text`);
695
+ // A document tree, never a string. A legacy HTML value is REJECTED rather than
696
+ // silently normalized to an empty doc — a 400 names the migration; a blank field
697
+ // would look like the content simply vanished. Except where the bag carries stored
698
+ // data the caller never sent (see `legacyBaseline`).
699
+ if (typeof v === "string") {
700
+ // Tolerated only if it is exactly what is already stored for this field.
701
+ if (opts.legacyBaseline && opts.legacyBaseline[def.name] === v) break;
702
+ throw new BadRequest(`field '${at}' must be a rich-text document, not an HTML string`);
703
+ }
704
+ if (typeof v !== "object" || Array.isArray(v) || (v as unknown as RichTextDoc).type !== "doc") {
705
+ throw new BadRequest(`field '${at}' must be a rich-text document ({ type: "doc", content: [...] })`);
706
+ }
559
707
  break;
560
708
  case "number":
561
709
  if (typeof v !== "number") throw new BadRequest(`field '${at}' must be a number`);
@@ -576,14 +724,35 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
576
724
  // (collectMediaIds/resolveMediaFields only handle string ids).
577
725
  if (typeof v !== "string") throw new BadRequest(`field '${at}' must be a media id (string)`);
578
726
  break;
579
- case "group":
580
- validateFields(def.fields, v, at, opts);
727
+ case "group": {
728
+ // The baseline MUST descend. Stopping at the top level meant a pre-migration
729
+ // richtext value nested in a group was rejected on every write that echoed the
730
+ // stored bag back — and placeBlock, which merges the block's OWN stored fields,
731
+ // could not place such a block at all. No editor can fix that: none ever mounted it.
732
+ const nested = opts.legacyBaseline?.[def.name];
733
+ validateFields(def.fields, v, at, {
734
+ requireRequired: opts.requireRequired,
735
+ legacyBaseline: nested && typeof nested === "object" && !Array.isArray(nested) ? (nested as FieldValues) : undefined,
736
+ });
581
737
  break;
738
+ }
582
739
  case "repeater": {
583
740
  if (!Array.isArray(v)) throw new BadRequest(`field '${at}' must be a list`);
584
741
  if (def.min != null && v.length < def.min) throw new BadRequest(`field '${at}' needs at least ${def.min} item(s)`);
585
742
  if (def.max != null && v.length > def.max) throw new BadRequest(`field '${at}' allows at most ${def.max} item(s)`);
586
- v.forEach((item, i) => validateFields(def.fields, item, `${at}[${i}]`, opts));
743
+ {
744
+ // Per-item baseline, positionally — a repeater item that kept its slot keeps its
745
+ // stored value, so an untouched legacy value inside one still validates.
746
+ const base = opts.legacyBaseline?.[def.name];
747
+ const baseItems = Array.isArray(base) ? base : [];
748
+ v.forEach((item, i) => {
749
+ const bi = baseItems[i];
750
+ validateFields(def.fields, item, `${at}[${i}]`, {
751
+ requireRequired: opts.requireRequired,
752
+ legacyBaseline: bi && typeof bi === "object" && !Array.isArray(bi) ? (bi as FieldValues) : undefined,
753
+ });
754
+ });
755
+ }
587
756
  break;
588
757
  }
589
758
  default:
@@ -592,39 +761,216 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
592
761
  }
593
762
  }
594
763
 
595
- // --- rich-text sanitization (server-side — the real XSS boundary) -------------
764
+ // --- rich text: the structural allow-list (server-side — the real XSS boundary) ---
765
+ //
766
+ // A `richtext` value is a document TREE, so there is no HTML to scrub — the boundary is
767
+ // STRUCTURAL: an unknown node or mark type is dropped, only the attributes declared for a
768
+ // type survive, an attribute value must be a JSON primitive, and a `link` href must pass a
769
+ // scheme allow-list. Client-side checks are not a boundary — a caller can POST any value
770
+ // straight to these handlers — so this runs on write, like the HTML sanitizer it replaces.
596
771
  //
597
- // richtext fields are HTML the site renders with set:html, so they MUST be sanitized
598
- // before persistence. Client-side scrubbing is not a boundary a caller can POST any
599
- // value straight to these handlers. We sanitize on write against a strict tag/attribute
600
- // allow-list with js-xss (`xss`), which is SYNCHRONOUS and pure-JS — this matters because
601
- // sanitize runs inside the DO's storage.transaction(), where async stream I/O (e.g.
602
- // HTMLRewriter) deadlocks. js-xss drops disallowed tags/attributes and blanks
603
- // javascript:/data: URLs in href/src by default.
604
-
605
- const RT_WHITELIST: Record<string, string[]> = {
606
- p: [], br: [], hr: [], blockquote: [], pre: [], code: [],
607
- strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [], mark: [], sub: [], sup: [],
608
- h2: [], h3: [], h4: [], ul: [], ol: [], li: [], a: ["href", "title"],
772
+ // Everything here is SYNCHRONOUS and pure. That still matters: normalization runs inside
773
+ // the DO's storage.transaction(), where async stream I/O (e.g. HTMLRewriter) deadlocks
774
+ // the same constraint that once ruled out a DOM-based sanitizer.
775
+
776
+ /** The node/mark vocabulary a `richtext` value may use. A node entry maps a node type to
777
+ * the attribute names kept on it; a mark entry does the same for a mark type. */
778
+ export interface RichTextSchema {
779
+ nodes: Record<string, readonly string[]>;
780
+ marks: Record<string, readonly string[]>;
781
+ /** Highest heading level accepted; anything above is CLAMPED to it, not dropped.
782
+ * Defaults to `MAX_HEADING_LEVEL` (3, the shipped editor's StarterKit config). Raise it
783
+ * if your editor is configured for more this is the widening the docs promise. */
784
+ maxHeadingLevel?: number;
785
+ }
786
+
787
+ /** What the shipped editor can actually produce (TipTap StarterKit + Highlight + TaskList,
788
+ * as configured by @podoba/react's BlockEditor). Pass your own to `normalizeFields` if your
789
+ * editor adds extensions — a node type absent from the schema is dropped on write. */
790
+ /** Highest heading level the shipped editor is configured for (StarterKit levels [1,2,3]). */
791
+ export const MAX_HEADING_LEVEL = 3;
792
+
793
+ export const DEFAULT_RICH_TEXT_SCHEMA: RichTextSchema = {
794
+ nodes: {
795
+ // NOTE: no `doc`. normalizeRichText builds the root itself and never looks it up, so
796
+ // an entry here would only ever authorize a NESTED doc — which TipTap cannot render
797
+ // (Document declares no renderHTML), blanking the field in the editor while the site
798
+ // renderers still showed the subtree. The first keystroke then saved the blank over it.
799
+ paragraph: [],
800
+ text: [],
801
+ hardBreak: [],
802
+ horizontalRule: [],
803
+ heading: ["level"],
804
+ blockquote: [],
805
+ codeBlock: ["language"],
806
+ bulletList: [],
807
+ orderedList: ["start"],
808
+ listItem: [],
809
+ taskList: [],
810
+ taskItem: ["checked"],
811
+ },
812
+ marks: {
813
+ bold: [],
814
+ italic: [],
815
+ underline: [],
816
+ strike: [],
817
+ code: [],
818
+ highlight: ["color"],
819
+ link: ["href", "title", "target"],
820
+ },
609
821
  };
610
- const RT_XSS_OPTS = { whiteList: RT_WHITELIST, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style"] as string[] };
611
822
 
612
- /** Sanitize one richtext HTML string to the allow-list. Synchronous by design. */
613
- function sanitizeRichText(html: string): string {
614
- return html ? filterXSS(html, RT_XSS_OPTS) : html;
823
+ // Re-exported from the leaf module `./href` so `@pramen/cms/react` can import them at
824
+ // runtime without dragging this file (and the whole server SDK) into a browser bundle.
825
+ export { isSafeHref, normalizeHref } from "./href";
826
+
827
+ /** Keep only the declared attributes, and only those holding a JSON primitive — an object
828
+ * or array in an attr is never something the editor emits, so it is smuggled payload. */
829
+ /** Look a type up in an allow-list WITHOUT walking the prototype chain. A plain-object
830
+ * index resolves `constructor` / `toString` / `valueOf` to inherited members, which are
831
+ * truthy — so `{ type: "constructor" }` passed the gate and was stored, and its "allowed
832
+ * attributes" became the `Object` function (length 1, not iterable), throwing a TypeError
833
+ * inside the DO's storage.transaction(). Both renderers and TipTap then choke on the
834
+ * stored node, which bricks the row. */
835
+ function allowedAttrsFor(table: Record<string, readonly string[]>, type: unknown): readonly string[] | undefined {
836
+ if (typeof type !== "string" || !Object.hasOwn(table, type)) return undefined;
837
+ return table[type];
838
+ }
839
+
840
+ function normalizeAttrs(attrs: unknown, allowed: readonly string[], maxHeading: number = MAX_HEADING_LEVEL): Record<string, JsonValue> | undefined {
841
+ if (!allowed.length || !attrs || typeof attrs !== "object" || Array.isArray(attrs)) return undefined;
842
+ const out: Record<string, JsonValue> = {};
843
+ for (const name of allowed) {
844
+ const v = (attrs as Record<string, unknown>)[name];
845
+ if (v === undefined) continue;
846
+ if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") continue;
847
+ // CLAMP, don't drop. Dropping `level` left the node level-less, and TipTap's Heading
848
+ // declares `level: { default: 1 }` — so an imported h4 still opened as h1 and the next
849
+ // autosave still persisted h1, while the renderers fell back to h2. Same silent
850
+ // mutation the narrowing was meant to stop, plus an editor/site mismatch.
851
+ if (name === "level") {
852
+ if (typeof v !== "number" || !Number.isInteger(v)) continue;
853
+ out[name] = Math.min(Math.max(v, 1), maxHeading);
854
+ continue;
855
+ }
856
+ out[name] = v;
857
+ }
858
+ return Object.keys(out).length ? out : undefined;
859
+ }
860
+
861
+ /** Drop unknown marks and any `link` whose href fails the scheme allow-list (dropping the
862
+ * whole mark, not just the href — an anchor with no destination is worse than plain text). */
863
+ function normalizeMarks(marks: unknown, schema: RichTextSchema): RichTextMark[] | undefined {
864
+ if (!Array.isArray(marks)) return undefined;
865
+ const out: RichTextMark[] = [];
866
+ for (const raw of marks) {
867
+ if (!raw || typeof raw !== "object") continue;
868
+ const mark = raw as RichTextMark;
869
+ const allowed = allowedAttrsFor(schema.marks, mark.type);
870
+ if (!allowed) continue;
871
+ const attrs = normalizeAttrs(mark.attrs, allowed, schema.maxHeadingLevel);
872
+ if (mark.type === "link") {
873
+ if (!isSafeHref(attrs?.href)) continue;
874
+ // Persist the parser-normalized form, so what was validated is what resolves.
875
+ if (attrs && typeof attrs.href === "string") attrs.href = normalizeHref(attrs.href);
876
+ }
877
+ out.push(attrs ? { type: mark.type, attrs } : { type: mark.type });
878
+ }
879
+ return out.length ? out : undefined;
880
+ }
881
+
882
+ /** How deep a document may nest before the normalizer stops descending. Real editor output
883
+ * is a handful of levels (list > item > paragraph > text); a hand-crafted doc nested tens
884
+ * of thousands deep would otherwise blow the stack INSIDE the DO's storage.transaction().
885
+ * JSON.parse is iterative in V8, so such a payload reaches the normalizer intact. */
886
+ export const MAX_RICH_TEXT_DEPTH = 100;
887
+
888
+ /** Normalize one node, or `null` if its type is not in the schema. */
889
+ function normalizeNode(raw: unknown, schema: RichTextSchema, depth = 0): RichTextNode | null {
890
+ if (depth > MAX_RICH_TEXT_DEPTH) return null;
891
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
892
+ const node = raw as RichTextNode;
893
+ const allowed = allowedAttrsFor(schema.nodes, node.type);
894
+ if (!allowed) return null;
895
+
896
+ const out: RichTextNode = { type: node.type };
897
+ if (node.type === "text") {
898
+ // A text node with no string — or an EMPTY one — is not text. ProseMirror forbids an
899
+ // empty text node outright (`schema.text("")` throws "Empty text nodes are not
900
+ // allowed"), and the editor builds its document inside a useState initializer, so a
901
+ // stored `{type:"text",text:""}` would throw during render and take the edit UI down
902
+ // for that row permanently.
903
+ if (typeof node.text !== "string" || node.text === "") return null;
904
+ out.text = node.text;
905
+ const marks = normalizeMarks(node.marks, schema);
906
+ if (marks) out.marks = marks;
907
+ }
908
+ const attrs = normalizeAttrs(node.attrs, allowed, schema.maxHeadingLevel);
909
+ if (attrs) out.attrs = attrs;
910
+ if (Array.isArray(node.content)) {
911
+ const content = normalizeNodes(node.content, schema, depth + 1);
912
+ if (content.length) out.content = content;
913
+ }
914
+ return out;
915
+ }
916
+
917
+ function normalizeNodes(nodes: readonly unknown[], schema: RichTextSchema, depth = 0): RichTextNode[] {
918
+ const out: RichTextNode[] = [];
919
+ for (const n of nodes) {
920
+ const node = normalizeNode(n, schema, depth);
921
+ if (node) out.push(node);
922
+ }
923
+ return out;
924
+ }
925
+
926
+ /** Normalize a rich-text value to a document the renderers can trust. A value that is not
927
+ * a doc at all yields an empty doc — `validateFields` rejects those first, so in handler
928
+ * flow this only ever sees a doc; the fallback is for direct callers. */
929
+ export function normalizeRichText(value: unknown, schema: RichTextSchema = DEFAULT_RICH_TEXT_SCHEMA): RichTextDoc {
930
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { type: "doc", content: [] };
931
+ const content = Array.isArray((value as RichTextDoc).content) ? normalizeNodes((value as RichTextDoc).content ?? [], schema) : [];
932
+ return { type: "doc", content };
933
+ }
934
+
935
+ /** The block-level node types that end a line when flattening to plain text. */
936
+ const RT_BLOCK_TYPES = new Set(["paragraph", "heading", "listItem", "taskItem", "blockquote", "codeBlock", "horizontalRule"]);
937
+
938
+ /** Flatten a rich-text document to plain text — for excerpts, meta descriptions, and search
939
+ * indexing, which want the words without the structure. */
940
+ export function richTextToPlainText(value: RichTextDoc | null | undefined): string {
941
+ const parts: string[] = [];
942
+ const walk = (nodes: readonly RichTextNode[]): void => {
943
+ for (const node of nodes) {
944
+ if (node.type === "text") parts.push(node.text ?? "");
945
+ else if (node.type === "hardBreak") parts.push("\n");
946
+ if (node.content) walk(node.content);
947
+ if (RT_BLOCK_TYPES.has(node.type)) parts.push("\n");
948
+ }
949
+ };
950
+ walk(value?.content ?? []);
951
+ // A block inside a block (a paragraph in a list item) closes both, so collapse the run:
952
+ // every boundary is worth exactly one line break in a flattened excerpt.
953
+ return parts.join("").replace(/\n{2,}/g, "\n").trim();
615
954
  }
616
955
 
617
- /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
618
- * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
619
- export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown> {
956
+ /** Deep-normalize the richtext fields in a values object against a field schema (recursing
957
+ * into group/repeater). Returns a normalized copy; other field types pass through. */
958
+ export function normalizeFields(
959
+ schema: FieldDefinition[] | undefined | null,
960
+ values: FieldValues,
961
+ richTextSchema: RichTextSchema = DEFAULT_RICH_TEXT_SCHEMA,
962
+ ): FieldValues {
620
963
  const defs = Array.isArray(schema) ? schema : [];
621
- const out: Record<string, unknown> = { ...values };
964
+ const out: FieldValues = { ...values };
622
965
  for (const def of defs) {
623
966
  const v = out[def.name];
624
967
  if (v == null) continue;
625
- if (def.type === "richtext" && typeof v === "string") out[def.name] = sanitizeRichText(v);
626
- else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = sanitizeFields(def.fields, v as Record<string, unknown>);
627
- else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as Record<string, unknown>) : it));
968
+ // A legacy HTML string survives normalization untouched: normalizeRichText would turn
969
+ // it into an EMPTY doc, i.e. silently delete the content. It only reaches here on the
970
+ // `legacyBaseline` paths, where it is the stored value being echoed back.
971
+ if (def.type === "richtext") out[def.name] = typeof v === "string" ? v : normalizeRichText(v, richTextSchema);
972
+ else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = normalizeFields(def.fields, v as FieldValues, richTextSchema);
973
+ else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? normalizeFields(def.fields, it as FieldValues, richTextSchema) : it));
628
974
  }
629
975
  return out;
630
976
  }
@@ -632,13 +978,15 @@ export function sanitizeFields(schema: FieldDefinition[] | undefined | null, val
632
978
  // --- assembled-page shape (the content-API result + revision snapshot) --------
633
979
 
634
980
  export interface RenderedBlock {
981
+ /** The block's optimistic-concurrency token — pass back as `expectedVersion`. */
982
+ version: number;
635
983
  /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
636
984
  id: string;
637
985
  /** The underlying block instance id (cms_blocks) — used to edit the block's content. */
638
986
  block_id: string;
639
987
  block_type: string;
640
988
  title: string | null;
641
- fields: Record<string, unknown>;
989
+ fields: FieldValues;
642
990
  is_shared: boolean;
643
991
  }
644
992
 
@@ -671,17 +1019,32 @@ export interface AssembledPage {
671
1019
  translationGroupId: string | null;
672
1020
  /** Published sibling locales of this page (for hreflang alternates). */
673
1021
  translations: PageTranslation[];
674
- fields: Record<string, unknown> | null;
1022
+ fields: FieldValues | null;
675
1023
  /** Back-compat: mirrors seo.metaTitle/metaDescription. */
676
1024
  metaTitle: string | null;
677
1025
  metaDescription: string | null;
678
1026
  seo: PageSeo;
1027
+ /** Optimistic-concurrency token — pass back as `expectedVersion` on a write. */
1028
+ version: number;
679
1029
  };
680
1030
  regions: Record<string, RenderedBlock[]>;
1031
+ /** True when this is a live draft assembled behind a preview grant, rather than the
1032
+ * published snapshot — so a frontend can render a "you are viewing a draft" banner. */
1033
+ isPreview?: boolean;
681
1034
  }
682
1035
 
683
1036
  // --- media -------------------------------------------------------------------
684
1037
 
1038
+ /** One authored field value inside a block / collection / page `fields` bag. Stored
1039
+ * as JSON; a `"media"` field is resolved from its stored id to a `ResolvedMedia` at
1040
+ * assemble time, and `group`/`repeater` fields nest further bags. */
1041
+ export type FieldValue = JsonValue | ResolvedMedia | RichTextDoc | FieldValues | FieldValue[];
1042
+
1043
+ /** A block / collection / page `fields` bag — field name -> authored value. */
1044
+ export interface FieldValues {
1045
+ [field: string]: FieldValue;
1046
+ }
1047
+
685
1048
  /** A `"media"` block field, resolved from a stored media id to a servable shape at
686
1049
  * assemble time. `url` is the raw (full-size) serving path; pass `key` to `imageUrl()`
687
1050
  * for on-the-fly transforms. `null` when the referenced media was deleted. */
@@ -720,7 +1083,7 @@ export function imageUrl(
720
1083
  }
721
1084
 
722
1085
  /** Collect the media ids referenced by a fields payload, walking group/repeater nesting. */
723
- function collectMediaIds(fields: Record<string, unknown>, schema: FieldDefinition[] | undefined, acc: Set<string>): void {
1086
+ function collectMediaIds(fields: FieldValues, schema: FieldDefinition[] | undefined, acc: Set<string>): void {
724
1087
  if (!Array.isArray(schema)) return;
725
1088
  for (const def of schema) {
726
1089
  const v = fields[def.name];
@@ -738,12 +1101,12 @@ function collectMediaIds(fields: Record<string, unknown>, schema: FieldDefinitio
738
1101
  /** Return a copy of `fields` with every `"media"` field resolved from its id to a
739
1102
  * `ResolvedMedia` (or null), recursing into group/repeater nesting. */
740
1103
  function resolveMediaFields(
741
- fields: Record<string, unknown>,
1104
+ fields: FieldValues,
742
1105
  schema: FieldDefinition[] | undefined,
743
1106
  mediaById: Map<string, ResolvedMedia>,
744
- ): Record<string, unknown> {
1107
+ ): FieldValues {
745
1108
  if (!Array.isArray(schema)) return fields;
746
- const out: Record<string, unknown> = { ...fields };
1109
+ const out: FieldValues = { ...fields };
747
1110
  for (const def of schema) {
748
1111
  const v = out[def.name];
749
1112
  if (v == null) continue;
@@ -782,7 +1145,7 @@ interface CmsDb {
782
1145
  const cdb = (ctx: HandlerContext): CmsDb => ctx.db as unknown as CmsDb;
783
1146
 
784
1147
  const notFound = (what: string) => new PramenError(`${what} not found`, 404, "not_found");
785
- const asObj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" ? (v as Record<string, unknown>) : {});
1148
+ const asObj = (v: unknown): FieldValues => (v && typeof v === "object" ? (v as FieldValues) : {});
786
1149
  // Timestamps in the SAME shape as the `expr.now()` column default (`datetime('now')`:
787
1150
  // "YYYY-MM-DD HH:MM:SS", UTC, second precision) so a column's insert-default and its
788
1151
  // handler-written updates stay lexically comparable (an ISO `T`/`Z` string sorts wrong).
@@ -816,7 +1179,9 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
816
1179
  // fields (id → ResolvedMedia) in one batched lookup across the whole page.
817
1180
  const merged = placements.map((p) => {
818
1181
  const block = asObj(p.block);
819
- const fields = { ...asObj(block.fields), ...(p.isShared ? asObj(p.overrides) : {}) };
1182
+ const fields = p.isShared
1183
+ ? { ...asObj(block.fields), ...asObj(p.overrides) }
1184
+ : { ...asObj(block.fields) };
820
1185
  return { p, block, fields, schema: typeById.get(String(block.typeId))?.fieldsSchema };
821
1186
  });
822
1187
  const mediaIds = new Set<string>();
@@ -843,6 +1208,7 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
843
1208
  (regions[region] ??= []).push({
844
1209
  id: String(m.p.id),
845
1210
  block_id: String(m.block.id),
1211
+ version: typeof m.block.version === "number" ? m.block.version : 1,
846
1212
  block_type: typeById.get(String(m.block.typeId))?.slug ?? "unknown",
847
1213
  title: (m.block.title as string | null) ?? null,
848
1214
  fields: resolveMediaFields(m.fields, m.schema, mediaById),
@@ -905,10 +1271,11 @@ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[]
905
1271
  slug: String(page.slug),
906
1272
  status: String(page.status),
907
1273
  locale: String(page.locale ?? "en"),
1274
+ version: typeof page.version === "number" ? page.version : 1,
908
1275
  contentType,
909
1276
  translationGroupId: (page.translationGroupId as string | null) ?? null,
910
1277
  translations,
911
- fields: (page.fields as Record<string, unknown> | null) ?? null,
1278
+ fields: (page.fields as FieldValues | null) ?? null,
912
1279
  metaTitle,
913
1280
  metaDescription,
914
1281
  seo: {
@@ -953,6 +1320,59 @@ async function assertRegionAllows(db: CmsDb, page: Record<string, unknown>, regi
953
1320
 
954
1321
  // --- handlers ----------------------------------------------------------------
955
1322
 
1323
+ // --- page preview links (signed capability urls) -----------------------------
1324
+ //
1325
+ // Preview used to be a ROLE check, so previewing a draft required an editor account —
1326
+ // which excludes the person preview actually exists for: the stakeholder reviewing copy
1327
+ // before it ships. A preview link is instead a signed, self-expiring CAPABILITY: it names
1328
+ // ONE page, carries its own expiry, and is verified in the Worker before any read happens.
1329
+ // Minting stays editor-gated; redeeming needs no account at all.
1330
+ //
1331
+ // Same machinery as signed file urls (`signToken`/`verifyToken` from @pramen/server), and
1332
+ // the same fail-closed rule: without a usable secret we refuse to mint rather than hand out
1333
+ // forgeable links.
1334
+
1335
+ /** What a preview link authorizes: one page, in one tenant, until `exp`. */
1336
+ export interface PreviewToken {
1337
+ /** tenant */ t: string;
1338
+ /** page id — the grant is scoped to this ONE page, never "all drafts" */ p: string;
1339
+ /** expiry (epoch seconds) */ exp: number;
1340
+ }
1341
+
1342
+ /** Secret preference order. `PREVIEW_SECRET` lets an operator rotate preview links without
1343
+ * invalidating every signed file url, but falling back keeps the common case zero-config. */
1344
+ export const PREVIEW_SECRET_NAMES = ["PREVIEW_SECRET", "FILES_SECRET", "AUTH_SECRET"] as const;
1345
+
1346
+ /** Resolve the preview signing secret, or `undefined` when nothing usable is configured. */
1347
+ export function previewSecret(env: EnvBag): string | undefined {
1348
+ return resolveSecret(env, PREVIEW_SECRET_NAMES);
1349
+ }
1350
+
1351
+ const previewUnconfigured = () =>
1352
+ new PramenError("page preview is not configured (set a strong PREVIEW_SECRET, FILES_SECRET or AUTH_SECRET)", 503, "unavailable");
1353
+
1354
+ /** The viewer roles for a given handler config — `editorRoles ∪ reviewerRoles`, computed
1355
+ * exactly as `createCmsHandlers` computes them.
1356
+ *
1357
+ * Exported so `cmsRoutes()` cannot drift from `createCmsHandlers()`: pass the SAME options
1358
+ * object to both. Configuring the two independently was how the preview route ended up
1359
+ * presenting an identity neither the handler gate nor the ACL accepted — and a partial
1360
+ * customization still worked, so the failure appeared only for the app that had most
1361
+ * carefully renamed its roles. */
1362
+ export function viewerRolesOf(opts: CmsHandlerOpts = {}): string[] {
1363
+ return [...new Set([...(opts.editorRoles ?? ["editor", "admin"]), ...(opts.reviewerRoles ?? ["reviewer", "admin"])])];
1364
+ }
1365
+
1366
+ /** The viewer roles for the DEFAULT handler config. */
1367
+ export const DEFAULT_VIEWER_ROLES = viewerRolesOf();
1368
+
1369
+ /** Where a preview link is redeemed. Spread `cmsRoutes()` into `app.routes` to serve it. */
1370
+ export const PREVIEW_PATH = "/cms/preview";
1371
+
1372
+ /** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
1373
+ * a link pasted into a public channel stops working the same afternoon. */
1374
+ export const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
1375
+
956
1376
  export interface CmsHandlerOpts {
957
1377
  /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
958
1378
  * `["editor", "admin"]`. */
@@ -964,6 +1384,12 @@ export interface CmsHandlerOpts {
964
1384
  /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
965
1385
  * Default `["reviewer", "admin"]`. */
966
1386
  reviewerRoles?: readonly string[];
1387
+ /** Preview-link lifetime in seconds. Default 3600 (1 hour). */
1388
+ previewTtlSeconds?: number;
1389
+ /** The node/mark vocabulary accepted on write. Defaults to `DEFAULT_RICH_TEXT_SCHEMA`
1390
+ * (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
1391
+ * a node type absent from the schema is DROPPED on write, not rejected. */
1392
+ richTextSchema?: RichTextSchema;
967
1393
  }
968
1394
 
969
1395
  /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
@@ -975,6 +1401,8 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
975
1401
  const defaultLocale = opts.defaultLocale ?? "en";
976
1402
  const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
977
1403
  const reviewer = { auth: reviewerRoles };
1404
+ const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
1405
+ const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
978
1406
  // Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
979
1407
  // content type/blocks before approving). Read/preview handlers use this; writes stay editor.
980
1408
  const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
@@ -986,14 +1414,86 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
986
1414
  const writeAudit = (db: CmsDb, e: { pageId: string; action: string; from?: string; to?: string; actor: string | null; note?: string }) =>
987
1415
  db.insert("cms_audit", { pageId: e.pageId, action: e.action, fromStatus: e.from ?? null, toStatus: e.to ?? null, actor: e.actor, note: e.note ?? null });
988
1416
 
1417
+ const mediaIdInput = {
1418
+ input: (raw: unknown): { id: string } => {
1419
+ const o = asObj(raw);
1420
+ if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
1421
+ return { id: o.id };
1422
+ },
1423
+ };
1424
+
1425
+ /** Mark a table changed after a RAW `exec` write.
1426
+ *
1427
+ * `Db.exec` is the one write path that does not record `touched`, so the DO never
1428
+ * broadcasts and every live subscriber keeps showing the pre-write state — a restored
1429
+ * page stays missing from an open page list, a purged one stays present. `deletePage`
1430
+ * goes through the ORM and DOES broadcast, so the staleness was asymmetric and read
1431
+ * like a lost write. */
1432
+ const markChanged = (db: CmsDb, ...tables: string[]): void => {
1433
+ const touched = (db as unknown as { touched?: Set<string> }).touched;
1434
+ if (touched) for (const t of tables) touched.add(t);
1435
+ };
1436
+
1437
+ // --- optimistic concurrency ------------------------------------------------
1438
+ //
1439
+ // On the DO — the default store — a read-then-write inside one mutation is atomic: the
1440
+ // Durable Object is a single writer and DoSqliteDriver.exec is synchronous. The EDITORS
1441
+ // are not serialized, though: two people on the same page means last save wins, silently,
1442
+ // with no signal to the loser. Passing back the `version` you read turns that into a 409.
1443
+ //
1444
+ // CAVEAT — the D1 store has no interactive transaction (D1Driver.transaction is a
1445
+ // pass-through), so two requests in the same millisecond can both read and both write.
1446
+ // The guard still catches the human-scale editor race; it is not a hard mutex there.
1447
+ //
1448
+ // Optional by design: omitting `expectedVersion` keeps last-write-wins, so nothing breaks.
1449
+ const nextVersion = (row: Record<string, unknown>, expected: number | undefined, label: string): number => {
1450
+ // Do NOT default a missing version to 1. Under a field-restricted read grant that
1451
+ // projected the column away, `current` would be 1 forever: a client that legitimately
1452
+ // read version 7 gets a permanent unresolvable 409, and an unguarded save then LOWERS
1453
+ // the stored version, so a genuinely stale write is accepted later.
1454
+ if (typeof row.version !== "number") {
1455
+ // Log the actionable detail, return a generic 500 — PramenError's message goes to the
1456
+ // caller verbatim, so naming the column would leak the schema and ACL shape.
1457
+ console.error(`pramen/cms: ${label} has no readable version — grant read on the \`version\` column`);
1458
+ throw new Error("version unavailable");
1459
+ }
1460
+ const current = row.version;
1461
+ if (expected !== undefined && expected !== current) {
1462
+ throw new Conflict(`${label} was changed by someone else (you have version ${expected}, current is ${current}) — reload and reapply your edit`);
1463
+ }
1464
+ return current + 1;
1465
+ };
1466
+ const versionInput = (o: Record<string, unknown>): void => {
1467
+ if (o.expectedVersion === undefined) return;
1468
+ if (typeof o.expectedVersion !== "number" || !Number.isInteger(o.expectedVersion)) {
1469
+ throw new BadRequest("expectedVersion must be an integer");
1470
+ }
1471
+ };
1472
+
1473
+ const pageIdInput = {
1474
+ input: (raw: unknown): { pageId: string } => {
1475
+ const o = asObj(raw);
1476
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
1477
+ return { pageId: o.pageId };
1478
+ },
1479
+ };
1480
+
989
1481
  // (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
990
1482
  const assertSlugFree = async (db: CmsDb, slug: string, locale: string, exceptId?: string): Promise<void> => {
991
1483
  const rows = await db.exec(
992
- "SELECT id FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
1484
+ "SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
993
1485
  slug,
994
1486
  locale,
995
1487
  );
996
- if (rows[0] && String(rows[0].id) !== exceptId) throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
1488
+ if (rows[0] && String(rows[0].id) !== exceptId) {
1489
+ // A trashed page keeps its slug until purged (the (slug, locale) unique index is a
1490
+ // DB constraint, not advisory). Say so, rather than leave the caller hunting for a
1491
+ // page they cannot see.
1492
+ if (rows[0].deletedAt != null) {
1493
+ throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
1494
+ }
1495
+ throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
1496
+ }
997
1497
  };
998
1498
 
999
1499
  const TASK_PUBLISH = "cms:publish";
@@ -1161,16 +1661,19 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1161
1661
  },
1162
1662
  }),
1163
1663
 
1164
- /** Delete a media row AND its R2 blob. (Automatic orphan sweeping media no longer
1165
- * referenced by any block is future work; refs live inside opaque block JSON.) */
1664
+ /** Trash a media row. The R2 OBJECT IS KEPTdeleting the bytes here would make
1665
+ * `restoreMedia` a lie, and a block still referencing the id would render a dead url
1666
+ * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
1667
+ * the id again, since every ACL-scoped read hides it from here on.
1668
+ *
1669
+ * (Automatic orphan sweeping — media no longer referenced by any block — is still
1670
+ * future work; refs live inside opaque block JSON.) */
1166
1671
  deleteMedia: mutation(async (ctx, input: { id: string }) => {
1167
1672
  const db = cdb(ctx);
1168
1673
  const rows = await db.find({ from: "cms_media", where: { id: input.id }, limit: 1 });
1169
1674
  const media = rows[0];
1170
1675
  if (!media) throw notFound("media");
1171
- const key = String(asObj(media.file).key ?? "");
1172
- await db.delete("cms_media", input.id);
1173
- if (key) await ctx.files.delete(key).catch(() => {});
1676
+ await db.update("cms_media", input.id, { deletedAt: new Date().toISOString() });
1174
1677
  return { ok: true };
1175
1678
  }, {
1176
1679
  ...editor,
@@ -1181,6 +1684,32 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1181
1684
  },
1182
1685
  }),
1183
1686
 
1687
+ restoreMedia: mutation(async (ctx, input: { id: string }) => {
1688
+ const db = cdb(ctx);
1689
+ const rows = await db.exec("SELECT id FROM cms_media WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.id);
1690
+ if (!rows[0]) throw notFound("trashed media");
1691
+ await db.exec("UPDATE cms_media SET deletedAt = NULL WHERE id = ?", input.id);
1692
+ markChanged(db, "cms_media");
1693
+ return { ok: true as const };
1694
+ }, { ...editor, ...mediaIdInput }),
1695
+
1696
+ /** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
1697
+ * irreversible; the blob is gone. */
1698
+ purgeMedia: mutation(async (ctx, input: { id: string }) => {
1699
+ const db = cdb(ctx);
1700
+ const rows = await db.exec("SELECT id, file FROM cms_media WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.id);
1701
+ const media = rows[0];
1702
+ if (!media) throw notFound("trashed media"); // purging live media is refused — trash it first
1703
+ // `file` comes back raw from exec (the object↔JSON codec sits on the ORM path, not
1704
+ // this one), so parse it before reaching for the key.
1705
+ const file = typeof media.file === "string" ? (JSON.parse(media.file) as { key?: string }) : asObj(media.file);
1706
+ const key = String(file.key ?? "");
1707
+ await db.exec("DELETE FROM cms_media WHERE id = ?", input.id);
1708
+ markChanged(db, "cms_media");
1709
+ if (key) await ctx.files.delete(key).catch(() => {});
1710
+ return { ok: true as const };
1711
+ }, { ...reviewer, ...mediaIdInput }),
1712
+
1184
1713
  listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
1185
1714
 
1186
1715
  getContentType: query(async (ctx, input: { id: string }) => {
@@ -1212,9 +1741,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1212
1741
  }),
1213
1742
 
1214
1743
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
1215
- updatePageSeo: mutation(async (ctx, input: { pageId: string; metaTitle?: string | null; metaDescription?: string | null; canonicalUrl?: string | null; robots?: string | null; ogTitle?: string | null; ogDescription?: string | null; ogImage?: string | null; structuredData?: unknown }) => {
1744
+ updatePageSeo: mutation(async (ctx, input: { pageId: string; metaTitle?: string | null; metaDescription?: string | null; canonicalUrl?: string | null; robots?: string | null; ogTitle?: string | null; ogDescription?: string | null; ogImage?: string | null; structuredData?: unknown; expectedVersion?: number }) => {
1216
1745
  const db = cdb(ctx);
1217
- const patch: Record<string, unknown> = { updatedAt: nowStamp() };
1746
+ // Read first so the version can be compared; this patched blind before.
1747
+ const seoRows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1748
+ if (!seoRows[0]) throw notFound("page");
1749
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(seoRows[0], input.expectedVersion, "this page") };
1218
1750
  for (const k of ["metaTitle", "metaDescription", "canonicalUrl", "robots", "ogTitle", "ogDescription", "ogImage"] as const) {
1219
1751
  if (k in input) patch[k] = (input as Record<string, unknown>)[k];
1220
1752
  }
@@ -1227,6 +1759,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1227
1759
  input: (raw): { pageId: string } => {
1228
1760
  const o = asObj(raw);
1229
1761
  if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
1762
+ versionInput(o);
1230
1763
  return o as never;
1231
1764
  },
1232
1765
  }),
@@ -1236,13 +1769,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1236
1769
  * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
1237
1770
  * page record itself, which was previously only settable at createPage. A slug/locale
1238
1771
  * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
1239
- * + sanitized against the content type's fieldsSchema, exactly like createPage. */
1240
- updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> }) => {
1772
+ * + normalized against the content type's fieldsSchema, exactly like createPage. */
1773
+ updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues; expectedVersion?: number }) => {
1241
1774
  const db = cdb(ctx);
1242
1775
  const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1243
1776
  const page = rows[0];
1244
1777
  if (!page) throw notFound("page");
1245
- const patch: Record<string, unknown> = { updatedAt: nowStamp() };
1778
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(page, input.expectedVersion, "this page") };
1246
1779
  if (input.title !== undefined) patch.title = input.title;
1247
1780
  if (input.slug !== undefined || input.locale !== undefined) {
1248
1781
  const nextSlug = input.slug ?? String(page.slug);
@@ -1254,32 +1787,34 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1254
1787
  if (input.fields !== undefined) {
1255
1788
  const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
1256
1789
  const schema = ctRows[0]?.fieldsSchema as FieldDefinition[] | undefined;
1257
- validateFields(schema, input.fields, "page.fields", { requireRequired: false });
1258
- patch.fields = await sanitizeFields(schema, input.fields);
1790
+ // Same whole-bag autosave as updateBlock — tolerate a stored legacy value.
1791
+ validateFields(schema, input.fields, "page.fields", { requireRequired: false, legacyBaseline: asObj(page.fields) as FieldValues });
1792
+ patch.fields = normalizeFields(schema, input.fields, rtSchema);
1259
1793
  }
1260
1794
  const updated = await db.update("cms_pages", input.pageId, patch);
1261
1795
  if (!updated) throw notFound("page");
1262
1796
  return { ok: true, page: updated };
1263
1797
  }, {
1264
1798
  ...editor,
1265
- input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> } => {
1799
+ input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues; expectedVersion?: number } => {
1266
1800
  const o = asObj(raw);
1267
1801
  if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
1268
1802
  for (const k of ["title", "slug", "locale"] as const) {
1269
1803
  if (o[k] !== undefined && typeof o[k] !== "string") throw new BadRequest(`${k} must be a string`);
1270
1804
  }
1805
+ versionInput(o);
1271
1806
  return o as never;
1272
1807
  },
1273
1808
  }),
1274
1809
 
1275
1810
  /** Create a page and auto-scaffold its content type's default blocks. */
1276
- createPage: mutation(async (ctx, input: { typeId: string; title: string; slug: string; locale?: string; fields?: Record<string, unknown> }) => {
1811
+ createPage: mutation(async (ctx, input: { typeId: string; title: string; slug: string; locale?: string; fields?: FieldValues }) => {
1277
1812
  const db = cdb(ctx);
1278
1813
  const ctRows = await db.find({ from: "cms_content_types", where: { id: input.typeId }, limit: 1 });
1279
1814
  const ct = ctRows[0];
1280
1815
  if (!ct) throw new BadRequest("unknown content type");
1281
1816
  validateFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "page.fields", { requireRequired: false });
1282
- const cleanPageFields = await sanitizeFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
1817
+ const cleanPageFields = normalizeFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, rtSchema);
1283
1818
  const locale = input.locale ?? defaultLocale;
1284
1819
  await assertSlugFree(db, input.slug, locale);
1285
1820
 
@@ -1303,7 +1838,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1303
1838
  if (!bts[0]) throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
1304
1839
  await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
1305
1840
  validateFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {}, "", { requireRequired: false });
1306
- const cleanDefault = await sanitizeFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {});
1841
+ const cleanDefault = normalizeFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {}, rtSchema);
1307
1842
  const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
1308
1843
  const position = await nextPosition(db, String(page.id), d.region);
1309
1844
  await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
@@ -1314,7 +1849,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1314
1849
  return page;
1315
1850
  }, {
1316
1851
  ...editor,
1317
- input: (raw): { typeId: string; title: string; slug: string; locale?: string; fields?: Record<string, unknown> } => {
1852
+ input: (raw): { typeId: string; title: string; slug: string; locale?: string; fields?: FieldValues } => {
1318
1853
  const o = asObj(raw);
1319
1854
  if (typeof o.typeId !== "string" || typeof o.title !== "string" || typeof o.slug !== "string") {
1320
1855
  throw new BadRequest("typeId, title and slug are required");
@@ -1341,8 +1876,22 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1341
1876
  group = crypto.randomUUID();
1342
1877
  await db.update("cms_pages", String(src.id), { translationGroupId: group });
1343
1878
  }
1344
- const existing = await db.find({ from: "cms_pages", where: { translationGroupId: group, locale: input.locale }, limit: 1 });
1345
- if (existing[0]) throw new BadRequest(`a '${input.locale}' translation already exists`);
1879
+ // Raw exec, like assertSlugFree: a check-then-act uniqueness guard must see TRASHED
1880
+ // rows too. Through ctx.db the read scope hides them, so trashing a `cs` translation
1881
+ // let a second one be created, and restoring the first left two live `cs` pages in
1882
+ // one group — two <link rel="alternate" hreflang="cs"> on every sibling.
1883
+ const existing = await db.exec(
1884
+ "SELECT id, deletedAt FROM cms_pages WHERE translationGroupId = ? AND locale = ? LIMIT 1",
1885
+ group,
1886
+ input.locale,
1887
+ );
1888
+ if (existing[0]) {
1889
+ throw new BadRequest(
1890
+ existing[0].deletedAt != null
1891
+ ? `a '${input.locale}' translation exists in the trash — restore or purge it first`
1892
+ : `a '${input.locale}' translation already exists`,
1893
+ );
1894
+ }
1346
1895
  const slug = input.slug ?? String(src.slug);
1347
1896
  await assertSlugFree(db, slug, input.locale);
1348
1897
  return db.insert("cms_pages", {
@@ -1385,7 +1934,9 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1385
1934
 
1386
1935
  /** Distinct locales present across all pages. */
1387
1936
  listLocales: query(async (ctx) => {
1388
- const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages ORDER BY locale");
1937
+ // Raw exec bypasses the ACL, so the trash filter has to be written out by hand —
1938
+ // otherwise the editor's locale switcher offers a locale with zero live pages.
1939
+ const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages WHERE deletedAt IS NULL ORDER BY locale");
1389
1940
  return rows.map((r) => String(r.locale ?? "en"));
1390
1941
  }, viewer),
1391
1942
 
@@ -1393,7 +1944,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1393
1944
  /** Create a block instance and place it into a page region in one call (the common
1394
1945
  * editor action). Validates the fields against the block type's schema and the region
1395
1946
  * against the content type's allow-list. */
1396
- addBlock: mutation(async (ctx, input: { pageId: string; blockTypeSlug: string; region: string; fields?: Record<string, unknown>; title?: string; position?: number; isReusable?: boolean }) => {
1947
+ addBlock: mutation(async (ctx, input: { pageId: string; blockTypeSlug: string; region: string; fields?: FieldValues; title?: string; position?: number; isReusable?: boolean }) => {
1397
1948
  const db = cdb(ctx);
1398
1949
  const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1399
1950
  const page = pages[0];
@@ -1401,7 +1952,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1401
1952
  const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
1402
1953
  await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
1403
1954
  validateFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "", { requireRequired: false });
1404
- const cleanFields = await sanitizeFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
1955
+ const cleanFields = normalizeFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, rtSchema);
1405
1956
 
1406
1957
  const block = await db.insert("cms_blocks", {
1407
1958
  typeId: bt.id,
@@ -1420,7 +1971,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1420
1971
  return { block, placement };
1421
1972
  }, {
1422
1973
  ...editor,
1423
- input: (raw): { pageId: string; blockTypeSlug: string; region: string; fields?: Record<string, unknown>; title?: string; position?: number; isReusable?: boolean } => {
1974
+ input: (raw): { pageId: string; blockTypeSlug: string; region: string; fields?: FieldValues; title?: string; position?: number; isReusable?: boolean } => {
1424
1975
  const o = asObj(raw);
1425
1976
  if (typeof o.pageId !== "string" || typeof o.blockTypeSlug !== "string" || typeof o.region !== "string") {
1426
1977
  throw new BadRequest("pageId, blockTypeSlug and region are required");
@@ -1435,7 +1986,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1435
1986
  * can be placed on several pages; editing it updates them all, while `overrides` let one
1436
1987
  * placement diverge. The merged (base + overrides) result is validated against the
1437
1988
  * block type's field schema. */
1438
- placeBlock: mutation(async (ctx, input: { pageId: string; blockId: string; region: string; position?: number; overrides?: Record<string, unknown> }) => {
1989
+ placeBlock: mutation(async (ctx, input: { pageId: string; blockId: string; region: string; position?: number; overrides?: FieldValues }) => {
1439
1990
  const db = cdb(ctx);
1440
1991
  const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1441
1992
  const page = pages[0];
@@ -1448,8 +1999,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1448
1999
  await assertRegionAllows(db, page, input.region, slug);
1449
2000
  let cleanOverrides: Record<string, unknown> | null = input.overrides ?? null;
1450
2001
  if (input.overrides !== undefined) {
1451
- validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
1452
- cleanOverrides = await sanitizeFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides);
2002
+ // The merged bag includes the block's OWN stored fields, which may predate Portable
2003
+ // Text. Tolerate a legacy string there so an untouched legacy block can still be
2004
+ // placed; the overrides themselves are new input and stay strict below.
2005
+ validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false, legacyBaseline: asObj(block.fields) as FieldValues });
2006
+ validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides, "", { requireRequired: false });
2007
+ cleanOverrides = normalizeFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides, rtSchema);
1453
2008
  }
1454
2009
  const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
1455
2010
  return db.insert("cms_page_blocks", {
@@ -1462,7 +2017,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1462
2017
  });
1463
2018
  }, {
1464
2019
  ...editor,
1465
- input: (raw): { pageId: string; blockId: string; region: string; position?: number; overrides?: Record<string, unknown> } => {
2020
+ input: (raw): { pageId: string; blockId: string; region: string; position?: number; overrides?: FieldValues } => {
1466
2021
  const o = asObj(raw);
1467
2022
  if (typeof o.pageId !== "string" || typeof o.blockId !== "string" || typeof o.region !== "string") {
1468
2023
  throw new BadRequest("pageId, blockId and region are required");
@@ -1485,26 +2040,33 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1485
2040
  }),
1486
2041
 
1487
2042
  /** Update a block's content (re-validated against its type's field schema). */
1488
- updateBlock: mutation(async (ctx, input: { blockId: string; fields?: Record<string, unknown>; title?: string }) => {
2043
+ updateBlock: mutation(async (ctx, input: { blockId: string; fields?: FieldValues; title?: string; expectedVersion?: number }) => {
1489
2044
  const db = cdb(ctx);
1490
2045
  const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
1491
2046
  const block = rows[0];
1492
2047
  if (!block) throw notFound("block");
2048
+ // Conflict first, like updatePage: a stale write carrying invalid fields should say
2049
+ // "someone else changed this", not 400 on content the caller is about to discard.
2050
+ const blockVersion = nextVersion(block, input.expectedVersion, "this block");
1493
2051
  let cleanFields = input.fields;
1494
2052
  if (input.fields !== undefined) {
1495
2053
  const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
1496
- validateFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, "", { requireRequired: false });
1497
- cleanFields = await sanitizeFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields);
2054
+ // The editor autosaves the WHOLE fields bag ~800ms after any edit, so a legacy
2055
+ // richtext value the author never touched rides along with an unrelated change.
2056
+ // Rejecting it would 400 on every keystroke and make the block unsaveable.
2057
+ validateFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, "", { requireRequired: false, legacyBaseline: asObj(block.fields) as FieldValues });
2058
+ cleanFields = normalizeFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, rtSchema);
1498
2059
  }
1499
- const patch: Record<string, unknown> = { updatedAt: nowStamp() };
2060
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: blockVersion };
1500
2061
  if (cleanFields !== undefined) patch.fields = cleanFields;
1501
2062
  if (input.title !== undefined) patch.title = input.title;
1502
2063
  return db.update("cms_blocks", input.blockId, patch);
1503
2064
  }, {
1504
2065
  ...editor,
1505
- input: (raw): { blockId: string; fields?: Record<string, unknown>; title?: string } => {
2066
+ input: (raw): { blockId: string; fields?: FieldValues; title?: string; expectedVersion?: number } => {
1506
2067
  const o = asObj(raw);
1507
2068
  if (typeof o.blockId !== "string") throw new BadRequest("blockId is required");
2069
+ versionInput(o);
1508
2070
  return o as never;
1509
2071
  },
1510
2072
  }),
@@ -1720,10 +2282,175 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1720
2282
  }),
1721
2283
 
1722
2284
  // ---- public content API ----
2285
+ /** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
2286
+ * anyone holding the resulting link can redeem it, which is the point. */
2287
+ signPagePreview: query(async (ctx, input: { pageId: string; expiresIn?: number }) => {
2288
+ const secret = previewSecret(ctx.env);
2289
+ if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
2290
+ // The redeem route always reaches a Durable Object (callPrivileged -> PRAMEN.get); it
2291
+ // has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
2292
+ // link that 404s forever while the editor reports success — refuse instead of
2293
+ // handing out a token that cannot work.
2294
+ if (ctx.store === "d1") throw new PramenError("page preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
2295
+ const db = cdb(ctx);
2296
+ // Read the page through the ACL first: minting a link is granting access to it, so a
2297
+ // caller who cannot read the page must not be able to mint a link that can.
2298
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
2299
+ const page = rows[0];
2300
+ if (!page) throw notFound("page");
2301
+
2302
+ const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
2303
+ const exp = Math.floor(Date.now() / 1000) + ttl;
2304
+ // Server-resolved, never caller-supplied — so the tenant inside the signature
2305
+ // cannot be steered by whoever asks for the link.
2306
+ const tenant = ctx.tenant;
2307
+ const token = await signToken<PreviewToken>({ t: tenant, p: String(page.id), exp }, secret);
2308
+ // RELATIVE, like signed file urls — the client resolves it against the CMS origin.
2309
+ return { url: `${PREVIEW_PATH}?token=${encodeURIComponent(token)}`, token, expiresAt: exp * 1000 };
2310
+ }, {
2311
+ ...editor,
2312
+ input: (raw): { pageId: string; expiresIn?: number } => {
2313
+ const o = asObj(raw);
2314
+ // Unvalidated, a non-string pageId reached the query compiler and surfaced as a
2315
+ // 500, and a string expiresIn made exp NaN — minting a link that always 403s,
2316
+ // with nothing anywhere to explain why.
2317
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
2318
+ if (o.expiresIn !== undefined && (typeof o.expiresIn !== "number" || !Number.isFinite(o.expiresIn))) {
2319
+ throw new BadRequest("expiresIn must be a number of seconds");
2320
+ }
2321
+ return o as never;
2322
+ },
2323
+ }),
2324
+
2325
+ /** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
2326
+ * `GET /cms/preview` route, which verifies the token and then calls this privileged.
2327
+ * Role-gated so it is not an anonymous back door on the /rpc surface. */
2328
+ getPagePreview: query(async (ctx, input: { pageId: string }) => {
2329
+ const db = cdb(ctx);
2330
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
2331
+ const page = rows[0];
2332
+ if (!page) throw notFound("page");
2333
+ const assembled = await assembleLive(db, page);
2334
+ assembled.isPreview = true;
2335
+ return assembled;
2336
+ }, {
2337
+ ...viewer,
2338
+ input: (raw): { pageId: string } => {
2339
+ const o = asObj(raw);
2340
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
2341
+ return { pageId: o.pageId };
2342
+ },
2343
+ }),
2344
+
1723
2345
  /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
1724
2346
  * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
1725
2347
  * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
1726
2348
  * to the configured default locale; a slug is unique per locale. */
2349
+ // --- trash: soft delete, restore, purge ---------------------------------
2350
+ //
2351
+ // A page had NO delete handler at all before this: once created it could only be
2352
+ // unpublished, never removed. Delete is therefore introduced already soft — the row
2353
+ // stays, `deletedAt` is stamped, and the ACL's read scope hides it everywhere.
2354
+ //
2355
+ // A trashed page KEEPS ITS SLUG. `(slug, locale)` is a DB unique constraint, so the
2356
+ // alternatives were mangling the stored slug on delete or dropping the constraint —
2357
+ // both worse than telling the caller plainly that the slug is in the trash. Purging
2358
+ // frees it.
2359
+
2360
+ deletePage: mutation(async (ctx, input: { pageId: string }) => {
2361
+ const db = cdb(ctx);
2362
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
2363
+ if (!rows[0]) throw notFound("page"); // already trashed reads as absent — the scope hides it
2364
+ const now = new Date().toISOString();
2365
+ // Clear the schedule. The publish/unpublish tasks run on the SYSTEM task context,
2366
+ // where the ACL is bypassed entirely — so the `deletedAt IS NULL` read scope does
2367
+ // NOT protect them, and a page trashed before its scheduled time was republished,
2368
+ // publicly live, with a fresh revision and nobody pressing publish. Clearing the
2369
+ // timestamps makes the tasks' existing intent-token check reject both for free.
2370
+ await db.update("cms_pages", input.pageId, { deletedAt: now, updatedAt: now, scheduledAt: null, unpublishAt: null });
2371
+ await writeAudit(db, { pageId: input.pageId, action: "delete", from: String(rows[0].status ?? ""), to: "trashed", actor: actorOf(ctx) });
2372
+ return { ok: true as const, deletedAt: now };
2373
+ }, { ...editor, ...pageIdInput }),
2374
+
2375
+ /** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
2376
+ * the ACL read scope hides exactly these rows: that is the scope doing its job, not a
2377
+ * hole to patch.
2378
+ *
2379
+ * Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
2380
+ * `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
2381
+ * could ever be called with its id again, while `/media/<key>` kept serving the bytes
2382
+ * (that route streams from R2 with no DB lookup at all). */
2383
+ listTrash: query(async (ctx, input: { limit?: number }) => {
2384
+ // Truncate like listMedia/listPageAudit — a fractional LIMIT reaches SQLite and 500s,
2385
+ // and any client computing `total / pages` sends one.
2386
+ const limit = Math.min(Math.max(Math.trunc(Number(input.limit)) || 50, 1), 200);
2387
+ const db = cdb(ctx);
2388
+ const pages = await db.exec(
2389
+ "SELECT id, title, slug, locale, status, deletedAt FROM cms_pages WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC LIMIT ?",
2390
+ limit,
2391
+ );
2392
+ const rawMedia = await db.exec(
2393
+ "SELECT id, alt, file, deletedAt FROM cms_media WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC LIMIT ?",
2394
+ limit,
2395
+ );
2396
+ // The fileRef object<->JSON codec sits on the ORM path, not raw exec — parse here or
2397
+ // a trash UI reusing the media card renders `/media/undefined`.
2398
+ const media = rawMedia.map((m) => ({ ...m, file: typeof m.file === "string" ? (JSON.parse(m.file) as JsonValue) : m.file }));
2399
+ return { pages, media };
2400
+ }, { ...viewer, input: (raw): { limit?: number } => {
2401
+ const o = asObj(raw);
2402
+ if (o.limit !== undefined && typeof o.limit !== "number") throw new BadRequest("limit must be a number");
2403
+ return o as never;
2404
+ } }),
2405
+
2406
+ restorePage: mutation(async (ctx, input: { pageId: string }) => {
2407
+ const db = cdb(ctx);
2408
+ const rows = await db.exec("SELECT id, slug, locale, status, scheduledAt, unpublishAt FROM cms_pages WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.pageId);
2409
+ const page = rows[0];
2410
+ if (!page) throw notFound("trashed page");
2411
+ // Defensive: the trashed row still occupies the (slug, locale) unique index, so in
2412
+ // practice nothing can have taken the slug. Kept so a future change that DOES free
2413
+ // the slug on delete surfaces as a clean 400 rather than a constraint violation.
2414
+ await assertSlugFree(db, String(page.slug), String(page.locale), String(page.id));
2415
+ await db.exec("UPDATE cms_pages SET deletedAt = NULL, updatedAt = ? WHERE id = ?", new Date().toISOString(), input.pageId);
2416
+ markChanged(db, "cms_pages");
2417
+ await writeAudit(db, { pageId: input.pageId, action: "restore", from: "trashed", to: String(page.status ?? ""), actor: actorOf(ctx) });
2418
+ // deletePage had to clear any schedule (the publish task runs SYSTEM-scoped, outside
2419
+ // the read scope). Restore cannot know what it was, so SAY so — otherwise a promo
2420
+ // page due to auto-unpublish comes back live forever with nothing in the audit trail.
2421
+ return { ok: true as const, scheduleCleared: page.scheduledAt != null || page.unpublishAt != null };
2422
+ }, { ...editor, ...pageIdInput }),
2423
+
2424
+ /** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
2425
+ * this is the only irreversible operation in the CMS. */
2426
+ purgePage: mutation(async (ctx, input: { pageId: string }) => {
2427
+ const db = cdb(ctx);
2428
+ const rows = await db.exec("SELECT id FROM cms_pages WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.pageId);
2429
+ if (!rows[0]) throw notFound("trashed page"); // purging a LIVE page is refused — trash it first
2430
+ // Placements, revisions and audit rows are logical relations (no FK cascade), so
2431
+ // clear them explicitly or they outlive the page as orphans.
2432
+ //
2433
+ // The BLOCKS themselves need the same treatment, and it has to happen before the
2434
+ // placements go: a non-reusable block used only by this page becomes unreachable
2435
+ // once its last placement is deleted (there is no listBlocks, and removeBlock needs
2436
+ // a pageBlockId that no longer exists). Mirrors removeBlock's own GC.
2437
+ const doomed = await db.exec(
2438
+ `SELECT b.id AS id FROM cms_blocks b
2439
+ JOIN cms_page_blocks pb ON pb.blockId = b.id
2440
+ WHERE pb.pageId = ? AND b.isReusable = 0
2441
+ AND NOT EXISTS (SELECT 1 FROM cms_page_blocks o WHERE o.blockId = b.id AND o.pageId <> ?)`,
2442
+ input.pageId,
2443
+ input.pageId,
2444
+ );
2445
+ await db.exec("DELETE FROM cms_page_blocks WHERE pageId = ?", input.pageId);
2446
+ for (const row of doomed) await db.exec("DELETE FROM cms_blocks WHERE id = ?", String(row.id));
2447
+ await db.exec("DELETE FROM cms_page_revisions WHERE pageId = ?", input.pageId);
2448
+ await db.exec("DELETE FROM cms_audit WHERE pageId = ?", input.pageId);
2449
+ await db.exec("DELETE FROM cms_pages WHERE id = ?", input.pageId);
2450
+ markChanged(db, "cms_pages", "cms_page_blocks", "cms_blocks", "cms_page_revisions", "cms_audit");
2451
+ return { ok: true as const };
2452
+ }, { ...reviewer, ...pageIdInput }),
2453
+
1727
2454
  getPage: query(async (ctx, input: { slug: string; locale?: string; preview?: boolean }) => {
1728
2455
  const db = cdb(ctx);
1729
2456
  // Preview is an editor capability — gate it before the lookup so a non-editor gets a
@@ -1734,7 +2461,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1734
2461
  const page = rows[0];
1735
2462
  if (!page) throw notFound("page"); // also the anonymous-vs-draft case: ACL yields no row
1736
2463
 
1737
- if (input.preview) return assembleLive(db, page);
2464
+ if (input.preview) {
2465
+ const live = await assembleLive(db, page);
2466
+ live.isPreview = true; // same flag the token route sets, so a banner works either way
2467
+ return live;
2468
+ }
1738
2469
  // Public path: serve the page's current published revision snapshot (selected by the
1739
2470
  // page's `currentRevisionId` pointer — deterministic, unlike ordering by a
1740
2471
  // second-precision timestamp). We do NOT assemble live here: anonymous has no read
@@ -1753,6 +2484,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1753
2484
  // those keys, but AssembledPage now types them as present — backfill from the live
1754
2485
  // page row so a frontend head template never hits `page.seo` === undefined.
1755
2486
  if (!snap.page.seo) snap.page.seo = pageMeta(page).seo;
2487
+ // `version` from the LIVE row, never the snapshot: a snapshot is baked at publish
2488
+ // time, so a client echoing it would 409 forever after the first draft edit — and
2489
+ // a pre-`version` snapshot has none at all, which (typed `number`) silently drops
2490
+ // out of the request body and reverts to the last-write-wins this feature removes.
2491
+ snap.page.version = typeof page.version === "number" ? page.version : 1;
1756
2492
  if (snap.page.translationGroupId === undefined) snap.page.translationGroupId = (page.translationGroupId as string | null) ?? null;
1757
2493
  return snap;
1758
2494
  }
@@ -1790,11 +2526,31 @@ export interface CmsPolicyOpts {
1790
2526
  * `editor` grants full CRUD across every cms_ table. */
1791
2527
  export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; editor: Policy[] } {
1792
2528
  const p = opts.prefix ?? "cms";
2529
+ // `cms_collection_revisions` is deliberately NOT here: it is append-only, and this loop
2530
+ // grants update AND delete. Spreading both fragments (which every wiring in the README
2531
+ // does) would otherwise hand every editor the ability to rewrite or purge history through
2532
+ // any app handler, silently overriding the read+create grant `collectionPolicies` emits —
2533
+ // duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
2534
+ // The collection half owns that table's grant; see `collectionPolicies`.
1793
2535
  const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"] as const;
2536
+ // Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
2537
+ // AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
2538
+ // API, the editor, relation traversals and eager-loads at once — where a per-handler
2539
+ // `where` would have to be remembered at ~40 call sites and would be wrong the first
2540
+ // time someone forgot. The trash itself is read with `ctx.db.exec` (below), which is the
2541
+ // documented raw escape hatch and deliberately outside this scope.
2542
+ const notTrashed = { where: { deletedAt: { isNull: true } } };
2543
+ const softDeleted: Record<string, true> = { cms_pages: true, cms_media: true };
1794
2544
  const editorPolicies: Policy[] = [];
1795
2545
  for (const table of tables) {
1796
2546
  for (const action of ["read", "create", "update", "delete"] as const) {
1797
- editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, allow()));
2547
+ // UPDATE is scoped as well as READ. Handlers that read the row first already 404 on
2548
+ // a trashed page, but `updatePageSeo`/`updateMedia` patched blind — so an editor with
2549
+ // a stale tab could mutate a page a colleague had just trashed, and the write echo
2550
+ // handed back the whole hidden row. Scoping the grant covers every future write
2551
+ // handler too, rather than relying on each one remembering to read first.
2552
+ const scoped = (action === "read" || action === "update") && softDeleted[table];
2553
+ editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, scoped ? notTrashed : allow()));
1798
2554
  }
1799
2555
  }
1800
2556
  return {
@@ -1804,14 +2560,18 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
1804
2560
  // can route/render by type. Slugs/names are structural, not sensitive.
1805
2561
  policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
1806
2562
  // Only published pages are readable; the snapshot carries the content.
1807
- policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
2563
+ policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published", deletedAt: { isNull: true } } }),
1808
2564
  // getPage reads the latest revision snapshot. Scope the grant by the revision's
1809
2565
  // PAGE being currently published (a relation-traversal where, compiled to a
1810
2566
  // subquery), so a revision of a later-unpublished/archived page is never publicly
1811
2567
  // readable — least-privilege even for a future revision-listing handler.
1812
- policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published" } } }),
2568
+ // `deletedAt` as well as `status`: getPage 404s on the page lookup first today, so
2569
+ // this is defense in depth — but a revision snapshot is a BAKED copy of the page's
2570
+ // content, and a future revision-listing handler reading it directly would otherwise
2571
+ // serve a trashed page's body.
2572
+ policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
1813
2573
  // Media metadata is public (the bytes are separately gated by signed urls).
1814
- policy(`${p}:public:media:read`, "cms_media", "read", allow()),
2574
+ policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
1815
2575
  ],
1816
2576
  editor: editorPolicies,
1817
2577
  };
@@ -1827,8 +2587,9 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
1827
2587
  // list + form UI, without being bent into a cms_pages row.
1828
2588
  //
1829
2589
  // Column-mapped: each scalar FieldDefinition.name is a real column on the entity; a
1830
- // repeater/group field maps to a t.json() column (the object↔JSON codec at the Db
1831
- // chokepoint stores it transparently). The generic handlers dispatch through a registry
2590
+ // repeater/group/richtext field maps to a t.json() column (the object↔JSON codec at the
2591
+ // Db chokepoint stores it transparently). `richtext` belongs with the latter group — its
2592
+ // value is a document tree, and a TEXT column would bind the object raw and be rejected. The generic handlers dispatch through a registry
1832
2593
  // keyed by `slug`, so `collection`/`entity` can never be spoofed to reach an arbitrary
1833
2594
  // table, and writes are whitelisted to declared fields — the client can't set columns the
1834
2595
  // collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
@@ -1861,6 +2622,11 @@ export interface CollectionDef {
1861
2622
  readonly idField?: string;
1862
2623
  /** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
1863
2624
  readonly orderBy?: { column: string; dir?: "asc" | "desc" };
2625
+ /** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
2626
+ * backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
2627
+ * Validated against your schema at `createCollectionHandlers` time (which is why that call
2628
+ * needs `{ schema }` once this is set). Absent = a plain CRUD collection, as before. */
2629
+ readonly supports?: readonly CollectionFeature[];
1864
2630
  }
1865
2631
 
1866
2632
  /** Declare a collection. Spread the results into `createCollectionHandlers` +
@@ -1894,6 +2660,9 @@ export interface CollectionMeta {
1894
2660
  titleField: string;
1895
2661
  idField: string;
1896
2662
  orderBy?: { column: string; dir?: "asc" | "desc" };
2663
+ /** Workflow features enabled — the editor uses this to decide which affordances to show
2664
+ * (a Publish button, a schedule picker, a revisions tab). Empty = plain CRUD. */
2665
+ supports: readonly CollectionFeature[];
1897
2666
  }
1898
2667
 
1899
2668
  /** The public view of a collection def (defaults filled). */
@@ -1909,21 +2678,356 @@ function collectionMeta(c: CollectionDef): CollectionMeta {
1909
2678
  titleField,
1910
2679
  idField: c.idField ?? "id",
1911
2680
  orderBy: c.orderBy,
2681
+ supports: c.supports ?? [],
1912
2682
  };
1913
2683
  }
1914
2684
 
2685
+
2686
+ // --- collection workflow features (`supports`) -------------------------------
2687
+ //
2688
+ // A collection may opt into page-style workflow with `supports: ["drafts", ...]`. Each
2689
+ // feature is backed by MANAGED COLUMNS on the collection's OWN entity: the app declares the
2690
+ // columns, the CMS owns their values.
2691
+ //
2692
+ // That ownership is the whole difference from the `publish` FIELD type this replaces. A
2693
+ // `publish` field is an ordinary entry in `fields`, and `fields` is the write whitelist —
2694
+ // so "is this row live?" was a value the client sent in the `values` bag, and the access
2695
+ // boundary was whatever the client last wrote. A managed column is never in the whitelist
2696
+ // (declaring one as a field is a boot error, see `validateCollections`), so only
2697
+ // `collectionPublish` / `collectionSchedule` / the scheduled tasks can move a row live.
2698
+ //
2699
+ // TIMESTAMP FORMAT. Managed timestamps are minted as ISO-8601 UTC with a `Z`
2700
+ // (`2026-08-20T12:00:00.000Z`), in exactly one place (`isoStamp`). That is the format
2701
+ // `$now()` produces, and the published-read scope compares against it LEXICOGRAPHICALLY.
2702
+ // It is deliberately NOT `nowStamp()` (`expr.now()`'s "YYYY-MM-DD HH:MM:SS"), which sorts
2703
+ // against the ISO form as if hours apart. Minting in one place is what closes the trap
2704
+ // documented on the `publish` field: there, `publish` and `datetime` wrote different
2705
+ // formats into the same TEXT column and both passed validation.
2706
+
2707
+ /** A workflow feature a collection can opt into.
2708
+ *
2709
+ * - `drafts` — a managed `status` column (`draft` | `published`) plus `collectionPublish` /
2710
+ * `collectionUnpublish`. Pair with `collectionPublicPolicies` so anonymous reads see
2711
+ * published rows only.
2712
+ *
2713
+ * This gates VISIBILITY, not content. A collection is column-mapped — the public reads the
2714
+ * entity's own columns — so there is nowhere to stage an unpublished VERSION of a live
2715
+ * row: an edit (or a revision restore) on a published row is live immediately. That is the
2716
+ * one place collections do not reach page parity, where `getPage` serves a baked revision
2717
+ * snapshot. Unpublish first if an edit needs review.
2718
+ * - `scheduling` — managed `publishedAt` / `scheduledAt` / `unpublishAt`, `collectionSchedule`,
2719
+ * and the deferred tasks from `createCollectionTasks`. Needs `drafts`.
2720
+ * - `revisions` — a snapshot of the row's prior state on every write, in
2721
+ * `cms_collection_revisions`, with `collectionListRevisions` / `collectionRestoreRevision`.
2722
+ * - `preview` — signed, single-row preview links (`signCollectionPreview`), redeemed at
2723
+ * `COLLECTION_PREVIEW_PATH` by the route `cmsRoutes()` serves. Needs `drafts`. It shows
2724
+ * the row's CURRENT state to whoever holds the link, which for a DRAFT is the unpublished
2725
+ * content and for a published row is what the public already sees (see `drafts` above:
2726
+ * there is no separate staged version to show). */
2727
+ export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
2728
+
2729
+ export const COLLECTION_FEATURES: readonly CollectionFeature[] = ["drafts", "scheduling", "revisions", "preview"];
2730
+
2731
+ /** The columns each feature needs on the collection's entity. The app declares them (they
2732
+ * are its own entity); the CMS writes them and `fields` may not. */
2733
+ export const COLLECTION_FEATURE_COLUMNS: Readonly<Record<CollectionFeature, readonly string[]>> = {
2734
+ drafts: ["status"],
2735
+ scheduling: ["publishedAt", "scheduledAt", "unpublishAt"],
2736
+ revisions: [],
2737
+ preview: [],
2738
+ };
2739
+
2740
+ /** Features that mean nothing on their own. Scheduling moves a row between draft and
2741
+ * published; preview shows the unpublished version — both presuppose `drafts`. */
2742
+ const COLLECTION_FEATURE_REQUIRES: Readonly<Partial<Record<CollectionFeature, CollectionFeature>>> = {
2743
+ scheduling: "drafts",
2744
+ preview: "drafts",
2745
+ };
2746
+
2747
+ /** The shared revision table for collections (see `cmsSchema`). */
2748
+ export const COLLECTION_REVISIONS_TABLE = "cms_collection_revisions";
2749
+
2750
+ /** The two `status` values a `drafts` collection uses. */
2751
+ export const COLLECTION_DRAFT = "draft";
2752
+ export const COLLECTION_PUBLISHED = "published";
2753
+
2754
+ /** `collectionList` page size when the caller names none, and the ceiling it is clamped to.
2755
+ * The cap is the point: an unbounded list of a wide entity is the D1-over-RPC failure mode
2756
+ * (GitHub #22), and `LIMIT -1` is SQLite for "no limit". */
2757
+ const DEFAULT_COLLECTION_LIST_LIMIT = 100;
2758
+ const MAX_COLLECTION_LIST_LIMIT = 500;
2759
+
2760
+ /** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
2761
+ * in, so it compares correctly against `$now()`. See the note above on why this is not
2762
+ * `nowStamp()`. */
2763
+ const isoStamp = (): string => new Date().toISOString();
2764
+
2765
+ /** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
2766
+ *
2767
+ * `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
2768
+ * `Date`) `toISOString()` throws a `RangeError` INSIDE the mutation — an opaque 500 for the
2769
+ * common client slip of sending epoch microseconds. And from year 10000 up, `toISOString()`
2770
+ * mints an EXPANDED-year string (`"+010000-01-01T00:00:00.000Z"`) whose leading `+` sorts
2771
+ * BEFORE every ordinary timestamp — inverting every lexicographic comparison this feature
2772
+ * rests on, so a takedown 8000 years out reads as already passed and a publish instant in
2773
+ * the far future reads as due. One range check closes both. */
2774
+ const MIN_SCHEDULE_MS = 0;
2775
+ const MAX_SCHEDULE_MS = 253402300799999; // 9999-12-31T23:59:59.999Z
2776
+
2777
+ const epochInput = (name: string, v: unknown): number => {
2778
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new BadRequest(`${name} must be a finite epoch ms`);
2779
+ if (!Number.isInteger(v)) throw new BadRequest(`${name} must be a whole number of epoch ms`);
2780
+ if (v < MIN_SCHEDULE_MS || v > MAX_SCHEDULE_MS) {
2781
+ throw new BadRequest(`${name} must be an epoch ms between ${MIN_SCHEDULE_MS} and ${MAX_SCHEDULE_MS} (1970 … 9999) — got ${v}`);
2782
+ }
2783
+ return v;
2784
+ };
2785
+
2786
+ /** The column types a declared field can be stored in. A collection field is COLUMN-MAPPED,
2787
+ * so the entity's column type has to match what the field writes: a `richtext`/`group`/
2788
+ * `repeater` value is a document (`t.json()`), the rest are scalars. Getting this wrong is
2789
+ * not a type error anywhere — it surfaces as a raw driver message on the first write
2790
+ * ("Binding expected string, TypedArray, …"), which is why it is checked at boot. */
2791
+ const COLLECTION_FIELD_COLUMN_TYPES: Readonly<Record<FieldDefinition["type"], readonly FieldType[]>> = {
2792
+ text: ["text", "uuid"],
2793
+ textarea: ["text"],
2794
+ richtext: ["json"],
2795
+ url: ["text"],
2796
+ number: ["integer", "real"],
2797
+ boolean: ["boolean", "integer"],
2798
+ date: ["text"],
2799
+ datetime: ["text"],
2800
+ publish: ["text"],
2801
+ slug: ["text"],
2802
+ media: ["text", "uuid"],
2803
+ select: ["text"],
2804
+ repeater: ["json"],
2805
+ group: ["json"],
2806
+ };
2807
+
2808
+ /** Check a collection registry at BOOT: slugs and entities are unique, features are known
2809
+ * and have their prerequisites, every declared field maps to a column that can hold it, and
2810
+ * every managed column exists, has the shape the CMS writes, and is not also an editable
2811
+ * field.
2812
+ *
2813
+ * Called by `createCollectionHandlers`. The point is that a misconfiguration surfaces when
2814
+ * the Worker starts, naming the collection and the column — not as a 500 the first time an
2815
+ * editor presses Publish, months later, on the one collection nobody exercised.
2816
+ *
2817
+ * `schema` is REQUIRED. Every check here reads the target entity, so a registry validated
2818
+ * without one is not validated at all — and the failures it catches (a field name typo, a
2819
+ * richtext field over a TEXT column, a non-PK idField) are exactly as fatal on a collection
2820
+ * that declares no `supports` as on one that declares all four. */
2821
+ export function validateCollections(collections: readonly CollectionDef[], schema?: SchemaDef): void {
2822
+ const seen = new Set<string>();
2823
+ const byEntity = new Map<string, string>();
2824
+ for (const c of collections) {
2825
+ if (seen.has(c.slug)) throw new Error(`pramen/cms: duplicate collection slug '${c.slug}' — slugs are the handler registry's key`);
2826
+ seen.add(c.slug);
2827
+ // ONE collection per entity. The ACL keys policies by (role, entity, action) and
2828
+ // OR-merges the matches — the policy NAME is not part of the key — so a second
2829
+ // collection over the same entity does not add a second, separate view: it WIDENS the
2830
+ // first one's read scope. Two `collectionPublicPolicies` grants over one entity collapse
2831
+ // to the loosest of the two, which is how a `drafts`-only collection silently removes
2832
+ // the `publishedAt <= $now()` and `unpublishAt > $now()` clauses from a `scheduling`
2833
+ // sibling — publishing a row a year early and defeating its scheduled takedown.
2834
+ const first = byEntity.get(c.entity);
2835
+ if (first) {
2836
+ throw new Error(
2837
+ `pramen/cms: collections '${first}' and '${c.slug}' both target entity '${c.entity}' — the ACL OR-merges policies on the same (role, entity, action), so a second collection widens the first one's read scope instead of adding a separate view. Register one collection per entity.`,
2838
+ );
2839
+ }
2840
+ byEntity.set(c.entity, c.slug);
2841
+ }
2842
+ if (collections.length === 0) return;
2843
+ if (!schema) {
2844
+ throw new Error(
2845
+ `pramen/cms: createCollectionHandlers needs your schema to check the registry against your entities: createCollectionHandlers(collections, { schema })`,
2846
+ );
2847
+ }
2848
+ for (const c of collections) {
2849
+ const features = c.supports ?? [];
2850
+ const set = new Set<CollectionFeature>(features);
2851
+ for (const f of features) {
2852
+ if (!COLLECTION_FEATURES.includes(f)) {
2853
+ throw new Error(`pramen/cms: collection '${c.slug}' declares unknown feature '${String(f)}' (known: ${COLLECTION_FEATURES.join(", ")})`);
2854
+ }
2855
+ const needs = COLLECTION_FEATURE_REQUIRES[f];
2856
+ if (needs && !set.has(needs)) {
2857
+ throw new Error(`pramen/cms: collection '${c.slug}' declares '${f}', which needs '${needs}' — add it to \`supports\``);
2858
+ }
2859
+ }
2860
+ const entity = schema[c.entity];
2861
+ if (!entity) throw new Error(`pramen/cms: collection '${c.slug}' targets entity '${c.entity}', which is not in the schema`);
2862
+ const columns = entity.fields as Record<string, FieldDef>;
2863
+ // EVERY collection handler dispatches to the default partition's DO: none of them
2864
+ // declares a `partition`, and `/rpc` routes by the handler's. An entity parked in
2865
+ // another partition therefore boots clean and then 400s on every single call
2866
+ // (`assertInPartition`), and a preview link 404s forever — `callPrivileged` has no
2867
+ // partition to pass either. Name it here instead.
2868
+ const entityPartition = partitionOf(schema, c.entity);
2869
+ if (entityPartition !== DEFAULT_PARTITION) {
2870
+ throw new Error(
2871
+ `pramen/cms: collection '${c.slug}' targets entity '${c.entity}' in partition '${entityPartition}', but the collection handlers are dispatched to the '${DEFAULT_PARTITION}' partition — every call would fail. Keep a collection's entity in the default partition.`,
2872
+ );
2873
+ }
2874
+ const idField = c.idField ?? "id";
2875
+ if (!(idField in columns)) {
2876
+ throw new Error(`pramen/cms: collection '${c.slug}' has idField '${idField}', which is not a column on '${c.entity}'`);
2877
+ }
2878
+ // …and it must be the PRIMARY KEY, not merely a column. Reads key on `idField`, but
2879
+ // `db.update`/`db.delete` key on the entity's actual PK — so a non-PK idField loads a
2880
+ // row fine and then writes nothing, surfacing as a 404 on a row the same handler just
2881
+ // read. Exactly the misconfiguration this validator exists to name.
2882
+ const pk = Object.entries(columns).find(([, f]) => f.primaryKey)?.[0] ?? "id";
2883
+ if (idField !== pk) {
2884
+ throw new Error(
2885
+ `pramen/cms: collection '${c.slug}' has idField '${idField}', but '${c.entity}' has primary key '${pk}' — writes key on the PK, so they would silently match no row`,
2886
+ );
2887
+ }
2888
+ // Declared fields ARE columns on the entity (that is what "column-mapped" means), so a
2889
+ // typo is a write that fails with the driver's own message and no HTTP status, and a
2890
+ // document field over a TEXT column is the trap example/app.ts documents in a comment.
2891
+ // Both are visible right here, with `columns` in hand.
2892
+ for (const f of c.fields) {
2893
+ const col = columns[f.name];
2894
+ if (!col) {
2895
+ throw new Error(
2896
+ `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`,
2897
+ );
2898
+ }
2899
+ const allowed = COLLECTION_FIELD_COLUMN_TYPES[f.type];
2900
+ if (allowed && !allowed.includes(col.type)) {
2901
+ const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
2902
+ throw new Error(
2903
+ `pramen/cms: collection '${c.slug}' declares '${f.name}' as '${f.type}', which is stored as ${allowed.join("/")}, but '${c.entity}.${f.name}' is ${col.type} — declare it as ${want}`,
2904
+ );
2905
+ }
2906
+ if (col.hidden) {
2907
+ throw new Error(
2908
+ `pramen/cms: collection '${c.slug}' declares '${f.name}' as an editable field, but '${c.entity}.${f.name}' is hidden() — a hidden column is stripped from every read, so the editor would show it empty and overwrite it on every save`,
2909
+ );
2910
+ }
2911
+ }
2912
+ // A declared `orderBy` fails SILENTLY when the column does not exist: the dialect
2913
+ // double-quotes the name and SQLite resolves an unknown quoted identifier to a string
2914
+ // CONSTANT, so every row sorts equal and the list comes back in arbitrary storage order
2915
+ // with no error anywhere.
2916
+ if (c.orderBy && !(c.orderBy.column in columns)) {
2917
+ throw new Error(
2918
+ `pramen/cms: collection '${c.slug}' orders by '${c.orderBy.column}', which is not a column on '${c.entity}' — SQLite would resolve the quoted name to a constant and sort every row equal`,
2919
+ );
2920
+ }
2921
+ const declared = new Set(c.fields.map((f) => f.name));
2922
+ for (const f of features) {
2923
+ for (const col of COLLECTION_FEATURE_COLUMNS[f]) {
2924
+ const column = columns[col];
2925
+ if (!column) {
2926
+ throw new Error(
2927
+ `pramen/cms: collection '${c.slug}' declares '${f}', which manages a \`${col}\` column on '${c.entity}' — add \`${col}: t.text()\` to the entity`,
2928
+ );
2929
+ }
2930
+ // NAME alone is not enough. The CMS writes these columns as TEXT (an ISO-8601
2931
+ // instant or a status word) and compares them lexicographically in the public read
2932
+ // scope, and every wrong declaration fails SILENTLY rather than loudly:
2933
+ // - `t.json()` stores `"\"published\""` (the Db chokepoint stringifies), so the
2934
+ // policy's `status = 'published'` never matches and the row is invisible
2935
+ // forever while `collectionPublish` echoes success;
2936
+ // - `notNull()` 500s on every create (the managed columns are seeded as NULL);
2937
+ // - `hidden()` strips the column from every read, disabling the spent-takedown
2938
+ // repair and hiding the state from the editor.
2939
+ if (column.type !== "text") {
2940
+ throw new Error(
2941
+ `pramen/cms: collection '${c.slug}' declares '${f}', which manages \`${c.entity}.${col}\` as TEXT, but it is ${column.type} — declare it as \`${col}: t.text()\` (a non-TEXT column compares wrong against $now() and would never match the published scope)`,
2942
+ );
2943
+ }
2944
+ if (column.notNull) {
2945
+ throw new Error(
2946
+ `pramen/cms: collection '${c.slug}' declares '${f}', which manages \`${c.entity}.${col}\`, but the column is notNull() — the CMS seeds and clears it with NULL, so every write would fail. Drop notNull() (a defaultTo() is fine).`,
2947
+ );
2948
+ }
2949
+ if (column.hidden) {
2950
+ throw new Error(
2951
+ `pramen/cms: collection '${c.slug}' declares '${f}', which manages \`${c.entity}.${col}\`, but the column is hidden() — the CMS reads it back to decide the row's state, so it must be projectable`,
2952
+ );
2953
+ }
2954
+ // `fields` IS the write whitelist. A `status` entry there would let any editor send
2955
+ // `values: { status: "published" }` through collectionUpdate and bypass the publish
2956
+ // handler entirely, leaving the gate as decoration over a client-set column.
2957
+ if (declared.has(col)) {
2958
+ throw new Error(
2959
+ `pramen/cms: collection '${c.slug}' declares \`${col}\` as an editable field, but '${f}' manages that column — remove it from \`fields\` (it would be a client-writable publish gate)`,
2960
+ );
2961
+ }
2962
+ }
2963
+ }
2964
+ if (set.has("revisions")) {
2965
+ if (!schema[COLLECTION_REVISIONS_TABLE]) {
2966
+ throw new Error(
2967
+ `pramen/cms: collection '${c.slug}' declares 'revisions', which needs the \`${COLLECTION_REVISIONS_TABLE}\` table — spread \`cmsSchema\` into defineSchema`,
2968
+ );
2969
+ }
2970
+ // A DO cannot write across a partition boundary, so a collection entity parked in its
2971
+ // own partition would 500 on the first snapshot insert (assertInPartition). The
2972
+ // default-partition check above already covers this; keep the specific message for
2973
+ // the case where `cms_collection_revisions` itself was moved.
2974
+ const revPartition = partitionOf(schema, COLLECTION_REVISIONS_TABLE);
2975
+ if (entityPartition !== revPartition) {
2976
+ throw new Error(
2977
+ `pramen/cms: collection '${c.slug}' declares 'revisions', but '${c.entity}' is in partition '${entityPartition}' while \`${COLLECTION_REVISIONS_TABLE}\` is in '${revPartition}' — a write cannot cross partitions, so keep the entity in '${revPartition}'`,
2978
+ );
2979
+ }
2980
+ }
2981
+ }
2982
+ }
2983
+
2984
+ /** A signed grant to preview ONE collection row. Mirrors {@link PreviewToken}. */
2985
+ export interface CollectionPreviewToken {
2986
+ /** tenant */ t: string;
2987
+ /** collection slug */ c: string;
2988
+ /** row id — the grant is scoped to this ONE row, never "all drafts" */ r: string;
2989
+ /** expiry (epoch seconds) */ exp: number;
2990
+ }
2991
+
2992
+ /** Where a collection preview link is redeemed. Served by `cmsRoutes()`. */
2993
+ export const COLLECTION_PREVIEW_PATH = "/cms/preview/collection";
2994
+
2995
+ /** Outbox task kinds behind `collectionSchedule`. Register the handlers with
2996
+ * `app.tasks = { ...cmsTasks, ...createCollectionTasks(collections) }`. */
2997
+ export const TASK_COLLECTION_PUBLISH = "cms:collection:publish";
2998
+ export const TASK_COLLECTION_UNPUBLISH = "cms:collection:unpublish";
2999
+
3000
+ /** Options for `createCollectionHandlers`. */
3001
+ export interface CollectionHandlerOpts extends CmsHandlerOpts {
3002
+ /** Your app's schema (the object you pass to `defineSchema`). REQUIRED: the whole registry
3003
+ * is checked against it at boot by `validateCollections` — managed columns, declared
3004
+ * field ↔ column types, the idField/PK pairing, `orderBy`, the partition — so a
3005
+ * misconfiguration is a startup error naming the collection and the column rather than a
3006
+ * 500 (or a silent wrong answer) on the first call. */
3007
+ schema?: SchemaDef;
3008
+ }
3009
+
1915
3010
  /** Build generic CRUD handlers over the registered collections. Spread into your app's
1916
3011
  * handlers alongside `cmsHandlers`:
1917
3012
  *
1918
- * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
3013
+ * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures], { schema }) };
1919
3014
  *
1920
3015
  * Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
1921
3016
  * `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
1922
3017
  * (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
1923
3018
  * `collectionPolicies` scopes them too). The `collection` param is resolved through the
1924
3019
  * registry — an unknown slug is a 400, never a raw table reference. */
1925
- export function createCollectionHandlers(collections: readonly CollectionDef[], opts: CmsHandlerOpts = {}) {
3020
+ export function createCollectionHandlers(collections: readonly CollectionDef[], opts: CollectionHandlerOpts = {}) {
3021
+ // Boot check, before a single handler is built: unknown/incoherent features and missing
3022
+ // managed columns throw here rather than 500ing on the first publish.
3023
+ validateCollections(collections, opts.schema);
3024
+ const collectionRtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
1926
3025
  const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
3026
+ // Preview redemption presents editorRoles ∪ reviewerRoles (see `viewerRolesOf`), so the
3027
+ // handler the route calls has to accept that set — gating it to `editor` alone would 403
3028
+ // every preview link for a reviewer-only identity. Same wiring as `getPagePreview`.
3029
+ const viewer = { auth: viewerRolesOf(opts) };
3030
+ const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
1927
3031
  const bySlug = new Map(collections.map((c) => [c.slug, c] as const));
1928
3032
  const metas = collections.map(collectionMeta);
1929
3033
  const def = (slug: unknown): CollectionDef => {
@@ -1931,16 +3035,166 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
1931
3035
  if (!c) throw new BadRequest(`unknown collection: ${String(slug)}`);
1932
3036
  return c;
1933
3037
  };
3038
+ /** Narrow a stored snapshot to the columns a caller may read on the collection's entity.
3039
+ * Used by both the history read and the restore write, so "what you can see" and "what you
3040
+ * can put back" are the same set. */
3041
+ const projectSnapshot = (snapshot: unknown, readable: ReadonlySet<string>): Record<string, unknown> => {
3042
+ const obj = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) ? (snapshot as Record<string, unknown>) : {};
3043
+ const out: Record<string, unknown> = {};
3044
+ for (const [k, v] of Object.entries(obj)) if (readable.has(k)) out[k] = v;
3045
+ return out;
3046
+ };
1934
3047
  const idOf = (c: CollectionDef): string => c.idField ?? "id";
3048
+ const has = (c: CollectionDef, f: CollectionFeature): boolean => (c.supports ?? []).includes(f);
3049
+ const columnsOf = (c: CollectionDef): Record<string, FieldDef> => ((opts.schema?.[c.entity]?.fields ?? {}) as Record<string, FieldDef>);
3050
+ /** The list ordering, resolved ONCE against the entity. The documented default is
3051
+ * `createdAt desc`, but that column is not guaranteed to exist — and an ORDER BY over a
3052
+ * missing column does not fail: the dialect quotes the name, SQLite resolves the unknown
3053
+ * quoted identifier to a string CONSTANT, every row sorts equal, and the list comes back
3054
+ * in arbitrary storage order. Fall back to the PK, which always exists, so the order is at
3055
+ * least stable and paging is coherent. (A DECLARED `orderBy` over a missing column is a
3056
+ * boot error — see `validateCollections`.) */
3057
+ const orderByOf = (c: CollectionDef): { column: string; dir: "asc" | "desc" } => {
3058
+ if (c.orderBy) return { column: c.orderBy.column, dir: c.orderBy.dir ?? "desc" };
3059
+ return { column: "createdAt" in columnsOf(c) ? "createdAt" : idOf(c), dir: "desc" };
3060
+ };
3061
+ const orderBys = new Map(collections.map((c) => [c.slug, orderByOf(c)] as const));
3062
+ /** 400 (not 500) when a caller invokes a workflow handler on a collection that never
3063
+ * opted into it — the handlers exist for every collection, the features do not. */
3064
+ const needs = (c: CollectionDef, f: CollectionFeature): void => {
3065
+ if (!has(c, f)) throw new BadRequest(`collection '${c.slug}' does not support '${f}' (add it to \`supports\`)`);
3066
+ };
3067
+ const loadRow = async (db: CmsDb, c: CollectionDef, id: string): Promise<Record<string, unknown>> => {
3068
+ const rows = await db.find({ from: c.entity, where: { [idOf(c)]: id }, limit: 1 });
3069
+ const row = rows[0];
3070
+ if (!row) throw notFound(c.label);
3071
+ return row;
3072
+ };
3073
+ /** Read a row's DECLARED FIELD columns unprojected, for the revision snapshot.
3074
+ *
3075
+ * `ctx.db.exec` is the documented raw escape hatch: it bypasses the row/field ACL, and it
3076
+ * also bypasses the `Db` chokepoint's cell codec — so a json-backed column comes back as
3077
+ * the stored TEXT and a boolean as 0/1. Both are decoded here from the entity's own column
3078
+ * types (checked against the field types at boot), so a snapshot holds exactly what
3079
+ * `db.find` would have returned for an unrestricted caller.
3080
+ *
3081
+ * Falls back to the ACL-projected row if the raw read comes back empty (a substrate quirk
3082
+ * or a row deleted concurrently) — a partial snapshot beats no snapshot. */
3083
+ const rawFieldValues = async (db: CmsDb, c: CollectionDef, rowId: string, projected: Record<string, unknown>): Promise<Record<string, unknown>> => {
3084
+ const columns = columnsOf(c);
3085
+ const names = c.fields.map((f) => f.name).filter((n) => n in columns);
3086
+ if (names.length === 0) return {};
3087
+ const cols = names.map((n) => `"${n}"`).join(", ");
3088
+ // Identifiers, not values: `entity`, `idField` and every field name were checked against
3089
+ // the schema at boot, so nothing caller-supplied is interpolated here. The id IS bound.
3090
+ const rows = (await db.exec(`SELECT ${cols} FROM "${c.entity}" WHERE "${idOf(c)}" = ?`, rowId)) as Array<Record<string, unknown>>;
3091
+ const raw = rows[0];
3092
+ if (!raw) {
3093
+ const fallback: Record<string, unknown> = {};
3094
+ for (const f of c.fields) if (f.name in projected) fallback[f.name] = projected[f.name];
3095
+ return fallback;
3096
+ }
3097
+ const values: Record<string, unknown> = {};
3098
+ for (const name of names) {
3099
+ const v = raw[name];
3100
+ const type = columns[name]?.type;
3101
+ if (v == null) values[name] = null;
3102
+ else if ((type === "json" || type === "fileRef") && typeof v === "string") {
3103
+ try {
3104
+ values[name] = JSON.parse(v) as unknown;
3105
+ } catch {
3106
+ values[name] = v; // not JSON after all — keep the literal rather than losing it
3107
+ }
3108
+ } else if (type === "boolean") values[name] = typeof v === "boolean" ? v : v !== 0 && v !== 0n;
3109
+ else values[name] = v;
3110
+ }
3111
+ return values;
3112
+ };
3113
+ /** Snapshot a row's CURRENT (pre-write) state into `cms_collection_revisions`, so a
3114
+ * revision always reads as "what it was before this edit" and restoring one is a plain
3115
+ * reversal. Declared fields only — the snapshot is replayed through the same write
3116
+ * whitelist on restore, so it can never carry a column the collection doesn't own.
3117
+ * No-op unless the collection supports `revisions`. */
3118
+ const snapshotRow = async (db: CmsDb, c: CollectionDef, row: Record<string, unknown>, ctx: HandlerContext, note: string): Promise<void> => {
3119
+ if (!has(c, "revisions")) return;
3120
+ const rowId = String(row[idOf(c)]);
3121
+ // The row handed in came through the ACL, so it is projected to what THIS caller may
3122
+ // read — which would make history a function of who happened to make the edit: an
3123
+ // editor whose read scope excludes `salary` would silently drop it from the snapshot,
3124
+ // and every later "restore to before that edit" would restore an incomplete row.
3125
+ // History is an audit record, not a view, so capture the row's REAL pre-state through
3126
+ // the raw escape hatch and let the READ path decide who may see which of its fields
3127
+ // (`collectionListRevisions` projects it back down).
3128
+ const values = await rawFieldValues(db, c, rowId, row);
3129
+ // Next in this row's sequence. Serialized by the DO's single writer; on D1 the composite
3130
+ // unique on (collection, rowId, revision) is the backstop — see the schema note.
3131
+ const [{ next = 1 } = {}] = (await db.exec(
3132
+ `SELECT COALESCE(MAX(revision), 0) + 1 AS next FROM ${COLLECTION_REVISIONS_TABLE} WHERE collection = ? AND rowId = ?`,
3133
+ c.slug,
3134
+ rowId,
3135
+ )) as Array<{ next?: number }>;
3136
+ await db.insert(COLLECTION_REVISIONS_TABLE, {
3137
+ collection: c.slug,
3138
+ rowId,
3139
+ revision: next,
3140
+ snapshot: values,
3141
+ note,
3142
+ actor: typeof ctx.identity?.userId === "string" ? ctx.identity.userId : null,
3143
+ // Explicit, ms-precision, and the only writer of this column — see the schema note.
3144
+ createdAt: isoStamp(),
3145
+ });
3146
+ };
3147
+ /** Shared input validator for the `{ collection, id }` handlers. */
3148
+ const rowInput = (raw: unknown): { collection: string; id: string } => {
3149
+ const o = asObj(raw);
3150
+ if (typeof o.collection !== "string" || o.collection === "") throw new BadRequest("collection is required");
3151
+ return { collection: o.collection, id: idInput(raw) };
3152
+ };
3153
+ const collectionInput = (raw: unknown): string => {
3154
+ const o = asObj(raw);
3155
+ if (typeof o.collection !== "string" || o.collection === "") throw new BadRequest("collection is required");
3156
+ return o.collection;
3157
+ };
3158
+ /** `{ collection, values }` — the write handlers. `values` is validated against the field
3159
+ * schema downstream (`toColumns`); this only rejects a non-object, so a string or an array
3160
+ * cannot reach the field validator as a bag of index keys. */
3161
+ const valuesInput = (raw: unknown): { collection: string; values: Record<string, unknown> } => {
3162
+ const o = asObj(raw);
3163
+ const values = o.values;
3164
+ if (values === null || typeof values !== "object" || Array.isArray(values)) throw new BadRequest("values must be an object");
3165
+ return { collection: collectionInput(raw), values: values as Record<string, unknown> };
3166
+ };
3167
+ const rowValuesInput = (raw: unknown): { collection: string; id: string; values: Record<string, unknown> } => ({
3168
+ ...rowInput(raw),
3169
+ values: valuesInput(raw).values,
3170
+ });
3171
+ /** `{ collection, limit?, offset? }`, both CLAMPED. `find` binds `limit` straight into
3172
+ * `LIMIT ?`, and SQLite reads a negative limit as UNBOUNDED — so `limit: -1` dumps the
3173
+ * whole table over RPC — while a fractional value reaches the driver as-is and 500s. */
3174
+ const listInput = (raw: unknown): { collection: string; limit?: number; offset?: number } => {
3175
+ const o = asObj(raw);
3176
+ const num = (name: string, v: unknown): number | undefined => {
3177
+ if (v === undefined || v === null) return undefined;
3178
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new BadRequest(`${name} must be a number`);
3179
+ return Math.floor(v);
3180
+ };
3181
+ const limit = num("limit", o.limit);
3182
+ const offset = num("offset", o.offset);
3183
+ return {
3184
+ collection: collectionInput(raw),
3185
+ limit: limit === undefined ? undefined : Math.max(1, Math.min(limit, MAX_COLLECTION_LIST_LIMIT)),
3186
+ offset: offset === undefined ? undefined : Math.max(0, offset),
3187
+ };
3188
+ };
1935
3189
  // Validate against the field schema, sanitize richtext, then PROJECT to declared field
1936
3190
  // names only — the write whitelist. `requireRequired` is off for updates (partial patch);
1937
3191
  // on for create. Nothing outside `c.fields` can reach the entity.
1938
- const toColumns = (c: CollectionDef, values: unknown, requireRequired: boolean): Record<string, unknown> => {
3192
+ const toColumns = (c: CollectionDef, values: unknown, requireRequired: boolean, legacyBaseline?: FieldValues): Record<string, unknown> => {
1939
3193
  const obj = asObj(values);
1940
- validateFields([...c.fields], obj, "", { requireRequired });
1941
- const sanitized = sanitizeFields([...c.fields], obj);
3194
+ validateFields([...c.fields], obj, "", { requireRequired, legacyBaseline });
3195
+ const normalized = normalizeFields([...c.fields], obj, collectionRtSchema);
1942
3196
  const out: Record<string, unknown> = {};
1943
- for (const f of c.fields) if (f.name in sanitized) out[f.name] = sanitized[f.name];
3197
+ for (const f of c.fields) if (f.name in normalized) out[f.name] = normalized[f.name];
1944
3198
  return out;
1945
3199
  };
1946
3200
  const idInput = (raw: unknown): string => {
@@ -1956,35 +3210,371 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
1956
3210
 
1957
3211
  collectionList: query((ctx, input: { collection: string; limit?: number; offset?: number }) => {
1958
3212
  const c = def(input.collection);
1959
- const limit = typeof input.limit === "number" ? input.limit : 100;
1960
- const offset = typeof input.offset === "number" ? input.offset : undefined;
1961
- return cdb(ctx).find({ from: c.entity, orderBy: c.orderBy ?? { column: "createdAt", dir: "desc" }, limit, offset });
1962
- }, editor),
3213
+ return cdb(ctx).find({
3214
+ from: c.entity,
3215
+ orderBy: orderBys.get(c.slug) ?? orderByOf(c),
3216
+ limit: input.limit ?? DEFAULT_COLLECTION_LIST_LIMIT,
3217
+ offset: input.offset,
3218
+ });
3219
+ }, { ...editor, input: listInput }),
1963
3220
 
1964
3221
  collectionGet: query(async (ctx, input: { collection: string; id: string }) => {
1965
3222
  const c = def(input.collection);
1966
3223
  const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
1967
3224
  return rows[0] ?? null;
1968
- }, editor),
3225
+ }, { ...editor, input: rowInput }),
1969
3226
 
1970
3227
  collectionCreate: mutation((ctx, input: { collection: string; values: Record<string, unknown> }) => {
1971
3228
  const c = def(input.collection);
1972
- return cdb(ctx).insert(c.entity, toColumns(c, input.values, true));
1973
- }, editor),
3229
+ const values = toColumns(c, input.values, true);
3230
+ // Seed the managed columns explicitly rather than leaning on a column default: the
3231
+ // entity belongs to the app, which may have declared `status` with no default (or a
3232
+ // NOT NULL one). A new row always starts as a draft — publishing is a separate,
3233
+ // separately-gated act.
3234
+ if (has(c, "drafts")) values.status = COLLECTION_DRAFT;
3235
+ if (has(c, "scheduling")) {
3236
+ values.publishedAt = null;
3237
+ values.scheduledAt = null;
3238
+ values.unpublishAt = null;
3239
+ }
3240
+ return cdb(ctx).insert(c.entity, values);
3241
+ }, { ...editor, input: valuesInput }),
1974
3242
 
1975
3243
  collectionUpdate: mutation(async (ctx, input: { collection: string; id: string; values: Record<string, unknown> }) => {
1976
3244
  const c = def(input.collection);
1977
- const updated = await cdb(ctx).update(c.entity, input.id, toColumns(c, input.values, false));
3245
+ const db = cdb(ctx);
3246
+ // Read the current row first: the editor autosaves the WHOLE values bag, so a
3247
+ // pre-Portable-Text richtext value rides along with an unrelated edit. It is
3248
+ // tolerated only when byte-identical to what is stored (see `legacyBaseline`).
3249
+ // A policy may grant `update` without `read` on the entity — that worked before this
3250
+ // pre-read existed, so it must not start 403ing. No baseline simply means a legacy
3251
+ // string is rejected, which is the strict default.
3252
+ let current: FieldValues | undefined;
3253
+ try {
3254
+ current = (await db.find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 }))[0] as FieldValues | undefined;
3255
+ } catch {
3256
+ current = undefined;
3257
+ }
3258
+ // Validate + whitelist the patch BEFORE snapshotting. `toColumns` throws on an invalid
3259
+ // patch, and on the DO that rollback is free (the mutation is one transaction) — but
3260
+ // `D1Driver.transaction` is a no-op, so snapshotting first meant a REJECTED edit still
3261
+ // committed a revision on D1: a phantom entry recording no change, and a burnt value
3262
+ // in the per-row `revision` counter.
3263
+ const patch = toColumns(c, input.values, false, current);
3264
+ // `current` is undefined only when the pre-read above was denied (an
3265
+ // update-without-read grant), in which case there is nothing to snapshot — the
3266
+ // revision is skipped rather than written empty.
3267
+ if (current) await snapshotRow(db, c, current as Record<string, unknown>, ctx, "edit");
3268
+ const updated = await db.update(c.entity, input.id, patch);
1978
3269
  if (updated === undefined) throw notFound(c.label);
1979
3270
  return updated;
1980
- }, editor),
3271
+ }, { ...editor, input: rowValuesInput }),
1981
3272
 
1982
3273
  collectionDelete: mutation(async (ctx, input: { collection: string; id: string }) => {
1983
3274
  const c = def(input.collection);
1984
- const ok = await cdb(ctx).delete(c.entity, input.id);
3275
+ const db = cdb(ctx);
3276
+ const ok = await db.delete(c.entity, input.id);
1985
3277
  if (!ok) throw notFound(c.label);
3278
+ // PURGE the row's revisions. Keeping them looks like free history, but a collection PK
3279
+ // can be a caller-chosen textId — recreating a row with the same id would inherit the
3280
+ // dead row's history, and `collectionRestoreRevision`'s scope check (collection +
3281
+ // rowId) would happily write the deleted row's content over the new one. It also
3282
+ // bounds the table: a collection has no trash, so nothing else ever collects these.
3283
+ //
3284
+ // Atomic with the delete on the DO (the mutation runs in storage.transaction). NOT on
3285
+ // the D1 store, where `transaction` is a no-op — a failure in between leaves orphan
3286
+ // revisions, which is exactly the inheritance above. Rare, and recoverable by
3287
+ // deleting the recreated row, but it is not a guarantee on that substrate.
3288
+ if (has(c, "revisions")) {
3289
+ await db.exec(`DELETE FROM ${COLLECTION_REVISIONS_TABLE} WHERE collection = ? AND rowId = ?`, c.slug, input.id);
3290
+ }
1986
3291
  return { ok: true as const };
1987
- }, { ...editor, input: (raw) => ({ collection: asObj(raw).collection as string, id: idInput(raw) }) }),
3292
+ }, { ...editor, input: rowInput }),
3293
+
3294
+ // ---- drafts -------------------------------------------------------------
3295
+
3296
+ /** Move a row live. With `scheduling` this also stamps `publishedAt` (the column the
3297
+ * public read scope compares against `$now()`) and clears `scheduledAt` — which makes
3298
+ * any pending scheduled-publish task a no-op, since its intent token no longer matches.
3299
+ * A pending scheduled UNPUBLISH is deliberately left standing: publishing early does not
3300
+ * cancel a planned takedown. */
3301
+ collectionPublish: mutation(async (ctx, input: { collection: string; id: string }) => {
3302
+ const c = def(input.collection);
3303
+ needs(c, "drafts");
3304
+ const db = cdb(ctx);
3305
+ const row = await loadRow(db, c, input.id);
3306
+ // Deliberately NOT snapshotted. A revision records CONTENT, and publishing changes
3307
+ // none — the managed columns are excluded from `fields` by design, so a "publish"
3308
+ // revision was byte-identical to the edit before it, and restoring it wrote only the
3309
+ // declared fields and left the row live. That reads as a broken button; an entry that
3310
+ // cannot be restored is worse than no entry.
3311
+ const patch: Record<string, unknown> = { status: COLLECTION_PUBLISHED };
3312
+ if (has(c, "scheduling")) {
3313
+ const now = isoStamp();
3314
+ patch.publishedAt = now;
3315
+ patch.scheduledAt = null;
3316
+ // Clear a takedown instant that has already PASSED. This is an EXPLICIT act by a
3317
+ // human holding publish rights, which is why it resolves differently from the
3318
+ // scheduled-publish task: that one converges to the state the schedule implies (a
3319
+ // passed takedown wins, and the row lands down), while here the editor is saying
3320
+ // "live, now" about a takedown that has already been served. A future one stands —
3321
+ // publishing early does not cancel a planned removal — but a spent one is not
3322
+ // "pending" at all, and since the public scope now enforces
3323
+ // `unpublishAt IS NULL OR unpublishAt > $now()`, leaving it would make this very
3324
+ // publish a no-op: the editor gets back `status: "published"` and the row stays
3325
+ // invisible, with no error to explain it. That state is reachable whenever the
3326
+ // unpublish task never ran (tasks unwired, outbox dead-lettered, no D1 cron).
3327
+ if (typeof row.unpublishAt === "string" && row.unpublishAt <= now) patch.unpublishAt = null;
3328
+ }
3329
+ const updated = await db.update(c.entity, input.id, patch);
3330
+ if (updated === undefined) throw notFound(c.label);
3331
+ return updated;
3332
+ }, { ...editor, input: rowInput }),
3333
+
3334
+ /** Take a row back to draft, clearing every schedule. Both tokens are cleared, so a
3335
+ * pending publish AND a pending unpublish both become no-ops — unpublishing is an
3336
+ * explicit "this is not live and nothing is queued to change that". */
3337
+ collectionUnpublish: mutation(async (ctx, input: { collection: string; id: string }) => {
3338
+ const c = def(input.collection);
3339
+ needs(c, "drafts");
3340
+ const db = cdb(ctx);
3341
+ await loadRow(db, c, input.id);
3342
+ const patch: Record<string, unknown> = { status: COLLECTION_DRAFT }; // not snapshotted — see collectionPublish
3343
+ if (has(c, "scheduling")) {
3344
+ patch.publishedAt = null;
3345
+ patch.scheduledAt = null;
3346
+ patch.unpublishAt = null;
3347
+ }
3348
+ const updated = await db.update(c.entity, input.id, patch);
3349
+ if (updated === undefined) throw notFound(c.label);
3350
+ return updated;
3351
+ }, { ...editor, input: rowInput }),
3352
+
3353
+ // ---- scheduling ---------------------------------------------------------
3354
+
3355
+ /** Schedule a future publish, and optionally a later unpublish. Mirrors `schedulePage`,
3356
+ * including the INTENT TOKEN: the row stores the scheduled instants
3357
+ * (`scheduledAt`/`unpublishAt`, ISO), the enqueued task carries a copy, and the task
3358
+ * runs only if the two still match. A reschedule overwrites the token, a manual
3359
+ * publish/unpublish clears it, and a duplicate delivery finds it already cleared — so a
3360
+ * superseded or cancelled schedule is a silent no-op rather than a surprise publish.
3361
+ *
3362
+ * The tasks are enqueued in THIS mutation's transaction (the outbox is transactional),
3363
+ * so a rolled-back schedule never leaves a task behind. They only run if you wired
3364
+ * `createCollectionTasks` into `app.tasks`. */
3365
+ collectionSchedule: mutation(async (ctx, input: { collection: string; id: string; publishAt: number; unpublishAt?: number | null }) => {
3366
+ const c = def(input.collection);
3367
+ needs(c, "scheduling");
3368
+ const db = cdb(ctx);
3369
+ const row = await loadRow(db, c, input.id);
3370
+ const now = Date.now();
3371
+ const publishToken = new Date(input.publishAt).toISOString();
3372
+ // Cross-call ordering. The boundary validator compares the two instants WITHIN one
3373
+ // call, which is not the invariant that matters: the documented way to move a publish
3374
+ // date is `collectionSchedule({ publishAt })` with `unpublishAt` omitted, and an
3375
+ // omitted takedown is left standing. Without this check a reschedule could push the
3376
+ // publish PAST a pending takedown — the takedown then fires first (clearing itself),
3377
+ // the publish fires after it against nothing, and the row is public with no takedown
3378
+ // left and no repair path. Compare against the takedown that will actually be in
3379
+ // effect: the one being written, or the one already stored.
3380
+ const effectiveUnpublish =
3381
+ input.unpublishAt !== undefined
3382
+ ? typeof input.unpublishAt === "number"
3383
+ ? new Date(input.unpublishAt).toISOString()
3384
+ : null
3385
+ : typeof row.unpublishAt === "string" && row.unpublishAt !== ""
3386
+ ? row.unpublishAt
3387
+ : null;
3388
+ if (effectiveUnpublish !== null && effectiveUnpublish <= publishToken) {
3389
+ throw new BadRequest(
3390
+ `publishAt (${publishToken}) is at or after the scheduled takedown (${effectiveUnpublish}) — move or cancel the takedown too (pass \`unpublishAt\`, or \`unpublishAt: null\` to cancel it)`,
3391
+ );
3392
+ }
3393
+ // PATCH semantics on the takedown: an ABSENT `unpublishAt` leaves an existing one
3394
+ // alone. Writing null unconditionally meant that merely moving the publish date
3395
+ // revoked a scheduled removal — and silently, since clearing the column also
3396
+ // neutralizes the already-enqueued task through the intent-token check. Pass
3397
+ // `unpublishAt: null` to cancel one deliberately.
3398
+ const hasUnpublish = input.unpublishAt !== undefined;
3399
+ const unpublishToken = typeof input.unpublishAt === "number" ? new Date(input.unpublishAt).toISOString() : null;
3400
+ const patch: Record<string, unknown> = { scheduledAt: publishToken };
3401
+ if (hasUnpublish) patch.unpublishAt = unpublishToken;
3402
+ // `loadRow` above goes through the READ scope; this goes through the UPDATE scope,
3403
+ // which can be narrower. Without the check a role that may read but not update the row
3404
+ // got `{ ok: true }` and two enqueued tasks over a write that never landed — the tasks
3405
+ // then found `scheduledAt` still null, mismatched their intent token, and no-op'd. A
3406
+ // confirmed schedule that silently never fires. Every sibling handler checks this.
3407
+ const updated = await db.update(c.entity, input.id, patch);
3408
+ if (updated === undefined) throw notFound(c.label);
3409
+ await ctx.tasks.enqueue({
3410
+ kind: TASK_COLLECTION_PUBLISH,
3411
+ payload: { collection: c.slug, id: input.id, token: publishToken },
3412
+ delayMs: Math.max(0, input.publishAt - now),
3413
+ });
3414
+ if (typeof input.unpublishAt === "number") {
3415
+ await ctx.tasks.enqueue({
3416
+ kind: TASK_COLLECTION_UNPUBLISH,
3417
+ payload: { collection: c.slug, id: input.id, token: unpublishToken },
3418
+ delayMs: Math.max(0, input.unpublishAt - now),
3419
+ });
3420
+ }
3421
+ return { ok: true as const, scheduledAt: publishToken, ...(hasUnpublish ? { unpublishAt: unpublishToken } : {}) };
3422
+ }, {
3423
+ ...editor,
3424
+ input: (raw): { collection: string; id: string; publishAt: number; unpublishAt?: number | null } => {
3425
+ const o = asObj(raw);
3426
+ const base = rowInput(raw);
3427
+ // Range-checked, not merely finite — see `epochInput`. An out-of-range value would
3428
+ // otherwise either throw a RangeError inside the transaction (an opaque 500) or
3429
+ // mint an expanded-year ISO string that compares backwards forever.
3430
+ const publishAt = epochInput("publishAt", o.publishAt);
3431
+ // `null` is the explicit "cancel the takedown"; absent leaves it untouched.
3432
+ if (o.unpublishAt !== undefined && o.unpublishAt !== null) {
3433
+ const unpublishAt = epochInput("unpublishAt", o.unpublishAt);
3434
+ if (unpublishAt <= publishAt) throw new BadRequest("unpublishAt must be after publishAt");
3435
+ return { ...base, publishAt, unpublishAt };
3436
+ }
3437
+ // Only `unpublishAt: null` (cancel) and an absent key reach here.
3438
+ return "unpublishAt" in o ? { ...base, publishAt, unpublishAt: null } : { ...base, publishAt };
3439
+ },
3440
+ }),
3441
+
3442
+ // ---- revisions ----------------------------------------------------------
3443
+
3444
+ /** A row's revision history, newest first. */
3445
+ collectionListRevisions: query(async (ctx, input: { collection: string; id: string; limit?: number }) => {
3446
+ const c = def(input.collection);
3447
+ needs(c, "revisions");
3448
+ const db = cdb(ctx);
3449
+ // Read the ROW through the ACL first. `cms_collection_revisions` is shared across
3450
+ // every collection and `collectionPolicies` grants it a flat allow(), so without this
3451
+ // a role holding narrower per-entity read policies could list the snapshots of a
3452
+ // collection it cannot read through `collectionGet`. `collectionRestoreRevision`
3453
+ // already had this via `loadRow`; the list path did not.
3454
+ const row = await loadRow(db, c, input.id);
3455
+ const revs = await db.find({
3456
+ from: COLLECTION_REVISIONS_TABLE,
3457
+ where: { collection: c.slug, rowId: input.id },
3458
+ // By the monotonic counter, never by a timestamp — see the `revision` column.
3459
+ orderBy: { column: "revision", dir: "desc" },
3460
+ limit: input.limit ?? 50,
3461
+ });
3462
+ // Project every snapshot to the fields THIS caller may read on the collection's own
3463
+ // entity. The row check above is a ROW-level gate, and `collectionPolicies` grants the
3464
+ // shared revisions table a flat allow() — so without this a caller holding a
3465
+ // FIELD-restricted read policy (`fields: ["id", "title", "status"]`) reads back the
3466
+ // columns that policy withholds, in full, out of the snapshot JSON. History must not
3467
+ // be a way around the field scope that governs the row itself.
3468
+ //
3469
+ // `loadRow` came through the ACL, and reads are column-projected, so its keys ARE the
3470
+ // caller's readable columns.
3471
+ const readable = new Set(Object.keys(row));
3472
+ return revs.map((r) => ({ ...r, snapshot: projectSnapshot(r.snapshot, readable) }));
3473
+ }, {
3474
+ ...editor,
3475
+ input: (raw): { collection: string; id: string; limit?: number } => {
3476
+ const o = asObj(raw);
3477
+ if (o.limit !== undefined && (typeof o.limit !== "number" || !Number.isFinite(o.limit))) throw new BadRequest("limit must be a number");
3478
+ // CLAMPED, not just validated: `find` binds this straight into `LIMIT ?`, and SQLite
3479
+ // reads a negative limit as UNBOUNDED — so `limit: -1` would dump a row's entire
3480
+ // history. A fractional value would reach the driver as-is.
3481
+ const limit = o.limit === undefined ? undefined : Math.max(1, Math.min(Math.floor(o.limit as number), 200));
3482
+ return { ...rowInput(raw), limit };
3483
+ },
3484
+ }),
3485
+
3486
+ /** Restore a row to one of its revisions. The CURRENT state is snapshotted first, so a
3487
+ * restore is itself undoable. */
3488
+ collectionRestoreRevision: mutation(async (ctx, input: { collection: string; id: string; revisionId: string }) => {
3489
+ const c = def(input.collection);
3490
+ needs(c, "revisions");
3491
+ const db = cdb(ctx);
3492
+ const revs = await db.find({ from: COLLECTION_REVISIONS_TABLE, where: { id: input.revisionId }, limit: 1 });
3493
+ const rev = revs[0];
3494
+ // Scope the revision to THIS collection AND row. A revision id is otherwise a global
3495
+ // handle into a table shared by every collection, so an id from another row — or
3496
+ // another collection entirely — would write a foreign snapshot over this row.
3497
+ if (!rev || String(rev.collection) !== c.slug || String(rev.rowId) !== input.id) throw notFound("revision");
3498
+ const current = await loadRow(db, c, input.id);
3499
+ // A snapshot is built from an ACL-PROJECTED row, so a caller whose read scope excluded
3500
+ // every declared column stored `{}`. `Db.update` returns undefined for a zero-column
3501
+ // patch, which would surface below as "not found" for a row loaded two lines earlier —
3502
+ // a misleading 404. Say what is actually wrong instead.
3503
+ // Restore exactly the fields this caller can READ, for the same reason
3504
+ // `collectionListRevisions` projects them: the snapshot is complete (it was captured
3505
+ // through the raw path), and the shared revisions table is granted flat, so replaying
3506
+ // it whole would let a field-restricted editor write back columns their own read
3507
+ // policy withholds — restoring a value they cannot see, out of a version they cannot
3508
+ // read. What you can see is what you can put back.
3509
+ const visible = projectSnapshot(rev.snapshot, new Set(Object.keys(current)));
3510
+ const restore = toColumns(c, visible, false, current as FieldValues);
3511
+ if (Object.keys(restore).length === 0) {
3512
+ throw new BadRequest(`revision ${input.revisionId} has no restorable fields (none of its columns are readable by this caller)`);
3513
+ }
3514
+ await snapshotRow(db, c, current, ctx, "restore");
3515
+ // Replay through the SAME validate + whitelist as an ordinary write: a snapshot taken
3516
+ // before a field was dropped from `fields` must not resurrect that column, and one
3517
+ // taken before a field's type changed must not bypass validation.
3518
+ const updated = await db.update(c.entity, input.id, restore);
3519
+ if (updated === undefined) throw notFound(c.label);
3520
+ return updated;
3521
+ }, {
3522
+ ...editor,
3523
+ input: (raw): { collection: string; id: string; revisionId: string } => {
3524
+ const o = asObj(raw);
3525
+ if (typeof o.revisionId !== "string" || o.revisionId === "") throw new BadRequest("revisionId is required");
3526
+ return { ...rowInput(raw), revisionId: o.revisionId };
3527
+ },
3528
+ }),
3529
+
3530
+ // ---- preview ------------------------------------------------------------
3531
+
3532
+ /** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
3533
+ * Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
3534
+ * same rule that the row is read through the ACL FIRST: minting a link is granting
3535
+ * access to the row, so a caller who cannot read it must not be able to mint one. */
3536
+ signCollectionPreview: query(async (ctx, input: { collection: string; id: string; expiresIn?: number }) => {
3537
+ const c = def(input.collection);
3538
+ needs(c, "preview");
3539
+ const secret = previewSecret(ctx.env);
3540
+ if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
3541
+ // Redemption always reaches a Durable Object (callPrivileged -> PRAMEN.get) and has no
3542
+ // notion of `x-pramen-store`, so a link minted on D1 would 404 forever while the
3543
+ // editor reported success.
3544
+ if (ctx.store === "d1") {
3545
+ throw new PramenError("collection preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
3546
+ }
3547
+ const row = await loadRow(cdb(ctx), c, input.id);
3548
+ const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
3549
+ const exp = Math.floor(Date.now() / 1000) + ttl;
3550
+ // Server-resolved, never caller-supplied, so the tenant inside the signature cannot
3551
+ // be steered by whoever asks for the link.
3552
+ const token = await signToken<CollectionPreviewToken>({ t: ctx.tenant, c: c.slug, r: String(row[idOf(c)]), exp }, secret);
3553
+ // RELATIVE, like signed file urls — the client resolves it against the CMS origin.
3554
+ return { url: `${COLLECTION_PREVIEW_PATH}?token=${encodeURIComponent(token)}`, token, expiresAt: exp * 1000 };
3555
+ }, {
3556
+ ...editor,
3557
+ input: (raw): { collection: string; id: string; expiresIn?: number } => {
3558
+ const o = asObj(raw);
3559
+ if (o.expiresIn !== undefined && (typeof o.expiresIn !== "number" || !Number.isFinite(o.expiresIn))) {
3560
+ throw new BadRequest("expiresIn must be a number of seconds");
3561
+ }
3562
+ return { ...rowInput(raw), expiresIn: o.expiresIn as number | undefined };
3563
+ },
3564
+ }),
3565
+
3566
+ /** Read one row's live (possibly unpublished) state. Not the redemption endpoint — that
3567
+ * is the public `GET /cms/preview/collection` route, which verifies the token and then
3568
+ * calls this privileged. Role-gated so it is not an anonymous back door on /rpc. */
3569
+ getCollectionPreview: query(async (ctx, input: { collection: string; id: string }) => {
3570
+ const c = def(input.collection);
3571
+ needs(c, "preview");
3572
+ const row = await loadRow(cdb(ctx), c, input.id);
3573
+ const values: Record<string, unknown> = { [idOf(c)]: row[idOf(c)] };
3574
+ for (const f of c.fields) if (f.name in row) values[f.name] = row[f.name];
3575
+ if (has(c, "drafts")) values.status = row.status;
3576
+ return { collection: c.slug, id: String(row[idOf(c)]), values };
3577
+ }, { ...viewer, input: rowInput }),
1988
3578
  };
1989
3579
  }
1990
3580
 
@@ -2004,9 +3594,184 @@ export function collectionPolicies(collections: readonly CollectionDef[], opts:
2004
3594
  out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
2005
3595
  }
2006
3596
  }
3597
+ // The revision table is SHARED across collections, so it is not covered by the per-entity
3598
+ // grants above. Granting it here — rather than leaning on `cmsPolicies().editor` — keeps
3599
+ // `revisions` self-contained: an app that registers collections without using the
3600
+ // block/page half still gets a working feature instead of a 403 on every write. Read +
3601
+ // create only; a revision is append-only, and nothing exposes editing or purging one.
3602
+ if (collections.some((c) => (c.supports ?? []).includes("revisions"))) {
3603
+ for (const action of ["read", "create"] as const) {
3604
+ out.push(policy(`${p}:editor:${COLLECTION_REVISIONS_TABLE}:${action}`, COLLECTION_REVISIONS_TABLE, action, allow()));
3605
+ }
3606
+ }
3607
+ return out;
3608
+ }
3609
+
3610
+ /** ACL fragments granting ANONYMOUS read of the PUBLISHED rows of every collection that
3611
+ * supports `drafts`. Spread into your public role next to `cmsPolicies().public`:
3612
+ *
3613
+ * role("anonymous", [...cmsPolicies().public, ...collectionPublicPolicies(collections)])
3614
+ *
3615
+ * This is the access boundary, not a UI filter — it is AND-merged into every `ctx.db` read
3616
+ * of the entity, so an unpublished row is invisible to the public API, to relation
3617
+ * traversals and to eager-loads alike, without a single query remembering to filter.
3618
+ *
3619
+ * With `scheduling`, the scope also requires `publishedAt <= $now()`. `status` alone would
3620
+ * not be enough the moment anything writes a future `publishedAt`, and `{ publishedAt:
3621
+ * { isNull: false } }` — the obvious-looking alternative — matches a FUTURE timestamp too,
3622
+ * so a row scheduled for next week would be anonymously readable the moment it was saved.
3623
+ * The comparison is lexicographic over TEXT, which is why every managed timestamp is minted
3624
+ * as ISO-8601 UTC (`isoStamp`), the same shape `$now()` produces.
3625
+ *
3626
+ * Collections WITHOUT `drafts` get nothing here: they have no publish state, so their
3627
+ * public exposure is entirely your app's own policy to write. */
3628
+ export function collectionPublicPolicies(collections: readonly CollectionDef[], opts: CmsPolicyOpts = {}): Policy[] {
3629
+ const p = opts.prefix ?? "cms";
3630
+ const out: Policy[] = [];
3631
+ for (const c of collections) {
3632
+ const features = c.supports ?? [];
3633
+ if (!features.includes("drafts")) continue;
3634
+ const name = `${p}:public:collection:${c.entity}:read`;
3635
+ // The PUBLIC surface is exactly what the collection declares as editable, plus its id
3636
+ // and the two columns a public page legitimately reads: `status` (constant `published`
3637
+ // for every visible row) and, with `scheduling`, `publishedAt`.
3638
+ //
3639
+ // Granting the whole row instead would quietly publish every future column: an
3640
+ // `internalNote` or `reviewerEmail` added to the entity — deliberately NOT a field —
3641
+ // would go world-readable the moment a row was published, with nothing at boot or in
3642
+ // review to catch it. But excluding `publishedAt` went too far the other way: the
3643
+ // single most obvious public query, "newest published first", 403s for anonymous while
3644
+ // working for an editor, because a caller may not order by a column it cannot read.
3645
+ // A publication date is public by construction — it is printed on the page. The
3646
+ // FORWARD-looking columns stay private: `scheduledAt` and `unpublishAt` would leak
3647
+ // "this comes down on Friday" to everyone.
3648
+ const fields = [c.idField ?? "id", ...c.fields.map((f) => f.name), "status", ...(features.includes("scheduling") ? ["publishedAt"] : [])];
3649
+ out.push(
3650
+ features.includes("scheduling")
3651
+ ? policy(name, c.entity, "read", {
3652
+ fields,
3653
+ where: {
3654
+ status: COLLECTION_PUBLISHED,
3655
+ // Both time clauses are OR-groups, so they go in an explicit `AND: [...]` —
3656
+ // two `OR` keys in one object literal would be the same property, and the
3657
+ // second would silently REPLACE the first.
3658
+ AND: [
3659
+ {
3660
+ // `publishedAt <= $now()` alone silently hides every published row with
3661
+ // no stamp — a `cmsBootstrap` seed, an import, a row published while the
3662
+ // collection was still `supports: ["drafts"]` — and does it EN MASSE the
3663
+ // moment `scheduling` is added to an existing collection, because
3664
+ // `NULL <= '2026-…'` is NULL, not true. NULL here means "published,
3665
+ // instant unknown", never "scheduled for later": `publishedAt` is a
3666
+ // managed column (never in the write whitelist) that only ever takes
3667
+ // `isoStamp()` or null, and a row awaiting a scheduled publish is
3668
+ // `status: 'draft'` with the instant in `scheduledAt`. So a missing stamp
3669
+ // cannot be a future one, and treating it as "already published" is both
3670
+ // safe and what the editor already shows.
3671
+ OR: [{ publishedAt: { isNull: true } }, { publishedAt: { lte: $now() } }],
3672
+ },
3673
+ {
3674
+ // A scheduled TAKEDOWN has to be enforced HERE, not only by the task. The
3675
+ // publish side is belt-and-braces (policy + task), but without this clause
3676
+ // an unpublish depends entirely on `createCollectionTasks` being wired and
3677
+ // the outbox draining — if either fails, a row an editor scheduled to come
3678
+ // down stays world-readable indefinitely, with no signal. That is the
3679
+ // wrong way round for a takedown, the direction that matters legally.
3680
+ OR: [{ unpublishAt: { isNull: true } }, { unpublishAt: { gt: $now() } }],
3681
+ },
3682
+ ],
3683
+ },
3684
+ })
3685
+ : policy(name, c.entity, "read", { fields, where: { status: COLLECTION_PUBLISHED } }),
3686
+ );
3687
+ }
2007
3688
  return out;
2008
3689
  }
2009
3690
 
3691
+ /** Task handlers backing `collectionSchedule`. Register alongside `cmsTasks`:
3692
+ *
3693
+ * const app = { tasks: { ...cmsTasks, ...createCollectionTasks(collections) } };
3694
+ *
3695
+ * WITHOUT THIS WIRING A SCHEDULE NEVER FIRES: `collectionSchedule` still stores the
3696
+ * instants and enqueues the tasks, but the drain finds no handler for their kind, so the
3697
+ * row silently stays a draft. (`cmsTasks` has the same requirement for page scheduling.)
3698
+ *
3699
+ * They run with a privileged, system-scoped ctx off the write path, and each validates its
3700
+ * INTENT TOKEN against the row's current `scheduledAt`/`unpublishAt` before acting — see
3701
+ * `collectionSchedule`. */
3702
+ export function createCollectionTasks(collections: readonly CollectionDef[]) {
3703
+ const bySlug = new Map(collections.map((c) => [c.slug, c] as const));
3704
+ const run = async (
3705
+ ctx: HandlerContext,
3706
+ payload: unknown,
3707
+ tokenColumn: "scheduledAt" | "unpublishAt",
3708
+ buildPatch: (row: Record<string, unknown>, now: string) => Record<string, unknown>,
3709
+ ): Promise<void> => {
3710
+ const { collection, id, token } = asObj(payload) as { collection?: string; id?: string; token?: string };
3711
+ if (typeof collection !== "string" || typeof id !== "string") return;
3712
+ // Resolved through the REGISTRY, exactly as the handlers do — the payload's `collection`
3713
+ // is never used as a table name.
3714
+ const c = bySlug.get(collection);
3715
+ // A slug the handlers accept but this registry does not know is a WIRING mistake:
3716
+ // `createCollectionHandlers(collections)` and `createCollectionTasks(otherList)` built
3717
+ // from different arrays. Returning quietly made the drain report `{ succeeded: 1 }`
3718
+ // while the row stayed a draft forever — strictly worse than not registering the tasks
3719
+ // at all, which dead-letters loudly. Throw so the outbox retries and then dead-letters
3720
+ // with the slug in the message.
3721
+ if (!c) throw new Error(`pramen/cms: no collection '${collection}' in createCollectionTasks' registry — pass the SAME collections array to createCollectionHandlers and createCollectionTasks`);
3722
+ const db = cdb(ctx);
3723
+ const rows = await db.find({ from: c.entity, where: { [c.idField ?? "id"]: id }, limit: 1 });
3724
+ const row = rows[0];
3725
+ if (!row) return;
3726
+ // Intent check: act only if this task is still the row's active schedule. A reschedule
3727
+ // (new token), a manual publish/unpublish (token cleared) or a duplicate delivery after
3728
+ // this task already ran all make it a no-op.
3729
+ //
3730
+ // Require a NON-EMPTY token on both sides. Comparing the coalesced strings alone treats
3731
+ // "no token in the payload" and "no schedule on the row" as a MATCH — so a payload
3732
+ // without a token (a hand-drained or replayed outbox row) against a row whose
3733
+ // `scheduledAt` is null, the normal state right after an unpublish, would publish it
3734
+ // unconditionally. The guard should fail closed, not open.
3735
+ const stored = typeof row[tokenColumn] === "string" ? (row[tokenColumn] as string) : "";
3736
+ if (!token || !stored || stored !== token) return;
3737
+ await db.update(c.entity, id, buildPatch(row, isoStamp()));
3738
+ };
3739
+ return {
3740
+ [TASK_COLLECTION_PUBLISH]: (ctx: HandlerContext, payload: unknown) =>
3741
+ run(ctx, payload, "scheduledAt", (row, now) => {
3742
+ // CONVERGE to the state the schedule implies AT `now`, rather than blindly applying
3743
+ // the step this task was enqueued for. A drain can lag arbitrarily (D1 cron
3744
+ // granularity, outbox backoff, a stalled DO alarm), and the two tasks can arrive in
3745
+ // either order, so "publish" has to mean "publish IF the takedown has not come yet".
3746
+ //
3747
+ // The previous behavior — publish anyway, and null the passed `unpublishAt` to keep
3748
+ // the row visible — destroyed a scheduled takedown outright: the token the unpublish
3749
+ // task compares against was gone, so it no-op'd, and the read scope's
3750
+ // `unpublishAt IS NULL OR unpublishAt > $now()` backstop had nothing left to enforce.
3751
+ // A row scheduled to come down at noon stayed world-readable forever, silently.
3752
+ // A takedown that failing OPEN cannot be recovered from is the wrong way round.
3753
+ if (typeof row.unpublishAt === "string" && row.unpublishAt !== "" && row.unpublishAt <= now) {
3754
+ // Both instants are in the past: the row's whole scheduled life has elapsed.
3755
+ // Land on the END state (down), and spend both tokens so neither task can fire
3756
+ // against this schedule again.
3757
+ return { status: COLLECTION_DRAFT, publishedAt: null, scheduledAt: null, unpublishAt: null };
3758
+ }
3759
+ // The ordinary case: publish now, keep a still-future takedown standing.
3760
+ return { status: COLLECTION_PUBLISHED, publishedAt: now, scheduledAt: null };
3761
+ }),
3762
+ [TASK_COLLECTION_UNPUBLISH]: (ctx: HandlerContext, payload: unknown) =>
3763
+ // Clear `scheduledAt` too — the third column `collectionUnpublish` clears and this
3764
+ // task used to leave behind. A pending publish token that survived a takedown is a
3765
+ // live re-publish: if the publish task drains after this one (either order is
3766
+ // possible) or is retried after a throw, it finds its token still matching and puts
3767
+ // the row back up — with `unpublishAt` now null, permanently and with no repair path.
3768
+ // A schedule always orders publish BEFORE takedown (`collectionSchedule` enforces it
3769
+ // against the stored value as well as the submitted one), so any `scheduledAt` still
3770
+ // standing at the takedown is spent by definition.
3771
+ run(ctx, payload, "unpublishAt", () => ({ status: COLLECTION_DRAFT, publishedAt: null, scheduledAt: null, unpublishAt: null })),
3772
+ };
3773
+ }
3774
+
2010
3775
  // --- deferred tasks (scheduled publish/unpublish) ----------------------------
2011
3776
 
2012
3777
  /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
@@ -2089,19 +3854,55 @@ export function robotsTxt(opts: { origin: string; disallow?: string[] }): string
2089
3854
 
2090
3855
  // Minimal shape of a pramen public route (see @pramen/server/worker app.routes).
2091
3856
  interface RouteCtx {
2092
- callPrivileged: (opts: { name: string; input?: unknown; tenant?: string; roles?: string[] }) => Promise<Response>;
3857
+ callPrivileged: (opts: { name: string; input?: JsonValue; tenant?: string; roles?: string[] }) => Promise<Response>;
2093
3858
  }
2094
3859
  interface CmsRoute {
2095
3860
  method: string;
2096
3861
  path: string;
2097
- handler: (request: Request, env: Readonly<Record<string, unknown>>, ctx: RouteCtx) => Promise<Response>;
3862
+ handler: (request: Request, env: EnvBag, ctx: RouteCtx) => Promise<Response>;
2098
3863
  }
2099
3864
 
2100
- /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
3865
+ const previewJson = (status: number, code: string, message: string): Response =>
3866
+ // `{ ok, error, code }` — the shape every other pramen error uses (runtime/errors.ts).
3867
+ new Response(JSON.stringify({ ok: false, error: message, code }), {
3868
+ status,
3869
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
3870
+ });
3871
+ /** One response for a missing, malformed, forged, or expired token — and for a page that
3872
+ * is not there. Distinguishing them would let a caller probe for valid page ids. */
3873
+ const previewDenied = (status = 403, message = "invalid or expired preview link"): Response =>
3874
+ previewJson(status, status === 404 ? "not_found" : "forbidden", message);
3875
+ const preview503 = (): Response =>
3876
+ previewJson(503, "unavailable", "page preview is not configured (set a strong PREVIEW_SECRET, FILES_SECRET or AUTH_SECRET)");
3877
+
3878
+ /** Turnkey public routes for `GET /sitemap.xml`, `GET /cms/preview` and `GET /robots.txt`. Spread into
2101
3879
  * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
2102
3880
  * `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
2103
- export function cmsRoutes(opts: { origin?: string; tenant?: string; pageUrl?: SitemapOpts["pageUrl"]; disallow?: string[] } = {}): CmsRoute[] {
3881
+ export function cmsRoutes(
3882
+ opts: {
3883
+ origin?: string;
3884
+ tenant?: string;
3885
+ pageUrl?: SitemapOpts["pageUrl"];
3886
+ disallow?: string[];
3887
+ /** The SAME options you passed to `createCmsHandlers`. The route derives its identity
3888
+ * from them with `viewerRolesOf`, so the two cannot drift. (`viewerRoles` overrides it
3889
+ * outright if you need to.) */
3890
+ handlers?: CmsHandlerOpts;
3891
+ /** The SAME options you passed to `createCollectionHandlers`, if they differ from
3892
+ * `handlers`. The COLLECTION preview route has its own gate — `getCollectionPreview` is
3893
+ * built by `createCollectionHandlers`, so an app that passes different `editorRoles` /
3894
+ * `reviewerRoles` to the two factories would have the route present the page half's
3895
+ * roles to a handler gated on the collection half's, and every collection preview link
3896
+ * would 404 uniformly (the response is deliberately indistinguishable from "not
3897
+ * found"). Defaults to `handlers`, which is right whenever both got the same options. */
3898
+ collectionHandlers?: CmsHandlerOpts;
3899
+ /** Explicit override for the roles the preview routes present to the DO. */
3900
+ viewerRoles?: readonly string[];
3901
+ } = {},
3902
+ ): CmsRoute[] {
2104
3903
  const tenant = opts.tenant ?? "main";
3904
+ const previewRoles = opts.viewerRoles ?? viewerRolesOf(opts.handlers);
3905
+ const collectionPreviewRoles = opts.viewerRoles ?? viewerRolesOf(opts.collectionHandlers ?? opts.handlers);
2105
3906
  return [
2106
3907
  {
2107
3908
  method: "GET",
@@ -2114,6 +3915,85 @@ export function cmsRoutes(opts: { origin?: string; tenant?: string; pageUrl?: Si
2114
3915
  return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
2115
3916
  },
2116
3917
  },
3918
+ {
3919
+ // Redeem a preview link. PUBLIC and pre-auth by design — the signature IS the
3920
+ // authorization, so a reviewer with no account can open it. The token is verified
3921
+ // BEFORE any read, and it names one page, so a valid signature never widens into
3922
+ // "see all drafts". Only then do we reach the DO, privileged.
3923
+ method: "GET",
3924
+ path: PREVIEW_PATH,
3925
+ handler: async (request, env, ctx) => {
3926
+ const secret = previewSecret(env);
3927
+ // Fail closed: with no usable secret every signature would verify against a weak
3928
+ // key, so refuse to verify at all rather than accept forged links.
3929
+ if (!secret) return preview503();
3930
+ const raw = new URL(request.url).searchParams.get("token");
3931
+ if (!raw) return previewDenied();
3932
+ const payload = await verifyToken<PreviewToken>(raw, secret);
3933
+ if (!payload || typeof payload.p !== "string" || typeof payload.t !== "string") return previewDenied();
3934
+
3935
+ // The synthetic identity has to satisfy BOTH gates the DO applies: the handler's
3936
+ // `auth` (viewerRoles) and the row ACL (whatever role the app granted cmsPolicies
3937
+ // to). Hardcoding ["admin"] satisfied neither under the wiring this package's own
3938
+ // README documents — `role("anonymous", …)` + `role("editor", …)`, no admin role
3939
+ // at all — so every preview link 404'd. The e2e only passed because example/app.ts
3940
+ // happens to define an admin role. Send the configured viewer roles instead.
3941
+ const res = await ctx.callPrivileged({
3942
+ name: "getPagePreview",
3943
+ input: { pageId: payload.p },
3944
+ tenant: payload.t, // from the SIGNED payload, never from the query string
3945
+ roles: [...previewRoles],
3946
+ });
3947
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: JsonValue; error?: string; code?: string };
3948
+ if (body.ok !== true) {
3949
+ // The client-visible response stays uniform (probe resistance), but LOG the real
3950
+ // reason: a role misconfiguration previously surfaced as an indistinguishable
3951
+ // "page not found" that could only be diagnosed by reading source.
3952
+ console.error(`pramen/cms: preview redemption failed (${res.status} ${body.code ?? "?"}: ${body.error ?? "no detail"})`);
3953
+ return previewDenied(404, "page not found");
3954
+ }
3955
+ // Never cache a draft, anywhere.
3956
+ return new Response(JSON.stringify(body.result), {
3957
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
3958
+ });
3959
+ },
3960
+ },
3961
+ {
3962
+ // Redeem a COLLECTION preview link. Same contract as the page preview route above:
3963
+ // public and pre-auth by design (the signature IS the authorization, so a reviewer
3964
+ // with no account can open it), verified BEFORE any read, and scoped by the signed
3965
+ // payload to one row of one collection — a valid signature never widens into "see
3966
+ // every draft".
3967
+ method: "GET",
3968
+ path: COLLECTION_PREVIEW_PATH,
3969
+ handler: async (request, env, ctx) => {
3970
+ const secret = previewSecret(env);
3971
+ // Fail closed: with no usable secret every signature would verify against a weak
3972
+ // key, so refuse to verify at all rather than accept forged links.
3973
+ if (!secret) return preview503();
3974
+ const raw = new URL(request.url).searchParams.get("token");
3975
+ if (!raw) return previewDenied();
3976
+ const payload = await verifyToken<CollectionPreviewToken>(raw, secret);
3977
+ if (!payload || typeof payload.c !== "string" || typeof payload.r !== "string" || typeof payload.t !== "string") return previewDenied();
3978
+ const res = await ctx.callPrivileged({
3979
+ name: "getCollectionPreview",
3980
+ input: { collection: payload.c, id: payload.r },
3981
+ tenant: payload.t, // from the SIGNED payload, never from the query string
3982
+ roles: [...collectionPreviewRoles], // the COLLECTION handlers' gate — see `collectionHandlers`
3983
+ });
3984
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: JsonValue; error?: string; code?: string };
3985
+ if (body.ok !== true) {
3986
+ // Uniform client-visible response (probe resistance), but LOG the real reason —
3987
+ // a role or wiring mistake here is otherwise indistinguishable from "not found".
3988
+ console.error(`pramen/cms: collection preview redemption failed (${res.status} ${body.code ?? "?"}: ${body.error ?? "no detail"})`);
3989
+ return previewDenied(404, "not found");
3990
+ }
3991
+ // Never cache a draft, anywhere.
3992
+ return new Response(JSON.stringify(body.result), {
3993
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
3994
+ });
3995
+ },
3996
+ },
2117
3997
  {
2118
3998
  method: "GET",
2119
3999
  path: "/robots.txt",