@pramen/cms 0.0.22 → 0.0.24

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
@@ -1,4 +1,4 @@
1
- import type { HandlerContext, Policy, FileRef } from "@pramen/server";
1
+ import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
2
2
  /** A field in a block type's (or content type's) field schema. Recursive: a `repeater`
3
3
  * or `group` nests `fields`. Mirrors WollyCMS's FieldDefinition. */
4
4
  export interface FieldDefinition {
@@ -72,6 +72,44 @@ export declare function defineBlockType<S extends string, F extends readonly Fie
72
72
  }): BlockTypeDef<S, F>;
73
73
  /** The inferred `fields` type of a `defineBlockType` result. */
74
74
  export type BlockFieldsOf<D extends BlockTypeDef> = InferBlockFields<D["fieldsSchema"]>;
75
+ /** A developer-authored content type: a page template. Page-level `fields`, named `regions`
76
+ * (each with an optional block-type allow-list), and optional `defaultBlocks` scaffolded when
77
+ * a page of this type is created. Mirror of `BlockTypeDef`; feed to `cmsBootstrap`. */
78
+ export interface ContentTypeDef {
79
+ readonly slug: string;
80
+ readonly name: string;
81
+ readonly description?: string;
82
+ readonly fields?: readonly FieldDefinition[];
83
+ readonly regions: readonly RegionDefinition[];
84
+ readonly defaultBlocks?: readonly DefaultBlockDefinition[];
85
+ }
86
+ /** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
87
+ *
88
+ * const article = defineContentType("article", {
89
+ * name: "Article",
90
+ * fields: [{ name: "perex", type: "textarea" }, { name: "date", type: "date" }],
91
+ * regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
92
+ * }); */
93
+ export declare function defineContentType(slug: string, opts: {
94
+ name?: string;
95
+ description?: string;
96
+ fields?: readonly FieldDefinition[];
97
+ regions: readonly RegionDefinition[];
98
+ defaultBlocks?: readonly DefaultBlockDefinition[];
99
+ }): ContentTypeDef;
100
+ /** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
101
+ * `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
102
+ * identical one untouched. Register it on your app:
103
+ *
104
+ * export const app = { schema, handlers, acl, tasks,
105
+ * bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
106
+ *
107
+ * Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
108
+ * code-declared types with no manual createContentType/createBlockType call. */
109
+ export declare function cmsBootstrap(defs: {
110
+ blockTypes?: readonly BlockTypeDef[];
111
+ contentTypes?: readonly ContentTypeDef[];
112
+ }): BootstrapFn;
75
113
  /** Emit a `.ts` module of per-slug field interfaces + a `BlockFieldsBySlug` registry from
76
114
  * DB-stored block types (`{ slug, fieldsSchema }` rows). The runtime counterpart to the
77
115
  * compile-time `InferBlockFields`, for webmaster-authored (data-driven) block types. */
@@ -494,6 +532,9 @@ export interface AssembledPage {
494
532
  slug: string;
495
533
  status: string;
496
534
  locale: string;
535
+ /** The page's content-type slug (e.g. "article", "page") — lets a frontend route/render
536
+ * by type. `null` if the type row is missing. */
537
+ contentType: string | null;
497
538
  translationGroupId: string | null;
498
539
  /** Published sibling locales of this page (for hreflang alternates). */
499
540
  translations: PageTranslation[];
@@ -627,6 +668,7 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
627
668
  listPublishedPages: import("@pramen/server").Handler<unknown, {
628
669
  slug: string;
629
670
  locale: string;
671
+ contentType: string | null;
630
672
  updatedAt: string;
631
673
  }[]>;
632
674
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
@@ -880,6 +922,7 @@ export declare const cmsHandlers: {
880
922
  listPublishedPages: import("@pramen/server").Handler<unknown, {
881
923
  slug: string;
882
924
  locale: string;
925
+ contentType: string | null;
883
926
  updatedAt: string;
884
927
  }[]>;
885
928
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
package/dist/index.js CHANGED
@@ -40,6 +40,69 @@ import { filterXSS } from "xss";
40
40
  export function defineBlockType(slug, fields, opts = {}) {
41
41
  return { slug, name: opts.name ?? slug, fieldsSchema: fields, description: opts.description, icon: opts.icon, category: opts.category };
42
42
  }
43
+ /** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
44
+ *
45
+ * const article = defineContentType("article", {
46
+ * name: "Article",
47
+ * fields: [{ name: "perex", type: "textarea" }, { name: "date", type: "date" }],
48
+ * regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
49
+ * }); */
50
+ export function defineContentType(slug, opts) {
51
+ return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
52
+ }
53
+ const sameJson = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
54
+ /** Insert `values` if no row has this `slug`, else patch only the columns that drifted
55
+ * (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op. */
56
+ async function upsertBySlug(db, table, slug, values) {
57
+ const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
58
+ if (!existing) {
59
+ await db.insert(table, values);
60
+ return;
61
+ }
62
+ const patch = {};
63
+ for (const [k, v] of Object.entries(values)) {
64
+ if (k === "slug")
65
+ continue;
66
+ if (!sameJson(existing[k], v))
67
+ patch[k] = v;
68
+ }
69
+ if (Object.keys(patch).length)
70
+ await db.update(table, String(existing.id), patch);
71
+ }
72
+ /** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
73
+ * `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
74
+ * identical one untouched. Register it on your app:
75
+ *
76
+ * export const app = { schema, handlers, acl, tasks,
77
+ * bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
78
+ *
79
+ * Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
80
+ * code-declared types with no manual createContentType/createBlockType call. */
81
+ export function cmsBootstrap(defs) {
82
+ return async ({ db }) => {
83
+ const sys = db;
84
+ for (const bt of defs.blockTypes ?? []) {
85
+ await upsertBySlug(sys, "cms_block_types", bt.slug, {
86
+ name: bt.name,
87
+ slug: bt.slug,
88
+ description: bt.description ?? null,
89
+ fieldsSchema: bt.fieldsSchema ?? [],
90
+ icon: bt.icon ?? null,
91
+ category: bt.category ?? null,
92
+ });
93
+ }
94
+ for (const ct of defs.contentTypes ?? []) {
95
+ await upsertBySlug(sys, "cms_content_types", ct.slug, {
96
+ name: ct.name,
97
+ slug: ct.slug,
98
+ description: ct.description ?? null,
99
+ fieldsSchema: ct.fields ?? [],
100
+ regions: ct.regions ?? [],
101
+ defaultBlocks: ct.defaultBlocks ?? [],
102
+ });
103
+ }
104
+ };
105
+ }
43
106
  // --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
44
107
  //
45
108
  // The data-driven half (webmaster-created block types) has no static type. This is the
@@ -402,6 +465,13 @@ const isEditor = (ctx, roles) => {
402
465
  };
403
466
  /** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
404
467
  * by position, merging each shared placement's `overrides` over its block's fields. */
468
+ /** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
469
+ async function contentTypeSlug(db, typeId) {
470
+ if (typeof typeId !== "string" || !typeId)
471
+ return null;
472
+ const rows = await db.find({ from: "cms_content_types", where: { id: typeId }, limit: 1 });
473
+ return rows[0] ? String(rows[0].slug) : null;
474
+ }
405
475
  async function assembleLive(db, page) {
406
476
  const placements = await db.find({
407
477
  from: "cms_page_blocks",
@@ -449,8 +519,8 @@ async function assembleLive(db, page) {
449
519
  is_shared: Boolean(m.p.isShared),
450
520
  });
451
521
  }
452
- const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
453
- return { page: pageMeta(page, translations, ogImage), regions };
522
+ const [translations, ogImage, contentType] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage), contentTypeSlug(db, page.typeId)]);
523
+ return { page: pageMeta(page, translations, ogImage, contentType), regions };
454
524
  }
455
525
  /** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
456
526
  async function resolveMediaId(db, id) {
@@ -496,7 +566,7 @@ async function siblingTranslations(db, page) {
496
566
  .map((r) => ({ locale: String(r.locale ?? "en"), slug: String(r.slug) }));
497
567
  }
498
568
  /** Project a page row to the public AssembledPage.page shape. */
499
- function pageMeta(page, translations = [], ogImage = null) {
569
+ function pageMeta(page, translations = [], ogImage = null, contentType = null) {
500
570
  const metaTitle = page.metaTitle ?? null;
501
571
  const metaDescription = page.metaDescription ?? null;
502
572
  return {
@@ -505,6 +575,7 @@ function pageMeta(page, translations = [], ogImage = null) {
505
575
  slug: String(page.slug),
506
576
  status: String(page.status),
507
577
  locale: String(page.locale ?? "en"),
578
+ contentType,
508
579
  translationGroupId: page.translationGroupId ?? null,
509
580
  translations,
510
581
  fields: page.fields ?? null,
@@ -777,8 +848,14 @@ export function createCmsHandlers(opts = {}) {
777
848
  /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
778
849
  * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
779
850
  listPublishedPages: query(async (ctx) => {
780
- const rows = await cdb(ctx).find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
781
- return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
851
+ const db = cdb(ctx);
852
+ const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
853
+ // Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
854
+ // vs pages) without a second round-trip.
855
+ const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v) => typeof v === "string"))];
856
+ const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
857
+ const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
858
+ return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
782
859
  }),
783
860
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
784
861
  updatePageSeo: mutation(async (ctx, input) => {
@@ -1346,6 +1423,10 @@ export function cmsPolicies(opts = {}) {
1346
1423
  }
1347
1424
  return {
1348
1425
  public: [
1426
+ // Content-type metadata (slug/name) is public: the content API returns each published
1427
+ // page's content-type slug (via listPublishedPages' live typeId→slug join) so a frontend
1428
+ // can route/render by type. Slugs/names are structural, not sensitive.
1429
+ policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
1349
1430
  // Only published pages are readable; the snapshot carries the content.
1350
1431
  policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
1351
1432
  // getPage reads the latest revision snapshot. Scope the grant by the revision's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
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.22",
44
+ "@pramen/server": "0.0.24",
45
45
  "xss": "^1.0.15"
46
46
  },
47
47
  "peerDependencies": {
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  Forbidden,
43
43
  PramenError,
44
44
  } from "@pramen/server";
45
- import type { HandlerContext, Policy, FileRef } from "@pramen/server";
45
+ import type { HandlerContext, Policy, FileRef, BootstrapFn } from "@pramen/server";
46
46
  import { filterXSS } from "xss";
47
47
 
48
48
  // --- field schema DSL (the block-editor field language) ---------------------
@@ -159,6 +159,109 @@ export function defineBlockType<S extends string, F extends readonly FieldDefini
159
159
  /** The inferred `fields` type of a `defineBlockType` result. */
160
160
  export type BlockFieldsOf<D extends BlockTypeDef> = InferBlockFields<D["fieldsSchema"]>;
161
161
 
162
+ // --- code-defined content types + bootstrap reconcile ---------------------------------
163
+ //
164
+ // Block/content types are runtime rows (a webmaster can add one with no deploy), but a repo
165
+ // that hard-depends on a fixed shape (e.g. an Astro site whose build fails unless an
166
+ // `article` type with certain fields exists) wants them CODE-DEFINED and auto-applied. These
167
+ // helpers let you declare types in code and converge them into the store on boot via pramen's
168
+ // `app.bootstrap` — so a fresh / reprovisioned database has them without a manual
169
+ // createContentType/createBlockType call.
170
+
171
+ /** A developer-authored content type: a page template. Page-level `fields`, named `regions`
172
+ * (each with an optional block-type allow-list), and optional `defaultBlocks` scaffolded when
173
+ * a page of this type is created. Mirror of `BlockTypeDef`; feed to `cmsBootstrap`. */
174
+ export interface ContentTypeDef {
175
+ readonly slug: string;
176
+ readonly name: string;
177
+ readonly description?: string;
178
+ readonly fields?: readonly FieldDefinition[];
179
+ readonly regions: readonly RegionDefinition[];
180
+ readonly defaultBlocks?: readonly DefaultBlockDefinition[];
181
+ }
182
+
183
+ /** Declare a content type in code. Spread the result into `cmsBootstrap({ contentTypes })`:
184
+ *
185
+ * const article = defineContentType("article", {
186
+ * name: "Article",
187
+ * fields: [{ name: "perex", type: "textarea" }, { name: "date", type: "date" }],
188
+ * regions: [{ name: "content", allowedTypes: ["rich_text", "image"] }],
189
+ * }); */
190
+ export function defineContentType(
191
+ slug: string,
192
+ opts: {
193
+ name?: string;
194
+ description?: string;
195
+ fields?: readonly FieldDefinition[];
196
+ regions: readonly RegionDefinition[];
197
+ defaultBlocks?: readonly DefaultBlockDefinition[];
198
+ },
199
+ ): ContentTypeDef {
200
+ return { slug, name: opts.name ?? slug, description: opts.description, fields: opts.fields, regions: opts.regions, defaultBlocks: opts.defaultBlocks };
201
+ }
202
+
203
+ /** The narrow slice of the system Db a reconcile needs. `cmsBootstrap` runs with a SYSTEM
204
+ * Db (ACL bypassed), so these calls are unrestricted; kept loose to avoid threading the
205
+ * host app's schema generic through a library helper. */
206
+ interface ReconcileDb {
207
+ find(q: { from: string; where?: Record<string, unknown>; limit?: number }): Promise<Record<string, unknown>[]>;
208
+ insert(table: string, values: Record<string, unknown>): Promise<unknown>;
209
+ update(table: string, id: string, patch: Record<string, unknown>): Promise<unknown>;
210
+ }
211
+
212
+ const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
213
+
214
+ /** Insert `values` if no row has this `slug`, else patch only the columns that drifted
215
+ * (never `id`/`slug`/`createdAt`). Idempotent — an identical definition is a no-op. */
216
+ async function upsertBySlug(db: ReconcileDb, table: string, slug: string, values: Record<string, unknown>): Promise<void> {
217
+ const existing = (await db.find({ from: table, where: { slug }, limit: 1 }))[0];
218
+ if (!existing) {
219
+ await db.insert(table, values);
220
+ return;
221
+ }
222
+ const patch: Record<string, unknown> = {};
223
+ for (const [k, v] of Object.entries(values)) {
224
+ if (k === "slug") continue;
225
+ if (!sameJson(existing[k], v)) patch[k] = v;
226
+ }
227
+ if (Object.keys(patch).length) await db.update(table, String(existing.id), patch);
228
+ }
229
+
230
+ /** Build a pramen `bootstrap` reconciler that upserts code-defined block + content types by
231
+ * `slug` on each boot. Idempotent: inserts a missing type, updates a drifted one, leaves an
232
+ * identical one untouched. Register it on your app:
233
+ *
234
+ * export const app = { schema, handlers, acl, tasks,
235
+ * bootstrap: [ cmsBootstrap({ blockTypes: [...], contentTypes: [...] }) ] };
236
+ *
237
+ * Runs with a privileged system Db, so a fresh/reprovisioned database converges to the
238
+ * code-declared types with no manual createContentType/createBlockType call. */
239
+ export function cmsBootstrap(defs: { blockTypes?: readonly BlockTypeDef[]; contentTypes?: readonly ContentTypeDef[] }): BootstrapFn {
240
+ return async ({ db }) => {
241
+ const sys = db as unknown as ReconcileDb;
242
+ for (const bt of defs.blockTypes ?? []) {
243
+ await upsertBySlug(sys, "cms_block_types", bt.slug, {
244
+ name: bt.name,
245
+ slug: bt.slug,
246
+ description: bt.description ?? null,
247
+ fieldsSchema: bt.fieldsSchema ?? [],
248
+ icon: bt.icon ?? null,
249
+ category: bt.category ?? null,
250
+ });
251
+ }
252
+ for (const ct of defs.contentTypes ?? []) {
253
+ await upsertBySlug(sys, "cms_content_types", ct.slug, {
254
+ name: ct.name,
255
+ slug: ct.slug,
256
+ description: ct.description ?? null,
257
+ fieldsSchema: ct.fields ?? [],
258
+ regions: ct.regions ?? [],
259
+ defaultBlocks: ct.defaultBlocks ?? [],
260
+ });
261
+ }
262
+ };
263
+ }
264
+
162
265
  // --- codegen: emit .ts field interfaces from DB-stored block schemas ------------------
163
266
  //
164
267
  // The data-driven half (webmaster-created block types) has no static type. This is the
@@ -502,6 +605,9 @@ export interface AssembledPage {
502
605
  slug: string;
503
606
  status: string;
504
607
  locale: string;
608
+ /** The page's content-type slug (e.g. "article", "page") — lets a frontend route/render
609
+ * by type. `null` if the type row is missing. */
610
+ contentType: string | null;
505
611
  translationGroupId: string | null;
506
612
  /** Published sibling locales of this page (for hreflang alternates). */
507
613
  translations: PageTranslation[];
@@ -628,6 +734,13 @@ const isEditor = (ctx: HandlerContext, roles: readonly string[]): boolean => {
628
734
 
629
735
  /** Assemble a page LIVE from its placements/blocks/types, grouped by region and ordered
630
736
  * by position, merging each shared placement's `overrides` over its block's fields. */
737
+ /** Resolve a page's content-type slug from its `typeId` (null when the type row is gone). */
738
+ async function contentTypeSlug(db: CmsDb, typeId: unknown): Promise<string | null> {
739
+ if (typeof typeId !== "string" || !typeId) return null;
740
+ const rows = await db.find({ from: "cms_content_types", where: { id: typeId }, limit: 1 });
741
+ return rows[0] ? String(rows[0].slug) : null;
742
+ }
743
+
631
744
  async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<AssembledPage> {
632
745
  const placements = await db.find({
633
746
  from: "cms_page_blocks",
@@ -676,8 +789,8 @@ async function assembleLive(db: CmsDb, page: Record<string, unknown>): Promise<A
676
789
  is_shared: Boolean(m.p.isShared),
677
790
  });
678
791
  }
679
- const [translations, ogImage] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage)]);
680
- return { page: pageMeta(page, translations, ogImage), regions };
792
+ const [translations, ogImage, contentType] = await Promise.all([siblingTranslations(db, page), resolveMediaId(db, page.ogImage), contentTypeSlug(db, page.typeId)]);
793
+ return { page: pageMeta(page, translations, ogImage, contentType), regions };
681
794
  }
682
795
 
683
796
  /** Resolve a single media id to a ResolvedMedia (for og:image etc.), or null. */
@@ -723,7 +836,7 @@ async function siblingTranslations(db: CmsDb, page: Record<string, unknown>): Pr
723
836
  }
724
837
 
725
838
  /** Project a page row to the public AssembledPage.page shape. */
726
- function pageMeta(page: Record<string, unknown>, translations: PageTranslation[] = [], ogImage: ResolvedMedia | null = null): AssembledPage["page"] {
839
+ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[] = [], ogImage: ResolvedMedia | null = null, contentType: string | null = null): AssembledPage["page"] {
727
840
  const metaTitle = (page.metaTitle as string | null) ?? null;
728
841
  const metaDescription = (page.metaDescription as string | null) ?? null;
729
842
  return {
@@ -732,6 +845,7 @@ function pageMeta(page: Record<string, unknown>, translations: PageTranslation[]
732
845
  slug: String(page.slug),
733
846
  status: String(page.status),
734
847
  locale: String(page.locale ?? "en"),
848
+ contentType,
735
849
  translationGroupId: (page.translationGroupId as string | null) ?? null,
736
850
  translations,
737
851
  fields: (page.fields as Record<string, unknown> | null) ?? null,
@@ -1027,8 +1141,14 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1027
1141
  /** Public: list published pages (slug, locale, updatedAt) for sitemap generation. The
1028
1142
  * anonymous ACL scopes cms_pages reads to status=published, so this is safe to expose. */
1029
1143
  listPublishedPages: query(async (ctx) => {
1030
- const rows = await cdb(ctx).find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1031
- return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
1144
+ const db = cdb(ctx);
1145
+ const rows = await db.find({ from: "cms_pages", where: { status: "published" }, orderBy: { column: "updatedAt", dir: "desc" }, limit: 5000 });
1146
+ // Join typeId → content-type slug so a frontend can route/filter by type (e.g. articles
1147
+ // vs pages) without a second round-trip.
1148
+ const typeIds = [...new Set(rows.map((r) => r.typeId).filter((v): v is string => typeof v === "string"))];
1149
+ const types = typeIds.length ? await db.find({ from: "cms_content_types", where: { id: { in: typeIds } } }) : [];
1150
+ const slugById = new Map(types.map((t) => [String(t.id), String(t.slug)]));
1151
+ return rows.map((r) => ({ slug: String(r.slug), locale: String(r.locale ?? "en"), contentType: slugById.get(String(r.typeId)) ?? null, updatedAt: String(r.updatedAt ?? r.createdAt ?? "") }));
1032
1152
  }),
1033
1153
 
1034
1154
  /** Update a page's SEO fields (meta/canonical/robots/OpenGraph/JSON-LD). Editor-gated. */
@@ -1578,6 +1698,10 @@ export function cmsPolicies(opts: CmsPolicyOpts = {}): { public: Policy[]; edito
1578
1698
  }
1579
1699
  return {
1580
1700
  public: [
1701
+ // Content-type metadata (slug/name) is public: the content API returns each published
1702
+ // page's content-type slug (via listPublishedPages' live typeId→slug join) so a frontend
1703
+ // can route/render by type. Slugs/names are structural, not sensitive.
1704
+ policy(`${p}:public:content-types:read`, "cms_content_types", "read", allow()),
1581
1705
  // Only published pages are readable; the snapshot carries the content.
1582
1706
  policy(`${p}:public:pages:read`, "cms_pages", "read", { where: { status: "published" } }),
1583
1707
  // getPage reads the latest revision snapshot. Scope the grant by the revision's