@pramen/cms 0.0.16 → 0.0.18

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
@@ -460,6 +460,9 @@ export interface ValidateOpts {
460
460
  requireRequired?: boolean;
461
461
  }
462
462
  export declare function validateFields(schema: FieldDefinition[] | undefined | null, values: unknown, path?: string, opts?: ValidateOpts): void;
463
+ /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
464
+ * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
465
+ export declare function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown>;
463
466
  export interface RenderedBlock {
464
467
  /** The placement id (cms_page_blocks) — stable per position; used for reorder/remove. */
465
468
  id: string;
@@ -557,6 +560,28 @@ export declare function createCmsHandlers(opts?: CmsHandlerOpts): {
557
560
  fieldsSchema?: FieldDefinition[];
558
561
  defaultBlocks?: DefaultBlockDefinition[];
559
562
  }, Record<string, unknown>>;
563
+ /** Update a block type (found by `id` or `slug`). `slug` is the stable key and is
564
+ * NOT mutable; only the metadata + `fieldsSchema` are patched. Editor-gated. This is
565
+ * what lets a schema evolve (e.g. a field text → date) without recreating the type. */
566
+ updateBlockType: import("@pramen/server").Handler<{
567
+ id?: string;
568
+ slug?: string;
569
+ name?: string;
570
+ fieldsSchema?: FieldDefinition[];
571
+ icon?: string | null;
572
+ category?: string | null;
573
+ description?: string | null;
574
+ }, Record<string, unknown> | undefined>;
575
+ /** Update a content type (found by `id` or `slug`). `slug` is the stable key and is
576
+ * NOT mutable; `name`/`regions`/`fieldsSchema`/`defaultBlocks` are patched. Editor-gated. */
577
+ updateContentType: import("@pramen/server").Handler<{
578
+ id?: string;
579
+ slug?: string;
580
+ name?: string;
581
+ regions?: RegionDefinition[];
582
+ fieldsSchema?: FieldDefinition[];
583
+ defaultBlocks?: DefaultBlockDefinition[];
584
+ }, Record<string, unknown> | undefined>;
560
585
  /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
561
586
  * prefix so the public /media route can serve it). The client PUTs the bytes to
562
587
  * `url`, then calls `createMedia` with the returned `ref`. */
@@ -788,6 +813,28 @@ export declare const cmsHandlers: {
788
813
  fieldsSchema?: FieldDefinition[];
789
814
  defaultBlocks?: DefaultBlockDefinition[];
790
815
  }, Record<string, unknown>>;
816
+ /** Update a block type (found by `id` or `slug`). `slug` is the stable key and is
817
+ * NOT mutable; only the metadata + `fieldsSchema` are patched. Editor-gated. This is
818
+ * what lets a schema evolve (e.g. a field text → date) without recreating the type. */
819
+ updateBlockType: import("@pramen/server").Handler<{
820
+ id?: string;
821
+ slug?: string;
822
+ name?: string;
823
+ fieldsSchema?: FieldDefinition[];
824
+ icon?: string | null;
825
+ category?: string | null;
826
+ description?: string | null;
827
+ }, Record<string, unknown> | undefined>;
828
+ /** Update a content type (found by `id` or `slug`). `slug` is the stable key and is
829
+ * NOT mutable; `name`/`regions`/`fieldsSchema`/`defaultBlocks` are patched. Editor-gated. */
830
+ updateContentType: import("@pramen/server").Handler<{
831
+ id?: string;
832
+ slug?: string;
833
+ name?: string;
834
+ regions?: RegionDefinition[];
835
+ fieldsSchema?: FieldDefinition[];
836
+ defaultBlocks?: DefaultBlockDefinition[];
837
+ }, Record<string, unknown> | undefined>;
791
838
  /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
792
839
  * prefix so the public /media route can serve it). The client PUTs the bytes to
793
840
  * `url`, then calls `createMedia` with the returned `ref`. */
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@
25
25
  // role("editor", [...cmsPolicies().editor]) ];
26
26
  // const app = { schema, handlers, acl, tasks: { ...cmsTasks } };
27
27
  import { Entity, query, mutation, primaryKey, generated, notNull, unique, indexed, defaultTo, expr, policy, allow, BadRequest, Forbidden, PramenError, } from "@pramen/server";
28
+ import { filterXSS } from "xss";
28
29
  /** Declare a typed block type. Pass `fields as const` to preserve the literals so
29
30
  * `BlockFieldsOf<typeof def>` infers the field shape:
30
31
  *
@@ -283,6 +284,43 @@ export function validateFields(schema, values, path = "", opts = {}) {
283
284
  }
284
285
  }
285
286
  }
287
+ // --- rich-text sanitization (server-side — the real XSS boundary) -------------
288
+ //
289
+ // richtext fields are HTML the site renders with set:html, so they MUST be sanitized
290
+ // before persistence. Client-side scrubbing is not a boundary — a caller can POST any
291
+ // value straight to these handlers. We sanitize on write against a strict tag/attribute
292
+ // allow-list with js-xss (`xss`), which is SYNCHRONOUS and pure-JS — this matters because
293
+ // sanitize runs inside the DO's storage.transaction(), where async stream I/O (e.g.
294
+ // HTMLRewriter) deadlocks. js-xss drops disallowed tags/attributes and blanks
295
+ // javascript:/data: URLs in href/src by default.
296
+ const RT_WHITELIST = {
297
+ p: [], br: [], hr: [], blockquote: [], pre: [], code: [],
298
+ strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [], mark: [], sub: [], sup: [],
299
+ h2: [], h3: [], h4: [], ul: [], ol: [], li: [], a: ["href", "title"],
300
+ };
301
+ const RT_XSS_OPTS = { whiteList: RT_WHITELIST, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style"] };
302
+ /** Sanitize one richtext HTML string to the allow-list. Synchronous by design. */
303
+ function sanitizeRichText(html) {
304
+ return html ? filterXSS(html, RT_XSS_OPTS) : html;
305
+ }
306
+ /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
307
+ * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
308
+ export function sanitizeFields(schema, values) {
309
+ const defs = Array.isArray(schema) ? schema : [];
310
+ const out = { ...values };
311
+ for (const def of defs) {
312
+ const v = out[def.name];
313
+ if (v == null)
314
+ continue;
315
+ if (def.type === "richtext" && typeof v === "string")
316
+ out[def.name] = sanitizeRichText(v);
317
+ else if (def.type === "group" && typeof v === "object" && !Array.isArray(v))
318
+ out[def.name] = sanitizeFields(def.fields, v);
319
+ else if (def.type === "repeater" && Array.isArray(v))
320
+ out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it) : it));
321
+ }
322
+ return out;
323
+ }
286
324
  /** The public serving path for a media blob (relative; the client resolves it against
287
325
  * its base). Served by the Worker's public `GET /media/<key>` route. */
288
326
  export function mediaPath(key) {
@@ -571,6 +609,55 @@ export function createCmsHandlers(opts = {}) {
571
609
  return o;
572
610
  },
573
611
  }),
612
+ /** Update a block type (found by `id` or `slug`). `slug` is the stable key and is
613
+ * NOT mutable; only the metadata + `fieldsSchema` are patched. Editor-gated. This is
614
+ * what lets a schema evolve (e.g. a field text → date) without recreating the type. */
615
+ updateBlockType: mutation(async (ctx, input) => {
616
+ const db = cdb(ctx);
617
+ const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
618
+ const row = rows[0];
619
+ if (!row)
620
+ throw notFound("block type");
621
+ const patch = {};
622
+ for (const k of ["name", "fieldsSchema", "icon", "category", "description"]) {
623
+ if (k in input)
624
+ patch[k] = input[k];
625
+ }
626
+ return db.update("cms_block_types", String(row.id), patch);
627
+ }, {
628
+ ...editor,
629
+ input: (raw) => {
630
+ const o = asObj(raw);
631
+ if (typeof o.id !== "string" && typeof o.slug !== "string")
632
+ throw new BadRequest("id or slug is required");
633
+ return o;
634
+ },
635
+ }),
636
+ /** Update a content type (found by `id` or `slug`). `slug` is the stable key and is
637
+ * NOT mutable; `name`/`regions`/`fieldsSchema`/`defaultBlocks` are patched. Editor-gated. */
638
+ updateContentType: mutation(async (ctx, input) => {
639
+ const db = cdb(ctx);
640
+ if ("regions" in input && (!Array.isArray(input.regions) || input.regions.length === 0))
641
+ throw new BadRequest("at least one region is required");
642
+ const rows = await db.find({ from: "cms_content_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
643
+ const row = rows[0];
644
+ if (!row)
645
+ throw notFound("content type");
646
+ const patch = {};
647
+ for (const k of ["name", "regions", "fieldsSchema", "defaultBlocks"]) {
648
+ if (k in input)
649
+ patch[k] = input[k];
650
+ }
651
+ return db.update("cms_content_types", String(row.id), patch);
652
+ }, {
653
+ ...editor,
654
+ input: (raw) => {
655
+ const o = asObj(raw);
656
+ if (typeof o.id !== "string" && typeof o.slug !== "string")
657
+ throw new BadRequest("id or slug is required");
658
+ return o;
659
+ },
660
+ }),
574
661
  // ---- media library ----
575
662
  /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
576
663
  * prefix so the public /media route can serve it). The client PUTs the bytes to
@@ -724,6 +811,7 @@ export function createCmsHandlers(opts = {}) {
724
811
  if (!ct)
725
812
  throw new BadRequest("unknown content type");
726
813
  validateFields(ct.fieldsSchema, input.fields ?? {}, "page.fields", { requireRequired: false });
814
+ const cleanPageFields = await sanitizeFields(ct.fieldsSchema, input.fields ?? {});
727
815
  const locale = input.locale ?? defaultLocale;
728
816
  await assertSlugFree(db, input.slug, locale);
729
817
  const page = await db.insert("cms_pages", {
@@ -731,7 +819,7 @@ export function createCmsHandlers(opts = {}) {
731
819
  title: input.title,
732
820
  slug: input.slug,
733
821
  locale,
734
- fields: input.fields ?? {},
822
+ fields: cleanPageFields,
735
823
  status: "draft",
736
824
  });
737
825
  const defaults = ct.defaultBlocks ?? [];
@@ -746,7 +834,8 @@ export function createCmsHandlers(opts = {}) {
746
834
  throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
747
835
  await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
748
836
  validateFields(bts[0].fieldsSchema, d.fields ?? {}, "", { requireRequired: false });
749
- const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: d.fields ?? {} });
837
+ const cleanDefault = await sanitizeFields(bts[0].fieldsSchema, d.fields ?? {});
838
+ const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
750
839
  const position = await nextPosition(db, String(page.id), d.region);
751
840
  await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
752
841
  }
@@ -849,10 +938,11 @@ export function createCmsHandlers(opts = {}) {
849
938
  const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
850
939
  await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
851
940
  validateFields(bt.fieldsSchema, input.fields ?? {}, "", { requireRequired: false });
941
+ const cleanFields = await sanitizeFields(bt.fieldsSchema, input.fields ?? {});
852
942
  const block = await db.insert("cms_blocks", {
853
943
  typeId: bt.id,
854
944
  title: input.title ?? null,
855
- fields: input.fields ?? {},
945
+ fields: cleanFields,
856
946
  isReusable: input.isReusable ?? false,
857
947
  });
858
948
  const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
@@ -893,8 +983,10 @@ export function createCmsHandlers(opts = {}) {
893
983
  const bts = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
894
984
  const slug = String(bts[0]?.slug ?? "");
895
985
  await assertRegionAllows(db, page, input.region, slug);
986
+ let cleanOverrides = input.overrides ?? null;
896
987
  if (input.overrides !== undefined) {
897
988
  validateFields(bts[0]?.fieldsSchema, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
989
+ cleanOverrides = await sanitizeFields(bts[0]?.fieldsSchema, input.overrides);
898
990
  }
899
991
  const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
900
992
  return db.insert("cms_page_blocks", {
@@ -903,7 +995,7 @@ export function createCmsHandlers(opts = {}) {
903
995
  region: input.region,
904
996
  position,
905
997
  isShared: true,
906
- overrides: input.overrides ?? null,
998
+ overrides: cleanOverrides,
907
999
  });
908
1000
  }, {
909
1001
  ...editor,
@@ -935,13 +1027,15 @@ export function createCmsHandlers(opts = {}) {
935
1027
  const block = rows[0];
936
1028
  if (!block)
937
1029
  throw notFound("block");
1030
+ let cleanFields = input.fields;
938
1031
  if (input.fields !== undefined) {
939
1032
  const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
940
1033
  validateFields(bt[0]?.fieldsSchema, input.fields, "", { requireRequired: false });
1034
+ cleanFields = await sanitizeFields(bt[0]?.fieldsSchema, input.fields);
941
1035
  }
942
1036
  const patch = { updatedAt: nowStamp() };
943
- if (input.fields !== undefined)
944
- patch.fields = input.fields;
1037
+ if (cleanFields !== undefined)
1038
+ patch.fields = cleanFields;
945
1039
  if (input.title !== undefined)
946
1040
  patch.title = input.title;
947
1041
  return db.update("cms_blocks", input.blockId, patch);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/cms",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
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,8 @@
41
41
  "access": "public"
42
42
  },
43
43
  "dependencies": {
44
- "@pramen/server": "0.0.16"
44
+ "@pramen/server": "0.0.18",
45
+ "xss": "^1.0.15"
45
46
  },
46
47
  "peerDependencies": {
47
48
  "react": ">=18"
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  PramenError,
44
44
  } from "@pramen/server";
45
45
  import type { HandlerContext, Policy, FileRef } from "@pramen/server";
46
+ import { filterXSS } from "xss";
46
47
 
47
48
  // --- field schema DSL (the block-editor field language) ---------------------
48
49
 
@@ -428,6 +429,43 @@ export function validateFields(schema: FieldDefinition[] | undefined | null, val
428
429
  }
429
430
  }
430
431
 
432
+ // --- rich-text sanitization (server-side — the real XSS boundary) -------------
433
+ //
434
+ // richtext fields are HTML the site renders with set:html, so they MUST be sanitized
435
+ // before persistence. Client-side scrubbing is not a boundary — a caller can POST any
436
+ // value straight to these handlers. We sanitize on write against a strict tag/attribute
437
+ // allow-list with js-xss (`xss`), which is SYNCHRONOUS and pure-JS — this matters because
438
+ // sanitize runs inside the DO's storage.transaction(), where async stream I/O (e.g.
439
+ // HTMLRewriter) deadlocks. js-xss drops disallowed tags/attributes and blanks
440
+ // javascript:/data: URLs in href/src by default.
441
+
442
+ const RT_WHITELIST: Record<string, string[]> = {
443
+ p: [], br: [], hr: [], blockquote: [], pre: [], code: [],
444
+ strong: [], b: [], em: [], i: [], u: [], s: [], strike: [], del: [], ins: [], mark: [], sub: [], sup: [],
445
+ h2: [], h3: [], h4: [], ul: [], ol: [], li: [], a: ["href", "title"],
446
+ };
447
+ const RT_XSS_OPTS = { whiteList: RT_WHITELIST, stripIgnoreTag: true, stripIgnoreTagBody: ["script", "style"] as string[] };
448
+
449
+ /** Sanitize one richtext HTML string to the allow-list. Synchronous by design. */
450
+ function sanitizeRichText(html: string): string {
451
+ return html ? filterXSS(html, RT_XSS_OPTS) : html;
452
+ }
453
+
454
+ /** Deep-sanitize the richtext fields in a values object against a field schema (recursing
455
+ * into group/repeater). Returns a sanitized copy; non-richtext fields pass through. */
456
+ export function sanitizeFields(schema: FieldDefinition[] | undefined | null, values: Record<string, unknown>): Record<string, unknown> {
457
+ const defs = Array.isArray(schema) ? schema : [];
458
+ const out: Record<string, unknown> = { ...values };
459
+ for (const def of defs) {
460
+ const v = out[def.name];
461
+ if (v == null) continue;
462
+ if (def.type === "richtext" && typeof v === "string") out[def.name] = sanitizeRichText(v);
463
+ else if (def.type === "group" && typeof v === "object" && !Array.isArray(v)) out[def.name] = sanitizeFields(def.fields, v as Record<string, unknown>);
464
+ else if (def.type === "repeater" && Array.isArray(v)) out[def.name] = v.map((it) => (it && typeof it === "object" ? sanitizeFields(def.fields, it as Record<string, unknown>) : it));
465
+ }
466
+ return out;
467
+ }
468
+
431
469
  // --- assembled-page shape (the content-API result + revision snapshot) --------
432
470
 
433
471
  export interface RenderedBlock {
@@ -827,6 +865,50 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
827
865
  },
828
866
  }),
829
867
 
868
+ /** Update a block type (found by `id` or `slug`). `slug` is the stable key and is
869
+ * NOT mutable; only the metadata + `fieldsSchema` are patched. Editor-gated. This is
870
+ * what lets a schema evolve (e.g. a field text → date) without recreating the type. */
871
+ updateBlockType: mutation(async (ctx, input: { id?: string; slug?: string; name?: string; fieldsSchema?: FieldDefinition[]; icon?: string | null; category?: string | null; description?: string | null }) => {
872
+ const db = cdb(ctx);
873
+ const rows = await db.find({ from: "cms_block_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
874
+ const row = rows[0];
875
+ if (!row) throw notFound("block type");
876
+ const patch: Record<string, unknown> = {};
877
+ for (const k of ["name", "fieldsSchema", "icon", "category", "description"] as const) {
878
+ if (k in input) patch[k] = (input as Record<string, unknown>)[k];
879
+ }
880
+ return db.update("cms_block_types", String(row.id), patch);
881
+ }, {
882
+ ...editor,
883
+ input: (raw): { id?: string; slug?: string; name?: string; fieldsSchema?: FieldDefinition[]; icon?: string | null; category?: string | null; description?: string | null } => {
884
+ const o = asObj(raw);
885
+ if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
886
+ return o as never;
887
+ },
888
+ }),
889
+
890
+ /** Update a content type (found by `id` or `slug`). `slug` is the stable key and is
891
+ * NOT mutable; `name`/`regions`/`fieldsSchema`/`defaultBlocks` are patched. Editor-gated. */
892
+ updateContentType: mutation(async (ctx, input: { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] }) => {
893
+ const db = cdb(ctx);
894
+ if ("regions" in input && (!Array.isArray(input.regions) || input.regions.length === 0)) throw new BadRequest("at least one region is required");
895
+ const rows = await db.find({ from: "cms_content_types", where: input.id ? { id: input.id } : { slug: input.slug }, limit: 1 });
896
+ const row = rows[0];
897
+ if (!row) throw notFound("content type");
898
+ const patch: Record<string, unknown> = {};
899
+ for (const k of ["name", "regions", "fieldsSchema", "defaultBlocks"] as const) {
900
+ if (k in input) patch[k] = (input as Record<string, unknown>)[k];
901
+ }
902
+ return db.update("cms_content_types", String(row.id), patch);
903
+ }, {
904
+ ...editor,
905
+ input: (raw): { id?: string; slug?: string; name?: string; regions?: RegionDefinition[]; fieldsSchema?: FieldDefinition[]; defaultBlocks?: DefaultBlockDefinition[] } => {
906
+ const o = asObj(raw);
907
+ if (typeof o.id !== "string" && typeof o.slug !== "string") throw new BadRequest("id or slug is required");
908
+ return o as never;
909
+ },
910
+ }),
911
+
830
912
  // ---- media library ----
831
913
  /** Mint a signed upload URL for a media blob (keyed under the tenant's `media/`
832
914
  * prefix so the public /media route can serve it). The client PUTs the bytes to
@@ -976,6 +1058,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
976
1058
  const ct = ctRows[0];
977
1059
  if (!ct) throw new BadRequest("unknown content type");
978
1060
  validateFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "page.fields", { requireRequired: false });
1061
+ const cleanPageFields = await sanitizeFields(ct.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
979
1062
  const locale = input.locale ?? defaultLocale;
980
1063
  await assertSlugFree(db, input.slug, locale);
981
1064
 
@@ -984,7 +1067,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
984
1067
  title: input.title,
985
1068
  slug: input.slug,
986
1069
  locale,
987
- fields: input.fields ?? {},
1070
+ fields: cleanPageFields,
988
1071
  status: "draft",
989
1072
  });
990
1073
 
@@ -999,7 +1082,8 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
999
1082
  if (!bts[0]) throw new BadRequest(`unknown block type '${d.blockTypeSlug}'`);
1000
1083
  await assertRegionAllows(db, page, d.region, d.blockTypeSlug);
1001
1084
  validateFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {}, "", { requireRequired: false });
1002
- const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: d.fields ?? {} });
1085
+ const cleanDefault = await sanitizeFields(bts[0].fieldsSchema as FieldDefinition[] | undefined, d.fields ?? {});
1086
+ const block = await db.insert("cms_blocks", { typeId: bts[0].id, fields: cleanDefault });
1003
1087
  const position = await nextPosition(db, String(page.id), d.region);
1004
1088
  await db.insert("cms_page_blocks", { pageId: page.id, blockId: block.id, region: d.region, position });
1005
1089
  } catch (e) {
@@ -1096,11 +1180,12 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1096
1180
  const bt = await loadBlockTypeBySlug(db, input.blockTypeSlug);
1097
1181
  await assertRegionAllows(db, page, input.region, input.blockTypeSlug);
1098
1182
  validateFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {}, "", { requireRequired: false });
1183
+ const cleanFields = await sanitizeFields(bt.fieldsSchema as FieldDefinition[] | undefined, input.fields ?? {});
1099
1184
 
1100
1185
  const block = await db.insert("cms_blocks", {
1101
1186
  typeId: bt.id,
1102
1187
  title: input.title ?? null,
1103
- fields: input.fields ?? {},
1188
+ fields: cleanFields,
1104
1189
  isReusable: input.isReusable ?? false,
1105
1190
  });
1106
1191
  const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
@@ -1140,8 +1225,10 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1140
1225
  const bts = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
1141
1226
  const slug = String(bts[0]?.slug ?? "");
1142
1227
  await assertRegionAllows(db, page, input.region, slug);
1228
+ let cleanOverrides: Record<string, unknown> | null = input.overrides ?? null;
1143
1229
  if (input.overrides !== undefined) {
1144
1230
  validateFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, { ...asObj(block.fields), ...input.overrides }, "", { requireRequired: false });
1231
+ cleanOverrides = await sanitizeFields(bts[0]?.fieldsSchema as FieldDefinition[] | undefined, input.overrides);
1145
1232
  }
1146
1233
  const position = input.position ?? (await nextPosition(db, input.pageId, input.region));
1147
1234
  return db.insert("cms_page_blocks", {
@@ -1150,7 +1237,7 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1150
1237
  region: input.region,
1151
1238
  position,
1152
1239
  isShared: true,
1153
- overrides: input.overrides ?? null,
1240
+ overrides: cleanOverrides,
1154
1241
  });
1155
1242
  }, {
1156
1243
  ...editor,
@@ -1182,12 +1269,14 @@ export function createCmsHandlers(opts: CmsHandlerOpts = {}) {
1182
1269
  const rows = await db.find({ from: "cms_blocks", where: { id: input.blockId }, limit: 1 });
1183
1270
  const block = rows[0];
1184
1271
  if (!block) throw notFound("block");
1272
+ let cleanFields = input.fields;
1185
1273
  if (input.fields !== undefined) {
1186
1274
  const bt = await db.find({ from: "cms_block_types", where: { id: block.typeId }, limit: 1 });
1187
1275
  validateFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields, "", { requireRequired: false });
1276
+ cleanFields = await sanitizeFields(bt[0]?.fieldsSchema as FieldDefinition[] | undefined, input.fields);
1188
1277
  }
1189
1278
  const patch: Record<string, unknown> = { updatedAt: nowStamp() };
1190
- if (input.fields !== undefined) patch.fields = input.fields;
1279
+ if (cleanFields !== undefined) patch.fields = cleanFields;
1191
1280
  if (input.title !== undefined) patch.title = input.title;
1192
1281
  return db.update("cms_blocks", input.blockId, patch);
1193
1282
  }, {