@pramen/cms 0.0.34 → 0.0.36

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.d.ts CHANGED
@@ -241,6 +241,7 @@ export declare const cmsSchema: {
241
241
  readonly kind: "belongsTo";
242
242
  readonly target: "cms_block_types";
243
243
  readonly column: string;
244
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
244
245
  };
245
246
  }>;
246
247
  cms_pages: import("@pramen/server").EntityDef<{
@@ -338,6 +339,7 @@ export declare const cmsSchema: {
338
339
  readonly kind: "belongsTo";
339
340
  readonly target: "cms_content_types";
340
341
  readonly column: string;
342
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
341
343
  };
342
344
  placements: {
343
345
  readonly kind: "hasMany";
@@ -387,11 +389,13 @@ export declare const cmsSchema: {
387
389
  readonly kind: "belongsTo";
388
390
  readonly target: "cms_pages";
389
391
  readonly column: string;
392
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
390
393
  };
391
394
  block: {
392
395
  readonly kind: "belongsTo";
393
396
  readonly target: "cms_blocks";
394
397
  readonly column: string;
398
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
395
399
  };
396
400
  }>;
397
401
  cms_page_revisions: import("@pramen/server").EntityDef<{
@@ -433,6 +437,7 @@ export declare const cmsSchema: {
433
437
  readonly kind: "belongsTo";
434
438
  readonly target: "cms_pages";
435
439
  readonly column: string;
440
+ readonly onDelete: import("@pramen/server").OnDelete | undefined;
436
441
  };
437
442
  }>;
438
443
  cms_audit: import("@pramen/server").EntityDef<{
@@ -681,6 +686,22 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
681
686
  ok: boolean;
682
687
  page: Record<string, unknown>;
683
688
  }>;
689
+ /** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
690
+ * (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
691
+ * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
692
+ * page record itself, which was previously only settable at createPage. A slug/locale
693
+ * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
694
+ * + sanitized against the content type's fieldsSchema, exactly like createPage. */
695
+ updatePage: import("@pramen/server").Handler<{
696
+ pageId: string;
697
+ title?: string;
698
+ slug?: string;
699
+ locale?: string;
700
+ fields?: Record<string, unknown>;
701
+ }, {
702
+ ok: boolean;
703
+ page: Record<string, unknown>;
704
+ }>;
684
705
  /** Create a page and auto-scaffold its content type's default blocks. */
685
706
  createPage: import("@pramen/server").Handler<{
686
707
  typeId: string;
@@ -935,6 +956,22 @@ export declare const cmsHandlers: {
935
956
  ok: boolean;
936
957
  page: Record<string, unknown>;
937
958
  }>;
959
+ /** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
960
+ * (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
961
+ * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
962
+ * page record itself, which was previously only settable at createPage. A slug/locale
963
+ * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
964
+ * + sanitized against the content type's fieldsSchema, exactly like createPage. */
965
+ updatePage: import("@pramen/server").Handler<{
966
+ pageId: string;
967
+ title?: string;
968
+ slug?: string;
969
+ locale?: string;
970
+ fields?: Record<string, unknown>;
971
+ }, {
972
+ ok: boolean;
973
+ page: Record<string, unknown>;
974
+ }>;
938
975
  /** Create a page and auto-scaffold its content type's default blocks. */
939
976
  createPage: import("@pramen/server").Handler<{
940
977
  typeId: string;
@@ -1111,6 +1148,118 @@ export declare function cmsPolicies(opts?: CmsPolicyOpts): {
1111
1148
  public: Policy[];
1112
1149
  editor: Policy[];
1113
1150
  };
1151
+ /** Declares that a pramen entity is editable as a collection in the CMS editor. Both
1152
+ * halves live here: the runtime facts (entity, idField, validation via `fields`) and the
1153
+ * UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
1154
+ export interface CollectionDef {
1155
+ /** The pramen entity (table) this collection edits — one of your own entities, spread
1156
+ * into `defineSchema` next to `cmsSchema`. The handler registry keys off `slug`, then
1157
+ * reads this; it is never taken from client input. */
1158
+ readonly entity: string;
1159
+ /** URL + nav key, e.g. `"lectures"`. Unique across collections. */
1160
+ readonly slug: string;
1161
+ /** Singular UI label, e.g. `"Lecture"`. */
1162
+ readonly label: string;
1163
+ /** Plural UI label; defaults to `label + "s"`. */
1164
+ readonly pluralLabel?: string;
1165
+ /** Optional nav icon (emoji or short string). */
1166
+ readonly icon?: string;
1167
+ /** The edit-form schema — the same DSL as blocks. Each scalar field is a real column
1168
+ * on `entity`; repeater/group map to a `t.json()` column. Also the WRITE WHITELIST:
1169
+ * only these field names are ever written to the entity. */
1170
+ readonly fields: readonly FieldDefinition[];
1171
+ /** Columns shown in the list view; defaults to `[titleField]`. */
1172
+ readonly list?: readonly string[];
1173
+ /** Column that titles a row in the list; defaults to `"title"`. */
1174
+ readonly titleField?: string;
1175
+ /** Primary-key column used to load/patch a single row; defaults to `"id"`. */
1176
+ readonly idField?: string;
1177
+ /** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
1178
+ readonly orderBy?: {
1179
+ column: string;
1180
+ dir?: "asc" | "desc";
1181
+ };
1182
+ }
1183
+ /** Declare a collection. Spread the results into `createCollectionHandlers` +
1184
+ * `collectionPolicies`:
1185
+ *
1186
+ * const lectures = collection("lectures", {
1187
+ * entity: "lectures", label: "Lecture", titleField: "title",
1188
+ * list: ["title", "speaker", "date"],
1189
+ * fields: [
1190
+ * { name: "title", type: "text", required: true },
1191
+ * { name: "speaker", type: "text" },
1192
+ * { name: "date", type: "date" },
1193
+ * ],
1194
+ * }); */
1195
+ export declare function collection(slug: string, opts: Omit<CollectionDef, "slug">): CollectionDef;
1196
+ /** The client-facing subset of a collection the editor fetches via `listCollections` to
1197
+ * build its nav + generic list/edit views (no per-collection editor code, no rebuild to
1198
+ * add one). Excludes the server-only `entity` (the editor addresses a collection by `slug`
1199
+ * only, never by table name); `idField` IS included — it's just the PK column name, which
1200
+ * the editor needs to read a row's id from a list result. */
1201
+ export interface CollectionMeta {
1202
+ slug: string;
1203
+ label: string;
1204
+ pluralLabel: string;
1205
+ icon?: string;
1206
+ fields: readonly FieldDefinition[];
1207
+ list: readonly string[];
1208
+ titleField: string;
1209
+ idField: string;
1210
+ orderBy?: {
1211
+ column: string;
1212
+ dir?: "asc" | "desc";
1213
+ };
1214
+ }
1215
+ /** Build generic CRUD handlers over the registered collections. Spread into your app's
1216
+ * handlers alongside `cmsHandlers`:
1217
+ *
1218
+ * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
1219
+ *
1220
+ * Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
1221
+ * `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
1222
+ * (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
1223
+ * `collectionPolicies` scopes them too). The `collection` param is resolved through the
1224
+ * registry — an unknown slug is a 400, never a raw table reference. */
1225
+ export declare function createCollectionHandlers(collections: readonly CollectionDef[], opts?: CmsHandlerOpts): {
1226
+ /** The registered collections (defaults filled) — editor discovery. Editor-gated so
1227
+ * the collection schemas aren't exposed to anonymous callers. */
1228
+ listCollections: import("@pramen/server").Handler<unknown, CollectionMeta[]>;
1229
+ collectionList: import("@pramen/server").Handler<{
1230
+ collection: string;
1231
+ limit?: number;
1232
+ offset?: number;
1233
+ }, Record<string, unknown>[]>;
1234
+ collectionGet: import("@pramen/server").Handler<{
1235
+ collection: string;
1236
+ id: string;
1237
+ }, Record<string, unknown>>;
1238
+ collectionCreate: import("@pramen/server").Handler<{
1239
+ collection: string;
1240
+ values: Record<string, unknown>;
1241
+ }, Record<string, unknown>>;
1242
+ collectionUpdate: import("@pramen/server").Handler<{
1243
+ collection: string;
1244
+ id: string;
1245
+ values: Record<string, unknown>;
1246
+ }, Record<string, unknown>>;
1247
+ collectionDelete: import("@pramen/server").Handler<{
1248
+ collection: string;
1249
+ id: string;
1250
+ }, {
1251
+ ok: true;
1252
+ }>;
1253
+ };
1254
+ /** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
1255
+ * into your editor role next to `cmsPolicies().editor`:
1256
+ *
1257
+ * role("editor", [...cmsPolicies().editor, ...collectionPolicies([lectures])])
1258
+ *
1259
+ * The generic collection handlers go through `ctx.db`, so without these the row ACL denies
1260
+ * them. Your app may already declare its own policies over the entity (e.g. a public read
1261
+ * scope) — these only ADD the editor grant the CMS UI needs. */
1262
+ export declare function collectionPolicies(collections: readonly CollectionDef[], opts?: CmsPolicyOpts): Policy[];
1114
1263
  /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
1115
1264
  * They run with a privileged, system-scoped ctx off the write path (the outbox drain).
1116
1265
  *
package/dist/index.js CHANGED
@@ -189,9 +189,10 @@ export const cmsSchema = {
189
189
  id: primaryKey(generated(t.uuid())),
190
190
  typeId: notNull(t.uuid()),
191
191
  title: notNull(t.text()),
192
- // NOT globally unique — a slug is unique PER LOCALE (`/en/about` + `/cs/about`).
193
- // pramen's unique() is single-column only, so (slug, locale) uniqueness is enforced
194
- // in createPage/createTranslation; this index just speeds the lookup.
192
+ // A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
193
+ // composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
194
+ // also pre-check so the caller gets a clean 409 before hitting the constraint; the index
195
+ // also speeds slug lookups.
195
196
  slug: indexed(notNull(t.text())),
196
197
  status: defaultTo(t.text(), "draft"), // draft | published | archived
197
198
  locale: defaultTo(t.text(), "en"),
@@ -220,7 +221,7 @@ export const cmsSchema = {
220
221
  }), (r) => ({
221
222
  type: r.belongsTo("cms_content_types", "typeId"),
222
223
  placements: r.hasMany("cms_page_blocks", "pageId"),
223
- })),
224
+ }), { unique: [["slug", "locale"]] }),
224
225
  cms_page_blocks: Entity((t) => ({
225
226
  id: primaryKey(generated(t.uuid())),
226
227
  pageId: notNull(t.uuid()),
@@ -880,6 +881,53 @@ export function createCmsHandlers(opts = {}) {
880
881
  return o;
881
882
  },
882
883
  }),
884
+ /** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
885
+ * (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
886
+ * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
887
+ * page record itself, which was previously only settable at createPage. A slug/locale
888
+ * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
889
+ * + sanitized against the content type's fieldsSchema, exactly like createPage. */
890
+ updatePage: mutation(async (ctx, input) => {
891
+ const db = cdb(ctx);
892
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
893
+ const page = rows[0];
894
+ if (!page)
895
+ throw notFound("page");
896
+ const patch = { updatedAt: nowStamp() };
897
+ if (input.title !== undefined)
898
+ patch.title = input.title;
899
+ if (input.slug !== undefined || input.locale !== undefined) {
900
+ const nextSlug = input.slug ?? String(page.slug);
901
+ const nextLocale = input.locale ?? String(page.locale);
902
+ await assertSlugFree(db, nextSlug, nextLocale, String(page.id));
903
+ if (input.slug !== undefined)
904
+ patch.slug = input.slug;
905
+ if (input.locale !== undefined)
906
+ patch.locale = input.locale;
907
+ }
908
+ if (input.fields !== undefined) {
909
+ const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
910
+ const schema = ctRows[0]?.fieldsSchema;
911
+ validateFields(schema, input.fields, "page.fields", { requireRequired: false });
912
+ patch.fields = await sanitizeFields(schema, input.fields);
913
+ }
914
+ const updated = await db.update("cms_pages", input.pageId, patch);
915
+ if (!updated)
916
+ throw notFound("page");
917
+ return { ok: true, page: updated };
918
+ }, {
919
+ ...editor,
920
+ input: (raw) => {
921
+ const o = asObj(raw);
922
+ if (typeof o.pageId !== "string")
923
+ throw new BadRequest("pageId is required");
924
+ for (const k of ["title", "slug", "locale"]) {
925
+ if (o[k] !== undefined && typeof o[k] !== "string")
926
+ throw new BadRequest(`${k} must be a string`);
927
+ }
928
+ return o;
929
+ },
930
+ }),
883
931
  /** Create a page and auto-scaffold its content type's default blocks. */
884
932
  createPage: mutation(async (ctx, input) => {
885
933
  const db = cdb(ctx);
@@ -1440,6 +1488,129 @@ export function cmsPolicies(opts = {}) {
1440
1488
  editor: editorPolicies,
1441
1489
  };
1442
1490
  }
1491
+ /** Declare a collection. Spread the results into `createCollectionHandlers` +
1492
+ * `collectionPolicies`:
1493
+ *
1494
+ * const lectures = collection("lectures", {
1495
+ * entity: "lectures", label: "Lecture", titleField: "title",
1496
+ * list: ["title", "speaker", "date"],
1497
+ * fields: [
1498
+ * { name: "title", type: "text", required: true },
1499
+ * { name: "speaker", type: "text" },
1500
+ * { name: "date", type: "date" },
1501
+ * ],
1502
+ * }); */
1503
+ export function collection(slug, opts) {
1504
+ return { ...opts, slug };
1505
+ }
1506
+ /** The public view of a collection def (defaults filled). */
1507
+ function collectionMeta(c) {
1508
+ const titleField = c.titleField ?? "title";
1509
+ return {
1510
+ slug: c.slug,
1511
+ label: c.label,
1512
+ pluralLabel: c.pluralLabel ?? `${c.label}s`,
1513
+ icon: c.icon,
1514
+ fields: c.fields,
1515
+ list: c.list ?? [titleField],
1516
+ titleField,
1517
+ idField: c.idField ?? "id",
1518
+ orderBy: c.orderBy,
1519
+ };
1520
+ }
1521
+ /** Build generic CRUD handlers over the registered collections. Spread into your app's
1522
+ * handlers alongside `cmsHandlers`:
1523
+ *
1524
+ * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
1525
+ *
1526
+ * Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
1527
+ * `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
1528
+ * (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
1529
+ * `collectionPolicies` scopes them too). The `collection` param is resolved through the
1530
+ * registry — an unknown slug is a 400, never a raw table reference. */
1531
+ export function createCollectionHandlers(collections, opts = {}) {
1532
+ const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
1533
+ const bySlug = new Map(collections.map((c) => [c.slug, c]));
1534
+ const metas = collections.map(collectionMeta);
1535
+ const def = (slug) => {
1536
+ const c = typeof slug === "string" ? bySlug.get(slug) : undefined;
1537
+ if (!c)
1538
+ throw new BadRequest(`unknown collection: ${String(slug)}`);
1539
+ return c;
1540
+ };
1541
+ const idOf = (c) => c.idField ?? "id";
1542
+ // Validate against the field schema, sanitize richtext, then PROJECT to declared field
1543
+ // names only — the write whitelist. `requireRequired` is off for updates (partial patch);
1544
+ // on for create. Nothing outside `c.fields` can reach the entity.
1545
+ const toColumns = (c, values, requireRequired) => {
1546
+ const obj = asObj(values);
1547
+ validateFields([...c.fields], obj, "", { requireRequired });
1548
+ const sanitized = sanitizeFields([...c.fields], obj);
1549
+ const out = {};
1550
+ for (const f of c.fields)
1551
+ if (f.name in sanitized)
1552
+ out[f.name] = sanitized[f.name];
1553
+ return out;
1554
+ };
1555
+ const idInput = (raw) => {
1556
+ const id = asObj(raw).id;
1557
+ if (typeof id !== "string" || id === "")
1558
+ throw new BadRequest("id is required");
1559
+ return id;
1560
+ };
1561
+ return {
1562
+ /** The registered collections (defaults filled) — editor discovery. Editor-gated so
1563
+ * the collection schemas aren't exposed to anonymous callers. */
1564
+ listCollections: query(() => metas, editor),
1565
+ collectionList: query((ctx, input) => {
1566
+ const c = def(input.collection);
1567
+ const limit = typeof input.limit === "number" ? input.limit : 100;
1568
+ const offset = typeof input.offset === "number" ? input.offset : undefined;
1569
+ return cdb(ctx).find({ from: c.entity, orderBy: c.orderBy ?? { column: "createdAt", dir: "desc" }, limit, offset });
1570
+ }, editor),
1571
+ collectionGet: query(async (ctx, input) => {
1572
+ const c = def(input.collection);
1573
+ const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
1574
+ return rows[0] ?? null;
1575
+ }, editor),
1576
+ collectionCreate: mutation((ctx, input) => {
1577
+ const c = def(input.collection);
1578
+ return cdb(ctx).insert(c.entity, toColumns(c, input.values, true));
1579
+ }, editor),
1580
+ collectionUpdate: mutation(async (ctx, input) => {
1581
+ const c = def(input.collection);
1582
+ const updated = await cdb(ctx).update(c.entity, input.id, toColumns(c, input.values, false));
1583
+ if (updated === undefined)
1584
+ throw notFound(c.label);
1585
+ return updated;
1586
+ }, editor),
1587
+ collectionDelete: mutation(async (ctx, input) => {
1588
+ const c = def(input.collection);
1589
+ const ok = await cdb(ctx).delete(c.entity, input.id);
1590
+ if (!ok)
1591
+ throw notFound(c.label);
1592
+ return { ok: true };
1593
+ }, { ...editor, input: (raw) => ({ collection: asObj(raw).collection, id: idInput(raw) }) }),
1594
+ };
1595
+ }
1596
+ /** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
1597
+ * into your editor role next to `cmsPolicies().editor`:
1598
+ *
1599
+ * role("editor", [...cmsPolicies().editor, ...collectionPolicies([lectures])])
1600
+ *
1601
+ * The generic collection handlers go through `ctx.db`, so without these the row ACL denies
1602
+ * them. Your app may already declare its own policies over the entity (e.g. a public read
1603
+ * scope) — these only ADD the editor grant the CMS UI needs. */
1604
+ export function collectionPolicies(collections, opts = {}) {
1605
+ const p = opts.prefix ?? "cms";
1606
+ const out = [];
1607
+ for (const c of collections) {
1608
+ for (const action of ["read", "create", "update", "delete"]) {
1609
+ out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
1610
+ }
1611
+ }
1612
+ return out;
1613
+ }
1443
1614
  // --- deferred tasks (scheduled publish/unpublish) ----------------------------
1444
1615
  /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.
1445
1616
  * They run with a privileged, system-scoped ctx off the write path (the outbox drain).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "description": "Optional block/page builder for pramen — Drupal-Paragraphs-style typed blocks in named regions, reusable blocks, scheduled publishing, built entirely from pramen primitives.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,7 +41,7 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@pramen/server": "0.0.34",
44
+ "@pramen/server": "0.0.36",
45
45
  "xss": "^1.0.15"
46
46
  },
47
47
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -365,9 +365,10 @@ export const cmsSchema = {
365
365
  id: primaryKey(generated(t.uuid())),
366
366
  typeId: notNull(t.uuid()),
367
367
  title: notNull(t.text()),
368
- // NOT globally unique — a slug is unique PER LOCALE (`/en/about` + `/cs/about`).
369
- // pramen's unique() is single-column only, so (slug, locale) uniqueness is enforced
370
- // in createPage/createTranslation; this index just speeds the lookup.
368
+ // A slug is unique PER LOCALE (`/en/about` + `/cs/about`) — enforced by the entity's
369
+ // composite `unique: [["slug","locale"]]` (below). createPage/updatePage/createTranslation
370
+ // also pre-check so the caller gets a clean 409 before hitting the constraint; the index
371
+ // also speeds slug lookups.
371
372
  slug: indexed(notNull(t.text())),
372
373
  status: defaultTo(t.text(), "draft"), // draft | published | archived
373
374
  locale: defaultTo(t.text(), "en"),
@@ -398,6 +399,7 @@ export const cmsSchema = {
398
399
  type: r.belongsTo("cms_content_types", "typeId"),
399
400
  placements: r.hasMany("cms_page_blocks", "pageId"),
400
401
  }),
402
+ { unique: [["slug", "locale"]] }, // a slug is unique per locale (DB-enforced)
401
403
  ),
402
404
 
403
405
  cms_page_blocks: Entity(
@@ -1174,6 +1176,47 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1174
1176
  },
1175
1177
  }),
1176
1178
 
1179
+ /** Edit a page's own attributes — title, slug, locale, and content-type-level `fields`
1180
+ * (the structured data of a non-block content type, e.g. a "Lecture" with date/speaker).
1181
+ * Blocks are edited via addBlock/updateBlock; SEO via updatePageSeo; this covers the
1182
+ * page record itself, which was previously only settable at createPage. A slug/locale
1183
+ * change re-checks (slug, locale) uniqueness (excluding this page); `fields` is validated
1184
+ * + sanitized against the content type's fieldsSchema, exactly like createPage. */
1185
+ updatePage: mutation(async (ctx, input: { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> }) => {
1186
+ const db = cdb(ctx);
1187
+ const rows = await db.find({ from: "cms_pages", where: { id: input.pageId }, limit: 1 });
1188
+ const page = rows[0];
1189
+ if (!page) throw notFound("page");
1190
+ const patch: Record<string, unknown> = { updatedAt: nowStamp() };
1191
+ if (input.title !== undefined) patch.title = input.title;
1192
+ if (input.slug !== undefined || input.locale !== undefined) {
1193
+ const nextSlug = input.slug ?? String(page.slug);
1194
+ const nextLocale = input.locale ?? String(page.locale);
1195
+ await assertSlugFree(db, nextSlug, nextLocale, String(page.id));
1196
+ if (input.slug !== undefined) patch.slug = input.slug;
1197
+ if (input.locale !== undefined) patch.locale = input.locale;
1198
+ }
1199
+ if (input.fields !== undefined) {
1200
+ const ctRows = await db.find({ from: "cms_content_types", where: { id: page.typeId }, limit: 1 });
1201
+ const schema = ctRows[0]?.fieldsSchema as FieldDefinition[] | undefined;
1202
+ validateFields(schema, input.fields, "page.fields", { requireRequired: false });
1203
+ patch.fields = await sanitizeFields(schema, input.fields);
1204
+ }
1205
+ const updated = await db.update("cms_pages", input.pageId, patch);
1206
+ if (!updated) throw notFound("page");
1207
+ return { ok: true, page: updated };
1208
+ }, {
1209
+ ...editor,
1210
+ input: (raw): { pageId: string; title?: string; slug?: string; locale?: string; fields?: Record<string, unknown> } => {
1211
+ const o = asObj(raw);
1212
+ if (typeof o.pageId !== "string") throw new BadRequest("pageId is required");
1213
+ for (const k of ["title", "slug", "locale"] as const) {
1214
+ if (o[k] !== undefined && typeof o[k] !== "string") throw new BadRequest(`${k} must be a string`);
1215
+ }
1216
+ return o as never;
1217
+ },
1218
+ }),
1219
+
1177
1220
  /** Create a page and auto-scaffold its content type's default blocks. */
1178
1221
  createPage: mutation(async (ctx, input: { typeId: string; title: string; slug: string; locale?: string; fields?: Record<string, unknown> }) => {
1179
1222
  const db = cdb(ctx);
@@ -1719,6 +1762,196 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
1719
1762
  };
1720
1763
  }
1721
1764
 
1765
+ // --- collections: edit arbitrary pramen entities in the CMS editor -----------
1766
+ //
1767
+ // The block/page model is one opinionated shape (a routable page with a mandatory slug +
1768
+ // regions of blocks). A COLLECTION is the generic escape hatch: it points the editor at
1769
+ // one of YOUR OWN pramen entities (spread into defineSchema alongside cmsSchema) and
1770
+ // describes how to edit it with the SAME field DSL that blocks use. So "Lectures" is a
1771
+ // first-class, queryable entity — real columns, relations, cell-ACL — that also gets a
1772
+ // list + form UI, without being bent into a cms_pages row.
1773
+ //
1774
+ // Column-mapped: each scalar FieldDefinition.name is a real column on the entity; a
1775
+ // repeater/group field maps to a t.json() column (the object↔JSON codec at the Db
1776
+ // chokepoint stores it transparently). The generic handlers dispatch through a registry
1777
+ // keyed by `slug`, so `collection`/`entity` can never be spoofed to reach an arbitrary
1778
+ // table, and writes are whitelisted to declared fields — the client can't set columns the
1779
+ // collection didn't declare (e.g. a `roles` or `passwordHash` column on the entity).
1780
+
1781
+ /** Declares that a pramen entity is editable as a collection in the CMS editor. Both
1782
+ * halves live here: the runtime facts (entity, idField, validation via `fields`) and the
1783
+ * UI facts (labels, list columns, ordering). Mirror of {@link ContentTypeDef}. */
1784
+ export interface CollectionDef {
1785
+ /** The pramen entity (table) this collection edits — one of your own entities, spread
1786
+ * into `defineSchema` next to `cmsSchema`. The handler registry keys off `slug`, then
1787
+ * reads this; it is never taken from client input. */
1788
+ readonly entity: string;
1789
+ /** URL + nav key, e.g. `"lectures"`. Unique across collections. */
1790
+ readonly slug: string;
1791
+ /** Singular UI label, e.g. `"Lecture"`. */
1792
+ readonly label: string;
1793
+ /** Plural UI label; defaults to `label + "s"`. */
1794
+ readonly pluralLabel?: string;
1795
+ /** Optional nav icon (emoji or short string). */
1796
+ readonly icon?: string;
1797
+ /** The edit-form schema — the same DSL as blocks. Each scalar field is a real column
1798
+ * on `entity`; repeater/group map to a `t.json()` column. Also the WRITE WHITELIST:
1799
+ * only these field names are ever written to the entity. */
1800
+ readonly fields: readonly FieldDefinition[];
1801
+ /** Columns shown in the list view; defaults to `[titleField]`. */
1802
+ readonly list?: readonly string[];
1803
+ /** Column that titles a row in the list; defaults to `"title"`. */
1804
+ readonly titleField?: string;
1805
+ /** Primary-key column used to load/patch a single row; defaults to `"id"`. */
1806
+ readonly idField?: string;
1807
+ /** Default list ordering; defaults to `{ column: "createdAt", dir: "desc" }`. */
1808
+ readonly orderBy?: { column: string; dir?: "asc" | "desc" };
1809
+ }
1810
+
1811
+ /** Declare a collection. Spread the results into `createCollectionHandlers` +
1812
+ * `collectionPolicies`:
1813
+ *
1814
+ * const lectures = collection("lectures", {
1815
+ * entity: "lectures", label: "Lecture", titleField: "title",
1816
+ * list: ["title", "speaker", "date"],
1817
+ * fields: [
1818
+ * { name: "title", type: "text", required: true },
1819
+ * { name: "speaker", type: "text" },
1820
+ * { name: "date", type: "date" },
1821
+ * ],
1822
+ * }); */
1823
+ export function collection(slug: string, opts: Omit<CollectionDef, "slug">): CollectionDef {
1824
+ return { ...opts, slug };
1825
+ }
1826
+
1827
+ /** The client-facing subset of a collection the editor fetches via `listCollections` to
1828
+ * build its nav + generic list/edit views (no per-collection editor code, no rebuild to
1829
+ * add one). Excludes the server-only `entity` (the editor addresses a collection by `slug`
1830
+ * only, never by table name); `idField` IS included — it's just the PK column name, which
1831
+ * the editor needs to read a row's id from a list result. */
1832
+ export interface CollectionMeta {
1833
+ slug: string;
1834
+ label: string;
1835
+ pluralLabel: string;
1836
+ icon?: string;
1837
+ fields: readonly FieldDefinition[];
1838
+ list: readonly string[];
1839
+ titleField: string;
1840
+ idField: string;
1841
+ orderBy?: { column: string; dir?: "asc" | "desc" };
1842
+ }
1843
+
1844
+ /** The public view of a collection def (defaults filled). */
1845
+ function collectionMeta(c: CollectionDef): CollectionMeta {
1846
+ const titleField = c.titleField ?? "title";
1847
+ return {
1848
+ slug: c.slug,
1849
+ label: c.label,
1850
+ pluralLabel: c.pluralLabel ?? `${c.label}s`,
1851
+ icon: c.icon,
1852
+ fields: c.fields,
1853
+ list: c.list ?? [titleField],
1854
+ titleField,
1855
+ idField: c.idField ?? "id",
1856
+ orderBy: c.orderBy,
1857
+ };
1858
+ }
1859
+
1860
+ /** Build generic CRUD handlers over the registered collections. Spread into your app's
1861
+ * handlers alongside `cmsHandlers`:
1862
+ *
1863
+ * const handlers = { ...cmsHandlers, ...createCollectionHandlers([lectures]) };
1864
+ *
1865
+ * Exposes `listCollections` (editor discovery) + `collectionList` / `collectionGet` /
1866
+ * `collectionCreate` / `collectionUpdate` / `collectionDelete`, all gated by `editorRoles`
1867
+ * (a fast 403 before the body) AND the row ACL (they go through `ctx.db`, so
1868
+ * `collectionPolicies` scopes them too). The `collection` param is resolved through the
1869
+ * registry — an unknown slug is a 400, never a raw table reference. */
1870
+ export function createCollectionHandlers(collections: readonly CollectionDef[], opts: CmsHandlerOpts = {}) {
1871
+ const editor = { auth: opts.editorRoles ?? ["editor", "admin"] };
1872
+ const bySlug = new Map(collections.map((c) => [c.slug, c] as const));
1873
+ const metas = collections.map(collectionMeta);
1874
+ const def = (slug: unknown): CollectionDef => {
1875
+ const c = typeof slug === "string" ? bySlug.get(slug) : undefined;
1876
+ if (!c) throw new BadRequest(`unknown collection: ${String(slug)}`);
1877
+ return c;
1878
+ };
1879
+ const idOf = (c: CollectionDef): string => c.idField ?? "id";
1880
+ // Validate against the field schema, sanitize richtext, then PROJECT to declared field
1881
+ // names only — the write whitelist. `requireRequired` is off for updates (partial patch);
1882
+ // on for create. Nothing outside `c.fields` can reach the entity.
1883
+ const toColumns = (c: CollectionDef, values: unknown, requireRequired: boolean): Record<string, unknown> => {
1884
+ const obj = asObj(values);
1885
+ validateFields([...c.fields], obj, "", { requireRequired });
1886
+ const sanitized = sanitizeFields([...c.fields], obj);
1887
+ const out: Record<string, unknown> = {};
1888
+ for (const f of c.fields) if (f.name in sanitized) out[f.name] = sanitized[f.name];
1889
+ return out;
1890
+ };
1891
+ const idInput = (raw: unknown): string => {
1892
+ const id = asObj(raw).id;
1893
+ if (typeof id !== "string" || id === "") throw new BadRequest("id is required");
1894
+ return id;
1895
+ };
1896
+
1897
+ return {
1898
+ /** The registered collections (defaults filled) — editor discovery. Editor-gated so
1899
+ * the collection schemas aren't exposed to anonymous callers. */
1900
+ listCollections: query((): CollectionMeta[] => metas, editor),
1901
+
1902
+ collectionList: query((ctx, input: { collection: string; limit?: number; offset?: number }) => {
1903
+ const c = def(input.collection);
1904
+ const limit = typeof input.limit === "number" ? input.limit : 100;
1905
+ const offset = typeof input.offset === "number" ? input.offset : undefined;
1906
+ return cdb(ctx).find({ from: c.entity, orderBy: c.orderBy ?? { column: "createdAt", dir: "desc" }, limit, offset });
1907
+ }, editor),
1908
+
1909
+ collectionGet: query(async (ctx, input: { collection: string; id: string }) => {
1910
+ const c = def(input.collection);
1911
+ const rows = await cdb(ctx).find({ from: c.entity, where: { [idOf(c)]: input.id }, limit: 1 });
1912
+ return rows[0] ?? null;
1913
+ }, editor),
1914
+
1915
+ collectionCreate: mutation((ctx, input: { collection: string; values: Record<string, unknown> }) => {
1916
+ const c = def(input.collection);
1917
+ return cdb(ctx).insert(c.entity, toColumns(c, input.values, true));
1918
+ }, editor),
1919
+
1920
+ collectionUpdate: mutation(async (ctx, input: { collection: string; id: string; values: Record<string, unknown> }) => {
1921
+ const c = def(input.collection);
1922
+ const updated = await cdb(ctx).update(c.entity, input.id, toColumns(c, input.values, false));
1923
+ if (updated === undefined) throw notFound(c.label);
1924
+ return updated;
1925
+ }, editor),
1926
+
1927
+ collectionDelete: mutation(async (ctx, input: { collection: string; id: string }) => {
1928
+ const c = def(input.collection);
1929
+ const ok = await cdb(ctx).delete(c.entity, input.id);
1930
+ if (!ok) throw notFound(c.label);
1931
+ return { ok: true as const };
1932
+ }, { ...editor, input: (raw) => ({ collection: asObj(raw).collection as string, id: idInput(raw) }) }),
1933
+ };
1934
+ }
1935
+
1936
+ /** ACL fragments granting the editor role full CRUD over each collection's entity. Spread
1937
+ * into your editor role next to `cmsPolicies().editor`:
1938
+ *
1939
+ * role("editor", [...cmsPolicies().editor, ...collectionPolicies([lectures])])
1940
+ *
1941
+ * The generic collection handlers go through `ctx.db`, so without these the row ACL denies
1942
+ * them. Your app may already declare its own policies over the entity (e.g. a public read
1943
+ * scope) — these only ADD the editor grant the CMS UI needs. */
1944
+ export function collectionPolicies(collections: readonly CollectionDef[], opts: CmsPolicyOpts = {}): Policy[] {
1945
+ const p = opts.prefix ?? "cms";
1946
+ const out: Policy[] = [];
1947
+ for (const c of collections) {
1948
+ for (const action of ["read", "create", "update", "delete"] as const) {
1949
+ out.push(policy(`${p}:editor:collection:${c.entity}:${action}`, c.entity, action, allow()));
1950
+ }
1951
+ }
1952
+ return out;
1953
+ }
1954
+
1722
1955
  // --- deferred tasks (scheduled publish/unpublish) ----------------------------
1723
1956
 
1724
1957
  /** Task handlers backing `schedulePage`. Register via `app.tasks = { ...cmsTasks }`.