@pramen/cms 0.0.14 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1342 @@
1
+ // @pramen/cms — a Drupal-Paragraphs-style block/page builder, built as an ordinary
2
+ // pramen app fragment (schema + handlers + ACL + tasks). Nothing here is a new runtime
3
+ // primitive: it composes the ones pramen already ships — `t.json()` for block field
4
+ // payloads, `t.fileRef()`+R2 for media, relations for page↔block traversal, the ACL for
5
+ // editor RBAC, and `ctx.tasks` (the transactional outbox) for scheduled publish/unpublish.
6
+ //
7
+ // Model (borrowed from WollyCMS / Drupal Paragraphs):
8
+ // - a BLOCK TYPE is a schema — a slug + a recursive field schema (data-driven, so a
9
+ // webmaster adds a type with no deploy);
10
+ // - a BLOCK is an instance — content matching a type's field schema (optionally reusable);
11
+ // - a PAGE has a content type, which declares named REGIONS (each with an allow-list of
12
+ // block types) and optional DEFAULT BLOCKS;
13
+ // - a PAGE_BLOCK places a block into a page's region at a position, optionally as a
14
+ // SHARED block with per-placement field OVERRIDES.
15
+ //
16
+ // Publishing writes a fully-assembled JSON snapshot into a revision; the public content
17
+ // API serves that snapshot (fast, and it never exposes unpublished block rows directly).
18
+ // Rendering stays the frontend's job — see `@pramen/cms/react` (BlockRenderer).
19
+ //
20
+ // Usage:
21
+ // import { cmsSchema, cmsHandlers, cmsPolicies, cmsTasks } from "@pramen/cms";
22
+ // const schema = defineSchema({ ...cmsSchema, ...yourEntities });
23
+ // const handlers = { ...cmsHandlers, ...yourHandlers };
24
+ // const acl = [ role("anonymous", [...cmsPolicies().public]),
25
+ // role("editor", [...cmsPolicies().editor]) ];
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
+ /** Declare a typed block type. Pass `fields as const` to preserve the literals so
29
+ * `BlockFieldsOf<typeof def>` infers the field shape:
30
+ *
31
+ * const hero = defineBlockType("hero", [
32
+ * { name: "heading", type: "text", required: true },
33
+ * { name: "image", type: "media" },
34
+ * ] as const);
35
+ * type HeroFields = BlockFieldsOf<typeof hero>; // { heading: string; image: ResolvedMedia|null|undefined }
36
+ *
37
+ * Spread `hero` (minus fieldsSchema key naming) into `createBlockType`, and use
38
+ * `BlockFieldsOf<typeof hero>` to type the block's React component. */
39
+ export function defineBlockType(slug, fields, opts = {}) {
40
+ return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
41
+ }
42
+ // --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
43
+ //
44
+ // The data-driven half (webmaster-created block types) has no static type. This is the
45
+ // runtime mirror of `InferBlockFields`: read `cms_block_types.fieldsSchema` rows and emit a
46
+ // `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry. A future
47
+ // `pramen cms codegen` CLI command fetches the rows over HTTP and writes the output.
48
+ const pascal = (s) => s.replace(/(^|[_-])(\w)/g, (_m, _sep, c) => c.toUpperCase());
49
+ function tsTypeOf(f) {
50
+ switch (f.type) {
51
+ case "text":
52
+ case "textarea":
53
+ case "url":
54
+ case "select":
55
+ return "string";
56
+ case "richtext":
57
+ return "RichText";
58
+ case "number":
59
+ return "number";
60
+ case "boolean":
61
+ return "boolean";
62
+ case "media":
63
+ return "ResolvedMedia | null";
64
+ case "group":
65
+ return `{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }`;
66
+ case "repeater":
67
+ return `Array<{ ${(f.fields ?? []).map(tsFieldLine).join(" ")} }>`;
68
+ default:
69
+ return "unknown";
70
+ }
71
+ }
72
+ const tsFieldLine = (f) => `${JSON.stringify(f.name)}${f.required ? "" : "?"}: ${tsTypeOf(f)};`;
73
+ /** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
74
+ * DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
75
+ * compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
76
+ export function generateBlockTypes(blockTypes) {
77
+ const interfaces = blockTypes
78
+ .map((bt) => {
79
+ const fields = Array.isArray(bt.fieldsSchema) ? bt.fieldsSchema : [];
80
+ const body = fields.map((f) => ` ${tsFieldLine(f)}`).join("\n");
81
+ return `export interface ${pascal(bt.slug)}Fields {\n${body}\n}`;
82
+ })
83
+ .join("\n\n");
84
+ const registry = blockTypes.map((bt) => ` ${JSON.stringify(bt.slug)}: ${pascal(bt.slug)}Fields;`).join("\n");
85
+ return (`// AUTO-GENERATED by @pramen/cms — do not edit.\n` +
86
+ `import type { ResolvedMedia, RichText } from "@pramen/cms";\n\n` +
87
+ `${interfaces}\n\nexport interface BlockFieldsBySlug {\n${registry}\n}\n`);
88
+ }
89
+ // --- schema fragment: spread into your defineSchema so the tables migrate --------
90
+ /** The block/page builder tables. All in the default partition (relations can't cross
91
+ * partitions). Prefixed `cms_` to avoid colliding with your own entities. */
92
+ export const cmsSchema = {
93
+ cms_content_types: Entity((t) => ({
94
+ id: primaryKey(generated(t.uuid())),
95
+ name: notNull(t.text()),
96
+ slug: unique(notNull(t.text())),
97
+ description: t.text(),
98
+ fieldsSchema: t.json(), // FieldDefinition[] for page-level fields
99
+ regions: t.json(), // RegionDefinition[]
100
+ defaultBlocks: t.json(), // DefaultBlockDefinition[]
101
+ createdAt: defaultTo(t.text(), expr.now()),
102
+ })),
103
+ cms_block_types: Entity((t) => ({
104
+ id: primaryKey(generated(t.uuid())),
105
+ name: notNull(t.text()),
106
+ slug: unique(notNull(t.text())),
107
+ description: t.text(),
108
+ fieldsSchema: t.json(), // FieldDefinition[]
109
+ icon: t.text(),
110
+ category: t.text(),
111
+ createdAt: defaultTo(t.text(), expr.now()),
112
+ })),
113
+ cms_blocks: Entity((t) => ({
114
+ id: primaryKey(generated(t.uuid())),
115
+ typeId: notNull(t.uuid()),
116
+ title: t.text(),
117
+ fields: t.json(), // content matching the block type's fieldsSchema
118
+ isReusable: defaultTo(t.bool(), false),
119
+ createdAt: defaultTo(t.text(), expr.now()),
120
+ updatedAt: defaultTo(t.text(), expr.now()),
121
+ }), (r) => ({ type: r.belongsTo("cms_block_types", "typeId") })),
122
+ cms_pages: Entity((t) => ({
123
+ id: primaryKey(generated(t.uuid())),
124
+ typeId: notNull(t.uuid()),
125
+ title: notNull(t.text()),
126
+ // NOT globally unique — a slug is unique PER LOCALE (`/en/about` + `/cs/about`).
127
+ // pramen's unique() is single-column only, so (slug, locale) uniqueness is enforced
128
+ // in createPage/createTranslation; this index just speeds the lookup.
129
+ slug: indexed(notNull(t.text())),
130
+ status: defaultTo(t.text(), "draft"), // draft | published | archived
131
+ locale: defaultTo(t.text(), "en"),
132
+ // Links a page to its translations: all locales of one logical page share this id.
133
+ // Auto-minted for a standalone page; a translation is created with the source's id.
134
+ translationGroupId: generated(t.uuid()),
135
+ fields: t.json(),
136
+ publishedAt: t.text(),
137
+ scheduledAt: t.text(),
138
+ unpublishAt: t.text(),
139
+ // The revision the public content API serves — set on publish. A direct pointer
140
+ // (not "latest by timestamp") so selection is deterministic even when two publishes
141
+ // land in the same second (expr.now() is second-precision).
142
+ currentRevisionId: t.uuid(),
143
+ // SEO
144
+ metaTitle: t.text(),
145
+ metaDescription: t.text(),
146
+ canonicalUrl: t.text(),
147
+ robots: t.text(), // e.g. "noindex, nofollow"
148
+ ogTitle: t.text(),
149
+ ogDescription: t.text(),
150
+ ogImage: t.uuid(), // a cms_media id, resolved to a URL at assemble time
151
+ structuredData: t.json(), // JSON-LD, emitted as-is into <head>
152
+ createdAt: defaultTo(t.text(), expr.now()),
153
+ updatedAt: defaultTo(t.text(), expr.now()),
154
+ }), (r) => ({
155
+ type: r.belongsTo("cms_content_types", "typeId"),
156
+ placements: r.hasMany("cms_page_blocks", "pageId"),
157
+ })),
158
+ cms_page_blocks: Entity((t) => ({
159
+ id: primaryKey(generated(t.uuid())),
160
+ pageId: notNull(t.uuid()),
161
+ blockId: notNull(t.uuid()),
162
+ region: notNull(t.text()),
163
+ position: notNull(t.int()),
164
+ isShared: defaultTo(t.bool(), false),
165
+ overrides: t.json(), // per-placement field overrides (merged over the block's fields)
166
+ }), (r) => ({
167
+ page: r.belongsTo("cms_pages", "pageId"),
168
+ block: r.belongsTo("cms_blocks", "blockId"),
169
+ })),
170
+ cms_page_revisions: Entity((t) => ({
171
+ id: primaryKey(generated(t.uuid())),
172
+ pageId: notNull(t.uuid()),
173
+ title: t.text(),
174
+ status: t.text(),
175
+ snapshot: t.json(), // the fully-assembled page + regions at publish time
176
+ note: t.text(),
177
+ actor: t.text(), // the identity.userId that published (null = system/unknown)
178
+ createdAt: defaultTo(t.text(), expr.now()),
179
+ }), (r) => ({ page: r.belongsTo("cms_pages", "pageId") })),
180
+ // Append-only audit trail of workflow transitions (who moved a page between states,
181
+ // when, with an optional note). In the DEFAULT partition — the transition handlers write
182
+ // it synchronously (transactional with the state change, and they hold the actor from
183
+ // ctx.identity); a handler can't write across a partition boundary, so an isolated
184
+ // audit partition isn't reachable from here.
185
+ cms_audit: Entity((t) => ({
186
+ id: primaryKey(generated(t.uuid())),
187
+ pageId: indexed(t.uuid()),
188
+ action: notNull(t.text()), // submit | approve | reject | publish | unpublish
189
+ fromStatus: t.text(),
190
+ toStatus: t.text(),
191
+ actor: t.text(),
192
+ note: t.text(),
193
+ createdAt: defaultTo(t.text(), expr.now()),
194
+ })),
195
+ // Media: a fileRef column holds only R2 metadata; bytes live in R2, uploaded via
196
+ // ctx.files + the Worker /files/* route. Block `fields` reference a media id.
197
+ cms_media: Entity((t) => ({
198
+ id: primaryKey(generated(t.uuid())),
199
+ file: t.fileRef(),
200
+ alt: t.text(),
201
+ createdAt: defaultTo(t.text(), expr.now()),
202
+ })),
203
+ };
204
+ /** Validate a block/page's `fields` payload against a field schema, throwing a 400 on
205
+ * the first violation. Recursive (repeater/group). Lenient on unknown field types. */
206
+ export function validateFields(schema, values, path = "", opts = {}) {
207
+ const requireRequired = opts.requireRequired !== false;
208
+ const defs = Array.isArray(schema) ? schema : [];
209
+ const obj = (values ?? {});
210
+ if (typeof obj !== "object" || Array.isArray(obj))
211
+ throw new BadRequest(`${path || "fields"} must be an object`);
212
+ for (const def of defs) {
213
+ const at = path ? `${path}.${def.name}` : def.name;
214
+ const v = obj[def.name];
215
+ const missing = v === undefined || v === null || v === "";
216
+ if (missing) {
217
+ if (def.required && requireRequired)
218
+ throw new BadRequest(`field '${at}' is required`);
219
+ continue;
220
+ }
221
+ switch (def.type) {
222
+ case "text":
223
+ case "textarea":
224
+ case "url":
225
+ case "select":
226
+ if (typeof v !== "string")
227
+ throw new BadRequest(`field '${at}' must be a string`);
228
+ if (def.type === "select" && def.options && !def.options.includes(v)) {
229
+ throw new BadRequest(`field '${at}' must be one of: ${def.options.join(", ")}`);
230
+ }
231
+ break;
232
+ case "richtext":
233
+ if (typeof v !== "string" && typeof v !== "object")
234
+ throw new BadRequest(`field '${at}' must be rich text`);
235
+ break;
236
+ case "number":
237
+ if (typeof v !== "number")
238
+ throw new BadRequest(`field '${at}' must be a number`);
239
+ break;
240
+ case "boolean":
241
+ if (typeof v !== "boolean")
242
+ throw new BadRequest(`field '${at}' must be a boolean`);
243
+ break;
244
+ case "media":
245
+ // Media ids are uuids (strings) — reject numbers so the value always resolves
246
+ // (collectMediaIds/resolveMediaFields only handle string ids).
247
+ if (typeof v !== "string")
248
+ throw new BadRequest(`field '${at}' must be a media id (string)`);
249
+ break;
250
+ case "group":
251
+ validateFields(def.fields, v, at, opts);
252
+ break;
253
+ case "repeater": {
254
+ if (!Array.isArray(v))
255
+ throw new BadRequest(`field '${at}' must be a list`);
256
+ if (def.min != null && v.length < def.min)
257
+ throw new BadRequest(`field '${at}' needs at least ${def.min} item(s)`);
258
+ if (def.max != null && v.length > def.max)
259
+ throw new BadRequest(`field '${at}' allows at most ${def.max} item(s)`);
260
+ v.forEach((item, i) => validateFields(def.fields, item, `${at}[${i}]`, opts));
261
+ break;
262
+ }
263
+ default:
264
+ break; // unknown type — don't block
265
+ }
266
+ }
267
+ }
268
+ /** The public serving path for a media blob (relative; the client resolves it against
269
+ * its base). Served by the Worker's public `GET /media/<key>` route. */
270
+ export function mediaPath(key) {
271
+ return `/media/${key}`;
272
+ }
273
+ /** Build a URL for a media blob, optionally with Cloudflare Image Resizing transforms
274
+ * (`/cdn-cgi/image/<opts>/…`). With no transform opts it's just `mediaPath` (optionally
275
+ * prefixed by `origin`). Transforms need the deploy's origin to resolve the source path. */
276
+ export function imageUrl(key, opts = {}) {
277
+ const path = mediaPath(key);
278
+ const origin = opts.origin ?? "";
279
+ const hasTransform = opts.width || opts.height || opts.quality || opts.format;
280
+ if (!hasTransform)
281
+ return `${origin}${path}`;
282
+ const params = [];
283
+ if (opts.width)
284
+ params.push(`width=${opts.width}`);
285
+ if (opts.height)
286
+ params.push(`height=${opts.height}`);
287
+ if (opts.quality)
288
+ params.push(`quality=${opts.quality}`);
289
+ params.push(`format=${opts.format ?? "auto"}`);
290
+ return `${origin}/cdn-cgi/image/${params.join(",")}${path}`;
291
+ }
292
+ /** Collect the media ids referenced by a fields payload, walking group/repeater nesting. */
293
+ function collectMediaIds(fields, schema, acc) {
294
+ if (!Array.isArray(schema))
295
+ return;
296
+ for (const def of schema) {
297
+ const v = fields[def.name];
298
+ if (v == null)
299
+ continue;
300
+ if (def.type === "media") {
301
+ if (typeof v === "string")
302
+ acc.add(v);
303
+ }
304
+ else if (def.type === "group") {
305
+ collectMediaIds(asObj(v), def.fields, acc);
306
+ }
307
+ else if (def.type === "repeater" && Array.isArray(v)) {
308
+ for (const item of v)
309
+ collectMediaIds(asObj(item), def.fields, acc);
310
+ }
311
+ }
312
+ }
313
+ /** Return a copy of `fields` with every `"media"` field resolved from its id to a
314
+ * `ResolvedMedia` (or null), recursing into group/repeater nesting. */
315
+ function resolveMediaFields(fields, schema, mediaById) {
316
+ if (!Array.isArray(schema))
317
+ return fields;
318
+ const out = { ...fields };
319
+ for (const def of schema) {
320
+ const v = out[def.name];
321
+ if (v == null)
322
+ continue;
323
+ if (def.type === "media") {
324
+ if (typeof v === "string")
325
+ out[def.name] = mediaById.get(v) ?? null;
326
+ }
327
+ else if (def.type === "group") {
328
+ out[def.name] = resolveMediaFields(asObj(v), def.fields, mediaById);
329
+ }
330
+ else if (def.type === "repeater" && Array.isArray(v)) {
331
+ out[def.name] = v.map((item) => resolveMediaFields(asObj(item), def.fields, mediaById));
332
+ }
333
+ }
334
+ return out;
335
+ }
336
+ const cdb = (ctx) => ctx.db;
337
+ const notFound = (what) => new PramenError(`${what} not found`, 404, "not_found");
338
+ const asObj = (v) => (v && typeof v === "object" ? v : {});
339
+ // Timestamps in the SAME shape as the `expr.now()` column default (`datetime('now')`:
340
+ // "YYYY-MM-DD HH:MM:SS", UTC, second precision) so a column's insert-default and its
341
+ // handler-written updates stay lexically comparable (an ISO `T`/`Z` string sorts wrong).
342
+ const nowStamp = () => new Date().toISOString().slice(0, 19).replace("T", " ");
343
+ const isEditor = (ctx, roles) => {
344
+ const held = ctx.identity?.roles ?? (ctx.identity?.role ? [ctx.identity.role] : []);
345
+ return held.some((r) => roles.includes(r));
346
+ };
347
+ /** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
348
+ * by position, merging each shared placement's `overrides` over its block's fields. */
349
+ async function assembleLive(db, page) {
350
+ const placements = await db.find({
351
+ from: "cms_page_blocks",
352
+ where: { pageId: page.id },
353
+ orderBy: [{ column: "region" }, { column: "position" }],
354
+ with: { block: true },
355
+ });
356
+ const typeIds = [...new Set(placements.map((p) => asObj(p.block).typeId).filter((v) => typeof v === "string"))];
357
+ const types = typeIds.length ? await db.find({ from: "cms_block_types", where: { id: { in: typeIds } } }) : [];
358
+ const typeById = new Map(types.map((t) => [t.id, { slug: t.slug, fieldsSchema: t.fieldsSchema }]));
359
+ // Merge each placement's fields (base + shared overrides), then resolve `"media"`
360
+ // fields (id → ResolvedMedia) in one batched lookup across the whole page.
361
+ const merged = placements.map((p) => {
362
+ const block = asObj(p.block);
363
+ const fields = { ...asObj(block.fields), ...(p.isShared ? asObj(p.overrides) : {}) };
364
+ return { p, block, fields, schema: typeById.get(String(block.typeId))?.fieldsSchema };
365
+ });
366
+ const mediaIds = new Set();
367
+ for (const m of merged)
368
+ collectMediaIds(m.fields, m.schema, mediaIds);
369
+ const mediaById = new Map();
370
+ if (mediaIds.size) {
371
+ const rows = await db.find({ from: "cms_media", where: { id: { in: [...mediaIds] } } });
372
+ for (const r of rows) {
373
+ const file = asObj(r.file);
374
+ mediaById.set(String(r.id), {
375
+ id: String(r.id),
376
+ key: String(file.key ?? ""),
377
+ url: mediaPath(String(file.key ?? "")),
378
+ alt: r.alt ?? null,
379
+ contentType: file.contentType ?? null,
380
+ filename: file.filename ?? null,
381
+ });
382
+ }
383
+ }
384
+ const regions = {};
385
+ for (const m of merged) {
386
+ const region = String(m.p.region);
387
+ (regions[region] ??= []).push({
388
+ id: String(m.p.id),
389
+ block_id: String(m.block.id),
390
+ block_type: typeById.get(String(m.block.typeId))?.slug ?? "unknown",
391
+ title: m.block.title ?? null,
392
+ fields: resolveMediaFields(m.fields, m.schema, mediaById),
393
+ is_shared: Boolean(m.p.isShared),
394
+ });
395
+ }
396
+ const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
397
+ return { page: pageMeta(page, translations, ogImage), regions };
398
+ }
399
+ /** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
400
+ async function resolveMediaId(db, id) {
401
+ if (typeof id !== "string" || !id)
402
+ return null;
403
+ const rows = await db.find({ from: "cms_media", where: { id }, limit: 1 });
404
+ if (!rows[0])
405
+ return null;
406
+ const file = asObj(rows[0].file);
407
+ return {
408
+ id: String(rows[0].id),
409
+ key: String(file.key ?? ""),
410
+ url: mediaPath(String(file.key ?? "")),
411
+ alt: rows[0].alt ?? null,
412
+ contentType: file.contentType ?? null,
413
+ filename: file.filename ?? null,
414
+ };
415
+ }
416
+ /** Publish a page: assemble a snapshot, write a revision (recording the actor), and point
417
+ * the page at it. Shared by publishPage, approve, and the scheduled cms:publish task.
418
+ * `clearSchedule` (a MANUAL publish) also clears the pending auto-unpublish token so a
419
+ * stale scheduled unpublish can't later archive the page behind the editor's back; the
420
+ * SCHEDULED cms:publish task passes false so a publish+unpublish pair both still fire. */
421
+ async function doPublish(db, page, actor, note, clearSchedule = false) {
422
+ const now = nowStamp();
423
+ const snapshot = await assembleLive(db, { ...page, status: "published" });
424
+ snapshot.page.status = "published";
425
+ const rev = await db.insert("cms_page_revisions", { pageId: page.id, title: page.title, status: "published", snapshot, note: note ?? null, actor });
426
+ const patch = { status: "published", publishedAt: now, scheduledAt: null, currentRevisionId: rev.id, updatedAt: now };
427
+ if (clearSchedule)
428
+ patch.unpublishAt = null;
429
+ return db.update("cms_pages", String(page.id), patch);
430
+ }
431
+ /** Published sibling translations of a page (other locales in the same translation group),
432
+ * for hreflang. Excludes the page itself. */
433
+ async function siblingTranslations(db, page) {
434
+ const group = page.translationGroupId;
435
+ if (!group)
436
+ return [];
437
+ const rows = await db.find({ from: "cms_pages", where: { translationGroupId: group, status: "published" } });
438
+ return rows
439
+ .filter((r) => String(r.id) !== String(page.id))
440
+ .map((r) => ({ locale: String(r.locale ?? "en"), slug: String(r.slug) }));
441
+ }
442
+ /** Project a page row to the public AssembledPage.page shape. */
443
+ function pageMeta(page, translations = [], ogImage = null) {
444
+ const metaTitle = page.metaTitle ?? null;
445
+ const metaDescription = page.metaDescription ?? null;
446
+ return {
447
+ id: String(page.id),
448
+ title: String(page.title),
449
+ slug: String(page.slug),
450
+ status: String(page.status),
451
+ locale: String(page.locale ?? "en"),
452
+ translationGroupId: page.translationGroupId ?? null,
453
+ translations,
454
+ fields: page.fields ?? null,
455
+ metaTitle,
456
+ metaDescription,
457
+ seo: {
458
+ metaTitle,
459
+ metaDescription,
460
+ canonicalUrl: page.canonicalUrl ?? null,
461
+ robots: page.robots ?? null,
462
+ ogTitle: page.ogTitle ?? null,
463
+ ogDescription: page.ogDescription ?? null,
464
+ ogImage,
465
+ structuredData: page.structuredData ?? null,
466
+ },
467
+ };
468
+ }
469
+ async function nextPosition(db, pageId, region) {
470
+ const rows = await db.exec("SELECT COALESCE(MAX(position), -1) AS m FROM cms_page_blocks WHERE pageId = ? AND region = ?", pageId, region);
471
+ return Number(rows[0]?.m ?? -1) + 1;
472
+ }
473
+ async function loadBlockTypeBySlug(db, slug) {
474
+ const rows = await db.find({ from: "cms_block_types", where: { slug }, limit: 1 });
475
+ if (!rows[0])
476
+ throw new BadRequest(`unknown block type '${slug}'`);
477
+ return rows[0];
478
+ }
479
+ async function assertRegionAllows(db, page, region, blockTypeSlug) {
480
+ const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
481
+ const regions = ctRows[0]?.regions ?? [];
482
+ const def = regions.find((r) => r.name === region);
483
+ if (!def)
484
+ throw new BadRequest(`region '${region}' is not defined on this page's content type`);
485
+ // A non-empty allowedTypes restricts; null/undefined OR an empty array means "any type"
486
+ // (matching the editor, which treats `[]` as unrestricted — otherwise the region is unusable).
487
+ if (def.allowedTypes && def.allowedTypes.length && !def.allowedTypes.includes(blockTypeSlug)) {
488
+ throw new BadRequest(`block type '${blockTypeSlug}' is not allowed in region '${region}'`);
489
+ }
490
+ }
491
+ /** Build the CMS handler map. Spread into your app's handlers. Editor mutations are
492
+ * gated both by `auth` (fast 403 before the body) and by the row ACL (cmsPolicies). */
493
+ export function createCmsHandlers(opts = {}) {
494
+ const editorRoles = opts.editorRoles ?? ["editor", "admin"];
495
+ const editor = { auth: editorRoles };
496
+ const mediaMaxSize = opts.mediaMaxSize ?? 25_000_000;
497
+ const defaultLocale = opts.defaultLocale ?? "en";
498
+ const reviewerRoles = opts.reviewerRoles ?? ["reviewer", "admin"];
499
+ const reviewer = { auth: reviewerRoles };
500
+ // Anyone who edits OR reviews may VIEW content (a reviewer must preview a page + load its
501
+ // content type/blocks before approving). Read/preview handlers use this; writes stay editor.
502
+ const viewerRoles = [...new Set([...editorRoles, ...reviewerRoles])];
503
+ const viewer = { auth: viewerRoles };
504
+ // The caller's identity id (the audit actor), or null for a system/unauthenticated write.
505
+ const actorOf = (ctx) => (typeof ctx.identity?.userId === "string" ? ctx.identity.userId : null);
506
+ // Append an audit row for a workflow transition (synchronous, in the mutation's txn).
507
+ 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 });
508
+ // (slug, locale) uniqueness is enforced here because pramen's unique() is single-column.
509
+ const assertSlugFree = async (db, slug, locale, exceptId) => {
510
+ const rows = await db.exec("SELECT id FROM cms_pages WHERE slug = ? AND locale = ? LIMIT 1", slug, locale);
511
+ if (rows[0] && String(rows[0].id) !== exceptId)
512
+ throw new BadRequest(`slug '${slug}' already exists for locale '${locale}'`);
513
+ };
514
+ const TASK_PUBLISH = "cms:publish";
515
+ const TASK_UNPUBLISH = "cms:unpublish";
516
+ return {
517
+ // ---- block types & content types (data-driven definitions) ----
518
+ listBlockTypes: query((ctx) => cdb(ctx).find({ from: "cms_block_types", orderBy: { column: "name" } })),
519
+ createBlockType: mutation(async (ctx, input) => {
520
+ return cdb(ctx).insert("cms_block_types", {
521
+ name: input.name,
522
+ slug: input.slug,
523
+ description: input.description ?? null,
524
+ fieldsSchema: input.fieldsSchema ?? [],
525
+ icon: input.icon ?? null,
526
+ category: input.category ?? null,
527
+ });
528
+ }, {
529
+ ...editor,
530
+ input: (raw) => {
531
+ const o = asObj(raw);
532
+ if (typeof o.name !== "string" || typeof o.slug !== "string")
533
+ throw new BadRequest("name and slug are required");
534
+ return o;
535
+ },
536
+ }),
537
+ createContentType: mutation(async (ctx, input) => {
538
+ return cdb(ctx).insert("cms_content_types", {
539
+ name: input.name,
540
+ slug: input.slug,
541
+ regions: input.regions ?? [],
542
+ fieldsSchema: input.fieldsSchema ?? [],
543
+ defaultBlocks: input.defaultBlocks ?? [],
544
+ });
545
+ }, {
546
+ ...editor,
547
+ input: (raw) => {
548
+ const o = asObj(raw);
549
+ if (typeof o.name !== "string" || typeof o.slug !== "string")
550
+ throw new BadRequest("name and slug are required");
551
+ if (!Array.isArray(o.regions) || o.regions.length === 0)
552
+ throw new BadRequest("at least one region is required");
553
+ return o;
554
+ },
555
+ }),
556
+ // ---- media library ----
557
+ /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
558
+ * prefix so the public /media route can serve it). The client PUTs the bytes to
559
+ * `url`, then calls `createMedia` with the returned `ref`. */
560
+ signMediaUpload: mutation((ctx, input) => {
561
+ return ctx.files.signUpload({ contentType: input.contentType, filename: input.filename, prefix: "media", maxSize: mediaMaxSize });
562
+ }, {
563
+ ...editor,
564
+ input: (raw) => {
565
+ const o = asObj(raw);
566
+ if (typeof o.contentType !== "string")
567
+ throw new BadRequest("contentType is required");
568
+ return { contentType: o.contentType, filename: typeof o.filename === "string" ? o.filename : undefined };
569
+ },
570
+ }),
571
+ /** Confirm an uploaded blob is really in storage (capturing its true size) and
572
+ * persist a `cms_media` row. Mirrors the notes attach flow. */
573
+ createMedia: mutation(async (ctx, input) => {
574
+ const head = await ctx.files.head(input.ref.key);
575
+ if (!head)
576
+ throw new BadRequest("uploaded file not found in storage");
577
+ const file = {
578
+ key: input.ref.key,
579
+ size: head.size,
580
+ contentType: head.contentType ?? input.ref.contentType,
581
+ filename: input.ref.filename,
582
+ uploadedAt: Date.now(),
583
+ };
584
+ return cdb(ctx).insert("cms_media", { file, alt: input.alt ?? null });
585
+ }, {
586
+ ...editor,
587
+ input: (raw) => {
588
+ const o = asObj(raw);
589
+ const ref = asObj(o.ref);
590
+ if (typeof ref.key !== "string")
591
+ throw new BadRequest("ref.key is required");
592
+ return {
593
+ ref: {
594
+ key: ref.key,
595
+ size: typeof ref.size === "number" ? ref.size : 0,
596
+ contentType: typeof ref.contentType === "string" ? ref.contentType : "application/octet-stream",
597
+ filename: typeof ref.filename === "string" ? ref.filename : undefined,
598
+ },
599
+ alt: typeof o.alt === "string" ? o.alt : undefined,
600
+ };
601
+ },
602
+ }),
603
+ listMedia: query((ctx, input) => {
604
+ const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
605
+ const offset = Math.max(Math.trunc(Number(input?.offset ?? 0)) || 0, 0);
606
+ return cdb(ctx).find({ from: "cms_media", orderBy: { column: "createdAt", dir: "desc" }, limit, offset });
607
+ }, viewer),
608
+ getMedia: query(async (ctx, input) => {
609
+ const rows = await cdb(ctx).find({ from: "cms_media", where: { id: input.id }, limit: 1 });
610
+ return rows[0] ?? null;
611
+ }, {
612
+ ...viewer,
613
+ input: (raw) => {
614
+ const o = asObj(raw);
615
+ if (typeof o.id !== "string")
616
+ throw new BadRequest("id is required");
617
+ return o;
618
+ },
619
+ }),
620
+ /** Edit a media asset's metadata (currently just `alt` text). Editor-gated. */
621
+ updateMedia: mutation(async (ctx, input) => {
622
+ const updated = await cdb(ctx).update("cms_media", input.id, { alt: input.alt ?? null });
623
+ if (!updated)
624
+ throw notFound("media");
625
+ return updated;
626
+ }, {
627
+ ...editor,
628
+ input: (raw) => {
629
+ const o = asObj(raw);
630
+ if (typeof o.id !== "string")
631
+ throw new BadRequest("id is required");
632
+ return { id: o.id, alt: typeof o.alt === "string" ? o.alt : null };
633
+ },
634
+ }),
635
+ /** Delete a media row AND its R2 blob. (Automatic orphan sweeping — media no longer
636
+ * referenced by any block — is future work; refs live inside opaque block JSON.) */
637
+ deleteMedia: mutation(async (ctx, input) => {
638
+ const db = cdb(ctx);
639
+ const rows = await db.find({ from: "cms_media", where: { id: input.id }, limit: 1 });
640
+ const media = rows[0];
641
+ if (!media)
642
+ throw notFound("media");
643
+ const key = String(asObj(media.file).key ?? "");
644
+ await db.delete("cms_media", input.id);
645
+ if (key)
646
+ await ctx.files.delete(key).catch(() => { });
647
+ return { ok: true };
648
+ }, {
649
+ ...editor,
650
+ input: (raw) => {
651
+ const o = asObj(raw);
652
+ if (typeof o.id !== "string")
653
+ throw new BadRequest("id is required");
654
+ return o;
655
+ },
656
+ }),
657
+ listContentTypes: query((ctx) => cdb(ctx).find({ from: "cms_content_types", orderBy: { column: "name" } }), viewer),
658
+ getContentType: query(async (ctx, input) => {
659
+ const rows = await cdb(ctx).find({ from: "cms_content_types", where: { id: input.id }, limit: 1 });
660
+ return rows[0] ?? null;
661
+ }, {
662
+ ...viewer,
663
+ input: (raw) => {
664
+ const o = asObj(raw);
665
+ if (typeof o.id !== "string")
666
+ throw new BadRequest("id is required");
667
+ return o;
668
+ },
669
+ }),
670
+ // ---- pages ----
671
+ listPages: query((ctx) => cdb(ctx).find({ from: "cms_pages", orderBy: { column: "createdAt", dir: "desc" }, limit: 100 })),
672
+ /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
673
+ * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
674
+ listPublishedPages: query(async (ctx) => {
675
+ const rows = await cdb(ctx).find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
676
+ return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
677
+ }),
678
+ /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
679
+ updatePageSeo: mutation(async (ctx, input) => {
680
+ const db = cdb(ctx);
681
+ const patch = { updatedAt: nowStamp() };
682
+ for (const k of ["metaTitle", "metaDescription", "canonicalUrl", "robots", "ogTitle", "ogDescription", "ogImage"]) {
683
+ if (k in input)
684
+ patch[k] = input[k];
685
+ }
686
+ if ("structuredData" in input)
687
+ patch.structuredData = input.structuredData ?? null;
688
+ const updated = await db.update("cms_pages", input.pageId, patch);
689
+ if (!updated)
690
+ throw notFound("page");
691
+ return { ok: true, page: updated };
692
+ }, {
693
+ ...editor,
694
+ input: (raw) => {
695
+ const o = asObj(raw);
696
+ if (typeof o.pageId !== "string")
697
+ throw new BadRequest("pageId is required");
698
+ return o;
699
+ },
700
+ }),
701
+ /** Create a page and auto-scaffold its content type's default blocks. */
702
+ createPage: mutation(async (ctx, input) => {
703
+ const db = cdb(ctx);
704
+ const ctRows = await db.find({ from: "cms_content_types", where: { id: input.typeId }, limit: 1 });
705
+ const ct = ctRows[0];
706
+ if (!ct)
707
+ throw new BadRequest("unknown content type");
708
+ validateFields(ct.fieldsSchema, input.fields ?? {}, "page.fields", { requireRequired: false });
709
+ const locale = input.locale ?? defaultLocale;
710
+ await assertSlugFree(db, input.slug, locale);
711
+ const page = await db.insert("cms_pages", {
712
+ typeId: input.typeId,
713
+ title: input.title,
714
+ slug: input.slug,
715
+ locale,
716
+ fields: input.fields ?? {},
717
+ status: "draft",
718
+ });
719
+ const defaults = ct.defaultBlocks ?? [];
720
+ for (const d of defaults) {
721
+ // A default block must pass the SAME guards as addBlock — a known type, an
722
+ // allowed region, and schema-valid fields — or the page would be scaffolded with
723
+ // an invalid/disallowed block that later renders on the published snapshot. A
724
+ // misconfigured default is skipped with a warning rather than aborting the page.
725
+ try {
726
+ const bts = await db.find({ from: "cms_block_types", where: { slug: d.blockTypeSlug }, limit: 1 });
727
+ if (!bts[0])
728
+ throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
729
+ await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
730
+ validateFields(bts[0].fieldsSchema, d.fields ?? {}, "", { requireRequired: false });
731
+ const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: d.fields ?? {} });
732
+ const position = await nextPosition(db, String(page.id), d.region);
733
+ await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
734
+ }
735
+ catch (e) {
736
+ console.warn(`@pramen/cms: content type '${ct.slug}' default block (${d.blockTypeSlug} → ${d.region}) skipped: ${e instanceof Error ? e.message : String(e)}`);
737
+ }
738
+ }
739
+ return page;
740
+ }, {
741
+ ...editor,
742
+ input: (raw) => {
743
+ const o = asObj(raw);
744
+ if (typeof o.typeId !== "string" || typeof o.title !== "string" || typeof o.slug !== "string") {
745
+ throw new BadRequest("typeId, title and slug are required");
746
+ }
747
+ if (o.locale !== undefined && typeof o.locale !== "string")
748
+ throw new BadRequest("locale must be a string");
749
+ return o;
750
+ },
751
+ }),
752
+ /** Create a translation of an existing page: a new page in `locale` sharing the
753
+ * source's translationGroupId (and content type). Content starts empty — the editor
754
+ * fills in the translated blocks. Slug defaults to the source's (allowed in a new locale). */
755
+ createTranslation: mutation(async (ctx, input) => {
756
+ const db = cdb(ctx);
757
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
758
+ const src = rows[0];
759
+ if (!src)
760
+ throw notFound("page");
761
+ if (String(src.locale) === input.locale)
762
+ throw new BadRequest("page is already in that locale");
763
+ // A legacy page (migrated in before this column existed) has a NULL group — a NULL
764
+ // match would collapse ALL legacy pages together. Backfill the source with its own
765
+ // group first, so this translation joins only it.
766
+ let group = src.translationGroupId;
767
+ if (typeof group !== "string" || !group) {
768
+ group = crypto.randomUUID();
769
+ await db.update("cms_pages", String(src.id), { translationGroupId: group });
770
+ }
771
+ const existing = await db.find({ from: "cms_pages", where: { translationGroupId: group, locale: input.locale }, limit: 1 });
772
+ if (existing[0])
773
+ throw new BadRequest(`a '${input.locale}' translation already exists`);
774
+ const slug = input.slug ?? String(src.slug);
775
+ await assertSlugFree(db, slug, input.locale);
776
+ return db.insert("cms_pages", {
777
+ typeId: src.typeId,
778
+ title: input.title ?? String(src.title),
779
+ slug,
780
+ locale: input.locale,
781
+ translationGroupId: group,
782
+ fields: {},
783
+ status: "draft",
784
+ });
785
+ }, {
786
+ ...editor,
787
+ input: (raw) => {
788
+ const o = asObj(raw);
789
+ if (typeof o.pageId !== "string" || typeof o.locale !== "string")
790
+ throw new BadRequest("pageId and locale are required");
791
+ return o;
792
+ },
793
+ }),
794
+ /** List all locales of a page (the translation group), including the page itself. */
795
+ listTranslations: query(async (ctx, input) => {
796
+ const db = cdb(ctx);
797
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
798
+ const self = rows[0];
799
+ if (!self)
800
+ throw notFound("page");
801
+ const proj = (r) => ({ id: String(r.id), locale: String(r.locale ?? "en"), slug: String(r.slug), title: String(r.title), status: String(r.status) });
802
+ // A NULL group (legacy page) matches all legacy pages — guard it and return just self.
803
+ if (typeof self.translationGroupId !== "string" || !self.translationGroupId)
804
+ return [proj(self)];
805
+ const group = await db.find({ from: "cms_pages", where: { translationGroupId: self.translationGroupId } });
806
+ return group.map(proj);
807
+ }, {
808
+ ...viewer,
809
+ input: (raw) => {
810
+ const o = asObj(raw);
811
+ if (typeof o.pageId !== "string")
812
+ throw new BadRequest("pageId is required");
813
+ return o;
814
+ },
815
+ }),
816
+ /** Distinct locales present across all pages. */
817
+ listLocales: query(async (ctx) => {
818
+ const rows = await cdb(ctx).exec("SELECT DISTINCT locale FROM cms_pages ORDER BY locale");
819
+ return rows.map((r) => String(r.locale ?? "en"));
820
+ }, viewer),
821
+ // ---- blocks & placement ----
822
+ /** Create a block instance and place it into a page region in one call (the common
823
+ * editor action). Validates the fields against the block type's schema and the region
824
+ * against the content type's allow-list. */
825
+ addBlock: mutation(async (ctx, input) => {
826
+ const db = cdb(ctx);
827
+ const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
828
+ const page = pages[0];
829
+ if (!page)
830
+ throw notFound("page");
831
+ const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
832
+ await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
833
+ validateFields(bt.fieldsSchema, input.fields ?? {}, "", { requireRequired: false });
834
+ const block = await db.insert("cms_blocks", {
835
+ typeId: bt.id,
836
+ title: input.title ?? null,
837
+ fields: input.fields ?? {},
838
+ isReusable: input.isReusable ?? false,
839
+ });
840
+ const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
841
+ const placement = await db.insert("cms_page_blocks", {
842
+ pageId: input.pageId,
843
+ blockId: block.id,
844
+ region: input.region,
845
+ position,
846
+ isShared: input.isReusable ?? false,
847
+ });
848
+ return { block, placement };
849
+ }, {
850
+ ...editor,
851
+ input: (raw) => {
852
+ const o = asObj(raw);
853
+ if (typeof o.pageId !== "string" || typeof o.blockTypeSlug !== "string" || typeof o.region !== "string") {
854
+ throw new BadRequest("pageId, blockTypeSlug and region are required");
855
+ }
856
+ return o;
857
+ },
858
+ }),
859
+ /** Place an EXISTING (typically reusable) block into a page region as a SHARED
860
+ * placement, with optional per-placement `overrides` merged over the block's fields at
861
+ * read time. This is the "edit once, appear on many pages" workflow — the same block id
862
+ * can be placed on several pages; editing it updates them all, while `overrides` let one
863
+ * placement diverge. The merged (base + overrides) result is validated against the
864
+ * block type's field schema. */
865
+ placeBlock: mutation(async (ctx, input) => {
866
+ const db = cdb(ctx);
867
+ const pages = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
868
+ const page = pages[0];
869
+ if (!page)
870
+ throw notFound("page");
871
+ const blocks = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
872
+ const block = blocks[0];
873
+ if (!block)
874
+ throw notFound("block");
875
+ const bts = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
876
+ const slug = String(bts[0]?.slug ?? "");
877
+ await assertRegionAllows(db, page, input.region, slug);
878
+ if (input.overrides !== undefined) {
879
+ validateFields(bts[0]?.fieldsSchema, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
880
+ }
881
+ const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
882
+ return db.insert("cms_page_blocks", {
883
+ pageId: input.pageId,
884
+ blockId: input.blockId,
885
+ region: input.region,
886
+ position,
887
+ isShared: true,
888
+ overrides: input.overrides ?? null,
889
+ });
890
+ }, {
891
+ ...editor,
892
+ input: (raw) => {
893
+ const o = asObj(raw);
894
+ if (typeof o.pageId !== "string" || typeof o.blockId !== "string" || typeof o.region !== "string") {
895
+ throw new BadRequest("pageId, blockId and region are required");
896
+ }
897
+ return o;
898
+ },
899
+ }),
900
+ /** Fetch a block's RAW content (media fields as ids, not resolved) — for editing. */
901
+ getBlock: query(async (ctx, input) => {
902
+ const rows = await cdb(ctx).find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
903
+ return rows[0] ?? null;
904
+ }, {
905
+ ...viewer,
906
+ input: (raw) => {
907
+ const o = asObj(raw);
908
+ if (typeof o.blockId !== "string")
909
+ throw new BadRequest("blockId is required");
910
+ return o;
911
+ },
912
+ }),
913
+ /** Update a block's content (re-validated against its type's field schema). */
914
+ updateBlock: mutation(async (ctx, input) => {
915
+ const db = cdb(ctx);
916
+ const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
917
+ const block = rows[0];
918
+ if (!block)
919
+ throw notFound("block");
920
+ if (input.fields !== undefined) {
921
+ const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
922
+ validateFields(bt[0]?.fieldsSchema, input.fields, "", { requireRequired: false });
923
+ }
924
+ const patch = { updatedAt: nowStamp() };
925
+ if (input.fields !== undefined)
926
+ patch.fields = input.fields;
927
+ if (input.title !== undefined)
928
+ patch.title = input.title;
929
+ return db.update("cms_blocks", input.blockId, patch);
930
+ }, {
931
+ ...editor,
932
+ input: (raw) => {
933
+ const o = asObj(raw);
934
+ if (typeof o.blockId !== "string")
935
+ throw new BadRequest("blockId is required");
936
+ return o;
937
+ },
938
+ }),
939
+ /** Reorder a region: `order` is the page_block ids in their new order. It must cover
940
+ * EXACTLY the region's current placements (same set, no dups) — otherwise a partial or
941
+ * stale list would leave untouched placements colliding at a shared position. */
942
+ reorderRegion: mutation(async (ctx, input) => {
943
+ const db = cdb(ctx);
944
+ const current = await db.find({ from: "cms_page_blocks", where: { pageId: input.pageId, region: input.region } });
945
+ const currentIds = new Set(current.map((p) => String(p.id)));
946
+ const orderSet = new Set(input.order.map(String));
947
+ if (orderSet.size !== input.order.length)
948
+ throw new BadRequest("order contains duplicate ids");
949
+ if (orderSet.size !== currentIds.size || ![...orderSet].every((id) => currentIds.has(id))) {
950
+ throw new BadRequest("order must list exactly this region's placements");
951
+ }
952
+ for (let i = 0; i < input.order.length; i++) {
953
+ await db.exec("UPDATE cms_page_blocks SET position = ? WHERE id = ? AND pageId = ? AND region = ?", i, input.order[i], input.pageId, input.region);
954
+ }
955
+ return { ok: true, count: input.order.length };
956
+ }, {
957
+ ...editor,
958
+ input: (raw) => {
959
+ const o = asObj(raw);
960
+ if (typeof o.pageId !== "string" || typeof o.region !== "string" || !Array.isArray(o.order)) {
961
+ throw new BadRequest("pageId, region and order[] are required");
962
+ }
963
+ if (!o.order.every((id) => typeof id === "string"))
964
+ throw new BadRequest("order must be string ids");
965
+ return o;
966
+ },
967
+ }),
968
+ /** Remove a placement. A reusable/shared block stays in the library (it may be placed
969
+ * elsewhere); a non-reusable block with no remaining placements is deleted too, so
970
+ * add/remove churn doesn't accumulate unreachable block rows. */
971
+ removeBlock: mutation(async (ctx, input) => {
972
+ const db = cdb(ctx);
973
+ const rows = await db.find({ from: "cms_page_blocks", where: { id: input.pageBlockId }, limit: 1 });
974
+ const placement = rows[0];
975
+ if (!placement)
976
+ throw notFound("placement");
977
+ await db.delete("cms_page_blocks", input.pageBlockId);
978
+ const blockId = String(placement.blockId);
979
+ const stillUsed = await db.find({ from: "cms_page_blocks", where: { blockId }, limit: 1 });
980
+ if (stillUsed.length === 0) {
981
+ const blk = await db.find({ from: "cms_blocks", where: { id: blockId }, limit: 1 });
982
+ if (blk[0] && !blk[0].isReusable)
983
+ await db.delete("cms_blocks", blockId);
984
+ }
985
+ return { ok: true };
986
+ }, {
987
+ ...editor,
988
+ input: (raw) => {
989
+ const o = asObj(raw);
990
+ if (typeof o.pageBlockId !== "string")
991
+ throw new BadRequest("pageBlockId is required");
992
+ return o;
993
+ },
994
+ }),
995
+ // ---- editorial workflow ----
996
+ /** Move a draft (or rejected) page into review. Editor-gated. */
997
+ submitForReview: mutation(async (ctx, input) => {
998
+ const db = cdb(ctx);
999
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1000
+ const page = rows[0];
1001
+ if (!page)
1002
+ throw notFound("page");
1003
+ const from = String(page.status);
1004
+ if (from !== "draft" && from !== "rejected")
1005
+ throw new BadRequest(`cannot submit a '${from}' page for review`);
1006
+ const updated = await db.update("cms_pages", input.pageId, { status: "review", updatedAt: nowStamp() });
1007
+ await writeAudit(db, { pageId: input.pageId, action: "submit", from, to: "review", actor: actorOf(ctx), note: input.note });
1008
+ return { ok: true, page: updated };
1009
+ }, {
1010
+ ...editor,
1011
+ input: (raw) => {
1012
+ const o = asObj(raw);
1013
+ if (typeof o.pageId !== "string")
1014
+ throw new BadRequest("pageId is required");
1015
+ return o;
1016
+ },
1017
+ }),
1018
+ /** Approve a page in review → publish it (snapshot + currentRevisionId). Reviewer-gated. */
1019
+ approve: mutation(async (ctx, input) => {
1020
+ const db = cdb(ctx);
1021
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1022
+ const page = rows[0];
1023
+ if (!page)
1024
+ throw notFound("page");
1025
+ if (String(page.status) !== "review")
1026
+ throw new BadRequest("only a page in review can be approved");
1027
+ const updated = await doPublish(db, page, actorOf(ctx), input.note, true); // manual → clear pending auto-unpublish
1028
+ await writeAudit(db, { pageId: input.pageId, action: "approve", from: "review", to: "published", actor: actorOf(ctx), note: input.note });
1029
+ return { ok: true, page: updated };
1030
+ }, {
1031
+ ...reviewer,
1032
+ input: (raw) => {
1033
+ const o = asObj(raw);
1034
+ if (typeof o.pageId !== "string")
1035
+ throw new BadRequest("pageId is required");
1036
+ return o;
1037
+ },
1038
+ }),
1039
+ /** Reject a page in review → back to draft. Reviewer-gated. */
1040
+ reject: mutation(async (ctx, input) => {
1041
+ const db = cdb(ctx);
1042
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1043
+ const page = rows[0];
1044
+ if (!page)
1045
+ throw notFound("page");
1046
+ if (String(page.status) !== "review")
1047
+ throw new BadRequest("only a page in review can be rejected");
1048
+ const updated = await db.update("cms_pages", input.pageId, { status: "rejected", updatedAt: nowStamp() });
1049
+ await writeAudit(db, { pageId: input.pageId, action: "reject", from: "review", to: "rejected", actor: actorOf(ctx), note: input.note });
1050
+ return { ok: true, page: updated };
1051
+ }, {
1052
+ ...reviewer,
1053
+ input: (raw) => {
1054
+ const o = asObj(raw);
1055
+ if (typeof o.pageId !== "string")
1056
+ throw new BadRequest("pageId is required");
1057
+ return o;
1058
+ },
1059
+ }),
1060
+ /** The workflow audit trail for a page (most recent first). Handler-gated to editors;
1061
+ * reads the append-only log via `exec` (a plain admin-scoped read of a gated log). */
1062
+ listPageAudit: query(async (ctx, input) => {
1063
+ const limit = Math.min(Math.max(Math.trunc(Number(input?.limit ?? 50)) || 50, 1), 200);
1064
+ return cdb(ctx).exec(`SELECT "id", "pageId", "action", "fromStatus", "toStatus", "actor", "note", "createdAt" FROM "cms_audit" WHERE "pageId" = ? ORDER BY "createdAt" DESC LIMIT ${limit}`, input.pageId);
1065
+ }, {
1066
+ ...viewer,
1067
+ input: (raw) => {
1068
+ const o = asObj(raw);
1069
+ if (typeof o.pageId !== "string")
1070
+ throw new BadRequest("pageId is required");
1071
+ return o;
1072
+ },
1073
+ }),
1074
+ // ---- publishing ----
1075
+ /** Publish a page directly: snapshot the assembled page into a revision and flip
1076
+ * status to `published` (records an audit entry). The public content API serves the
1077
+ * snapshot. `approve` is the review-gated path to the same outcome. */
1078
+ publishPage: mutation(async (ctx, input) => {
1079
+ const db = cdb(ctx);
1080
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1081
+ const page = rows[0];
1082
+ if (!page)
1083
+ throw notFound("page");
1084
+ const from = String(page.status);
1085
+ const updated = await doPublish(db, page, actorOf(ctx), input.note, true); // manual → clear pending auto-unpublish
1086
+ await writeAudit(db, { pageId: input.pageId, action: "publish", from, to: "published", actor: actorOf(ctx), note: input.note });
1087
+ return { ok: true, page: updated };
1088
+ }, {
1089
+ // Reviewer-gated: publishing makes content live, so it requires the same authority as
1090
+ // `approve` — an editor can't bypass review by calling publishPage directly. (Editors
1091
+ // author + submitForReview; reviewers approve/publish.)
1092
+ ...reviewer,
1093
+ input: (raw) => {
1094
+ const o = asObj(raw);
1095
+ if (typeof o.pageId !== "string")
1096
+ throw new BadRequest("pageId is required");
1097
+ return o;
1098
+ },
1099
+ }),
1100
+ unpublishPage: mutation(async (ctx, input) => {
1101
+ // Clear any pending schedule tokens too: a queued cms:publish/cms:unpublish task
1102
+ // validates its token against these at fire time, so nulling them cancels the
1103
+ // schedule (the editor is taking manual control).
1104
+ const db = cdb(ctx);
1105
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1106
+ const from = rows[0] ? String(rows[0].status) : undefined;
1107
+ const updated = await db.update("cms_pages", input.pageId, { status: "draft", scheduledAt: null, unpublishAt: null, updatedAt: nowStamp() });
1108
+ if (!updated)
1109
+ throw notFound("page");
1110
+ await writeAudit(db, { pageId: input.pageId, action: "unpublish", from, to: "draft", actor: actorOf(ctx) });
1111
+ return { ok: true, page: updated };
1112
+ }, {
1113
+ ...editor,
1114
+ input: (raw) => {
1115
+ const o = asObj(raw);
1116
+ if (typeof o.pageId !== "string")
1117
+ throw new BadRequest("pageId is required");
1118
+ return o;
1119
+ },
1120
+ }),
1121
+ /** Schedule a page to publish at `publishAt` (epoch ms), and optionally unpublish at
1122
+ * `unpublishAt`. Enqueues delayed outbox tasks (atomic with this write).
1123
+ *
1124
+ * Outbox tasks can't be recalled, so cancellation/rescheduling is handled by INTENT
1125
+ * TOKENS: the page stores the scheduled times (`scheduledAt`/`unpublishAt`, ISO), and
1126
+ * each task carries the token it was enqueued for. At fire time the task acts ONLY if
1127
+ * its token still equals the page's current token — so rescheduling (new token),
1128
+ * manual publish/unpublish (token cleared), and duplicate deliveries all make a stale
1129
+ * task a no-op. `unpublishAt` must be after `publishAt`. */
1130
+ schedulePage: mutation(async (ctx, input) => {
1131
+ const db = cdb(ctx);
1132
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1133
+ if (!rows[0])
1134
+ throw notFound("page");
1135
+ const now = Date.now();
1136
+ const publishToken = new Date(input.publishAt).toISOString();
1137
+ const unpublishToken = input.unpublishAt !== undefined ? new Date(input.unpublishAt).toISOString() : null;
1138
+ await db.update("cms_pages", input.pageId, { scheduledAt: publishToken, unpublishAt: unpublishToken });
1139
+ await ctx.tasks.enqueue({ kind: TASK_PUBLISH, payload: { pageId: input.pageId, token: publishToken }, delayMs: Math.max(0, input.publishAt - now) });
1140
+ if (input.unpublishAt !== undefined) {
1141
+ await ctx.tasks.enqueue({ kind: TASK_UNPUBLISH, payload: { pageId: input.pageId, token: unpublishToken }, delayMs: Math.max(0, input.unpublishAt - now) });
1142
+ }
1143
+ return { ok: true, publishInMs: Math.max(0, input.publishAt - now) };
1144
+ }, {
1145
+ // Reviewer-gated like publishPage — a scheduled publish is still a publish.
1146
+ ...reviewer,
1147
+ input: (raw) => {
1148
+ const o = asObj(raw);
1149
+ if (typeof o.pageId !== "string" || !Number.isFinite(o.publishAt))
1150
+ throw new BadRequest("pageId and a finite publishAt (epoch ms) are required");
1151
+ if (o.unpublishAt !== undefined) {
1152
+ if (!Number.isFinite(o.unpublishAt))
1153
+ throw new BadRequest("unpublishAt must be a finite epoch ms");
1154
+ if (o.unpublishAt <= o.publishAt)
1155
+ throw new BadRequest("unpublishAt must be after publishAt");
1156
+ }
1157
+ return o;
1158
+ },
1159
+ }),
1160
+ // ---- public content API ----
1161
+ /** Fetch an assembled page by slug (+ locale). Anonymous callers get the published
1162
+ * snapshot (the ACL scopes `cms_pages` reads to `status = published`). Editors may pass
1163
+ * `preview: true` to assemble the current DRAFT live from the tables. `locale` defaults
1164
+ * to the configured default locale; a slug is unique per locale. */
1165
+ getPage: query(async (ctx, input) => {
1166
+ const db = cdb(ctx);
1167
+ // Preview is an editor capability — gate it before the lookup so a non-editor gets a
1168
+ // clear 403 (rather than a 404 that merely reflects the published-only read scope).
1169
+ if (input.preview && !isEditor(ctx, viewerRoles))
1170
+ throw new Forbidden("preview requires an editor or reviewer role");
1171
+ const locale = input.locale ?? defaultLocale;
1172
+ const rows = await db.find({ from: "cms_pages", where: { slug: input.slug, locale }, limit: 1 });
1173
+ const page = rows[0];
1174
+ if (!page)
1175
+ throw notFound("page"); // also the anonymous-vs-draft case: ACL yields no row
1176
+ if (input.preview)
1177
+ return assembleLive(db, page);
1178
+ // Public path: serve the page's current published revision snapshot (selected by the
1179
+ // page's `currentRevisionId` pointer — deterministic, unlike ordering by a
1180
+ // second-precision timestamp). We do NOT assemble live here: anonymous has no read
1181
+ // grant on the block tables (only pages + revisions), so a page without a current
1182
+ // revision returns its meta with empty regions rather than a spurious 403. In
1183
+ // practice publish always sets currentRevisionId, so that fallback is defensive.
1184
+ if (page.currentRevisionId) {
1185
+ const revs = await db.find({ from: "cms_page_revisions", where: { id: page.currentRevisionId }, limit: 1 });
1186
+ if (revs[0]?.snapshot) {
1187
+ const snap = revs[0].snapshot;
1188
+ // hreflang alternates are computed LIVE (not from the snapshot): a sibling
1189
+ // published AFTER this page won't be in the baked snapshot. Anonymous can read
1190
+ // published pages, so this query is in-policy on the public path.
1191
+ snap.page.translations = await siblingTranslations(db, page);
1192
+ // Back-compat: a snapshot baked before `seo`/`translationGroupId` existed lacks
1193
+ // those keys, but AssembledPage now types them as present — backfill from the live
1194
+ // page row so a frontend head template never hits `page.seo` === undefined.
1195
+ if (!snap.page.seo)
1196
+ snap.page.seo = pageMeta(page).seo;
1197
+ if (snap.page.translationGroupId === undefined)
1198
+ snap.page.translationGroupId = page.translationGroupId ?? null;
1199
+ return snap;
1200
+ }
1201
+ }
1202
+ return { page: pageMeta(page, await siblingTranslations(db, page), await resolveMediaId(db, page.ogImage)), regions: {} };
1203
+ }, {
1204
+ input: (raw) => {
1205
+ const o = asObj(raw);
1206
+ if (typeof o.slug !== "string")
1207
+ throw new BadRequest("slug is required");
1208
+ if (o.locale !== undefined && typeof o.locale !== "string")
1209
+ throw new BadRequest("locale must be a string");
1210
+ return o;
1211
+ },
1212
+ }),
1213
+ };
1214
+ }
1215
+ /** The default CMS handlers (editor roles `["editor", "admin"]`). */
1216
+ export const cmsHandlers = createCmsHandlers();
1217
+ /** ACL fragments. Spread `public` into your anonymous role and `editor` into your
1218
+ * editor/admin role:
1219
+ *
1220
+ * role("anonymous", [...cmsPolicies().public])
1221
+ * role("editor", [...cmsPolicies().editor])
1222
+ *
1223
+ * `public` grants read of PUBLISHED pages + their revision snapshots only (the public
1224
+ * content API reads the snapshot, so unpublished block rows are never exposed).
1225
+ * `editor` grants full CRUD across every cms_ table. */
1226
+ export function cmsPolicies(opts = {}) {
1227
+ const p = opts.prefix ?? "cms";
1228
+ const tables = ["cms_content_types", "cms_block_types", "cms_blocks", "cms_pages", "cms_page_blocks", "cms_page_revisions", "cms_media", "cms_audit"];
1229
+ const editorPolicies = [];
1230
+ for (const table of tables) {
1231
+ for (const action of ["read", "create", "update", "delete"]) {
1232
+ editorPolicies.push(policy(`${p}:editor:${table}:${action}`, table, action, allow()));
1233
+ }
1234
+ }
1235
+ return {
1236
+ public: [
1237
+ // Only published pages are readable; the snapshot carries the content.
1238
+ policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
1239
+ // getPage reads the latest revision snapshot. Scope the grant by the revision's
1240
+ // PAGE being currently published (a relation-traversal where, compiled to a
1241
+ // subquery), so a revision of a later-unpublished/archived page is never publicly
1242
+ // readable — least-privilege even for a future revision-listing handler.
1243
+ policy(`${p}:public:revisions:read`, "cms_page_revisions", "read", { where: { page: { status: "published" } } }),
1244
+ // Media metadata is public (the bytes are separately gated by signed urls).
1245
+ policy(`${p}:public:media:read`, "cms_media", "read", allow()),
1246
+ ],
1247
+ editor: editorPolicies,
1248
+ };
1249
+ }
1250
+ // --- deferred tasks (scheduled publish/unpublish) ----------------------------
1251
+ /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
1252
+ * They run with a privileged, system-scoped ctx off the write path (the outbox drain).
1253
+ *
1254
+ * Each task validates its INTENT TOKEN against the page's current `scheduledAt`/`unpublishAt`
1255
+ * (set by `schedulePage`, cleared/overwritten by a manual publish/unpublish or a reschedule).
1256
+ * A task whose token no longer matches is a no-op — that's how a superseded/cancelled
1257
+ * schedule, and an at-least-once duplicate delivery, are neutralized (outbox tasks can't be
1258
+ * recalled). NOTE on atomicity: unlike the interactive `publishPage` (one mutation
1259
+ * transaction), the drain runs a handler WITHOUT a surrounding transaction, so a crash
1260
+ * between the revision insert and the page update leaves the page unpublished with an orphan
1261
+ * revision until the next at-least-once redelivery re-runs (the token still matches, so it
1262
+ * completes). Acceptable for a scheduled job; the interactive path is atomic. */
1263
+ export const cmsTasks = {
1264
+ "cms:publish": async (ctx, payload) => {
1265
+ const { pageId, token } = asObj(payload);
1266
+ if (!pageId)
1267
+ return;
1268
+ const db = cdb(ctx);
1269
+ const rows = await db.find({ from: "cms_pages", where: { id: pageId }, limit: 1 });
1270
+ const page = rows[0];
1271
+ if (!page)
1272
+ return;
1273
+ // Intent check: only publish if this task is still the page's active schedule. A
1274
+ // reschedule (new token), a manual publish/unpublish (token cleared), or a duplicate
1275
+ // delivery after this task already ran (token cleared below) all make this a no-op.
1276
+ if (String(page.scheduledAt ?? "") !== String(token ?? ""))
1277
+ return;
1278
+ const from = String(page.status);
1279
+ await doPublish(db, page, null, "scheduled publish"); // actor null = system
1280
+ await db.insert("cms_audit", { pageId, action: "publish", fromStatus: from, toStatus: "published", actor: null, note: "scheduled" });
1281
+ },
1282
+ "cms:unpublish": async (ctx, payload) => {
1283
+ const { pageId, token } = asObj(payload);
1284
+ if (!pageId)
1285
+ return;
1286
+ const db = cdb(ctx);
1287
+ const rows = await db.find({ from: "cms_pages", where: { id: pageId }, limit: 1 });
1288
+ const page = rows[0];
1289
+ if (!page)
1290
+ return;
1291
+ if (String(page.unpublishAt ?? "") !== String(token ?? ""))
1292
+ return; // superseded/cancelled
1293
+ const from = String(page.status);
1294
+ await db.update("cms_pages", pageId, { status: "archived", unpublishAt: null, updatedAt: nowStamp() });
1295
+ await db.insert("cms_audit", { pageId, action: "unpublish", fromStatus: from, toStatus: "archived", actor: null, note: "scheduled" });
1296
+ },
1297
+ };
1298
+ const xmlEscape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1299
+ /** Build a sitemap.xml body from published-page entries. */
1300
+ export function sitemapXml(entries, opts) {
1301
+ const toUrl = opts.pageUrl ?? ((e, origin) => `${origin}/${e.locale}/${e.slug}`);
1302
+ const urls = entries
1303
+ .map((e) => {
1304
+ const loc = xmlEscape(toUrl(e, opts.origin));
1305
+ const lastmod = e.updatedAt ? `<lastmod>${xmlEscape(e.updatedAt.slice(0, 10))}</lastmod>` : "";
1306
+ return ` <url><loc>${loc}</loc>${lastmod}</url>`;
1307
+ })
1308
+ .join("\n");
1309
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
1310
+ }
1311
+ /** Build a robots.txt body pointing at the sitemap. */
1312
+ export function robotsTxt(opts) {
1313
+ const dis = (opts.disallow ?? []).map((p) => `Disallow: ${p}`).join("\n");
1314
+ return `User-agent: *\n${dis ? dis + "\n" : "Allow: /\n"}Sitemap: ${opts.origin}/sitemap.xml\n`;
1315
+ }
1316
+ /** Turnkey public routes for `GET /sitemap.xml` and `GET /robots.txt`. Spread into
1317
+ * `app.routes`. The sitemap pulls published pages via `callPrivileged(listPublishedPages)`.
1318
+ * `origin` defaults to the request's origin; `pageUrl` customizes the URL shape. */
1319
+ export function cmsRoutes(opts = {}) {
1320
+ const tenant = opts.tenant ?? "main";
1321
+ return [
1322
+ {
1323
+ method: "GET",
1324
+ path: "/sitemap.xml",
1325
+ handler: async (request, _env, ctx) => {
1326
+ const origin = opts.origin ?? new URL(request.url).origin;
1327
+ const res = await ctx.callPrivileged({ name: "listPublishedPages", tenant, roles: ["admin"] });
1328
+ const body = (await res.json().catch(() => ({})));
1329
+ const xml = sitemapXml(body.result ?? [], { origin, pageUrl: opts.pageUrl });
1330
+ return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } });
1331
+ },
1332
+ },
1333
+ {
1334
+ method: "GET",
1335
+ path: "/robots.txt",
1336
+ handler: async (request) => {
1337
+ const origin = opts.origin ?? new URL(request.url).origin;
1338
+ return new Response(robotsTxt({ origin, disallow: opts.disallow }), { headers: { "content-type": "text/plain; charset=utf-8" } });
1339
+ },
1340
+ },
1341
+ ];
1342
+ }