@pramen/cms 0.0.48 → 0.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +364 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +138 -0
- package/dist/href.d.ts +14 -0
- package/dist/href.js +22 -0
- package/dist/index.d.ts +542 -33
- package/dist/index.js +1729 -84
- package/dist/react.d.ts +15 -2
- package/dist/react.js +96 -1
- package/package.json +8 -3
- package/src/cli.ts +148 -0
- package/src/href.ts +24 -0
- package/src/index.ts +1990 -110
- package/src/react.ts +132 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
|
|
1
|
+
import type { HandlerContext, Policy, FileRef, BootstrapFn, JsonValue, SchemaDef } from "@pramen/server";
|
|
2
|
+
import type { EnvBag } from "@pramen/server";
|
|
2
3
|
/** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
|
|
3
4
|
* or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
|
|
4
5
|
export interface FieldDefinition {
|
|
@@ -69,13 +70,37 @@ export interface RegionDefinition {
|
|
|
69
70
|
export interface DefaultBlockDefinition {
|
|
70
71
|
region: string;
|
|
71
72
|
blockTypeSlug: string;
|
|
72
|
-
fields?:
|
|
73
|
+
fields?: FieldValues;
|
|
73
74
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
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 {
|
|
76
91
|
type: string;
|
|
77
|
-
content?:
|
|
78
|
-
|
|
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;
|
|
79
104
|
/** Map one FieldDefinition (as a const literal) to the TS type of its RENDERED value.
|
|
80
105
|
* Media resolves to `ResolvedMedia` (the assemble-time shape a component receives). */
|
|
81
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;
|
|
@@ -266,6 +291,11 @@ export declare const cmsSchema: {
|
|
|
266
291
|
} & {
|
|
267
292
|
readonly default: false;
|
|
268
293
|
};
|
|
294
|
+
version: {
|
|
295
|
+
readonly type: "integer";
|
|
296
|
+
} & {
|
|
297
|
+
readonly default: 1;
|
|
298
|
+
};
|
|
269
299
|
createdAt: {
|
|
270
300
|
readonly type: "text";
|
|
271
301
|
} & {
|
|
@@ -340,6 +370,11 @@ export declare const cmsSchema: {
|
|
|
340
370
|
currentRevisionId: {
|
|
341
371
|
readonly type: "uuid";
|
|
342
372
|
};
|
|
373
|
+
deletedAt: {
|
|
374
|
+
readonly type: "text";
|
|
375
|
+
} & {
|
|
376
|
+
readonly index: true;
|
|
377
|
+
};
|
|
343
378
|
metaTitle: {
|
|
344
379
|
readonly type: "text";
|
|
345
380
|
};
|
|
@@ -364,6 +399,11 @@ export declare const cmsSchema: {
|
|
|
364
399
|
structuredData: {
|
|
365
400
|
readonly type: "json";
|
|
366
401
|
};
|
|
402
|
+
version: {
|
|
403
|
+
readonly type: "integer";
|
|
404
|
+
} & {
|
|
405
|
+
readonly default: 1;
|
|
406
|
+
};
|
|
367
407
|
createdAt: {
|
|
368
408
|
readonly type: "text";
|
|
369
409
|
} & {
|
|
@@ -517,6 +557,47 @@ export declare const cmsSchema: {
|
|
|
517
557
|
readonly defaultExpr: string;
|
|
518
558
|
};
|
|
519
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>>;
|
|
520
601
|
cms_media: import("@pramen/server").EntityDef<{
|
|
521
602
|
id: {
|
|
522
603
|
readonly type: "uuid";
|
|
@@ -532,6 +613,11 @@ export declare const cmsSchema: {
|
|
|
532
613
|
alt: {
|
|
533
614
|
readonly type: "text";
|
|
534
615
|
};
|
|
616
|
+
deletedAt: {
|
|
617
|
+
readonly type: "text";
|
|
618
|
+
} & {
|
|
619
|
+
readonly index: true;
|
|
620
|
+
};
|
|
535
621
|
createdAt: {
|
|
536
622
|
readonly type: "text";
|
|
537
623
|
} & {
|
|
@@ -544,19 +630,60 @@ export interface ValidateOpts {
|
|
|
544
630
|
* writes (addBlock/updateBlock/createPage) pass `false` — a DRAFT block may be incomplete;
|
|
545
631
|
* required is only mandatory when publishing. Type checks always run. */
|
|
546
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;
|
|
547
643
|
}
|
|
548
644
|
export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
|
|
549
|
-
/**
|
|
550
|
-
*
|
|
551
|
-
export
|
|
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;
|
|
552
677
|
export interface RenderedBlock {
|
|
678
|
+
/** The block's optimistic-concurrency token — pass back as `expectedVersion`. */
|
|
679
|
+
version: number;
|
|
553
680
|
/** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
|
|
554
681
|
id: string;
|
|
555
682
|
/** The underlying block instance id (cms_blocks) — used to edit the block's content. */
|
|
556
683
|
block_id: string;
|
|
557
684
|
block_type: string;
|
|
558
685
|
title: string | null;
|
|
559
|
-
fields:
|
|
686
|
+
fields: FieldValues;
|
|
560
687
|
is_shared: boolean;
|
|
561
688
|
}
|
|
562
689
|
export interface PageTranslation {
|
|
@@ -586,13 +713,26 @@ export interface AssembledPage {
|
|
|
586
713
|
translationGroupId: string | null;
|
|
587
714
|
/** Published sibling locales of this page (for hreflang alternates). */
|
|
588
715
|
translations: PageTranslation[];
|
|
589
|
-
fields:
|
|
716
|
+
fields: FieldValues | null;
|
|
590
717
|
/** Back-compat: mirrors seo.metaTitle/metaDescription. */
|
|
591
718
|
metaTitle: string | null;
|
|
592
719
|
metaDescription: string | null;
|
|
593
720
|
seo: PageSeo;
|
|
721
|
+
/** Optimistic-concurrency token — pass back as `expectedVersion` on a write. */
|
|
722
|
+
version: number;
|
|
594
723
|
};
|
|
595
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;
|
|
728
|
+
}
|
|
729
|
+
/** One authored field value inside a block / collection / page `fields` bag. Stored
|
|
730
|
+
* as JSON; a `"media"` field is resolved from its stored id to a `ResolvedMedia` at
|
|
731
|
+
* assemble time, and `group`/`repeater` fields nest further bags. */
|
|
732
|
+
export type FieldValue = JsonValue | ResolvedMedia | RichTextDoc | FieldValues | FieldValue[];
|
|
733
|
+
/** A block / collection / page `fields` bag — field name -> authored value. */
|
|
734
|
+
export interface FieldValues {
|
|
735
|
+
[field: string]: FieldValue;
|
|
596
736
|
}
|
|
597
737
|
/** A `"media"` block field, resolved from a stored media id to a servable shape at
|
|
598
738
|
* assemble time. `url` is the raw (full-size) serving path; pass `key` to `imageUrl()`
|
|
@@ -618,6 +758,33 @@ export declare function imageUrl(key: string, opts?: {
|
|
|
618
758
|
quality?: number;
|
|
619
759
|
format?: "auto" | "webp" | "avif";
|
|
620
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;
|
|
621
788
|
export interface CmsHandlerOpts {
|
|
622
789
|
/** Roles permitted to call the editor mutations (also enforced by the ACL). Default
|
|
623
790
|
* `["editor", "admin"]`. */
|
|
@@ -629,6 +796,12 @@ export interface CmsHandlerOpts {
|
|
|
629
796
|
/** Roles permitted to approve/reject a page in review and publish (the editorial gate).
|
|
630
797
|
* Default `["reviewer", "admin"]`. */
|
|
631
798
|
reviewerRoles?: readonly string[];
|
|
799
|
+
/** Preview-link lifetime in seconds. Default 3600 (1 hour). */
|
|
800
|
+
previewTtlSeconds?: number;
|
|
801
|
+
/** The node/mark vocabulary accepted on write. Defaults to `DEFAULT_RICH_TEXT_SCHEMA`
|
|
802
|
+
* (what the shipped editor produces). Widen it if your editor adds TipTap extensions —
|
|
803
|
+
* a node type absent from the schema is DROPPED on write, not rejected. */
|
|
804
|
+
richTextSchema?: RichTextSchema;
|
|
632
805
|
}
|
|
633
806
|
/** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
|
|
634
807
|
* gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
|
|
@@ -699,13 +872,30 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
699
872
|
id: string;
|
|
700
873
|
alt: string | null;
|
|
701
874
|
}, Record<string, unknown>>;
|
|
702
|
-
/**
|
|
703
|
-
*
|
|
875
|
+
/** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
|
|
876
|
+
* `restoreMedia` a lie, and a block still referencing the id would render a dead url
|
|
877
|
+
* with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
|
|
878
|
+
* the id again, since every ACL-scoped read hides it from here on.
|
|
879
|
+
*
|
|
880
|
+
* (Automatic orphan sweeping — media no longer referenced by any block — is still
|
|
881
|
+
* future work; refs live inside opaque block JSON.) */
|
|
704
882
|
deleteMedia: import("@pramen/server").Handler<{
|
|
705
883
|
id: string;
|
|
706
884
|
}, {
|
|
707
885
|
ok: boolean;
|
|
708
886
|
}>;
|
|
887
|
+
restoreMedia: import("@pramen/server").Handler<{
|
|
888
|
+
id: string;
|
|
889
|
+
}, {
|
|
890
|
+
ok: true;
|
|
891
|
+
}>;
|
|
892
|
+
/** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
|
|
893
|
+
* irreversible; the blob is gone. */
|
|
894
|
+
purgeMedia: import("@pramen/server").Handler<{
|
|
895
|
+
id: string;
|
|
896
|
+
}, {
|
|
897
|
+
ok: true;
|
|
898
|
+
}>;
|
|
709
899
|
listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
|
|
710
900
|
getContentType: import("@pramen/server").Handler<{
|
|
711
901
|
id: string;
|
|
@@ -731,13 +921,14 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
731
921
|
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
732
922
|
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
733
923
|
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
734
|
-
* +
|
|
924
|
+
* + normalized against the content type's fieldsSchema, exactly like createPage. */
|
|
735
925
|
updatePage: import("@pramen/server").Handler<{
|
|
736
926
|
pageId: string;
|
|
737
927
|
title?: string;
|
|
738
928
|
slug?: string;
|
|
739
929
|
locale?: string;
|
|
740
|
-
fields?:
|
|
930
|
+
fields?: FieldValues;
|
|
931
|
+
expectedVersion?: number;
|
|
741
932
|
}, {
|
|
742
933
|
ok: boolean;
|
|
743
934
|
page: Record<string, unknown>;
|
|
@@ -748,7 +939,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
748
939
|
title: string;
|
|
749
940
|
slug: string;
|
|
750
941
|
locale?: string;
|
|
751
|
-
fields?:
|
|
942
|
+
fields?: FieldValues;
|
|
752
943
|
}, Record<string, unknown>>;
|
|
753
944
|
/** Create a translation of an existing page: a new page in `locale` sharing the
|
|
754
945
|
* source's translationGroupId (and content type). Content starts empty — the editor
|
|
@@ -778,7 +969,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
778
969
|
pageId: string;
|
|
779
970
|
blockTypeSlug: string;
|
|
780
971
|
region: string;
|
|
781
|
-
fields?:
|
|
972
|
+
fields?: FieldValues;
|
|
782
973
|
title?: string;
|
|
783
974
|
position?: number;
|
|
784
975
|
isReusable?: boolean;
|
|
@@ -797,7 +988,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
797
988
|
blockId: string;
|
|
798
989
|
region: string;
|
|
799
990
|
position?: number;
|
|
800
|
-
overrides?:
|
|
991
|
+
overrides?: FieldValues;
|
|
801
992
|
}, Record<string, unknown>>;
|
|
802
993
|
/** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
|
|
803
994
|
getBlock: import("@pramen/server").Handler<{
|
|
@@ -806,8 +997,9 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
806
997
|
/** Update a block's content (re-validated against its type's field schema). */
|
|
807
998
|
updateBlock: import("@pramen/server").Handler<{
|
|
808
999
|
blockId: string;
|
|
809
|
-
fields?:
|
|
1000
|
+
fields?: FieldValues;
|
|
810
1001
|
title?: string;
|
|
1002
|
+
expectedVersion?: number;
|
|
811
1003
|
}, Record<string, unknown> | undefined>;
|
|
812
1004
|
/** Reorder a region: `order` is the page_block ids in their new order. It must cover
|
|
813
1005
|
* EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
|
|
@@ -891,10 +1083,61 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
|
|
|
891
1083
|
ok: boolean;
|
|
892
1084
|
publishInMs: number;
|
|
893
1085
|
}>;
|
|
1086
|
+
/** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
|
|
1087
|
+
* anyone holding the resulting link can redeem it, which is the point. */
|
|
1088
|
+
signPagePreview: import("@pramen/server").Handler<{
|
|
1089
|
+
pageId: string;
|
|
1090
|
+
expiresIn?: number;
|
|
1091
|
+
}, {
|
|
1092
|
+
url: string;
|
|
1093
|
+
token: string;
|
|
1094
|
+
expiresAt: number;
|
|
1095
|
+
}>;
|
|
1096
|
+
/** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
|
|
1097
|
+
* `GET /cms/preview` route, which verifies the token and then calls this privileged.
|
|
1098
|
+
* Role-gated so it is not an anonymous back door on the /rpc surface. */
|
|
1099
|
+
getPagePreview: import("@pramen/server").Handler<{
|
|
1100
|
+
pageId: string;
|
|
1101
|
+
}, AssembledPage>;
|
|
894
1102
|
/** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
|
|
895
1103
|
* snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
|
|
896
1104
|
* `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
|
|
897
1105
|
* to the configured default locale; a slug is unique per locale. */
|
|
1106
|
+
deletePage: import("@pramen/server").Handler<{
|
|
1107
|
+
pageId: string;
|
|
1108
|
+
}, {
|
|
1109
|
+
ok: true;
|
|
1110
|
+
deletedAt: string;
|
|
1111
|
+
}>;
|
|
1112
|
+
/** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
|
|
1113
|
+
* the ACL read scope hides exactly these rows: that is the scope doing its job, not a
|
|
1114
|
+
* hole to patch.
|
|
1115
|
+
*
|
|
1116
|
+
* Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
|
|
1117
|
+
* `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
|
|
1118
|
+
* could ever be called with its id again, while `/media/<key>` kept serving the bytes
|
|
1119
|
+
* (that route streams from R2 with no DB lookup at all). */
|
|
1120
|
+
listTrash: import("@pramen/server").Handler<{
|
|
1121
|
+
limit?: number;
|
|
1122
|
+
}, {
|
|
1123
|
+
pages: Record<string, unknown>[];
|
|
1124
|
+
media: {
|
|
1125
|
+
file: unknown;
|
|
1126
|
+
}[];
|
|
1127
|
+
}>;
|
|
1128
|
+
restorePage: import("@pramen/server").Handler<{
|
|
1129
|
+
pageId: string;
|
|
1130
|
+
}, {
|
|
1131
|
+
ok: true;
|
|
1132
|
+
scheduleCleared: boolean;
|
|
1133
|
+
}>;
|
|
1134
|
+
/** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
|
|
1135
|
+
* this is the only irreversible operation in the CMS. */
|
|
1136
|
+
purgePage: import("@pramen/server").Handler<{
|
|
1137
|
+
pageId: string;
|
|
1138
|
+
}, {
|
|
1139
|
+
ok: true;
|
|
1140
|
+
}>;
|
|
898
1141
|
getPage: import("@pramen/server").Handler<{
|
|
899
1142
|
slug: string;
|
|
900
1143
|
locale?: string;
|
|
@@ -969,13 +1212,30 @@ export declare const cmsHandlers: {
|
|
|
969
1212
|
id: string;
|
|
970
1213
|
alt: string | null;
|
|
971
1214
|
}, Record<string, unknown>>;
|
|
972
|
-
/**
|
|
973
|
-
*
|
|
1215
|
+
/** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
|
|
1216
|
+
* `restoreMedia` a lie, and a block still referencing the id would render a dead url
|
|
1217
|
+
* with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
|
|
1218
|
+
* the id again, since every ACL-scoped read hides it from here on.
|
|
1219
|
+
*
|
|
1220
|
+
* (Automatic orphan sweeping — media no longer referenced by any block — is still
|
|
1221
|
+
* future work; refs live inside opaque block JSON.) */
|
|
974
1222
|
deleteMedia: import("@pramen/server").Handler<{
|
|
975
1223
|
id: string;
|
|
976
1224
|
}, {
|
|
977
1225
|
ok: boolean;
|
|
978
1226
|
}>;
|
|
1227
|
+
restoreMedia: import("@pramen/server").Handler<{
|
|
1228
|
+
id: string;
|
|
1229
|
+
}, {
|
|
1230
|
+
ok: true;
|
|
1231
|
+
}>;
|
|
1232
|
+
/** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
|
|
1233
|
+
* irreversible; the blob is gone. */
|
|
1234
|
+
purgeMedia: import("@pramen/server").Handler<{
|
|
1235
|
+
id: string;
|
|
1236
|
+
}, {
|
|
1237
|
+
ok: true;
|
|
1238
|
+
}>;
|
|
979
1239
|
listContentTypes: import("@pramen/server").Handler<unknown, Record<string, unknown>[]>;
|
|
980
1240
|
getContentType: import("@pramen/server").Handler<{
|
|
981
1241
|
id: string;
|
|
@@ -1001,13 +1261,14 @@ export declare const cmsHandlers: {
|
|
|
1001
1261
|
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
1002
1262
|
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
1003
1263
|
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
1004
|
-
* +
|
|
1264
|
+
* + normalized against the content type's fieldsSchema, exactly like createPage. */
|
|
1005
1265
|
updatePage: import("@pramen/server").Handler<{
|
|
1006
1266
|
pageId: string;
|
|
1007
1267
|
title?: string;
|
|
1008
1268
|
slug?: string;
|
|
1009
1269
|
locale?: string;
|
|
1010
|
-
fields?:
|
|
1270
|
+
fields?: FieldValues;
|
|
1271
|
+
expectedVersion?: number;
|
|
1011
1272
|
}, {
|
|
1012
1273
|
ok: boolean;
|
|
1013
1274
|
page: Record<string, unknown>;
|
|
@@ -1018,7 +1279,7 @@ export declare const cmsHandlers: {
|
|
|
1018
1279
|
title: string;
|
|
1019
1280
|
slug: string;
|
|
1020
1281
|
locale?: string;
|
|
1021
|
-
fields?:
|
|
1282
|
+
fields?: FieldValues;
|
|
1022
1283
|
}, Record<string, unknown>>;
|
|
1023
1284
|
/** Create a translation of an existing page: a new page in `locale` sharing the
|
|
1024
1285
|
* source's translationGroupId (and content type). Content starts empty — the editor
|
|
@@ -1048,7 +1309,7 @@ export declare const cmsHandlers: {
|
|
|
1048
1309
|
pageId: string;
|
|
1049
1310
|
blockTypeSlug: string;
|
|
1050
1311
|
region: string;
|
|
1051
|
-
fields?:
|
|
1312
|
+
fields?: FieldValues;
|
|
1052
1313
|
title?: string;
|
|
1053
1314
|
position?: number;
|
|
1054
1315
|
isReusable?: boolean;
|
|
@@ -1067,7 +1328,7 @@ export declare const cmsHandlers: {
|
|
|
1067
1328
|
blockId: string;
|
|
1068
1329
|
region: string;
|
|
1069
1330
|
position?: number;
|
|
1070
|
-
overrides?:
|
|
1331
|
+
overrides?: FieldValues;
|
|
1071
1332
|
}, Record<string, unknown>>;
|
|
1072
1333
|
/** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
|
|
1073
1334
|
getBlock: import("@pramen/server").Handler<{
|
|
@@ -1076,8 +1337,9 @@ export declare const cmsHandlers: {
|
|
|
1076
1337
|
/** Update a block's content (re-validated against its type's field schema). */
|
|
1077
1338
|
updateBlock: import("@pramen/server").Handler<{
|
|
1078
1339
|
blockId: string;
|
|
1079
|
-
fields?:
|
|
1340
|
+
fields?: FieldValues;
|
|
1080
1341
|
title?: string;
|
|
1342
|
+
expectedVersion?: number;
|
|
1081
1343
|
}, Record<string, unknown> | undefined>;
|
|
1082
1344
|
/** Reorder a region: `order` is the page_block ids in their new order. It must cover
|
|
1083
1345
|
* EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
|
|
@@ -1161,10 +1423,61 @@ export declare const cmsHandlers: {
|
|
|
1161
1423
|
ok: boolean;
|
|
1162
1424
|
publishInMs: number;
|
|
1163
1425
|
}>;
|
|
1426
|
+
/** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
|
|
1427
|
+
* anyone holding the resulting link can redeem it, which is the point. */
|
|
1428
|
+
signPagePreview: import("@pramen/server").Handler<{
|
|
1429
|
+
pageId: string;
|
|
1430
|
+
expiresIn?: number;
|
|
1431
|
+
}, {
|
|
1432
|
+
url: string;
|
|
1433
|
+
token: string;
|
|
1434
|
+
expiresAt: number;
|
|
1435
|
+
}>;
|
|
1436
|
+
/** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
|
|
1437
|
+
* `GET /cms/preview` route, which verifies the token and then calls this privileged.
|
|
1438
|
+
* Role-gated so it is not an anonymous back door on the /rpc surface. */
|
|
1439
|
+
getPagePreview: import("@pramen/server").Handler<{
|
|
1440
|
+
pageId: string;
|
|
1441
|
+
}, AssembledPage>;
|
|
1164
1442
|
/** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
|
|
1165
1443
|
* snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
|
|
1166
1444
|
* `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
|
|
1167
1445
|
* to the configured default locale; a slug is unique per locale. */
|
|
1446
|
+
deletePage: import("@pramen/server").Handler<{
|
|
1447
|
+
pageId: string;
|
|
1448
|
+
}, {
|
|
1449
|
+
ok: true;
|
|
1450
|
+
deletedAt: string;
|
|
1451
|
+
}>;
|
|
1452
|
+
/** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
|
|
1453
|
+
* the ACL read scope hides exactly these rows: that is the scope doing its job, not a
|
|
1454
|
+
* hole to patch.
|
|
1455
|
+
*
|
|
1456
|
+
* Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
|
|
1457
|
+
* `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
|
|
1458
|
+
* could ever be called with its id again, while `/media/<key>` kept serving the bytes
|
|
1459
|
+
* (that route streams from R2 with no DB lookup at all). */
|
|
1460
|
+
listTrash: import("@pramen/server").Handler<{
|
|
1461
|
+
limit?: number;
|
|
1462
|
+
}, {
|
|
1463
|
+
pages: Record<string, unknown>[];
|
|
1464
|
+
media: {
|
|
1465
|
+
file: unknown;
|
|
1466
|
+
}[];
|
|
1467
|
+
}>;
|
|
1468
|
+
restorePage: import("@pramen/server").Handler<{
|
|
1469
|
+
pageId: string;
|
|
1470
|
+
}, {
|
|
1471
|
+
ok: true;
|
|
1472
|
+
scheduleCleared: boolean;
|
|
1473
|
+
}>;
|
|
1474
|
+
/** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
|
|
1475
|
+
* this is the only irreversible operation in the CMS. */
|
|
1476
|
+
purgePage: import("@pramen/server").Handler<{
|
|
1477
|
+
pageId: string;
|
|
1478
|
+
}, {
|
|
1479
|
+
ok: true;
|
|
1480
|
+
}>;
|
|
1168
1481
|
getPage: import("@pramen/server").Handler<{
|
|
1169
1482
|
slug: string;
|
|
1170
1483
|
locale?: string;
|
|
@@ -1219,6 +1532,11 @@ export interface CollectionDef {
|
|
|
1219
1532
|
column: string;
|
|
1220
1533
|
dir?: "asc" | "desc";
|
|
1221
1534
|
};
|
|
1535
|
+
/** Workflow features this collection opts into — see {@link CollectionFeature}. Each is
|
|
1536
|
+
* backed by MANAGED COLUMNS on `entity` that the CMS writes and `fields` may not declare.
|
|
1537
|
+
* Validated against your schema at `createCollectionHandlers` time (which is why that call
|
|
1538
|
+
* needs `{ schema }` once this is set). Absent = a plain CRUD collection, as before. */
|
|
1539
|
+
readonly supports?: readonly CollectionFeature[];
|
|
1222
1540
|
}
|
|
1223
1541
|
/** Declare a collection. Spread the results into `createCollectionHandlers` +
|
|
1224
1542
|
* `collectionPolicies`:
|
|
@@ -1251,18 +1569,87 @@ export interface CollectionMeta {
|
|
|
1251
1569
|
column: string;
|
|
1252
1570
|
dir?: "asc" | "desc";
|
|
1253
1571
|
};
|
|
1572
|
+
/** Workflow features enabled — the editor uses this to decide which affordances to show
|
|
1573
|
+
* (a Publish button, a schedule picker, a revisions tab). Empty = plain CRUD. */
|
|
1574
|
+
supports: readonly CollectionFeature[];
|
|
1575
|
+
}
|
|
1576
|
+
/** A workflow feature a collection can opt into.
|
|
1577
|
+
*
|
|
1578
|
+
* - `drafts` — a managed `status` column (`draft` | `published`) plus `collectionPublish` /
|
|
1579
|
+
* `collectionUnpublish`. Pair with `collectionPublicPolicies` so anonymous reads see
|
|
1580
|
+
* published rows only.
|
|
1581
|
+
*
|
|
1582
|
+
* This gates VISIBILITY, not content. A collection is column-mapped — the public reads the
|
|
1583
|
+
* entity's own columns — so there is nowhere to stage an unpublished VERSION of a live
|
|
1584
|
+
* row: an edit (or a revision restore) on a published row is live immediately. That is the
|
|
1585
|
+
* one place collections do not reach page parity, where `getPage` serves a baked revision
|
|
1586
|
+
* snapshot. Unpublish first if an edit needs review.
|
|
1587
|
+
* - `scheduling` — managed `publishedAt` / `scheduledAt` / `unpublishAt`, `collectionSchedule`,
|
|
1588
|
+
* and the deferred tasks from `createCollectionTasks`. Needs `drafts`.
|
|
1589
|
+
* - `revisions` — a snapshot of the row's prior state on every write, in
|
|
1590
|
+
* `cms_collection_revisions`, with `collectionListRevisions` / `collectionRestoreRevision`.
|
|
1591
|
+
* - `preview` — signed, single-row preview links (`signCollectionPreview`), redeemed at
|
|
1592
|
+
* `COLLECTION_PREVIEW_PATH` by the route `cmsRoutes()` serves. Needs `drafts`. It shows
|
|
1593
|
+
* the row's CURRENT state to whoever holds the link, which for a DRAFT is the unpublished
|
|
1594
|
+
* content and for a published row is what the public already sees (see `drafts` above:
|
|
1595
|
+
* there is no separate staged version to show). */
|
|
1596
|
+
export type CollectionFeature = "drafts" | "scheduling" | "revisions" | "preview";
|
|
1597
|
+
export declare const COLLECTION_FEATURES: readonly CollectionFeature[];
|
|
1598
|
+
/** The columns each feature needs on the collection's entity. The app declares them (they
|
|
1599
|
+
* are its own entity); the CMS writes them and `fields` may not. */
|
|
1600
|
+
export declare const COLLECTION_FEATURE_COLUMNS: Readonly<Record<CollectionFeature, readonly string[]>>;
|
|
1601
|
+
/** The shared revision table for collections (see `cmsSchema`). */
|
|
1602
|
+
export declare const COLLECTION_REVISIONS_TABLE = "cms_collection_revisions";
|
|
1603
|
+
/** The two `status` values a `drafts` collection uses. */
|
|
1604
|
+
export declare const COLLECTION_DRAFT = "draft";
|
|
1605
|
+
export declare const COLLECTION_PUBLISHED = "published";
|
|
1606
|
+
/** Check a collection registry at BOOT: slugs and entities are unique, features are known
|
|
1607
|
+
* and have their prerequisites, every declared field maps to a column that can hold it, and
|
|
1608
|
+
* every managed column exists, has the shape the CMS writes, and is not also an editable
|
|
1609
|
+
* field.
|
|
1610
|
+
*
|
|
1611
|
+
* Called by `createCollectionHandlers`. The point is that a misconfiguration surfaces when
|
|
1612
|
+
* the Worker starts, naming the collection and the column — not as a 500 the first time an
|
|
1613
|
+
* editor presses Publish, months later, on the one collection nobody exercised.
|
|
1614
|
+
*
|
|
1615
|
+
* `schema` is REQUIRED. Every check here reads the target entity, so a registry validated
|
|
1616
|
+
* without one is not validated at all — and the failures it catches (a field name typo, a
|
|
1617
|
+
* richtext field over a TEXT column, a non-PK idField) are exactly as fatal on a collection
|
|
1618
|
+
* that declares no `supports` as on one that declares all four. */
|
|
1619
|
+
export declare function validateCollections(collections: readonly CollectionDef[], schema?: SchemaDef): void;
|
|
1620
|
+
/** A signed grant to preview ONE collection row. Mirrors {@link PreviewToken}. */
|
|
1621
|
+
export interface CollectionPreviewToken {
|
|
1622
|
+
/** tenant */ t: string;
|
|
1623
|
+
/** collection slug */ c: string;
|
|
1624
|
+
/** row id — the grant is scoped to this ONE row, never "all drafts" */ r: string;
|
|
1625
|
+
/** expiry (epoch seconds) */ exp: number;
|
|
1626
|
+
}
|
|
1627
|
+
/** Where a collection preview link is redeemed. Served by `cmsRoutes()`. */
|
|
1628
|
+
export declare const COLLECTION_PREVIEW_PATH = "/cms/preview/collection";
|
|
1629
|
+
/** Outbox task kinds behind `collectionSchedule`. Register the handlers with
|
|
1630
|
+
* `app.tasks = { ...cmsTasks, ...createCollectionTasks(collections) }`. */
|
|
1631
|
+
export declare const TASK_COLLECTION_PUBLISH = "cms:collection:publish";
|
|
1632
|
+
export declare const TASK_COLLECTION_UNPUBLISH = "cms:collection:unpublish";
|
|
1633
|
+
/** Options for `createCollectionHandlers`. */
|
|
1634
|
+
export interface CollectionHandlerOpts extends CmsHandlerOpts {
|
|
1635
|
+
/** Your app's schema (the object you pass to `defineSchema`). REQUIRED: the whole registry
|
|
1636
|
+
* is checked against it at boot by `validateCollections` — managed columns, declared
|
|
1637
|
+
* field ↔ column types, the idField/PK pairing, `orderBy`, the partition — so a
|
|
1638
|
+
* misconfiguration is a startup error naming the collection and the column rather than a
|
|
1639
|
+
* 500 (or a silent wrong answer) on the first call. */
|
|
1640
|
+
schema?: SchemaDef;
|
|
1254
1641
|
}
|
|
1255
1642
|
/** Build generic CRUD handlers over the registered collections. Spread into your app's
|
|
1256
1643
|
* handlers alongside `cmsHandlers`:
|
|
1257
1644
|
*
|
|
1258
|
-
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
|
|
1645
|
+
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures], { schema }) };
|
|
1259
1646
|
*
|
|
1260
1647
|
* Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
|
|
1261
1648
|
* `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
|
|
1262
1649
|
* (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
|
|
1263
1650
|
* `collectionPolicies` scopes them too). The `collection` param is resolved through the
|
|
1264
1651
|
* registry — an unknown slug is a 400, never a raw table reference. */
|
|
1265
|
-
export declare function createCollectionHandlers(collections: readonly CollectionDef[], opts?:
|
|
1652
|
+
export declare function createCollectionHandlers(collections: readonly CollectionDef[], opts?: CollectionHandlerOpts): {
|
|
1266
1653
|
/** The registered collections (defaults filled) — editor discovery. Editor-gated so
|
|
1267
1654
|
* the collection schemas aren't exposed to anonymous callers. */
|
|
1268
1655
|
listCollections: import("@pramen/server").Handler<unknown, CollectionMeta[]>;
|
|
@@ -1290,6 +1677,81 @@ export declare function createCollectionHandlers(collections: readonly Collectio
|
|
|
1290
1677
|
}, {
|
|
1291
1678
|
ok: true;
|
|
1292
1679
|
}>;
|
|
1680
|
+
/** Move a row live. With `scheduling` this also stamps `publishedAt` (the column the
|
|
1681
|
+
* public read scope compares against `$now()`) and clears `scheduledAt` — which makes
|
|
1682
|
+
* any pending scheduled-publish task a no-op, since its intent token no longer matches.
|
|
1683
|
+
* A pending scheduled UNPUBLISH is deliberately left standing: publishing early does not
|
|
1684
|
+
* cancel a planned takedown. */
|
|
1685
|
+
collectionPublish: import("@pramen/server").Handler<{
|
|
1686
|
+
collection: string;
|
|
1687
|
+
id: string;
|
|
1688
|
+
}, Record<string, unknown>>;
|
|
1689
|
+
/** Take a row back to draft, clearing every schedule. Both tokens are cleared, so a
|
|
1690
|
+
* pending publish AND a pending unpublish both become no-ops — unpublishing is an
|
|
1691
|
+
* explicit "this is not live and nothing is queued to change that". */
|
|
1692
|
+
collectionUnpublish: import("@pramen/server").Handler<{
|
|
1693
|
+
collection: string;
|
|
1694
|
+
id: string;
|
|
1695
|
+
}, Record<string, unknown>>;
|
|
1696
|
+
/** Schedule a future publish, and optionally a later unpublish. Mirrors `schedulePage`,
|
|
1697
|
+
* including the INTENT TOKEN: the row stores the scheduled instants
|
|
1698
|
+
* (`scheduledAt`/`unpublishAt`, ISO), the enqueued task carries a copy, and the task
|
|
1699
|
+
* runs only if the two still match. A reschedule overwrites the token, a manual
|
|
1700
|
+
* publish/unpublish clears it, and a duplicate delivery finds it already cleared — so a
|
|
1701
|
+
* superseded or cancelled schedule is a silent no-op rather than a surprise publish.
|
|
1702
|
+
*
|
|
1703
|
+
* The tasks are enqueued in THIS mutation's transaction (the outbox is transactional),
|
|
1704
|
+
* so a rolled-back schedule never leaves a task behind. They only run if you wired
|
|
1705
|
+
* `createCollectionTasks` into `app.tasks`. */
|
|
1706
|
+
collectionSchedule: import("@pramen/server").Handler<{
|
|
1707
|
+
collection: string;
|
|
1708
|
+
id: string;
|
|
1709
|
+
publishAt: number;
|
|
1710
|
+
unpublishAt?: number | null;
|
|
1711
|
+
}, {
|
|
1712
|
+
unpublishAt?: string | null | undefined;
|
|
1713
|
+
ok: true;
|
|
1714
|
+
scheduledAt: string;
|
|
1715
|
+
}>;
|
|
1716
|
+
/** A row's revision history, newest first. */
|
|
1717
|
+
collectionListRevisions: import("@pramen/server").Handler<{
|
|
1718
|
+
collection: string;
|
|
1719
|
+
id: string;
|
|
1720
|
+
limit?: number;
|
|
1721
|
+
}, {
|
|
1722
|
+
snapshot: Record<string, unknown>;
|
|
1723
|
+
}[]>;
|
|
1724
|
+
/** Restore a row to one of its revisions. The CURRENT state is snapshotted first, so a
|
|
1725
|
+
* restore is itself undoable. */
|
|
1726
|
+
collectionRestoreRevision: import("@pramen/server").Handler<{
|
|
1727
|
+
collection: string;
|
|
1728
|
+
id: string;
|
|
1729
|
+
revisionId: string;
|
|
1730
|
+
}, Record<string, unknown>>;
|
|
1731
|
+
/** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
|
|
1732
|
+
* Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
|
|
1733
|
+
* same rule that the row is read through the ACL FIRST: minting a link is granting
|
|
1734
|
+
* access to the row, so a caller who cannot read it must not be able to mint one. */
|
|
1735
|
+
signCollectionPreview: import("@pramen/server").Handler<{
|
|
1736
|
+
collection: string;
|
|
1737
|
+
id: string;
|
|
1738
|
+
expiresIn?: number;
|
|
1739
|
+
}, {
|
|
1740
|
+
url: string;
|
|
1741
|
+
token: string;
|
|
1742
|
+
expiresAt: number;
|
|
1743
|
+
}>;
|
|
1744
|
+
/** Read one row's live (possibly unpublished) state. Not the redemption endpoint — that
|
|
1745
|
+
* is the public `GET /cms/preview/collection` route, which verifies the token and then
|
|
1746
|
+
* calls this privileged. Role-gated so it is not an anonymous back door on /rpc. */
|
|
1747
|
+
getCollectionPreview: import("@pramen/server").Handler<{
|
|
1748
|
+
collection: string;
|
|
1749
|
+
id: string;
|
|
1750
|
+
}, {
|
|
1751
|
+
collection: string;
|
|
1752
|
+
id: string;
|
|
1753
|
+
values: Record<string, unknown>;
|
|
1754
|
+
}>;
|
|
1293
1755
|
};
|
|
1294
1756
|
/** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
|
|
1295
1757
|
* into your editor role next to `cmsPolicies().editor`:
|
|
@@ -1300,6 +1762,40 @@ export declare function createCollectionHandlers(collections: readonly Collectio
|
|
|
1300
1762
|
* them. Your app may already declare its own policies over the entity (e.g. a public read
|
|
1301
1763
|
* scope) — these only ADD the editor grant the CMS UI needs. */
|
|
1302
1764
|
export declare function collectionPolicies(collections: readonly CollectionDef[], opts?: CmsPolicyOpts): Policy[];
|
|
1765
|
+
/** ACL fragments granting ANONYMOUS read of the PUBLISHED rows of every collection that
|
|
1766
|
+
* supports `drafts`. Spread into your public role next to `cmsPolicies().public`:
|
|
1767
|
+
*
|
|
1768
|
+
* role("anonymous", [...cmsPolicies().public, ...collectionPublicPolicies(collections)])
|
|
1769
|
+
*
|
|
1770
|
+
* This is the access boundary, not a UI filter — it is AND-merged into every `ctx.db` read
|
|
1771
|
+
* of the entity, so an unpublished row is invisible to the public API, to relation
|
|
1772
|
+
* traversals and to eager-loads alike, without a single query remembering to filter.
|
|
1773
|
+
*
|
|
1774
|
+
* With `scheduling`, the scope also requires `publishedAt <= $now()`. `status` alone would
|
|
1775
|
+
* not be enough the moment anything writes a future `publishedAt`, and `{ publishedAt:
|
|
1776
|
+
* { isNull: false } }` — the obvious-looking alternative — matches a FUTURE timestamp too,
|
|
1777
|
+
* so a row scheduled for next week would be anonymously readable the moment it was saved.
|
|
1778
|
+
* The comparison is lexicographic over TEXT, which is why every managed timestamp is minted
|
|
1779
|
+
* as ISO-8601 UTC (`isoStamp`), the same shape `$now()` produces.
|
|
1780
|
+
*
|
|
1781
|
+
* Collections WITHOUT `drafts` get nothing here: they have no publish state, so their
|
|
1782
|
+
* public exposure is entirely your app's own policy to write. */
|
|
1783
|
+
export declare function collectionPublicPolicies(collections: readonly CollectionDef[], opts?: CmsPolicyOpts): Policy[];
|
|
1784
|
+
/** Task handlers backing `collectionSchedule`. Register alongside `cmsTasks`:
|
|
1785
|
+
*
|
|
1786
|
+
* const app = { tasks: { ...cmsTasks, ...createCollectionTasks(collections) } };
|
|
1787
|
+
*
|
|
1788
|
+
* WITHOUT THIS WIRING A SCHEDULE NEVER FIRES: `collectionSchedule` still stores the
|
|
1789
|
+
* instants and enqueues the tasks, but the drain finds no handler for their kind, so the
|
|
1790
|
+
* row silently stays a draft. (`cmsTasks` has the same requirement for page scheduling.)
|
|
1791
|
+
*
|
|
1792
|
+
* They run with a privileged, system-scoped ctx off the write path, and each validates its
|
|
1793
|
+
* INTENT TOKEN against the row's current `scheduledAt`/`unpublishAt` before acting — see
|
|
1794
|
+
* `collectionSchedule`. */
|
|
1795
|
+
export declare function createCollectionTasks(collections: readonly CollectionDef[]): {
|
|
1796
|
+
"cms:collection:publish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
|
|
1797
|
+
"cms:collection:unpublish": (ctx: HandlerContext, payload: unknown) => Promise<void>;
|
|
1798
|
+
};
|
|
1303
1799
|
/** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
|
|
1304
1800
|
* They run with a privileged, system-scoped ctx off the write path (the outbox drain).
|
|
1305
1801
|
*
|
|
@@ -1336,7 +1832,7 @@ export declare function robotsTxt(opts: {
|
|
|
1336
1832
|
interface RouteCtx {
|
|
1337
1833
|
callPrivileged: (opts: {
|
|
1338
1834
|
name: string;
|
|
1339
|
-
input?:
|
|
1835
|
+
input?: JsonValue;
|
|
1340
1836
|
tenant?: string;
|
|
1341
1837
|
roles?: string[];
|
|
1342
1838
|
}) => Promise<Response>;
|
|
@@ -1344,9 +1840,9 @@ interface RouteCtx {
|
|
|
1344
1840
|
interface CmsRoute {
|
|
1345
1841
|
method: string;
|
|
1346
1842
|
path: string;
|
|
1347
|
-
handler: (request: Request, env:
|
|
1843
|
+
handler: (request: Request, env: EnvBag, ctx: RouteCtx) => Promise<Response>;
|
|
1348
1844
|
}
|
|
1349
|
-
/** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
|
|
1845
|
+
/** Turnkey public routes for `GET /sitemap.xml`, `GET /cms/preview` and `GET /robots.txt`. Spread into
|
|
1350
1846
|
* `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
|
|
1351
1847
|
* `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
|
|
1352
1848
|
export declare function cmsRoutes(opts?: {
|
|
@@ -1354,5 +1850,18 @@ export declare function cmsRoutes(opts?: {
|
|
|
1354
1850
|
tenant?: string;
|
|
1355
1851
|
pageUrl?: SitemapOpts["pageUrl"];
|
|
1356
1852
|
disallow?: string[];
|
|
1853
|
+
/** The SAME options you passed to `createCmsHandlers`. The route derives its identity
|
|
1854
|
+
* from them with `viewerRolesOf`, so the two cannot drift. (`viewerRoles` overrides it
|
|
1855
|
+
* outright if you need to.) */
|
|
1856
|
+
handlers?: CmsHandlerOpts;
|
|
1857
|
+
/** The SAME options you passed to `createCollectionHandlers`, if they differ from
|
|
1858
|
+
* `handlers`. The COLLECTION preview route has its own gate — `getCollectionPreview` is
|
|
1859
|
+
* built by `createCollectionHandlers`, so an app that passes different `editorRoles` /
|
|
1860
|
+
* `reviewerRoles` to the two factories would have the route present the page half's
|
|
1861
|
+
* roles to a handler gated on the collection half's, and every collection preview link
|
|
1862
|
+
* would 404 uniformly (the response is deliberately indistinguishable from "not
|
|
1863
|
+
* found"). Defaults to `handlers`, which is right whenever both got the same options. */
|
|
1864
|
+
collectionHandlers?: CmsHandlerOpts;
|
|
1865
|
+
/** Explicit override for the roles the preview routes present to the DO. */
|
|
1866
|
+
viewerRoles?: readonly string[];
|
|
1357
1867
|
}): CmsRoute[];
|
|
1358
|
-
export {};
|