@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/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue } from "@pramen/server";
1
+ import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef } from "@pramen/server";
2
2
  import type { EnvBag } from "@pramen/server";
3
3
  /** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
4
4
  * or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
@@ -72,11 +72,35 @@ export interface DefaultBlockDefinition {
72
72
  blockTypeSlug: string;
73
73
  fields?: FieldValues;
74
74
  }
75
- /** A rich-text value — a serialized editor document (or a plain string). */
76
- export type RichText = string | {
75
+ /**
76
+ * A rich-text document the editor's structured JSON (a ProseMirror/TipTap doc tree).
77
+ *
78
+ * **Not an HTML string.** HTML never enters storage: the write path validates this tree
79
+ * against a node/mark allow-list (`normalizeRichText`) and the render side maps node types
80
+ * to components, so there is no `set:html` / `dangerouslySetInnerHTML` anywhere in the
81
+ * chain and nothing to scrub. The one attribute that can still carry script is a `link`
82
+ * mark's `href`, so that one IS checked — see `isSafeHref`.
83
+ */
84
+ export interface RichTextDoc {
85
+ type: "doc";
86
+ content?: RichTextNode[];
87
+ }
88
+ /** One node in a {@link RichTextDoc}. A leaf carries `text` (plus optional inline `marks`);
89
+ * a container carries `content`. */
90
+ export interface RichTextNode {
77
91
  type: string;
78
- content?: unknown[];
79
- };
92
+ content?: RichTextNode[];
93
+ text?: string;
94
+ marks?: RichTextMark[];
95
+ attrs?: Record<string, JsonValue>;
96
+ }
97
+ /** An inline mark on a text node — bold, link, highlight, … */
98
+ export interface RichTextMark {
99
+ type: string;
100
+ attrs?: Record<string, JsonValue>;
101
+ }
102
+ /** The value of a `richtext` field. */
103
+ export type RichText = RichTextDoc;
80
104
  /** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
81
105
  * Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
82
106
  export type FieldTsType<D extends FieldDefinition> = D["type"] extends "text" | "textarea" | "url" | "select" | "date" | "datetime" | "publish" | "slug" ? string : D["type"] extends "richtext" ? RichText : D["type"] extends "number" ? number : D["type"] extends "boolean" ? boolean : D["type"] extends "media" ? ResolvedMedia | null : D["type"] extends "group" ? InferBlockFields<NonNullable<D["fields"]>> : D["type"] extends "repeater" ? InferBlockFields<NonNullable<D["fields"]>>[] : unknown;
@@ -267,6 +291,11 @@ export declare const cmsSchema: {
267
291
  } & {
268
292
  readonly default: false;
269
293
  };
294
+ version: {
295
+ readonly type: "integer";
296
+ } & {
297
+ readonly default: 1;
298
+ };
270
299
  createdAt: {
271
300
  readonly type: "text";
272
301
  } & {
@@ -341,6 +370,11 @@ export declare const cmsSchema: {
341
370
  currentRevisionId: {
342
371
  readonly type: "uuid";
343
372
  };
373
+ deletedAt: {
374
+ readonly type: "text";
375
+ } & {
376
+ readonly index: true;
377
+ };
344
378
  metaTitle: {
345
379
  readonly type: "text";
346
380
  };
@@ -365,6 +399,11 @@ export declare const cmsSchema: {
365
399
  structuredData: {
366
400
  readonly type: "json";
367
401
  };
402
+ version: {
403
+ readonly type: "integer";
404
+ } & {
405
+ readonly default: 1;
406
+ };
368
407
  createdAt: {
369
408
  readonly type: "text";
370
409
  } & {
@@ -518,6 +557,47 @@ export declare const cmsSchema: {
518
557
  readonly defaultExpr: string;
519
558
  };
520
559
  }, Record<string, never>>;
560
+ cms_collection_revisions: import("@pramen/server").EntityDef<{
561
+ id: {
562
+ readonly type: "uuid";
563
+ } & {
564
+ readonly generated: true;
565
+ } & {
566
+ readonly primaryKey: true;
567
+ readonly notNull: true;
568
+ };
569
+ collection: {
570
+ readonly type: "text";
571
+ } & {
572
+ readonly notNull: true;
573
+ } & {
574
+ readonly index: true;
575
+ };
576
+ rowId: {
577
+ readonly type: "text";
578
+ } & {
579
+ readonly notNull: true;
580
+ } & {
581
+ readonly index: true;
582
+ };
583
+ revision: {
584
+ readonly type: "integer";
585
+ } & {
586
+ readonly notNull: true;
587
+ };
588
+ snapshot: {
589
+ readonly type: "json";
590
+ };
591
+ note: {
592
+ readonly type: "text";
593
+ };
594
+ actor: {
595
+ readonly type: "text";
596
+ };
597
+ createdAt: {
598
+ readonly type: "text";
599
+ };
600
+ }, Record<string, never>>;
521
601
  cms_media: import("@pramen/server").EntityDef<{
522
602
  id: {
523
603
  readonly type: "uuid";
@@ -533,6 +613,11 @@ export declare const cmsSchema: {
533
613
  alt: {
534
614
  readonly type: "text";
535
615
  };
616
+ deletedAt: {
617
+ readonly type: "text";
618
+ } & {
619
+ readonly index: true;
620
+ };
536
621
  createdAt: {
537
622
  readonly type: "text";
538
623
  } & {
@@ -545,12 +630,53 @@ export interface ValidateOpts {
545
630
  * writes (addBlock/updateBlock/createPage) pass `false` — a DRAFT block may be incomplete;
546
631
  * required is only mandatory when publishing. Type checks always run. */
547
632
  requireRequired?: boolean;
633
+ /** The row's CURRENTLY STORED field values. A legacy HTML-string `richtext` value is
634
+ * tolerated only when it is byte-identical to the stored one — i.e. the caller echoed
635
+ * back a pre-Portable-Text value it never authored (the editor autosaves the whole bag).
636
+ * Anything else is rejected.
637
+ *
638
+ * This must NOT be a plain boolean. The `xss` sanitizer is gone, and `normalizeFields`
639
+ * passes a tolerated string through untouched, so a blanket "allow strings" would let
640
+ * any caller store arbitrary unsanitized HTML — which every consumer still on the
641
+ * pre-migration `set:html` contract would then execute. */
642
+ legacyBaseline?: FieldValues;
548
643
  }
549
644
  export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
550
- /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
551
- * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
552
- export declare function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: FieldValues): FieldValues;
645
+ /** The node/mark vocabulary a `richtext` value may use. A node entry maps a node type to
646
+ * the attribute names kept on it; a mark entry does the same for a mark type. */
647
+ export interface RichTextSchema {
648
+ nodes: Record<string, readonly string[]>;
649
+ marks: Record<string, readonly string[]>;
650
+ /** Highest heading level accepted; anything above is CLAMPED to it, not dropped.
651
+ * Defaults to `MAX_HEADING_LEVEL` (3, the shipped editor's StarterKit config). Raise it
652
+ * if your editor is configured for more — this is the widening the docs promise. */
653
+ maxHeadingLevel?: number;
654
+ }
655
+ /** What the shipped editor can actually produce (TipTap StarterKit + Highlight + TaskList,
656
+ * as configured by @podoba/react's BlockEditor). Pass your own to `normalizeFields` if your
657
+ * editor adds extensions — a node type absent from the schema is dropped on write. */
658
+ /** Highest heading level the shipped editor is configured for (StarterKit levels [1,2,3]). */
659
+ export declare const MAX_HEADING_LEVEL = 3;
660
+ export declare const DEFAULT_RICH_TEXT_SCHEMA: RichTextSchema;
661
+ export { isSafeHref, normalizeHref } from "./href";
662
+ /** How deep a document may nest before the normalizer stops descending. Real editor output
663
+ * is a handful of levels (list > item > paragraph > text); a hand-crafted doc nested tens
664
+ * of thousands deep would otherwise blow the stack INSIDE the DO's storage.transaction().
665
+ * JSON.parse is iterative in V8, so such a payload reaches the normalizer intact. */
666
+ export declare const MAX_RICH_TEXT_DEPTH = 100;
667
+ /** Normalize a rich-text value to a document the renderers can trust. A value that is not
668
+ * a doc at all yields an empty doc — `validateFields` rejects those first, so in handler
669
+ * flow this only ever sees a doc; the fallback is for direct callers. */
670
+ export declare function normalizeRichText(value: unknown, schema?: RichTextSchema): RichTextDoc;
671
+ /** Flatten a rich-text document to plain text — for excerpts, meta descriptions, and search
672
+ * indexing, which want the words without the structure. */
673
+ export declare function richTextToPlainText(value: RichTextDoc | null | undefined): string;
674
+ /** Deep-normalize the richtext fields in a values object against a field schema (recursing
675
+ * into group/repeater). Returns a normalized copy; other field types pass through. */
676
+ export declare function normalizeFields(schema: FieldDefinition[] | undefined | null, values: FieldValues, richTextSchema?: RichTextSchema): FieldValues;
553
677
  export interface RenderedBlock {
678
+ /** The block's optimistic-concurrency token — pass back as `expectedVersion`. */
679
+ version: number;
554
680
  /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
555
681
  id: string;
556
682
  /** The underlying block instance id (cms_blocks) — used to edit the block's content. */
@@ -592,13 +718,18 @@ export interface AssembledPage {
592
718
  metaTitle: string | null;
593
719
  metaDescription: string | null;
594
720
  seo: PageSeo;
721
+ /** Optimistic-concurrency token — pass back as `expectedVersion` on a write. */
722
+ version: number;
595
723
  };
596
724
  regions: Record<string, RenderedBlock[]>;
725
+ /** True when this is a live draft assembled behind a preview grant, rather than the
726
+ * published snapshot — so a frontend can render a "you are viewing a draft" banner. */
727
+ isPreview?: boolean;
597
728
  }
598
729
  /** One authored field value inside a block / collection / page `fields` bag. Stored
599
730
  * as JSON; a `"media"` field is resolved from its stored id to a `ResolvedMedia` at
600
731
  * assemble time, and `group`/`repeater` fields nest further bags. */
601
- export type FieldValue = JsonValue | ResolvedMedia | FieldValues | FieldValue[];
732
+ export type FieldValue = JsonValue | ResolvedMedia | RichTextDoc | FieldValues | FieldValue[];
602
733
  /** A block / collection / page `fields` bag — field name -> authored value. */
603
734
  export interface FieldValues {
604
735
  [field: string]: FieldValue;
@@ -627,17 +758,61 @@ export declare function imageUrl(key: string, opts?: {
627
758
  quality?: number;
628
759
  format?: "auto" | "webp" | "avif";
629
760
  }): string;
761
+ /** What a preview link authorizes: one page, in one tenant, until `exp`. */
762
+ export interface PreviewToken {
763
+ /** tenant */ t: string;
764
+ /** page id — the grant is scoped to this ONE page, never "all drafts" */ p: string;
765
+ /** expiry (epoch seconds) */ exp: number;
766
+ }
767
+ /** Secret preference order. `PREVIEW_SECRET` lets an operator rotate preview links without
768
+ * invalidating every signed file url, but falling back keeps the common case zero-config. */
769
+ export declare const PREVIEW_SECRET_NAMES: readonly ["PREVIEW_SECRET", "FILES_SECRET", "AUTH_SECRET"];
770
+ /** Resolve the preview signing secret, or `undefined` when nothing usable is configured. */
771
+ export declare function previewSecret(env: EnvBag): string | undefined;
772
+ /** The viewer roles for a given handler config — `editorRoles ∪ reviewerRoles`, computed
773
+ * exactly as `createCmsHandlers` computes them.
774
+ *
775
+ * Exported so `cmsRoutes()` cannot drift from `createCmsHandlers()`: pass the SAME options
776
+ * object to both. Configuring the two independently was how the preview route ended up
777
+ * presenting an identity neither the handler gate nor the ACL accepted — and a partial
778
+ * customization still worked, so the failure appeared only for the app that had most
779
+ * carefully renamed its roles. */
780
+ export declare function viewerRolesOf(opts?: CmsHandlerOpts): string[];
781
+ /** The viewer roles for the DEFAULT handler config. */
782
+ export declare const DEFAULT_VIEWER_ROLES: string[];
783
+ /** Where a preview link is redeemed. Spread `cmsRoutes()` into `app.routes` to serve it. */
784
+ export declare const PREVIEW_PATH = "/cms/preview";
785
+ /** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
786
+ * a link pasted into a public channel stops working the same afternoon. */
787
+ export declare const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
630
788
  export interface CmsHandlerOpts {
631
789
  /** Roles permitted to call the editor mutations (also enforced by the ACL). Default
632
790
  * `["editor", "admin"]`. */
633
791
  editorRoles?: readonly string[];
634
792
  /** Max accepted media upload size in bytes (enforced at the Worker). Default 25 MB. */
635
793
  mediaMaxSize?: number;
636
- /** Default locale used when `getPage`/`createPage` omit one. Default `"en"`. */
637
- defaultLocale?: string;
794
+ /** The locales this deployment publishes in, most-preferred first. Default `["en"]`.
795
+ *
796
+ * DECLARED, not inferred. The editor renders its i18n surface — the Translations panel,
797
+ * the Locale field, the per-row locale column — only when there is more than one, and
798
+ * `listCmsCapabilities` is how it finds out. Inferring "is this site multilingual?" from
799
+ * the locales PRESENT IN DATA cannot work: the only way to create a second locale is
800
+ * `createTranslation`, which the editor exposes from inside the very panel that would
801
+ * stay hidden, so a monolingual site could never become multilingual.
802
+ *
803
+ * The first entry is the default stamped on a page created without one, which is why
804
+ * `defaultLocale` is derived from this rather than configured beside it — two options
805
+ * that can disagree about the same fact is how a Czech-only site ends up stamping "en". */
806
+ locales?: readonly string[];
638
807
  /** Roles permitted to approve/reject a page in review and publish (the editorial gate).
639
808
  * Default `["reviewer", "admin"]`. */
640
809
  reviewerRoles?: readonly string[];
810
+ /** Preview-link lifetime in seconds. Default 3600 (1 hour). */
811
+ previewTtlSeconds?: number;
812
+ /** The node/mark vocabulary accepted on write. Defaults to `DEFAULT_RICH_TEXT_SCHEMA`
813
+ * (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
814
+ * a node type absent from the schema is DROPPED on write, not rejected. */
815
+ richTextSchema?: RichTextSchema;
641
816
  }
642
817
  /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
643
818
  * gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
@@ -708,13 +883,30 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
708
883
  id: string;
709
884
  alt: string | null;
710
885
  }, Record<string, unknown>>;
711
- /** Delete a media row AND its R2 blob. (Automatic orphan sweeping media no longer
712
- * referenced by any block is future work; refs live inside opaque block JSON.) */
886
+ /** Trash a media row. The R2 OBJECT IS KEPTdeleting the bytes here would make
887
+ * `restoreMedia` a lie, and a block still referencing the id would render a dead url
888
+ * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
889
+ * the id again, since every ACL-scoped read hides it from here on.
890
+ *
891
+ * (Automatic orphan sweeping — media no longer referenced by any block — is still
892
+ * future work; refs live inside opaque block JSON.) */
713
893
  deleteMedia: import("@pramen/server").Handler<{
714
894
  id: string;
715
895
  }, {
716
896
  ok: boolean;
717
897
  }>;
898
+ restoreMedia: import("@pramen/server").Handler<{
899
+ id: string;
900
+ }, {
901
+ ok: true;
902
+ }>;
903
+ /** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
904
+ * irreversible; the blob is gone. */
905
+ purgeMedia: import("@pramen/server").Handler<{
906
+ id: string;
907
+ }, {
908
+ ok: true;
909
+ }>;
718
910
  listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
719
911
  getContentType: import("@pramen/server").Handler<{
720
912
  id: string;
@@ -740,13 +932,14 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
740
932
  * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
741
933
  * page record itself, which was previously only settable at createPage. A slug/locale
742
934
  * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
743
- * + sanitized against the content type's fieldsSchema, exactly like createPage. */
935
+ * + normalized against the content type's fieldsSchema, exactly like createPage. */
744
936
  updatePage: import("@pramen/server").Handler<{
745
937
  pageId: string;
746
938
  title?: string;
747
939
  slug?: string;
748
940
  locale?: string;
749
941
  fields?: FieldValues;
942
+ expectedVersion?: number;
750
943
  }, {
751
944
  ok: boolean;
752
945
  page: Record<string, unknown>;
@@ -778,7 +971,21 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
778
971
  title: string;
779
972
  status: string;
780
973
  }[]>;
781
- /** Distinct locales present across all pages. */
974
+ /** What this deployment supports, for an editor to render against — the pages-side
975
+ * counterpart to `listCollections`' `supports: [...]`.
976
+ *
977
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
978
+ * a client flag can hide a control but cannot make the data right, and the two drift
979
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
980
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
981
+ listCmsCapabilities: import("@pramen/server").Handler<unknown, {
982
+ locales: string[];
983
+ defaultLocale: string;
984
+ multilingual: boolean;
985
+ }>;
986
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
987
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
988
+ * declares; these two differ while a locale is declared but not yet authored. */
782
989
  listLocales: import("@pramen/server").Handler<unknown, string[]>;
783
990
  /** Create a block instance and place it into a page region in one call (the common
784
991
  * editor action). Validates the fields against the block type's schema and the region
@@ -817,6 +1024,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
817
1024
  blockId: string;
818
1025
  fields?: FieldValues;
819
1026
  title?: string;
1027
+ expectedVersion?: number;
820
1028
  }, Record<string, unknown> | undefined>;
821
1029
  /** Reorder a region: `order` is the page_block ids in their new order. It must cover
822
1030
  * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
@@ -900,10 +1108,61 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
900
1108
  ok: boolean;
901
1109
  publishInMs: number;
902
1110
  }>;
1111
+ /** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
1112
+ * anyone holding the resulting link can redeem it, which is the point. */
1113
+ signPagePreview: import("@pramen/server").Handler<{
1114
+ pageId: string;
1115
+ expiresIn?: number;
1116
+ }, {
1117
+ url: string;
1118
+ token: string;
1119
+ expiresAt: number;
1120
+ }>;
1121
+ /** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
1122
+ * `GET /cms/preview` route, which verifies the token and then calls this privileged.
1123
+ * Role-gated so it is not an anonymous back door on the /rpc surface. */
1124
+ getPagePreview: import("@pramen/server").Handler<{
1125
+ pageId: string;
1126
+ }, AssembledPage>;
903
1127
  /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
904
1128
  * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
905
1129
  * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
906
1130
  * to the configured default locale; a slug is unique per locale. */
1131
+ deletePage: import("@pramen/server").Handler<{
1132
+ pageId: string;
1133
+ }, {
1134
+ ok: true;
1135
+ deletedAt: string;
1136
+ }>;
1137
+ /** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
1138
+ * the ACL read scope hides exactly these rows: that is the scope doing its job, not a
1139
+ * hole to patch.
1140
+ *
1141
+ * Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
1142
+ * `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
1143
+ * could ever be called with its id again, while `/media/<key>` kept serving the bytes
1144
+ * (that route streams from R2 with no DB lookup at all). */
1145
+ listTrash: import("@pramen/server").Handler<{
1146
+ limit?: number;
1147
+ }, {
1148
+ pages: Record<string, unknown>[];
1149
+ media: {
1150
+ file: unknown;
1151
+ }[];
1152
+ }>;
1153
+ restorePage: import("@pramen/server").Handler<{
1154
+ pageId: string;
1155
+ }, {
1156
+ ok: true;
1157
+ scheduleCleared: boolean;
1158
+ }>;
1159
+ /** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
1160
+ * this is the only irreversible operation in the CMS. */
1161
+ purgePage: import("@pramen/server").Handler<{
1162
+ pageId: string;
1163
+ }, {
1164
+ ok: true;
1165
+ }>;
907
1166
  getPage: import("@pramen/server").Handler<{
908
1167
  slug: string;
909
1168
  locale?: string;
@@ -978,13 +1237,30 @@ export declare const cmsHandlers: {
978
1237
  id: string;
979
1238
  alt: string | null;
980
1239
  }, Record<string, unknown>>;
981
- /** Delete a media row AND its R2 blob. (Automatic orphan sweeping media no longer
982
- * referenced by any block is future work; refs live inside opaque block JSON.) */
1240
+ /** Trash a media row. The R2 OBJECT IS KEPTdeleting the bytes here would make
1241
+ * `restoreMedia` a lie, and a block still referencing the id would render a dead url
1242
+ * with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
1243
+ * the id again, since every ACL-scoped read hides it from here on.
1244
+ *
1245
+ * (Automatic orphan sweeping — media no longer referenced by any block — is still
1246
+ * future work; refs live inside opaque block JSON.) */
983
1247
  deleteMedia: import("@pramen/server").Handler<{
984
1248
  id: string;
985
1249
  }, {
986
1250
  ok: boolean;
987
1251
  }>;
1252
+ restoreMedia: import("@pramen/server").Handler<{
1253
+ id: string;
1254
+ }, {
1255
+ ok: true;
1256
+ }>;
1257
+ /** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
1258
+ * irreversible; the blob is gone. */
1259
+ purgeMedia: import("@pramen/server").Handler<{
1260
+ id: string;
1261
+ }, {
1262
+ ok: true;
1263
+ }>;
988
1264
  listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
989
1265
  getContentType: import("@pramen/server").Handler<{
990
1266
  id: string;
@@ -1010,13 +1286,14 @@ export declare const cmsHandlers: {
1010
1286
  * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
1011
1287
  * page record itself, which was previously only settable at createPage. A slug/locale
1012
1288
  * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
1013
- * + sanitized against the content type's fieldsSchema, exactly like createPage. */
1289
+ * + normalized against the content type's fieldsSchema, exactly like createPage. */
1014
1290
  updatePage: import("@pramen/server").Handler<{
1015
1291
  pageId: string;
1016
1292
  title?: string;
1017
1293
  slug?: string;
1018
1294
  locale?: string;
1019
1295
  fields?: FieldValues;
1296
+ expectedVersion?: number;
1020
1297
  }, {
1021
1298
  ok: boolean;
1022
1299
  page: Record<string, unknown>;
@@ -1048,7 +1325,21 @@ export declare const cmsHandlers: {
1048
1325
  title: string;
1049
1326
  status: string;
1050
1327
  }[]>;
1051
- /** Distinct locales present across all pages. */
1328
+ /** What this deployment supports, for an editor to render against — the pages-side
1329
+ * counterpart to `listCollections`' `supports: [...]`.
1330
+ *
1331
+ * The editor asks the SERVER what exists rather than being told by its own /config.js:
1332
+ * a client flag can hide a control but cannot make the data right, and the two drift
1333
+ * the moment someone adds a locale. `multilingual` is the derived answer to the only
1334
+ * question the UI actually asks, so each surface doesn't re-derive it from the list. */
1335
+ listCmsCapabilities: import("@pramen/server").Handler<unknown, {
1336
+ locales: string[];
1337
+ defaultLocale: string;
1338
+ multilingual: boolean;
1339
+ }>;
1340
+ /** Distinct locales present across all pages. NOTE: a DATA query — what is in the
1341
+ * store — not configuration. `listCmsCapabilities().locales` is what the deployment
1342
+ * declares; these two differ while a locale is declared but not yet authored. */
1052
1343
  listLocales: import("@pramen/server").Handler<unknown, string[]>;
1053
1344
  /** Create a block instance and place it into a page region in one call (the common
1054
1345
  * editor action). Validates the fields against the block type's schema and the region
@@ -1087,6 +1378,7 @@ export declare const cmsHandlers: {
1087
1378
  blockId: string;
1088
1379
  fields?: FieldValues;
1089
1380
  title?: string;
1381
+ expectedVersion?: number;
1090
1382
  }, Record<string, unknown> | undefined>;
1091
1383
  /** Reorder a region: `order` is the page_block ids in their new order. It must cover
1092
1384
  * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
@@ -1170,10 +1462,61 @@ export declare const cmsHandlers: {
1170
1462
  ok: boolean;
1171
1463
  publishInMs: number;
1172
1464
  }>;
1465
+ /** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
1466
+ * anyone holding the resulting link can redeem it, which is the point. */
1467
+ signPagePreview: import("@pramen/server").Handler<{
1468
+ pageId: string;
1469
+ expiresIn?: number;
1470
+ }, {
1471
+ url: string;
1472
+ token: string;
1473
+ expiresAt: number;
1474
+ }>;
1475
+ /** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
1476
+ * `GET /cms/preview` route, which verifies the token and then calls this privileged.
1477
+ * Role-gated so it is not an anonymous back door on the /rpc surface. */
1478
+ getPagePreview: import("@pramen/server").Handler<{
1479
+ pageId: string;
1480
+ }, AssembledPage>;
1173
1481
  /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
1174
1482
  * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
1175
1483
  * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
1176
1484
  * to the configured default locale; a slug is unique per locale. */
1485
+ deletePage: import("@pramen/server").Handler<{
1486
+ pageId: string;
1487
+ }, {
1488
+ ok: true;
1489
+ deletedAt: string;
1490
+ }>;
1491
+ /** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
1492
+ * the ACL read scope hides exactly these rows: that is the scope doing its job, not a
1493
+ * hole to patch.
1494
+ *
1495
+ * Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
1496
+ * `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
1497
+ * could ever be called with its id again, while `/media/<key>` kept serving the bytes
1498
+ * (that route streams from R2 with no DB lookup at all). */
1499
+ listTrash: import("@pramen/server").Handler<{
1500
+ limit?: number;
1501
+ }, {
1502
+ pages: Record<string, unknown>[];
1503
+ media: {
1504
+ file: unknown;
1505
+ }[];
1506
+ }>;
1507
+ restorePage: import("@pramen/server").Handler<{
1508
+ pageId: string;
1509
+ }, {
1510
+ ok: true;
1511
+ scheduleCleared: boolean;
1512
+ }>;
1513
+ /** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
1514
+ * this is the only irreversible operation in the CMS. */
1515
+ purgePage: import("@pramen/server").Handler<{
1516
+ pageId: string;
1517
+ }, {
1518
+ ok: true;
1519
+ }>;
1177
1520
  getPage: import("@pramen/server").Handler<{
1178
1521
  slug: string;
1179
1522
  locale?: string;
@@ -1228,6 +1571,11 @@ export interface CollectionDef {
1228
1571
  column: string;
1229
1572
  dir?: "asc" | "desc";
1230
1573
  };
1574
+ /** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
1575
+ * backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
1576
+ * Validated against your schema at `createCollectionHandlers` time (which is why that call
1577
+ * needs `{ schema }` once this is set). Absent = a plain CRUD collection, as before. */
1578
+ readonly supports?: readonly CollectionFeature[];
1231
1579
  }
1232
1580
  /** Declare a collection. Spread the results into `createCollectionHandlers` +
1233
1581
  * `collectionPolicies`:
@@ -1260,18 +1608,87 @@ export interface CollectionMeta {
1260
1608
  column: string;
1261
1609
  dir?: "asc" | "desc";
1262
1610
  };
1611
+ /** Workflow features enabled — the editor uses this to decide which affordances to show
1612
+ * (a Publish button, a schedule picker, a revisions tab). Empty = plain CRUD. */
1613
+ supports: readonly CollectionFeature[];
1614
+ }
1615
+ /** A workflow feature a collection can opt into.
1616
+ *
1617
+ * - `drafts` — a managed `status` column (`draft` | `published`) plus `collectionPublish` /
1618
+ * `collectionUnpublish`. Pair with `collectionPublicPolicies` so anonymous reads see
1619
+ * published rows only.
1620
+ *
1621
+ * This gates VISIBILITY, not content. A collection is column-mapped — the public reads the
1622
+ * entity's own columns — so there is nowhere to stage an unpublished VERSION of a live
1623
+ * row: an edit (or a revision restore) on a published row is live immediately. That is the
1624
+ * one place collections do not reach page parity, where `getPage` serves a baked revision
1625
+ * snapshot. Unpublish first if an edit needs review.
1626
+ * - `scheduling` — managed `publishedAt` / `scheduledAt` / `unpublishAt`, `collectionSchedule`,
1627
+ * and the deferred tasks from `createCollectionTasks`. Needs `drafts`.
1628
+ * - `revisions` — a snapshot of the row's prior state on every write, in
1629
+ * `cms_collection_revisions`, with `collectionListRevisions` / `collectionRestoreRevision`.
1630
+ * - `preview` — signed, single-row preview links (`signCollectionPreview`), redeemed at
1631
+ * `COLLECTION_PREVIEW_PATH` by the route `cmsRoutes()` serves. Needs `drafts`. It shows
1632
+ * the row's CURRENT state to whoever holds the link, which for a DRAFT is the unpublished
1633
+ * content and for a published row is what the public already sees (see `drafts` above:
1634
+ * there is no separate staged version to show). */
1635
+ export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
1636
+ export declare const COLLECTION_FEATURES: readonly CollectionFeature[];
1637
+ /** The columns each feature needs on the collection's entity. The app declares them (they
1638
+ * are its own entity); the CMS writes them and `fields` may not. */
1639
+ export declare const COLLECTION_FEATURE_COLUMNS: Readonly<Record<CollectionFeature, readonly string[]>>;
1640
+ /** The shared revision table for collections (see `cmsSchema`). */
1641
+ export declare const COLLECTION_REVISIONS_TABLE = "cms_collection_revisions";
1642
+ /** The two `status` values a `drafts` collection uses. */
1643
+ export declare const COLLECTION_DRAFT = "draft";
1644
+ export declare const COLLECTION_PUBLISHED = "published";
1645
+ /** Check a collection registry at BOOT: slugs and entities are unique, features are known
1646
+ * and have their prerequisites, every declared field maps to a column that can hold it, and
1647
+ * every managed column exists, has the shape the CMS writes, and is not also an editable
1648
+ * field.
1649
+ *
1650
+ * Called by `createCollectionHandlers`. The point is that a misconfiguration surfaces when
1651
+ * the Worker starts, naming the collection and the column — not as a 500 the first time an
1652
+ * editor presses Publish, months later, on the one collection nobody exercised.
1653
+ *
1654
+ * `schema` is REQUIRED. Every check here reads the target entity, so a registry validated
1655
+ * without one is not validated at all — and the failures it catches (a field name typo, a
1656
+ * richtext field over a TEXT column, a non-PK idField) are exactly as fatal on a collection
1657
+ * that declares no `supports` as on one that declares all four. */
1658
+ export declare function validateCollections(collections: readonly CollectionDef[], schema?: SchemaDef): void;
1659
+ /** A signed grant to preview ONE collection row. Mirrors {@link PreviewToken}. */
1660
+ export interface CollectionPreviewToken {
1661
+ /** tenant */ t: string;
1662
+ /** collection slug */ c: string;
1663
+ /** row id — the grant is scoped to this ONE row, never "all drafts" */ r: string;
1664
+ /** expiry (epoch seconds) */ exp: number;
1665
+ }
1666
+ /** Where a collection preview link is redeemed. Served by `cmsRoutes()`. */
1667
+ export declare const COLLECTION_PREVIEW_PATH = "/cms/preview/collection";
1668
+ /** Outbox task kinds behind `collectionSchedule`. Register the handlers with
1669
+ * `app.tasks = { ...cmsTasks, ...createCollectionTasks(collections) }`. */
1670
+ export declare const TASK_COLLECTION_PUBLISH = "cms:collection:publish";
1671
+ export declare const TASK_COLLECTION_UNPUBLISH = "cms:collection:unpublish";
1672
+ /** Options for `createCollectionHandlers`. */
1673
+ export interface CollectionHandlerOpts extends CmsHandlerOpts {
1674
+ /** Your app's schema (the object you pass to `defineSchema`). REQUIRED: the whole registry
1675
+ * is checked against it at boot by `validateCollections` — managed columns, declared
1676
+ * field ↔ column types, the idField/PK pairing, `orderBy`, the partition — so a
1677
+ * misconfiguration is a startup error naming the collection and the column rather than a
1678
+ * 500 (or a silent wrong answer) on the first call. */
1679
+ schema?: SchemaDef;
1263
1680
  }
1264
1681
  /** Build generic CRUD handlers over the registered collections. Spread into your app's
1265
1682
  * handlers alongside `cmsHandlers`:
1266
1683
  *
1267
- * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
1684
+ * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures], { schema }) };
1268
1685
  *
1269
1686
  * Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
1270
1687
  * `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
1271
1688
  * (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
1272
1689
  * `collectionPolicies` scopes them too). The `collection` param is resolved through the
1273
1690
  * registry — an unknown slug is a 400, never a raw table reference. */
1274
- export declare function createCollectionHandlers(collections: readonly CollectionDef[], opts?: CmsHandlerOpts): {
1691
+ export declare function createCollectionHandlers(collections: readonly CollectionDef[], opts?: CollectionHandlerOpts): {
1275
1692
  /** The registered collections (defaults filled) — editor discovery. Editor-gated so
1276
1693
  * the collection schemas aren't exposed to anonymous callers. */
1277
1694
  listCollections: import("@pramen/server").Handler<unknown, CollectionMeta[]>;
@@ -1299,6 +1716,81 @@ export declare function createCollectionHandlers(collections: readonly Collectio
1299
1716
  }, {
1300
1717
  ok: true;
1301
1718
  }>;
1719
+ /** Move a row live. With `scheduling` this also stamps `publishedAt` (the column the
1720
+ * public read scope compares against `$now()`) and clears `scheduledAt` — which makes
1721
+ * any pending scheduled-publish task a no-op, since its intent token no longer matches.
1722
+ * A pending scheduled UNPUBLISH is deliberately left standing: publishing early does not
1723
+ * cancel a planned takedown. */
1724
+ collectionPublish: import("@pramen/server").Handler<{
1725
+ collection: string;
1726
+ id: string;
1727
+ }, Record<string, unknown>>;
1728
+ /** Take a row back to draft, clearing every schedule. Both tokens are cleared, so a
1729
+ * pending publish AND a pending unpublish both become no-ops — unpublishing is an
1730
+ * explicit "this is not live and nothing is queued to change that". */
1731
+ collectionUnpublish: import("@pramen/server").Handler<{
1732
+ collection: string;
1733
+ id: string;
1734
+ }, Record<string, unknown>>;
1735
+ /** Schedule a future publish, and optionally a later unpublish. Mirrors `schedulePage`,
1736
+ * including the INTENT TOKEN: the row stores the scheduled instants
1737
+ * (`scheduledAt`/`unpublishAt`, ISO), the enqueued task carries a copy, and the task
1738
+ * runs only if the two still match. A reschedule overwrites the token, a manual
1739
+ * publish/unpublish clears it, and a duplicate delivery finds it already cleared — so a
1740
+ * superseded or cancelled schedule is a silent no-op rather than a surprise publish.
1741
+ *
1742
+ * The tasks are enqueued in THIS mutation's transaction (the outbox is transactional),
1743
+ * so a rolled-back schedule never leaves a task behind. They only run if you wired
1744
+ * `createCollectionTasks` into `app.tasks`. */
1745
+ collectionSchedule: import("@pramen/server").Handler<{
1746
+ collection: string;
1747
+ id: string;
1748
+ publishAt: number;
1749
+ unpublishAt?: number | null;
1750
+ }, {
1751
+ unpublishAt?: string | null | undefined;
1752
+ ok: true;
1753
+ scheduledAt: string;
1754
+ }>;
1755
+ /** A row's revision history, newest first. */
1756
+ collectionListRevisions: import("@pramen/server").Handler<{
1757
+ collection: string;
1758
+ id: string;
1759
+ limit?: number;
1760
+ }, {
1761
+ snapshot: Record<string, unknown>;
1762
+ }[]>;
1763
+ /** Restore a row to one of its revisions. The CURRENT state is snapshotted first, so a
1764
+ * restore is itself undoable. */
1765
+ collectionRestoreRevision: import("@pramen/server").Handler<{
1766
+ collection: string;
1767
+ id: string;
1768
+ revisionId: string;
1769
+ }, Record<string, unknown>>;
1770
+ /** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
1771
+ * Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
1772
+ * same rule that the row is read through the ACL FIRST: minting a link is granting
1773
+ * access to the row, so a caller who cannot read it must not be able to mint one. */
1774
+ signCollectionPreview: import("@pramen/server").Handler<{
1775
+ collection: string;
1776
+ id: string;
1777
+ expiresIn?: number;
1778
+ }, {
1779
+ url: string;
1780
+ token: string;
1781
+ expiresAt: number;
1782
+ }>;
1783
+ /** Read one row's live (possibly unpublished) state. Not the redemption endpoint — that
1784
+ * is the public `GET /cms/preview/collection` route, which verifies the token and then
1785
+ * calls this privileged. Role-gated so it is not an anonymous back door on /rpc. */
1786
+ getCollectionPreview: import("@pramen/server").Handler<{
1787
+ collection: string;
1788
+ id: string;
1789
+ }, {
1790
+ collection: string;
1791
+ id: string;
1792
+ values: Record<string, unknown>;
1793
+ }>;
1302
1794
  };
1303
1795
  /** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
1304
1796
  * into your editor role next to `cmsPolicies().editor`:
@@ -1309,6 +1801,40 @@ export declare function createCollectionHandlers(collections: readonly Collectio
1309
1801
  * them. Your app may already declare its own policies over the entity (e.g. a public read
1310
1802
  * scope) — these only ADD the editor grant the CMS UI needs. */
1311
1803
  export declare function collectionPolicies(collections: readonly CollectionDef[], opts?: CmsPolicyOpts): Policy[];
1804
+ /** ACL fragments granting ANONYMOUS read of the PUBLISHED rows of every collection that
1805
+ * supports `drafts`. Spread into your public role next to `cmsPolicies().public`:
1806
+ *
1807
+ * role("anonymous", [...cmsPolicies().public, ...collectionPublicPolicies(collections)])
1808
+ *
1809
+ * This is the access boundary, not a UI filter — it is AND-merged into every `ctx.db` read
1810
+ * of the entity, so an unpublished row is invisible to the public API, to relation
1811
+ * traversals and to eager-loads alike, without a single query remembering to filter.
1812
+ *
1813
+ * With `scheduling`, the scope also requires `publishedAt <= $now()`. `status` alone would
1814
+ * not be enough the moment anything writes a future `publishedAt`, and `{ publishedAt:
1815
+ * { isNull: false } }` — the obvious-looking alternative — matches a FUTURE timestamp too,
1816
+ * so a row scheduled for next week would be anonymously readable the moment it was saved.
1817
+ * The comparison is lexicographic over TEXT, which is why every managed timestamp is minted
1818
+ * as ISO-8601 UTC (`isoStamp`), the same shape `$now()` produces.
1819
+ *
1820
+ * Collections WITHOUT `drafts` get nothing here: they have no publish state, so their
1821
+ * public exposure is entirely your app's own policy to write. */
1822
+ export declare function collectionPublicPolicies(collections: readonly CollectionDef[], opts?: CmsPolicyOpts): Policy[];
1823
+ /** Task handlers backing `collectionSchedule`. Register alongside `cmsTasks`:
1824
+ *
1825
+ * const app = { tasks: { ...cmsTasks, ...createCollectionTasks(collections) } };
1826
+ *
1827
+ * WITHOUT THIS WIRING A SCHEDULE NEVER FIRES: `collectionSchedule` still stores the
1828
+ * instants and enqueues the tasks, but the drain finds no handler for their kind, so the
1829
+ * row silently stays a draft. (`cmsTasks` has the same requirement for page scheduling.)
1830
+ *
1831
+ * They run with a privileged, system-scoped ctx off the write path, and each validates its
1832
+ * INTENT TOKEN against the row's current `scheduledAt`/`unpublishAt` before acting — see
1833
+ * `collectionSchedule`. */
1834
+ export declare function createCollectionTasks(collections: readonly CollectionDef[]): {
1835
+ "cms:collection:publish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
1836
+ "cms:collection:unpublish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
1837
+ };
1312
1838
  /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
1313
1839
  * They run with a privileged, system-scoped ctx off the write path (the outbox drain).
1314
1840
  *
@@ -1355,7 +1881,7 @@ interface CmsRoute {
1355
1881
  path: string;
1356
1882
  handler: (request: Request, env: EnvBag, ctx: RouteCtx) => Promise<Response>;
1357
1883
  }
1358
- /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
1884
+ /** Turnkey public routes for `GET /sitemap.xml`, `GET /cms/preview` and `GET /robots.txt`. Spread into
1359
1885
  * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
1360
1886
  * `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
1361
1887
  export declare function cmsRoutes(opts?: {
@@ -1363,5 +1889,18 @@ export declare function cmsRoutes(opts?: {
1363
1889
  tenant?: string;
1364
1890
  pageUrl?: SitemapOpts["pageUrl"];
1365
1891
  disallow?: string[];
1892
+ /** The SAME options you passed to `createCmsHandlers`. The route derives its identity
1893
+ * from them with `viewerRolesOf`, so the two cannot drift. (`viewerRoles` overrides it
1894
+ * outright if you need to.) */
1895
+ handlers?: CmsHandlerOpts;
1896
+ /** The SAME options you passed to `createCollectionHandlers`, if they differ from
1897
+ * `handlers`. The COLLECTION preview route has its own gate — `getCollectionPreview` is
1898
+ * built by `createCollectionHandlers`, so an app that passes different `editorRoles` /
1899
+ * `reviewerRoles` to the two factories would have the route present the page half's
1900
+ * roles to a handler gated on the collection half's, and every collection preview link
1901
+ * would 404 uniformly (the response is deliberately indistinguishable from "not
1902
+ * found"). Defaults to `handlers`, which is right whenever both got the same options. */
1903
+ collectionHandlers?: CmsHandlerOpts;
1904
+ /** Explicit override for the roles the preview routes present to the DO. */
1905
+ viewerRoles?: readonly string[];
1366
1906
  }): CmsRoute[];
1367
- export {};