@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/dist/index.js
CHANGED
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
// const acl = [ role("anonymous", [...cmsPolicies().public]),
|
|
25
25
|
// role("editor", [...cmsPolicies().editor]) ];
|
|
26
26
|
// const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
|
|
27
|
-
import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, BadRequest, Forbidden, PramenError, } from "@pramen/server";
|
|
28
|
-
import {
|
|
27
|
+
import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, $now, partitionOf, DEFAULT_PARTITION, BadRequest, Forbidden, PramenError, Conflict, signToken, verifyToken, resolveSecret, } from "@pramen/server";
|
|
28
|
+
import { isSafeHref, normalizeHref } from "./href";
|
|
29
29
|
/** Declare a typed block type. Pass `fields as const` to preserve the literals so
|
|
30
30
|
* `BlockFieldsOf<typeof def>` infers the field shape:
|
|
31
31
|
*
|
|
@@ -137,11 +137,49 @@ function tsTypeOf(f) {
|
|
|
137
137
|
return "unknown";
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
+
/** Which imported helper types a field schema actually needs, found by walking the tree
|
|
141
|
+
* (group/repeater nest, so a `richtext` three levels down still counts). Order is stable
|
|
142
|
+
* so the emitted import line does not churn between runs. */
|
|
143
|
+
function referencedHelperTypes(fields) {
|
|
144
|
+
const found = new Set();
|
|
145
|
+
const walk = (defs) => {
|
|
146
|
+
for (const f of defs) {
|
|
147
|
+
if (f.type === "richtext")
|
|
148
|
+
found.add("RichText");
|
|
149
|
+
else if (f.type === "media")
|
|
150
|
+
found.add("ResolvedMedia");
|
|
151
|
+
else if (f.fields)
|
|
152
|
+
walk(f.fields);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
walk(fields);
|
|
156
|
+
return ["ResolvedMedia", "RichText"].filter((t) => found.has(t));
|
|
157
|
+
}
|
|
140
158
|
const tsFieldLine = (f) => `${JSON.stringify(f.name)}${f.required ? "" : "?"}: ${tsTypeOf(f)};`;
|
|
141
159
|
/** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
|
|
142
160
|
* DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
|
|
143
161
|
* compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
|
|
144
162
|
export function generateBlockTypes(blockTypes) {
|
|
163
|
+
// Slugs are webmaster-authored with no deploy, so they are not guaranteed to map to a
|
|
164
|
+
// valid or DISTINCT TypeScript identifier: "2-col" -> `interface 2ColFields` is a syntax
|
|
165
|
+
// error, and "rich-text"/"rich_text" both -> `RichTextFields`. Fail with the offending
|
|
166
|
+
// slugs rather than emit a file that does not parse.
|
|
167
|
+
const seen = new Map();
|
|
168
|
+
const bad = [];
|
|
169
|
+
for (const bt of blockTypes) {
|
|
170
|
+
const name = pascal(bt.slug);
|
|
171
|
+
// Unicode-aware: `úvodní-blok` -> `úvodníBlok` IS a legal TypeScript identifier, and an
|
|
172
|
+
// ASCII-only test rejected it — aborting codegen for the WHOLE tenant over a slug that
|
|
173
|
+
// works, with no fix short of renaming production data.
|
|
174
|
+
if (!/^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u.test(name))
|
|
175
|
+
bad.push(`${bt.slug} (-> '${name}', not an identifier)`);
|
|
176
|
+
else if (seen.has(name))
|
|
177
|
+
bad.push(`${bt.slug} (-> '${name}', collides with '${seen.get(name)}')`);
|
|
178
|
+
else
|
|
179
|
+
seen.set(name, bt.slug);
|
|
180
|
+
}
|
|
181
|
+
if (bad.length)
|
|
182
|
+
throw new BadRequest(`cannot generate types for block type slug(s): ${bad.join("; ")}`);
|
|
145
183
|
const interfaces = blockTypes
|
|
146
184
|
.map((bt) => {
|
|
147
185
|
const fields = Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [];
|
|
@@ -150,9 +188,16 @@ export function generateBlockTypes(blockTypes) {
|
|
|
150
188
|
})
|
|
151
189
|
.join("\n\n");
|
|
152
190
|
const registry = blockTypes.map((bt) => ` ${JSON.stringify(bt.slug)}: ${pascal(bt.slug)}Fields;`).join("\n");
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
191
|
+
// Import ONLY what the emitted interfaces reference: every tsconfig in this repo sets
|
|
192
|
+
// `noUnusedLocals`, so an unconditional import is a guaranteed TS6192 build break in the
|
|
193
|
+
// consumer's own project — for a file they are told not to edit.
|
|
194
|
+
//
|
|
195
|
+
// Walk the SCHEMA rather than regexing the rendered text: field names are printed into
|
|
196
|
+
// the output, so a field literally named `RichText` matched a `\bRichText\b` scan and
|
|
197
|
+
// produced the unused import this exists to avoid.
|
|
198
|
+
const used = referencedHelperTypes(blockTypes.flatMap((bt) => (Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [])));
|
|
199
|
+
const importLine = used.length ? `import type { ${used.join(", ")} } from "@pramen/cms";\n\n` : "";
|
|
200
|
+
return `// AUTO-GENERATED by @pramen/cms — do not edit.\n${importLine}${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`;
|
|
156
201
|
}
|
|
157
202
|
// --- schema fragment: spread into your defineSchema so the tables migrate --------
|
|
158
203
|
/** The block/page builder tables. All in the default partition (relations can't cross
|
|
@@ -184,6 +229,9 @@ export const cmsSchema = {
|
|
|
184
229
|
title: t.text(),
|
|
185
230
|
fields: t.json(), // content matching the block type's fieldsSchema
|
|
186
231
|
isReusable: defaultTo(t.bool(), false),
|
|
232
|
+
// Optimistic concurrency: bumped on every edit. A caller may pass the version it
|
|
233
|
+
// read as `expectedVersion` and get a 409 instead of silently clobbering.
|
|
234
|
+
version: defaultTo(t.int(), 1),
|
|
187
235
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
188
236
|
updatedAt: defaultTo(t.text(), expr.now()),
|
|
189
237
|
}), (r) => ({ type: r.belongsTo("cms_block_types", "typeId") })),
|
|
@@ -209,6 +257,11 @@ export const cmsSchema = {
|
|
|
209
257
|
// (not "latest by timestamp") so selection is deterministic even when two publishes
|
|
210
258
|
// land in the same second (expr.now() is second-precision).
|
|
211
259
|
currentRevisionId: t.uuid(),
|
|
260
|
+
// Soft delete: the epoch-ISO instant the page was trashed, NULL while it is live.
|
|
261
|
+
// Every read scope AND-merges `deletedAt IS NULL` (see cmsPolicies), so a trashed
|
|
262
|
+
// page disappears from the public API and the editor alike without a single handler
|
|
263
|
+
// remembering to filter. `restorePage` clears it; `purgePage` removes the row.
|
|
264
|
+
deletedAt: indexed(t.text()),
|
|
212
265
|
// SEO
|
|
213
266
|
metaTitle: t.text(),
|
|
214
267
|
metaDescription: t.text(),
|
|
@@ -218,6 +271,8 @@ export const cmsSchema = {
|
|
|
218
271
|
ogDescription: t.text(),
|
|
219
272
|
ogImage: t.uuid(), // a cms_media id, resolved to a URL at assemble time
|
|
220
273
|
structuredData: t.json(), // JSON-LD, emitted as-is into <head>
|
|
274
|
+
// Optimistic concurrency — see cms_blocks.version.
|
|
275
|
+
version: defaultTo(t.int(), 1),
|
|
221
276
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
222
277
|
updatedAt: defaultTo(t.text(), expr.now()),
|
|
223
278
|
}), (r) => ({
|
|
@@ -261,12 +316,52 @@ export const cmsSchema = {
|
|
|
261
316
|
note: t.text(),
|
|
262
317
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
263
318
|
})),
|
|
319
|
+
// Revision history for COLLECTION rows (`supports: ["revisions"]`). One shared table
|
|
320
|
+
// rather than one per collection: a collection targets an arbitrary app entity, so there
|
|
321
|
+
// is no place to hang a per-entity revisions table and no way to declare a real FK to a
|
|
322
|
+
// target that varies. `collection` + `rowId` identify the subject; `rowId` is TEXT
|
|
323
|
+
// because a collection's PK may be a uuid or a textId.
|
|
324
|
+
//
|
|
325
|
+
// A revision holds the row's state BEFORE the write that created it, projected to the
|
|
326
|
+
// collection's declared fields — so restoring one is a plain reversal, and a snapshot
|
|
327
|
+
// taken before a field was dropped from `fields` cannot resurrect that column (restore
|
|
328
|
+
// replays through the same write whitelist).
|
|
329
|
+
cms_collection_revisions: Entity((t) => ({
|
|
330
|
+
id: primaryKey(generated(t.uuid())),
|
|
331
|
+
collection: indexed(notNull(t.text())),
|
|
332
|
+
rowId: indexed(notNull(t.text())),
|
|
333
|
+
// A monotonic per-row counter, and the ONLY ordering key. Timestamps cannot do this
|
|
334
|
+
// job: `expr.now()` is second-resolution and even an ISO ms stamp collides, because a
|
|
335
|
+
// collection revision is written on EVERY edit and two writes land in the same
|
|
336
|
+
// millisecond often enough to be reproducible. Ordering then falls to a uuid tiebreak,
|
|
337
|
+
// which is deterministic but NOT insertion order — so "restore the previous version"
|
|
338
|
+
// could pick the wrong snapshot.
|
|
339
|
+
//
|
|
340
|
+
// The read-then-increment in `snapshotRow` is serialized by the DO's single writer. On
|
|
341
|
+
// the D1 store it is NOT — `D1Driver.transaction` is a no-op (D1 has no interactive
|
|
342
|
+
// transactions), so two concurrent updates in different isolates can read the same MAX.
|
|
343
|
+
// The composite unique below is what makes that a visible failure instead of a silent
|
|
344
|
+
// duplicate that quietly restores the ordering ambiguity this column exists to remove.
|
|
345
|
+
revision: notNull(t.int()),
|
|
346
|
+
snapshot: t.json(),
|
|
347
|
+
note: t.text(),
|
|
348
|
+
actor: t.text(),
|
|
349
|
+
// NO expr.now() default. `snapshotRow` is the only writer and stamps this itself with
|
|
350
|
+
// ISO-8601 ms precision, because unlike cms_page_revisions (written only on publish) a
|
|
351
|
+
// collection revision is written on EVERY edit — an autosave followed immediately by a
|
|
352
|
+
// publish lands two rows in the same second, and `datetime('now')` (second resolution)
|
|
353
|
+
// would make "the previous version" an arbitrary pick between them.
|
|
354
|
+
createdAt: t.text(),
|
|
355
|
+
}), undefined, { unique: [["collection", "rowId", "revision"]] }),
|
|
264
356
|
// Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
|
|
265
357
|
// ctx.files + the Worker /files/* route. Block `fields` reference a media id.
|
|
266
358
|
cms_media: Entity((t) => ({
|
|
267
359
|
id: primaryKey(generated(t.uuid())),
|
|
268
360
|
file: t.fileRef(),
|
|
269
361
|
alt: t.text(),
|
|
362
|
+
// Soft delete, as on cms_pages. The R2 OBJECT is deliberately kept while a media row
|
|
363
|
+
// is trashed — deleting the bytes would make restore a lie. `purgeMedia` drops both.
|
|
364
|
+
deletedAt: indexed(t.text()),
|
|
270
365
|
createdAt: defaultTo(t.text(), expr.now()),
|
|
271
366
|
})),
|
|
272
367
|
};
|
|
@@ -320,8 +415,19 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
320
415
|
throw new BadRequest(`field '${at}' must be a slug (lowercase letters, digits and single hyphens)`);
|
|
321
416
|
break;
|
|
322
417
|
case "richtext":
|
|
323
|
-
|
|
324
|
-
|
|
418
|
+
// A document tree, never a string. A legacy HTML value is REJECTED rather than
|
|
419
|
+
// silently normalized to an empty doc — a 400 names the migration; a blank field
|
|
420
|
+
// would look like the content simply vanished. Except where the bag carries stored
|
|
421
|
+
// data the caller never sent (see `legacyBaseline`).
|
|
422
|
+
if (typeof v === "string") {
|
|
423
|
+
// Tolerated only if it is exactly what is already stored for this field.
|
|
424
|
+
if (opts.legacyBaseline && opts.legacyBaseline[def.name] === v)
|
|
425
|
+
break;
|
|
426
|
+
throw new BadRequest(`field '${at}' must be a rich-text document, not an HTML string`);
|
|
427
|
+
}
|
|
428
|
+
if (typeof v !== "object" || Array.isArray(v) || v.type !== "doc") {
|
|
429
|
+
throw new BadRequest(`field '${at}' must be a rich-text document ({ type: "doc", content: [...] })`);
|
|
430
|
+
}
|
|
325
431
|
break;
|
|
326
432
|
case "number":
|
|
327
433
|
if (typeof v !== "number")
|
|
@@ -347,9 +453,18 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
347
453
|
if (typeof v !== "string")
|
|
348
454
|
throw new BadRequest(`field '${at}' must be a media id (string)`);
|
|
349
455
|
break;
|
|
350
|
-
case "group":
|
|
351
|
-
|
|
456
|
+
case "group": {
|
|
457
|
+
// The baseline MUST descend. Stopping at the top level meant a pre-migration
|
|
458
|
+
// richtext value nested in a group was rejected on every write that echoed the
|
|
459
|
+
// stored bag back — and placeBlock, which merges the block's OWN stored fields,
|
|
460
|
+
// could not place such a block at all. No editor can fix that: none ever mounted it.
|
|
461
|
+
const nested = opts.legacyBaseline?.[def.name];
|
|
462
|
+
validateFields(def.fields, v, at, {
|
|
463
|
+
requireRequired: opts.requireRequired,
|
|
464
|
+
legacyBaseline: nested && typeof nested === "object" && !Array.isArray(nested) ? nested : undefined,
|
|
465
|
+
});
|
|
352
466
|
break;
|
|
467
|
+
}
|
|
353
468
|
case "repeater": {
|
|
354
469
|
if (!Array.isArray(v))
|
|
355
470
|
throw new BadRequest(`field '${at}' must be a list`);
|
|
@@ -357,7 +472,19 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
357
472
|
throw new BadRequest(`field '${at}' needs at least ${def.min} item(s)`);
|
|
358
473
|
if (def.max != null && v.length > def.max)
|
|
359
474
|
throw new BadRequest(`field '${at}' allows at most ${def.max} item(s)`);
|
|
360
|
-
|
|
475
|
+
{
|
|
476
|
+
// Per-item baseline, positionally — a repeater item that kept its slot keeps its
|
|
477
|
+
// stored value, so an untouched legacy value inside one still validates.
|
|
478
|
+
const base = opts.legacyBaseline?.[def.name];
|
|
479
|
+
const baseItems = Array.isArray(base) ? base : [];
|
|
480
|
+
v.forEach((item, i) => {
|
|
481
|
+
const bi = baseItems[i];
|
|
482
|
+
validateFields(def.fields, item, `${at}[${i}]`, {
|
|
483
|
+
requireRequired: opts.requireRequired,
|
|
484
|
+
legacyBaseline: bi && typeof bi === "object" && !Array.isArray(bi) ? bi : undefined,
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
}
|
|
361
488
|
break;
|
|
362
489
|
}
|
|
363
490
|
default:
|
|
@@ -365,40 +492,203 @@ export function validateFields(schema, values, path = "", opts = {}) {
|
|
|
365
492
|
}
|
|
366
493
|
}
|
|
367
494
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
495
|
+
/** What the shipped editor can actually produce (TipTap StarterKit + Highlight + TaskList,
|
|
496
|
+
* as configured by @podoba/react's BlockEditor). Pass your own to `normalizeFields` if your
|
|
497
|
+
* editor adds extensions — a node type absent from the schema is dropped on write. */
|
|
498
|
+
/** Highest heading level the shipped editor is configured for (StarterKit levels [1,2,3]). */
|
|
499
|
+
export const MAX_HEADING_LEVEL = 3;
|
|
500
|
+
export const DEFAULT_RICH_TEXT_SCHEMA = {
|
|
501
|
+
nodes: {
|
|
502
|
+
// NOTE: no `doc`. normalizeRichText builds the root itself and never looks it up, so
|
|
503
|
+
// an entry here would only ever authorize a NESTED doc — which TipTap cannot render
|
|
504
|
+
// (Document declares no renderHTML), blanking the field in the editor while the site
|
|
505
|
+
// renderers still showed the subtree. The first keystroke then saved the blank over it.
|
|
506
|
+
paragraph: [],
|
|
507
|
+
text: [],
|
|
508
|
+
hardBreak: [],
|
|
509
|
+
horizontalRule: [],
|
|
510
|
+
heading: ["level"],
|
|
511
|
+
blockquote: [],
|
|
512
|
+
codeBlock: ["language"],
|
|
513
|
+
bulletList: [],
|
|
514
|
+
orderedList: ["start"],
|
|
515
|
+
listItem: [],
|
|
516
|
+
taskList: [],
|
|
517
|
+
taskItem: ["checked"],
|
|
518
|
+
},
|
|
519
|
+
marks: {
|
|
520
|
+
bold: [],
|
|
521
|
+
italic: [],
|
|
522
|
+
underline: [],
|
|
523
|
+
strike: [],
|
|
524
|
+
code: [],
|
|
525
|
+
highlight: ["color"],
|
|
526
|
+
link: ["href", "title", "target"],
|
|
527
|
+
},
|
|
381
528
|
};
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
529
|
+
// Re-exported from the leaf module `./href` so `@pramen/cms/react` can import them at
|
|
530
|
+
// runtime without dragging this file (and the whole server SDK) into a browser bundle.
|
|
531
|
+
export { isSafeHref, normalizeHref } from "./href";
|
|
532
|
+
/** Keep only the declared attributes, and only those holding a JSON primitive — an object
|
|
533
|
+
* or array in an attr is never something the editor emits, so it is smuggled payload. */
|
|
534
|
+
/** Look a type up in an allow-list WITHOUT walking the prototype chain. A plain-object
|
|
535
|
+
* index resolves `constructor` / `toString` / `valueOf` to inherited members, which are
|
|
536
|
+
* truthy — so `{ type: "constructor" }` passed the gate and was stored, and its "allowed
|
|
537
|
+
* attributes" became the `Object` function (length 1, not iterable), throwing a TypeError
|
|
538
|
+
* inside the DO's storage.transaction(). Both renderers and TipTap then choke on the
|
|
539
|
+
* stored node, which bricks the row. */
|
|
540
|
+
function allowedAttrsFor(table, type) {
|
|
541
|
+
if (typeof type !== "string" || !Object.hasOwn(table, type))
|
|
542
|
+
return undefined;
|
|
543
|
+
return table[type];
|
|
544
|
+
}
|
|
545
|
+
function normalizeAttrs(attrs, allowed, maxHeading = MAX_HEADING_LEVEL) {
|
|
546
|
+
if (!allowed.length || !attrs || typeof attrs !== "object" || Array.isArray(attrs))
|
|
547
|
+
return undefined;
|
|
548
|
+
const out = {};
|
|
549
|
+
for (const name of allowed) {
|
|
550
|
+
const v = attrs[name];
|
|
551
|
+
if (v === undefined)
|
|
552
|
+
continue;
|
|
553
|
+
if (v !== null && typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean")
|
|
554
|
+
continue;
|
|
555
|
+
// CLAMP, don't drop. Dropping `level` left the node level-less, and TipTap's Heading
|
|
556
|
+
// declares `level: { default: 1 }` — so an imported h4 still opened as h1 and the next
|
|
557
|
+
// autosave still persisted h1, while the renderers fell back to h2. Same silent
|
|
558
|
+
// mutation the narrowing was meant to stop, plus an editor/site mismatch.
|
|
559
|
+
if (name === "level") {
|
|
560
|
+
if (typeof v !== "number" || !Number.isInteger(v))
|
|
561
|
+
continue;
|
|
562
|
+
out[name] = Math.min(Math.max(v, 1), maxHeading);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
out[name] = v;
|
|
566
|
+
}
|
|
567
|
+
return Object.keys(out).length ? out : undefined;
|
|
568
|
+
}
|
|
569
|
+
/** Drop unknown marks and any `link` whose href fails the scheme allow-list (dropping the
|
|
570
|
+
* whole mark, not just the href — an anchor with no destination is worse than plain text). */
|
|
571
|
+
function normalizeMarks(marks, schema) {
|
|
572
|
+
if (!Array.isArray(marks))
|
|
573
|
+
return undefined;
|
|
574
|
+
const out = [];
|
|
575
|
+
for (const raw of marks) {
|
|
576
|
+
if (!raw || typeof raw !== "object")
|
|
577
|
+
continue;
|
|
578
|
+
const mark = raw;
|
|
579
|
+
const allowed = allowedAttrsFor(schema.marks, mark.type);
|
|
580
|
+
if (!allowed)
|
|
581
|
+
continue;
|
|
582
|
+
const attrs = normalizeAttrs(mark.attrs, allowed, schema.maxHeadingLevel);
|
|
583
|
+
if (mark.type === "link") {
|
|
584
|
+
if (!isSafeHref(attrs?.href))
|
|
585
|
+
continue;
|
|
586
|
+
// Persist the parser-normalized form, so what was validated is what resolves.
|
|
587
|
+
if (attrs && typeof attrs.href === "string")
|
|
588
|
+
attrs.href = normalizeHref(attrs.href);
|
|
589
|
+
}
|
|
590
|
+
out.push(attrs ? { type: mark.type, attrs } : { type: mark.type });
|
|
591
|
+
}
|
|
592
|
+
return out.length ? out : undefined;
|
|
593
|
+
}
|
|
594
|
+
/** How deep a document may nest before the normalizer stops descending. Real editor output
|
|
595
|
+
* is a handful of levels (list > item > paragraph > text); a hand-crafted doc nested tens
|
|
596
|
+
* of thousands deep would otherwise blow the stack INSIDE the DO's storage.transaction().
|
|
597
|
+
* JSON.parse is iterative in V8, so such a payload reaches the normalizer intact. */
|
|
598
|
+
export const MAX_RICH_TEXT_DEPTH = 100;
|
|
599
|
+
/** Normalize one node, or `null` if its type is not in the schema. */
|
|
600
|
+
function normalizeNode(raw, schema, depth = 0) {
|
|
601
|
+
if (depth > MAX_RICH_TEXT_DEPTH)
|
|
602
|
+
return null;
|
|
603
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
604
|
+
return null;
|
|
605
|
+
const node = raw;
|
|
606
|
+
const allowed = allowedAttrsFor(schema.nodes, node.type);
|
|
607
|
+
if (!allowed)
|
|
608
|
+
return null;
|
|
609
|
+
const out = { type: node.type };
|
|
610
|
+
if (node.type === "text") {
|
|
611
|
+
// A text node with no string — or an EMPTY one — is not text. ProseMirror forbids an
|
|
612
|
+
// empty text node outright (`schema.text("")` throws "Empty text nodes are not
|
|
613
|
+
// allowed"), and the editor builds its document inside a useState initializer, so a
|
|
614
|
+
// stored `{type:"text",text:""}` would throw during render and take the edit UI down
|
|
615
|
+
// for that row permanently.
|
|
616
|
+
if (typeof node.text !== "string" || node.text === "")
|
|
617
|
+
return null;
|
|
618
|
+
out.text = node.text;
|
|
619
|
+
const marks = normalizeMarks(node.marks, schema);
|
|
620
|
+
if (marks)
|
|
621
|
+
out.marks = marks;
|
|
622
|
+
}
|
|
623
|
+
const attrs = normalizeAttrs(node.attrs, allowed, schema.maxHeadingLevel);
|
|
624
|
+
if (attrs)
|
|
625
|
+
out.attrs = attrs;
|
|
626
|
+
if (Array.isArray(node.content)) {
|
|
627
|
+
const content = normalizeNodes(node.content, schema, depth + 1);
|
|
628
|
+
if (content.length)
|
|
629
|
+
out.content = content;
|
|
630
|
+
}
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
function normalizeNodes(nodes, schema, depth = 0) {
|
|
634
|
+
const out = [];
|
|
635
|
+
for (const n of nodes) {
|
|
636
|
+
const node = normalizeNode(n, schema, depth);
|
|
637
|
+
if (node)
|
|
638
|
+
out.push(node);
|
|
639
|
+
}
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
/** Normalize a rich-text value to a document the renderers can trust. A value that is not
|
|
643
|
+
* a doc at all yields an empty doc — `validateFields` rejects those first, so in handler
|
|
644
|
+
* flow this only ever sees a doc; the fallback is for direct callers. */
|
|
645
|
+
export function normalizeRichText(value, schema = DEFAULT_RICH_TEXT_SCHEMA) {
|
|
646
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
647
|
+
return { type: "doc", content: [] };
|
|
648
|
+
const content = Array.isArray(value.content) ? normalizeNodes(value.content ?? [], schema) : [];
|
|
649
|
+
return { type: "doc", content };
|
|
386
650
|
}
|
|
387
|
-
/**
|
|
388
|
-
|
|
389
|
-
|
|
651
|
+
/** The block-level node types that end a line when flattening to plain text. */
|
|
652
|
+
const RT_BLOCK_TYPES = new Set(["paragraph", "heading", "listItem", "taskItem", "blockquote", "codeBlock", "horizontalRule"]);
|
|
653
|
+
/** Flatten a rich-text document to plain text — for excerpts, meta descriptions, and search
|
|
654
|
+
* indexing, which want the words without the structure. */
|
|
655
|
+
export function richTextToPlainText(value) {
|
|
656
|
+
const parts = [];
|
|
657
|
+
const walk = (nodes) => {
|
|
658
|
+
for (const node of nodes) {
|
|
659
|
+
if (node.type === "text")
|
|
660
|
+
parts.push(node.text ?? "");
|
|
661
|
+
else if (node.type === "hardBreak")
|
|
662
|
+
parts.push("\n");
|
|
663
|
+
if (node.content)
|
|
664
|
+
walk(node.content);
|
|
665
|
+
if (RT_BLOCK_TYPES.has(node.type))
|
|
666
|
+
parts.push("\n");
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
walk(value?.content ?? []);
|
|
670
|
+
// A block inside a block (a paragraph in a list item) closes both, so collapse the run:
|
|
671
|
+
// every boundary is worth exactly one line break in a flattened excerpt.
|
|
672
|
+
return parts.join("").replace(/\n{2,}/g, "\n").trim();
|
|
673
|
+
}
|
|
674
|
+
/** Deep-normalize the richtext fields in a values object against a field schema (recursing
|
|
675
|
+
* into group/repeater). Returns a normalized copy; other field types pass through. */
|
|
676
|
+
export function normalizeFields(schema, values, richTextSchema = DEFAULT_RICH_TEXT_SCHEMA) {
|
|
390
677
|
const defs = Array.isArray(schema) ? schema : [];
|
|
391
678
|
const out = { ...values };
|
|
392
679
|
for (const def of defs) {
|
|
393
680
|
const v = out[def.name];
|
|
394
681
|
if (v == null)
|
|
395
682
|
continue;
|
|
396
|
-
|
|
397
|
-
|
|
683
|
+
// A legacy HTML string survives normalization untouched: normalizeRichText would turn
|
|
684
|
+
// it into an EMPTY doc, i.e. silently delete the content. It only reaches here on the
|
|
685
|
+
// `legacyBaseline` paths, where it is the stored value being echoed back.
|
|
686
|
+
if (def.type === "richtext")
|
|
687
|
+
out[def.name] = typeof v === "string" ? v : normalizeRichText(v, richTextSchema);
|
|
398
688
|
else if (def.type === "group" && typeof v === "object" && !Array.isArray(v))
|
|
399
|
-
out[def.name] =
|
|
689
|
+
out[def.name] = normalizeFields(def.fields, v, richTextSchema);
|
|
400
690
|
else if (def.type === "repeater" && Array.isArray(v))
|
|
401
|
-
out[def.name] = v.map((it) => (it && typeof it === "object" ?
|
|
691
|
+
out[def.name] = v.map((it) => (it && typeof it === "object" ? normalizeFields(def.fields, it, richTextSchema) : it));
|
|
402
692
|
}
|
|
403
693
|
return out;
|
|
404
694
|
}
|
|
@@ -533,6 +823,7 @@ async function assembleLive(db, page) {
|
|
|
533
823
|
(regions[region] ??= []).push({
|
|
534
824
|
id: String(m.p.id),
|
|
535
825
|
block_id: String(m.block.id),
|
|
826
|
+
version: typeof m.block.version === "number" ? m.block.version : 1,
|
|
536
827
|
block_type: typeById.get(String(m.block.typeId))?.slug ?? "unknown",
|
|
537
828
|
title: m.block.title ?? null,
|
|
538
829
|
fields: resolveMediaFields(m.fields, m.schema, mediaById),
|
|
@@ -595,6 +886,7 @@ function pageMeta(page, translations = [], ogImage = null, contentType = null) {
|
|
|
595
886
|
slug: String(page.slug),
|
|
596
887
|
status: String(page.status),
|
|
597
888
|
locale: String(page.locale ?? "en"),
|
|
889
|
+
version: typeof page.version === "number" ? page.version : 1,
|
|
598
890
|
contentType,
|
|
599
891
|
translationGroupId: page.translationGroupId ?? null,
|
|
600
892
|
translations,
|
|
@@ -635,15 +927,44 @@ async function assertRegionAllows(db, page, region, blockTypeSlug) {
|
|
|
635
927
|
throw new BadRequest(`block type '${blockTypeSlug}' is not allowed in region '${region}'`);
|
|
636
928
|
}
|
|
637
929
|
}
|
|
930
|
+
/** Secret preference order. `PREVIEW_SECRET` lets an operator rotate preview links without
|
|
931
|
+
* invalidating every signed file url, but falling back keeps the common case zero-config. */
|
|
932
|
+
export const PREVIEW_SECRET_NAMES = ["PREVIEW_SECRET", "FILES_SECRET", "AUTH_SECRET"];
|
|
933
|
+
/** Resolve the preview signing secret, or `undefined` when nothing usable is configured. */
|
|
934
|
+
export function previewSecret(env) {
|
|
935
|
+
return resolveSecret(env, PREVIEW_SECRET_NAMES);
|
|
936
|
+
}
|
|
937
|
+
const previewUnconfigured = () => new PramenError("page preview is not configured (set a strong PREVIEW_SECRET, FILES_SECRET or AUTH_SECRET)", 503, "unavailable");
|
|
938
|
+
/** The viewer roles for a given handler config — `editorRoles ∪ reviewerRoles`, computed
|
|
939
|
+
* exactly as `createCmsHandlers` computes them.
|
|
940
|
+
*
|
|
941
|
+
* Exported so `cmsRoutes()` cannot drift from `createCmsHandlers()`: pass the SAME options
|
|
942
|
+
* object to both. Configuring the two independently was how the preview route ended up
|
|
943
|
+
* presenting an identity neither the handler gate nor the ACL accepted — and a partial
|
|
944
|
+
* customization still worked, so the failure appeared only for the app that had most
|
|
945
|
+
* carefully renamed its roles. */
|
|
946
|
+
export function viewerRolesOf(opts = {}) {
|
|
947
|
+
return [...new Set([...(opts.editorRoles ?? ["editor", "admin"]), ...(opts.reviewerRoles ?? ["reviewer", "admin"])])];
|
|
948
|
+
}
|
|
949
|
+
/** The viewer roles for the DEFAULT handler config. */
|
|
950
|
+
export const DEFAULT_VIEWER_ROLES = viewerRolesOf();
|
|
951
|
+
/** Where a preview link is redeemed. Spread `cmsRoutes()` into `app.routes` to serve it. */
|
|
952
|
+
export const PREVIEW_PATH = "/cms/preview";
|
|
953
|
+
/** Default preview-link lifetime: 1 hour. Long enough to share and open, short enough that
|
|
954
|
+
* a link pasted into a public channel stops working the same afternoon. */
|
|
955
|
+
export const DEFAULT_PREVIEW_TTL_SECONDS = 3600;
|
|
638
956
|
/** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
|
|
639
957
|
* gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
|
|
640
958
|
export function createCmsHandlers(opts = {}) {
|
|
641
959
|
const editorRoles = opts.editorRoles ?? ["editor", "admin"];
|
|
642
960
|
const editor = { auth: editorRoles };
|
|
643
961
|
const mediaMaxSize = opts.mediaMaxSize ?? 25_000_000;
|
|
644
|
-
const
|
|
962
|
+
const locales = opts.locales && opts.locales.length > 0 ? [...opts.locales] : ["en"];
|
|
963
|
+
const defaultLocale = locales[0];
|
|
645
964
|
const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
|
|
646
965
|
const reviewer = { auth: reviewerRoles };
|
|
966
|
+
const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
|
967
|
+
const rtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
|
|
647
968
|
// Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
|
|
648
969
|
// content type/blocks before approving). Read/preview handlers use this; writes stay editor.
|
|
649
970
|
const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
|
|
@@ -652,11 +973,83 @@ export function createCmsHandlers(opts = {}) {
|
|
|
652
973
|
const actorOf = (ctx) => (typeof ctx.identity?.userId === "string" ? ctx.identity.userId : null);
|
|
653
974
|
// Append an audit row for a workflow transition (synchronous, in the mutation's txn).
|
|
654
975
|
const writeAudit = (db, e) => 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 });
|
|
976
|
+
const mediaIdInput = {
|
|
977
|
+
input: (raw) => {
|
|
978
|
+
const o = asObj(raw);
|
|
979
|
+
if (typeof o.id !== "string" || o.id === "")
|
|
980
|
+
throw new BadRequest("id is required");
|
|
981
|
+
return { id: o.id };
|
|
982
|
+
},
|
|
983
|
+
};
|
|
984
|
+
/** Mark a table changed after a RAW `exec` write.
|
|
985
|
+
*
|
|
986
|
+
* `Db.exec` is the one write path that does not record `touched`, so the DO never
|
|
987
|
+
* broadcasts and every live subscriber keeps showing the pre-write state — a restored
|
|
988
|
+
* page stays missing from an open page list, a purged one stays present. `deletePage`
|
|
989
|
+
* goes through the ORM and DOES broadcast, so the staleness was asymmetric and read
|
|
990
|
+
* like a lost write. */
|
|
991
|
+
const markChanged = (db, ...tables) => {
|
|
992
|
+
const touched = db.touched;
|
|
993
|
+
if (touched)
|
|
994
|
+
for (const t of tables)
|
|
995
|
+
touched.add(t);
|
|
996
|
+
};
|
|
997
|
+
// --- optimistic concurrency ------------------------------------------------
|
|
998
|
+
//
|
|
999
|
+
// On the DO — the default store — a read-then-write inside one mutation is atomic: the
|
|
1000
|
+
// Durable Object is a single writer and DoSqliteDriver.exec is synchronous. The EDITORS
|
|
1001
|
+
// are not serialized, though: two people on the same page means last save wins, silently,
|
|
1002
|
+
// with no signal to the loser. Passing back the `version` you read turns that into a 409.
|
|
1003
|
+
//
|
|
1004
|
+
// CAVEAT — the D1 store has no interactive transaction (D1Driver.transaction is a
|
|
1005
|
+
// pass-through), so two requests in the same millisecond can both read and both write.
|
|
1006
|
+
// The guard still catches the human-scale editor race; it is not a hard mutex there.
|
|
1007
|
+
//
|
|
1008
|
+
// Optional by design: omitting `expectedVersion` keeps last-write-wins, so nothing breaks.
|
|
1009
|
+
const nextVersion = (row, expected, label) => {
|
|
1010
|
+
// Do NOT default a missing version to 1. Under a field-restricted read grant that
|
|
1011
|
+
// projected the column away, `current` would be 1 forever: a client that legitimately
|
|
1012
|
+
// read version 7 gets a permanent unresolvable 409, and an unguarded save then LOWERS
|
|
1013
|
+
// the stored version, so a genuinely stale write is accepted later.
|
|
1014
|
+
if (typeof row.version !== "number") {
|
|
1015
|
+
// Log the actionable detail, return a generic 500 — PramenError's message goes to the
|
|
1016
|
+
// caller verbatim, so naming the column would leak the schema and ACL shape.
|
|
1017
|
+
console.error(`pramen/cms: ${label} has no readable version — grant read on the \`version\` column`);
|
|
1018
|
+
throw new Error("version unavailable");
|
|
1019
|
+
}
|
|
1020
|
+
const current = row.version;
|
|
1021
|
+
if (expected !== undefined && expected !== current) {
|
|
1022
|
+
throw new Conflict(`${label} was changed by someone else (you have version ${expected}, current is ${current}) — reload and reapply your edit`);
|
|
1023
|
+
}
|
|
1024
|
+
return current + 1;
|
|
1025
|
+
};
|
|
1026
|
+
const versionInput = (o) => {
|
|
1027
|
+
if (o.expectedVersion === undefined)
|
|
1028
|
+
return;
|
|
1029
|
+
if (typeof o.expectedVersion !== "number" || !Number.isInteger(o.expectedVersion)) {
|
|
1030
|
+
throw new BadRequest("expectedVersion must be an integer");
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
const pageIdInput = {
|
|
1034
|
+
input: (raw) => {
|
|
1035
|
+
const o = asObj(raw);
|
|
1036
|
+
if (typeof o.pageId !== "string" || o.pageId === "")
|
|
1037
|
+
throw new BadRequest("pageId is required");
|
|
1038
|
+
return { pageId: o.pageId };
|
|
1039
|
+
},
|
|
1040
|
+
};
|
|
655
1041
|
// (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
|
|
656
1042
|
const assertSlugFree = async (db, slug, locale, exceptId) => {
|
|
657
|
-
const rows = await db.exec("SELECT id FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
|
|
658
|
-
if (rows[0] && String(rows[0].id) !== exceptId)
|
|
1043
|
+
const rows = await db.exec("SELECT id, deletedAt FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
|
|
1044
|
+
if (rows[0] && String(rows[0].id) !== exceptId) {
|
|
1045
|
+
// A trashed page keeps its slug until purged (the (slug, locale) unique index is a
|
|
1046
|
+
// DB constraint, not advisory). Say so, rather than leave the caller hunting for a
|
|
1047
|
+
// page they cannot see.
|
|
1048
|
+
if (rows[0].deletedAt != null) {
|
|
1049
|
+
throw new BadRequest(`slug '${slug}' is held by a page in the trash for locale '${locale}' — restore or purge it first`);
|
|
1050
|
+
}
|
|
659
1051
|
throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
|
|
1052
|
+
}
|
|
660
1053
|
};
|
|
661
1054
|
const TASK_PUBLISH = "cms:publish";
|
|
662
1055
|
const TASK_UNPUBLISH = "cms:unpublish";
|
|
@@ -828,18 +1221,20 @@ export function createCmsHandlers(opts = {}) {
|
|
|
828
1221
|
return { id: o.id, alt: typeof o.alt === "string" ? o.alt : null };
|
|
829
1222
|
},
|
|
830
1223
|
}),
|
|
831
|
-
/**
|
|
832
|
-
*
|
|
1224
|
+
/** Trash a media row. The R2 OBJECT IS KEPT — deleting the bytes here would make
|
|
1225
|
+
* `restoreMedia` a lie, and a block still referencing the id would render a dead url
|
|
1226
|
+
* with no way back. `purgeMedia` is what drops both — and `listTrash` is how you find
|
|
1227
|
+
* the id again, since every ACL-scoped read hides it from here on.
|
|
1228
|
+
*
|
|
1229
|
+
* (Automatic orphan sweeping — media no longer referenced by any block — is still
|
|
1230
|
+
* future work; refs live inside opaque block JSON.) */
|
|
833
1231
|
deleteMedia: mutation(async (ctx, input) => {
|
|
834
1232
|
const db = cdb(ctx);
|
|
835
1233
|
const rows = await db.find({ from: "cms_media", where: { id: input.id }, limit: 1 });
|
|
836
1234
|
const media = rows[0];
|
|
837
1235
|
if (!media)
|
|
838
1236
|
throw notFound("media");
|
|
839
|
-
|
|
840
|
-
await db.delete("cms_media", input.id);
|
|
841
|
-
if (key)
|
|
842
|
-
await ctx.files.delete(key).catch(() => { });
|
|
1237
|
+
await db.update("cms_media", input.id, { deletedAt: new Date().toISOString() });
|
|
843
1238
|
return { ok: true };
|
|
844
1239
|
}, {
|
|
845
1240
|
...editor,
|
|
@@ -850,6 +1245,33 @@ export function createCmsHandlers(opts = {}) {
|
|
|
850
1245
|
return o;
|
|
851
1246
|
},
|
|
852
1247
|
}),
|
|
1248
|
+
restoreMedia: mutation(async (ctx, input) => {
|
|
1249
|
+
const db = cdb(ctx);
|
|
1250
|
+
const rows = await db.exec("SELECT id FROM cms_media WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.id);
|
|
1251
|
+
if (!rows[0])
|
|
1252
|
+
throw notFound("trashed media");
|
|
1253
|
+
await db.exec("UPDATE cms_media SET deletedAt = NULL WHERE id = ?", input.id);
|
|
1254
|
+
markChanged(db, "cms_media");
|
|
1255
|
+
return { ok: true };
|
|
1256
|
+
}, { ...editor, ...mediaIdInput }),
|
|
1257
|
+
/** Permanently remove trashed media — the row AND the R2 object. Reviewer-gated and
|
|
1258
|
+
* irreversible; the blob is gone. */
|
|
1259
|
+
purgeMedia: mutation(async (ctx, input) => {
|
|
1260
|
+
const db = cdb(ctx);
|
|
1261
|
+
const rows = await db.exec("SELECT id, file FROM cms_media WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.id);
|
|
1262
|
+
const media = rows[0];
|
|
1263
|
+
if (!media)
|
|
1264
|
+
throw notFound("trashed media"); // purging live media is refused — trash it first
|
|
1265
|
+
// `file` comes back raw from exec (the object↔JSON codec sits on the ORM path, not
|
|
1266
|
+
// this one), so parse it before reaching for the key.
|
|
1267
|
+
const file = typeof media.file === "string" ? JSON.parse(media.file) : asObj(media.file);
|
|
1268
|
+
const key = String(file.key ?? "");
|
|
1269
|
+
await db.exec("DELETE FROM cms_media WHERE id = ?", input.id);
|
|
1270
|
+
markChanged(db, "cms_media");
|
|
1271
|
+
if (key)
|
|
1272
|
+
await ctx.files.delete(key).catch(() => { });
|
|
1273
|
+
return { ok: true };
|
|
1274
|
+
}, { ...reviewer, ...mediaIdInput }),
|
|
853
1275
|
listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
|
|
854
1276
|
getContentType: query(async (ctx, input) => {
|
|
855
1277
|
const rows = await cdb(ctx).find({ from: "cms_content_types", where: { id: input.id }, limit: 1 });
|
|
@@ -880,7 +1302,11 @@ export function createCmsHandlers(opts = {}) {
|
|
|
880
1302
|
/** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
|
|
881
1303
|
updatePageSeo: mutation(async (ctx, input) => {
|
|
882
1304
|
const db = cdb(ctx);
|
|
883
|
-
|
|
1305
|
+
// Read first so the version can be compared; this patched blind before.
|
|
1306
|
+
const seoRows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1307
|
+
if (!seoRows[0])
|
|
1308
|
+
throw notFound("page");
|
|
1309
|
+
const patch = { updatedAt: nowStamp(), version: nextVersion(seoRows[0], input.expectedVersion, "this page") };
|
|
884
1310
|
for (const k of ["metaTitle", "metaDescription", "canonicalUrl", "robots", "ogTitle", "ogDescription", "ogImage"]) {
|
|
885
1311
|
if (k in input)
|
|
886
1312
|
patch[k] = input[k];
|
|
@@ -897,6 +1323,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
897
1323
|
const o = asObj(raw);
|
|
898
1324
|
if (typeof o.pageId !== "string")
|
|
899
1325
|
throw new BadRequest("pageId is required");
|
|
1326
|
+
versionInput(o);
|
|
900
1327
|
return o;
|
|
901
1328
|
},
|
|
902
1329
|
}),
|
|
@@ -905,14 +1332,14 @@ export function createCmsHandlers(opts = {}) {
|
|
|
905
1332
|
* Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
|
|
906
1333
|
* page record itself, which was previously only settable at createPage. A slug/locale
|
|
907
1334
|
* change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
|
|
908
|
-
* +
|
|
1335
|
+
* + normalized against the content type's fieldsSchema, exactly like createPage. */
|
|
909
1336
|
updatePage: mutation(async (ctx, input) => {
|
|
910
1337
|
const db = cdb(ctx);
|
|
911
1338
|
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
912
1339
|
const page = rows[0];
|
|
913
1340
|
if (!page)
|
|
914
1341
|
throw notFound("page");
|
|
915
|
-
const patch = { updatedAt: nowStamp() };
|
|
1342
|
+
const patch = { updatedAt: nowStamp(), version: nextVersion(page, input.expectedVersion, "this page") };
|
|
916
1343
|
if (input.title !== undefined)
|
|
917
1344
|
patch.title = input.title;
|
|
918
1345
|
if (input.slug !== undefined || input.locale !== undefined) {
|
|
@@ -927,8 +1354,9 @@ export function createCmsHandlers(opts = {}) {
|
|
|
927
1354
|
if (input.fields !== undefined) {
|
|
928
1355
|
const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
|
|
929
1356
|
const schema = ctRows[0]?.fieldsSchema;
|
|
930
|
-
|
|
931
|
-
|
|
1357
|
+
// Same whole-bag autosave as updateBlock — tolerate a stored legacy value.
|
|
1358
|
+
validateFields(schema, input.fields, "page.fields", { requireRequired: false, legacyBaseline: asObj(page.fields) });
|
|
1359
|
+
patch.fields = normalizeFields(schema, input.fields, rtSchema);
|
|
932
1360
|
}
|
|
933
1361
|
const updated = await db.update("cms_pages", input.pageId, patch);
|
|
934
1362
|
if (!updated)
|
|
@@ -944,6 +1372,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
944
1372
|
if (o[k] !== undefined && typeof o[k] !== "string")
|
|
945
1373
|
throw new BadRequest(`${k} must be a string`);
|
|
946
1374
|
}
|
|
1375
|
+
versionInput(o);
|
|
947
1376
|
return o;
|
|
948
1377
|
},
|
|
949
1378
|
}),
|
|
@@ -955,7 +1384,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
955
1384
|
if (!ct)
|
|
956
1385
|
throw new BadRequest("unknown content type");
|
|
957
1386
|
validateFields(ct.fieldsSchema, input.fields ?? {}, "page.fields", { requireRequired: false });
|
|
958
|
-
const cleanPageFields =
|
|
1387
|
+
const cleanPageFields = normalizeFields(ct.fieldsSchema, input.fields ?? {}, rtSchema);
|
|
959
1388
|
const locale = input.locale ?? defaultLocale;
|
|
960
1389
|
await assertSlugFree(db, input.slug, locale);
|
|
961
1390
|
const page = await db.insert("cms_pages", {
|
|
@@ -978,7 +1407,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
978
1407
|
throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
|
|
979
1408
|
await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
|
|
980
1409
|
validateFields(bts[0].fieldsSchema, d.fields ?? {}, "", { requireRequired: false });
|
|
981
|
-
const cleanDefault =
|
|
1410
|
+
const cleanDefault = normalizeFields(bts[0].fieldsSchema, d.fields ?? {}, rtSchema);
|
|
982
1411
|
const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
|
|
983
1412
|
const position = await nextPosition(db, String(page.id), d.region);
|
|
984
1413
|
await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
|
|
@@ -1019,9 +1448,16 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1019
1448
|
group = crypto.randomUUID();
|
|
1020
1449
|
await db.update("cms_pages", String(src.id), { translationGroupId: group });
|
|
1021
1450
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1451
|
+
// Raw exec, like assertSlugFree: a check-then-act uniqueness guard must see TRASHED
|
|
1452
|
+
// rows too. Through ctx.db the read scope hides them, so trashing a `cs` translation
|
|
1453
|
+
// let a second one be created, and restoring the first left two live `cs` pages in
|
|
1454
|
+
// one group — two <link rel="alternate" hreflang="cs"> on every sibling.
|
|
1455
|
+
const existing = await db.exec("SELECT id, deletedAt FROM cms_pages WHERE translationGroupId = ? AND locale = ? LIMIT 1", group, input.locale);
|
|
1456
|
+
if (existing[0]) {
|
|
1457
|
+
throw new BadRequest(existing[0].deletedAt != null
|
|
1458
|
+
? `a '${input.locale}' translation exists in the trash — restore or purge it first`
|
|
1459
|
+
: `a '${input.locale}' translation already exists`);
|
|
1460
|
+
}
|
|
1025
1461
|
const slug = input.slug ?? String(src.slug);
|
|
1026
1462
|
await assertSlugFree(db, slug, input.locale);
|
|
1027
1463
|
return db.insert("cms_pages", {
|
|
@@ -1064,9 +1500,21 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1064
1500
|
return o;
|
|
1065
1501
|
},
|
|
1066
1502
|
}),
|
|
1067
|
-
/**
|
|
1503
|
+
/** What this deployment supports, for an editor to render against — the pages-side
|
|
1504
|
+
* counterpart to `listCollections`' `supports: [...]`.
|
|
1505
|
+
*
|
|
1506
|
+
* The editor asks the SERVER what exists rather than being told by its own /config.js:
|
|
1507
|
+
* a client flag can hide a control but cannot make the data right, and the two drift
|
|
1508
|
+
* the moment someone adds a locale. `multilingual` is the derived answer to the only
|
|
1509
|
+
* question the UI actually asks, so each surface doesn't re-derive it from the list. */
|
|
1510
|
+
listCmsCapabilities: query(() => ({ locales, defaultLocale, multilingual: locales.length > 1 }), viewer),
|
|
1511
|
+
/** Distinct locales present across all pages. NOTE: a DATA query — what is in the
|
|
1512
|
+
* store — not configuration. `listCmsCapabilities().locales` is what the deployment
|
|
1513
|
+
* declares; these two differ while a locale is declared but not yet authored. */
|
|
1068
1514
|
listLocales: query(async (ctx) => {
|
|
1069
|
-
|
|
1515
|
+
// Raw exec bypasses the ACL, so the trash filter has to be written out by hand —
|
|
1516
|
+
// otherwise the editor's locale switcher offers a locale with zero live pages.
|
|
1517
|
+
const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages WHERE deletedAt IS NULL ORDER BY locale");
|
|
1070
1518
|
return rows.map((r) => String(r.locale ?? "en"));
|
|
1071
1519
|
}, viewer),
|
|
1072
1520
|
// ---- blocks & placement ----
|
|
@@ -1082,7 +1530,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1082
1530
|
const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
|
|
1083
1531
|
await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
|
|
1084
1532
|
validateFields(bt.fieldsSchema, input.fields ?? {}, "", { requireRequired: false });
|
|
1085
|
-
const cleanFields =
|
|
1533
|
+
const cleanFields = normalizeFields(bt.fieldsSchema, input.fields ?? {}, rtSchema);
|
|
1086
1534
|
const block = await db.insert("cms_blocks", {
|
|
1087
1535
|
typeId: bt.id,
|
|
1088
1536
|
title: input.title ?? null,
|
|
@@ -1129,8 +1577,12 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1129
1577
|
await assertRegionAllows(db, page, input.region, slug);
|
|
1130
1578
|
let cleanOverrides = input.overrides ?? null;
|
|
1131
1579
|
if (input.overrides !== undefined) {
|
|
1132
|
-
|
|
1133
|
-
|
|
1580
|
+
// The merged bag includes the block's OWN stored fields, which may predate Portable
|
|
1581
|
+
// Text. Tolerate a legacy string there so an untouched legacy block can still be
|
|
1582
|
+
// placed; the overrides themselves are new input and stay strict below.
|
|
1583
|
+
validateFields(bts[0]?.fieldsSchema, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false, legacyBaseline: asObj(block.fields) });
|
|
1584
|
+
validateFields(bts[0]?.fieldsSchema, input.overrides, "", { requireRequired: false });
|
|
1585
|
+
cleanOverrides = normalizeFields(bts[0]?.fieldsSchema, input.overrides, rtSchema);
|
|
1134
1586
|
}
|
|
1135
1587
|
const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
|
|
1136
1588
|
return db.insert("cms_page_blocks", {
|
|
@@ -1171,13 +1623,19 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1171
1623
|
const block = rows[0];
|
|
1172
1624
|
if (!block)
|
|
1173
1625
|
throw notFound("block");
|
|
1626
|
+
// Conflict first, like updatePage: a stale write carrying invalid fields should say
|
|
1627
|
+
// "someone else changed this", not 400 on content the caller is about to discard.
|
|
1628
|
+
const blockVersion = nextVersion(block, input.expectedVersion, "this block");
|
|
1174
1629
|
let cleanFields = input.fields;
|
|
1175
1630
|
if (input.fields !== undefined) {
|
|
1176
1631
|
const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
|
|
1177
|
-
|
|
1178
|
-
|
|
1632
|
+
// The editor autosaves the WHOLE fields bag ~800ms after any edit, so a legacy
|
|
1633
|
+
// richtext value the author never touched rides along with an unrelated change.
|
|
1634
|
+
// Rejecting it would 400 on every keystroke and make the block unsaveable.
|
|
1635
|
+
validateFields(bt[0]?.fieldsSchema, input.fields, "", { requireRequired: false, legacyBaseline: asObj(block.fields) });
|
|
1636
|
+
cleanFields = normalizeFields(bt[0]?.fieldsSchema, input.fields, rtSchema);
|
|
1179
1637
|
}
|
|
1180
|
-
const patch = { updatedAt: nowStamp() };
|
|
1638
|
+
const patch = { updatedAt: nowStamp(), version: blockVersion };
|
|
1181
1639
|
if (cleanFields !== undefined)
|
|
1182
1640
|
patch.fields = cleanFields;
|
|
1183
1641
|
if (input.title !== undefined)
|
|
@@ -1189,6 +1647,7 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1189
1647
|
const o = asObj(raw);
|
|
1190
1648
|
if (typeof o.blockId !== "string")
|
|
1191
1649
|
throw new BadRequest("blockId is required");
|
|
1650
|
+
versionInput(o);
|
|
1192
1651
|
return o;
|
|
1193
1652
|
},
|
|
1194
1653
|
}),
|
|
@@ -1414,10 +1873,168 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1414
1873
|
},
|
|
1415
1874
|
}),
|
|
1416
1875
|
// ---- public content API ----
|
|
1876
|
+
/** Mint a signed, self-expiring preview link for one page. Editor-gated to MINT —
|
|
1877
|
+
* anyone holding the resulting link can redeem it, which is the point. */
|
|
1878
|
+
signPagePreview: query(async (ctx, input) => {
|
|
1879
|
+
const secret = previewSecret(ctx.env);
|
|
1880
|
+
if (!secret)
|
|
1881
|
+
throw previewUnconfigured(); // fail closed — never mint a forgeable link
|
|
1882
|
+
// The redeem route always reaches a Durable Object (callPrivileged -> PRAMEN.get); it
|
|
1883
|
+
// has no notion of `x-pramen-store`. Minting on the D1 store therefore produces a
|
|
1884
|
+
// link that 404s forever while the editor reports success — refuse instead of
|
|
1885
|
+
// handing out a token that cannot work.
|
|
1886
|
+
if (ctx.store === "d1")
|
|
1887
|
+
throw new PramenError("page preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
|
|
1888
|
+
const db = cdb(ctx);
|
|
1889
|
+
// Read the page through the ACL first: minting a link is granting access to it, so a
|
|
1890
|
+
// caller who cannot read the page must not be able to mint a link that can.
|
|
1891
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1892
|
+
const page = rows[0];
|
|
1893
|
+
if (!page)
|
|
1894
|
+
throw notFound("page");
|
|
1895
|
+
const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
|
|
1896
|
+
const exp = Math.floor(Date.now() / 1000) + ttl;
|
|
1897
|
+
// Server-resolved, never caller-supplied — so the tenant inside the signature
|
|
1898
|
+
// cannot be steered by whoever asks for the link.
|
|
1899
|
+
const tenant = ctx.tenant;
|
|
1900
|
+
const token = await signToken({ t: tenant, p: String(page.id), exp }, secret);
|
|
1901
|
+
// RELATIVE, like signed file urls — the client resolves it against the CMS origin.
|
|
1902
|
+
return { url: `${PREVIEW_PATH}?token=${encodeURIComponent(token)}`, token, expiresAt: exp * 1000 };
|
|
1903
|
+
}, {
|
|
1904
|
+
...editor,
|
|
1905
|
+
input: (raw) => {
|
|
1906
|
+
const o = asObj(raw);
|
|
1907
|
+
// Unvalidated, a non-string pageId reached the query compiler and surfaced as a
|
|
1908
|
+
// 500, and a string expiresIn made exp NaN — minting a link that always 403s,
|
|
1909
|
+
// with nothing anywhere to explain why.
|
|
1910
|
+
if (typeof o.pageId !== "string" || o.pageId === "")
|
|
1911
|
+
throw new BadRequest("pageId is required");
|
|
1912
|
+
if (o.expiresIn !== undefined && (typeof o.expiresIn !== "number" || !Number.isFinite(o.expiresIn))) {
|
|
1913
|
+
throw new BadRequest("expiresIn must be a number of seconds");
|
|
1914
|
+
}
|
|
1915
|
+
return o;
|
|
1916
|
+
},
|
|
1917
|
+
}),
|
|
1918
|
+
/** Assemble a page's LIVE draft by id. Not the redemption endpoint — that is the public
|
|
1919
|
+
* `GET /cms/preview` route, which verifies the token and then calls this privileged.
|
|
1920
|
+
* Role-gated so it is not an anonymous back door on the /rpc surface. */
|
|
1921
|
+
getPagePreview: query(async (ctx, input) => {
|
|
1922
|
+
const db = cdb(ctx);
|
|
1923
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1924
|
+
const page = rows[0];
|
|
1925
|
+
if (!page)
|
|
1926
|
+
throw notFound("page");
|
|
1927
|
+
const assembled = await assembleLive(db, page);
|
|
1928
|
+
assembled.isPreview = true;
|
|
1929
|
+
return assembled;
|
|
1930
|
+
}, {
|
|
1931
|
+
...viewer,
|
|
1932
|
+
input: (raw) => {
|
|
1933
|
+
const o = asObj(raw);
|
|
1934
|
+
if (typeof o.pageId !== "string" || o.pageId === "")
|
|
1935
|
+
throw new BadRequest("pageId is required");
|
|
1936
|
+
return { pageId: o.pageId };
|
|
1937
|
+
},
|
|
1938
|
+
}),
|
|
1417
1939
|
/** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
|
|
1418
1940
|
* snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
|
|
1419
1941
|
* `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
|
|
1420
1942
|
* to the configured default locale; a slug is unique per locale. */
|
|
1943
|
+
// --- trash: soft delete, restore, purge ---------------------------------
|
|
1944
|
+
//
|
|
1945
|
+
// A page had NO delete handler at all before this: once created it could only be
|
|
1946
|
+
// unpublished, never removed. Delete is therefore introduced already soft — the row
|
|
1947
|
+
// stays, `deletedAt` is stamped, and the ACL's read scope hides it everywhere.
|
|
1948
|
+
//
|
|
1949
|
+
// A trashed page KEEPS ITS SLUG. `(slug, locale)` is a DB unique constraint, so the
|
|
1950
|
+
// alternatives were mangling the stored slug on delete or dropping the constraint —
|
|
1951
|
+
// both worse than telling the caller plainly that the slug is in the trash. Purging
|
|
1952
|
+
// frees it.
|
|
1953
|
+
deletePage: mutation(async (ctx, input) => {
|
|
1954
|
+
const db = cdb(ctx);
|
|
1955
|
+
const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
|
|
1956
|
+
if (!rows[0])
|
|
1957
|
+
throw notFound("page"); // already trashed reads as absent — the scope hides it
|
|
1958
|
+
const now = new Date().toISOString();
|
|
1959
|
+
// Clear the schedule. The publish/unpublish tasks run on the SYSTEM task context,
|
|
1960
|
+
// where the ACL is bypassed entirely — so the `deletedAt IS NULL` read scope does
|
|
1961
|
+
// NOT protect them, and a page trashed before its scheduled time was republished,
|
|
1962
|
+
// publicly live, with a fresh revision and nobody pressing publish. Clearing the
|
|
1963
|
+
// timestamps makes the tasks' existing intent-token check reject both for free.
|
|
1964
|
+
await db.update("cms_pages", input.pageId, { deletedAt: now, updatedAt: now, scheduledAt: null, unpublishAt: null });
|
|
1965
|
+
await writeAudit(db, { pageId: input.pageId, action: "delete", from: String(rows[0].status ?? ""), to: "trashed", actor: actorOf(ctx) });
|
|
1966
|
+
return { ok: true, deletedAt: now };
|
|
1967
|
+
}, { ...editor, ...pageIdInput }),
|
|
1968
|
+
/** What is currently in the trash — pages AND media. Read with `ctx.db.exec` because
|
|
1969
|
+
* the ACL read scope hides exactly these rows: that is the scope doing its job, not a
|
|
1970
|
+
* hole to patch.
|
|
1971
|
+
*
|
|
1972
|
+
* Media has to be listed here or it becomes UNREACHABLE the moment it is trashed —
|
|
1973
|
+
* `listMedia`/`getMedia` are ACL-scoped, so neither `restoreMedia` nor `purgeMedia`
|
|
1974
|
+
* could ever be called with its id again, while `/media/<key>` kept serving the bytes
|
|
1975
|
+
* (that route streams from R2 with no DB lookup at all). */
|
|
1976
|
+
listTrash: query(async (ctx, input) => {
|
|
1977
|
+
// Truncate like listMedia/listPageAudit — a fractional LIMIT reaches SQLite and 500s,
|
|
1978
|
+
// and any client computing `total / pages` sends one.
|
|
1979
|
+
const limit = Math.min(Math.max(Math.trunc(Number(input.limit)) || 50, 1), 200);
|
|
1980
|
+
const db = cdb(ctx);
|
|
1981
|
+
const pages = await db.exec("SELECT id, title, slug, locale, status, deletedAt FROM cms_pages WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC LIMIT ?", limit);
|
|
1982
|
+
const rawMedia = await db.exec("SELECT id, alt, file, deletedAt FROM cms_media WHERE deletedAt IS NOT NULL ORDER BY deletedAt DESC LIMIT ?", limit);
|
|
1983
|
+
// The fileRef object<->JSON codec sits on the ORM path, not raw exec — parse here or
|
|
1984
|
+
// a trash UI reusing the media card renders `/media/undefined`.
|
|
1985
|
+
const media = rawMedia.map((m) => ({ ...m, file: typeof m.file === "string" ? JSON.parse(m.file) : m.file }));
|
|
1986
|
+
return { pages, media };
|
|
1987
|
+
}, { ...viewer, input: (raw) => {
|
|
1988
|
+
const o = asObj(raw);
|
|
1989
|
+
if (o.limit !== undefined && typeof o.limit !== "number")
|
|
1990
|
+
throw new BadRequest("limit must be a number");
|
|
1991
|
+
return o;
|
|
1992
|
+
} }),
|
|
1993
|
+
restorePage: mutation(async (ctx, input) => {
|
|
1994
|
+
const db = cdb(ctx);
|
|
1995
|
+
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);
|
|
1996
|
+
const page = rows[0];
|
|
1997
|
+
if (!page)
|
|
1998
|
+
throw notFound("trashed page");
|
|
1999
|
+
// Defensive: the trashed row still occupies the (slug, locale) unique index, so in
|
|
2000
|
+
// practice nothing can have taken the slug. Kept so a future change that DOES free
|
|
2001
|
+
// the slug on delete surfaces as a clean 400 rather than a constraint violation.
|
|
2002
|
+
await assertSlugFree(db, String(page.slug), String(page.locale), String(page.id));
|
|
2003
|
+
await db.exec("UPDATE cms_pages SET deletedAt = NULL, updatedAt = ? WHERE id = ?", new Date().toISOString(), input.pageId);
|
|
2004
|
+
markChanged(db, "cms_pages");
|
|
2005
|
+
await writeAudit(db, { pageId: input.pageId, action: "restore", from: "trashed", to: String(page.status ?? ""), actor: actorOf(ctx) });
|
|
2006
|
+
// deletePage had to clear any schedule (the publish task runs SYSTEM-scoped, outside
|
|
2007
|
+
// the read scope). Restore cannot know what it was, so SAY so — otherwise a promo
|
|
2008
|
+
// page due to auto-unpublish comes back live forever with nothing in the audit trail.
|
|
2009
|
+
return { ok: true, scheduleCleared: page.scheduledAt != null || page.unpublishAt != null };
|
|
2010
|
+
}, { ...editor, ...pageIdInput }),
|
|
2011
|
+
/** Permanently remove a trashed page and everything hanging off it. Reviewer-gated:
|
|
2012
|
+
* this is the only irreversible operation in the CMS. */
|
|
2013
|
+
purgePage: mutation(async (ctx, input) => {
|
|
2014
|
+
const db = cdb(ctx);
|
|
2015
|
+
const rows = await db.exec("SELECT id FROM cms_pages WHERE id = ? AND deletedAt IS NOT NULL LIMIT 1", input.pageId);
|
|
2016
|
+
if (!rows[0])
|
|
2017
|
+
throw notFound("trashed page"); // purging a LIVE page is refused — trash it first
|
|
2018
|
+
// Placements, revisions and audit rows are logical relations (no FK cascade), so
|
|
2019
|
+
// clear them explicitly or they outlive the page as orphans.
|
|
2020
|
+
//
|
|
2021
|
+
// The BLOCKS themselves need the same treatment, and it has to happen before the
|
|
2022
|
+
// placements go: a non-reusable block used only by this page becomes unreachable
|
|
2023
|
+
// once its last placement is deleted (there is no listBlocks, and removeBlock needs
|
|
2024
|
+
// a pageBlockId that no longer exists). Mirrors removeBlock's own GC.
|
|
2025
|
+
const doomed = await db.exec(`SELECT b.id AS id FROM cms_blocks b
|
|
2026
|
+
JOIN cms_page_blocks pb ON pb.blockId = b.id
|
|
2027
|
+
WHERE pb.pageId = ? AND b.isReusable = 0
|
|
2028
|
+
AND NOT EXISTS (SELECT 1 FROM cms_page_blocks o WHERE o.blockId = b.id AND o.pageId <> ?)`, input.pageId, input.pageId);
|
|
2029
|
+
await db.exec("DELETE FROM cms_page_blocks WHERE pageId = ?", input.pageId);
|
|
2030
|
+
for (const row of doomed)
|
|
2031
|
+
await db.exec("DELETE FROM cms_blocks WHERE id = ?", String(row.id));
|
|
2032
|
+
await db.exec("DELETE FROM cms_page_revisions WHERE pageId = ?", input.pageId);
|
|
2033
|
+
await db.exec("DELETE FROM cms_audit WHERE pageId = ?", input.pageId);
|
|
2034
|
+
await db.exec("DELETE FROM cms_pages WHERE id = ?", input.pageId);
|
|
2035
|
+
markChanged(db, "cms_pages", "cms_page_blocks", "cms_blocks", "cms_page_revisions", "cms_audit");
|
|
2036
|
+
return { ok: true };
|
|
2037
|
+
}, { ...reviewer, ...pageIdInput }),
|
|
1421
2038
|
getPage: query(async (ctx, input) => {
|
|
1422
2039
|
const db = cdb(ctx);
|
|
1423
2040
|
// Preview is an editor capability — gate it before the lookup so a non-editor gets a
|
|
@@ -1429,8 +2046,11 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1429
2046
|
const page = rows[0];
|
|
1430
2047
|
if (!page)
|
|
1431
2048
|
throw notFound("page"); // also the anonymous-vs-draft case: ACL yields no row
|
|
1432
|
-
if (input.preview)
|
|
1433
|
-
|
|
2049
|
+
if (input.preview) {
|
|
2050
|
+
const live = await assembleLive(db, page);
|
|
2051
|
+
live.isPreview = true; // same flag the token route sets, so a banner works either way
|
|
2052
|
+
return live;
|
|
2053
|
+
}
|
|
1434
2054
|
// Public path: serve the page's current published revision snapshot (selected by the
|
|
1435
2055
|
// page's `currentRevisionId` pointer — deterministic, unlike ordering by a
|
|
1436
2056
|
// second-precision timestamp). We do NOT assemble live here: anonymous has no read
|
|
@@ -1450,6 +2070,11 @@ export function createCmsHandlers(opts = {}) {
|
|
|
1450
2070
|
// page row so a frontend head template never hits `page.seo` === undefined.
|
|
1451
2071
|
if (!snap.page.seo)
|
|
1452
2072
|
snap.page.seo = pageMeta(page).seo;
|
|
2073
|
+
// `version` from the LIVE row, never the snapshot: a snapshot is baked at publish
|
|
2074
|
+
// time, so a client echoing it would 409 forever after the first draft edit — and
|
|
2075
|
+
// a pre-`version` snapshot has none at all, which (typed `number`) silently drops
|
|
2076
|
+
// out of the request body and reverts to the last-write-wins this feature removes.
|
|
2077
|
+
snap.page.version = typeof page.version === "number" ? page.version : 1;
|
|
1453
2078
|
if (snap.page.translationGroupId === undefined)
|
|
1454
2079
|
snap.page.translationGroupId = page.translationGroupId ?? null;
|
|
1455
2080
|
return snap;
|
|
@@ -1481,11 +2106,31 @@ export const cmsHandlers = createCmsHandlers();
|
|
|
1481
2106
|
* `editor` grants full CRUD across every cms_ table. */
|
|
1482
2107
|
export function cmsPolicies(opts = {}) {
|
|
1483
2108
|
const p = opts.prefix ?? "cms";
|
|
2109
|
+
// `cms_collection_revisions` is deliberately NOT here: it is append-only, and this loop
|
|
2110
|
+
// grants update AND delete. Spreading both fragments (which every wiring in the README
|
|
2111
|
+
// does) would otherwise hand every editor the ability to rewrite or purge history through
|
|
2112
|
+
// any app handler, silently overriding the read+create grant `collectionPolicies` emits —
|
|
2113
|
+
// duplicate policies on the same (role, entity, action) OR-merge, so the wider one wins.
|
|
2114
|
+
// The collection half owns that table's grant; see `collectionPolicies`.
|
|
1484
2115
|
const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"];
|
|
2116
|
+
// Soft-deleted rows are filtered in the ACL, not in each handler. A read scope is
|
|
2117
|
+
// AND-merged into every `ctx.db` read, so one policy hides a trashed row from the public
|
|
2118
|
+
// API, the editor, relation traversals and eager-loads at once — where a per-handler
|
|
2119
|
+
// `where` would have to be remembered at ~40 call sites and would be wrong the first
|
|
2120
|
+
// time someone forgot. The trash itself is read with `ctx.db.exec` (below), which is the
|
|
2121
|
+
// documented raw escape hatch and deliberately outside this scope.
|
|
2122
|
+
const notTrashed = { where: { deletedAt: { isNull: true } } };
|
|
2123
|
+
const softDeleted = { cms_pages: true, cms_media: true };
|
|
1485
2124
|
const editorPolicies = [];
|
|
1486
2125
|
for (const table of tables) {
|
|
1487
2126
|
for (const action of ["read", "create", "update", "delete"]) {
|
|
1488
|
-
|
|
2127
|
+
// UPDATE is scoped as well as READ. Handlers that read the row first already 404 on
|
|
2128
|
+
// a trashed page, but `updatePageSeo`/`updateMedia` patched blind — so an editor with
|
|
2129
|
+
// a stale tab could mutate a page a colleague had just trashed, and the write echo
|
|
2130
|
+
// handed back the whole hidden row. Scoping the grant covers every future write
|
|
2131
|
+
// handler too, rather than relying on each one remembering to read first.
|
|
2132
|
+
const scoped = (action === "read" || action === "update") && softDeleted[table];
|
|
2133
|
+
editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, scoped ? notTrashed : allow()));
|
|
1489
2134
|
}
|
|
1490
2135
|
}
|
|
1491
2136
|
return {
|
|
@@ -1495,14 +2140,18 @@ export function cmsPolicies(opts = {}) {
|
|
|
1495
2140
|
// can route/render by type. Slugs/names are structural, not sensitive.
|
|
1496
2141
|
policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
|
|
1497
2142
|
// Only published pages are readable; the snapshot carries the content.
|
|
1498
|
-
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
|
|
2143
|
+
policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published", deletedAt: { isNull: true } } }),
|
|
1499
2144
|
// getPage reads the latest revision snapshot. Scope the grant by the revision's
|
|
1500
2145
|
// PAGE being currently published (a relation-traversal where, compiled to a
|
|
1501
2146
|
// subquery), so a revision of a later-unpublished/archived page is never publicly
|
|
1502
2147
|
// readable — least-privilege even for a future revision-listing handler.
|
|
1503
|
-
|
|
2148
|
+
// `deletedAt` as well as `status`: getPage 404s on the page lookup first today, so
|
|
2149
|
+
// this is defense in depth — but a revision snapshot is a BAKED copy of the page's
|
|
2150
|
+
// content, and a future revision-listing handler reading it directly would otherwise
|
|
2151
|
+
// serve a trashed page's body.
|
|
2152
|
+
policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published", deletedAt: { isNull: true } } } }),
|
|
1504
2153
|
// Media metadata is public (the bytes are separately gated by signed urls).
|
|
1505
|
-
policy(`${p}:public:media:read`, "cms_media", "read",
|
|
2154
|
+
policy(`${p}:public:media:read`, "cms_media", "read", { where: { deletedAt: { isNull: true } } }),
|
|
1506
2155
|
],
|
|
1507
2156
|
editor: editorPolicies,
|
|
1508
2157
|
};
|
|
@@ -1535,12 +2184,238 @@ function collectionMeta(c) {
|
|
|
1535
2184
|
titleField,
|
|
1536
2185
|
idField: c.idField ?? "id",
|
|
1537
2186
|
orderBy: c.orderBy,
|
|
2187
|
+
supports: c.supports ?? [],
|
|
1538
2188
|
};
|
|
1539
2189
|
}
|
|
2190
|
+
export const COLLECTION_FEATURES = ["drafts", "scheduling", "revisions", "preview"];
|
|
2191
|
+
/** The columns each feature needs on the collection's entity. The app declares them (they
|
|
2192
|
+
* are its own entity); the CMS writes them and `fields` may not. */
|
|
2193
|
+
export const COLLECTION_FEATURE_COLUMNS = {
|
|
2194
|
+
drafts: ["status"],
|
|
2195
|
+
scheduling: ["publishedAt", "scheduledAt", "unpublishAt"],
|
|
2196
|
+
revisions: [],
|
|
2197
|
+
preview: [],
|
|
2198
|
+
};
|
|
2199
|
+
/** Features that mean nothing on their own. Scheduling moves a row between draft and
|
|
2200
|
+
* published; preview shows the unpublished version — both presuppose `drafts`. */
|
|
2201
|
+
const COLLECTION_FEATURE_REQUIRES = {
|
|
2202
|
+
scheduling: "drafts",
|
|
2203
|
+
preview: "drafts",
|
|
2204
|
+
};
|
|
2205
|
+
/** The shared revision table for collections (see `cmsSchema`). */
|
|
2206
|
+
export const COLLECTION_REVISIONS_TABLE = "cms_collection_revisions";
|
|
2207
|
+
/** The two `status` values a `drafts` collection uses. */
|
|
2208
|
+
export const COLLECTION_DRAFT = "draft";
|
|
2209
|
+
export const COLLECTION_PUBLISHED = "published";
|
|
2210
|
+
/** `collectionList` page size when the caller names none, and the ceiling it is clamped to.
|
|
2211
|
+
* The cap is the point: an unbounded list of a wide entity is the D1-over-RPC failure mode
|
|
2212
|
+
* (GitHub #22), and `LIMIT -1` is SQLite for "no limit". */
|
|
2213
|
+
const DEFAULT_COLLECTION_LIST_LIMIT = 100;
|
|
2214
|
+
const MAX_COLLECTION_LIST_LIMIT = 500;
|
|
2215
|
+
/** An ISO-8601 UTC instant — the one format every managed collection timestamp is written
|
|
2216
|
+
* in, so it compares correctly against `$now()`. See the note above on why this is not
|
|
2217
|
+
* `nowStamp()`. */
|
|
2218
|
+
const isoStamp = () => new Date().toISOString();
|
|
2219
|
+
/** The epoch-ms range a schedule may name: 1970-01-01 up to (not including) year 10000.
|
|
2220
|
+
*
|
|
2221
|
+
* `Number.isFinite` is NOT a sufficient bound, in two directions. Above `8.64e15` (the max
|
|
2222
|
+
* `Date`) `toISOString()` throws a `RangeError` INSIDE the mutation — an opaque 500 for the
|
|
2223
|
+
* common client slip of sending epoch microseconds. And from year 10000 up, `toISOString()`
|
|
2224
|
+
* mints an EXPANDED-year string (`"+010000-01-01T00:00:00.000Z"`) whose leading `+` sorts
|
|
2225
|
+
* BEFORE every ordinary timestamp — inverting every lexicographic comparison this feature
|
|
2226
|
+
* rests on, so a takedown 8000 years out reads as already passed and a publish instant in
|
|
2227
|
+
* the far future reads as due. One range check closes both. */
|
|
2228
|
+
const MIN_SCHEDULE_MS = 0;
|
|
2229
|
+
const MAX_SCHEDULE_MS = 253402300799999; // 9999-12-31T23:59:59.999Z
|
|
2230
|
+
const epochInput = (name, v) => {
|
|
2231
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
2232
|
+
throw new BadRequest(`${name} must be a finite epoch ms`);
|
|
2233
|
+
if (!Number.isInteger(v))
|
|
2234
|
+
throw new BadRequest(`${name} must be a whole number of epoch ms`);
|
|
2235
|
+
if (v < MIN_SCHEDULE_MS || v > MAX_SCHEDULE_MS) {
|
|
2236
|
+
throw new BadRequest(`${name} must be an epoch ms between ${MIN_SCHEDULE_MS} and ${MAX_SCHEDULE_MS} (1970 … 9999) — got ${v}`);
|
|
2237
|
+
}
|
|
2238
|
+
return v;
|
|
2239
|
+
};
|
|
2240
|
+
/** The column types a declared field can be stored in. A collection field is COLUMN-MAPPED,
|
|
2241
|
+
* so the entity's column type has to match what the field writes: a `richtext`/`group`/
|
|
2242
|
+
* `repeater` value is a document (`t.json()`), the rest are scalars. Getting this wrong is
|
|
2243
|
+
* not a type error anywhere — it surfaces as a raw driver message on the first write
|
|
2244
|
+
* ("Binding expected string, TypedArray, …"), which is why it is checked at boot. */
|
|
2245
|
+
const COLLECTION_FIELD_COLUMN_TYPES = {
|
|
2246
|
+
text: ["text", "uuid"],
|
|
2247
|
+
textarea: ["text"],
|
|
2248
|
+
richtext: ["json"],
|
|
2249
|
+
url: ["text"],
|
|
2250
|
+
number: ["integer", "real"],
|
|
2251
|
+
boolean: ["boolean", "integer"],
|
|
2252
|
+
date: ["text"],
|
|
2253
|
+
datetime: ["text"],
|
|
2254
|
+
publish: ["text"],
|
|
2255
|
+
slug: ["text"],
|
|
2256
|
+
media: ["text", "uuid"],
|
|
2257
|
+
select: ["text"],
|
|
2258
|
+
repeater: ["json"],
|
|
2259
|
+
group: ["json"],
|
|
2260
|
+
};
|
|
2261
|
+
/** Check a collection registry at BOOT: slugs and entities are unique, features are known
|
|
2262
|
+
* and have their prerequisites, every declared field maps to a column that can hold it, and
|
|
2263
|
+
* every managed column exists, has the shape the CMS writes, and is not also an editable
|
|
2264
|
+
* field.
|
|
2265
|
+
*
|
|
2266
|
+
* Called by `createCollectionHandlers`. The point is that a misconfiguration surfaces when
|
|
2267
|
+
* the Worker starts, naming the collection and the column — not as a 500 the first time an
|
|
2268
|
+
* editor presses Publish, months later, on the one collection nobody exercised.
|
|
2269
|
+
*
|
|
2270
|
+
* `schema` is REQUIRED. Every check here reads the target entity, so a registry validated
|
|
2271
|
+
* without one is not validated at all — and the failures it catches (a field name typo, a
|
|
2272
|
+
* richtext field over a TEXT column, a non-PK idField) are exactly as fatal on a collection
|
|
2273
|
+
* that declares no `supports` as on one that declares all four. */
|
|
2274
|
+
export function validateCollections(collections, schema) {
|
|
2275
|
+
const seen = new Set();
|
|
2276
|
+
const byEntity = new Map();
|
|
2277
|
+
for (const c of collections) {
|
|
2278
|
+
if (seen.has(c.slug))
|
|
2279
|
+
throw new Error(`pramen/cms: duplicate collection slug '${c.slug}' — slugs are the handler registry's key`);
|
|
2280
|
+
seen.add(c.slug);
|
|
2281
|
+
// ONE collection per entity. The ACL keys policies by (role, entity, action) and
|
|
2282
|
+
// OR-merges the matches — the policy NAME is not part of the key — so a second
|
|
2283
|
+
// collection over the same entity does not add a second, separate view: it WIDENS the
|
|
2284
|
+
// first one's read scope. Two `collectionPublicPolicies` grants over one entity collapse
|
|
2285
|
+
// to the loosest of the two, which is how a `drafts`-only collection silently removes
|
|
2286
|
+
// the `publishedAt <= $now()` and `unpublishAt > $now()` clauses from a `scheduling`
|
|
2287
|
+
// sibling — publishing a row a year early and defeating its scheduled takedown.
|
|
2288
|
+
const first = byEntity.get(c.entity);
|
|
2289
|
+
if (first) {
|
|
2290
|
+
throw new Error(`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.`);
|
|
2291
|
+
}
|
|
2292
|
+
byEntity.set(c.entity, c.slug);
|
|
2293
|
+
}
|
|
2294
|
+
if (collections.length === 0)
|
|
2295
|
+
return;
|
|
2296
|
+
if (!schema) {
|
|
2297
|
+
throw new Error(`pramen/cms: createCollectionHandlers needs your schema to check the registry against your entities: createCollectionHandlers(collections, { schema })`);
|
|
2298
|
+
}
|
|
2299
|
+
for (const c of collections) {
|
|
2300
|
+
const features = c.supports ?? [];
|
|
2301
|
+
const set = new Set(features);
|
|
2302
|
+
for (const f of features) {
|
|
2303
|
+
if (!COLLECTION_FEATURES.includes(f)) {
|
|
2304
|
+
throw new Error(`pramen/cms: collection '${c.slug}' declares unknown feature '${String(f)}' (known: ${COLLECTION_FEATURES.join(", ")})`);
|
|
2305
|
+
}
|
|
2306
|
+
const needs = COLLECTION_FEATURE_REQUIRES[f];
|
|
2307
|
+
if (needs && !set.has(needs)) {
|
|
2308
|
+
throw new Error(`pramen/cms: collection '${c.slug}' declares '${f}', which needs '${needs}' — add it to \`supports\``);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
const entity = schema[c.entity];
|
|
2312
|
+
if (!entity)
|
|
2313
|
+
throw new Error(`pramen/cms: collection '${c.slug}' targets entity '${c.entity}', which is not in the schema`);
|
|
2314
|
+
const columns = entity.fields;
|
|
2315
|
+
// EVERY collection handler dispatches to the default partition's DO: none of them
|
|
2316
|
+
// declares a `partition`, and `/rpc` routes by the handler's. An entity parked in
|
|
2317
|
+
// another partition therefore boots clean and then 400s on every single call
|
|
2318
|
+
// (`assertInPartition`), and a preview link 404s forever — `callPrivileged` has no
|
|
2319
|
+
// partition to pass either. Name it here instead.
|
|
2320
|
+
const entityPartition = partitionOf(schema, c.entity);
|
|
2321
|
+
if (entityPartition !== DEFAULT_PARTITION) {
|
|
2322
|
+
throw new Error(`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.`);
|
|
2323
|
+
}
|
|
2324
|
+
const idField = c.idField ?? "id";
|
|
2325
|
+
if (!(idField in columns)) {
|
|
2326
|
+
throw new Error(`pramen/cms: collection '${c.slug}' has idField '${idField}', which is not a column on '${c.entity}'`);
|
|
2327
|
+
}
|
|
2328
|
+
// …and it must be the PRIMARY KEY, not merely a column. Reads key on `idField`, but
|
|
2329
|
+
// `db.update`/`db.delete` key on the entity's actual PK — so a non-PK idField loads a
|
|
2330
|
+
// row fine and then writes nothing, surfacing as a 404 on a row the same handler just
|
|
2331
|
+
// read. Exactly the misconfiguration this validator exists to name.
|
|
2332
|
+
const pk = Object.entries(columns).find(([, f]) => f.primaryKey)?.[0] ?? "id";
|
|
2333
|
+
if (idField !== pk) {
|
|
2334
|
+
throw new Error(`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`);
|
|
2335
|
+
}
|
|
2336
|
+
// Declared fields ARE columns on the entity (that is what "column-mapped" means), so a
|
|
2337
|
+
// typo is a write that fails with the driver's own message and no HTTP status, and a
|
|
2338
|
+
// document field over a TEXT column is the trap example/app.ts documents in a comment.
|
|
2339
|
+
// Both are visible right here, with `columns` in hand.
|
|
2340
|
+
for (const f of c.fields) {
|
|
2341
|
+
const col = columns[f.name];
|
|
2342
|
+
if (!col) {
|
|
2343
|
+
throw new Error(`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`);
|
|
2344
|
+
}
|
|
2345
|
+
const allowed = COLLECTION_FIELD_COLUMN_TYPES[f.type];
|
|
2346
|
+
if (allowed && !allowed.includes(col.type)) {
|
|
2347
|
+
const want = allowed.map((t) => `t.${t === "integer" ? "int" : t === "boolean" ? "bool" : t}()`).join(" or ");
|
|
2348
|
+
throw new Error(`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}`);
|
|
2349
|
+
}
|
|
2350
|
+
if (col.hidden) {
|
|
2351
|
+
throw new Error(`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`);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
// A declared `orderBy` fails SILENTLY when the column does not exist: the dialect
|
|
2355
|
+
// double-quotes the name and SQLite resolves an unknown quoted identifier to a string
|
|
2356
|
+
// CONSTANT, so every row sorts equal and the list comes back in arbitrary storage order
|
|
2357
|
+
// with no error anywhere.
|
|
2358
|
+
if (c.orderBy && !(c.orderBy.column in columns)) {
|
|
2359
|
+
throw new Error(`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`);
|
|
2360
|
+
}
|
|
2361
|
+
const declared = new Set(c.fields.map((f) => f.name));
|
|
2362
|
+
for (const f of features) {
|
|
2363
|
+
for (const col of COLLECTION_FEATURE_COLUMNS[f]) {
|
|
2364
|
+
const column = columns[col];
|
|
2365
|
+
if (!column) {
|
|
2366
|
+
throw new Error(`pramen/cms: collection '${c.slug}' declares '${f}', which manages a \`${col}\` column on '${c.entity}' — add \`${col}: t.text()\` to the entity`);
|
|
2367
|
+
}
|
|
2368
|
+
// NAME alone is not enough. The CMS writes these columns as TEXT (an ISO-8601
|
|
2369
|
+
// instant or a status word) and compares them lexicographically in the public read
|
|
2370
|
+
// scope, and every wrong declaration fails SILENTLY rather than loudly:
|
|
2371
|
+
// - `t.json()` stores `"\"published\""` (the Db chokepoint stringifies), so the
|
|
2372
|
+
// policy's `status = 'published'` never matches and the row is invisible
|
|
2373
|
+
// forever while `collectionPublish` echoes success;
|
|
2374
|
+
// - `notNull()` 500s on every create (the managed columns are seeded as NULL);
|
|
2375
|
+
// - `hidden()` strips the column from every read, disabling the spent-takedown
|
|
2376
|
+
// repair and hiding the state from the editor.
|
|
2377
|
+
if (column.type !== "text") {
|
|
2378
|
+
throw new Error(`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)`);
|
|
2379
|
+
}
|
|
2380
|
+
if (column.notNull) {
|
|
2381
|
+
throw new Error(`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).`);
|
|
2382
|
+
}
|
|
2383
|
+
if (column.hidden) {
|
|
2384
|
+
throw new Error(`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`);
|
|
2385
|
+
}
|
|
2386
|
+
// `fields` IS the write whitelist. A `status` entry there would let any editor send
|
|
2387
|
+
// `values: { status: "published" }` through collectionUpdate and bypass the publish
|
|
2388
|
+
// handler entirely, leaving the gate as decoration over a client-set column.
|
|
2389
|
+
if (declared.has(col)) {
|
|
2390
|
+
throw new Error(`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)`);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
if (set.has("revisions")) {
|
|
2395
|
+
if (!schema[COLLECTION_REVISIONS_TABLE]) {
|
|
2396
|
+
throw new Error(`pramen/cms: collection '${c.slug}' declares 'revisions', which needs the \`${COLLECTION_REVISIONS_TABLE}\` table — spread \`cmsSchema\` into defineSchema`);
|
|
2397
|
+
}
|
|
2398
|
+
// A DO cannot write across a partition boundary, so a collection entity parked in its
|
|
2399
|
+
// own partition would 500 on the first snapshot insert (assertInPartition). The
|
|
2400
|
+
// default-partition check above already covers this; keep the specific message for
|
|
2401
|
+
// the case where `cms_collection_revisions` itself was moved.
|
|
2402
|
+
const revPartition = partitionOf(schema, COLLECTION_REVISIONS_TABLE);
|
|
2403
|
+
if (entityPartition !== revPartition) {
|
|
2404
|
+
throw new Error(`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}'`);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
/** Where a collection preview link is redeemed. Served by `cmsRoutes()`. */
|
|
2410
|
+
export const COLLECTION_PREVIEW_PATH = "/cms/preview/collection";
|
|
2411
|
+
/** Outbox task kinds behind `collectionSchedule`. Register the handlers with
|
|
2412
|
+
* `app.tasks = { ...cmsTasks, ...createCollectionTasks(collections) }`. */
|
|
2413
|
+
export const TASK_COLLECTION_PUBLISH = "cms:collection:publish";
|
|
2414
|
+
export const TASK_COLLECTION_UNPUBLISH = "cms:collection:unpublish";
|
|
1540
2415
|
/** Build generic CRUD handlers over the registered collections. Spread into your app's
|
|
1541
2416
|
* handlers alongside `cmsHandlers`:
|
|
1542
2417
|
*
|
|
1543
|
-
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
|
|
2418
|
+
* const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures], { schema }) };
|
|
1544
2419
|
*
|
|
1545
2420
|
* Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
|
|
1546
2421
|
* `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
|
|
@@ -1548,7 +2423,16 @@ function collectionMeta(c) {
|
|
|
1548
2423
|
* `collectionPolicies` scopes them too). The `collection` param is resolved through the
|
|
1549
2424
|
* registry — an unknown slug is a 400, never a raw table reference. */
|
|
1550
2425
|
export function createCollectionHandlers(collections, opts = {}) {
|
|
2426
|
+
// Boot check, before a single handler is built: unknown/incoherent features and missing
|
|
2427
|
+
// managed columns throw here rather than 500ing on the first publish.
|
|
2428
|
+
validateCollections(collections, opts.schema);
|
|
2429
|
+
const collectionRtSchema = opts.richTextSchema ?? DEFAULT_RICH_TEXT_SCHEMA;
|
|
1551
2430
|
const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
|
|
2431
|
+
// Preview redemption presents editorRoles ∪ reviewerRoles (see `viewerRolesOf`), so the
|
|
2432
|
+
// handler the route calls has to accept that set — gating it to `editor` alone would 403
|
|
2433
|
+
// every preview link for a reviewer-only identity. Same wiring as `getPagePreview`.
|
|
2434
|
+
const viewer = { auth: viewerRolesOf(opts) };
|
|
2435
|
+
const previewTtl = opts.previewTtlSeconds ?? DEFAULT_PREVIEW_TTL_SECONDS;
|
|
1552
2436
|
const bySlug = new Map(collections.map((c) => [c.slug, c]));
|
|
1553
2437
|
const metas = collections.map(collectionMeta);
|
|
1554
2438
|
const def = (slug) => {
|
|
@@ -1557,18 +2441,183 @@ export function createCollectionHandlers(collections, opts = {}) {
|
|
|
1557
2441
|
throw new BadRequest(`unknown collection: ${String(slug)}`);
|
|
1558
2442
|
return c;
|
|
1559
2443
|
};
|
|
2444
|
+
/** Narrow a stored snapshot to the columns a caller may read on the collection's entity.
|
|
2445
|
+
* Used by both the history read and the restore write, so "what you can see" and "what you
|
|
2446
|
+
* can put back" are the same set. */
|
|
2447
|
+
const projectSnapshot = (snapshot, readable) => {
|
|
2448
|
+
const obj = snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) ? snapshot : {};
|
|
2449
|
+
const out = {};
|
|
2450
|
+
for (const [k, v] of Object.entries(obj))
|
|
2451
|
+
if (readable.has(k))
|
|
2452
|
+
out[k] = v;
|
|
2453
|
+
return out;
|
|
2454
|
+
};
|
|
1560
2455
|
const idOf = (c) => c.idField ?? "id";
|
|
2456
|
+
const has = (c, f) => (c.supports ?? []).includes(f);
|
|
2457
|
+
const columnsOf = (c) => (opts.schema?.[c.entity]?.fields ?? {});
|
|
2458
|
+
/** The list ordering, resolved ONCE against the entity. The documented default is
|
|
2459
|
+
* `createdAt desc`, but that column is not guaranteed to exist — and an ORDER BY over a
|
|
2460
|
+
* missing column does not fail: the dialect quotes the name, SQLite resolves the unknown
|
|
2461
|
+
* quoted identifier to a string CONSTANT, every row sorts equal, and the list comes back
|
|
2462
|
+
* in arbitrary storage order. Fall back to the PK, which always exists, so the order is at
|
|
2463
|
+
* least stable and paging is coherent. (A DECLARED `orderBy` over a missing column is a
|
|
2464
|
+
* boot error — see `validateCollections`.) */
|
|
2465
|
+
const orderByOf = (c) => {
|
|
2466
|
+
if (c.orderBy)
|
|
2467
|
+
return { column: c.orderBy.column, dir: c.orderBy.dir ?? "desc" };
|
|
2468
|
+
return { column: "createdAt" in columnsOf(c) ? "createdAt" : idOf(c), dir: "desc" };
|
|
2469
|
+
};
|
|
2470
|
+
const orderBys = new Map(collections.map((c) => [c.slug, orderByOf(c)]));
|
|
2471
|
+
/** 400 (not 500) when a caller invokes a workflow handler on a collection that never
|
|
2472
|
+
* opted into it — the handlers exist for every collection, the features do not. */
|
|
2473
|
+
const needs = (c, f) => {
|
|
2474
|
+
if (!has(c, f))
|
|
2475
|
+
throw new BadRequest(`collection '${c.slug}' does not support '${f}' (add it to \`supports\`)`);
|
|
2476
|
+
};
|
|
2477
|
+
const loadRow = async (db, c, id) => {
|
|
2478
|
+
const rows = await db.find({ from: c.entity, where: { [idOf(c)]: id }, limit: 1 });
|
|
2479
|
+
const row = rows[0];
|
|
2480
|
+
if (!row)
|
|
2481
|
+
throw notFound(c.label);
|
|
2482
|
+
return row;
|
|
2483
|
+
};
|
|
2484
|
+
/** Read a row's DECLARED FIELD columns unprojected, for the revision snapshot.
|
|
2485
|
+
*
|
|
2486
|
+
* `ctx.db.exec` is the documented raw escape hatch: it bypasses the row/field ACL, and it
|
|
2487
|
+
* also bypasses the `Db` chokepoint's cell codec — so a json-backed column comes back as
|
|
2488
|
+
* the stored TEXT and a boolean as 0/1. Both are decoded here from the entity's own column
|
|
2489
|
+
* types (checked against the field types at boot), so a snapshot holds exactly what
|
|
2490
|
+
* `db.find` would have returned for an unrestricted caller.
|
|
2491
|
+
*
|
|
2492
|
+
* Falls back to the ACL-projected row if the raw read comes back empty (a substrate quirk
|
|
2493
|
+
* or a row deleted concurrently) — a partial snapshot beats no snapshot. */
|
|
2494
|
+
const rawFieldValues = async (db, c, rowId, projected) => {
|
|
2495
|
+
const columns = columnsOf(c);
|
|
2496
|
+
const names = c.fields.map((f) => f.name).filter((n) => n in columns);
|
|
2497
|
+
if (names.length === 0)
|
|
2498
|
+
return {};
|
|
2499
|
+
const cols = names.map((n) => `"${n}"`).join(", ");
|
|
2500
|
+
// Identifiers, not values: `entity`, `idField` and every field name were checked against
|
|
2501
|
+
// the schema at boot, so nothing caller-supplied is interpolated here. The id IS bound.
|
|
2502
|
+
const rows = (await db.exec(`SELECT ${cols} FROM "${c.entity}" WHERE "${idOf(c)}" = ?`, rowId));
|
|
2503
|
+
const raw = rows[0];
|
|
2504
|
+
if (!raw) {
|
|
2505
|
+
const fallback = {};
|
|
2506
|
+
for (const f of c.fields)
|
|
2507
|
+
if (f.name in projected)
|
|
2508
|
+
fallback[f.name] = projected[f.name];
|
|
2509
|
+
return fallback;
|
|
2510
|
+
}
|
|
2511
|
+
const values = {};
|
|
2512
|
+
for (const name of names) {
|
|
2513
|
+
const v = raw[name];
|
|
2514
|
+
const type = columns[name]?.type;
|
|
2515
|
+
if (v == null)
|
|
2516
|
+
values[name] = null;
|
|
2517
|
+
else if ((type === "json" || type === "fileRef") && typeof v === "string") {
|
|
2518
|
+
try {
|
|
2519
|
+
values[name] = JSON.parse(v);
|
|
2520
|
+
}
|
|
2521
|
+
catch {
|
|
2522
|
+
values[name] = v; // not JSON after all — keep the literal rather than losing it
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
else if (type === "boolean")
|
|
2526
|
+
values[name] = typeof v === "boolean" ? v : v !== 0 && v !== 0n;
|
|
2527
|
+
else
|
|
2528
|
+
values[name] = v;
|
|
2529
|
+
}
|
|
2530
|
+
return values;
|
|
2531
|
+
};
|
|
2532
|
+
/** Snapshot a row's CURRENT (pre-write) state into `cms_collection_revisions`, so a
|
|
2533
|
+
* revision always reads as "what it was before this edit" and restoring one is a plain
|
|
2534
|
+
* reversal. Declared fields only — the snapshot is replayed through the same write
|
|
2535
|
+
* whitelist on restore, so it can never carry a column the collection doesn't own.
|
|
2536
|
+
* No-op unless the collection supports `revisions`. */
|
|
2537
|
+
const snapshotRow = async (db, c, row, ctx, note) => {
|
|
2538
|
+
if (!has(c, "revisions"))
|
|
2539
|
+
return;
|
|
2540
|
+
const rowId = String(row[idOf(c)]);
|
|
2541
|
+
// The row handed in came through the ACL, so it is projected to what THIS caller may
|
|
2542
|
+
// read — which would make history a function of who happened to make the edit: an
|
|
2543
|
+
// editor whose read scope excludes `salary` would silently drop it from the snapshot,
|
|
2544
|
+
// and every later "restore to before that edit" would restore an incomplete row.
|
|
2545
|
+
// History is an audit record, not a view, so capture the row's REAL pre-state through
|
|
2546
|
+
// the raw escape hatch and let the READ path decide who may see which of its fields
|
|
2547
|
+
// (`collectionListRevisions` projects it back down).
|
|
2548
|
+
const values = await rawFieldValues(db, c, rowId, row);
|
|
2549
|
+
// Next in this row's sequence. Serialized by the DO's single writer; on D1 the composite
|
|
2550
|
+
// unique on (collection, rowId, revision) is the backstop — see the schema note.
|
|
2551
|
+
const [{ next = 1 } = {}] = (await db.exec(`SELECT COALESCE(MAX(revision), 0) + 1 AS next FROM ${COLLECTION_REVISIONS_TABLE} WHERE collection = ? AND rowId = ?`, c.slug, rowId));
|
|
2552
|
+
await db.insert(COLLECTION_REVISIONS_TABLE, {
|
|
2553
|
+
collection: c.slug,
|
|
2554
|
+
rowId,
|
|
2555
|
+
revision: next,
|
|
2556
|
+
snapshot: values,
|
|
2557
|
+
note,
|
|
2558
|
+
actor: typeof ctx.identity?.userId === "string" ? ctx.identity.userId : null,
|
|
2559
|
+
// Explicit, ms-precision, and the only writer of this column — see the schema note.
|
|
2560
|
+
createdAt: isoStamp(),
|
|
2561
|
+
});
|
|
2562
|
+
};
|
|
2563
|
+
/** Shared input validator for the `{ collection, id }` handlers. */
|
|
2564
|
+
const rowInput = (raw) => {
|
|
2565
|
+
const o = asObj(raw);
|
|
2566
|
+
if (typeof o.collection !== "string" || o.collection === "")
|
|
2567
|
+
throw new BadRequest("collection is required");
|
|
2568
|
+
return { collection: o.collection, id: idInput(raw) };
|
|
2569
|
+
};
|
|
2570
|
+
const collectionInput = (raw) => {
|
|
2571
|
+
const o = asObj(raw);
|
|
2572
|
+
if (typeof o.collection !== "string" || o.collection === "")
|
|
2573
|
+
throw new BadRequest("collection is required");
|
|
2574
|
+
return o.collection;
|
|
2575
|
+
};
|
|
2576
|
+
/** `{ collection, values }` — the write handlers. `values` is validated against the field
|
|
2577
|
+
* schema downstream (`toColumns`); this only rejects a non-object, so a string or an array
|
|
2578
|
+
* cannot reach the field validator as a bag of index keys. */
|
|
2579
|
+
const valuesInput = (raw) => {
|
|
2580
|
+
const o = asObj(raw);
|
|
2581
|
+
const values = o.values;
|
|
2582
|
+
if (values === null || typeof values !== "object" || Array.isArray(values))
|
|
2583
|
+
throw new BadRequest("values must be an object");
|
|
2584
|
+
return { collection: collectionInput(raw), values: values };
|
|
2585
|
+
};
|
|
2586
|
+
const rowValuesInput = (raw) => ({
|
|
2587
|
+
...rowInput(raw),
|
|
2588
|
+
values: valuesInput(raw).values,
|
|
2589
|
+
});
|
|
2590
|
+
/** `{ collection, limit?, offset? }`, both CLAMPED. `find` binds `limit` straight into
|
|
2591
|
+
* `LIMIT ?`, and SQLite reads a negative limit as UNBOUNDED — so `limit: -1` dumps the
|
|
2592
|
+
* whole table over RPC — while a fractional value reaches the driver as-is and 500s. */
|
|
2593
|
+
const listInput = (raw) => {
|
|
2594
|
+
const o = asObj(raw);
|
|
2595
|
+
const num = (name, v) => {
|
|
2596
|
+
if (v === undefined || v === null)
|
|
2597
|
+
return undefined;
|
|
2598
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
2599
|
+
throw new BadRequest(`${name} must be a number`);
|
|
2600
|
+
return Math.floor(v);
|
|
2601
|
+
};
|
|
2602
|
+
const limit = num("limit", o.limit);
|
|
2603
|
+
const offset = num("offset", o.offset);
|
|
2604
|
+
return {
|
|
2605
|
+
collection: collectionInput(raw),
|
|
2606
|
+
limit: limit === undefined ? undefined : Math.max(1, Math.min(limit, MAX_COLLECTION_LIST_LIMIT)),
|
|
2607
|
+
offset: offset === undefined ? undefined : Math.max(0, offset),
|
|
2608
|
+
};
|
|
2609
|
+
};
|
|
1561
2610
|
// Validate against the field schema, sanitize richtext, then PROJECT to declared field
|
|
1562
2611
|
// names only — the write whitelist. `requireRequired` is off for updates (partial patch);
|
|
1563
2612
|
// on for create. Nothing outside `c.fields` can reach the entity.
|
|
1564
|
-
const toColumns = (c, values, requireRequired) => {
|
|
2613
|
+
const toColumns = (c, values, requireRequired, legacyBaseline) => {
|
|
1565
2614
|
const obj = asObj(values);
|
|
1566
|
-
validateFields([...c.fields], obj, "", { requireRequired });
|
|
1567
|
-
const
|
|
2615
|
+
validateFields([...c.fields], obj, "", { requireRequired, legacyBaseline });
|
|
2616
|
+
const normalized = normalizeFields([...c.fields], obj, collectionRtSchema);
|
|
1568
2617
|
const out = {};
|
|
1569
2618
|
for (const f of c.fields)
|
|
1570
|
-
if (f.name in
|
|
1571
|
-
out[f.name] =
|
|
2619
|
+
if (f.name in normalized)
|
|
2620
|
+
out[f.name] = normalized[f.name];
|
|
1572
2621
|
return out;
|
|
1573
2622
|
};
|
|
1574
2623
|
const idInput = (raw) => {
|
|
@@ -1583,33 +2632,372 @@ export function createCollectionHandlers(collections, opts = {}) {
|
|
|
1583
2632
|
listCollections: query(() => metas, editor),
|
|
1584
2633
|
collectionList: query((ctx, input) => {
|
|
1585
2634
|
const c = def(input.collection);
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2635
|
+
return cdb(ctx).find({
|
|
2636
|
+
from: c.entity,
|
|
2637
|
+
orderBy: orderBys.get(c.slug) ?? orderByOf(c),
|
|
2638
|
+
limit: input.limit ?? DEFAULT_COLLECTION_LIST_LIMIT,
|
|
2639
|
+
offset: input.offset,
|
|
2640
|
+
});
|
|
2641
|
+
}, { ...editor, input: listInput }),
|
|
1590
2642
|
collectionGet: query(async (ctx, input) => {
|
|
1591
2643
|
const c = def(input.collection);
|
|
1592
2644
|
const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
|
|
1593
2645
|
return rows[0] ?? null;
|
|
1594
|
-
}, editor),
|
|
2646
|
+
}, { ...editor, input: rowInput }),
|
|
1595
2647
|
collectionCreate: mutation((ctx, input) => {
|
|
1596
2648
|
const c = def(input.collection);
|
|
1597
|
-
|
|
1598
|
-
|
|
2649
|
+
const values = toColumns(c, input.values, true);
|
|
2650
|
+
// Seed the managed columns explicitly rather than leaning on a column default: the
|
|
2651
|
+
// entity belongs to the app, which may have declared `status` with no default (or a
|
|
2652
|
+
// NOT NULL one). A new row always starts as a draft — publishing is a separate,
|
|
2653
|
+
// separately-gated act.
|
|
2654
|
+
if (has(c, "drafts"))
|
|
2655
|
+
values.status = COLLECTION_DRAFT;
|
|
2656
|
+
if (has(c, "scheduling")) {
|
|
2657
|
+
values.publishedAt = null;
|
|
2658
|
+
values.scheduledAt = null;
|
|
2659
|
+
values.unpublishAt = null;
|
|
2660
|
+
}
|
|
2661
|
+
return cdb(ctx).insert(c.entity, values);
|
|
2662
|
+
}, { ...editor, input: valuesInput }),
|
|
1599
2663
|
collectionUpdate: mutation(async (ctx, input) => {
|
|
1600
2664
|
const c = def(input.collection);
|
|
1601
|
-
const
|
|
2665
|
+
const db = cdb(ctx);
|
|
2666
|
+
// Read the current row first: the editor autosaves the WHOLE values bag, so a
|
|
2667
|
+
// pre-Portable-Text richtext value rides along with an unrelated edit. It is
|
|
2668
|
+
// tolerated only when byte-identical to what is stored (see `legacyBaseline`).
|
|
2669
|
+
// A policy may grant `update` without `read` on the entity — that worked before this
|
|
2670
|
+
// pre-read existed, so it must not start 403ing. No baseline simply means a legacy
|
|
2671
|
+
// string is rejected, which is the strict default.
|
|
2672
|
+
let current;
|
|
2673
|
+
try {
|
|
2674
|
+
current = (await db.find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 }))[0];
|
|
2675
|
+
}
|
|
2676
|
+
catch {
|
|
2677
|
+
current = undefined;
|
|
2678
|
+
}
|
|
2679
|
+
// Validate + whitelist the patch BEFORE snapshotting. `toColumns` throws on an invalid
|
|
2680
|
+
// patch, and on the DO that rollback is free (the mutation is one transaction) — but
|
|
2681
|
+
// `D1Driver.transaction` is a no-op, so snapshotting first meant a REJECTED edit still
|
|
2682
|
+
// committed a revision on D1: a phantom entry recording no change, and a burnt value
|
|
2683
|
+
// in the per-row `revision` counter.
|
|
2684
|
+
const patch = toColumns(c, input.values, false, current);
|
|
2685
|
+
// `current` is undefined only when the pre-read above was denied (an
|
|
2686
|
+
// update-without-read grant), in which case there is nothing to snapshot — the
|
|
2687
|
+
// revision is skipped rather than written empty.
|
|
2688
|
+
if (current)
|
|
2689
|
+
await snapshotRow(db, c, current, ctx, "edit");
|
|
2690
|
+
const updated = await db.update(c.entity, input.id, patch);
|
|
1602
2691
|
if (updated === undefined)
|
|
1603
2692
|
throw notFound(c.label);
|
|
1604
2693
|
return updated;
|
|
1605
|
-
}, editor),
|
|
2694
|
+
}, { ...editor, input: rowValuesInput }),
|
|
1606
2695
|
collectionDelete: mutation(async (ctx, input) => {
|
|
1607
2696
|
const c = def(input.collection);
|
|
1608
|
-
const
|
|
2697
|
+
const db = cdb(ctx);
|
|
2698
|
+
const ok = await db.delete(c.entity, input.id);
|
|
1609
2699
|
if (!ok)
|
|
1610
2700
|
throw notFound(c.label);
|
|
2701
|
+
// PURGE the row's revisions. Keeping them looks like free history, but a collection PK
|
|
2702
|
+
// can be a caller-chosen textId — recreating a row with the same id would inherit the
|
|
2703
|
+
// dead row's history, and `collectionRestoreRevision`'s scope check (collection +
|
|
2704
|
+
// rowId) would happily write the deleted row's content over the new one. It also
|
|
2705
|
+
// bounds the table: a collection has no trash, so nothing else ever collects these.
|
|
2706
|
+
//
|
|
2707
|
+
// Atomic with the delete on the DO (the mutation runs in storage.transaction). NOT on
|
|
2708
|
+
// the D1 store, where `transaction` is a no-op — a failure in between leaves orphan
|
|
2709
|
+
// revisions, which is exactly the inheritance above. Rare, and recoverable by
|
|
2710
|
+
// deleting the recreated row, but it is not a guarantee on that substrate.
|
|
2711
|
+
if (has(c, "revisions")) {
|
|
2712
|
+
await db.exec(`DELETE FROM ${COLLECTION_REVISIONS_TABLE} WHERE collection = ? AND rowId = ?`, c.slug, input.id);
|
|
2713
|
+
}
|
|
1611
2714
|
return { ok: true };
|
|
1612
|
-
}, { ...editor, input:
|
|
2715
|
+
}, { ...editor, input: rowInput }),
|
|
2716
|
+
// ---- drafts -------------------------------------------------------------
|
|
2717
|
+
/** Move a row live. With `scheduling` this also stamps `publishedAt` (the column the
|
|
2718
|
+
* public read scope compares against `$now()`) and clears `scheduledAt` — which makes
|
|
2719
|
+
* any pending scheduled-publish task a no-op, since its intent token no longer matches.
|
|
2720
|
+
* A pending scheduled UNPUBLISH is deliberately left standing: publishing early does not
|
|
2721
|
+
* cancel a planned takedown. */
|
|
2722
|
+
collectionPublish: mutation(async (ctx, input) => {
|
|
2723
|
+
const c = def(input.collection);
|
|
2724
|
+
needs(c, "drafts");
|
|
2725
|
+
const db = cdb(ctx);
|
|
2726
|
+
const row = await loadRow(db, c, input.id);
|
|
2727
|
+
// Deliberately NOT snapshotted. A revision records CONTENT, and publishing changes
|
|
2728
|
+
// none — the managed columns are excluded from `fields` by design, so a "publish"
|
|
2729
|
+
// revision was byte-identical to the edit before it, and restoring it wrote only the
|
|
2730
|
+
// declared fields and left the row live. That reads as a broken button; an entry that
|
|
2731
|
+
// cannot be restored is worse than no entry.
|
|
2732
|
+
const patch = { status: COLLECTION_PUBLISHED };
|
|
2733
|
+
if (has(c, "scheduling")) {
|
|
2734
|
+
const now = isoStamp();
|
|
2735
|
+
patch.publishedAt = now;
|
|
2736
|
+
patch.scheduledAt = null;
|
|
2737
|
+
// Clear a takedown instant that has already PASSED. This is an EXPLICIT act by a
|
|
2738
|
+
// human holding publish rights, which is why it resolves differently from the
|
|
2739
|
+
// scheduled-publish task: that one converges to the state the schedule implies (a
|
|
2740
|
+
// passed takedown wins, and the row lands down), while here the editor is saying
|
|
2741
|
+
// "live, now" about a takedown that has already been served. A future one stands —
|
|
2742
|
+
// publishing early does not cancel a planned removal — but a spent one is not
|
|
2743
|
+
// "pending" at all, and since the public scope now enforces
|
|
2744
|
+
// `unpublishAt IS NULL OR unpublishAt > $now()`, leaving it would make this very
|
|
2745
|
+
// publish a no-op: the editor gets back `status: "published"` and the row stays
|
|
2746
|
+
// invisible, with no error to explain it. That state is reachable whenever the
|
|
2747
|
+
// unpublish task never ran (tasks unwired, outbox dead-lettered, no D1 cron).
|
|
2748
|
+
if (typeof row.unpublishAt === "string" && row.unpublishAt <= now)
|
|
2749
|
+
patch.unpublishAt = null;
|
|
2750
|
+
}
|
|
2751
|
+
const updated = await db.update(c.entity, input.id, patch);
|
|
2752
|
+
if (updated === undefined)
|
|
2753
|
+
throw notFound(c.label);
|
|
2754
|
+
return updated;
|
|
2755
|
+
}, { ...editor, input: rowInput }),
|
|
2756
|
+
/** Take a row back to draft, clearing every schedule. Both tokens are cleared, so a
|
|
2757
|
+
* pending publish AND a pending unpublish both become no-ops — unpublishing is an
|
|
2758
|
+
* explicit "this is not live and nothing is queued to change that". */
|
|
2759
|
+
collectionUnpublish: mutation(async (ctx, input) => {
|
|
2760
|
+
const c = def(input.collection);
|
|
2761
|
+
needs(c, "drafts");
|
|
2762
|
+
const db = cdb(ctx);
|
|
2763
|
+
await loadRow(db, c, input.id);
|
|
2764
|
+
const patch = { status: COLLECTION_DRAFT }; // not snapshotted — see collectionPublish
|
|
2765
|
+
if (has(c, "scheduling")) {
|
|
2766
|
+
patch.publishedAt = null;
|
|
2767
|
+
patch.scheduledAt = null;
|
|
2768
|
+
patch.unpublishAt = null;
|
|
2769
|
+
}
|
|
2770
|
+
const updated = await db.update(c.entity, input.id, patch);
|
|
2771
|
+
if (updated === undefined)
|
|
2772
|
+
throw notFound(c.label);
|
|
2773
|
+
return updated;
|
|
2774
|
+
}, { ...editor, input: rowInput }),
|
|
2775
|
+
// ---- scheduling ---------------------------------------------------------
|
|
2776
|
+
/** Schedule a future publish, and optionally a later unpublish. Mirrors `schedulePage`,
|
|
2777
|
+
* including the INTENT TOKEN: the row stores the scheduled instants
|
|
2778
|
+
* (`scheduledAt`/`unpublishAt`, ISO), the enqueued task carries a copy, and the task
|
|
2779
|
+
* runs only if the two still match. A reschedule overwrites the token, a manual
|
|
2780
|
+
* publish/unpublish clears it, and a duplicate delivery finds it already cleared — so a
|
|
2781
|
+
* superseded or cancelled schedule is a silent no-op rather than a surprise publish.
|
|
2782
|
+
*
|
|
2783
|
+
* The tasks are enqueued in THIS mutation's transaction (the outbox is transactional),
|
|
2784
|
+
* so a rolled-back schedule never leaves a task behind. They only run if you wired
|
|
2785
|
+
* `createCollectionTasks` into `app.tasks`. */
|
|
2786
|
+
collectionSchedule: mutation(async (ctx, input) => {
|
|
2787
|
+
const c = def(input.collection);
|
|
2788
|
+
needs(c, "scheduling");
|
|
2789
|
+
const db = cdb(ctx);
|
|
2790
|
+
const row = await loadRow(db, c, input.id);
|
|
2791
|
+
const now = Date.now();
|
|
2792
|
+
const publishToken = new Date(input.publishAt).toISOString();
|
|
2793
|
+
// Cross-call ordering. The boundary validator compares the two instants WITHIN one
|
|
2794
|
+
// call, which is not the invariant that matters: the documented way to move a publish
|
|
2795
|
+
// date is `collectionSchedule({ publishAt })` with `unpublishAt` omitted, and an
|
|
2796
|
+
// omitted takedown is left standing. Without this check a reschedule could push the
|
|
2797
|
+
// publish PAST a pending takedown — the takedown then fires first (clearing itself),
|
|
2798
|
+
// the publish fires after it against nothing, and the row is public with no takedown
|
|
2799
|
+
// left and no repair path. Compare against the takedown that will actually be in
|
|
2800
|
+
// effect: the one being written, or the one already stored.
|
|
2801
|
+
const effectiveUnpublish = input.unpublishAt !== undefined
|
|
2802
|
+
? typeof input.unpublishAt === "number"
|
|
2803
|
+
? new Date(input.unpublishAt).toISOString()
|
|
2804
|
+
: null
|
|
2805
|
+
: typeof row.unpublishAt === "string" && row.unpublishAt !== ""
|
|
2806
|
+
? row.unpublishAt
|
|
2807
|
+
: null;
|
|
2808
|
+
if (effectiveUnpublish !== null && effectiveUnpublish <= publishToken) {
|
|
2809
|
+
throw new BadRequest(`publishAt (${publishToken}) is at or after the scheduled takedown (${effectiveUnpublish}) — move or cancel the takedown too (pass \`unpublishAt\`, or \`unpublishAt: null\` to cancel it)`);
|
|
2810
|
+
}
|
|
2811
|
+
// PATCH semantics on the takedown: an ABSENT `unpublishAt` leaves an existing one
|
|
2812
|
+
// alone. Writing null unconditionally meant that merely moving the publish date
|
|
2813
|
+
// revoked a scheduled removal — and silently, since clearing the column also
|
|
2814
|
+
// neutralizes the already-enqueued task through the intent-token check. Pass
|
|
2815
|
+
// `unpublishAt: null` to cancel one deliberately.
|
|
2816
|
+
const hasUnpublish = input.unpublishAt !== undefined;
|
|
2817
|
+
const unpublishToken = typeof input.unpublishAt === "number" ? new Date(input.unpublishAt).toISOString() : null;
|
|
2818
|
+
const patch = { scheduledAt: publishToken };
|
|
2819
|
+
if (hasUnpublish)
|
|
2820
|
+
patch.unpublishAt = unpublishToken;
|
|
2821
|
+
// `loadRow` above goes through the READ scope; this goes through the UPDATE scope,
|
|
2822
|
+
// which can be narrower. Without the check a role that may read but not update the row
|
|
2823
|
+
// got `{ ok: true }` and two enqueued tasks over a write that never landed — the tasks
|
|
2824
|
+
// then found `scheduledAt` still null, mismatched their intent token, and no-op'd. A
|
|
2825
|
+
// confirmed schedule that silently never fires. Every sibling handler checks this.
|
|
2826
|
+
const updated = await db.update(c.entity, input.id, patch);
|
|
2827
|
+
if (updated === undefined)
|
|
2828
|
+
throw notFound(c.label);
|
|
2829
|
+
await ctx.tasks.enqueue({
|
|
2830
|
+
kind: TASK_COLLECTION_PUBLISH,
|
|
2831
|
+
payload: { collection: c.slug, id: input.id, token: publishToken },
|
|
2832
|
+
delayMs: Math.max(0, input.publishAt - now),
|
|
2833
|
+
});
|
|
2834
|
+
if (typeof input.unpublishAt === "number") {
|
|
2835
|
+
await ctx.tasks.enqueue({
|
|
2836
|
+
kind: TASK_COLLECTION_UNPUBLISH,
|
|
2837
|
+
payload: { collection: c.slug, id: input.id, token: unpublishToken },
|
|
2838
|
+
delayMs: Math.max(0, input.unpublishAt - now),
|
|
2839
|
+
});
|
|
2840
|
+
}
|
|
2841
|
+
return { ok: true, scheduledAt: publishToken, ...(hasUnpublish ? { unpublishAt: unpublishToken } : {}) };
|
|
2842
|
+
}, {
|
|
2843
|
+
...editor,
|
|
2844
|
+
input: (raw) => {
|
|
2845
|
+
const o = asObj(raw);
|
|
2846
|
+
const base = rowInput(raw);
|
|
2847
|
+
// Range-checked, not merely finite — see `epochInput`. An out-of-range value would
|
|
2848
|
+
// otherwise either throw a RangeError inside the transaction (an opaque 500) or
|
|
2849
|
+
// mint an expanded-year ISO string that compares backwards forever.
|
|
2850
|
+
const publishAt = epochInput("publishAt", o.publishAt);
|
|
2851
|
+
// `null` is the explicit "cancel the takedown"; absent leaves it untouched.
|
|
2852
|
+
if (o.unpublishAt !== undefined && o.unpublishAt !== null) {
|
|
2853
|
+
const unpublishAt = epochInput("unpublishAt", o.unpublishAt);
|
|
2854
|
+
if (unpublishAt <= publishAt)
|
|
2855
|
+
throw new BadRequest("unpublishAt must be after publishAt");
|
|
2856
|
+
return { ...base, publishAt, unpublishAt };
|
|
2857
|
+
}
|
|
2858
|
+
// Only `unpublishAt: null` (cancel) and an absent key reach here.
|
|
2859
|
+
return "unpublishAt" in o ? { ...base, publishAt, unpublishAt: null } : { ...base, publishAt };
|
|
2860
|
+
},
|
|
2861
|
+
}),
|
|
2862
|
+
// ---- revisions ----------------------------------------------------------
|
|
2863
|
+
/** A row's revision history, newest first. */
|
|
2864
|
+
collectionListRevisions: query(async (ctx, input) => {
|
|
2865
|
+
const c = def(input.collection);
|
|
2866
|
+
needs(c, "revisions");
|
|
2867
|
+
const db = cdb(ctx);
|
|
2868
|
+
// Read the ROW through the ACL first. `cms_collection_revisions` is shared across
|
|
2869
|
+
// every collection and `collectionPolicies` grants it a flat allow(), so without this
|
|
2870
|
+
// a role holding narrower per-entity read policies could list the snapshots of a
|
|
2871
|
+
// collection it cannot read through `collectionGet`. `collectionRestoreRevision`
|
|
2872
|
+
// already had this via `loadRow`; the list path did not.
|
|
2873
|
+
const row = await loadRow(db, c, input.id);
|
|
2874
|
+
const revs = await db.find({
|
|
2875
|
+
from: COLLECTION_REVISIONS_TABLE,
|
|
2876
|
+
where: { collection: c.slug, rowId: input.id },
|
|
2877
|
+
// By the monotonic counter, never by a timestamp — see the `revision` column.
|
|
2878
|
+
orderBy: { column: "revision", dir: "desc" },
|
|
2879
|
+
limit: input.limit ?? 50,
|
|
2880
|
+
});
|
|
2881
|
+
// Project every snapshot to the fields THIS caller may read on the collection's own
|
|
2882
|
+
// entity. The row check above is a ROW-level gate, and `collectionPolicies` grants the
|
|
2883
|
+
// shared revisions table a flat allow() — so without this a caller holding a
|
|
2884
|
+
// FIELD-restricted read policy (`fields: ["id", "title", "status"]`) reads back the
|
|
2885
|
+
// columns that policy withholds, in full, out of the snapshot JSON. History must not
|
|
2886
|
+
// be a way around the field scope that governs the row itself.
|
|
2887
|
+
//
|
|
2888
|
+
// `loadRow` came through the ACL, and reads are column-projected, so its keys ARE the
|
|
2889
|
+
// caller's readable columns.
|
|
2890
|
+
const readable = new Set(Object.keys(row));
|
|
2891
|
+
return revs.map((r) => ({ ...r, snapshot: projectSnapshot(r.snapshot, readable) }));
|
|
2892
|
+
}, {
|
|
2893
|
+
...editor,
|
|
2894
|
+
input: (raw) => {
|
|
2895
|
+
const o = asObj(raw);
|
|
2896
|
+
if (o.limit !== undefined && (typeof o.limit !== "number" || !Number.isFinite(o.limit)))
|
|
2897
|
+
throw new BadRequest("limit must be a number");
|
|
2898
|
+
// CLAMPED, not just validated: `find` binds this straight into `LIMIT ?`, and SQLite
|
|
2899
|
+
// reads a negative limit as UNBOUNDED — so `limit: -1` would dump a row's entire
|
|
2900
|
+
// history. A fractional value would reach the driver as-is.
|
|
2901
|
+
const limit = o.limit === undefined ? undefined : Math.max(1, Math.min(Math.floor(o.limit), 200));
|
|
2902
|
+
return { ...rowInput(raw), limit };
|
|
2903
|
+
},
|
|
2904
|
+
}),
|
|
2905
|
+
/** Restore a row to one of its revisions. The CURRENT state is snapshotted first, so a
|
|
2906
|
+
* restore is itself undoable. */
|
|
2907
|
+
collectionRestoreRevision: mutation(async (ctx, input) => {
|
|
2908
|
+
const c = def(input.collection);
|
|
2909
|
+
needs(c, "revisions");
|
|
2910
|
+
const db = cdb(ctx);
|
|
2911
|
+
const revs = await db.find({ from: COLLECTION_REVISIONS_TABLE, where: { id: input.revisionId }, limit: 1 });
|
|
2912
|
+
const rev = revs[0];
|
|
2913
|
+
// Scope the revision to THIS collection AND row. A revision id is otherwise a global
|
|
2914
|
+
// handle into a table shared by every collection, so an id from another row — or
|
|
2915
|
+
// another collection entirely — would write a foreign snapshot over this row.
|
|
2916
|
+
if (!rev || String(rev.collection) !== c.slug || String(rev.rowId) !== input.id)
|
|
2917
|
+
throw notFound("revision");
|
|
2918
|
+
const current = await loadRow(db, c, input.id);
|
|
2919
|
+
// A snapshot is built from an ACL-PROJECTED row, so a caller whose read scope excluded
|
|
2920
|
+
// every declared column stored `{}`. `Db.update` returns undefined for a zero-column
|
|
2921
|
+
// patch, which would surface below as "not found" for a row loaded two lines earlier —
|
|
2922
|
+
// a misleading 404. Say what is actually wrong instead.
|
|
2923
|
+
// Restore exactly the fields this caller can READ, for the same reason
|
|
2924
|
+
// `collectionListRevisions` projects them: the snapshot is complete (it was captured
|
|
2925
|
+
// through the raw path), and the shared revisions table is granted flat, so replaying
|
|
2926
|
+
// it whole would let a field-restricted editor write back columns their own read
|
|
2927
|
+
// policy withholds — restoring a value they cannot see, out of a version they cannot
|
|
2928
|
+
// read. What you can see is what you can put back.
|
|
2929
|
+
const visible = projectSnapshot(rev.snapshot, new Set(Object.keys(current)));
|
|
2930
|
+
const restore = toColumns(c, visible, false, current);
|
|
2931
|
+
if (Object.keys(restore).length === 0) {
|
|
2932
|
+
throw new BadRequest(`revision ${input.revisionId} has no restorable fields (none of its columns are readable by this caller)`);
|
|
2933
|
+
}
|
|
2934
|
+
await snapshotRow(db, c, current, ctx, "restore");
|
|
2935
|
+
// Replay through the SAME validate + whitelist as an ordinary write: a snapshot taken
|
|
2936
|
+
// before a field was dropped from `fields` must not resurrect that column, and one
|
|
2937
|
+
// taken before a field's type changed must not bypass validation.
|
|
2938
|
+
const updated = await db.update(c.entity, input.id, restore);
|
|
2939
|
+
if (updated === undefined)
|
|
2940
|
+
throw notFound(c.label);
|
|
2941
|
+
return updated;
|
|
2942
|
+
}, {
|
|
2943
|
+
...editor,
|
|
2944
|
+
input: (raw) => {
|
|
2945
|
+
const o = asObj(raw);
|
|
2946
|
+
if (typeof o.revisionId !== "string" || o.revisionId === "")
|
|
2947
|
+
throw new BadRequest("revisionId is required");
|
|
2948
|
+
return { ...rowInput(raw), revisionId: o.revisionId };
|
|
2949
|
+
},
|
|
2950
|
+
}),
|
|
2951
|
+
// ---- preview ------------------------------------------------------------
|
|
2952
|
+
/** Mint a signed link that shows ONE row's unpublished state, to whoever holds it.
|
|
2953
|
+
* Mirrors `signPagePreview` — same secret, same TTL clamp, same D1 refusal, and the
|
|
2954
|
+
* same rule that the row is read through the ACL FIRST: minting a link is granting
|
|
2955
|
+
* access to the row, so a caller who cannot read it must not be able to mint one. */
|
|
2956
|
+
signCollectionPreview: query(async (ctx, input) => {
|
|
2957
|
+
const c = def(input.collection);
|
|
2958
|
+
needs(c, "preview");
|
|
2959
|
+
const secret = previewSecret(ctx.env);
|
|
2960
|
+
if (!secret)
|
|
2961
|
+
throw previewUnconfigured(); // fail closed — never mint a forgeable link
|
|
2962
|
+
// Redemption always reaches a Durable Object (callPrivileged -> PRAMEN.get) and has no
|
|
2963
|
+
// notion of `x-pramen-store`, so a link minted on D1 would 404 forever while the
|
|
2964
|
+
// editor reported success.
|
|
2965
|
+
if (ctx.store === "d1") {
|
|
2966
|
+
throw new PramenError("collection preview is not available on the D1 store (redemption requires the Durable Object)", 503, "unavailable");
|
|
2967
|
+
}
|
|
2968
|
+
const row = await loadRow(cdb(ctx), c, input.id);
|
|
2969
|
+
const ttl = Math.max(60, Math.min(input.expiresIn ?? previewTtl, 30 * 24 * 3600));
|
|
2970
|
+
const exp = Math.floor(Date.now() / 1000) + ttl;
|
|
2971
|
+
// Server-resolved, never caller-supplied, so the tenant inside the signature cannot
|
|
2972
|
+
// be steered by whoever asks for the link.
|
|
2973
|
+
const token = await signToken({ t: ctx.tenant, c: c.slug, r: String(row[idOf(c)]), exp }, secret);
|
|
2974
|
+
// RELATIVE, like signed file urls — the client resolves it against the CMS origin.
|
|
2975
|
+
return { url: `${COLLECTION_PREVIEW_PATH}?token=${encodeURIComponent(token)}`, token, expiresAt: exp * 1000 };
|
|
2976
|
+
}, {
|
|
2977
|
+
...editor,
|
|
2978
|
+
input: (raw) => {
|
|
2979
|
+
const o = asObj(raw);
|
|
2980
|
+
if (o.expiresIn !== undefined && (typeof o.expiresIn !== "number" || !Number.isFinite(o.expiresIn))) {
|
|
2981
|
+
throw new BadRequest("expiresIn must be a number of seconds");
|
|
2982
|
+
}
|
|
2983
|
+
return { ...rowInput(raw), expiresIn: o.expiresIn };
|
|
2984
|
+
},
|
|
2985
|
+
}),
|
|
2986
|
+
/** Read one row's live (possibly unpublished) state. Not the redemption endpoint — that
|
|
2987
|
+
* is the public `GET /cms/preview/collection` route, which verifies the token and then
|
|
2988
|
+
* calls this privileged. Role-gated so it is not an anonymous back door on /rpc. */
|
|
2989
|
+
getCollectionPreview: query(async (ctx, input) => {
|
|
2990
|
+
const c = def(input.collection);
|
|
2991
|
+
needs(c, "preview");
|
|
2992
|
+
const row = await loadRow(cdb(ctx), c, input.id);
|
|
2993
|
+
const values = { [idOf(c)]: row[idOf(c)] };
|
|
2994
|
+
for (const f of c.fields)
|
|
2995
|
+
if (f.name in row)
|
|
2996
|
+
values[f.name] = row[f.name];
|
|
2997
|
+
if (has(c, "drafts"))
|
|
2998
|
+
values.status = row.status;
|
|
2999
|
+
return { collection: c.slug, id: String(row[idOf(c)]), values };
|
|
3000
|
+
}, { ...viewer, input: rowInput }),
|
|
1613
3001
|
};
|
|
1614
3002
|
}
|
|
1615
3003
|
/** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
|
|
@@ -1628,8 +3016,178 @@ export function collectionPolicies(collections, opts = {}) {
|
|
|
1628
3016
|
out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
|
|
1629
3017
|
}
|
|
1630
3018
|
}
|
|
3019
|
+
// The revision table is SHARED across collections, so it is not covered by the per-entity
|
|
3020
|
+
// grants above. Granting it here — rather than leaning on `cmsPolicies().editor` — keeps
|
|
3021
|
+
// `revisions` self-contained: an app that registers collections without using the
|
|
3022
|
+
// block/page half still gets a working feature instead of a 403 on every write. Read +
|
|
3023
|
+
// create only; a revision is append-only, and nothing exposes editing or purging one.
|
|
3024
|
+
if (collections.some((c) => (c.supports ?? []).includes("revisions"))) {
|
|
3025
|
+
for (const action of ["read", "create"]) {
|
|
3026
|
+
out.push(policy(`${p}:editor:${COLLECTION_REVISIONS_TABLE}:${action}`, COLLECTION_REVISIONS_TABLE, action, allow()));
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
1631
3029
|
return out;
|
|
1632
3030
|
}
|
|
3031
|
+
/** ACL fragments granting ANONYMOUS read of the PUBLISHED rows of every collection that
|
|
3032
|
+
* supports `drafts`. Spread into your public role next to `cmsPolicies().public`:
|
|
3033
|
+
*
|
|
3034
|
+
* role("anonymous", [...cmsPolicies().public, ...collectionPublicPolicies(collections)])
|
|
3035
|
+
*
|
|
3036
|
+
* This is the access boundary, not a UI filter — it is AND-merged into every `ctx.db` read
|
|
3037
|
+
* of the entity, so an unpublished row is invisible to the public API, to relation
|
|
3038
|
+
* traversals and to eager-loads alike, without a single query remembering to filter.
|
|
3039
|
+
*
|
|
3040
|
+
* With `scheduling`, the scope also requires `publishedAt <= $now()`. `status` alone would
|
|
3041
|
+
* not be enough the moment anything writes a future `publishedAt`, and `{ publishedAt:
|
|
3042
|
+
* { isNull: false } }` — the obvious-looking alternative — matches a FUTURE timestamp too,
|
|
3043
|
+
* so a row scheduled for next week would be anonymously readable the moment it was saved.
|
|
3044
|
+
* The comparison is lexicographic over TEXT, which is why every managed timestamp is minted
|
|
3045
|
+
* as ISO-8601 UTC (`isoStamp`), the same shape `$now()` produces.
|
|
3046
|
+
*
|
|
3047
|
+
* Collections WITHOUT `drafts` get nothing here: they have no publish state, so their
|
|
3048
|
+
* public exposure is entirely your app's own policy to write. */
|
|
3049
|
+
export function collectionPublicPolicies(collections, opts = {}) {
|
|
3050
|
+
const p = opts.prefix ?? "cms";
|
|
3051
|
+
const out = [];
|
|
3052
|
+
for (const c of collections) {
|
|
3053
|
+
const features = c.supports ?? [];
|
|
3054
|
+
if (!features.includes("drafts"))
|
|
3055
|
+
continue;
|
|
3056
|
+
const name = `${p}:public:collection:${c.entity}:read`;
|
|
3057
|
+
// The PUBLIC surface is exactly what the collection declares as editable, plus its id
|
|
3058
|
+
// and the two columns a public page legitimately reads: `status` (constant `published`
|
|
3059
|
+
// for every visible row) and, with `scheduling`, `publishedAt`.
|
|
3060
|
+
//
|
|
3061
|
+
// Granting the whole row instead would quietly publish every future column: an
|
|
3062
|
+
// `internalNote` or `reviewerEmail` added to the entity — deliberately NOT a field —
|
|
3063
|
+
// would go world-readable the moment a row was published, with nothing at boot or in
|
|
3064
|
+
// review to catch it. But excluding `publishedAt` went too far the other way: the
|
|
3065
|
+
// single most obvious public query, "newest published first", 403s for anonymous while
|
|
3066
|
+
// working for an editor, because a caller may not order by a column it cannot read.
|
|
3067
|
+
// A publication date is public by construction — it is printed on the page. The
|
|
3068
|
+
// FORWARD-looking columns stay private: `scheduledAt` and `unpublishAt` would leak
|
|
3069
|
+
// "this comes down on Friday" to everyone.
|
|
3070
|
+
const fields = [c.idField ?? "id", ...c.fields.map((f) => f.name), "status", ...(features.includes("scheduling") ? ["publishedAt"] : [])];
|
|
3071
|
+
out.push(features.includes("scheduling")
|
|
3072
|
+
? policy(name, c.entity, "read", {
|
|
3073
|
+
fields,
|
|
3074
|
+
where: {
|
|
3075
|
+
status: COLLECTION_PUBLISHED,
|
|
3076
|
+
// Both time clauses are OR-groups, so they go in an explicit `AND: [...]` —
|
|
3077
|
+
// two `OR` keys in one object literal would be the same property, and the
|
|
3078
|
+
// second would silently REPLACE the first.
|
|
3079
|
+
AND: [
|
|
3080
|
+
{
|
|
3081
|
+
// `publishedAt <= $now()` alone silently hides every published row with
|
|
3082
|
+
// no stamp — a `cmsBootstrap` seed, an import, a row published while the
|
|
3083
|
+
// collection was still `supports: ["drafts"]` — and does it EN MASSE the
|
|
3084
|
+
// moment `scheduling` is added to an existing collection, because
|
|
3085
|
+
// `NULL <= '2026-…'` is NULL, not true. NULL here means "published,
|
|
3086
|
+
// instant unknown", never "scheduled for later": `publishedAt` is a
|
|
3087
|
+
// managed column (never in the write whitelist) that only ever takes
|
|
3088
|
+
// `isoStamp()` or null, and a row awaiting a scheduled publish is
|
|
3089
|
+
// `status: 'draft'` with the instant in `scheduledAt`. So a missing stamp
|
|
3090
|
+
// cannot be a future one, and treating it as "already published" is both
|
|
3091
|
+
// safe and what the editor already shows.
|
|
3092
|
+
OR: [{ publishedAt: { isNull: true } }, { publishedAt: { lte: $now() } }],
|
|
3093
|
+
},
|
|
3094
|
+
{
|
|
3095
|
+
// A scheduled TAKEDOWN has to be enforced HERE, not only by the task. The
|
|
3096
|
+
// publish side is belt-and-braces (policy + task), but without this clause
|
|
3097
|
+
// an unpublish depends entirely on `createCollectionTasks` being wired and
|
|
3098
|
+
// the outbox draining — if either fails, a row an editor scheduled to come
|
|
3099
|
+
// down stays world-readable indefinitely, with no signal. That is the
|
|
3100
|
+
// wrong way round for a takedown, the direction that matters legally.
|
|
3101
|
+
OR: [{ unpublishAt: { isNull: true } }, { unpublishAt: { gt: $now() } }],
|
|
3102
|
+
},
|
|
3103
|
+
],
|
|
3104
|
+
},
|
|
3105
|
+
})
|
|
3106
|
+
: policy(name, c.entity, "read", { fields, where: { status: COLLECTION_PUBLISHED } }));
|
|
3107
|
+
}
|
|
3108
|
+
return out;
|
|
3109
|
+
}
|
|
3110
|
+
/** Task handlers backing `collectionSchedule`. Register alongside `cmsTasks`:
|
|
3111
|
+
*
|
|
3112
|
+
* const app = { tasks: { ...cmsTasks, ...createCollectionTasks(collections) } };
|
|
3113
|
+
*
|
|
3114
|
+
* WITHOUT THIS WIRING A SCHEDULE NEVER FIRES: `collectionSchedule` still stores the
|
|
3115
|
+
* instants and enqueues the tasks, but the drain finds no handler for their kind, so the
|
|
3116
|
+
* row silently stays a draft. (`cmsTasks` has the same requirement for page scheduling.)
|
|
3117
|
+
*
|
|
3118
|
+
* They run with a privileged, system-scoped ctx off the write path, and each validates its
|
|
3119
|
+
* INTENT TOKEN against the row's current `scheduledAt`/`unpublishAt` before acting — see
|
|
3120
|
+
* `collectionSchedule`. */
|
|
3121
|
+
export function createCollectionTasks(collections) {
|
|
3122
|
+
const bySlug = new Map(collections.map((c) => [c.slug, c]));
|
|
3123
|
+
const run = async (ctx, payload, tokenColumn, buildPatch) => {
|
|
3124
|
+
const { collection, id, token } = asObj(payload);
|
|
3125
|
+
if (typeof collection !== "string" || typeof id !== "string")
|
|
3126
|
+
return;
|
|
3127
|
+
// Resolved through the REGISTRY, exactly as the handlers do — the payload's `collection`
|
|
3128
|
+
// is never used as a table name.
|
|
3129
|
+
const c = bySlug.get(collection);
|
|
3130
|
+
// A slug the handlers accept but this registry does not know is a WIRING mistake:
|
|
3131
|
+
// `createCollectionHandlers(collections)` and `createCollectionTasks(otherList)` built
|
|
3132
|
+
// from different arrays. Returning quietly made the drain report `{ succeeded: 1 }`
|
|
3133
|
+
// while the row stayed a draft forever — strictly worse than not registering the tasks
|
|
3134
|
+
// at all, which dead-letters loudly. Throw so the outbox retries and then dead-letters
|
|
3135
|
+
// with the slug in the message.
|
|
3136
|
+
if (!c)
|
|
3137
|
+
throw new Error(`pramen/cms: no collection '${collection}' in createCollectionTasks' registry — pass the SAME collections array to createCollectionHandlers and createCollectionTasks`);
|
|
3138
|
+
const db = cdb(ctx);
|
|
3139
|
+
const rows = await db.find({ from: c.entity, where: { [c.idField ?? "id"]: id }, limit: 1 });
|
|
3140
|
+
const row = rows[0];
|
|
3141
|
+
if (!row)
|
|
3142
|
+
return;
|
|
3143
|
+
// Intent check: act only if this task is still the row's active schedule. A reschedule
|
|
3144
|
+
// (new token), a manual publish/unpublish (token cleared) or a duplicate delivery after
|
|
3145
|
+
// this task already ran all make it a no-op.
|
|
3146
|
+
//
|
|
3147
|
+
// Require a NON-EMPTY token on both sides. Comparing the coalesced strings alone treats
|
|
3148
|
+
// "no token in the payload" and "no schedule on the row" as a MATCH — so a payload
|
|
3149
|
+
// without a token (a hand-drained or replayed outbox row) against a row whose
|
|
3150
|
+
// `scheduledAt` is null, the normal state right after an unpublish, would publish it
|
|
3151
|
+
// unconditionally. The guard should fail closed, not open.
|
|
3152
|
+
const stored = typeof row[tokenColumn] === "string" ? row[tokenColumn] : "";
|
|
3153
|
+
if (!token || !stored || stored !== token)
|
|
3154
|
+
return;
|
|
3155
|
+
await db.update(c.entity, id, buildPatch(row, isoStamp()));
|
|
3156
|
+
};
|
|
3157
|
+
return {
|
|
3158
|
+
[TASK_COLLECTION_PUBLISH]: (ctx, payload) => run(ctx, payload, "scheduledAt", (row, now) => {
|
|
3159
|
+
// CONVERGE to the state the schedule implies AT `now`, rather than blindly applying
|
|
3160
|
+
// the step this task was enqueued for. A drain can lag arbitrarily (D1 cron
|
|
3161
|
+
// granularity, outbox backoff, a stalled DO alarm), and the two tasks can arrive in
|
|
3162
|
+
// either order, so "publish" has to mean "publish IF the takedown has not come yet".
|
|
3163
|
+
//
|
|
3164
|
+
// The previous behavior — publish anyway, and null the passed `unpublishAt` to keep
|
|
3165
|
+
// the row visible — destroyed a scheduled takedown outright: the token the unpublish
|
|
3166
|
+
// task compares against was gone, so it no-op'd, and the read scope's
|
|
3167
|
+
// `unpublishAt IS NULL OR unpublishAt > $now()` backstop had nothing left to enforce.
|
|
3168
|
+
// A row scheduled to come down at noon stayed world-readable forever, silently.
|
|
3169
|
+
// A takedown that failing OPEN cannot be recovered from is the wrong way round.
|
|
3170
|
+
if (typeof row.unpublishAt === "string" && row.unpublishAt !== "" && row.unpublishAt <= now) {
|
|
3171
|
+
// Both instants are in the past: the row's whole scheduled life has elapsed.
|
|
3172
|
+
// Land on the END state (down), and spend both tokens so neither task can fire
|
|
3173
|
+
// against this schedule again.
|
|
3174
|
+
return { status: COLLECTION_DRAFT, publishedAt: null, scheduledAt: null, unpublishAt: null };
|
|
3175
|
+
}
|
|
3176
|
+
// The ordinary case: publish now, keep a still-future takedown standing.
|
|
3177
|
+
return { status: COLLECTION_PUBLISHED, publishedAt: now, scheduledAt: null };
|
|
3178
|
+
}),
|
|
3179
|
+
[TASK_COLLECTION_UNPUBLISH]: (ctx, payload) =>
|
|
3180
|
+
// Clear `scheduledAt` too — the third column `collectionUnpublish` clears and this
|
|
3181
|
+
// task used to leave behind. A pending publish token that survived a takedown is a
|
|
3182
|
+
// live re-publish: if the publish task drains after this one (either order is
|
|
3183
|
+
// possible) or is retried after a throw, it finds its token still matching and puts
|
|
3184
|
+
// the row back up — with `unpublishAt` now null, permanently and with no repair path.
|
|
3185
|
+
// A schedule always orders publish BEFORE takedown (`collectionSchedule` enforces it
|
|
3186
|
+
// against the stored value as well as the submitted one), so any `scheduledAt` still
|
|
3187
|
+
// standing at the takedown is spent by definition.
|
|
3188
|
+
run(ctx, payload, "unpublishAt", () => ({ status: COLLECTION_DRAFT, publishedAt: null, scheduledAt: null, unpublishAt: null })),
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
1633
3191
|
// --- deferred tasks (scheduled publish/unpublish) ----------------------------
|
|
1634
3192
|
/** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
|
|
1635
3193
|
* They run with a privileged, system-scoped ctx off the write path (the outbox drain).
|
|
@@ -1696,11 +3254,23 @@ export function robotsTxt(opts) {
|
|
|
1696
3254
|
const dis = (opts.disallow ?? []).map((p) => `Disallow: ${p}`).join("\n");
|
|
1697
3255
|
return `User-agent: *\n${dis ? dis + "\n" : "Allow: /\n"}Sitemap: ${opts.origin}/sitemap.xml\n`;
|
|
1698
3256
|
}
|
|
1699
|
-
|
|
3257
|
+
const previewJson = (status, code, message) =>
|
|
3258
|
+
// `{ ok, error, code }` — the shape every other pramen error uses (runtime/errors.ts).
|
|
3259
|
+
new Response(JSON.stringify({ ok: false, error: message, code }), {
|
|
3260
|
+
status,
|
|
3261
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
|
|
3262
|
+
});
|
|
3263
|
+
/** One response for a missing, malformed, forged, or expired token — and for a page that
|
|
3264
|
+
* is not there. Distinguishing them would let a caller probe for valid page ids. */
|
|
3265
|
+
const previewDenied = (status = 403, message = "invalid or expired preview link") => previewJson(status, status === 404 ? "not_found" : "forbidden", message);
|
|
3266
|
+
const preview503 = () => previewJson(503, "unavailable", "page preview is not configured (set a strong PREVIEW_SECRET, FILES_SECRET or AUTH_SECRET)");
|
|
3267
|
+
/** Turnkey public routes for `GET /sitemap.xml`, `GET /cms/preview` and `GET /robots.txt`. Spread into
|
|
1700
3268
|
* `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
|
|
1701
3269
|
* `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
|
|
1702
3270
|
export function cmsRoutes(opts = {}) {
|
|
1703
3271
|
const tenant = opts.tenant ?? "main";
|
|
3272
|
+
const previewRoles = opts.viewerRoles ?? viewerRolesOf(opts.handlers);
|
|
3273
|
+
const collectionPreviewRoles = opts.viewerRoles ?? viewerRolesOf(opts.collectionHandlers ?? opts.handlers);
|
|
1704
3274
|
return [
|
|
1705
3275
|
{
|
|
1706
3276
|
method: "GET",
|
|
@@ -1713,6 +3283,90 @@ export function cmsRoutes(opts = {}) {
|
|
|
1713
3283
|
return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
|
|
1714
3284
|
},
|
|
1715
3285
|
},
|
|
3286
|
+
{
|
|
3287
|
+
// Redeem a preview link. PUBLIC and pre-auth by design — the signature IS the
|
|
3288
|
+
// authorization, so a reviewer with no account can open it. The token is verified
|
|
3289
|
+
// BEFORE any read, and it names one page, so a valid signature never widens into
|
|
3290
|
+
// "see all drafts". Only then do we reach the DO, privileged.
|
|
3291
|
+
method: "GET",
|
|
3292
|
+
path: PREVIEW_PATH,
|
|
3293
|
+
handler: async (request, env, ctx) => {
|
|
3294
|
+
const secret = previewSecret(env);
|
|
3295
|
+
// Fail closed: with no usable secret every signature would verify against a weak
|
|
3296
|
+
// key, so refuse to verify at all rather than accept forged links.
|
|
3297
|
+
if (!secret)
|
|
3298
|
+
return preview503();
|
|
3299
|
+
const raw = new URL(request.url).searchParams.get("token");
|
|
3300
|
+
if (!raw)
|
|
3301
|
+
return previewDenied();
|
|
3302
|
+
const payload = await verifyToken(raw, secret);
|
|
3303
|
+
if (!payload || typeof payload.p !== "string" || typeof payload.t !== "string")
|
|
3304
|
+
return previewDenied();
|
|
3305
|
+
// The synthetic identity has to satisfy BOTH gates the DO applies: the handler's
|
|
3306
|
+
// `auth` (viewerRoles) and the row ACL (whatever role the app granted cmsPolicies
|
|
3307
|
+
// to). Hardcoding ["admin"] satisfied neither under the wiring this package's own
|
|
3308
|
+
// README documents — `role("anonymous", …)` + `role("editor", …)`, no admin role
|
|
3309
|
+
// at all — so every preview link 404'd. The e2e only passed because example/app.ts
|
|
3310
|
+
// happens to define an admin role. Send the configured viewer roles instead.
|
|
3311
|
+
const res = await ctx.callPrivileged({
|
|
3312
|
+
name: "getPagePreview",
|
|
3313
|
+
input: { pageId: payload.p },
|
|
3314
|
+
tenant: payload.t, // from the SIGNED payload, never from the query string
|
|
3315
|
+
roles: [...previewRoles],
|
|
3316
|
+
});
|
|
3317
|
+
const body = (await res.json().catch(() => ({})));
|
|
3318
|
+
if (body.ok !== true) {
|
|
3319
|
+
// The client-visible response stays uniform (probe resistance), but LOG the real
|
|
3320
|
+
// reason: a role misconfiguration previously surfaced as an indistinguishable
|
|
3321
|
+
// "page not found" that could only be diagnosed by reading source.
|
|
3322
|
+
console.error(`pramen/cms: preview redemption failed (${res.status} ${body.code ?? "?"}: ${body.error ?? "no detail"})`);
|
|
3323
|
+
return previewDenied(404, "page not found");
|
|
3324
|
+
}
|
|
3325
|
+
// Never cache a draft, anywhere.
|
|
3326
|
+
return new Response(JSON.stringify(body.result), {
|
|
3327
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
|
|
3328
|
+
});
|
|
3329
|
+
},
|
|
3330
|
+
},
|
|
3331
|
+
{
|
|
3332
|
+
// Redeem a COLLECTION preview link. Same contract as the page preview route above:
|
|
3333
|
+
// public and pre-auth by design (the signature IS the authorization, so a reviewer
|
|
3334
|
+
// with no account can open it), verified BEFORE any read, and scoped by the signed
|
|
3335
|
+
// payload to one row of one collection — a valid signature never widens into "see
|
|
3336
|
+
// every draft".
|
|
3337
|
+
method: "GET",
|
|
3338
|
+
path: COLLECTION_PREVIEW_PATH,
|
|
3339
|
+
handler: async (request, env, ctx) => {
|
|
3340
|
+
const secret = previewSecret(env);
|
|
3341
|
+
// Fail closed: with no usable secret every signature would verify against a weak
|
|
3342
|
+
// key, so refuse to verify at all rather than accept forged links.
|
|
3343
|
+
if (!secret)
|
|
3344
|
+
return preview503();
|
|
3345
|
+
const raw = new URL(request.url).searchParams.get("token");
|
|
3346
|
+
if (!raw)
|
|
3347
|
+
return previewDenied();
|
|
3348
|
+
const payload = await verifyToken(raw, secret);
|
|
3349
|
+
if (!payload || typeof payload.c !== "string" || typeof payload.r !== "string" || typeof payload.t !== "string")
|
|
3350
|
+
return previewDenied();
|
|
3351
|
+
const res = await ctx.callPrivileged({
|
|
3352
|
+
name: "getCollectionPreview",
|
|
3353
|
+
input: { collection: payload.c, id: payload.r },
|
|
3354
|
+
tenant: payload.t, // from the SIGNED payload, never from the query string
|
|
3355
|
+
roles: [...collectionPreviewRoles], // the COLLECTION handlers' gate — see `collectionHandlers`
|
|
3356
|
+
});
|
|
3357
|
+
const body = (await res.json().catch(() => ({})));
|
|
3358
|
+
if (body.ok !== true) {
|
|
3359
|
+
// Uniform client-visible response (probe resistance), but LOG the real reason —
|
|
3360
|
+
// a role or wiring mistake here is otherwise indistinguishable from "not found".
|
|
3361
|
+
console.error(`pramen/cms: collection preview redemption failed (${res.status} ${body.code ?? "?"}: ${body.error ?? "no detail"})`);
|
|
3362
|
+
return previewDenied(404, "not found");
|
|
3363
|
+
}
|
|
3364
|
+
// Never cache a draft, anywhere.
|
|
3365
|
+
return new Response(JSON.stringify(body.result), {
|
|
3366
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "private, no-store" },
|
|
3367
|
+
});
|
|
3368
|
+
},
|
|
3369
|
+
},
|
|
1716
3370
|
{
|
|
1717
3371
|
method: "GET",
|
|
1718
3372
|
path: "/robots.txt",
|