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