@beechcms/core 0.5.0 → 0.6.0-preview.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/auth/hash-provider.d.ts.map +1 -1
- package/dist/auth/hash-provider.js +2 -0
- package/dist/auth/password-reset-token.repository.d.ts.map +1 -1
- package/dist/auth/password-reset-token.repository.js +2 -0
- package/dist/auth/session.repository.d.ts.map +1 -1
- package/dist/auth/session.repository.js +2 -0
- package/dist/auth/token-service.d.ts +2 -0
- package/dist/auth/token-service.d.ts.map +1 -1
- package/dist/auth/token-service.js +2 -0
- package/dist/auth/user.repository.d.ts +4 -1
- package/dist/auth/user.repository.d.ts.map +1 -1
- package/dist/auth/user.repository.js +2 -0
- package/dist/automations-grammar-words.d.ts.map +1 -1
- package/dist/automations-grammar-words.js +2 -0
- package/dist/automations.repository.interface.d.ts.map +1 -1
- package/dist/automations.repository.interface.js +2 -0
- package/dist/automations.runner.interface.d.ts.map +1 -1
- package/dist/automations.runner.interface.js +2 -0
- package/dist/automations.runner.stub.d.ts.map +1 -1
- package/dist/automations.runner.stub.js +2 -0
- package/dist/automations.types.d.ts +2 -0
- package/dist/automations.types.d.ts.map +1 -1
- package/dist/automations.types.js +2 -0
- package/dist/clock.d.ts.map +1 -1
- package/dist/clock.js +2 -0
- package/dist/content-scan.repository.d.ts.map +1 -1
- package/dist/content-scan.repository.js +2 -0
- package/dist/content.repository.d.ts +62 -0
- package/dist/content.repository.d.ts.map +1 -1
- package/dist/content.repository.js +18 -0
- package/dist/define-seed.d.ts.map +1 -1
- package/dist/define-seed.js +2 -0
- package/dist/demo-data.repository.d.ts +5 -0
- package/dist/demo-data.repository.d.ts.map +1 -0
- package/dist/demo-data.repository.js +3 -0
- package/dist/engine.d.ts +76 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +246 -5
- package/dist/file-types.d.ts +13 -0
- package/dist/file-types.d.ts.map +1 -0
- package/dist/file-types.js +85 -0
- package/dist/id-generator.d.ts +7 -0
- package/dist/id-generator.d.ts.map +1 -1
- package/dist/id-generator.js +5 -0
- package/dist/idempotency.repository.d.ts.map +1 -1
- package/dist/idempotency.repository.js +2 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/layout-permissions.d.ts +3 -0
- package/dist/layout-permissions.d.ts.map +1 -0
- package/dist/layout-permissions.js +11 -0
- package/dist/media.repository.d.ts.map +1 -1
- package/dist/media.repository.js +2 -0
- package/dist/notifications/notification-service.d.ts.map +1 -1
- package/dist/notifications/notification-service.js +2 -0
- package/dist/notifications/notification.repository.d.ts.map +1 -1
- package/dist/notifications/notification.repository.js +2 -9
- package/dist/observability/activity-log.repository.d.ts.map +1 -1
- package/dist/observability/activity-log.repository.js +2 -0
- package/dist/observability/activity-logger.d.ts +2 -2
- package/dist/observability/activity-logger.d.ts.map +1 -1
- package/dist/observability/activity-logger.js +2 -11
- package/dist/observability/analytics.repository.d.ts.map +1 -1
- package/dist/observability/analytics.repository.js +2 -0
- package/dist/policies.d.ts.map +1 -1
- package/dist/policies.js +2 -0
- package/dist/rate-limit/rate-limiter.d.ts.map +1 -1
- package/dist/rate-limit/rate-limiter.js +2 -0
- package/dist/relations.d.ts +28 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +25 -0
- package/dist/richtext-render.d.ts.map +1 -1
- package/dist/richtext-render.js +2 -0
- package/dist/richtext.d.ts.map +1 -1
- package/dist/richtext.js +2 -0
- package/dist/scheduler.interface.d.ts.map +1 -1
- package/dist/scheduler.interface.js +2 -0
- package/dist/scheduler.stub.d.ts.map +1 -1
- package/dist/scheduler.stub.js +2 -0
- package/dist/schema-mutator.d.ts +22 -0
- package/dist/schema-mutator.d.ts.map +1 -0
- package/dist/schema-mutator.js +3 -0
- package/dist/search/search.repository.d.ts.map +1 -1
- package/dist/search/search.repository.js +2 -0
- package/dist/seed-ddl.d.ts +40 -0
- package/dist/seed-ddl.d.ts.map +1 -0
- package/dist/seed-ddl.js +100 -0
- package/dist/seed-layout.d.ts +117 -0
- package/dist/seed-layout.d.ts.map +1 -0
- package/dist/seed-layout.js +169 -0
- package/dist/seed-layout.repository.d.ts +18 -0
- package/dist/seed-layout.repository.d.ts.map +1 -0
- package/dist/seed-layout.repository.js +3 -0
- package/dist/seed-registry.d.ts +23 -1
- package/dist/seed-registry.d.ts.map +1 -1
- package/dist/seed-registry.js +105 -0
- package/dist/seed-validation.d.ts +23 -0
- package/dist/seed-validation.d.ts.map +1 -0
- package/dist/seed-validation.js +156 -0
- package/dist/seed.repository.d.ts +37 -0
- package/dist/seed.repository.d.ts.map +1 -0
- package/dist/seed.repository.js +3 -0
- package/dist/seeds.d.ts.map +1 -1
- package/dist/seeds.js +2 -0
- package/dist/site-settings.repository.d.ts +16 -0
- package/dist/site-settings.repository.d.ts.map +1 -0
- package/dist/site-settings.repository.js +3 -0
- package/dist/slug-utils.d.ts.map +1 -1
- package/dist/slug-utils.js +2 -0
- package/dist/storage.d.ts +12 -38
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +2 -0
- package/dist/types.d.ts +55 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/validation.d.ts +23 -17
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +428 -379
- package/dist/webhook-validation.d.ts +3 -0
- package/dist/webhook-validation.d.ts.map +1 -0
- package/dist/webhook-validation.js +6 -0
- package/dist/widget/widget.repository.d.ts.map +1 -1
- package/dist/widget/widget.repository.js +2 -0
- package/package.json +5 -4
package/dist/validation.js
CHANGED
|
@@ -1,243 +1,225 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
1
3
|
import { z } from 'zod';
|
|
2
4
|
import { RICHTEXT_SCHEMA_VERSION, isRichtextEnvelopeV1 } from './richtext.js';
|
|
3
|
-
|
|
5
|
+
import { extensionFromUrl, isExtensionAccepted } from './file-types.js';
|
|
6
|
+
const DEFAULT_MAX_TEXT_LENGTH = 50_000;
|
|
7
|
+
const DEFAULT_FILE_MAX_SIZE = 5 * 1024 * 1024;
|
|
4
8
|
const CONTROL_CHARS_REGEX = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
5
9
|
const DANGEROUS_TAG_REGEX = /<(script|iframe|object|embed)\b/i;
|
|
10
|
+
const DANGEROUS_TAG_STRIP_REGEX = /<\/?(script|iframe|object|embed)[^>]*>/gi;
|
|
6
11
|
const DANGEROUS_ATTR_REGEX = /\son[a-z]+\s*=/i;
|
|
12
|
+
const DANGEROUS_HANDLER_STRIP_REGEX = /\son[a-z]+\s*=\s*(['"]).*?\1/gi;
|
|
7
13
|
const DANGEROUS_PROTOCOL_REGEX = /^\s*javascript:/i;
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
function
|
|
16
|
-
return
|
|
14
|
+
const FORBIDDEN_RICHTEXT_NODE_TYPES = new Set(['script', 'iframe', 'object', 'embed']);
|
|
15
|
+
const URL_LIKE_RICHTEXT_KEYS = new Set(['href', 'src']);
|
|
16
|
+
const STATUS_VALUES = ['draft', 'review', 'published'];
|
|
17
|
+
const statusSchema = z.enum(STATUS_VALUES);
|
|
18
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Primitive helpers
|
|
20
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
function stripControlChars(input) {
|
|
22
|
+
return input.replaceAll(CONTROL_CHARS_REGEX, '');
|
|
17
23
|
}
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
24
|
+
function cleanString(input) {
|
|
25
|
+
return stripControlChars(input).trim();
|
|
26
|
+
}
|
|
27
|
+
function isPlainObject(input) {
|
|
28
|
+
return typeof input === 'object' && input !== null && !Array.isArray(input);
|
|
29
|
+
}
|
|
30
|
+
function tryParseJson(raw) {
|
|
26
31
|
try {
|
|
27
|
-
|
|
28
|
-
if (!parsed.protocol.startsWith('http')) {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
32
|
+
return JSON.parse(raw);
|
|
31
33
|
}
|
|
32
34
|
catch {
|
|
33
|
-
return
|
|
35
|
+
return raw;
|
|
34
36
|
}
|
|
35
|
-
return cleaned;
|
|
36
37
|
}
|
|
37
|
-
function
|
|
38
|
+
function parseHttpUrl(input) {
|
|
39
|
+
if (typeof input !== 'string')
|
|
40
|
+
return null;
|
|
41
|
+
const cleaned = cleanString(input);
|
|
42
|
+
if (cleaned.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
let parsed;
|
|
38
45
|
try {
|
|
39
|
-
|
|
46
|
+
parsed = new URL(cleaned);
|
|
40
47
|
}
|
|
41
48
|
catch {
|
|
42
|
-
return value;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
function isPlainObject(value) {
|
|
46
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
|
-
}
|
|
48
|
-
function normalizeAssetListValue(rawValue) {
|
|
49
|
-
const input = typeof rawValue === 'string' ? parseJsonString(rawValue) : rawValue;
|
|
50
|
-
const values = Array.isArray(input) ? input : [input];
|
|
51
|
-
const normalized = [];
|
|
52
|
-
for (const item of values) {
|
|
53
|
-
if (item == null)
|
|
54
|
-
continue;
|
|
55
|
-
const directUrl = normalizeHttpUrl(item);
|
|
56
|
-
if (directUrl) {
|
|
57
|
-
normalized.push(directUrl);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
if (typeof item === 'object' && !Array.isArray(item)) {
|
|
61
|
-
const nestedUrl = normalizeHttpUrl(item.url);
|
|
62
|
-
if (nestedUrl) {
|
|
63
|
-
normalized.push(nestedUrl);
|
|
64
|
-
continue;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
49
|
return null;
|
|
68
50
|
}
|
|
69
|
-
return
|
|
51
|
+
return parsed.protocol.startsWith('http') ? cleaned : null;
|
|
52
|
+
}
|
|
53
|
+
function isValidIsoDate(value) {
|
|
54
|
+
if (!/^\d{4}-\d{2}-\d{2}/.test(value))
|
|
55
|
+
return false;
|
|
56
|
+
const ts = Date.parse(value);
|
|
57
|
+
if (Number.isNaN(ts))
|
|
58
|
+
return false;
|
|
59
|
+
return new Date(ts).toISOString().startsWith(value.slice(0, 10));
|
|
70
60
|
}
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
62
|
+
// File branch helpers
|
|
63
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
export function resolveFileOptions(branch) {
|
|
65
|
+
const opts = branch.fileOptions;
|
|
66
|
+
return {
|
|
67
|
+
accept: opts?.accept ?? 'any',
|
|
68
|
+
maxSize: opts?.maxSize ?? DEFAULT_FILE_MAX_SIZE,
|
|
69
|
+
};
|
|
73
70
|
}
|
|
74
|
-
function
|
|
75
|
-
|
|
76
|
-
for (const item of value)
|
|
77
|
-
collectRichtextVisibleText(item, chunks);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (!isPlainObject(value))
|
|
81
|
-
return;
|
|
82
|
-
if (typeof value.text === 'string') {
|
|
83
|
-
chunks.push(sanitizePlainString(value.text));
|
|
84
|
-
}
|
|
85
|
-
const attrs = typeof value.attrs === 'object' && value.attrs !== null ? value.attrs : null;
|
|
86
|
-
if (attrs && typeof attrs.latex === 'string') {
|
|
87
|
-
chunks.push(sanitizePlainString(attrs.latex));
|
|
88
|
-
}
|
|
89
|
-
if (Array.isArray(value.content)) {
|
|
90
|
-
for (const nested of value.content)
|
|
91
|
-
collectRichtextVisibleText(nested, chunks);
|
|
92
|
-
}
|
|
71
|
+
function isAssetListBranch(branch) {
|
|
72
|
+
return branch.type === 'file' && (branch.multiple === true || branch.format === 'asset-list');
|
|
93
73
|
}
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
74
|
+
function extractUrlFromCandidate(candidate) {
|
|
75
|
+
const direct = parseHttpUrl(candidate);
|
|
76
|
+
if (direct)
|
|
77
|
+
return direct;
|
|
78
|
+
if (isPlainObject(candidate)) {
|
|
79
|
+
return parseHttpUrl(candidate.url);
|
|
99
80
|
}
|
|
100
|
-
|
|
101
|
-
return false;
|
|
102
|
-
const chunks = [];
|
|
103
|
-
collectRichtextVisibleText(value, chunks);
|
|
104
|
-
return chunks.join('').trim().length === 0;
|
|
81
|
+
return null;
|
|
105
82
|
}
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
83
|
+
function collectAssetListUrls(raw) {
|
|
84
|
+
const source = typeof raw === 'string' ? tryParseJson(raw) : raw;
|
|
85
|
+
const items = Array.isArray(source) ? source : [source];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
if (item === null || item === undefined)
|
|
90
|
+
continue;
|
|
91
|
+
const url = extractUrlFromCandidate(item);
|
|
92
|
+
if (!url)
|
|
93
|
+
return null;
|
|
94
|
+
if (!seen.has(url)) {
|
|
95
|
+
seen.add(url);
|
|
96
|
+
out.push(url);
|
|
116
97
|
}
|
|
117
|
-
if (isRichtextDocEmpty(value))
|
|
118
|
-
return true;
|
|
119
|
-
return Object.keys(value).length === 0;
|
|
120
98
|
}
|
|
121
|
-
return
|
|
99
|
+
return out;
|
|
122
100
|
}
|
|
123
|
-
function sanitizeRichtextString(
|
|
124
|
-
const
|
|
125
|
-
const dangerous = DANGEROUS_TAG_REGEX.test(
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const cleaned = value.replaceAll(CONTROL_CHARS_REGEX, '');
|
|
101
|
+
function sanitizeRichtextString(raw) {
|
|
102
|
+
const noControls = stripControlChars(raw);
|
|
103
|
+
const dangerous = DANGEROUS_TAG_REGEX.test(noControls) || DANGEROUS_ATTR_REGEX.test(noControls);
|
|
104
|
+
const noTags = noControls.replaceAll(DANGEROUS_TAG_STRIP_REGEX, '');
|
|
105
|
+
const noHandlers = noTags.replaceAll(DANGEROUS_HANDLER_STRIP_REGEX, '');
|
|
106
|
+
const finalValue = noHandlers.trim();
|
|
107
|
+
return { value: finalValue, dangerous, valid: true, size: finalValue.length };
|
|
108
|
+
}
|
|
109
|
+
function walkRichtextNode(node, state) {
|
|
110
|
+
if (typeof node === 'string') {
|
|
111
|
+
const cleaned = stripControlChars(node);
|
|
135
112
|
if (DANGEROUS_TAG_REGEX.test(cleaned) || DANGEROUS_ATTR_REGEX.test(cleaned)) {
|
|
136
113
|
state.dangerous = true;
|
|
137
114
|
}
|
|
138
115
|
return cleaned;
|
|
139
116
|
}
|
|
140
|
-
if (Array.isArray(
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
if (!isPlainObject(value)) {
|
|
144
|
-
return value;
|
|
117
|
+
if (Array.isArray(node)) {
|
|
118
|
+
return node.map((child) => walkRichtextNode(child, state));
|
|
145
119
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
120
|
+
if (!isPlainObject(node))
|
|
121
|
+
return node;
|
|
122
|
+
const result = {};
|
|
123
|
+
for (const [key, entry] of Object.entries(node)) {
|
|
124
|
+
const lower = key.toLowerCase();
|
|
125
|
+
if (lower.startsWith('on')) {
|
|
150
126
|
state.dangerous = true;
|
|
151
127
|
}
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
128
|
+
if (lower === 'type' && typeof entry === 'string') {
|
|
129
|
+
if (FORBIDDEN_RICHTEXT_NODE_TYPES.has(entry.toLowerCase())) {
|
|
130
|
+
state.dangerous = true;
|
|
131
|
+
}
|
|
156
132
|
}
|
|
157
|
-
if (
|
|
158
|
-
typeof rawEntry === 'string' &&
|
|
159
|
-
DANGEROUS_PROTOCOL_REGEX.test(rawEntry)) {
|
|
133
|
+
if (URL_LIKE_RICHTEXT_KEYS.has(lower) && typeof entry === 'string' && DANGEROUS_PROTOCOL_REGEX.test(entry)) {
|
|
160
134
|
state.dangerous = true;
|
|
161
135
|
}
|
|
162
|
-
|
|
136
|
+
result[key] = walkRichtextNode(entry, state);
|
|
163
137
|
}
|
|
164
|
-
return
|
|
138
|
+
return result;
|
|
165
139
|
}
|
|
166
|
-
function sanitizeRichtextJson(
|
|
140
|
+
function sanitizeRichtextJson(raw) {
|
|
167
141
|
const state = { dangerous: false };
|
|
168
|
-
const
|
|
169
|
-
const asObject = isPlainObject(
|
|
142
|
+
const cleaned = walkRichtextNode(raw, state);
|
|
143
|
+
const asObject = isPlainObject(cleaned) ? cleaned : {};
|
|
170
144
|
const valid = asObject.type === 'doc';
|
|
171
145
|
const serialized = JSON.stringify(asObject);
|
|
172
|
-
return {
|
|
173
|
-
value: asObject,
|
|
174
|
-
dangerous: state.dangerous,
|
|
175
|
-
valid,
|
|
176
|
-
size: serialized.length,
|
|
177
|
-
};
|
|
146
|
+
return { value: asObject, dangerous: state.dangerous, valid, size: serialized.length };
|
|
178
147
|
}
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
148
|
+
function sanitizeRichtext(raw) {
|
|
149
|
+
const envelopeMode = isRichtextEnvelopeV1(raw);
|
|
150
|
+
const payload = envelopeMode ? raw.doc : raw;
|
|
151
|
+
if (typeof payload === 'string') {
|
|
152
|
+
return sanitizeRichtextString(payload);
|
|
182
153
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
function sanitizeRichtext(value) {
|
|
186
|
-
const { inner, wrapAsEnvelope } = unwrapRichtextPayload(value);
|
|
187
|
-
if (typeof inner === 'string') {
|
|
188
|
-
const sanitized = sanitizeRichtextString(inner);
|
|
189
|
-
return {
|
|
190
|
-
value: sanitized.value,
|
|
191
|
-
dangerous: sanitized.dangerous,
|
|
192
|
-
valid: true,
|
|
193
|
-
size: sanitized.size,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
if (isPlainObject(inner)) {
|
|
197
|
-
const jsonResult = sanitizeRichtextJson(inner);
|
|
154
|
+
if (isPlainObject(payload)) {
|
|
155
|
+
const jsonResult = sanitizeRichtextJson(payload);
|
|
198
156
|
if (!jsonResult.valid) {
|
|
199
|
-
return {
|
|
200
|
-
value,
|
|
201
|
-
dangerous: jsonResult.dangerous,
|
|
202
|
-
valid: false,
|
|
203
|
-
size: jsonResult.size,
|
|
204
|
-
};
|
|
157
|
+
return { value: raw, dangerous: jsonResult.dangerous, valid: false, size: jsonResult.size };
|
|
205
158
|
}
|
|
206
|
-
const
|
|
159
|
+
const finalValue = envelopeMode
|
|
207
160
|
? { schemaVersion: RICHTEXT_SCHEMA_VERSION, doc: jsonResult.value }
|
|
208
161
|
: jsonResult.value;
|
|
209
162
|
return {
|
|
210
|
-
value:
|
|
163
|
+
value: finalValue,
|
|
211
164
|
dangerous: jsonResult.dangerous,
|
|
212
165
|
valid: true,
|
|
213
|
-
size: JSON.stringify(
|
|
166
|
+
size: JSON.stringify(finalValue).length,
|
|
214
167
|
};
|
|
215
168
|
}
|
|
216
|
-
return {
|
|
217
|
-
value,
|
|
218
|
-
dangerous: false,
|
|
219
|
-
valid: false,
|
|
220
|
-
size: 0,
|
|
221
|
-
};
|
|
169
|
+
return { value: raw, dangerous: false, valid: false, size: 0 };
|
|
222
170
|
}
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
171
|
+
function gatherRichtextText(node, sink) {
|
|
172
|
+
if (Array.isArray(node)) {
|
|
173
|
+
for (const child of node)
|
|
174
|
+
gatherRichtextText(child, sink);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!isPlainObject(node))
|
|
178
|
+
return;
|
|
179
|
+
if (typeof node.text === 'string') {
|
|
180
|
+
sink.push(cleanString(node.text));
|
|
181
|
+
}
|
|
182
|
+
if (isPlainObject(node.attrs) && typeof node.attrs.latex === 'string') {
|
|
183
|
+
sink.push(cleanString(node.attrs.latex));
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(node.content)) {
|
|
186
|
+
for (const child of node.content)
|
|
187
|
+
gatherRichtextText(child, sink);
|
|
188
|
+
}
|
|
227
189
|
}
|
|
228
|
-
function
|
|
229
|
-
|
|
190
|
+
function isRichtextDocEmpty(value) {
|
|
191
|
+
if (!isPlainObject(value))
|
|
192
|
+
return false;
|
|
193
|
+
if (isRichtextEnvelopeV1(value)) {
|
|
194
|
+
return isRichtextDocEmpty(value.doc);
|
|
195
|
+
}
|
|
196
|
+
if (value.type !== 'doc')
|
|
197
|
+
return false;
|
|
198
|
+
const sink = [];
|
|
199
|
+
gatherRichtextText(value, sink);
|
|
200
|
+
return sink.join('').trim().length === 0;
|
|
201
|
+
}
|
|
202
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
203
|
+
// Schema builders per branch type
|
|
204
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
function withNullable(schema, allowNull) {
|
|
206
|
+
return allowNull ? z.union([schema, z.null()]) : schema;
|
|
207
|
+
}
|
|
208
|
+
function withEmptyPreprocessing(schema, allowNull) {
|
|
209
|
+
const fallback = allowNull ? null : undefined;
|
|
210
|
+
return z.preprocess((val) => (val === '' || val === null ? fallback : val), schema.optional());
|
|
230
211
|
}
|
|
231
|
-
function
|
|
232
|
-
const
|
|
233
|
-
.
|
|
212
|
+
function textSchema(options, allowNull) {
|
|
213
|
+
const inner = z
|
|
214
|
+
.string()
|
|
215
|
+
.transform((value) => cleanString(value))
|
|
234
216
|
.refine((value) => value.length <= options.maxTextLength, {
|
|
235
217
|
message: `Expected string(max:${options.maxTextLength})`,
|
|
236
218
|
});
|
|
237
|
-
return
|
|
219
|
+
return withNullable(inner, allowNull);
|
|
238
220
|
}
|
|
239
|
-
function
|
|
240
|
-
const
|
|
221
|
+
function richtextSchema(options, allowNull) {
|
|
222
|
+
const inner = z.any().transform((value, ctx) => {
|
|
241
223
|
const sanitized = sanitizeRichtext(value);
|
|
242
224
|
if (!sanitized.valid) {
|
|
243
225
|
ctx.addIssue({
|
|
@@ -262,182 +244,270 @@ function buildRichtextSchema(options, nullable) {
|
|
|
262
244
|
}
|
|
263
245
|
return sanitized.value;
|
|
264
246
|
});
|
|
265
|
-
return
|
|
247
|
+
return withNullable(inner, allowNull);
|
|
266
248
|
}
|
|
267
|
-
function
|
|
249
|
+
function numberSchema(branch, allowNull) {
|
|
268
250
|
const opts = branch.numberOptions;
|
|
269
|
-
const
|
|
270
|
-
const refinedSchema = numSchema.superRefine((val, ctx) => {
|
|
251
|
+
const base = z.number().superRefine((val, ctx) => {
|
|
271
252
|
if (!Number.isFinite(val)) {
|
|
272
253
|
ctx.addIssue({ code: 'custom', message: 'Expected finite number' });
|
|
273
|
-
return
|
|
254
|
+
return;
|
|
274
255
|
}
|
|
275
256
|
if (opts?.min !== undefined && val < opts.min) {
|
|
276
257
|
ctx.addIssue({ code: 'custom', message: `Expected number(min:${opts.min})` });
|
|
277
|
-
return
|
|
258
|
+
return;
|
|
278
259
|
}
|
|
279
260
|
if (opts?.max !== undefined && val > opts.max) {
|
|
280
261
|
ctx.addIssue({ code: 'custom', message: `Expected number(max:${opts.max})` });
|
|
281
|
-
return
|
|
262
|
+
return;
|
|
282
263
|
}
|
|
283
264
|
if (opts?.step !== undefined) {
|
|
284
265
|
const step = opts.step;
|
|
285
|
-
const
|
|
286
|
-
const valDecimals = (val.toString().split('.')[1]
|
|
287
|
-
const stepDecimals = (step.toString().split('.')[1]
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
266
|
+
const origin = opts.min ?? 0;
|
|
267
|
+
const valDecimals = (val.toString().split('.')[1] ?? '').length;
|
|
268
|
+
const stepDecimals = (step.toString().split('.')[1] ?? '').length;
|
|
269
|
+
const scale = 10 ** Math.max(valDecimals, stepDecimals);
|
|
270
|
+
const offset = Math.round((val - origin) * scale);
|
|
271
|
+
const stepScaled = Math.round(step * scale);
|
|
272
|
+
if (stepScaled !== 0 && offset % stepScaled !== 0) {
|
|
291
273
|
ctx.addIssue({ code: 'custom', message: `Expected number(step:${step})` });
|
|
292
274
|
}
|
|
293
275
|
}
|
|
294
276
|
});
|
|
295
|
-
|
|
296
|
-
|
|
277
|
+
return withNullable(withEmptyPreprocessing(base, allowNull), allowNull);
|
|
278
|
+
}
|
|
279
|
+
function booleanSchema(allowNull) {
|
|
280
|
+
return withNullable(withEmptyPreprocessing(z.boolean(), allowNull), allowNull);
|
|
281
|
+
}
|
|
282
|
+
function dateSchema(allowNull) {
|
|
283
|
+
const base = z
|
|
284
|
+
.string()
|
|
285
|
+
.transform((value) => cleanString(value))
|
|
286
|
+
.refine((value) => isValidIsoDate(value), { message: 'Expected date(ISO)' });
|
|
287
|
+
return withNullable(withEmptyPreprocessing(base, allowNull), allowNull);
|
|
297
288
|
}
|
|
298
|
-
function
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
289
|
+
function jsonOrTagsSchema(allowNull) {
|
|
290
|
+
const base = z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]);
|
|
291
|
+
return withNullable(withEmptyPreprocessing(base, allowNull), allowNull);
|
|
292
|
+
}
|
|
293
|
+
function relationSchema(branch, options) {
|
|
294
|
+
const gen = options.idGenerator;
|
|
295
|
+
if (!gen) {
|
|
296
|
+
throw new Error('IIdGenerator must be provided in ValidateSeedPayloadOptions when validating seeds with relation branches. ' +
|
|
297
|
+
'Pass `idGenerator` (e.g. SystemIdGenerator) in the options object.');
|
|
298
|
+
}
|
|
299
|
+
const idSchema = z.string().refine((value) => gen.isValid(value), { message: 'Invalid relation id format' });
|
|
300
|
+
// Many-to-many: expect string[] with no duplicates
|
|
301
|
+
if (branch.multiple === true) {
|
|
302
|
+
const arraySchema = z.array(idSchema).refine((arr) => new Set(arr).size === arr.length, { message: 'Duplicate ids in multi-relation array' });
|
|
303
|
+
return options.allowNull ? z.union([arraySchema, z.null()]) : arraySchema;
|
|
304
|
+
}
|
|
305
|
+
return withNullable(idSchema, options.allowNull);
|
|
303
306
|
}
|
|
304
|
-
function
|
|
307
|
+
function fileSchema(branch, allowNull) {
|
|
308
|
+
const { accept } = resolveFileOptions(branch);
|
|
309
|
+
const verifyExtension = (url, ctx) => {
|
|
310
|
+
if (accept === 'any')
|
|
311
|
+
return true;
|
|
312
|
+
const ext = extensionFromUrl(url);
|
|
313
|
+
if (isExtensionAccepted(ext, accept))
|
|
314
|
+
return true;
|
|
315
|
+
ctx.addIssue({ code: 'custom', message: `Expected file(accept:${accept})` });
|
|
316
|
+
return false;
|
|
317
|
+
};
|
|
305
318
|
if (isAssetListBranch(branch)) {
|
|
306
|
-
const
|
|
307
|
-
.
|
|
308
|
-
|
|
309
|
-
|
|
319
|
+
const inner = z
|
|
320
|
+
.any()
|
|
321
|
+
.transform((raw, ctx) => {
|
|
322
|
+
const urls = collectAssetListUrls(raw);
|
|
323
|
+
if (urls === null) {
|
|
310
324
|
ctx.addIssue({ code: 'custom', message: 'Expected url-string[]' });
|
|
311
325
|
return z.NEVER;
|
|
312
326
|
}
|
|
313
|
-
|
|
327
|
+
for (const url of urls) {
|
|
328
|
+
if (!verifyExtension(url, ctx))
|
|
329
|
+
return z.NEVER;
|
|
330
|
+
}
|
|
331
|
+
return urls;
|
|
314
332
|
})
|
|
315
|
-
.pipe(z.array(z.string().url()))
|
|
316
|
-
return
|
|
333
|
+
.pipe(z.array(z.string().url()));
|
|
334
|
+
return withNullable(withEmptyPreprocessing(inner, allowNull), allowNull);
|
|
317
335
|
}
|
|
318
|
-
const
|
|
319
|
-
.
|
|
320
|
-
|
|
321
|
-
|
|
336
|
+
const inner = z
|
|
337
|
+
.any()
|
|
338
|
+
.transform((raw, ctx) => {
|
|
339
|
+
const url = parseHttpUrl(raw);
|
|
340
|
+
if (!url) {
|
|
322
341
|
ctx.addIssue({ code: 'custom', message: 'Expected url-string' });
|
|
323
342
|
return z.NEVER;
|
|
324
343
|
}
|
|
325
|
-
|
|
344
|
+
if (!verifyExtension(url, ctx))
|
|
345
|
+
return z.NEVER;
|
|
346
|
+
return url;
|
|
326
347
|
})
|
|
327
|
-
.pipe(z.string().url())
|
|
328
|
-
return
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
case 'json':
|
|
346
|
-
case 'tags': {
|
|
347
|
-
const schema = getPreprocessEmpty(options.allowNull)(jsonObjectOrArraySchema);
|
|
348
|
-
return nullable ? z.union([schema, nullable]) : schema;
|
|
349
|
-
}
|
|
350
|
-
case 'file':
|
|
351
|
-
return buildFileSchema(branch, options.allowNull, nullable);
|
|
352
|
-
default:
|
|
353
|
-
throw new Error(`Unhandled branch type: ${branch.type}`);
|
|
348
|
+
.pipe(z.string().url());
|
|
349
|
+
return withNullable(withEmptyPreprocessing(inner, allowNull), allowNull);
|
|
350
|
+
}
|
|
351
|
+
const BRANCH_SCHEMA_BUILDERS = {
|
|
352
|
+
text: (_branch, options) => textSchema(options, options.allowNull),
|
|
353
|
+
richtext: (_branch, options) => richtextSchema(options, options.allowNull),
|
|
354
|
+
number: (branch, options) => numberSchema(branch, options.allowNull),
|
|
355
|
+
boolean: (_branch, options) => booleanSchema(options.allowNull),
|
|
356
|
+
date: (_branch, options) => dateSchema(options.allowNull),
|
|
357
|
+
json: (_branch, options) => jsonOrTagsSchema(options.allowNull),
|
|
358
|
+
tags: (_branch, options) => jsonOrTagsSchema(options.allowNull),
|
|
359
|
+
file: (branch, options) => fileSchema(branch, options.allowNull),
|
|
360
|
+
relation: (branch, options) => relationSchema(branch, options),
|
|
361
|
+
};
|
|
362
|
+
function schemaForBranch(branch, options) {
|
|
363
|
+
const builder = BRANCH_SCHEMA_BUILDERS[branch.type];
|
|
364
|
+
if (!builder) {
|
|
365
|
+
throw new Error(`Unhandled branch type: ${branch.type}`);
|
|
354
366
|
}
|
|
367
|
+
return builder(branch, options);
|
|
355
368
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
369
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
// Seed schema compilation with cache
|
|
371
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
const seedSchemaCache = new Map();
|
|
373
|
+
function buildSeedFingerprint(seed) {
|
|
374
|
+
const parts = seed.branches.map((branch) => ({
|
|
375
|
+
a: branch.alias,
|
|
376
|
+
t: branch.type,
|
|
377
|
+
f: branch.format ?? null,
|
|
378
|
+
m: branch.multiple === true,
|
|
379
|
+
rc: branch.requiredOnCreate === true,
|
|
380
|
+
ru: branch.requiredOnUpdate === true,
|
|
381
|
+
n: branch.numberOptions ?? null,
|
|
382
|
+
fi: branch.fileOptions ?? null,
|
|
383
|
+
}));
|
|
384
|
+
return JSON.stringify({ s: seed.slug, b: parts });
|
|
385
|
+
}
|
|
386
|
+
function buildCacheKey(seed, options) {
|
|
387
|
+
return [
|
|
388
|
+
buildSeedFingerprint(seed),
|
|
389
|
+
options.operation,
|
|
390
|
+
options.allowNull ? '1' : '0',
|
|
391
|
+
String(options.maxTextLength),
|
|
392
|
+
].join('|');
|
|
370
393
|
}
|
|
371
394
|
function compileSeedSchema(seed, options) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
395
|
+
// Seeds with relation branches cannot be safely cached because the schema
|
|
396
|
+
// captures the idGenerator by closure, and different generators (e.g.
|
|
397
|
+
// SystemIdGenerator vs SequentialIdGenerator) have different isValid() semantics.
|
|
398
|
+
const hasRelation = seed.branches.some((b) => b.type === 'relation');
|
|
399
|
+
if (!hasRelation) {
|
|
400
|
+
const key = buildCacheKey(seed, options);
|
|
401
|
+
const cached = seedSchemaCache.get(key);
|
|
402
|
+
if (cached)
|
|
403
|
+
return cached;
|
|
404
|
+
}
|
|
405
|
+
const requiredFlag = options.operation === 'create' ? 'requiredOnCreate' : 'requiredOnUpdate';
|
|
382
406
|
const shape = {};
|
|
383
407
|
for (const branch of seed.branches) {
|
|
384
|
-
const
|
|
385
|
-
shape[branch.alias] =
|
|
408
|
+
const branchSchema = schemaForBranch(branch, options);
|
|
409
|
+
shape[branch.alias] = branch[requiredFlag] ? branchSchema : branchSchema.optional();
|
|
386
410
|
}
|
|
387
411
|
const compiled = z.object(shape).strict();
|
|
388
|
-
|
|
412
|
+
if (!hasRelation) {
|
|
413
|
+
const key = buildCacheKey(seed, options);
|
|
414
|
+
seedSchemaCache.set(key, compiled);
|
|
415
|
+
}
|
|
389
416
|
return compiled;
|
|
390
417
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
return
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return false;
|
|
410
|
-
const d = new Date(parsed);
|
|
411
|
-
return d.toISOString().startsWith(value.substring(0, 10));
|
|
418
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
419
|
+
// Required-field enforcement
|
|
420
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
421
|
+
function isEffectivelyEmpty(value) {
|
|
422
|
+
if (value === null || value === undefined)
|
|
423
|
+
return true;
|
|
424
|
+
if (typeof value === 'string')
|
|
425
|
+
return cleanString(value).length === 0;
|
|
426
|
+
if (Array.isArray(value))
|
|
427
|
+
return value.length === 0;
|
|
428
|
+
if (isPlainObject(value)) {
|
|
429
|
+
if (isRichtextEnvelopeV1(value))
|
|
430
|
+
return isRichtextDocEmpty(value.doc);
|
|
431
|
+
if (isRichtextDocEmpty(value))
|
|
432
|
+
return true;
|
|
433
|
+
return Object.keys(value).length === 0;
|
|
434
|
+
}
|
|
435
|
+
return false;
|
|
412
436
|
}
|
|
413
|
-
function
|
|
414
|
-
const
|
|
437
|
+
function detectMissingRequired(seed, rawPayload, filtered, parsedData, parseSucceeded, options) {
|
|
438
|
+
const missing = [];
|
|
415
439
|
const details = [];
|
|
416
|
-
const
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
if (!
|
|
420
|
-
|
|
440
|
+
const op = options.operation;
|
|
441
|
+
for (const branch of seed.branches) {
|
|
442
|
+
const isRequired = op === 'create' ? branch.requiredOnCreate : branch.requiredOnUpdate;
|
|
443
|
+
if (!isRequired)
|
|
444
|
+
continue;
|
|
445
|
+
if (!Object.hasOwn(rawPayload, branch.alias)) {
|
|
446
|
+
missing.push(branch.alias);
|
|
421
447
|
details.push({
|
|
422
|
-
field: alias,
|
|
423
|
-
expected: '
|
|
424
|
-
received: '
|
|
425
|
-
message: `Field '${alias}' is
|
|
448
|
+
field: branch.alias,
|
|
449
|
+
expected: 'required-field',
|
|
450
|
+
received: 'missing',
|
|
451
|
+
message: `Field '${branch.alias}' is required for ${op}`,
|
|
426
452
|
});
|
|
427
453
|
continue;
|
|
428
454
|
}
|
|
429
|
-
|
|
455
|
+
const candidate = parseSucceeded ? parsedData[branch.alias] : filtered[branch.alias];
|
|
456
|
+
if (isEffectivelyEmpty(candidate)) {
|
|
457
|
+
missing.push(branch.alias);
|
|
458
|
+
details.push({
|
|
459
|
+
field: branch.alias,
|
|
460
|
+
expected: 'required-field',
|
|
461
|
+
received: 'empty',
|
|
462
|
+
message: `Field '${branch.alias}' cannot be empty for ${op}`,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return { missing, details };
|
|
467
|
+
}
|
|
468
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
469
|
+
// Issue mapping
|
|
470
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
471
|
+
function describeReceivedType(value) {
|
|
472
|
+
if (value === null)
|
|
473
|
+
return 'null';
|
|
474
|
+
if (Array.isArray(value))
|
|
475
|
+
return 'array';
|
|
476
|
+
return typeof value;
|
|
477
|
+
}
|
|
478
|
+
function expectedFromMessage(message) {
|
|
479
|
+
if (typeof message !== 'string')
|
|
480
|
+
return 'valid-field-value';
|
|
481
|
+
return message.startsWith('Expected ') ? message.slice('Expected '.length) : 'valid-field-value';
|
|
482
|
+
}
|
|
483
|
+
function splitUnknownAliases(seed, payload) {
|
|
484
|
+
const knownAliases = new Set(seed.branches.map((branch) => branch.alias));
|
|
485
|
+
const filtered = {};
|
|
486
|
+
const unknown = [];
|
|
487
|
+
const details = [];
|
|
488
|
+
for (const alias of Object.keys(payload)) {
|
|
489
|
+
if (knownAliases.has(alias)) {
|
|
490
|
+
filtered[alias] = payload[alias];
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
unknown.push(alias);
|
|
494
|
+
details.push({
|
|
495
|
+
field: alias,
|
|
496
|
+
expected: 'known-seed-alias',
|
|
497
|
+
received: 'unknown-alias',
|
|
498
|
+
message: `Field '${alias}' is not defined in seed '${seed.slug}'`,
|
|
499
|
+
});
|
|
430
500
|
}
|
|
431
|
-
return {
|
|
501
|
+
return { filtered, unknown, details };
|
|
432
502
|
}
|
|
433
|
-
function
|
|
434
|
-
const unknownAliases = [];
|
|
503
|
+
function processZodIssues(seed, issues, filtered, options) {
|
|
435
504
|
const details = [];
|
|
436
|
-
const
|
|
505
|
+
const unknown = [];
|
|
506
|
+
const dangerous = [];
|
|
437
507
|
for (const issue of issues) {
|
|
438
508
|
if (issue.code === 'unrecognized_keys') {
|
|
439
509
|
for (const alias of issue.keys) {
|
|
440
|
-
|
|
510
|
+
unknown.push(alias);
|
|
441
511
|
details.push({
|
|
442
512
|
field: alias,
|
|
443
513
|
expected: 'known-seed-alias',
|
|
@@ -447,83 +517,68 @@ function collectZodIssueDetails(seed, issues, filteredPayload, normalizedOptions
|
|
|
447
517
|
}
|
|
448
518
|
continue;
|
|
449
519
|
}
|
|
450
|
-
if (issue.message === 'Required' &&
|
|
451
|
-
continue; // Let enforceRequiredFields logic handle it properly
|
|
452
|
-
}
|
|
453
|
-
const field = String(issue.path[0] ?? 'payload');
|
|
454
|
-
const receivedValue = filteredPayload[field];
|
|
455
|
-
const expected = typeof issue.message === 'string' && issue.message.startsWith('Expected ')
|
|
456
|
-
? issue.message.replace('Expected ', '')
|
|
457
|
-
: 'valid-field-value';
|
|
458
|
-
if (issue.params?.dangerous === true) {
|
|
459
|
-
dangerousFields.push(field);
|
|
460
|
-
}
|
|
461
|
-
details.push(makeDetail(field, expected, receivedValue));
|
|
462
|
-
}
|
|
463
|
-
return { unknownAliases, zodDetails: details, dangerousFields };
|
|
464
|
-
}
|
|
465
|
-
function enforceRequiredFields(seed, payload, filteredPayload, data, isSuccess, normalizedOptions) {
|
|
466
|
-
const requiredFieldsMissing = [];
|
|
467
|
-
const details = [];
|
|
468
|
-
for (const branch of seed.branches) {
|
|
469
|
-
const isRequired = normalizedOptions.operation === 'create' ? branch.requiredOnCreate : branch.requiredOnUpdate;
|
|
470
|
-
if (!isRequired)
|
|
471
|
-
continue;
|
|
472
|
-
const hasProvidedAlias = Object.hasOwn(payload, branch.alias);
|
|
473
|
-
if (!hasProvidedAlias) {
|
|
474
|
-
requiredFieldsMissing.push(branch.alias);
|
|
475
|
-
details.push({
|
|
476
|
-
field: branch.alias,
|
|
477
|
-
expected: 'required-field',
|
|
478
|
-
received: 'missing',
|
|
479
|
-
message: `Field '${branch.alias}' is required for ${normalizedOptions.operation}`,
|
|
480
|
-
});
|
|
520
|
+
if (issue.message === 'Required' && options.enforceRequiredFields) {
|
|
481
521
|
continue;
|
|
482
522
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
field: branch.alias,
|
|
488
|
-
expected: 'required-field',
|
|
489
|
-
received: 'empty',
|
|
490
|
-
message: `Field '${branch.alias}' cannot be empty for ${normalizedOptions.operation}`,
|
|
491
|
-
});
|
|
523
|
+
const field = String(issue.path[0] ?? 'payload');
|
|
524
|
+
const params = issue.params;
|
|
525
|
+
if (params?.dangerous === true) {
|
|
526
|
+
dangerous.push(field);
|
|
492
527
|
}
|
|
528
|
+
details.push({
|
|
529
|
+
field,
|
|
530
|
+
expected: expectedFromMessage(issue.message),
|
|
531
|
+
received: describeReceivedType(filtered[field]),
|
|
532
|
+
message: `Field '${field}' expects type '${expectedFromMessage(issue.message)}' but received '${describeReceivedType(filtered[field])}'`,
|
|
533
|
+
});
|
|
493
534
|
}
|
|
494
|
-
return {
|
|
535
|
+
return { details, unknown, dangerous };
|
|
495
536
|
}
|
|
537
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
538
|
+
// Public entry points
|
|
539
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
496
540
|
/**
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* Hashing occurs AFTER validation, not before.
|
|
503
|
-
* This ensures that the validated value is the original raw value,
|
|
504
|
-
* not the digest — avoiding false failures on fields with formats (e.g., email).
|
|
541
|
+
* Schema-driven validation and sanitization of a seed payload.
|
|
542
|
+
* - Drops unknown aliases (reports them as details).
|
|
543
|
+
* - Runs branch-specific schemas to coerce / sanitize known values.
|
|
544
|
+
* - Optionally enforces required-on-create / required-on-update branches.
|
|
545
|
+
* - Never throws on malformed input — returns a structured result.
|
|
505
546
|
*/
|
|
506
547
|
export function validateAndSanitizeSeedPayload(seed, payload, options = {}) {
|
|
507
|
-
const
|
|
548
|
+
const resolved = {
|
|
508
549
|
allowNull: options.allowNull ?? false,
|
|
509
550
|
operation: options.operation ?? 'create',
|
|
510
551
|
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
511
552
|
enforceRequiredFields: options.enforceRequiredFields ?? true,
|
|
512
553
|
maxTextLength: options.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
|
|
554
|
+
idGenerator: options.idGenerator,
|
|
513
555
|
};
|
|
514
|
-
const {
|
|
515
|
-
const schema = compileSeedSchema(seed,
|
|
516
|
-
const parsed = schema.safeParse(
|
|
517
|
-
const
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
556
|
+
const { filtered, unknown: preUnknown, details: preDetails } = splitUnknownAliases(seed, payload);
|
|
557
|
+
const schema = compileSeedSchema(seed, resolved);
|
|
558
|
+
const parsed = schema.safeParse(filtered);
|
|
559
|
+
const accumulatedDetails = [...preDetails];
|
|
560
|
+
const unknownAliases = new Set(preUnknown);
|
|
561
|
+
const dangerousFields = new Set();
|
|
562
|
+
let data = {};
|
|
563
|
+
if (parsed.success) {
|
|
564
|
+
data = { ...parsed.data };
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
const zodOutcome = processZodIssues(seed, parsed.error.issues, filtered, resolved);
|
|
568
|
+
accumulatedDetails.push(...zodOutcome.details);
|
|
569
|
+
for (const alias of zodOutcome.unknown)
|
|
570
|
+
unknownAliases.add(alias);
|
|
571
|
+
for (const field of zodOutcome.dangerous)
|
|
572
|
+
dangerousFields.add(field);
|
|
573
|
+
}
|
|
574
|
+
let requiredFieldsMissing = [];
|
|
575
|
+
if (resolved.enforceRequiredFields) {
|
|
576
|
+
const requiredOutcome = detectMissingRequired(seed, payload, filtered, data, parsed.success, resolved);
|
|
577
|
+
requiredFieldsMissing = requiredOutcome.missing;
|
|
578
|
+
accumulatedDetails.push(...requiredOutcome.details);
|
|
579
|
+
}
|
|
580
|
+
if (resolved.requireAtLeastOneValidField && Object.keys(data).length === 0) {
|
|
581
|
+
accumulatedDetails.push({
|
|
527
582
|
field: 'data',
|
|
528
583
|
expected: 'at-least-one-valid-field',
|
|
529
584
|
received: 'empty',
|
|
@@ -532,22 +587,16 @@ export function validateAndSanitizeSeedPayload(seed, payload, options = {}) {
|
|
|
532
587
|
}
|
|
533
588
|
return {
|
|
534
589
|
data,
|
|
535
|
-
details,
|
|
536
|
-
unknownAliases: [...
|
|
537
|
-
dangerousFields: [...
|
|
590
|
+
details: accumulatedDetails,
|
|
591
|
+
unknownAliases: [...unknownAliases],
|
|
592
|
+
dangerousFields: [...dangerousFields],
|
|
538
593
|
requiredFieldsMissing,
|
|
539
594
|
hasAnyValidField: Object.keys(data).length > 0,
|
|
540
595
|
};
|
|
541
596
|
}
|
|
542
597
|
/**
|
|
543
|
-
*
|
|
598
|
+
* Type-guard for the content status enum.
|
|
544
599
|
*/
|
|
545
600
|
export function isValidContentStatus(value) {
|
|
546
601
|
return statusSchema.safeParse(value).success;
|
|
547
602
|
}
|
|
548
|
-
/**
|
|
549
|
-
* Full Zod runtime compiler:
|
|
550
|
-
* - compile per-seed/per-operation with cache
|
|
551
|
-
* - strict unknown aliases
|
|
552
|
-
* - required fields by operation
|
|
553
|
-
*/
|