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