@beechcms/core 0.6.0-preview.4 → 0.6.1
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/automations/automations-grammar-words.d.ts.map +1 -0
- package/dist/automations/automations.repository.interface.d.ts.map +1 -0
- package/dist/automations/automations.runner.interface.d.ts.map +1 -0
- package/dist/automations/automations.types.d.ts.map +1 -0
- package/dist/common/clock.d.ts.map +1 -0
- package/dist/common/email.provider.d.ts +32 -0
- package/dist/common/email.provider.d.ts.map +1 -0
- package/dist/common/email.provider.js +4 -0
- package/dist/{hooks.d.ts → common/hooks.d.ts} +2 -2
- package/dist/common/hooks.d.ts.map +1 -0
- package/dist/common/id-generator.d.ts.map +1 -0
- package/dist/common/storage.d.ts.map +1 -0
- package/dist/{content-scan.repository.d.ts → content/content-scan.repository.d.ts} +1 -1
- package/dist/content/content-scan.repository.d.ts.map +1 -0
- package/dist/{content.repository.d.ts → content/content.repository.d.ts} +13 -4
- package/dist/content/content.repository.d.ts.map +1 -0
- package/dist/content/demo-data.repository.d.ts.map +1 -0
- package/dist/content/idempotency.repository.d.ts.map +1 -0
- package/dist/content/richtext/richtext-render.d.ts +12 -0
- package/dist/content/richtext/richtext-render.d.ts.map +1 -0
- package/dist/{richtext-render.js → content/richtext/richtext-render.js} +7 -14
- package/dist/content/richtext/richtext.d.ts.map +1 -0
- package/dist/{seed.repository.d.ts → content/seed.repository.d.ts} +1 -1
- package/dist/content/seed.repository.d.ts.map +1 -0
- package/dist/content/slug-utils.d.ts.map +1 -0
- package/dist/{dashboard-layout.d.ts → dashboard-layout/dashboard-layout.d.ts} +1 -1
- package/dist/dashboard-layout/dashboard-layout.d.ts.map +1 -0
- package/dist/dashboard-layout/dashboard-layout.repository.d.ts.map +1 -0
- package/dist/dashboard-layout/dashboard-permissions.d.ts.map +1 -0
- package/dist/dashboard-layout/dashboard-scopes.d.ts.map +1 -0
- package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts +19 -0
- package/dist/dashboard-layout/kanban/kanban-position.repository.d.ts.map +1 -0
- package/dist/dashboard-layout/kanban/kanban.d.ts +75 -0
- package/dist/dashboard-layout/kanban/kanban.d.ts.map +1 -0
- package/dist/dashboard-layout/kanban/kanban.js +82 -0
- package/dist/dashboard-layout/layout-permissions.d.ts.map +1 -0
- package/dist/{seed-layout.d.ts → dashboard-layout/seed-layout.d.ts} +77 -3
- package/dist/dashboard-layout/seed-layout.d.ts.map +1 -0
- package/dist/{seed-layout.js → dashboard-layout/seed-layout.js} +101 -8
- package/dist/{seed-layout.repository.d.ts → dashboard-layout/seed-layout.repository.d.ts} +5 -1
- package/dist/dashboard-layout/seed-layout.repository.d.ts.map +1 -0
- package/dist/dashboard-layout/view-authorization.d.ts +18 -0
- package/dist/dashboard-layout/view-authorization.d.ts.map +1 -0
- package/dist/dashboard-layout/view-authorization.js +28 -0
- package/dist/engine/ddl.d.ts +173 -0
- package/dist/engine/ddl.d.ts.map +1 -0
- package/dist/engine/ddl.js +454 -0
- package/dist/engine/define-seed.d.ts.map +1 -0
- package/dist/engine/engine.d.ts +4 -0
- package/dist/engine/engine.d.ts.map +1 -0
- package/dist/engine/engine.js +4 -0
- package/dist/engine/policies.d.ts.map +1 -0
- package/dist/engine/query.d.ts +23 -0
- package/dist/engine/query.d.ts.map +1 -0
- package/dist/engine/query.js +217 -0
- package/dist/engine/relations.d.ts.map +1 -0
- package/dist/{schema-mutator.d.ts → engine/schema-mutator.d.ts} +4 -0
- package/dist/engine/schema-mutator.d.ts.map +1 -0
- package/dist/engine/seed-ddl-destructive.d.ts.map +1 -0
- package/dist/engine/seed-ddl.d.ts.map +1 -0
- package/dist/engine/seed-registry.d.ts.map +1 -0
- package/dist/{seed-registry.js → engine/seed-registry.js} +1 -1
- package/dist/engine/seed-types-generator.d.ts +10 -0
- package/dist/engine/seed-types-generator.d.ts.map +1 -0
- package/dist/engine/seed-types-generator.js +86 -0
- package/dist/engine/seed-validation.d.ts.map +1 -0
- package/dist/{seed-validation.js → engine/seed-validation.js} +14 -1
- package/dist/engine/seeds.d.ts.map +1 -0
- package/dist/engine/serialize.d.ts +20 -0
- package/dist/engine/serialize.d.ts.map +1 -0
- package/dist/engine/serialize.js +160 -0
- package/dist/engine/types.d.ts +278 -0
- package/dist/engine/types.d.ts.map +1 -0
- package/dist/engine/validation/cache.d.ts +12 -0
- package/dist/engine/validation/cache.d.ts.map +1 -0
- package/dist/engine/validation/cache.js +136 -0
- package/dist/engine/validation/file-branch.d.ts +48 -0
- package/dist/engine/validation/file-branch.d.ts.map +1 -0
- package/dist/engine/validation/file-branch.js +133 -0
- package/dist/engine/validation/index.d.ts +115 -0
- package/dist/engine/validation/index.d.ts.map +1 -0
- package/dist/engine/validation/index.js +336 -0
- package/dist/engine/validation/primitives.d.ts +34 -0
- package/dist/engine/validation/primitives.d.ts.map +1 -0
- package/dist/engine/validation/primitives.js +47 -0
- package/dist/engine/validation/richtext-sanitizer.d.ts +30 -0
- package/dist/engine/validation/richtext-sanitizer.d.ts.map +1 -0
- package/dist/engine/validation/richtext-sanitizer.js +203 -0
- package/dist/engine/validation/schema-builders.d.ts +12 -0
- package/dist/engine/validation/schema-builders.d.ts.map +1 -0
- package/dist/engine/validation/schema-builders.js +413 -0
- package/dist/index.d.ts +48 -44
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -44
- package/dist/media/file-types.d.ts +13 -0
- package/dist/media/file-types.d.ts.map +1 -0
- package/dist/media/media.repository.d.ts.map +1 -0
- package/dist/queue/queue.interface.d.ts +39 -0
- package/dist/queue/queue.interface.d.ts.map +1 -0
- package/dist/queue/queue.stub.d.ts +6 -0
- package/dist/queue/queue.stub.d.ts.map +1 -0
- package/dist/queue/queue.stub.js +8 -0
- package/dist/queue/scheduler.interface.d.ts.map +1 -0
- package/dist/queue/scheduler.interface.js +3 -0
- package/dist/queue/scheduler.stub.d.ts.map +1 -0
- package/dist/queue.interface.d.ts +4 -4
- package/dist/queue.interface.d.ts.map +1 -1
- package/dist/queue.stub.d.ts.map +1 -1
- package/dist/rate-limit/rate-limiter.d.ts +5 -0
- package/dist/rate-limit/rate-limiter.d.ts.map +1 -1
- package/dist/search/search.repository.d.ts +1 -1
- package/dist/search/search.repository.d.ts.map +1 -1
- package/dist/seed-types-generator.d.ts +1 -1
- package/dist/seed-types-generator.d.ts.map +1 -1
- package/dist/settings/site-settings.repository.d.ts.map +1 -0
- package/dist/settings/site-settings.repository.js +3 -0
- package/dist/webhooks/webhook-crypto.d.ts +8 -0
- package/dist/webhooks/webhook-crypto.d.ts.map +1 -0
- package/dist/webhooks/webhook-crypto.js +37 -0
- package/dist/webhooks/webhook-validation.d.ts.map +1 -0
- package/dist/widget/widget.repository.d.ts +1 -1
- package/dist/widget/widget.repository.d.ts.map +1 -1
- package/package.json +4 -3
- package/dist/automations-grammar-words.d.ts.map +0 -1
- package/dist/automations.repository.interface.d.ts.map +0 -1
- package/dist/automations.runner.interface.d.ts.map +0 -1
- package/dist/automations.runner.stub.d.ts +0 -5
- package/dist/automations.runner.stub.d.ts.map +0 -1
- package/dist/automations.runner.stub.js +0 -7
- package/dist/automations.types.d.ts.map +0 -1
- package/dist/clock.d.ts.map +0 -1
- package/dist/content-scan.repository.d.ts.map +0 -1
- package/dist/content.repository.d.ts.map +0 -1
- package/dist/dashboard-layout.d.ts.map +0 -1
- package/dist/dashboard-layout.repository.d.ts.map +0 -1
- package/dist/dashboard-permissions.d.ts.map +0 -1
- package/dist/dashboard-scopes.d.ts.map +0 -1
- package/dist/define-seed.d.ts.map +0 -1
- package/dist/demo-data.repository.d.ts.map +0 -1
- package/dist/engine.d.ts +0 -144
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -723
- package/dist/file-types.d.ts +0 -13
- package/dist/file-types.d.ts.map +0 -1
- package/dist/hooks.d.ts.map +0 -1
- package/dist/id-generator.d.ts.map +0 -1
- package/dist/idempotency.repository.d.ts.map +0 -1
- package/dist/layout-permissions.d.ts.map +0 -1
- package/dist/media.repository.d.ts.map +0 -1
- package/dist/policies.d.ts.map +0 -1
- package/dist/relations.d.ts.map +0 -1
- package/dist/richtext-render.d.ts +0 -11
- package/dist/richtext-render.d.ts.map +0 -1
- package/dist/richtext.d.ts.map +0 -1
- package/dist/scheduler.interface.d.ts.map +0 -1
- package/dist/scheduler.stub.d.ts.map +0 -1
- package/dist/schema-mutator.d.ts.map +0 -1
- package/dist/seed-ddl-destructive.d.ts.map +0 -1
- package/dist/seed-ddl.d.ts.map +0 -1
- package/dist/seed-layout.d.ts.map +0 -1
- package/dist/seed-layout.repository.d.ts.map +0 -1
- package/dist/seed-registry.d.ts.map +0 -1
- package/dist/seed-validation.d.ts.map +0 -1
- package/dist/seed.repository.d.ts.map +0 -1
- package/dist/seeds.d.ts.map +0 -1
- package/dist/site-settings.repository.d.ts.map +0 -1
- package/dist/slug-utils.d.ts.map +0 -1
- package/dist/storage.d.ts.map +0 -1
- package/dist/types.d.ts +0 -237
- package/dist/types.d.ts.map +0 -1
- package/dist/validation.d.ts +0 -50
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -659
- package/dist/webhook-validation.d.ts.map +0 -1
- /package/dist/{automations-grammar-words.d.ts → automations/automations-grammar-words.d.ts} +0 -0
- /package/dist/{automations-grammar-words.js → automations/automations-grammar-words.js} +0 -0
- /package/dist/{automations.repository.interface.d.ts → automations/automations.repository.interface.d.ts} +0 -0
- /package/dist/{automations.repository.interface.js → automations/automations.repository.interface.js} +0 -0
- /package/dist/{automations.runner.interface.d.ts → automations/automations.runner.interface.d.ts} +0 -0
- /package/dist/{automations.runner.interface.js → automations/automations.runner.interface.js} +0 -0
- /package/dist/{automations.types.d.ts → automations/automations.types.d.ts} +0 -0
- /package/dist/{automations.types.js → automations/automations.types.js} +0 -0
- /package/dist/{clock.d.ts → common/clock.d.ts} +0 -0
- /package/dist/{clock.js → common/clock.js} +0 -0
- /package/dist/{hooks.js → common/hooks.js} +0 -0
- /package/dist/{id-generator.d.ts → common/id-generator.d.ts} +0 -0
- /package/dist/{id-generator.js → common/id-generator.js} +0 -0
- /package/dist/{storage.d.ts → common/storage.d.ts} +0 -0
- /package/dist/{storage.js → common/storage.js} +0 -0
- /package/dist/{content-scan.repository.js → content/content-scan.repository.js} +0 -0
- /package/dist/{content.repository.js → content/content.repository.js} +0 -0
- /package/dist/{demo-data.repository.d.ts → content/demo-data.repository.d.ts} +0 -0
- /package/dist/{demo-data.repository.js → content/demo-data.repository.js} +0 -0
- /package/dist/{idempotency.repository.d.ts → content/idempotency.repository.d.ts} +0 -0
- /package/dist/{idempotency.repository.js → content/idempotency.repository.js} +0 -0
- /package/dist/{richtext.d.ts → content/richtext/richtext.d.ts} +0 -0
- /package/dist/{richtext.js → content/richtext/richtext.js} +0 -0
- /package/dist/{seed.repository.js → content/seed.repository.js} +0 -0
- /package/dist/{slug-utils.d.ts → content/slug-utils.d.ts} +0 -0
- /package/dist/{slug-utils.js → content/slug-utils.js} +0 -0
- /package/dist/{dashboard-layout.js → dashboard-layout/dashboard-layout.js} +0 -0
- /package/dist/{dashboard-layout.repository.d.ts → dashboard-layout/dashboard-layout.repository.d.ts} +0 -0
- /package/dist/{dashboard-layout.repository.js → dashboard-layout/dashboard-layout.repository.js} +0 -0
- /package/dist/{dashboard-permissions.d.ts → dashboard-layout/dashboard-permissions.d.ts} +0 -0
- /package/dist/{dashboard-permissions.js → dashboard-layout/dashboard-permissions.js} +0 -0
- /package/dist/{dashboard-scopes.d.ts → dashboard-layout/dashboard-scopes.d.ts} +0 -0
- /package/dist/{dashboard-scopes.js → dashboard-layout/dashboard-scopes.js} +0 -0
- /package/dist/{media.repository.js → dashboard-layout/kanban/kanban-position.repository.js} +0 -0
- /package/dist/{layout-permissions.d.ts → dashboard-layout/layout-permissions.d.ts} +0 -0
- /package/dist/{layout-permissions.js → dashboard-layout/layout-permissions.js} +0 -0
- /package/dist/{seed-layout.repository.js → dashboard-layout/seed-layout.repository.js} +0 -0
- /package/dist/{define-seed.d.ts → engine/define-seed.d.ts} +0 -0
- /package/dist/{define-seed.js → engine/define-seed.js} +0 -0
- /package/dist/{policies.d.ts → engine/policies.d.ts} +0 -0
- /package/dist/{policies.js → engine/policies.js} +0 -0
- /package/dist/{relations.d.ts → engine/relations.d.ts} +0 -0
- /package/dist/{relations.js → engine/relations.js} +0 -0
- /package/dist/{schema-mutator.js → engine/schema-mutator.js} +0 -0
- /package/dist/{seed-ddl-destructive.d.ts → engine/seed-ddl-destructive.d.ts} +0 -0
- /package/dist/{seed-ddl-destructive.js → engine/seed-ddl-destructive.js} +0 -0
- /package/dist/{seed-ddl.d.ts → engine/seed-ddl.d.ts} +0 -0
- /package/dist/{seed-ddl.js → engine/seed-ddl.js} +0 -0
- /package/dist/{seed-registry.d.ts → engine/seed-registry.d.ts} +0 -0
- /package/dist/{seed-validation.d.ts → engine/seed-validation.d.ts} +0 -0
- /package/dist/{seeds.d.ts → engine/seeds.d.ts} +0 -0
- /package/dist/{seeds.js → engine/seeds.js} +0 -0
- /package/dist/{types.js → engine/types.js} +0 -0
- /package/dist/{file-types.js → media/file-types.js} +0 -0
- /package/dist/{media.repository.d.ts → media/media.repository.d.ts} +0 -0
- /package/dist/{scheduler.interface.js → media/media.repository.js} +0 -0
- /package/dist/{site-settings.repository.js → queue/queue.interface.js} +0 -0
- /package/dist/{scheduler.interface.d.ts → queue/scheduler.interface.d.ts} +0 -0
- /package/dist/{scheduler.stub.d.ts → queue/scheduler.stub.d.ts} +0 -0
- /package/dist/{scheduler.stub.js → queue/scheduler.stub.js} +0 -0
- /package/dist/{site-settings.repository.d.ts → settings/site-settings.repository.d.ts} +0 -0
- /package/dist/{webhook-validation.d.ts → webhooks/webhook-validation.d.ts} +0 -0
- /package/dist/{webhook-validation.js → webhooks/webhook-validation.js} +0 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { RICHTEXT_SCHEMA_VERSION, isRichtextEnvelopeV1 } from '../../content/richtext/richtext.js';
|
|
4
|
+
import { stripControlChars, cleanString, isPlainObject, byteLength } from './primitives.js';
|
|
5
|
+
/** Allowlisted TipTap node `type` values. Keep in sync with
|
|
6
|
+
* richtext-render.ts::createRichTextHtmlExtensions. */
|
|
7
|
+
const ALLOWED_RICHTEXT_NODE_TYPES = new Set([
|
|
8
|
+
'doc', 'paragraph', 'text', 'heading', 'blockquote', 'bulletList', 'orderedList',
|
|
9
|
+
'listItem', 'codeBlock', 'horizontalRule', 'hardBreak', 'image',
|
|
10
|
+
'table', 'tableRow', 'tableHeader', 'tableCell',
|
|
11
|
+
'inlineMath', 'blockMath', // Mathematics extension
|
|
12
|
+
]);
|
|
13
|
+
/** Allowlisted TipTap mark `type` values. */
|
|
14
|
+
const ALLOWED_RICHTEXT_MARK_TYPES = new Set([
|
|
15
|
+
'bold', 'italic', 'strike', 'code', 'link', 'highlight',
|
|
16
|
+
'superscript', 'subscript', 'textStyle',
|
|
17
|
+
]);
|
|
18
|
+
/** Keys that may carry URLs inside a node/mark attrs. */
|
|
19
|
+
const URL_LIKE_RICHTEXT_KEYS = new Set(['href', 'src']);
|
|
20
|
+
/** Link protocols accepted AFTER normalization. Allowlist, not blocklist. */
|
|
21
|
+
const ALLOWED_URL_PROTOCOLS = new Set(['http', 'https', 'mailto', 'tel']);
|
|
22
|
+
/** DoS guards, evaluated before the sanitizing walk. */
|
|
23
|
+
const RICHTEXT_MAX_DEPTH = 50;
|
|
24
|
+
/** RichText string input is no longer accepted (JSON-only). Reject as invalid. */
|
|
25
|
+
function sanitizeRichtextString(raw) {
|
|
26
|
+
return { value: raw, dangerous: false, valid: false, size: byteLength(raw) };
|
|
27
|
+
}
|
|
28
|
+
/** Normalizes a URL value and confirms its protocol is allowlisted.
|
|
29
|
+
* Strips ALL whitespace + control chars first, defeating java\tscript: obfuscation. */
|
|
30
|
+
function isProtocolAllowed(raw) {
|
|
31
|
+
const normalized = stripControlChars(raw).replace(/\s+/g, '').toLowerCase();
|
|
32
|
+
const schemeMatch = /^([a-z][a-z0-9+.-]*):/.exec(normalized);
|
|
33
|
+
if (!schemeMatch)
|
|
34
|
+
return true; // relative URL / anchor / fragment — no protocol
|
|
35
|
+
return ALLOWED_URL_PROTOCOLS.has(schemeMatch[1]);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Recursively walks a rich text node structure, removing (not just flagging) XSS threats
|
|
39
|
+
* and control characters. Returns `undefined` for nodes/attrs that must be dropped entirely.
|
|
40
|
+
*
|
|
41
|
+
* @param node - The node to walk.
|
|
42
|
+
* @param state - The shared sanitization state tracking danger flags.
|
|
43
|
+
* @returns The cleaned rich text node, or `undefined` if it must be removed.
|
|
44
|
+
*/
|
|
45
|
+
function walkRichtextNode(node, state) {
|
|
46
|
+
if (state.depth > RICHTEXT_MAX_DEPTH) {
|
|
47
|
+
state.dangerous = true;
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (typeof node === 'string')
|
|
51
|
+
return stripControlChars(node);
|
|
52
|
+
if (Array.isArray(node)) {
|
|
53
|
+
state.depth++;
|
|
54
|
+
const mapped = [];
|
|
55
|
+
for (const child of node) {
|
|
56
|
+
const walked = walkRichtextNode(child, state);
|
|
57
|
+
if (walked !== undefined)
|
|
58
|
+
mapped.push(walked);
|
|
59
|
+
}
|
|
60
|
+
state.depth--;
|
|
61
|
+
return mapped;
|
|
62
|
+
}
|
|
63
|
+
if (!isPlainObject(node))
|
|
64
|
+
return node;
|
|
65
|
+
// Node/mark type allowlist: any present `type` that isn't an allowlisted string is dropped.
|
|
66
|
+
// Objects with no `type` key (e.g. attrs bags) are not nodes and skip this check.
|
|
67
|
+
if ('type' in node) {
|
|
68
|
+
const rawType = node.type;
|
|
69
|
+
const isAllowed = typeof rawType === 'string' &&
|
|
70
|
+
(ALLOWED_RICHTEXT_NODE_TYPES.has(rawType) || ALLOWED_RICHTEXT_MARK_TYPES.has(rawType));
|
|
71
|
+
if (!isAllowed) {
|
|
72
|
+
state.dangerous = true;
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const result = Object.create(null);
|
|
77
|
+
state.depth++;
|
|
78
|
+
for (const [key, entry] of Object.entries(node)) {
|
|
79
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const lower = key.toLowerCase();
|
|
83
|
+
if (lower.startsWith('on')) {
|
|
84
|
+
state.dangerous = true;
|
|
85
|
+
continue; // drop event-handler attr
|
|
86
|
+
}
|
|
87
|
+
if (URL_LIKE_RICHTEXT_KEYS.has(lower) &&
|
|
88
|
+
typeof entry === 'string' &&
|
|
89
|
+
!isProtocolAllowed(entry)) {
|
|
90
|
+
state.dangerous = true;
|
|
91
|
+
continue; // drop disallowed URL
|
|
92
|
+
}
|
|
93
|
+
const walked = walkRichtextNode(entry, state);
|
|
94
|
+
if (walked !== undefined)
|
|
95
|
+
result[key] = walked;
|
|
96
|
+
}
|
|
97
|
+
state.depth--;
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Sanitizes a rich text JSON document.
|
|
102
|
+
*
|
|
103
|
+
* @param raw - The raw rich text JSON object.
|
|
104
|
+
* @returns The sanitization result.
|
|
105
|
+
*/
|
|
106
|
+
function sanitizeRichtextJson(raw) {
|
|
107
|
+
const state = { dangerous: false, depth: 0 };
|
|
108
|
+
const cleaned = walkRichtextNode(raw, state);
|
|
109
|
+
const asObject = isPlainObject(cleaned) ? cleaned : {};
|
|
110
|
+
const valid = asObject.type === 'doc';
|
|
111
|
+
let serialized;
|
|
112
|
+
try {
|
|
113
|
+
serialized = JSON.stringify(asObject);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { value: asObject, dangerous: state.dangerous, valid: false, size: 0 };
|
|
117
|
+
}
|
|
118
|
+
return { value: asObject, dangerous: state.dangerous, valid, size: byteLength(serialized) };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Main entrance helper to sanitize rich text, handling both v1 envelope formats and raw JSON payloads.
|
|
122
|
+
* String-form input is rejected (JSON-only). Byte size is fail-fast checked before the sanitizing walk.
|
|
123
|
+
*
|
|
124
|
+
* @param raw - The raw rich text input.
|
|
125
|
+
* @param maxBytes - Maximum allowed serialized size, checked before the walk.
|
|
126
|
+
* @returns The sanitization result.
|
|
127
|
+
*/
|
|
128
|
+
export function sanitizeRichtext(raw, maxBytes) {
|
|
129
|
+
const envelopeMode = isRichtextEnvelopeV1(raw);
|
|
130
|
+
const payload = envelopeMode ? raw.doc : raw;
|
|
131
|
+
if (typeof payload === 'string') {
|
|
132
|
+
return sanitizeRichtextString(payload);
|
|
133
|
+
}
|
|
134
|
+
if (!isPlainObject(payload)) {
|
|
135
|
+
return { value: raw, dangerous: false, valid: false, size: 0 };
|
|
136
|
+
}
|
|
137
|
+
// Fail-fast DoS pre-check: size BEFORE the sanitizing walk.
|
|
138
|
+
const rawSize = byteLength(JSON.stringify(payload));
|
|
139
|
+
if (rawSize > maxBytes) {
|
|
140
|
+
return { value: raw, dangerous: false, valid: false, size: rawSize, oversize: true };
|
|
141
|
+
}
|
|
142
|
+
const jsonResult = sanitizeRichtextJson(payload);
|
|
143
|
+
if (!jsonResult.valid) {
|
|
144
|
+
return { value: raw, dangerous: jsonResult.dangerous, valid: false, size: jsonResult.size };
|
|
145
|
+
}
|
|
146
|
+
const finalValue = envelopeMode
|
|
147
|
+
? { schemaVersion: RICHTEXT_SCHEMA_VERSION, doc: jsonResult.value }
|
|
148
|
+
: jsonResult.value;
|
|
149
|
+
// Envelope wrapping changes the serialized bytes; reuse jsonResult.size otherwise.
|
|
150
|
+
const finalSize = envelopeMode ? byteLength(JSON.stringify(finalValue)) : jsonResult.size;
|
|
151
|
+
return {
|
|
152
|
+
value: finalValue,
|
|
153
|
+
dangerous: jsonResult.dangerous,
|
|
154
|
+
valid: true,
|
|
155
|
+
size: finalSize,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Gathers all text and latex strings from a rich text structure to compute if it contains content.
|
|
160
|
+
*
|
|
161
|
+
* @param node - The rich text node to inspect.
|
|
162
|
+
* @param sink - Accumulated text chunks.
|
|
163
|
+
* @param depth - Current recursion depth (guard against pathological structures).
|
|
164
|
+
*/
|
|
165
|
+
function gatherRichtextText(node, sink, depth = 0) {
|
|
166
|
+
if (depth > RICHTEXT_MAX_DEPTH)
|
|
167
|
+
return;
|
|
168
|
+
if (Array.isArray(node)) {
|
|
169
|
+
for (const child of node)
|
|
170
|
+
gatherRichtextText(child, sink, depth + 1);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (!isPlainObject(node))
|
|
174
|
+
return;
|
|
175
|
+
if (typeof node.text === 'string') {
|
|
176
|
+
sink.push(cleanString(node.text));
|
|
177
|
+
}
|
|
178
|
+
if (isPlainObject(node.attrs) && typeof node.attrs.latex === 'string') {
|
|
179
|
+
sink.push(cleanString(node.attrs.latex));
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(node.content)) {
|
|
182
|
+
for (const child of node.content)
|
|
183
|
+
gatherRichtextText(child, sink, depth + 1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Checks if a rich text document is effectively empty (contains no text or LaTeX blocks).
|
|
188
|
+
*
|
|
189
|
+
* @param value - The rich text document structure.
|
|
190
|
+
* @returns True if empty, false otherwise.
|
|
191
|
+
*/
|
|
192
|
+
export function isRichtextDocEmpty(value) {
|
|
193
|
+
if (!isPlainObject(value))
|
|
194
|
+
return false;
|
|
195
|
+
if (isRichtextEnvelopeV1(value)) {
|
|
196
|
+
return isRichtextDocEmpty(value.doc);
|
|
197
|
+
}
|
|
198
|
+
if (value.type !== 'doc')
|
|
199
|
+
return false;
|
|
200
|
+
const sink = [];
|
|
201
|
+
gatherRichtextText(value, sink);
|
|
202
|
+
return sink.join('').trim().length === 0;
|
|
203
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Branch } from '../types.js';
|
|
3
|
+
import type { ResolvedOptions } from './index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Returns the appropriate Zod schema builder for a given branch based on its type.
|
|
6
|
+
*
|
|
7
|
+
* @param branch - The branch definition.
|
|
8
|
+
* @param options - The resolved validation options.
|
|
9
|
+
* @returns The compiled schema.
|
|
10
|
+
*/
|
|
11
|
+
export declare function schemaForBranch(branch: Branch, options: ResolvedOptions): z.ZodTypeAny;
|
|
12
|
+
//# sourceMappingURL=schema-builders.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-builders.d.ts","sourceRoot":"","sources":["../../../src/engine/validation/schema-builders.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AACvB,OAAO,KAAK,EAAE,MAAM,EAAc,MAAM,aAAa,CAAA;AAErD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAmajD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,CAAC,CAAC,UAAU,CAMtF"}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { extensionFromUrl, isExtensionAccepted } from '../../media/file-types.js';
|
|
5
|
+
import { cleanString, stripControlChars, byteLength, isPlainObject } from './primitives.js';
|
|
6
|
+
import { sanitizeRichtext } from './richtext-sanitizer.js';
|
|
7
|
+
import { resolveFileOptions, isAssetListBranch, collectAssetListItems, extractFileCandidate } from './file-branch.js';
|
|
8
|
+
/**
|
|
9
|
+
* Validates if a string is a valid ISO 8601 date.
|
|
10
|
+
*
|
|
11
|
+
* @param value - The date string to validate.
|
|
12
|
+
* @returns True if the string is a valid ISO date, false otherwise.
|
|
13
|
+
*/
|
|
14
|
+
function isValidIsoDate(value) {
|
|
15
|
+
if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}(?::?\d{2})?)?)?$/.test(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const ts = Date.parse(value);
|
|
19
|
+
if (Number.isNaN(ts))
|
|
20
|
+
return false;
|
|
21
|
+
const y = parseInt(value.slice(0, 4), 10);
|
|
22
|
+
const m = parseInt(value.slice(5, 7), 10);
|
|
23
|
+
const d = parseInt(value.slice(8, 10), 10);
|
|
24
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
25
|
+
return (date.getUTCFullYear() === y &&
|
|
26
|
+
date.getUTCMonth() === m - 1 &&
|
|
27
|
+
date.getUTCDate() === d);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Wraps a Zod schema to allow null values if `allowNull` is enabled.
|
|
31
|
+
*
|
|
32
|
+
* @param schema - The base schema to wrap.
|
|
33
|
+
* @param allowNull - Whether to allow null values.
|
|
34
|
+
* @returns The wrapped union schema or the base schema.
|
|
35
|
+
*/
|
|
36
|
+
function withNullable(schema, allowNull) {
|
|
37
|
+
return allowNull ? z.union([schema, z.null()]) : schema;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Preprocesses input to convert empty strings or nulls to appropriate fallbacks.
|
|
41
|
+
*
|
|
42
|
+
* @param schema - The Zod schema to apply post-preprocessing.
|
|
43
|
+
* @param allowNull - Whether null/empty should fall back to null (true) or undefined (false).
|
|
44
|
+
* @returns The preprocessed Zod schema.
|
|
45
|
+
*/
|
|
46
|
+
function withEmptyPreprocessing(schema, allowNull) {
|
|
47
|
+
const fallback = allowNull ? null : undefined;
|
|
48
|
+
const wrapped = allowNull ? schema.optional().nullable() : schema.optional();
|
|
49
|
+
return z.preprocess((val) => {
|
|
50
|
+
if (val === '')
|
|
51
|
+
return fallback;
|
|
52
|
+
if (val === null)
|
|
53
|
+
return allowNull ? null : val;
|
|
54
|
+
return val;
|
|
55
|
+
}, wrapped);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Compiles a Zod validation schema for a text branch.
|
|
59
|
+
*
|
|
60
|
+
* @param options - The resolved validation options.
|
|
61
|
+
* @param allowNull - Whether null values are allowed.
|
|
62
|
+
* @returns The compiled text schema.
|
|
63
|
+
*/
|
|
64
|
+
function textSchema(options, allowNull) {
|
|
65
|
+
const inner = z
|
|
66
|
+
.string()
|
|
67
|
+
.transform((value) => cleanString(value))
|
|
68
|
+
.refine((value) => byteLength(value) <= options.maxTextLength, {
|
|
69
|
+
message: `Expected string(max:${options.maxTextLength})`,
|
|
70
|
+
});
|
|
71
|
+
return withNullable(inner, allowNull);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Compiles a Zod validation schema for a rich text branch, including sanitization.
|
|
75
|
+
*
|
|
76
|
+
* @param options - The resolved validation options.
|
|
77
|
+
* @param allowNull - Whether null values are allowed.
|
|
78
|
+
* @returns The compiled rich text schema.
|
|
79
|
+
*/
|
|
80
|
+
function richtextSchema(options, allowNull) {
|
|
81
|
+
const inner = z.any().transform((value, ctx) => {
|
|
82
|
+
const sanitized = sanitizeRichtext(value, options.maxTextLength);
|
|
83
|
+
if (!sanitized.valid) {
|
|
84
|
+
ctx.addIssue({
|
|
85
|
+
code: 'custom',
|
|
86
|
+
message: 'Expected richtext-json|string',
|
|
87
|
+
params: { expected: 'richtext-json|string' },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (sanitized.oversize || sanitized.size > options.maxTextLength) {
|
|
91
|
+
ctx.addIssue({
|
|
92
|
+
code: 'custom',
|
|
93
|
+
message: `Expected richtext(max:${options.maxTextLength})`,
|
|
94
|
+
params: { expected: `richtext(max:${options.maxTextLength})` },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (sanitized.dangerous) {
|
|
98
|
+
ctx.addIssue({
|
|
99
|
+
code: 'custom',
|
|
100
|
+
message: 'Dangerous richtext content',
|
|
101
|
+
params: { dangerous: true },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return sanitized.value;
|
|
105
|
+
});
|
|
106
|
+
return withNullable(inner, allowNull);
|
|
107
|
+
}
|
|
108
|
+
/** Decimal places of a number, correct for scientific notation (1e-7 → 7). */
|
|
109
|
+
function decimalPlaces(n) {
|
|
110
|
+
if (!Number.isFinite(n))
|
|
111
|
+
return 0;
|
|
112
|
+
const s = n.toString();
|
|
113
|
+
if (s.includes('e') || s.includes('E')) {
|
|
114
|
+
const [mantissa, expPart] = s.toLowerCase().split('e');
|
|
115
|
+
const exp = parseInt(expPart, 10);
|
|
116
|
+
const mantDecimals = (mantissa.split('.')[1] ?? '').length;
|
|
117
|
+
return Math.max(0, mantDecimals - exp); // exp is negative for small numbers
|
|
118
|
+
}
|
|
119
|
+
return (s.split('.')[1] ?? '').length;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Compiles a Zod validation schema for a number branch with min/max/step checks.
|
|
123
|
+
*
|
|
124
|
+
* @param branch - The number branch definition.
|
|
125
|
+
* @param allowNull - Whether null values are allowed.
|
|
126
|
+
* @returns The compiled number schema.
|
|
127
|
+
*/
|
|
128
|
+
function numberSchema(branch, allowNull) {
|
|
129
|
+
const opts = branch.numberOptions;
|
|
130
|
+
const base = z.number().superRefine((val, ctx) => {
|
|
131
|
+
if (!Number.isFinite(val)) {
|
|
132
|
+
ctx.addIssue({ code: 'custom', message: 'Expected finite number' });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (opts?.min !== undefined && val < opts.min) {
|
|
136
|
+
ctx.addIssue({ code: 'custom', message: `Expected number(min:${opts.min})` });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (opts?.max !== undefined && val > opts.max) {
|
|
140
|
+
ctx.addIssue({ code: 'custom', message: `Expected number(max:${opts.max})` });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (opts?.step !== undefined) {
|
|
144
|
+
const step = opts.step;
|
|
145
|
+
const origin = opts.min ?? 0;
|
|
146
|
+
const valDecimals = decimalPlaces(val);
|
|
147
|
+
const stepDecimals = decimalPlaces(step);
|
|
148
|
+
const originDecimals = decimalPlaces(origin);
|
|
149
|
+
const scale = 10 ** Math.max(valDecimals, stepDecimals, originDecimals);
|
|
150
|
+
const offset = Math.round((val - origin) * scale);
|
|
151
|
+
const stepScaled = Math.round(step * scale);
|
|
152
|
+
if (stepScaled !== 0 && offset % stepScaled !== 0) {
|
|
153
|
+
ctx.addIssue({ code: 'custom', message: `Expected number(step:${step})` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return withEmptyPreprocessing(base, allowNull);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Compiles a Zod validation schema for a boolean branch.
|
|
161
|
+
*
|
|
162
|
+
* @param allowNull - Whether null values are allowed.
|
|
163
|
+
* @returns The compiled boolean schema.
|
|
164
|
+
*/
|
|
165
|
+
function booleanSchema(allowNull) {
|
|
166
|
+
return withEmptyPreprocessing(z.boolean(), allowNull);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Compiles a Zod validation schema for a date branch checking for valid ISO date format.
|
|
170
|
+
*
|
|
171
|
+
* @param allowNull - Whether null values are allowed.
|
|
172
|
+
* @returns The compiled date schema.
|
|
173
|
+
*/
|
|
174
|
+
function dateSchema(allowNull) {
|
|
175
|
+
const base = z
|
|
176
|
+
.string()
|
|
177
|
+
.transform((value) => cleanString(value))
|
|
178
|
+
.refine((value) => isValidIsoDate(value), { message: 'Expected date(ISO)' });
|
|
179
|
+
return withEmptyPreprocessing(base, allowNull);
|
|
180
|
+
}
|
|
181
|
+
/** Max nesting depth accepted when walking a `json` branch value. */
|
|
182
|
+
const JSON_MAX_DEPTH = 50;
|
|
183
|
+
/** Max number of items accepted in a `tags` branch array. */
|
|
184
|
+
const MAX_TAGS_COUNT = 100;
|
|
185
|
+
/**
|
|
186
|
+
* Recursively strips control characters from string leaves while enforcing a
|
|
187
|
+
* depth cap, mirroring the richtext sanitizer's DoS guards for arbitrary JSON.
|
|
188
|
+
*
|
|
189
|
+
* @param value - The value (or sub-value) being walked.
|
|
190
|
+
* @param depth - Current recursion depth.
|
|
191
|
+
* @returns The sanitized value, or `undefined` with `tooDeep: true` if the cap was exceeded.
|
|
192
|
+
*/
|
|
193
|
+
function sanitizeJsonValue(value, depth) {
|
|
194
|
+
if (depth > JSON_MAX_DEPTH)
|
|
195
|
+
return { value: undefined, tooDeep: true };
|
|
196
|
+
if (typeof value === 'string')
|
|
197
|
+
return { value: stripControlChars(value), tooDeep: false };
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
const out = [];
|
|
200
|
+
for (const item of value) {
|
|
201
|
+
const res = sanitizeJsonValue(item, depth + 1);
|
|
202
|
+
if (res.tooDeep)
|
|
203
|
+
return res;
|
|
204
|
+
out.push(res.value);
|
|
205
|
+
}
|
|
206
|
+
return { value: out, tooDeep: false };
|
|
207
|
+
}
|
|
208
|
+
if (isPlainObject(value)) {
|
|
209
|
+
const out = {};
|
|
210
|
+
for (const [k, v] of Object.entries(value)) {
|
|
211
|
+
const res = sanitizeJsonValue(v, depth + 1);
|
|
212
|
+
if (res.tooDeep)
|
|
213
|
+
return res;
|
|
214
|
+
out[k] = res.value;
|
|
215
|
+
}
|
|
216
|
+
return { value: out, tooDeep: false };
|
|
217
|
+
}
|
|
218
|
+
return { value, tooDeep: false };
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Compiles a Zod validation schema for a `json` branch, bounding size and nesting
|
|
222
|
+
* depth like richtext, and stripping control characters from string leaves.
|
|
223
|
+
*
|
|
224
|
+
* @param options - The resolved validation options.
|
|
225
|
+
* @param allowNull - Whether null values are allowed.
|
|
226
|
+
* @returns The compiled JSON schema.
|
|
227
|
+
*/
|
|
228
|
+
function jsonSchema(options, allowNull) {
|
|
229
|
+
const base = z
|
|
230
|
+
.union([z.record(z.string(), z.unknown()), z.array(z.unknown())])
|
|
231
|
+
.transform((val, ctx) => {
|
|
232
|
+
// Fail-fast size check before the sanitizing walk, same order as richtext.
|
|
233
|
+
const rawSize = byteLength(JSON.stringify(val));
|
|
234
|
+
if (rawSize > options.maxTextLength) {
|
|
235
|
+
ctx.addIssue({ code: 'custom', message: `Expected json(max:${options.maxTextLength})` });
|
|
236
|
+
return z.NEVER;
|
|
237
|
+
}
|
|
238
|
+
const { value, tooDeep } = sanitizeJsonValue(val, 0);
|
|
239
|
+
if (tooDeep) {
|
|
240
|
+
ctx.addIssue({ code: 'custom', message: `Expected json(maxDepth:${JSON_MAX_DEPTH})` });
|
|
241
|
+
return z.NEVER;
|
|
242
|
+
}
|
|
243
|
+
return value;
|
|
244
|
+
});
|
|
245
|
+
return withEmptyPreprocessing(base, allowNull);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Compiles a Zod validation schema for a `tags` branch, constraining it to a
|
|
249
|
+
* bounded array of cleaned strings (fixes #183: previously any array/object
|
|
250
|
+
* shape passed, breaking callers like the kanban swap that cast to `string[]`).
|
|
251
|
+
*
|
|
252
|
+
* @param options - The resolved validation options.
|
|
253
|
+
* @param allowNull - Whether null values are allowed.
|
|
254
|
+
* @returns The compiled tags schema.
|
|
255
|
+
*/
|
|
256
|
+
function tagsSchema(options, allowNull) {
|
|
257
|
+
const tagSchema = z
|
|
258
|
+
.string()
|
|
259
|
+
.transform((value) => cleanString(value))
|
|
260
|
+
.refine((value) => byteLength(value) <= options.maxTextLength, {
|
|
261
|
+
message: `Expected string(max:${options.maxTextLength})`,
|
|
262
|
+
});
|
|
263
|
+
const base = z.array(tagSchema).max(MAX_TAGS_COUNT, {
|
|
264
|
+
message: `Expected tags(max:${MAX_TAGS_COUNT})`,
|
|
265
|
+
});
|
|
266
|
+
return withEmptyPreprocessing(base, allowNull);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Compiles a Zod validation schema for a relation branch, validating ID formats using an ID generator.
|
|
270
|
+
*
|
|
271
|
+
* @param branch - The relation branch definition.
|
|
272
|
+
* @param options - The resolved validation options.
|
|
273
|
+
* @returns The compiled relation schema.
|
|
274
|
+
*/
|
|
275
|
+
function relationSchema(branch, options) {
|
|
276
|
+
const gen = options.idGenerator;
|
|
277
|
+
if (!gen) {
|
|
278
|
+
throw new Error('IIdGenerator must be provided in ValidateSeedPayloadOptions when validating seeds with relation branches. ' +
|
|
279
|
+
'Pass `idGenerator` (e.g. SystemIdGenerator) in the options object.');
|
|
280
|
+
}
|
|
281
|
+
const idSchema = z.string().refine((value) => gen.isValid(value), { message: 'Invalid relation id format' });
|
|
282
|
+
// Many-to-many: expect string[] with no duplicates
|
|
283
|
+
if (branch.multiple === true) {
|
|
284
|
+
const arraySchema = z.array(idSchema).refine((arr) => new Set(arr).size === arr.length, { message: 'Duplicate ids in multi-relation array' });
|
|
285
|
+
return options.allowNull ? z.union([arraySchema, z.null()]) : arraySchema;
|
|
286
|
+
}
|
|
287
|
+
return withNullable(idSchema, options.allowNull);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Compiles a Zod validation schema for file or asset list branches.
|
|
291
|
+
*
|
|
292
|
+
* @param branch - The file branch definition.
|
|
293
|
+
* @param allowNull - Whether null values are allowed.
|
|
294
|
+
* @returns The compiled file schema.
|
|
295
|
+
*/
|
|
296
|
+
function fileSchema(branch, allowNull) {
|
|
297
|
+
const { accept, maxSize } = resolveFileOptions(branch);
|
|
298
|
+
const verifyExtension = (url, ctx) => {
|
|
299
|
+
if (accept === 'any')
|
|
300
|
+
return true;
|
|
301
|
+
const ext = extensionFromUrl(url);
|
|
302
|
+
if (isExtensionAccepted(ext, accept))
|
|
303
|
+
return true;
|
|
304
|
+
ctx.addIssue({ code: 'custom', message: `Expected valid-${accept}-url-with-extension` });
|
|
305
|
+
return false;
|
|
306
|
+
};
|
|
307
|
+
const verifySize = (size, ctx) => {
|
|
308
|
+
if (size === null || size <= maxSize)
|
|
309
|
+
return true;
|
|
310
|
+
ctx.addIssue({ code: 'custom', message: `Expected file(maxSize:${maxSize})` });
|
|
311
|
+
return false;
|
|
312
|
+
};
|
|
313
|
+
if (isAssetListBranch(branch)) {
|
|
314
|
+
const inner = z
|
|
315
|
+
.any()
|
|
316
|
+
.transform((raw, ctx) => {
|
|
317
|
+
const items = collectAssetListItems(raw);
|
|
318
|
+
if (items === null) {
|
|
319
|
+
ctx.addIssue({ code: 'custom', message: 'Expected valid-url-string[]' });
|
|
320
|
+
return z.NEVER;
|
|
321
|
+
}
|
|
322
|
+
for (const item of items) {
|
|
323
|
+
if (!verifyExtension(item.url, ctx))
|
|
324
|
+
return z.NEVER;
|
|
325
|
+
if (!verifySize(item.size, ctx))
|
|
326
|
+
return z.NEVER;
|
|
327
|
+
}
|
|
328
|
+
return items.map((item) => item.url);
|
|
329
|
+
})
|
|
330
|
+
.pipe(z.array(z.string().url()));
|
|
331
|
+
return withEmptyPreprocessing(inner, allowNull);
|
|
332
|
+
}
|
|
333
|
+
const inner = z
|
|
334
|
+
.any()
|
|
335
|
+
.transform((raw, ctx) => {
|
|
336
|
+
const candidate = extractFileCandidate(raw);
|
|
337
|
+
if (!candidate) {
|
|
338
|
+
ctx.addIssue({ code: 'custom', message: 'Expected valid-url-string' });
|
|
339
|
+
return z.NEVER;
|
|
340
|
+
}
|
|
341
|
+
if (!verifyExtension(candidate.url, ctx))
|
|
342
|
+
return z.NEVER;
|
|
343
|
+
if (!verifySize(candidate.size, ctx))
|
|
344
|
+
return z.NEVER;
|
|
345
|
+
return candidate.url;
|
|
346
|
+
})
|
|
347
|
+
.pipe(z.string().url());
|
|
348
|
+
return withEmptyPreprocessing(inner, allowNull);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Sub-branches of a `repeater` are restricted to leaf/scalar types — no nested
|
|
352
|
+
* `repeater`, `relation`, or `file` (v1 restriction, see types.ts Branch.fields).
|
|
353
|
+
*/
|
|
354
|
+
const REPEATER_DISALLOWED_SUBTYPES = new Set(['repeater', 'relation', 'file']);
|
|
355
|
+
/**
|
|
356
|
+
* Compiles a Zod validation schema for repeater branches, validating arrays of items matching sub-fields.
|
|
357
|
+
*
|
|
358
|
+
* @param branch - The repeater branch definition.
|
|
359
|
+
* @param options - The resolved validation options.
|
|
360
|
+
* @returns The compiled repeater schema.
|
|
361
|
+
*/
|
|
362
|
+
function repeaterSchema(branch, options) {
|
|
363
|
+
const requiredFlag = options.operation === 'create' ? 'requiredOnCreate' : 'requiredOnUpdate';
|
|
364
|
+
const subBranches = (branch.fields ?? []).filter((sub) => !REPEATER_DISALLOWED_SUBTYPES.has(sub.type));
|
|
365
|
+
const shape = {};
|
|
366
|
+
for (const sub of subBranches) {
|
|
367
|
+
const subSchema = schemaForBranch(sub, options);
|
|
368
|
+
const isRequired = options.enforceRequiredFields && sub[requiredFlag];
|
|
369
|
+
shape[sub.alias] = isRequired ? subSchema : subSchema.optional();
|
|
370
|
+
}
|
|
371
|
+
// z.object() strips unknown keys by default — old item shapes from a renamed/
|
|
372
|
+
// removed sub-field are dropped rather than rejected (sprint 10 §5.1).
|
|
373
|
+
const itemSchema = z.object(shape);
|
|
374
|
+
let arraySchema = z.array(itemSchema);
|
|
375
|
+
if (Number.isInteger(branch.minItems) && branch.minItems >= 0) {
|
|
376
|
+
arraySchema = arraySchema.min(branch.minItems, {
|
|
377
|
+
message: `Expected array(min:${branch.minItems})`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (Number.isInteger(branch.maxItems) && branch.maxItems >= 0) {
|
|
381
|
+
arraySchema = arraySchema.max(branch.maxItems, {
|
|
382
|
+
message: `Expected array(max:${branch.maxItems})`,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return withEmptyPreprocessing(arraySchema, options.allowNull);
|
|
386
|
+
}
|
|
387
|
+
/** Mapping of branch types to their respective schema compilation functions. */
|
|
388
|
+
const BRANCH_SCHEMA_BUILDERS = {
|
|
389
|
+
text: (_branch, options) => textSchema(options, options.allowNull),
|
|
390
|
+
richtext: (_branch, options) => richtextSchema(options, options.allowNull),
|
|
391
|
+
number: (branch, options) => numberSchema(branch, options.allowNull),
|
|
392
|
+
boolean: (_branch, options) => booleanSchema(options.allowNull),
|
|
393
|
+
date: (_branch, options) => dateSchema(options.allowNull),
|
|
394
|
+
json: (_branch, options) => jsonSchema(options, options.allowNull),
|
|
395
|
+
tags: (_branch, options) => tagsSchema(options, options.allowNull),
|
|
396
|
+
file: (branch, options) => fileSchema(branch, options.allowNull),
|
|
397
|
+
relation: (branch, options) => relationSchema(branch, options),
|
|
398
|
+
repeater: (branch, options) => repeaterSchema(branch, options),
|
|
399
|
+
};
|
|
400
|
+
/**
|
|
401
|
+
* Returns the appropriate Zod schema builder for a given branch based on its type.
|
|
402
|
+
*
|
|
403
|
+
* @param branch - The branch definition.
|
|
404
|
+
* @param options - The resolved validation options.
|
|
405
|
+
* @returns The compiled schema.
|
|
406
|
+
*/
|
|
407
|
+
export function schemaForBranch(branch, options) {
|
|
408
|
+
const builder = BRANCH_SCHEMA_BUILDERS[branch.type];
|
|
409
|
+
if (!builder) {
|
|
410
|
+
throw new Error(`Unhandled branch type: ${branch.type}`);
|
|
411
|
+
}
|
|
412
|
+
return builder(branch, options);
|
|
413
|
+
}
|