@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 +47 -0
- package/dist/index.js +100 -6
- package/package.json +3 -2
- package/src/index.ts +94 -5
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:
|
|
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
|
|
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:
|
|
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:
|
|
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 (
|
|
944
|
-
patch.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.
|
|
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.
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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 (
|
|
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
|
}, {
|