@pramen/cms 0.0.49 → 0.0.51

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,13 +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, JsonValue } from "@pramen/server";
46
- import { filterXSS } from "xss";
52
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef, FieldDef, FieldType } from "@pramen/server";
47
53
  import type { EnvBag } from "@pramen/server";
54
+ import { isSafeHref, normalizeHref } from "./href";
48
55
 
49
56
  // --- field schema DSL (the block-editor field language) ---------------------
50
57
 
@@ -143,8 +150,38 @@ export interface DefaultBlockDefinition {
143
150
  // the schema, exactly like `typeof app.handlers` types the RPC client. (A `pramen cms codegen`
144
151
  // command that emits these types from DB-stored schemas is future work.)
145
152
 
146
- /** A rich-text value — a serialized editor document (or a plain string). */
147
- 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;
148
185
 
149
186
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
150
187
  * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
@@ -342,12 +379,45 @@ function tsTypeOf(f: FieldDefinition): string {
342
379
  return "unknown";
343
380
  }
344
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
+
345
398
  const tsFieldLine = (f: FieldDefinition): string => `${JSON.stringify(f.name)}${f.required ? "" : "?"}: ${tsTypeOf(f)};`;
346
399
 
347
400
  /** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
348
401
  * DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
349
402
  * compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
350
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
+
351
421
  const interfaces = blockTypes
352
422
  .map((bt) => {
353
423
  const fields = Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [];
@@ -356,11 +426,16 @@ export function generateBlockTypes(blockTypes: Array<{ slug: string; fieldsSchem
356
426
  })
357
427
  .join("\n\n");
358
428
  const registry = blockTypes.map((bt) => ` ${JSON.stringify(bt.slug)}: ${pascal(bt.slug)}Fields;`).join("\n");
359
- return (
360
- `// AUTO-GENERATED by @pramen/cms do not edit.\n` +
361
- `import type { ResolvedMedia, RichText } from "@pramen/cms";\n\n` +
362
- `${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`
363
- );
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`;
364
439
  }
365
440
 
366
441
  // --- schema fragment: spread into your defineSchema so the tables migrate --------
@@ -397,6 +472,9 @@ export const cmsSchema = {
397
472
  title: t.text(),
398
473
  fields: t.json(), // content matching the block type's fieldsSchema
399
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),
400
478
  createdAt: defaultTo(t.text(), expr.now()),
401
479
  updatedAt: defaultTo(t.text(), expr.now()),
402
480
  }),
@@ -426,6 +504,11 @@ export const cmsSchema = {
426
504
  // (not "latest by timestamp") so selection is deterministic even when two publishes
427
505
  // land in the same second (expr.now() is second-precision).
428
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()),
429
512
  // SEO
430
513
  metaTitle: t.text(),
431
514
  metaDescription: t.text(),
@@ -435,6 +518,8 @@ export const cmsSchema = {
435
518
  ogDescription: t.text(),
436
519
  ogImage: t.uuid(), // a cms_media id, resolved to a URL at assemble time
437
520
  structuredData: t.json(), // JSON-LD, emitted as-is into <head>
521
+ // Optimistic concurrency — see cms_blocks.version.
522
+ version: defaultTo(t.int(), 1),
438
523
  createdAt: defaultTo(t.text(), expr.now()),
439
524
  updatedAt: defaultTo(t.text(), expr.now()),
440
525
  }),
@@ -491,12 +576,53 @@ export const cmsSchema = {
491
576
  createdAt: defaultTo(t.text(), expr.now()),
492
577
  })),
493
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
+
494
617
  // Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
495
618
  // ctx.files + the Worker /files/* route. Block `fields` reference a media id.
496
619
  cms_media: Entity((t) => ({
497
620
  id: primaryKey(generated(t.uuid())),
498
621
  file: t.fileRef(),
499
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()),
500
626
  createdAt: defaultTo(t.text(), expr.now()),
501
627
  })),
502
628
  };
@@ -508,6 +634,16 @@ export interface ValidateOpts {
508
634
  * writes (addBlock/updateBlock/createPage) pass `false` — a DRAFT block may be incomplete;
509
635
  * required is only mandatory when publishing. Type checks always run. */
510
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;
511
647
  }
512
648
 
513
649
  /** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
@@ -556,7 +692,18 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
556
692
  if (!isSlugString(v)) throw new BadRequest(`field '${at}' must be a slug (lowercase letters, digits and single hyphens)`);
557
693
  break;
558
694
  case "richtext":
559
- 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
+ }
560
707
  break;
561
708
  case "number":
562
709
  if (typeof v !== "number") throw new BadRequest(`field '${at}' must be a number`);
@@ -577,14 +724,35 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
577
724
  // (collectMediaIds/resolveMediaFields only handle string ids).
578
725
  if (typeof v !== "string") throw new BadRequest(`field '${at}' must be a media id (string)`);
579
726
  break;
580
- case "group":
581
- 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
+ });
582
737
  break;
738
+ }
583
739
  case "repeater": {
584
740
  if (!Array.isArray(v)) throw new BadRequest(`field '${at}' must be a list`);
585
741
  if (def.min != null && v.length < def.min) throw new BadRequest(`field '${at}' needs at least ${def.min} item(s)`);
586
742
  if (def.max != null && v.length > def.max) throw new BadRequest(`field '${at}' allows at most ${def.max} item(s)`);
587
- 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
+ }
588
756
  break;
589
757
  }
590
758
  default:
@@ -593,39 +761,216 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
593
761
  }
594
762
  }
595
763
 
596
- // --- 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.
597
771
  //
598
- // richtext fields are HTML the site renders with set:html, so they MUST be sanitized
599
- // before persistence. Client-side scrubbing is not a boundary a caller can POST any
600
- // value straight to these handlers. We sanitize on write against a strict tag/attribute
601
- // allow-list with js-xss (`xss`), which is SYNCHRONOUS and pure-JS — this matters because
602
- // sanitize runs inside the DO's storage.transaction(), where async stream I/O (e.g.
603
- // HTMLRewriter) deadlocks. js-xss drops disallowed tags/attributes and blanks
604
- // javascript:/data: URLs in href/src by default.
605
-
606
- const RT_WHITELIST: Record<string, string[]> = {
607
- p: [], br: [], hr: [], blockquote: [], pre: [], code: [],
608
- strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [], mark: [], sub: [], sup: [],
609
- 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
+ },
610
821
  };
611
- const RT_XSS_OPTS = { whiteList: RT_WHITELIST, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style"] as string[] };
612
822
 
613
- /** Sanitize one richtext HTML string to the allow-list. Synchronous by design. */
614
- function sanitizeRichText(html: string): string {
615
- 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();
616
954
  }
617
955
 
618
- /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
619
- * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
620
- export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: FieldValues): FieldValues {
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 {
621
963
  const defs = Array.isArray(schema) ? schema : [];
622
964
  const out: FieldValues = { ...values };
623
965
  for (const def of defs) {
624
966
  const v = out[def.name];
625
967
  if (v == null) continue;
626
- if (def.type === "richtext" && typeof v === "string") out[def.name] = sanitizeRichText(v);
627
- else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = sanitizeFields(def.fields, v as FieldValues);
628
- else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as FieldValues) : 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));
629
974
  }
630
975
  return out;
631
976
  }
@@ -633,6 +978,8 @@ export function sanitizeFields(schema: FieldDefinition[] | undefined | null, val
633
978
  // --- assembled-page shape (the content-API result + revision snapshot) --------
634
979
 
635
980
  export interface RenderedBlock {
981
+ /** The block's optimistic-concurrency token — pass back as `expectedVersion`. */
982
+ version: number;
636
983
  /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
637
984
  id: string;
638
985
  /** The underlying block instance id (cms_blocks) — used to edit the block's content. */
@@ -677,8 +1024,13 @@ export interface AssembledPage {
677
1024
  metaTitle: string | null;
678
1025
  metaDescription: string | null;
679
1026
  seo: PageSeo;
1027
+ /** Optimistic-concurrency token — pass back as `expectedVersion` on a write. */
1028
+ version: number;
680
1029
  };
681
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;
682
1034
  }
683
1035
 
684
1036
  // --- media -------------------------------------------------------------------
@@ -686,7 +1038,7 @@ export interface AssembledPage {
686
1038
  /** One authored field value inside a block / collection / page `fields` bag. Stored
687
1039
  * as JSON; a `"media"` field is resolved from its stored id to a `ResolvedMedia` at
688
1040
  * assemble time, and `group`/`repeater` fields nest further bags. */
689
- export type FieldValue = JsonValue | ResolvedMedia | FieldValues | FieldValue[];
1041
+ export type FieldValue = JsonValue | ResolvedMedia | RichTextDoc | FieldValues | FieldValue[];
690
1042
 
691
1043
  /** A block / collection / page `fields` bag — field name -> authored value. */
692
1044
  export interface FieldValues {
@@ -856,6 +1208,7 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
856
1208
  (regions[region] ??= []).push({
857
1209
  id: String(m.p.id),
858
1210
  block_id: String(m.block.id),
1211
+ version: typeof m.block.version === "number" ? m.block.version : 1,
859
1212
  block_type: typeById.get(String(m.block.typeId))?.slug ?? "unknown",
860
1213
  title: (m.block.title as string | null) ?? null,
861
1214
  fields: resolveMediaFields(m.fields, m.schema, mediaById),
@@ -918,6 +1271,7 @@ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[]
918
1271
  slug: String(page.slug),
919
1272
  status: String(page.status),
920
1273
  locale: String(page.locale ?? "en"),
1274
+ version: typeof page.version === "number" ? page.version : 1,
921
1275
  contentType,
922
1276
  translationGroupId: (page.translationGroupId as string | null) ?? null,
923
1277
  translations,
@@ -966,17 +1320,87 @@ async function assertRegionAllows(db: CmsDb, page: Record<string, unknown>, regi
966
1320
 
967
1321
  // --- handlers ----------------------------------------------------------------
968
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
+
969
1376
  export interface CmsHandlerOpts {
970
1377
  /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
971
1378
  * `["editor", "admin"]`. */
972
1379
  editorRoles?: readonly string[];
973
1380
  /** Max accepted media upload size in bytes (enforced at the Worker). Default 25 MB. */
974
1381
  mediaMaxSize?: number;
975
- /** Default locale used when `getPage`/`createPage` omit one. Default `"en"`. */
976
- defaultLocale?: string;
1382
+ /** The locales this deployment publishes in, most-preferred first. Default `["en"]`.
1383
+ *
1384
+ * DECLARED, not inferred. The editor renders its i18n surface — the Translations panel,
1385
+ * the Locale field, the per-row locale column — only when there is more than one, and
1386
+ * `listCmsCapabilities` is how it finds out. Inferring "is this site multilingual?" from
1387
+ * the locales PRESENT IN DATA cannot work: the only way to create a second locale is
1388
+ * `createTranslation`, which the editor exposes from inside the very panel that would
1389
+ * stay hidden, so a monolingual site could never become multilingual.
1390
+ *
1391
+ * The first entry is the default stamped on a page created without one, which is why
1392
+ * `defaultLocale` is derived from this rather than configured beside it — two options
1393
+ * that can disagree about the same fact is how a Czech-only site ends up stamping "en". */
1394
+ locales?: readonly string[];
977
1395
  /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
978
1396
  * Default `["reviewer", "admin"]`. */
979
1397
  reviewerRoles?: readonly string[];
1398
+ /** Preview-link lifetime in seconds. Default 3600 (1 hour). */
1399
+ previewTtlSeconds?: number;
1400
+ /** The node/mark vocabulary accepted on write. Defaults to `DEFAULT_RICH_TEXT_SCHEMA`
1401
+ * (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
1402
+ * a node type absent from the schema is DROPPED on write, not rejected. */
1403
+ richTextSchema?: RichTextSchema;
980
1404
  }
981
1405
 
982
1406
  /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
@@ -985,9 +1409,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
985
1409
  const editorRoles = opts.editorRoles ?? ["editor", "admin"];
986
1410
  const editor = { auth: editorRoles };
987
1411
  const mediaMaxSize = opts.mediaMaxSize ?? 25_000_000;
988
- const defaultLocale = opts.defaultLocale ?? "en";
1412
+ const locales = opts.locales && opts.locales.length > 0 ? [...opts.locales] : ["en"];
1413
+ const defaultLocale = locales[0]!;
989
1414
  const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
990
1415
  const reviewer = { auth: reviewerRoles };
1416
+ const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
1417
+ const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
991
1418
  // Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
992
1419
  // content type/blocks before approving). Read/preview handlers use this; writes stay editor.
993
1420
  const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
@@ -999,14 +1426,86 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
999
1426
  const writeAudit = (db: CmsDb, e: { pageId: string; action: string; from?: string; to?: string; actor: string | null; note?: string }) =>
1000
1427
  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 });
1001
1428
 
1429
+ const mediaIdInput = {
1430
+ input: (raw: unknown): { id: string } => {
1431
+ const o = asObj(raw);
1432
+ if (typeof o.id !== "string" || o.id === "") throw new BadRequest("id is required");
1433
+ return { id: o.id };
1434
+ },
1435
+ };
1436
+
1437
+ /** Mark a table changed after a RAW `exec` write.
1438
+ *
1439
+ * `Db.exec` is the one write path that does not record `touched`, so the DO never
1440
+ * broadcasts and every live subscriber keeps showing the pre-write state — a restored
1441
+ * page stays missing from an open page list, a purged one stays present. `deletePage`
1442
+ * goes through the ORM and DOES broadcast, so the staleness was asymmetric and read
1443
+ * like a lost write. */
1444
+ const markChanged = (db: CmsDb, ...tables: string[]): void => {
1445
+ const touched = (db as unknown as { touched?: Set<string> }).touched;
1446
+ if (touched) for (const t of tables) touched.add(t);
1447
+ };
1448
+
1449
+ // --- optimistic concurrency ------------------------------------------------
1450
+ //
1451
+ // On the DO — the default store — a read-then-write inside one mutation is atomic: the
1452
+ // Durable Object is a single writer and DoSqliteDriver.exec is synchronous. The EDITORS
1453
+ // are not serialized, though: two people on the same page means last save wins, silently,
1454
+ // with no signal to the loser. Passing back the `version` you read turns that into a 409.
1455
+ //
1456
+ // CAVEAT — the D1 store has no interactive transaction (D1Driver.transaction is a
1457
+ // pass-through), so two requests in the same millisecond can both read and both write.
1458
+ // The guard still catches the human-scale editor race; it is not a hard mutex there.
1459
+ //
1460
+ // Optional by design: omitting `expectedVersion` keeps last-write-wins, so nothing breaks.
1461
+ const nextVersion = (row: Record<string, unknown>, expected: number | undefined, label: string): number => {
1462
+ // Do NOT default a missing version to 1. Under a field-restricted read grant that
1463
+ // projected the column away, `current` would be 1 forever: a client that legitimately
1464
+ // read version 7 gets a permanent unresolvable 409, and an unguarded save then LOWERS
1465
+ // the stored version, so a genuinely stale write is accepted later.
1466
+ if (typeof row.version !== "number") {
1467
+ // Log the actionable detail, return a generic 500 — PramenError's message goes to the
1468
+ // caller verbatim, so naming the column would leak the schema and ACL shape.
1469
+ console.error(`pramen/cms: ${label} has no readable version — grant read on the \`version\` column`);
1470
+ throw new Error("version unavailable");
1471
+ }
1472
+ const current = row.version;
1473
+ if (expected !== undefined && expected !== current) {
1474
+ throw new Conflict(`${label} was changed by someone else (you have version ${expected}, current is ${current}) — reload and reapply your edit`);
1475
+ }
1476
+ return current + 1;
1477
+ };
1478
+ const versionInput = (o: Record<string, unknown>): void => {
1479
+ if (o.expectedVersion === undefined) return;
1480
+ if (typeof o.expectedVersion !== "number" || !Number.isInteger(o.expectedVersion)) {
1481
+ throw new BadRequest("expectedVersion must be an integer");
1482
+ }
1483
+ };
1484
+
1485
+ const pageIdInput = {
1486
+ input: (raw: unknown): { pageId: string } => {
1487
+ const o = asObj(raw);
1488
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
1489
+ return { pageId: o.pageId };
1490
+ },
1491
+ };
1492
+
1002
1493
  // (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
1003
1494
  const assertSlugFree = async (db: CmsDb, slug: string, locale: string, exceptId?: string): Promise<void> => {
1004
1495
  const rows = await db.exec(
1005
- "SELECT id FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
1496
+ "SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1",
1006
1497
  slug,
1007
1498
  locale,
1008
1499
  );
1009
- if (rows[0] && String(rows[0].id) !== exceptId) throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
1500
+ if (rows[0] && String(rows[0].id) !== exceptId) {
1501
+ // A trashed page keeps its slug until purged (the (slug, locale) unique index is a
1502
+ // DB constraint, not advisory). Say so, rather than leave the caller hunting for a
1503
+ // page they cannot see.
1504
+ if (rows[0].deletedAt != null) {
1505
+ throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
1506
+ }
1507
+ throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
1508
+ }
1010
1509
  };
1011
1510
 
1012
1511
  const TASK_PUBLISH = "cms:publish";
@@ -1174,16 +1673,19 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1174
1673
  },
1175
1674
  }),
1176
1675
 
1177
- /** Delete a media row AND its R2 blob. (Automatic orphan sweeping media no longer
1178
- * referenced by any block is future work; refs live inside opaque block JSON.) */
1676
+ /** Trash a media row. The R2 OBJECT IS KEPTdeleting the bytes here would make
1677
+ * `restoreMedia` a lie, and a block still referencing the id would render a dead url
1678
+ * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
1679
+ * the id again, since every ACL-scoped read hides it from here on.
1680
+ *
1681
+ * (Automatic orphan sweeping — media no longer referenced by any block — is still
1682
+ * future work; refs live inside opaque block JSON.) */
1179
1683
  deleteMedia: mutation(async (ctx, input: { id: string }) => {
1180
1684
  const db = cdb(ctx);
1181
1685
  const rows = await db.find({ from: "cms_media", where: { id: input.id }, limit: 1 });
1182
1686
  const media = rows[0];
1183
1687
  if (!media) throw notFound("media");
1184
- const key = String(asObj(media.file).key ?? "");
1185
- await db.delete("cms_media", input.id);
1186
- if (key) await ctx.files.delete(key).catch(() => {});
1688
+ await db.update("cms_media", input.id, { deletedAt: new Date().toISOString() });
1187
1689
  return { ok: true };
1188
1690
  }, {
1189
1691
  ...editor,
@@ -1194,6 +1696,32 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1194
1696
  },
1195
1697
  }),
1196
1698
 
1699
+ restoreMedia: mutation(async (ctx, input: { id: string }) => {
1700
+ const db = cdb(ctx);
1701
+ const rows = await db.exec("SELECT id FROM cms_media WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.id);
1702
+ if (!rows[0]) throw notFound("trashed media");
1703
+ await db.exec("UPDATE cms_media SET deletedAt = NULL WHERE id = ?", input.id);
1704
+ markChanged(db, "cms_media");
1705
+ return { ok: true as const };
1706
+ }, { ...editor, ...mediaIdInput }),
1707
+
1708
+ /** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
1709
+ * irreversible; the blob is gone. */
1710
+ purgeMedia: mutation(async (ctx, input: { id: string }) => {
1711
+ const db = cdb(ctx);
1712
+ const rows = await db.exec("SELECT id, file FROM cms_media WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.id);
1713
+ const media = rows[0];
1714
+ if (!media) throw notFound("trashed media"); // purging live media is refused — trash it first
1715
+ // `file` comes back raw from exec (the object↔JSON codec sits on the ORM path, not
1716
+ // this one), so parse it before reaching for the key.
1717
+ const file = typeof media.file === "string" ? (JSON.parse(media.file) as { key?: string }) : asObj(media.file);
1718
+ const key = String(file.key ?? "");
1719
+ await db.exec("DELETE FROM cms_media WHERE id = ?", input.id);
1720
+ markChanged(db, "cms_media");
1721
+ if (key) await ctx.files.delete(key).catch(() => {});
1722
+ return { ok: true as const };
1723
+ }, { ...reviewer, ...mediaIdInput }),
1724
+
1197
1725
  listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
1198
1726
 
1199
1727
  getContentType: query(async (ctx, input: { id: string }) => {
@@ -1225,9 +1753,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1225
1753
  }),
1226
1754
 
1227
1755
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
1228
- 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 }) => {
1756
+ 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 }) => {
1229
1757
  const db = cdb(ctx);
1230
- const patch: Record<string, unknown> = { updatedAt: nowStamp() };
1758
+ // Read first so the version can be compared; this patched blind before.
1759
+ const seoRows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1760
+ if (!seoRows[0]) throw notFound("page");
1761
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(seoRows[0], input.expectedVersion, "this page") };
1231
1762
  for (const k of ["metaTitle", "metaDescription", "canonicalUrl", "robots", "ogTitle", "ogDescription", "ogImage"] as const) {
1232
1763
  if (k in input) patch[k] = (input as Record<string, unknown>)[k];
1233
1764
  }
@@ -1240,6 +1771,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1240
1771
  input: (raw): { pageId: string } => {
1241
1772
  const o = asObj(raw);
1242
1773
  if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
1774
+ versionInput(o);
1243
1775
  return o as never;
1244
1776
  },
1245
1777
  }),
@@ -1249,13 +1781,13 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1249
1781
  * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
1250
1782
  * page record itself, which was previously only settable at createPage. A slug/locale
1251
1783
  * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
1252
- * + sanitized against the content type's fieldsSchema, exactly like createPage. */
1253
- updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues }) => {
1784
+ * + normalized against the content type's fieldsSchema, exactly like createPage. */
1785
+ updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues; expectedVersion?: number }) => {
1254
1786
  const db = cdb(ctx);
1255
1787
  const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1256
1788
  const page = rows[0];
1257
1789
  if (!page) throw notFound("page");
1258
- const patch: Record<string, unknown> = { updatedAt: nowStamp() };
1790
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: nextVersion(page, input.expectedVersion, "this page") };
1259
1791
  if (input.title !== undefined) patch.title = input.title;
1260
1792
  if (input.slug !== undefined || input.locale !== undefined) {
1261
1793
  const nextSlug = input.slug ?? String(page.slug);
@@ -1267,20 +1799,22 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1267
1799
  if (input.fields !== undefined) {
1268
1800
  const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
1269
1801
  const schema = ctRows[0]?.fieldsSchema as FieldDefinition[] | undefined;
1270
- validateFields(schema, input.fields, "page.fields", { requireRequired: false });
1271
- patch.fields = await sanitizeFields(schema, input.fields);
1802
+ // Same whole-bag autosave as updateBlock — tolerate a stored legacy value.
1803
+ validateFields(schema, input.fields, "page.fields", { requireRequired: false, legacyBaseline: asObj(page.fields) as FieldValues });
1804
+ patch.fields = normalizeFields(schema, input.fields, rtSchema);
1272
1805
  }
1273
1806
  const updated = await db.update("cms_pages", input.pageId, patch);
1274
1807
  if (!updated) throw notFound("page");
1275
1808
  return { ok: true, page: updated };
1276
1809
  }, {
1277
1810
  ...editor,
1278
- input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues } => {
1811
+ input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: FieldValues; expectedVersion?: number } => {
1279
1812
  const o = asObj(raw);
1280
1813
  if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
1281
1814
  for (const k of ["title", "slug", "locale"] as const) {
1282
1815
  if (o[k] !== undefined && typeof o[k] !== "string") throw new BadRequest(`${k} must be a string`);
1283
1816
  }
1817
+ versionInput(o);
1284
1818
  return o as never;
1285
1819
  },
1286
1820
  }),
@@ -1292,7 +1826,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1292
1826
  const ct = ctRows[0];
1293
1827
  if (!ct) throw new BadRequest("unknown content type");
1294
1828
  validateFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "page.fields", { requireRequired: false });
1295
- const cleanPageFields = await sanitizeFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
1829
+ const cleanPageFields = normalizeFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, rtSchema);
1296
1830
  const locale = input.locale ?? defaultLocale;
1297
1831
  await assertSlugFree(db, input.slug, locale);
1298
1832
 
@@ -1316,7 +1850,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1316
1850
  if (!bts[0]) throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
1317
1851
  await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
1318
1852
  validateFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {}, "", { requireRequired: false });
1319
- const cleanDefault = await sanitizeFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {});
1853
+ const cleanDefault = normalizeFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {}, rtSchema);
1320
1854
  const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
1321
1855
  const position = await nextPosition(db, String(page.id), d.region);
1322
1856
  await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
@@ -1354,8 +1888,22 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1354
1888
  group = crypto.randomUUID();
1355
1889
  await db.update("cms_pages", String(src.id), { translationGroupId: group });
1356
1890
  }
1357
- const existing = await db.find({ from: "cms_pages", where: { translationGroupId: group, locale: input.locale }, limit: 1 });
1358
- if (existing[0]) throw new BadRequest(`a '${input.locale}' translation already exists`);
1891
+ // Raw exec, like assertSlugFree: a check-then-act uniqueness guard must see TRASHED
1892
+ // rows too. Through ctx.db the read scope hides them, so trashing a `cs` translation
1893
+ // let a second one be created, and restoring the first left two live `cs` pages in
1894
+ // one group — two <link rel="alternate" hreflang="cs"> on every sibling.
1895
+ const existing = await db.exec(
1896
+ "SELECT id, deletedAt FROM cms_pages WHERE translationGroupId = ? AND locale = ? LIMIT 1",
1897
+ group,
1898
+ input.locale,
1899
+ );
1900
+ if (existing[0]) {
1901
+ throw new BadRequest(
1902
+ existing[0].deletedAt != null
1903
+ ? `a '${input.locale}' translation exists in the trash — restore or purge it first`
1904
+ : `a '${input.locale}' translation already exists`,
1905
+ );
1906
+ }
1359
1907
  const slug = input.slug ?? String(src.slug);
1360
1908
  await assertSlugFree(db, slug, input.locale);
1361
1909
  return db.insert("cms_pages", {
@@ -1396,9 +1944,22 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1396
1944
  },
1397
1945
  }),
1398
1946
 
1399
- /** Distinct locales present across all pages. */
1947
+ /** What this deployment supports, for an editor to render against — the pages-side
1948
+ * counterpart to `listCollections`' `supports: [...]`.
1949
+ *
1950
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
1951
+ * a client flag can hide a control but cannot make the data right, and the two drift
1952
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
1953
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1954
+ listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1 }), viewer),
1955
+
1956
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1957
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1958
+ * declares; these two differ while a locale is declared but not yet authored. */
1400
1959
  listLocales: query(async (ctx) => {
1401
- const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages ORDER BY locale");
1960
+ // Raw exec bypasses the ACL, so the trash filter has to be written out by hand —
1961
+ // otherwise the editor's locale switcher offers a locale with zero live pages.
1962
+ const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages WHERE deletedAt IS NULL ORDER BY locale");
1402
1963
  return rows.map((r) => String(r.locale ?? "en"));
1403
1964
  }, viewer),
1404
1965
 
@@ -1414,7 +1975,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1414
1975
  const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
1415
1976
  await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
1416
1977
  validateFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "", { requireRequired: false });
1417
- const cleanFields = await sanitizeFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
1978
+ const cleanFields = normalizeFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, rtSchema);
1418
1979
 
1419
1980
  const block = await db.insert("cms_blocks", {
1420
1981
  typeId: bt.id,
@@ -1461,8 +2022,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1461
2022
  await assertRegionAllows(db, page, input.region, slug);
1462
2023
  let cleanOverrides: Record<string, unknown> | null = input.overrides ?? null;
1463
2024
  if (input.overrides !== undefined) {
1464
- validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
1465
- cleanOverrides = await sanitizeFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides);
2025
+ // The merged bag includes the block's OWN stored fields, which may predate Portable
2026
+ // Text. Tolerate a legacy string there so an untouched legacy block can still be
2027
+ // placed; the overrides themselves are new input and stay strict below.
2028
+ validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false, legacyBaseline: asObj(block.fields) as FieldValues });
2029
+ validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides, "", { requireRequired: false });
2030
+ cleanOverrides = normalizeFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides, rtSchema);
1466
2031
  }
1467
2032
  const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
1468
2033
  return db.insert("cms_page_blocks", {
@@ -1498,26 +2063,33 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1498
2063
  }),
1499
2064
 
1500
2065
  /** Update a block's content (re-validated against its type's field schema). */
1501
- updateBlock: mutation(async (ctx, input: { blockId: string; fields?: FieldValues; title?: string }) => {
2066
+ updateBlock: mutation(async (ctx, input: { blockId: string; fields?: FieldValues; title?: string; expectedVersion?: number }) => {
1502
2067
  const db = cdb(ctx);
1503
2068
  const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
1504
2069
  const block = rows[0];
1505
2070
  if (!block) throw notFound("block");
2071
+ // Conflict first, like updatePage: a stale write carrying invalid fields should say
2072
+ // "someone else changed this", not 400 on content the caller is about to discard.
2073
+ const blockVersion = nextVersion(block, input.expectedVersion, "this block");
1506
2074
  let cleanFields = input.fields;
1507
2075
  if (input.fields !== undefined) {
1508
2076
  const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
1509
- validateFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, "", { requireRequired: false });
1510
- cleanFields = await sanitizeFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields);
2077
+ // The editor autosaves the WHOLE fields bag ~800ms after any edit, so a legacy
2078
+ // richtext value the author never touched rides along with an unrelated change.
2079
+ // Rejecting it would 400 on every keystroke and make the block unsaveable.
2080
+ validateFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, "", { requireRequired: false, legacyBaseline: asObj(block.fields) as FieldValues });
2081
+ cleanFields = normalizeFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, rtSchema);
1511
2082
  }
1512
- const patch: Record<string, unknown> = { updatedAt: nowStamp() };
2083
+ const patch: Record<string, unknown> = { updatedAt: nowStamp(), version: blockVersion };
1513
2084
  if (cleanFields !== undefined) patch.fields = cleanFields;
1514
2085
  if (input.title !== undefined) patch.title = input.title;
1515
2086
  return db.update("cms_blocks", input.blockId, patch);
1516
2087
  }, {
1517
2088
  ...editor,
1518
- input: (raw): { blockId: string; fields?: FieldValues; title?: string } => {
2089
+ input: (raw): { blockId: string; fields?: FieldValues; title?: string; expectedVersion?: number } => {
1519
2090
  const o = asObj(raw);
1520
2091
  if (typeof o.blockId !== "string") throw new BadRequest("blockId is required");
2092
+ versionInput(o);
1521
2093
  return o as never;
1522
2094
  },
1523
2095
  }),
@@ -1733,10 +2305,175 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1733
2305
  }),
1734
2306
 
1735
2307
  // ---- public content API ----
2308
+ /** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
2309
+ * anyone holding the resulting link can redeem it, which is the point. */
2310
+ signPagePreview: query(async (ctx, input: { pageId: string; expiresIn?: number }) => {
2311
+ const secret = previewSecret(ctx.env);
2312
+ if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
2313
+ // The redeem route always reaches a Durable Object (callPrivileged -> PRAMEN.get); it
2314
+ // has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
2315
+ // link that 404s forever while the editor reports success — refuse instead of
2316
+ // handing out a token that cannot work.
2317
+ if (ctx.store === "d1") throw new PramenError("page preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
2318
+ const db = cdb(ctx);
2319
+ // Read the page through the ACL first: minting a link is granting access to it, so a
2320
+ // caller who cannot read the page must not be able to mint a link that can.
2321
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
2322
+ const page = rows[0];
2323
+ if (!page) throw notFound("page");
2324
+
2325
+ const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
2326
+ const exp = Math.floor(Date.now() / 1000) + ttl;
2327
+ // Server-resolved, never caller-supplied — so the tenant inside the signature
2328
+ // cannot be steered by whoever asks for the link.
2329
+ const tenant = ctx.tenant;
2330
+ const token = await signToken<PreviewToken>({ t: tenant, p: String(page.id), exp }, secret);
2331
+ // RELATIVE, like signed file urls — the client resolves it against the CMS origin.
2332
+ return { url: `${PREVIEW_PATH}?token=${encodeURIComponent(token)}`, token, expiresAt: exp * 1000 };
2333
+ }, {
2334
+ ...editor,
2335
+ input: (raw): { pageId: string; expiresIn?: number } => {
2336
+ const o = asObj(raw);
2337
+ // Unvalidated, a non-string pageId reached the query compiler and surfaced as a
2338
+ // 500, and a string expiresIn made exp NaN — minting a link that always 403s,
2339
+ // with nothing anywhere to explain why.
2340
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
2341
+ if (o.expiresIn !== undefined && (typeof o.expiresIn !== "number" || !Number.isFinite(o.expiresIn))) {
2342
+ throw new BadRequest("expiresIn must be a number of seconds");
2343
+ }
2344
+ return o as never;
2345
+ },
2346
+ }),
2347
+
2348
+ /** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
2349
+ * `GET /cms/preview` route, which verifies the token and then calls this privileged.
2350
+ * Role-gated so it is not an anonymous back door on the /rpc surface. */
2351
+ getPagePreview: query(async (ctx, input: { pageId: string }) => {
2352
+ const db = cdb(ctx);
2353
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
2354
+ const page = rows[0];
2355
+ if (!page) throw notFound("page");
2356
+ const assembled = await assembleLive(db, page);
2357
+ assembled.isPreview = true;
2358
+ return assembled;
2359
+ }, {
2360
+ ...viewer,
2361
+ input: (raw): { pageId: string } => {
2362
+ const o = asObj(raw);
2363
+ if (typeof o.pageId !== "string" || o.pageId === "") throw new BadRequest("pageId is required");
2364
+ return { pageId: o.pageId };
2365
+ },
2366
+ }),
2367
+
1736
2368
  /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
1737
2369
  * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
1738
2370
  * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
1739
2371
  * to the configured default locale; a slug is unique per locale. */
2372
+ // --- trash: soft delete, restore, purge ---------------------------------
2373
+ //
2374
+ // A page had NO delete handler at all before this: once created it could only be
2375
+ // unpublished, never removed. Delete is therefore introduced already soft — the row
2376
+ // stays, `deletedAt` is stamped, and the ACL's read scope hides it everywhere.
2377
+ //
2378
+ // A trashed page KEEPS ITS SLUG. `(slug, locale)` is a DB unique constraint, so the
2379
+ // alternatives were mangling the stored slug on delete or dropping the constraint —
2380
+ // both worse than telling the caller plainly that the slug is in the trash. Purging
2381
+ // frees it.
2382
+
2383
+ deletePage: mutation(async (ctx, input: { pageId: string }) => {
2384
+ const db = cdb(ctx);
2385
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
2386
+ if (!rows[0]) throw notFound("page"); // already trashed reads as absent — the scope hides it
2387
+ const now = new Date().toISOString();
2388
+ // Clear the schedule. The publish/unpublish tasks run on the SYSTEM task context,
2389
+ // where the ACL is bypassed entirely — so the `deletedAt IS NULL` read scope does
2390
+ // NOT protect them, and a page trashed before its scheduled time was republished,
2391
+ // publicly live, with a fresh revision and nobody pressing publish. Clearing the
2392
+ // timestamps makes the tasks' existing intent-token check reject both for free.
2393
+ await db.update("cms_pages", input.pageId, { deletedAt: now, updatedAt: now, scheduledAt: null, unpublishAt: null });
2394
+ await writeAudit(db, { pageId: input.pageId, action: "delete", from: String(rows[0].status ?? ""), to: "trashed", actor: actorOf(ctx) });
2395
+ return { ok: true as const, deletedAt: now };
2396
+ }, { ...editor, ...pageIdInput }),
2397
+
2398
+ /** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
2399
+ * the ACL read scope hides exactly these rows: that is the scope doing its job, not a
2400
+ * hole to patch.
2401
+ *
2402
+ * Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
2403
+ * `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
2404
+ * could ever be called with its id again, while `/media/<key>` kept serving the bytes
2405
+ * (that route streams from R2 with no DB lookup at all). */
2406
+ listTrash: query(async (ctx, input: { limit?: number }) => {
2407
+ // Truncate like listMedia/listPageAudit — a fractional LIMIT reaches SQLite and 500s,
2408
+ // and any client computing `total / pages` sends one.
2409
+ const limit = Math.min(Math.max(Math.trunc(Number(input.limit)) || 50, 1), 200);
2410
+ const db = cdb(ctx);
2411
+ const pages = await db.exec(
2412
+ "SELECT id, title, slug, locale, status, deletedAt FROM cms_pages WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC LIMIT ?",
2413
+ limit,
2414
+ );
2415
+ const rawMedia = await db.exec(
2416
+ "SELECT id, alt, file, deletedAt FROM cms_media WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC LIMIT ?",
2417
+ limit,
2418
+ );
2419
+ // The fileRef object<->JSON codec sits on the ORM path, not raw exec — parse here or
2420
+ // a trash UI reusing the media card renders `/media/undefined`.
2421
+ const media = rawMedia.map((m) => ({ ...m, file: typeof m.file === "string" ? (JSON.parse(m.file) as JsonValue) : m.file }));
2422
+ return { pages, media };
2423
+ }, { ...viewer, input: (raw): { limit?: number } => {
2424
+ const o = asObj(raw);
2425
+ if (o.limit !== undefined && typeof o.limit !== "number") throw new BadRequest("limit must be a number");
2426
+ return o as never;
2427
+ } }),
2428
+
2429
+ restorePage: mutation(async (ctx, input: { pageId: string }) => {
2430
+ const db = cdb(ctx);
2431
+ 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);
2432
+ const page = rows[0];
2433
+ if (!page) throw notFound("trashed page");
2434
+ // Defensive: the trashed row still occupies the (slug, locale) unique index, so in
2435
+ // practice nothing can have taken the slug. Kept so a future change that DOES free
2436
+ // the slug on delete surfaces as a clean 400 rather than a constraint violation.
2437
+ await assertSlugFree(db, String(page.slug), String(page.locale), String(page.id));
2438
+ await db.exec("UPDATE cms_pages SET deletedAt = NULL, updatedAt = ? WHERE id = ?", new Date().toISOString(), input.pageId);
2439
+ markChanged(db, "cms_pages");
2440
+ await writeAudit(db, { pageId: input.pageId, action: "restore", from: "trashed", to: String(page.status ?? ""), actor: actorOf(ctx) });
2441
+ // deletePage had to clear any schedule (the publish task runs SYSTEM-scoped, outside
2442
+ // the read scope). Restore cannot know what it was, so SAY so — otherwise a promo
2443
+ // page due to auto-unpublish comes back live forever with nothing in the audit trail.
2444
+ return { ok: true as const, scheduleCleared: page.scheduledAt != null || page.unpublishAt != null };
2445
+ }, { ...editor, ...pageIdInput }),
2446
+
2447
+ /** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
2448
+ * this is the only irreversible operation in the CMS. */
2449
+ purgePage: mutation(async (ctx, input: { pageId: string }) => {
2450
+ const db = cdb(ctx);
2451
+ const rows = await db.exec("SELECT id FROM cms_pages WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.pageId);
2452
+ if (!rows[0]) throw notFound("trashed page"); // purging a LIVE page is refused — trash it first
2453
+ // Placements, revisions and audit rows are logical relations (no FK cascade), so
2454
+ // clear them explicitly or they outlive the page as orphans.
2455
+ //
2456
+ // The BLOCKS themselves need the same treatment, and it has to happen before the
2457
+ // placements go: a non-reusable block used only by this page becomes unreachable
2458
+ // once its last placement is deleted (there is no listBlocks, and removeBlock needs
2459
+ // a pageBlockId that no longer exists). Mirrors removeBlock's own GC.
2460
+ const doomed = await db.exec(
2461
+ `SELECT b.id AS id FROM cms_blocks b
2462
+ JOIN cms_page_blocks pb ON pb.blockId = b.id
2463
+ WHERE pb.pageId = ? AND b.isReusable = 0
2464
+ AND NOT EXISTS (SELECT 1 FROM cms_page_blocks o WHERE o.blockId = b.id AND o.pageId <> ?)`,
2465
+ input.pageId,
2466
+ input.pageId,
2467
+ );
2468
+ await db.exec("DELETE FROM cms_page_blocks WHERE pageId = ?", input.pageId);
2469
+ for (const row of doomed) await db.exec("DELETE FROM cms_blocks WHERE id = ?", String(row.id));
2470
+ await db.exec("DELETE FROM cms_page_revisions WHERE pageId = ?", input.pageId);
2471
+ await db.exec("DELETE FROM cms_audit WHERE pageId = ?", input.pageId);
2472
+ await db.exec("DELETE FROM cms_pages WHERE id = ?", input.pageId);
2473
+ markChanged(db, "cms_pages", "cms_page_blocks", "cms_blocks", "cms_page_revisions", "cms_audit");
2474
+ return { ok: true as const };
2475
+ }, { ...reviewer, ...pageIdInput }),
2476
+
1740
2477
  getPage: query(async (ctx, input: { slug: string; locale?: string; preview?: boolean }) => {
1741
2478
  const db = cdb(ctx);
1742
2479
  // Preview is an editor capability — gate it before the lookup so a non-editor gets a
@@ -1747,7 +2484,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1747
2484
  const page = rows[0];
1748
2485
  if (!page) throw notFound("page"); // also the anonymous-vs-draft case: ACL yields no row
1749
2486
 
1750
- if (input.preview) return assembleLive(db, page);
2487
+ if (input.preview) {
2488
+ const live = await assembleLive(db, page);
2489
+ live.isPreview = true; // same flag the token route sets, so a banner works either way
2490
+ return live;
2491
+ }
1751
2492
  // Public path: serve the page's current published revision snapshot (selected by the
1752
2493
  // page's `currentRevisionId` pointer — deterministic, unlike ordering by a
1753
2494
  // second-precision timestamp). We do NOT assemble live here: anonymous has no read
@@ -1766,6 +2507,11 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1766
2507
  // those keys, but AssembledPage now types them as present — backfill from the live
1767
2508
  // page row so a frontend head template never hits `page.seo` === undefined.
1768
2509
  if (!snap.page.seo) snap.page.seo = pageMeta(page).seo;
2510
+ // `version` from the LIVE row, never the snapshot: a snapshot is baked at publish
2511
+ // time, so a client echoing it would 409 forever after the first draft edit — and
2512
+ // a pre-`version` snapshot has none at all, which (typed `number`) silently drops
2513
+ // out of the request body and reverts to the last-write-wins this feature removes.
2514
+ snap.page.version = typeof page.version === "number" ? page.version : 1;
1769
2515
  if (snap.page.translationGroupId === undefined) snap.page.translationGroupId = (page.translationGroupId as string | null) ?? null;
1770
2516
  return snap;
1771
2517
  }
@@ -1803,11 +2549,31 @@ export interface CmsPolicyOpts {
1803
2549
  * `editor` grants full CRUD across every cms_ table. */
1804
2550
  export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; editor: Policy[] } {
1805
2551
  const p = opts.prefix ?? "cms";
2552
+ // `cms_collection_revisions` is deliberately NOT here: it is append-only, and this loop
2553
+ // grants update AND delete. Spreading both fragments (which every wiring in the README
2554
+ // does) would otherwise hand every editor the ability to rewrite or purge history through
2555
+ // any app handler, silently overriding the read+create grant `collectionPolicies` emits —
2556
+ // duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
2557
+ // The collection half owns that table's grant; see `collectionPolicies`.
1806
2558
  const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"] as const;
2559
+ // Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
2560
+ // AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
2561
+ // API, the editor, relation traversals and eager-loads at once — where a per-handler
2562
+ // `where` would have to be remembered at ~40 call sites and would be wrong the first
2563
+ // time someone forgot. The trash itself is read with `ctx.db.exec` (below), which is the
2564
+ // documented raw escape hatch and deliberately outside this scope.
2565
+ const notTrashed = { where: { deletedAt: { isNull: true } } };
2566
+ const softDeleted: Record<string, true> = { cms_pages: true, cms_media: true };
1807
2567
  const editorPolicies: Policy[] = [];
1808
2568
  for (const table of tables) {
1809
2569
  for (const action of ["read", "create", "update", "delete"] as const) {
1810
- editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, allow()));
2570
+ // UPDATE is scoped as well as READ. Handlers that read the row first already 404 on
2571
+ // a trashed page, but `updatePageSeo`/`updateMedia` patched blind — so an editor with
2572
+ // a stale tab could mutate a page a colleague had just trashed, and the write echo
2573
+ // handed back the whole hidden row. Scoping the grant covers every future write
2574
+ // handler too, rather than relying on each one remembering to read first.
2575
+ const scoped = (action === "read" || action === "update") && softDeleted[table];
2576
+ editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, scoped ? notTrashed : allow()));
1811
2577
  }
1812
2578
  }
1813
2579
  return {
@@ -1817,14 +2583,18 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
1817
2583
  // can route/render by type. Slugs/names are structural, not sensitive.
1818
2584
  policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
1819
2585
  // Only published pages are readable; the snapshot carries the content.
1820
- policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
2586
+ policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published", deletedAt: { isNull: true } } }),
1821
2587
  // getPage reads the latest revision snapshot. Scope the grant by the revision's
1822
2588
  // PAGE being currently published (a relation-traversal where, compiled to a
1823
2589
  // subquery), so a revision of a later-unpublished/archived page is never publicly
1824
2590
  // readable — least-privilege even for a future revision-listing handler.
1825
- policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published" } } }),
2591
+ // `deletedAt` as well as `status`: getPage 404s on the page lookup first today, so
2592
+ // this is defense in depth — but a revision snapshot is a BAKED copy of the page's
2593
+ // content, and a future revision-listing handler reading it directly would otherwise
2594
+ // serve a trashed page's body.
2595
+ policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
1826
2596
  // Media metadata is public (the bytes are separately gated by signed urls).
1827
- policy(`${p}:public:media:read`, "cms_media", "read", allow()),
2597
+ policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
1828
2598
  ],
1829
2599
  editor: editorPolicies,
1830
2600
  };
@@ -1840,8 +2610,9 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
1840
2610
  // list + form UI, without being bent into a cms_pages row.
1841
2611
  //
1842
2612
  // Column-mapped: each scalar FieldDefinition.name is a real column on the entity; a
1843
- // repeater/group field maps to a t.json() column (the object↔JSON codec at the Db
1844
- // chokepoint stores it transparently). The generic handlers dispatch through a registry
2613
+ // repeater/group/richtext field maps to a t.json() column (the object↔JSON codec at the
2614
+ // Db chokepoint stores it transparently). `richtext` belongs with the latter group — its
2615
+ // value is a document tree, and a TEXT column would bind the object raw and be rejected. The generic handlers dispatch through a registry
1845
2616
  // keyed by `slug`, so `collection`/`entity` can never be spoofed to reach an arbitrary
1846
2617
  // table, and writes are whitelisted to declared fields — the client can't set columns the
1847
2618
  // collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
@@ -1874,6 +2645,11 @@ export interface CollectionDef {
1874
2645
  readonly idField?: string;
1875
2646
  /** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
1876
2647
  readonly orderBy?: { column: string; dir?: "asc" | "desc" };
2648
+ /** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
2649
+ * backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
2650
+ * Validated against your schema at `createCollectionHandlers` time (which is why that call
2651
+ * needs `{ schema }` once this is set). Absent = a plain CRUD collection, as before. */
2652
+ readonly supports?: readonly CollectionFeature[];
1877
2653
  }
1878
2654
 
1879
2655
  /** Declare a collection. Spread the results into `createCollectionHandlers` +
@@ -1907,6 +2683,9 @@ export interface CollectionMeta {
1907
2683
  titleField: string;
1908
2684
  idField: string;
1909
2685
  orderBy?: { column: string; dir?: "asc" | "desc" };
2686
+ /** Workflow features enabled — the editor uses this to decide which affordances to show
2687
+ * (a Publish button, a schedule picker, a revisions tab). Empty = plain CRUD. */
2688
+ supports: readonly CollectionFeature[];
1910
2689
  }
1911
2690
 
1912
2691
  /** The public view of a collection def (defaults filled). */
@@ -1922,21 +2701,356 @@ function collectionMeta(c: CollectionDef): CollectionMeta {
1922
2701
  titleField,
1923
2702
  idField: c.idField ?? "id",
1924
2703
  orderBy: c.orderBy,
2704
+ supports: c.supports ?? [],
1925
2705
  };
1926
2706
  }
1927
2707
 
2708
+
2709
+ // --- collection workflow features (`supports`) -------------------------------
2710
+ //
2711
+ // A collection may opt into page-style workflow with `supports: ["drafts", ...]`. Each
2712
+ // feature is backed by MANAGED COLUMNS on the collection's OWN entity: the app declares the
2713
+ // columns, the CMS owns their values.
2714
+ //
2715
+ // That ownership is the whole difference from the `publish` FIELD type this replaces. A
2716
+ // `publish` field is an ordinary entry in `fields`, and `fields` is the write whitelist —
2717
+ // so "is this row live?" was a value the client sent in the `values` bag, and the access
2718
+ // boundary was whatever the client last wrote. A managed column is never in the whitelist
2719
+ // (declaring one as a field is a boot error, see `validateCollections`), so only
2720
+ // `collectionPublish` / `collectionSchedule` / the scheduled tasks can move a row live.
2721
+ //
2722
+ // TIMESTAMP FORMAT. Managed timestamps are minted as ISO-8601 UTC with a `Z`
2723
+ // (`2026-08-20T12:00:00.000Z`), in exactly one place (`isoStamp`). That is the format
2724
+ // `$now()` produces, and the published-read scope compares against it LEXICOGRAPHICALLY.
2725
+ // It is deliberately NOT `nowStamp()` (`expr.now()`'s "YYYY-MM-DD HH:MM:SS"), which sorts
2726
+ // against the ISO form as if hours apart. Minting in one place is what closes the trap
2727
+ // documented on the `publish` field: there, `publish` and `datetime` wrote different
2728
+ // formats into the same TEXT column and both passed validation.
2729
+
2730
+ /** A workflow feature a collection can opt into.
2731
+ *
2732
+ * - `drafts` — a managed `status` column (`draft` | `published`) plus `collectionPublish` /
2733
+ * `collectionUnpublish`. Pair with `collectionPublicPolicies` so anonymous reads see
2734
+ * published rows only.
2735
+ *
2736
+ * This gates VISIBILITY, not content. A collection is column-mapped — the public reads the
2737
+ * entity's own columns — so there is nowhere to stage an unpublished VERSION of a live
2738
+ * row: an edit (or a revision restore) on a published row is live immediately. That is the
2739
+ * one place collections do not reach page parity, where `getPage` serves a baked revision
2740
+ * snapshot. Unpublish first if an edit needs review.
2741
+ * - `scheduling` — managed `publishedAt` / `scheduledAt` / `unpublishAt`, `collectionSchedule`,
2742
+ * and the deferred tasks from `createCollectionTasks`. Needs `drafts`.
2743
+ * - `revisions` — a snapshot of the row's prior state on every write, in
2744
+ * `cms_collection_revisions`, with `collectionListRevisions` / `collectionRestoreRevision`.
2745
+ * - `preview` — signed, single-row preview links (`signCollectionPreview`), redeemed at
2746
+ * `COLLECTION_PREVIEW_PATH` by the route `cmsRoutes()` serves. Needs `drafts`. It shows
2747
+ * the row's CURRENT state to whoever holds the link, which for a DRAFT is the unpublished
2748
+ * content and for a published row is what the public already sees (see `drafts` above:
2749
+ * there is no separate staged version to show). */
2750
+ export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
2751
+
2752
+ export const COLLECTION_FEATURES: readonly CollectionFeature[] = ["drafts", "scheduling", "revisions", "preview"];
2753
+
2754
+ /** The columns each feature needs on the collection's entity. The app declares them (they
2755
+ * are its own entity); the CMS writes them and `fields` may not. */
2756
+ export const COLLECTION_FEATURE_COLUMNS: Readonly<Record<CollectionFeature, readonly string[]>> = {
2757
+ drafts: ["status"],
2758
+ scheduling: ["publishedAt", "scheduledAt", "unpublishAt"],
2759
+ revisions: [],
2760
+ preview: [],
2761
+ };
2762
+
2763
+ /** Features that mean nothing on their own. Scheduling moves a row between draft and
2764
+ * published; preview shows the unpublished version — both presuppose `drafts`. */
2765
+ const COLLECTION_FEATURE_REQUIRES: Readonly<Partial<Record<CollectionFeature, CollectionFeature>>> = {
2766
+ scheduling: "drafts",
2767
+ preview: "drafts",
2768
+ };
2769
+
2770
+ /** The shared revision table for collections (see `cmsSchema`). */
2771
+ export const COLLECTION_REVISIONS_TABLE = "cms_collection_revisions";
2772
+
2773
+ /** The two `status` values a `drafts` collection uses. */
2774
+ export const COLLECTION_DRAFT = "draft";
2775
+ export const COLLECTION_PUBLISHED = "published";
2776
+
2777
+ /** `collectionList` page size when the caller names none, and the ceiling it is clamped to.
2778
+ * The cap is the point: an unbounded list of a wide entity is the D1-over-RPC failure mode
2779
+ * (GitHub #22), and `LIMIT -1` is SQLite for "no limit". */
2780
+ const DEFAULT_COLLECTION_LIST_LIMIT = 100;
2781
+ const MAX_COLLECTION_LIST_LIMIT = 500;
2782
+
2783
+ /** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
2784
+ * in, so it compares correctly against `$now()`. See the note above on why this is not
2785
+ * `nowStamp()`. */
2786
+ const isoStamp = (): string => new Date().toISOString();
2787
+
2788
+ /** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
2789
+ *
2790
+ * `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
2791
+ * `Date`) `toISOString()` throws a `RangeError` INSIDE the mutation — an opaque 500 for the
2792
+ * common client slip of sending epoch microseconds. And from year 10000 up, `toISOString()`
2793
+ * mints an EXPANDED-year string (`"+010000-01-01T00:00:00.000Z"`) whose leading `+` sorts
2794
+ * BEFORE every ordinary timestamp — inverting every lexicographic comparison this feature
2795
+ * rests on, so a takedown 8000 years out reads as already passed and a publish instant in
2796
+ * the far future reads as due. One range check closes both. */
2797
+ const MIN_SCHEDULE_MS = 0;
2798
+ const MAX_SCHEDULE_MS = 253402300799999; // 9999-12-31T23:59:59.999Z
2799
+
2800
+ const epochInput = (name: string, v: unknown): number => {
2801
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new BadRequest(`${name} must be a finite epoch ms`);
2802
+ if (!Number.isInteger(v)) throw new BadRequest(`${name} must be a whole number of epoch ms`);
2803
+ if (v < MIN_SCHEDULE_MS || v > MAX_SCHEDULE_MS) {
2804
+ throw new BadRequest(`${name} must be an epoch ms between ${MIN_SCHEDULE_MS} and ${MAX_SCHEDULE_MS} (1970 … 9999) — got ${v}`);
2805
+ }
2806
+ return v;
2807
+ };
2808
+
2809
+ /** The column types a declared field can be stored in. A collection field is COLUMN-MAPPED,
2810
+ * so the entity's column type has to match what the field writes: a `richtext`/`group`/
2811
+ * `repeater` value is a document (`t.json()`), the rest are scalars. Getting this wrong is
2812
+ * not a type error anywhere — it surfaces as a raw driver message on the first write
2813
+ * ("Binding expected string, TypedArray, …"), which is why it is checked at boot. */
2814
+ const COLLECTION_FIELD_COLUMN_TYPES: Readonly<Record<FieldDefinition["type"], readonly FieldType[]>> = {
2815
+ text: ["text", "uuid"],
2816
+ textarea: ["text"],
2817
+ richtext: ["json"],
2818
+ url: ["text"],
2819
+ number: ["integer", "real"],
2820
+ boolean: ["boolean", "integer"],
2821
+ date: ["text"],
2822
+ datetime: ["text"],
2823
+ publish: ["text"],
2824
+ slug: ["text"],
2825
+ media: ["text", "uuid"],
2826
+ select: ["text"],
2827
+ repeater: ["json"],
2828
+ group: ["json"],
2829
+ };
2830
+
2831
+ /** Check a collection registry at BOOT: slugs and entities are unique, features are known
2832
+ * and have their prerequisites, every declared field maps to a column that can hold it, and
2833
+ * every managed column exists, has the shape the CMS writes, and is not also an editable
2834
+ * field.
2835
+ *
2836
+ * Called by `createCollectionHandlers`. The point is that a misconfiguration surfaces when
2837
+ * the Worker starts, naming the collection and the column — not as a 500 the first time an
2838
+ * editor presses Publish, months later, on the one collection nobody exercised.
2839
+ *
2840
+ * `schema` is REQUIRED. Every check here reads the target entity, so a registry validated
2841
+ * without one is not validated at all — and the failures it catches (a field name typo, a
2842
+ * richtext field over a TEXT column, a non-PK idField) are exactly as fatal on a collection
2843
+ * that declares no `supports` as on one that declares all four. */
2844
+ export function validateCollections(collections: readonly CollectionDef[], schema?: SchemaDef): void {
2845
+ const seen = new Set<string>();
2846
+ const byEntity = new Map<string, string>();
2847
+ for (const c of collections) {
2848
+ if (seen.has(c.slug)) throw new Error(`pramen/cms: duplicate collection slug '${c.slug}' — slugs are the handler registry's key`);
2849
+ seen.add(c.slug);
2850
+ // ONE collection per entity. The ACL keys policies by (role, entity, action) and
2851
+ // OR-merges the matches — the policy NAME is not part of the key — so a second
2852
+ // collection over the same entity does not add a second, separate view: it WIDENS the
2853
+ // first one's read scope. Two `collectionPublicPolicies` grants over one entity collapse
2854
+ // to the loosest of the two, which is how a `drafts`-only collection silently removes
2855
+ // the `publishedAt <= $now()` and `unpublishAt > $now()` clauses from a `scheduling`
2856
+ // sibling — publishing a row a year early and defeating its scheduled takedown.
2857
+ const first = byEntity.get(c.entity);
2858
+ if (first) {
2859
+ throw new Error(
2860
+ `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.`,
2861
+ );
2862
+ }
2863
+ byEntity.set(c.entity, c.slug);
2864
+ }
2865
+ if (collections.length === 0) return;
2866
+ if (!schema) {
2867
+ throw new Error(
2868
+ `pramen/cms: createCollectionHandlers needs your schema to check the registry against your entities: createCollectionHandlers(collections, { schema })`,
2869
+ );
2870
+ }
2871
+ for (const c of collections) {
2872
+ const features = c.supports ?? [];
2873
+ const set = new Set<CollectionFeature>(features);
2874
+ for (const f of features) {
2875
+ if (!COLLECTION_FEATURES.includes(f)) {
2876
+ throw new Error(`pramen/cms: collection '${c.slug}' declares unknown feature '${String(f)}' (known: ${COLLECTION_FEATURES.join(", ")})`);
2877
+ }
2878
+ const needs = COLLECTION_FEATURE_REQUIRES[f];
2879
+ if (needs && !set.has(needs)) {
2880
+ throw new Error(`pramen/cms: collection '${c.slug}' declares '${f}', which needs '${needs}' — add it to \`supports\``);
2881
+ }
2882
+ }
2883
+ const entity = schema[c.entity];
2884
+ if (!entity) throw new Error(`pramen/cms: collection '${c.slug}' targets entity '${c.entity}', which is not in the schema`);
2885
+ const columns = entity.fields as Record<string, FieldDef>;
2886
+ // EVERY collection handler dispatches to the default partition's DO: none of them
2887
+ // declares a `partition`, and `/rpc` routes by the handler's. An entity parked in
2888
+ // another partition therefore boots clean and then 400s on every single call
2889
+ // (`assertInPartition`), and a preview link 404s forever — `callPrivileged` has no
2890
+ // partition to pass either. Name it here instead.
2891
+ const entityPartition = partitionOf(schema, c.entity);
2892
+ if (entityPartition !== DEFAULT_PARTITION) {
2893
+ throw new Error(
2894
+ `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.`,
2895
+ );
2896
+ }
2897
+ const idField = c.idField ?? "id";
2898
+ if (!(idField in columns)) {
2899
+ throw new Error(`pramen/cms: collection '${c.slug}' has idField '${idField}', which is not a column on '${c.entity}'`);
2900
+ }
2901
+ // …and it must be the PRIMARY KEY, not merely a column. Reads key on `idField`, but
2902
+ // `db.update`/`db.delete` key on the entity's actual PK — so a non-PK idField loads a
2903
+ // row fine and then writes nothing, surfacing as a 404 on a row the same handler just
2904
+ // read. Exactly the misconfiguration this validator exists to name.
2905
+ const pk = Object.entries(columns).find(([, f]) => f.primaryKey)?.[0] ?? "id";
2906
+ if (idField !== pk) {
2907
+ throw new Error(
2908
+ `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`,
2909
+ );
2910
+ }
2911
+ // Declared fields ARE columns on the entity (that is what "column-mapped" means), so a
2912
+ // typo is a write that fails with the driver's own message and no HTTP status, and a
2913
+ // document field over a TEXT column is the trap example/app.ts documents in a comment.
2914
+ // Both are visible right here, with `columns` in hand.
2915
+ for (const f of c.fields) {
2916
+ const col = columns[f.name];
2917
+ if (!col) {
2918
+ throw new Error(
2919
+ `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`,
2920
+ );
2921
+ }
2922
+ const allowed = COLLECTION_FIELD_COLUMN_TYPES[f.type];
2923
+ if (allowed && !allowed.includes(col.type)) {
2924
+ const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
2925
+ throw new Error(
2926
+ `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}`,
2927
+ );
2928
+ }
2929
+ if (col.hidden) {
2930
+ throw new Error(
2931
+ `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`,
2932
+ );
2933
+ }
2934
+ }
2935
+ // A declared `orderBy` fails SILENTLY when the column does not exist: the dialect
2936
+ // double-quotes the name and SQLite resolves an unknown quoted identifier to a string
2937
+ // CONSTANT, so every row sorts equal and the list comes back in arbitrary storage order
2938
+ // with no error anywhere.
2939
+ if (c.orderBy && !(c.orderBy.column in columns)) {
2940
+ throw new Error(
2941
+ `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`,
2942
+ );
2943
+ }
2944
+ const declared = new Set(c.fields.map((f) => f.name));
2945
+ for (const f of features) {
2946
+ for (const col of COLLECTION_FEATURE_COLUMNS[f]) {
2947
+ const column = columns[col];
2948
+ if (!column) {
2949
+ throw new Error(
2950
+ `pramen/cms: collection '${c.slug}' declares '${f}', which manages a \`${col}\` column on '${c.entity}' — add \`${col}: t.text()\` to the entity`,
2951
+ );
2952
+ }
2953
+ // NAME alone is not enough. The CMS writes these columns as TEXT (an ISO-8601
2954
+ // instant or a status word) and compares them lexicographically in the public read
2955
+ // scope, and every wrong declaration fails SILENTLY rather than loudly:
2956
+ // - `t.json()` stores `"\"published\""` (the Db chokepoint stringifies), so the
2957
+ // policy's `status = 'published'` never matches and the row is invisible
2958
+ // forever while `collectionPublish` echoes success;
2959
+ // - `notNull()` 500s on every create (the managed columns are seeded as NULL);
2960
+ // - `hidden()` strips the column from every read, disabling the spent-takedown
2961
+ // repair and hiding the state from the editor.
2962
+ if (column.type !== "text") {
2963
+ throw new Error(
2964
+ `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)`,
2965
+ );
2966
+ }
2967
+ if (column.notNull) {
2968
+ throw new Error(
2969
+ `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).`,
2970
+ );
2971
+ }
2972
+ if (column.hidden) {
2973
+ throw new Error(
2974
+ `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`,
2975
+ );
2976
+ }
2977
+ // `fields` IS the write whitelist. A `status` entry there would let any editor send
2978
+ // `values: { status: "published" }` through collectionUpdate and bypass the publish
2979
+ // handler entirely, leaving the gate as decoration over a client-set column.
2980
+ if (declared.has(col)) {
2981
+ throw new Error(
2982
+ `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)`,
2983
+ );
2984
+ }
2985
+ }
2986
+ }
2987
+ if (set.has("revisions")) {
2988
+ if (!schema[COLLECTION_REVISIONS_TABLE]) {
2989
+ throw new Error(
2990
+ `pramen/cms: collection '${c.slug}' declares 'revisions', which needs the \`${COLLECTION_REVISIONS_TABLE}\` table — spread \`cmsSchema\` into defineSchema`,
2991
+ );
2992
+ }
2993
+ // A DO cannot write across a partition boundary, so a collection entity parked in its
2994
+ // own partition would 500 on the first snapshot insert (assertInPartition). The
2995
+ // default-partition check above already covers this; keep the specific message for
2996
+ // the case where `cms_collection_revisions` itself was moved.
2997
+ const revPartition = partitionOf(schema, COLLECTION_REVISIONS_TABLE);
2998
+ if (entityPartition !== revPartition) {
2999
+ throw new Error(
3000
+ `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}'`,
3001
+ );
3002
+ }
3003
+ }
3004
+ }
3005
+ }
3006
+
3007
+ /** A signed grant to preview ONE collection row. Mirrors {@link PreviewToken}. */
3008
+ export interface CollectionPreviewToken {
3009
+ /** tenant */ t: string;
3010
+ /** collection slug */ c: string;
3011
+ /** row id — the grant is scoped to this ONE row, never "all drafts" */ r: string;
3012
+ /** expiry (epoch seconds) */ exp: number;
3013
+ }
3014
+
3015
+ /** Where a collection preview link is redeemed. Served by `cmsRoutes()`. */
3016
+ export const COLLECTION_PREVIEW_PATH = "/cms/preview/collection";
3017
+
3018
+ /** Outbox task kinds behind `collectionSchedule`. Register the handlers with
3019
+ * `app.tasks = { ...cmsTasks, ...createCollectionTasks(collections) }`. */
3020
+ export const TASK_COLLECTION_PUBLISH = "cms:collection:publish";
3021
+ export const TASK_COLLECTION_UNPUBLISH = "cms:collection:unpublish";
3022
+
3023
+ /** Options for `createCollectionHandlers`. */
3024
+ export interface CollectionHandlerOpts extends CmsHandlerOpts {
3025
+ /** Your app's schema (the object you pass to `defineSchema`). REQUIRED: the whole registry
3026
+ * is checked against it at boot by `validateCollections` — managed columns, declared
3027
+ * field ↔ column types, the idField/PK pairing, `orderBy`, the partition — so a
3028
+ * misconfiguration is a startup error naming the collection and the column rather than a
3029
+ * 500 (or a silent wrong answer) on the first call. */
3030
+ schema?: SchemaDef;
3031
+ }
3032
+
1928
3033
  /** Build generic CRUD handlers over the registered collections. Spread into your app's
1929
3034
  * handlers alongside `cmsHandlers`:
1930
3035
  *
1931
- * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
3036
+ * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures], { schema }) };
1932
3037
  *
1933
3038
  * Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
1934
3039
  * `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
1935
3040
  * (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
1936
3041
  * `collectionPolicies` scopes them too). The `collection` param is resolved through the
1937
3042
  * registry — an unknown slug is a 400, never a raw table reference. */
1938
- export function createCollectionHandlers(collections: readonly CollectionDef[], opts: CmsHandlerOpts = {}) {
3043
+ export function createCollectionHandlers(collections: readonly CollectionDef[], opts: CollectionHandlerOpts = {}) {
3044
+ // Boot check, before a single handler is built: unknown/incoherent features and missing
3045
+ // managed columns throw here rather than 500ing on the first publish.
3046
+ validateCollections(collections, opts.schema);
3047
+ const collectionRtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
1939
3048
  const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
3049
+ // Preview redemption presents editorRoles ∪ reviewerRoles (see `viewerRolesOf`), so the
3050
+ // handler the route calls has to accept that set — gating it to `editor` alone would 403
3051
+ // every preview link for a reviewer-only identity. Same wiring as `getPagePreview`.
3052
+ const viewer = { auth: viewerRolesOf(opts) };
3053
+ const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
1940
3054
  const bySlug = new Map(collections.map((c) => [c.slug, c] as const));
1941
3055
  const metas = collections.map(collectionMeta);
1942
3056
  const def = (slug: unknown): CollectionDef => {
@@ -1944,16 +3058,166 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
1944
3058
  if (!c) throw new BadRequest(`unknown collection: ${String(slug)}`);
1945
3059
  return c;
1946
3060
  };
3061
+ /** Narrow a stored snapshot to the columns a caller may read on the collection's entity.
3062
+ * Used by both the history read and the restore write, so "what you can see" and "what you
3063
+ * can put back" are the same set. */
3064
+ const projectSnapshot = (snapshot: unknown, readable: ReadonlySet<string>): Record<string, unknown> => {
3065
+ const obj = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) ? (snapshot as Record<string, unknown>) : {};
3066
+ const out: Record<string, unknown> = {};
3067
+ for (const [k, v] of Object.entries(obj)) if (readable.has(k)) out[k] = v;
3068
+ return out;
3069
+ };
1947
3070
  const idOf = (c: CollectionDef): string => c.idField ?? "id";
3071
+ const has = (c: CollectionDef, f: CollectionFeature): boolean => (c.supports ?? []).includes(f);
3072
+ const columnsOf = (c: CollectionDef): Record<string, FieldDef> => ((opts.schema?.[c.entity]?.fields ?? {}) as Record<string, FieldDef>);
3073
+ /** The list ordering, resolved ONCE against the entity. The documented default is
3074
+ * `createdAt desc`, but that column is not guaranteed to exist — and an ORDER BY over a
3075
+ * missing column does not fail: the dialect quotes the name, SQLite resolves the unknown
3076
+ * quoted identifier to a string CONSTANT, every row sorts equal, and the list comes back
3077
+ * in arbitrary storage order. Fall back to the PK, which always exists, so the order is at
3078
+ * least stable and paging is coherent. (A DECLARED `orderBy` over a missing column is a
3079
+ * boot error — see `validateCollections`.) */
3080
+ const orderByOf = (c: CollectionDef): { column: string; dir: "asc" | "desc" } => {
3081
+ if (c.orderBy) return { column: c.orderBy.column, dir: c.orderBy.dir ?? "desc" };
3082
+ return { column: "createdAt" in columnsOf(c) ? "createdAt" : idOf(c), dir: "desc" };
3083
+ };
3084
+ const orderBys = new Map(collections.map((c) => [c.slug, orderByOf(c)] as const));
3085
+ /** 400 (not 500) when a caller invokes a workflow handler on a collection that never
3086
+ * opted into it — the handlers exist for every collection, the features do not. */
3087
+ const needs = (c: CollectionDef, f: CollectionFeature): void => {
3088
+ if (!has(c, f)) throw new BadRequest(`collection '${c.slug}' does not support '${f}' (add it to \`supports\`)`);
3089
+ };
3090
+ const loadRow = async (db: CmsDb, c: CollectionDef, id: string): Promise<Record<string, unknown>> => {
3091
+ const rows = await db.find({ from: c.entity, where: { [idOf(c)]: id }, limit: 1 });
3092
+ const row = rows[0];
3093
+ if (!row) throw notFound(c.label);
3094
+ return row;
3095
+ };
3096
+ /** Read a row's DECLARED FIELD columns unprojected, for the revision snapshot.
3097
+ *
3098
+ * `ctx.db.exec` is the documented raw escape hatch: it bypasses the row/field ACL, and it
3099
+ * also bypasses the `Db` chokepoint's cell codec — so a json-backed column comes back as
3100
+ * the stored TEXT and a boolean as 0/1. Both are decoded here from the entity's own column
3101
+ * types (checked against the field types at boot), so a snapshot holds exactly what
3102
+ * `db.find` would have returned for an unrestricted caller.
3103
+ *
3104
+ * Falls back to the ACL-projected row if the raw read comes back empty (a substrate quirk
3105
+ * or a row deleted concurrently) — a partial snapshot beats no snapshot. */
3106
+ const rawFieldValues = async (db: CmsDb, c: CollectionDef, rowId: string, projected: Record<string, unknown>): Promise<Record<string, unknown>> => {
3107
+ const columns = columnsOf(c);
3108
+ const names = c.fields.map((f) => f.name).filter((n) => n in columns);
3109
+ if (names.length === 0) return {};
3110
+ const cols = names.map((n) => `"${n}"`).join(", ");
3111
+ // Identifiers, not values: `entity`, `idField` and every field name were checked against
3112
+ // the schema at boot, so nothing caller-supplied is interpolated here. The id IS bound.
3113
+ const rows = (await db.exec(`SELECT ${cols} FROM "${c.entity}" WHERE "${idOf(c)}" = ?`, rowId)) as Array<Record<string, unknown>>;
3114
+ const raw = rows[0];
3115
+ if (!raw) {
3116
+ const fallback: Record<string, unknown> = {};
3117
+ for (const f of c.fields) if (f.name in projected) fallback[f.name] = projected[f.name];
3118
+ return fallback;
3119
+ }
3120
+ const values: Record<string, unknown> = {};
3121
+ for (const name of names) {
3122
+ const v = raw[name];
3123
+ const type = columns[name]?.type;
3124
+ if (v == null) values[name] = null;
3125
+ else if ((type === "json" || type === "fileRef") && typeof v === "string") {
3126
+ try {
3127
+ values[name] = JSON.parse(v) as unknown;
3128
+ } catch {
3129
+ values[name] = v; // not JSON after all — keep the literal rather than losing it
3130
+ }
3131
+ } else if (type === "boolean") values[name] = typeof v === "boolean" ? v : v !== 0 && v !== 0n;
3132
+ else values[name] = v;
3133
+ }
3134
+ return values;
3135
+ };
3136
+ /** Snapshot a row's CURRENT (pre-write) state into `cms_collection_revisions`, so a
3137
+ * revision always reads as "what it was before this edit" and restoring one is a plain
3138
+ * reversal. Declared fields only — the snapshot is replayed through the same write
3139
+ * whitelist on restore, so it can never carry a column the collection doesn't own.
3140
+ * No-op unless the collection supports `revisions`. */
3141
+ const snapshotRow = async (db: CmsDb, c: CollectionDef, row: Record<string, unknown>, ctx: HandlerContext, note: string): Promise<void> => {
3142
+ if (!has(c, "revisions")) return;
3143
+ const rowId = String(row[idOf(c)]);
3144
+ // The row handed in came through the ACL, so it is projected to what THIS caller may
3145
+ // read — which would make history a function of who happened to make the edit: an
3146
+ // editor whose read scope excludes `salary` would silently drop it from the snapshot,
3147
+ // and every later "restore to before that edit" would restore an incomplete row.
3148
+ // History is an audit record, not a view, so capture the row's REAL pre-state through
3149
+ // the raw escape hatch and let the READ path decide who may see which of its fields
3150
+ // (`collectionListRevisions` projects it back down).
3151
+ const values = await rawFieldValues(db, c, rowId, row);
3152
+ // Next in this row's sequence. Serialized by the DO's single writer; on D1 the composite
3153
+ // unique on (collection, rowId, revision) is the backstop — see the schema note.
3154
+ const [{ next = 1 } = {}] = (await db.exec(
3155
+ `SELECT COALESCE(MAX(revision), 0) + 1 AS next FROM ${COLLECTION_REVISIONS_TABLE} WHERE collection = ? AND rowId = ?`,
3156
+ c.slug,
3157
+ rowId,
3158
+ )) as Array<{ next?: number }>;
3159
+ await db.insert(COLLECTION_REVISIONS_TABLE, {
3160
+ collection: c.slug,
3161
+ rowId,
3162
+ revision: next,
3163
+ snapshot: values,
3164
+ note,
3165
+ actor: typeof ctx.identity?.userId === "string" ? ctx.identity.userId : null,
3166
+ // Explicit, ms-precision, and the only writer of this column — see the schema note.
3167
+ createdAt: isoStamp(),
3168
+ });
3169
+ };
3170
+ /** Shared input validator for the `{ collection, id }` handlers. */
3171
+ const rowInput = (raw: unknown): { collection: string; id: string } => {
3172
+ const o = asObj(raw);
3173
+ if (typeof o.collection !== "string" || o.collection === "") throw new BadRequest("collection is required");
3174
+ return { collection: o.collection, id: idInput(raw) };
3175
+ };
3176
+ const collectionInput = (raw: unknown): string => {
3177
+ const o = asObj(raw);
3178
+ if (typeof o.collection !== "string" || o.collection === "") throw new BadRequest("collection is required");
3179
+ return o.collection;
3180
+ };
3181
+ /** `{ collection, values }` — the write handlers. `values` is validated against the field
3182
+ * schema downstream (`toColumns`); this only rejects a non-object, so a string or an array
3183
+ * cannot reach the field validator as a bag of index keys. */
3184
+ const valuesInput = (raw: unknown): { collection: string; values: Record<string, unknown> } => {
3185
+ const o = asObj(raw);
3186
+ const values = o.values;
3187
+ if (values === null || typeof values !== "object" || Array.isArray(values)) throw new BadRequest("values must be an object");
3188
+ return { collection: collectionInput(raw), values: values as Record<string, unknown> };
3189
+ };
3190
+ const rowValuesInput = (raw: unknown): { collection: string; id: string; values: Record<string, unknown> } => ({
3191
+ ...rowInput(raw),
3192
+ values: valuesInput(raw).values,
3193
+ });
3194
+ /** `{ collection, limit?, offset? }`, both CLAMPED. `find` binds `limit` straight into
3195
+ * `LIMIT ?`, and SQLite reads a negative limit as UNBOUNDED — so `limit: -1` dumps the
3196
+ * whole table over RPC — while a fractional value reaches the driver as-is and 500s. */
3197
+ const listInput = (raw: unknown): { collection: string; limit?: number; offset?: number } => {
3198
+ const o = asObj(raw);
3199
+ const num = (name: string, v: unknown): number | undefined => {
3200
+ if (v === undefined || v === null) return undefined;
3201
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new BadRequest(`${name} must be a number`);
3202
+ return Math.floor(v);
3203
+ };
3204
+ const limit = num("limit", o.limit);
3205
+ const offset = num("offset", o.offset);
3206
+ return {
3207
+ collection: collectionInput(raw),
3208
+ limit: limit === undefined ? undefined : Math.max(1, Math.min(limit, MAX_COLLECTION_LIST_LIMIT)),
3209
+ offset: offset === undefined ? undefined : Math.max(0, offset),
3210
+ };
3211
+ };
1948
3212
  // Validate against the field schema, sanitize richtext, then PROJECT to declared field
1949
3213
  // names only — the write whitelist. `requireRequired` is off for updates (partial patch);
1950
3214
  // on for create. Nothing outside `c.fields` can reach the entity.
1951
- const toColumns = (c: CollectionDef, values: unknown, requireRequired: boolean): Record<string, unknown> => {
3215
+ const toColumns = (c: CollectionDef, values: unknown, requireRequired: boolean, legacyBaseline?: FieldValues): Record<string, unknown> => {
1952
3216
  const obj = asObj(values);
1953
- validateFields([...c.fields], obj, "", { requireRequired });
1954
- const sanitized = sanitizeFields([...c.fields], obj);
3217
+ validateFields([...c.fields], obj, "", { requireRequired, legacyBaseline });
3218
+ const normalized = normalizeFields([...c.fields], obj, collectionRtSchema);
1955
3219
  const out: Record<string, unknown> = {};
1956
- for (const f of c.fields) if (f.name in sanitized) out[f.name] = sanitized[f.name];
3220
+ for (const f of c.fields) if (f.name in normalized) out[f.name] = normalized[f.name];
1957
3221
  return out;
1958
3222
  };
1959
3223
  const idInput = (raw: unknown): string => {
@@ -1969,35 +3233,371 @@ export function createCollectionHandlers(collections: readonly CollectionDef[],
1969
3233
 
1970
3234
  collectionList: query((ctx, input: { collection: string; limit?: number; offset?: number }) => {
1971
3235
  const c = def(input.collection);
1972
- const limit = typeof input.limit === "number" ? input.limit : 100;
1973
- const offset = typeof input.offset === "number" ? input.offset : undefined;
1974
- return cdb(ctx).find({ from: c.entity, orderBy: c.orderBy ?? { column: "createdAt", dir: "desc" }, limit, offset });
1975
- }, editor),
3236
+ return cdb(ctx).find({
3237
+ from: c.entity,
3238
+ orderBy: orderBys.get(c.slug) ?? orderByOf(c),
3239
+ limit: input.limit ?? DEFAULT_COLLECTION_LIST_LIMIT,
3240
+ offset: input.offset,
3241
+ });
3242
+ }, { ...editor, input: listInput }),
1976
3243
 
1977
3244
  collectionGet: query(async (ctx, input: { collection: string; id: string }) => {
1978
3245
  const c = def(input.collection);
1979
3246
  const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
1980
3247
  return rows[0] ?? null;
1981
- }, editor),
3248
+ }, { ...editor, input: rowInput }),
1982
3249
 
1983
3250
  collectionCreate: mutation((ctx, input: { collection: string; values: Record<string, unknown> }) => {
1984
3251
  const c = def(input.collection);
1985
- return cdb(ctx).insert(c.entity, toColumns(c, input.values, true));
1986
- }, editor),
3252
+ const values = toColumns(c, input.values, true);
3253
+ // Seed the managed columns explicitly rather than leaning on a column default: the
3254
+ // entity belongs to the app, which may have declared `status` with no default (or a
3255
+ // NOT NULL one). A new row always starts as a draft — publishing is a separate,
3256
+ // separately-gated act.
3257
+ if (has(c, "drafts")) values.status = COLLECTION_DRAFT;
3258
+ if (has(c, "scheduling")) {
3259
+ values.publishedAt = null;
3260
+ values.scheduledAt = null;
3261
+ values.unpublishAt = null;
3262
+ }
3263
+ return cdb(ctx).insert(c.entity, values);
3264
+ }, { ...editor, input: valuesInput }),
1987
3265
 
1988
3266
  collectionUpdate: mutation(async (ctx, input: { collection: string; id: string; values: Record<string, unknown> }) => {
1989
3267
  const c = def(input.collection);
1990
- const updated = await cdb(ctx).update(c.entity, input.id, toColumns(c, input.values, false));
3268
+ const db = cdb(ctx);
3269
+ // Read the current row first: the editor autosaves the WHOLE values bag, so a
3270
+ // pre-Portable-Text richtext value rides along with an unrelated edit. It is
3271
+ // tolerated only when byte-identical to what is stored (see `legacyBaseline`).
3272
+ // A policy may grant `update` without `read` on the entity — that worked before this
3273
+ // pre-read existed, so it must not start 403ing. No baseline simply means a legacy
3274
+ // string is rejected, which is the strict default.
3275
+ let current: FieldValues | undefined;
3276
+ try {
3277
+ current = (await db.find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 }))[0] as FieldValues | undefined;
3278
+ } catch {
3279
+ current = undefined;
3280
+ }
3281
+ // Validate + whitelist the patch BEFORE snapshotting. `toColumns` throws on an invalid
3282
+ // patch, and on the DO that rollback is free (the mutation is one transaction) — but
3283
+ // `D1Driver.transaction` is a no-op, so snapshotting first meant a REJECTED edit still
3284
+ // committed a revision on D1: a phantom entry recording no change, and a burnt value
3285
+ // in the per-row `revision` counter.
3286
+ const patch = toColumns(c, input.values, false, current);
3287
+ // `current` is undefined only when the pre-read above was denied (an
3288
+ // update-without-read grant), in which case there is nothing to snapshot — the
3289
+ // revision is skipped rather than written empty.
3290
+ if (current) await snapshotRow(db, c, current as Record<string, unknown>, ctx, "edit");
3291
+ const updated = await db.update(c.entity, input.id, patch);
1991
3292
  if (updated === undefined) throw notFound(c.label);
1992
3293
  return updated;
1993
- }, editor),
3294
+ }, { ...editor, input: rowValuesInput }),
1994
3295
 
1995
3296
  collectionDelete: mutation(async (ctx, input: { collection: string; id: string }) => {
1996
3297
  const c = def(input.collection);
1997
- const ok = await cdb(ctx).delete(c.entity, input.id);
3298
+ const db = cdb(ctx);
3299
+ const ok = await db.delete(c.entity, input.id);
1998
3300
  if (!ok) throw notFound(c.label);
3301
+ // PURGE the row's revisions. Keeping them looks like free history, but a collection PK
3302
+ // can be a caller-chosen textId — recreating a row with the same id would inherit the
3303
+ // dead row's history, and `collectionRestoreRevision`'s scope check (collection +
3304
+ // rowId) would happily write the deleted row's content over the new one. It also
3305
+ // bounds the table: a collection has no trash, so nothing else ever collects these.
3306
+ //
3307
+ // Atomic with the delete on the DO (the mutation runs in storage.transaction). NOT on
3308
+ // the D1 store, where `transaction` is a no-op — a failure in between leaves orphan
3309
+ // revisions, which is exactly the inheritance above. Rare, and recoverable by
3310
+ // deleting the recreated row, but it is not a guarantee on that substrate.
3311
+ if (has(c, "revisions")) {
3312
+ await db.exec(`DELETE FROM ${COLLECTION_REVISIONS_TABLE} WHERE collection = ? AND rowId = ?`, c.slug, input.id);
3313
+ }
1999
3314
  return { ok: true as const };
2000
- }, { ...editor, input: (raw) => ({ collection: asObj(raw).collection as string, id: idInput(raw) }) }),
3315
+ }, { ...editor, input: rowInput }),
3316
+
3317
+ // ---- drafts -------------------------------------------------------------
3318
+
3319
+ /** Move a row live. With `scheduling` this also stamps `publishedAt` (the column the
3320
+ * public read scope compares against `$now()`) and clears `scheduledAt` — which makes
3321
+ * any pending scheduled-publish task a no-op, since its intent token no longer matches.
3322
+ * A pending scheduled UNPUBLISH is deliberately left standing: publishing early does not
3323
+ * cancel a planned takedown. */
3324
+ collectionPublish: mutation(async (ctx, input: { collection: string; id: string }) => {
3325
+ const c = def(input.collection);
3326
+ needs(c, "drafts");
3327
+ const db = cdb(ctx);
3328
+ const row = await loadRow(db, c, input.id);
3329
+ // Deliberately NOT snapshotted. A revision records CONTENT, and publishing changes
3330
+ // none — the managed columns are excluded from `fields` by design, so a "publish"
3331
+ // revision was byte-identical to the edit before it, and restoring it wrote only the
3332
+ // declared fields and left the row live. That reads as a broken button; an entry that
3333
+ // cannot be restored is worse than no entry.
3334
+ const patch: Record<string, unknown> = { status: COLLECTION_PUBLISHED };
3335
+ if (has(c, "scheduling")) {
3336
+ const now = isoStamp();
3337
+ patch.publishedAt = now;
3338
+ patch.scheduledAt = null;
3339
+ // Clear a takedown instant that has already PASSED. This is an EXPLICIT act by a
3340
+ // human holding publish rights, which is why it resolves differently from the
3341
+ // scheduled-publish task: that one converges to the state the schedule implies (a
3342
+ // passed takedown wins, and the row lands down), while here the editor is saying
3343
+ // "live, now" about a takedown that has already been served. A future one stands —
3344
+ // publishing early does not cancel a planned removal — but a spent one is not
3345
+ // "pending" at all, and since the public scope now enforces
3346
+ // `unpublishAt IS NULL OR unpublishAt > $now()`, leaving it would make this very
3347
+ // publish a no-op: the editor gets back `status: "published"` and the row stays
3348
+ // invisible, with no error to explain it. That state is reachable whenever the
3349
+ // unpublish task never ran (tasks unwired, outbox dead-lettered, no D1 cron).
3350
+ if (typeof row.unpublishAt === "string" && row.unpublishAt <= now) patch.unpublishAt = null;
3351
+ }
3352
+ const updated = await db.update(c.entity, input.id, patch);
3353
+ if (updated === undefined) throw notFound(c.label);
3354
+ return updated;
3355
+ }, { ...editor, input: rowInput }),
3356
+
3357
+ /** Take a row back to draft, clearing every schedule. Both tokens are cleared, so a
3358
+ * pending publish AND a pending unpublish both become no-ops — unpublishing is an
3359
+ * explicit "this is not live and nothing is queued to change that". */
3360
+ collectionUnpublish: mutation(async (ctx, input: { collection: string; id: string }) => {
3361
+ const c = def(input.collection);
3362
+ needs(c, "drafts");
3363
+ const db = cdb(ctx);
3364
+ await loadRow(db, c, input.id);
3365
+ const patch: Record<string, unknown> = { status: COLLECTION_DRAFT }; // not snapshotted — see collectionPublish
3366
+ if (has(c, "scheduling")) {
3367
+ patch.publishedAt = null;
3368
+ patch.scheduledAt = null;
3369
+ patch.unpublishAt = null;
3370
+ }
3371
+ const updated = await db.update(c.entity, input.id, patch);
3372
+ if (updated === undefined) throw notFound(c.label);
3373
+ return updated;
3374
+ }, { ...editor, input: rowInput }),
3375
+
3376
+ // ---- scheduling ---------------------------------------------------------
3377
+
3378
+ /** Schedule a future publish, and optionally a later unpublish. Mirrors `schedulePage`,
3379
+ * including the INTENT TOKEN: the row stores the scheduled instants
3380
+ * (`scheduledAt`/`unpublishAt`, ISO), the enqueued task carries a copy, and the task
3381
+ * runs only if the two still match. A reschedule overwrites the token, a manual
3382
+ * publish/unpublish clears it, and a duplicate delivery finds it already cleared — so a
3383
+ * superseded or cancelled schedule is a silent no-op rather than a surprise publish.
3384
+ *
3385
+ * The tasks are enqueued in THIS mutation's transaction (the outbox is transactional),
3386
+ * so a rolled-back schedule never leaves a task behind. They only run if you wired
3387
+ * `createCollectionTasks` into `app.tasks`. */
3388
+ collectionSchedule: mutation(async (ctx, input: { collection: string; id: string; publishAt: number; unpublishAt?: number | null }) => {
3389
+ const c = def(input.collection);
3390
+ needs(c, "scheduling");
3391
+ const db = cdb(ctx);
3392
+ const row = await loadRow(db, c, input.id);
3393
+ const now = Date.now();
3394
+ const publishToken = new Date(input.publishAt).toISOString();
3395
+ // Cross-call ordering. The boundary validator compares the two instants WITHIN one
3396
+ // call, which is not the invariant that matters: the documented way to move a publish
3397
+ // date is `collectionSchedule({ publishAt })` with `unpublishAt` omitted, and an
3398
+ // omitted takedown is left standing. Without this check a reschedule could push the
3399
+ // publish PAST a pending takedown — the takedown then fires first (clearing itself),
3400
+ // the publish fires after it against nothing, and the row is public with no takedown
3401
+ // left and no repair path. Compare against the takedown that will actually be in
3402
+ // effect: the one being written, or the one already stored.
3403
+ const effectiveUnpublish =
3404
+ input.unpublishAt !== undefined
3405
+ ? typeof input.unpublishAt === "number"
3406
+ ? new Date(input.unpublishAt).toISOString()
3407
+ : null
3408
+ : typeof row.unpublishAt === "string" && row.unpublishAt !== ""
3409
+ ? row.unpublishAt
3410
+ : null;
3411
+ if (effectiveUnpublish !== null && effectiveUnpublish <= publishToken) {
3412
+ throw new BadRequest(
3413
+ `publishAt (${publishToken}) is at or after the scheduled takedown (${effectiveUnpublish}) — move or cancel the takedown too (pass \`unpublishAt\`, or \`unpublishAt: null\` to cancel it)`,
3414
+ );
3415
+ }
3416
+ // PATCH semantics on the takedown: an ABSENT `unpublishAt` leaves an existing one
3417
+ // alone. Writing null unconditionally meant that merely moving the publish date
3418
+ // revoked a scheduled removal — and silently, since clearing the column also
3419
+ // neutralizes the already-enqueued task through the intent-token check. Pass
3420
+ // `unpublishAt: null` to cancel one deliberately.
3421
+ const hasUnpublish = input.unpublishAt !== undefined;
3422
+ const unpublishToken = typeof input.unpublishAt === "number" ? new Date(input.unpublishAt).toISOString() : null;
3423
+ const patch: Record<string, unknown> = { scheduledAt: publishToken };
3424
+ if (hasUnpublish) patch.unpublishAt = unpublishToken;
3425
+ // `loadRow` above goes through the READ scope; this goes through the UPDATE scope,
3426
+ // which can be narrower. Without the check a role that may read but not update the row
3427
+ // got `{ ok: true }` and two enqueued tasks over a write that never landed — the tasks
3428
+ // then found `scheduledAt` still null, mismatched their intent token, and no-op'd. A
3429
+ // confirmed schedule that silently never fires. Every sibling handler checks this.
3430
+ const updated = await db.update(c.entity, input.id, patch);
3431
+ if (updated === undefined) throw notFound(c.label);
3432
+ await ctx.tasks.enqueue({
3433
+ kind: TASK_COLLECTION_PUBLISH,
3434
+ payload: { collection: c.slug, id: input.id, token: publishToken },
3435
+ delayMs: Math.max(0, input.publishAt - now),
3436
+ });
3437
+ if (typeof input.unpublishAt === "number") {
3438
+ await ctx.tasks.enqueue({
3439
+ kind: TASK_COLLECTION_UNPUBLISH,
3440
+ payload: { collection: c.slug, id: input.id, token: unpublishToken },
3441
+ delayMs: Math.max(0, input.unpublishAt - now),
3442
+ });
3443
+ }
3444
+ return { ok: true as const, scheduledAt: publishToken, ...(hasUnpublish ? { unpublishAt: unpublishToken } : {}) };
3445
+ }, {
3446
+ ...editor,
3447
+ input: (raw): { collection: string; id: string; publishAt: number; unpublishAt?: number | null } => {
3448
+ const o = asObj(raw);
3449
+ const base = rowInput(raw);
3450
+ // Range-checked, not merely finite — see `epochInput`. An out-of-range value would
3451
+ // otherwise either throw a RangeError inside the transaction (an opaque 500) or
3452
+ // mint an expanded-year ISO string that compares backwards forever.
3453
+ const publishAt = epochInput("publishAt", o.publishAt);
3454
+ // `null` is the explicit "cancel the takedown"; absent leaves it untouched.
3455
+ if (o.unpublishAt !== undefined && o.unpublishAt !== null) {
3456
+ const unpublishAt = epochInput("unpublishAt", o.unpublishAt);
3457
+ if (unpublishAt <= publishAt) throw new BadRequest("unpublishAt must be after publishAt");
3458
+ return { ...base, publishAt, unpublishAt };
3459
+ }
3460
+ // Only `unpublishAt: null` (cancel) and an absent key reach here.
3461
+ return "unpublishAt" in o ? { ...base, publishAt, unpublishAt: null } : { ...base, publishAt };
3462
+ },
3463
+ }),
3464
+
3465
+ // ---- revisions ----------------------------------------------------------
3466
+
3467
+ /** A row's revision history, newest first. */
3468
+ collectionListRevisions: query(async (ctx, input: { collection: string; id: string; limit?: number }) => {
3469
+ const c = def(input.collection);
3470
+ needs(c, "revisions");
3471
+ const db = cdb(ctx);
3472
+ // Read the ROW through the ACL first. `cms_collection_revisions` is shared across
3473
+ // every collection and `collectionPolicies` grants it a flat allow(), so without this
3474
+ // a role holding narrower per-entity read policies could list the snapshots of a
3475
+ // collection it cannot read through `collectionGet`. `collectionRestoreRevision`
3476
+ // already had this via `loadRow`; the list path did not.
3477
+ const row = await loadRow(db, c, input.id);
3478
+ const revs = await db.find({
3479
+ from: COLLECTION_REVISIONS_TABLE,
3480
+ where: { collection: c.slug, rowId: input.id },
3481
+ // By the monotonic counter, never by a timestamp — see the `revision` column.
3482
+ orderBy: { column: "revision", dir: "desc" },
3483
+ limit: input.limit ?? 50,
3484
+ });
3485
+ // Project every snapshot to the fields THIS caller may read on the collection's own
3486
+ // entity. The row check above is a ROW-level gate, and `collectionPolicies` grants the
3487
+ // shared revisions table a flat allow() — so without this a caller holding a
3488
+ // FIELD-restricted read policy (`fields: ["id", "title", "status"]`) reads back the
3489
+ // columns that policy withholds, in full, out of the snapshot JSON. History must not
3490
+ // be a way around the field scope that governs the row itself.
3491
+ //
3492
+ // `loadRow` came through the ACL, and reads are column-projected, so its keys ARE the
3493
+ // caller's readable columns.
3494
+ const readable = new Set(Object.keys(row));
3495
+ return revs.map((r) => ({ ...r, snapshot: projectSnapshot(r.snapshot, readable) }));
3496
+ }, {
3497
+ ...editor,
3498
+ input: (raw): { collection: string; id: string; limit?: number } => {
3499
+ const o = asObj(raw);
3500
+ if (o.limit !== undefined && (typeof o.limit !== "number" || !Number.isFinite(o.limit))) throw new BadRequest("limit must be a number");
3501
+ // CLAMPED, not just validated: `find` binds this straight into `LIMIT ?`, and SQLite
3502
+ // reads a negative limit as UNBOUNDED — so `limit: -1` would dump a row's entire
3503
+ // history. A fractional value would reach the driver as-is.
3504
+ const limit = o.limit === undefined ? undefined : Math.max(1, Math.min(Math.floor(o.limit as number), 200));
3505
+ return { ...rowInput(raw), limit };
3506
+ },
3507
+ }),
3508
+
3509
+ /** Restore a row to one of its revisions. The CURRENT state is snapshotted first, so a
3510
+ * restore is itself undoable. */
3511
+ collectionRestoreRevision: mutation(async (ctx, input: { collection: string; id: string; revisionId: string }) => {
3512
+ const c = def(input.collection);
3513
+ needs(c, "revisions");
3514
+ const db = cdb(ctx);
3515
+ const revs = await db.find({ from: COLLECTION_REVISIONS_TABLE, where: { id: input.revisionId }, limit: 1 });
3516
+ const rev = revs[0];
3517
+ // Scope the revision to THIS collection AND row. A revision id is otherwise a global
3518
+ // handle into a table shared by every collection, so an id from another row — or
3519
+ // another collection entirely — would write a foreign snapshot over this row.
3520
+ if (!rev || String(rev.collection) !== c.slug || String(rev.rowId) !== input.id) throw notFound("revision");
3521
+ const current = await loadRow(db, c, input.id);
3522
+ // A snapshot is built from an ACL-PROJECTED row, so a caller whose read scope excluded
3523
+ // every declared column stored `{}`. `Db.update` returns undefined for a zero-column
3524
+ // patch, which would surface below as "not found" for a row loaded two lines earlier —
3525
+ // a misleading 404. Say what is actually wrong instead.
3526
+ // Restore exactly the fields this caller can READ, for the same reason
3527
+ // `collectionListRevisions` projects them: the snapshot is complete (it was captured
3528
+ // through the raw path), and the shared revisions table is granted flat, so replaying
3529
+ // it whole would let a field-restricted editor write back columns their own read
3530
+ // policy withholds — restoring a value they cannot see, out of a version they cannot
3531
+ // read. What you can see is what you can put back.
3532
+ const visible = projectSnapshot(rev.snapshot, new Set(Object.keys(current)));
3533
+ const restore = toColumns(c, visible, false, current as FieldValues);
3534
+ if (Object.keys(restore).length === 0) {
3535
+ throw new BadRequest(`revision ${input.revisionId} has no restorable fields (none of its columns are readable by this caller)`);
3536
+ }
3537
+ await snapshotRow(db, c, current, ctx, "restore");
3538
+ // Replay through the SAME validate + whitelist as an ordinary write: a snapshot taken
3539
+ // before a field was dropped from `fields` must not resurrect that column, and one
3540
+ // taken before a field's type changed must not bypass validation.
3541
+ const updated = await db.update(c.entity, input.id, restore);
3542
+ if (updated === undefined) throw notFound(c.label);
3543
+ return updated;
3544
+ }, {
3545
+ ...editor,
3546
+ input: (raw): { collection: string; id: string; revisionId: string } => {
3547
+ const o = asObj(raw);
3548
+ if (typeof o.revisionId !== "string" || o.revisionId === "") throw new BadRequest("revisionId is required");
3549
+ return { ...rowInput(raw), revisionId: o.revisionId };
3550
+ },
3551
+ }),
3552
+
3553
+ // ---- preview ------------------------------------------------------------
3554
+
3555
+ /** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
3556
+ * Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
3557
+ * same rule that the row is read through the ACL FIRST: minting a link is granting
3558
+ * access to the row, so a caller who cannot read it must not be able to mint one. */
3559
+ signCollectionPreview: query(async (ctx, input: { collection: string; id: string; expiresIn?: number }) => {
3560
+ const c = def(input.collection);
3561
+ needs(c, "preview");
3562
+ const secret = previewSecret(ctx.env);
3563
+ if (!secret) throw previewUnconfigured(); // fail closed — never mint a forgeable link
3564
+ // Redemption always reaches a Durable Object (callPrivileged -> PRAMEN.get) and has no
3565
+ // notion of `x-pramen-store`, so a link minted on D1 would 404 forever while the
3566
+ // editor reported success.
3567
+ if (ctx.store === "d1") {
3568
+ throw new PramenError("collection preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
3569
+ }
3570
+ const row = await loadRow(cdb(ctx), c, input.id);
3571
+ const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
3572
+ const exp = Math.floor(Date.now() / 1000) + ttl;
3573
+ // Server-resolved, never caller-supplied, so the tenant inside the signature cannot
3574
+ // be steered by whoever asks for the link.
3575
+ const token = await signToken<CollectionPreviewToken>({ t: ctx.tenant, c: c.slug, r: String(row[idOf(c)]), exp }, secret);
3576
+ // RELATIVE, like signed file urls — the client resolves it against the CMS origin.
3577
+ return { url: `${COLLECTION_PREVIEW_PATH}?token=${encodeURIComponent(token)}`, token, expiresAt: exp * 1000 };
3578
+ }, {
3579
+ ...editor,
3580
+ input: (raw): { collection: string; id: string; expiresIn?: number } => {
3581
+ const o = asObj(raw);
3582
+ if (o.expiresIn !== undefined && (typeof o.expiresIn !== "number" || !Number.isFinite(o.expiresIn))) {
3583
+ throw new BadRequest("expiresIn must be a number of seconds");
3584
+ }
3585
+ return { ...rowInput(raw), expiresIn: o.expiresIn as number | undefined };
3586
+ },
3587
+ }),
3588
+
3589
+ /** Read one row's live (possibly unpublished) state. Not the redemption endpoint — that
3590
+ * is the public `GET /cms/preview/collection` route, which verifies the token and then
3591
+ * calls this privileged. Role-gated so it is not an anonymous back door on /rpc. */
3592
+ getCollectionPreview: query(async (ctx, input: { collection: string; id: string }) => {
3593
+ const c = def(input.collection);
3594
+ needs(c, "preview");
3595
+ const row = await loadRow(cdb(ctx), c, input.id);
3596
+ const values: Record<string, unknown> = { [idOf(c)]: row[idOf(c)] };
3597
+ for (const f of c.fields) if (f.name in row) values[f.name] = row[f.name];
3598
+ if (has(c, "drafts")) values.status = row.status;
3599
+ return { collection: c.slug, id: String(row[idOf(c)]), values };
3600
+ }, { ...viewer, input: rowInput }),
2001
3601
  };
2002
3602
  }
2003
3603
 
@@ -2017,9 +3617,184 @@ export function collectionPolicies(collections: readonly CollectionDef[], opts:
2017
3617
  out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
2018
3618
  }
2019
3619
  }
3620
+ // The revision table is SHARED across collections, so it is not covered by the per-entity
3621
+ // grants above. Granting it here — rather than leaning on `cmsPolicies().editor` — keeps
3622
+ // `revisions` self-contained: an app that registers collections without using the
3623
+ // block/page half still gets a working feature instead of a 403 on every write. Read +
3624
+ // create only; a revision is append-only, and nothing exposes editing or purging one.
3625
+ if (collections.some((c) => (c.supports ?? []).includes("revisions"))) {
3626
+ for (const action of ["read", "create"] as const) {
3627
+ out.push(policy(`${p}:editor:${COLLECTION_REVISIONS_TABLE}:${action}`, COLLECTION_REVISIONS_TABLE, action, allow()));
3628
+ }
3629
+ }
3630
+ return out;
3631
+ }
3632
+
3633
+ /** ACL fragments granting ANONYMOUS read of the PUBLISHED rows of every collection that
3634
+ * supports `drafts`. Spread into your public role next to `cmsPolicies().public`:
3635
+ *
3636
+ * role("anonymous", [...cmsPolicies().public, ...collectionPublicPolicies(collections)])
3637
+ *
3638
+ * This is the access boundary, not a UI filter — it is AND-merged into every `ctx.db` read
3639
+ * of the entity, so an unpublished row is invisible to the public API, to relation
3640
+ * traversals and to eager-loads alike, without a single query remembering to filter.
3641
+ *
3642
+ * With `scheduling`, the scope also requires `publishedAt <= $now()`. `status` alone would
3643
+ * not be enough the moment anything writes a future `publishedAt`, and `{ publishedAt:
3644
+ * { isNull: false } }` — the obvious-looking alternative — matches a FUTURE timestamp too,
3645
+ * so a row scheduled for next week would be anonymously readable the moment it was saved.
3646
+ * The comparison is lexicographic over TEXT, which is why every managed timestamp is minted
3647
+ * as ISO-8601 UTC (`isoStamp`), the same shape `$now()` produces.
3648
+ *
3649
+ * Collections WITHOUT `drafts` get nothing here: they have no publish state, so their
3650
+ * public exposure is entirely your app's own policy to write. */
3651
+ export function collectionPublicPolicies(collections: readonly CollectionDef[], opts: CmsPolicyOpts = {}): Policy[] {
3652
+ const p = opts.prefix ?? "cms";
3653
+ const out: Policy[] = [];
3654
+ for (const c of collections) {
3655
+ const features = c.supports ?? [];
3656
+ if (!features.includes("drafts")) continue;
3657
+ const name = `${p}:public:collection:${c.entity}:read`;
3658
+ // The PUBLIC surface is exactly what the collection declares as editable, plus its id
3659
+ // and the two columns a public page legitimately reads: `status` (constant `published`
3660
+ // for every visible row) and, with `scheduling`, `publishedAt`.
3661
+ //
3662
+ // Granting the whole row instead would quietly publish every future column: an
3663
+ // `internalNote` or `reviewerEmail` added to the entity — deliberately NOT a field —
3664
+ // would go world-readable the moment a row was published, with nothing at boot or in
3665
+ // review to catch it. But excluding `publishedAt` went too far the other way: the
3666
+ // single most obvious public query, "newest published first", 403s for anonymous while
3667
+ // working for an editor, because a caller may not order by a column it cannot read.
3668
+ // A publication date is public by construction — it is printed on the page. The
3669
+ // FORWARD-looking columns stay private: `scheduledAt` and `unpublishAt` would leak
3670
+ // "this comes down on Friday" to everyone.
3671
+ const fields = [c.idField ?? "id", ...c.fields.map((f) => f.name), "status", ...(features.includes("scheduling") ? ["publishedAt"] : [])];
3672
+ out.push(
3673
+ features.includes("scheduling")
3674
+ ? policy(name, c.entity, "read", {
3675
+ fields,
3676
+ where: {
3677
+ status: COLLECTION_PUBLISHED,
3678
+ // Both time clauses are OR-groups, so they go in an explicit `AND: [...]` —
3679
+ // two `OR` keys in one object literal would be the same property, and the
3680
+ // second would silently REPLACE the first.
3681
+ AND: [
3682
+ {
3683
+ // `publishedAt <= $now()` alone silently hides every published row with
3684
+ // no stamp — a `cmsBootstrap` seed, an import, a row published while the
3685
+ // collection was still `supports: ["drafts"]` — and does it EN MASSE the
3686
+ // moment `scheduling` is added to an existing collection, because
3687
+ // `NULL <= '2026-…'` is NULL, not true. NULL here means "published,
3688
+ // instant unknown", never "scheduled for later": `publishedAt` is a
3689
+ // managed column (never in the write whitelist) that only ever takes
3690
+ // `isoStamp()` or null, and a row awaiting a scheduled publish is
3691
+ // `status: 'draft'` with the instant in `scheduledAt`. So a missing stamp
3692
+ // cannot be a future one, and treating it as "already published" is both
3693
+ // safe and what the editor already shows.
3694
+ OR: [{ publishedAt: { isNull: true } }, { publishedAt: { lte: $now() } }],
3695
+ },
3696
+ {
3697
+ // A scheduled TAKEDOWN has to be enforced HERE, not only by the task. The
3698
+ // publish side is belt-and-braces (policy + task), but without this clause
3699
+ // an unpublish depends entirely on `createCollectionTasks` being wired and
3700
+ // the outbox draining — if either fails, a row an editor scheduled to come
3701
+ // down stays world-readable indefinitely, with no signal. That is the
3702
+ // wrong way round for a takedown, the direction that matters legally.
3703
+ OR: [{ unpublishAt: { isNull: true } }, { unpublishAt: { gt: $now() } }],
3704
+ },
3705
+ ],
3706
+ },
3707
+ })
3708
+ : policy(name, c.entity, "read", { fields, where: { status: COLLECTION_PUBLISHED } }),
3709
+ );
3710
+ }
2020
3711
  return out;
2021
3712
  }
2022
3713
 
3714
+ /** Task handlers backing `collectionSchedule`. Register alongside `cmsTasks`:
3715
+ *
3716
+ * const app = { tasks: { ...cmsTasks, ...createCollectionTasks(collections) } };
3717
+ *
3718
+ * WITHOUT THIS WIRING A SCHEDULE NEVER FIRES: `collectionSchedule` still stores the
3719
+ * instants and enqueues the tasks, but the drain finds no handler for their kind, so the
3720
+ * row silently stays a draft. (`cmsTasks` has the same requirement for page scheduling.)
3721
+ *
3722
+ * They run with a privileged, system-scoped ctx off the write path, and each validates its
3723
+ * INTENT TOKEN against the row's current `scheduledAt`/`unpublishAt` before acting — see
3724
+ * `collectionSchedule`. */
3725
+ export function createCollectionTasks(collections: readonly CollectionDef[]) {
3726
+ const bySlug = new Map(collections.map((c) => [c.slug, c] as const));
3727
+ const run = async (
3728
+ ctx: HandlerContext,
3729
+ payload: unknown,
3730
+ tokenColumn: "scheduledAt" | "unpublishAt",
3731
+ buildPatch: (row: Record<string, unknown>, now: string) => Record<string, unknown>,
3732
+ ): Promise<void> => {
3733
+ const { collection, id, token } = asObj(payload) as { collection?: string; id?: string; token?: string };
3734
+ if (typeof collection !== "string" || typeof id !== "string") return;
3735
+ // Resolved through the REGISTRY, exactly as the handlers do — the payload's `collection`
3736
+ // is never used as a table name.
3737
+ const c = bySlug.get(collection);
3738
+ // A slug the handlers accept but this registry does not know is a WIRING mistake:
3739
+ // `createCollectionHandlers(collections)` and `createCollectionTasks(otherList)` built
3740
+ // from different arrays. Returning quietly made the drain report `{ succeeded: 1 }`
3741
+ // while the row stayed a draft forever — strictly worse than not registering the tasks
3742
+ // at all, which dead-letters loudly. Throw so the outbox retries and then dead-letters
3743
+ // with the slug in the message.
3744
+ if (!c) throw new Error(`pramen/cms: no collection '${collection}' in createCollectionTasks' registry — pass the SAME collections array to createCollectionHandlers and createCollectionTasks`);
3745
+ const db = cdb(ctx);
3746
+ const rows = await db.find({ from: c.entity, where: { [c.idField ?? "id"]: id }, limit: 1 });
3747
+ const row = rows[0];
3748
+ if (!row) return;
3749
+ // Intent check: act only if this task is still the row's active schedule. A reschedule
3750
+ // (new token), a manual publish/unpublish (token cleared) or a duplicate delivery after
3751
+ // this task already ran all make it a no-op.
3752
+ //
3753
+ // Require a NON-EMPTY token on both sides. Comparing the coalesced strings alone treats
3754
+ // "no token in the payload" and "no schedule on the row" as a MATCH — so a payload
3755
+ // without a token (a hand-drained or replayed outbox row) against a row whose
3756
+ // `scheduledAt` is null, the normal state right after an unpublish, would publish it
3757
+ // unconditionally. The guard should fail closed, not open.
3758
+ const stored = typeof row[tokenColumn] === "string" ? (row[tokenColumn] as string) : "";
3759
+ if (!token || !stored || stored !== token) return;
3760
+ await db.update(c.entity, id, buildPatch(row, isoStamp()));
3761
+ };
3762
+ return {
3763
+ [TASK_COLLECTION_PUBLISH]: (ctx: HandlerContext, payload: unknown) =>
3764
+ run(ctx, payload, "scheduledAt", (row, now) => {
3765
+ // CONVERGE to the state the schedule implies AT `now`, rather than blindly applying
3766
+ // the step this task was enqueued for. A drain can lag arbitrarily (D1 cron
3767
+ // granularity, outbox backoff, a stalled DO alarm), and the two tasks can arrive in
3768
+ // either order, so "publish" has to mean "publish IF the takedown has not come yet".
3769
+ //
3770
+ // The previous behavior — publish anyway, and null the passed `unpublishAt` to keep
3771
+ // the row visible — destroyed a scheduled takedown outright: the token the unpublish
3772
+ // task compares against was gone, so it no-op'd, and the read scope's
3773
+ // `unpublishAt IS NULL OR unpublishAt > $now()` backstop had nothing left to enforce.
3774
+ // A row scheduled to come down at noon stayed world-readable forever, silently.
3775
+ // A takedown that failing OPEN cannot be recovered from is the wrong way round.
3776
+ if (typeof row.unpublishAt === "string" && row.unpublishAt !== "" && row.unpublishAt <= now) {
3777
+ // Both instants are in the past: the row's whole scheduled life has elapsed.
3778
+ // Land on the END state (down), and spend both tokens so neither task can fire
3779
+ // against this schedule again.
3780
+ return { status: COLLECTION_DRAFT, publishedAt: null, scheduledAt: null, unpublishAt: null };
3781
+ }
3782
+ // The ordinary case: publish now, keep a still-future takedown standing.
3783
+ return { status: COLLECTION_PUBLISHED, publishedAt: now, scheduledAt: null };
3784
+ }),
3785
+ [TASK_COLLECTION_UNPUBLISH]: (ctx: HandlerContext, payload: unknown) =>
3786
+ // Clear `scheduledAt` too — the third column `collectionUnpublish` clears and this
3787
+ // task used to leave behind. A pending publish token that survived a takedown is a
3788
+ // live re-publish: if the publish task drains after this one (either order is
3789
+ // possible) or is retried after a throw, it finds its token still matching and puts
3790
+ // the row back up — with `unpublishAt` now null, permanently and with no repair path.
3791
+ // A schedule always orders publish BEFORE takedown (`collectionSchedule` enforces it
3792
+ // against the stored value as well as the submitted one), so any `scheduledAt` still
3793
+ // standing at the takedown is spent by definition.
3794
+ run(ctx, payload, "unpublishAt", () => ({ status: COLLECTION_DRAFT, publishedAt: null, scheduledAt: null, unpublishAt: null })),
3795
+ };
3796
+ }
3797
+
2023
3798
  // --- deferred tasks (scheduled publish/unpublish) ----------------------------
2024
3799
 
2025
3800
  /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
@@ -2110,11 +3885,47 @@ interface CmsRoute {
2110
3885
  handler: (request: Request, env: EnvBag, ctx: RouteCtx) => Promise<Response>;
2111
3886
  }
2112
3887
 
2113
- /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
3888
+ const previewJson = (status: number, code: string, message: string): Response =>
3889
+ // `{ ok, error, code }` — the shape every other pramen error uses (runtime/errors.ts).
3890
+ new Response(JSON.stringify({ ok: false, error: message, code }), {
3891
+ status,
3892
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
3893
+ });
3894
+ /** One response for a missing, malformed, forged, or expired token — and for a page that
3895
+ * is not there. Distinguishing them would let a caller probe for valid page ids. */
3896
+ const previewDenied = (status = 403, message = "invalid or expired preview link"): Response =>
3897
+ previewJson(status, status === 404 ? "not_found" : "forbidden", message);
3898
+ const preview503 = (): Response =>
3899
+ previewJson(503, "unavailable", "page preview is not configured (set a strong PREVIEW_SECRET, FILES_SECRET or AUTH_SECRET)");
3900
+
3901
+ /** Turnkey public routes for `GET /sitemap.xml`, `GET /cms/preview` and `GET /robots.txt`. Spread into
2114
3902
  * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
2115
3903
  * `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
2116
- export function cmsRoutes(opts: { origin?: string; tenant?: string; pageUrl?: SitemapOpts["pageUrl"]; disallow?: string[] } = {}): CmsRoute[] {
3904
+ export function cmsRoutes(
3905
+ opts: {
3906
+ origin?: string;
3907
+ tenant?: string;
3908
+ pageUrl?: SitemapOpts["pageUrl"];
3909
+ disallow?: string[];
3910
+ /** The SAME options you passed to `createCmsHandlers`. The route derives its identity
3911
+ * from them with `viewerRolesOf`, so the two cannot drift. (`viewerRoles` overrides it
3912
+ * outright if you need to.) */
3913
+ handlers?: CmsHandlerOpts;
3914
+ /** The SAME options you passed to `createCollectionHandlers`, if they differ from
3915
+ * `handlers`. The COLLECTION preview route has its own gate — `getCollectionPreview` is
3916
+ * built by `createCollectionHandlers`, so an app that passes different `editorRoles` /
3917
+ * `reviewerRoles` to the two factories would have the route present the page half's
3918
+ * roles to a handler gated on the collection half's, and every collection preview link
3919
+ * would 404 uniformly (the response is deliberately indistinguishable from "not
3920
+ * found"). Defaults to `handlers`, which is right whenever both got the same options. */
3921
+ collectionHandlers?: CmsHandlerOpts;
3922
+ /** Explicit override for the roles the preview routes present to the DO. */
3923
+ viewerRoles?: readonly string[];
3924
+ } = {},
3925
+ ): CmsRoute[] {
2117
3926
  const tenant = opts.tenant ?? "main";
3927
+ const previewRoles = opts.viewerRoles ?? viewerRolesOf(opts.handlers);
3928
+ const collectionPreviewRoles = opts.viewerRoles ?? viewerRolesOf(opts.collectionHandlers ?? opts.handlers);
2118
3929
  return [
2119
3930
  {
2120
3931
  method: "GET",
@@ -2127,6 +3938,85 @@ export function cmsRoutes(opts: { origin?: string; tenant?: string; pageUrl?: Si
2127
3938
  return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
2128
3939
  },
2129
3940
  },
3941
+ {
3942
+ // Redeem a preview link. PUBLIC and pre-auth by design — the signature IS the
3943
+ // authorization, so a reviewer with no account can open it. The token is verified
3944
+ // BEFORE any read, and it names one page, so a valid signature never widens into
3945
+ // "see all drafts". Only then do we reach the DO, privileged.
3946
+ method: "GET",
3947
+ path: PREVIEW_PATH,
3948
+ handler: async (request, env, ctx) => {
3949
+ const secret = previewSecret(env);
3950
+ // Fail closed: with no usable secret every signature would verify against a weak
3951
+ // key, so refuse to verify at all rather than accept forged links.
3952
+ if (!secret) return preview503();
3953
+ const raw = new URL(request.url).searchParams.get("token");
3954
+ if (!raw) return previewDenied();
3955
+ const payload = await verifyToken<PreviewToken>(raw, secret);
3956
+ if (!payload || typeof payload.p !== "string" || typeof payload.t !== "string") return previewDenied();
3957
+
3958
+ // The synthetic identity has to satisfy BOTH gates the DO applies: the handler's
3959
+ // `auth` (viewerRoles) and the row ACL (whatever role the app granted cmsPolicies
3960
+ // to). Hardcoding ["admin"] satisfied neither under the wiring this package's own
3961
+ // README documents — `role("anonymous", …)` + `role("editor", …)`, no admin role
3962
+ // at all — so every preview link 404'd. The e2e only passed because example/app.ts
3963
+ // happens to define an admin role. Send the configured viewer roles instead.
3964
+ const res = await ctx.callPrivileged({
3965
+ name: "getPagePreview",
3966
+ input: { pageId: payload.p },
3967
+ tenant: payload.t, // from the SIGNED payload, never from the query string
3968
+ roles: [...previewRoles],
3969
+ });
3970
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: JsonValue; error?: string; code?: string };
3971
+ if (body.ok !== true) {
3972
+ // The client-visible response stays uniform (probe resistance), but LOG the real
3973
+ // reason: a role misconfiguration previously surfaced as an indistinguishable
3974
+ // "page not found" that could only be diagnosed by reading source.
3975
+ console.error(`pramen/cms: preview redemption failed (${res.status} ${body.code ?? "?"}: ${body.error ?? "no detail"})`);
3976
+ return previewDenied(404, "page not found");
3977
+ }
3978
+ // Never cache a draft, anywhere.
3979
+ return new Response(JSON.stringify(body.result), {
3980
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
3981
+ });
3982
+ },
3983
+ },
3984
+ {
3985
+ // Redeem a COLLECTION preview link. Same contract as the page preview route above:
3986
+ // public and pre-auth by design (the signature IS the authorization, so a reviewer
3987
+ // with no account can open it), verified BEFORE any read, and scoped by the signed
3988
+ // payload to one row of one collection — a valid signature never widens into "see
3989
+ // every draft".
3990
+ method: "GET",
3991
+ path: COLLECTION_PREVIEW_PATH,
3992
+ handler: async (request, env, ctx) => {
3993
+ const secret = previewSecret(env);
3994
+ // Fail closed: with no usable secret every signature would verify against a weak
3995
+ // key, so refuse to verify at all rather than accept forged links.
3996
+ if (!secret) return preview503();
3997
+ const raw = new URL(request.url).searchParams.get("token");
3998
+ if (!raw) return previewDenied();
3999
+ const payload = await verifyToken<CollectionPreviewToken>(raw, secret);
4000
+ if (!payload || typeof payload.c !== "string" || typeof payload.r !== "string" || typeof payload.t !== "string") return previewDenied();
4001
+ const res = await ctx.callPrivileged({
4002
+ name: "getCollectionPreview",
4003
+ input: { collection: payload.c, id: payload.r },
4004
+ tenant: payload.t, // from the SIGNED payload, never from the query string
4005
+ roles: [...collectionPreviewRoles], // the COLLECTION handlers' gate — see `collectionHandlers`
4006
+ });
4007
+ const body = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: JsonValue; error?: string; code?: string };
4008
+ if (body.ok !== true) {
4009
+ // Uniform client-visible response (probe resistance), but LOG the real reason —
4010
+ // a role or wiring mistake here is otherwise indistinguishable from "not found".
4011
+ console.error(`pramen/cms: collection preview redemption failed (${res.status} ${body.code ?? "?"}: ${body.error ?? "no detail"})`);
4012
+ return previewDenied(404, "not found");
4013
+ }
4014
+ // Never cache a draft, anywhere.
4015
+ return new Response(JSON.stringify(body.result), {
4016
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
4017
+ });
4018
+ },
4019
+ },
2130
4020
  {
2131
4021
  method: "GET",
2132
4022
  path: "/robots.txt",