@beechcms/core 0.4.3 → 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.
Files changed (125) hide show
  1. package/dist/auth/hash-provider.d.ts.map +1 -1
  2. package/dist/auth/hash-provider.js +2 -0
  3. package/dist/auth/password-reset-token.repository.d.ts.map +1 -1
  4. package/dist/auth/password-reset-token.repository.js +2 -0
  5. package/dist/auth/session.repository.d.ts.map +1 -1
  6. package/dist/auth/session.repository.js +2 -0
  7. package/dist/auth/token-service.d.ts +2 -0
  8. package/dist/auth/token-service.d.ts.map +1 -1
  9. package/dist/auth/token-service.js +2 -0
  10. package/dist/auth/user.repository.d.ts +4 -1
  11. package/dist/auth/user.repository.d.ts.map +1 -1
  12. package/dist/auth/user.repository.js +2 -0
  13. package/dist/automations-grammar-words.d.ts.map +1 -1
  14. package/dist/automations-grammar-words.js +2 -0
  15. package/dist/automations.repository.interface.d.ts.map +1 -1
  16. package/dist/automations.repository.interface.js +2 -0
  17. package/dist/automations.runner.interface.d.ts.map +1 -1
  18. package/dist/automations.runner.interface.js +2 -0
  19. package/dist/automations.runner.stub.d.ts.map +1 -1
  20. package/dist/automations.runner.stub.js +2 -0
  21. package/dist/automations.types.d.ts +2 -0
  22. package/dist/automations.types.d.ts.map +1 -1
  23. package/dist/automations.types.js +2 -0
  24. package/dist/clock.d.ts.map +1 -1
  25. package/dist/clock.js +2 -0
  26. package/dist/content-scan.repository.d.ts.map +1 -1
  27. package/dist/content-scan.repository.js +2 -0
  28. package/dist/content.repository.d.ts +62 -0
  29. package/dist/content.repository.d.ts.map +1 -1
  30. package/dist/content.repository.js +18 -0
  31. package/dist/define-seed.d.ts.map +1 -1
  32. package/dist/define-seed.js +2 -0
  33. package/dist/demo-data.repository.d.ts +5 -0
  34. package/dist/demo-data.repository.d.ts.map +1 -0
  35. package/dist/demo-data.repository.js +3 -0
  36. package/dist/engine.d.ts +76 -0
  37. package/dist/engine.d.ts.map +1 -1
  38. package/dist/engine.js +253 -8
  39. package/dist/file-types.d.ts +13 -0
  40. package/dist/file-types.d.ts.map +1 -0
  41. package/dist/file-types.js +85 -0
  42. package/dist/id-generator.d.ts +7 -0
  43. package/dist/id-generator.d.ts.map +1 -1
  44. package/dist/id-generator.js +5 -0
  45. package/dist/idempotency.repository.d.ts.map +1 -1
  46. package/dist/idempotency.repository.js +2 -0
  47. package/dist/index.d.ts +12 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +14 -1
  50. package/dist/layout-permissions.d.ts +3 -0
  51. package/dist/layout-permissions.d.ts.map +1 -0
  52. package/dist/layout-permissions.js +11 -0
  53. package/dist/media.repository.d.ts.map +1 -1
  54. package/dist/media.repository.js +2 -0
  55. package/dist/notifications/notification-service.d.ts.map +1 -1
  56. package/dist/notifications/notification-service.js +2 -0
  57. package/dist/notifications/notification.repository.d.ts.map +1 -1
  58. package/dist/notifications/notification.repository.js +2 -9
  59. package/dist/observability/activity-log.repository.d.ts.map +1 -1
  60. package/dist/observability/activity-log.repository.js +2 -0
  61. package/dist/observability/activity-logger.d.ts +2 -2
  62. package/dist/observability/activity-logger.d.ts.map +1 -1
  63. package/dist/observability/activity-logger.js +2 -11
  64. package/dist/observability/analytics.repository.d.ts.map +1 -1
  65. package/dist/observability/analytics.repository.js +2 -0
  66. package/dist/policies.d.ts.map +1 -1
  67. package/dist/policies.js +2 -0
  68. package/dist/rate-limit/rate-limiter.d.ts.map +1 -1
  69. package/dist/rate-limit/rate-limiter.js +2 -0
  70. package/dist/relations.d.ts +28 -0
  71. package/dist/relations.d.ts.map +1 -0
  72. package/dist/relations.js +25 -0
  73. package/dist/richtext-render.d.ts.map +1 -1
  74. package/dist/richtext-render.js +2 -0
  75. package/dist/richtext.d.ts.map +1 -1
  76. package/dist/richtext.js +2 -0
  77. package/dist/scheduler.interface.d.ts.map +1 -1
  78. package/dist/scheduler.interface.js +2 -0
  79. package/dist/scheduler.stub.d.ts.map +1 -1
  80. package/dist/scheduler.stub.js +2 -0
  81. package/dist/schema-mutator.d.ts +22 -0
  82. package/dist/schema-mutator.d.ts.map +1 -0
  83. package/dist/schema-mutator.js +3 -0
  84. package/dist/search/search.repository.d.ts.map +1 -1
  85. package/dist/search/search.repository.js +2 -0
  86. package/dist/seed-ddl.d.ts +40 -0
  87. package/dist/seed-ddl.d.ts.map +1 -0
  88. package/dist/seed-ddl.js +100 -0
  89. package/dist/seed-layout.d.ts +117 -0
  90. package/dist/seed-layout.d.ts.map +1 -0
  91. package/dist/seed-layout.js +169 -0
  92. package/dist/seed-layout.repository.d.ts +18 -0
  93. package/dist/seed-layout.repository.d.ts.map +1 -0
  94. package/dist/seed-layout.repository.js +3 -0
  95. package/dist/seed-registry.d.ts +23 -1
  96. package/dist/seed-registry.d.ts.map +1 -1
  97. package/dist/seed-registry.js +105 -0
  98. package/dist/seed-validation.d.ts +23 -0
  99. package/dist/seed-validation.d.ts.map +1 -0
  100. package/dist/seed-validation.js +156 -0
  101. package/dist/seed.repository.d.ts +37 -0
  102. package/dist/seed.repository.d.ts.map +1 -0
  103. package/dist/seed.repository.js +3 -0
  104. package/dist/seeds.d.ts.map +1 -1
  105. package/dist/seeds.js +2 -0
  106. package/dist/site-settings.repository.d.ts +16 -0
  107. package/dist/site-settings.repository.d.ts.map +1 -0
  108. package/dist/site-settings.repository.js +3 -0
  109. package/dist/slug-utils.d.ts.map +1 -1
  110. package/dist/slug-utils.js +2 -0
  111. package/dist/storage.d.ts +12 -38
  112. package/dist/storage.d.ts.map +1 -1
  113. package/dist/storage.js +2 -0
  114. package/dist/types.d.ts +80 -1
  115. package/dist/types.d.ts.map +1 -1
  116. package/dist/types.js +2 -0
  117. package/dist/validation.d.ts +23 -17
  118. package/dist/validation.d.ts.map +1 -1
  119. package/dist/validation.js +495 -393
  120. package/dist/webhook-validation.d.ts +3 -0
  121. package/dist/webhook-validation.d.ts.map +1 -0
  122. package/dist/webhook-validation.js +6 -0
  123. package/dist/widget/widget.repository.d.ts.map +1 -1
  124. package/dist/widget/widget.repository.js +2 -0
  125. package/package.json +5 -4
@@ -1,476 +1,584 @@
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
- const DEFAULT_MAX_TEXT_LENGTH = 50000;
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 DANGEROUS_RICHTEXT_NODE_TYPES = new Set(['script', 'iframe', 'object', 'embed']);
9
- const LINK_LIKE_RICHTEXT_ATTRS = new Set(['href', 'src']);
10
- const statusSchema = z.enum(['draft', 'review', 'published']);
11
- const finiteNumberSchema = z.number().refine(Number.isFinite, 'Expected finite number');
12
- const stringSchema = z.string();
13
- const booleanSchema = z.boolean();
14
- const jsonObjectOrArraySchema = z.union([z.record(z.string(), z.unknown()), z.array(z.unknown())]);
15
- function isAssetListBranch(branch) {
16
- return branch.type === 'file' && (branch.multiple === true || branch.format === 'asset-list');
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 normalizeHttpUrl(value) {
19
- if (!stringSchema.safeParse(value).success) {
20
- return null;
21
- }
22
- const cleaned = sanitizePlainString(value);
23
- if (!cleaned) {
24
- return null;
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
- const parsed = new URL(cleaned);
28
- if (!parsed.protocol.startsWith('http')) {
29
- return null;
30
- }
32
+ return JSON.parse(raw);
31
33
  }
32
34
  catch {
33
- return null;
35
+ return raw;
34
36
  }
35
- return cleaned;
36
37
  }
37
- function parseJsonString(value) {
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
- return JSON.parse(value);
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 [...new Set(normalized)];
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
- function sanitizePlainString(value) {
72
- return value.replaceAll(CONTROL_CHARS_REGEX, '').trim();
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 collectRichtextVisibleText(value, chunks) {
75
- if (Array.isArray(value)) {
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 isRichtextDocEmpty(value) {
95
- if (!isPlainObject(value))
96
- return false;
97
- if (isRichtextEnvelopeV1(value)) {
98
- return isRichtextDocEmpty(value.doc);
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
- if (value.type !== 'doc')
101
- return false;
102
- const chunks = [];
103
- collectRichtextVisibleText(value, chunks);
104
- return chunks.join('').trim().length === 0;
81
+ return null;
105
82
  }
106
- function isMissingRequiredValue(value) {
107
- if (value == null)
108
- return true;
109
- if (typeof value === 'string')
110
- return sanitizePlainString(value).length === 0;
111
- if (Array.isArray(value))
112
- return value.length === 0;
113
- if (isPlainObject(value)) {
114
- if (isRichtextEnvelopeV1(value)) {
115
- return isRichtextDocEmpty(value.doc);
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 false;
99
+ return out;
122
100
  }
123
- function sanitizeRichtextString(value) {
124
- const noControl = value.replaceAll(CONTROL_CHARS_REGEX, '');
125
- const dangerous = DANGEROUS_TAG_REGEX.test(noControl) || DANGEROUS_ATTR_REGEX.test(noControl);
126
- // Best-effort sanitization for Sprint 02; complete policy tracked in TODOs below.
127
- const strippedTags = noControl.replaceAll(/<\/?(script|iframe|object|embed)[^>]*>/gi, '');
128
- const strippedHandlers = strippedTags.replaceAll(/\son[a-z]+\s*=\s*(['"]).*?\1/gi, '');
129
- const nextValue = strippedHandlers.trim();
130
- return { value: nextValue, dangerous, size: nextValue.length };
131
- }
132
- function sanitizeRichtextJsonNode(value, state) {
133
- if (typeof value === 'string') {
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(value)) {
141
- return value.map((item) => sanitizeRichtextJsonNode(item, state));
142
- }
143
- if (!isPlainObject(value)) {
144
- return value;
117
+ if (Array.isArray(node)) {
118
+ return node.map((child) => walkRichtextNode(child, state));
145
119
  }
146
- const next = {};
147
- for (const [key, rawEntry] of Object.entries(value)) {
148
- const loweredKey = key.toLowerCase();
149
- if (loweredKey.startsWith('on')) {
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 (loweredKey === 'type' &&
153
- typeof rawEntry === 'string' &&
154
- DANGEROUS_RICHTEXT_NODE_TYPES.has(rawEntry.toLowerCase())) {
155
- state.dangerous = true;
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 (LINK_LIKE_RICHTEXT_ATTRS.has(loweredKey) &&
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
- next[key] = sanitizeRichtextJsonNode(rawEntry, state);
136
+ result[key] = walkRichtextNode(entry, state);
163
137
  }
164
- return next;
138
+ return result;
165
139
  }
166
- function sanitizeRichtextJson(value) {
140
+ function sanitizeRichtextJson(raw) {
167
141
  const state = { dangerous: false };
168
- const sanitized = sanitizeRichtextJsonNode(value, state);
169
- const asObject = isPlainObject(sanitized) ? sanitized : {};
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 unwrapRichtextPayload(value) {
180
- if (isRichtextEnvelopeV1(value)) {
181
- return { inner: value.doc, wrapAsEnvelope: true };
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
- return { inner: value, wrapAsEnvelope: false };
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 outValue = wrapAsEnvelope
159
+ const finalValue = envelopeMode
207
160
  ? { schemaVersion: RICHTEXT_SCHEMA_VERSION, doc: jsonResult.value }
208
161
  : jsonResult.value;
209
162
  return {
210
- value: outValue,
163
+ value: finalValue,
211
164
  dangerous: jsonResult.dangerous,
212
165
  valid: true,
213
- size: JSON.stringify(outValue).length,
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 requiredAliases(seed, operation) {
224
- return seed.branches
225
- .filter((branch) => (operation === 'create' ? branch.requiredOnCreate : branch.requiredOnUpdate))
226
- .map((branch) => branch.alias);
227
- }
228
- function buildBranchSchema(branch, options) {
229
- const nullable = options.allowNull ? z.null() : null;
230
- const preprocessEmpty = (schema) => z.preprocess((val) => (val === '' || val === null ? (options.allowNull ? null : undefined) : val), schema.optional());
231
- switch (branch.type) {
232
- case 'text': {
233
- const schema = stringSchema
234
- .transform((value) => sanitizePlainString(value))
235
- .refine((value) => value.length <= options.maxTextLength, {
236
- message: `Expected string(max:${options.maxTextLength})`,
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
+ }
189
+ }
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());
211
+ }
212
+ function textSchema(options, allowNull) {
213
+ const inner = z
214
+ .string()
215
+ .transform((value) => cleanString(value))
216
+ .refine((value) => value.length <= options.maxTextLength, {
217
+ message: `Expected string(max:${options.maxTextLength})`,
218
+ });
219
+ return withNullable(inner, allowNull);
220
+ }
221
+ function richtextSchema(options, allowNull) {
222
+ const inner = z.any().transform((value, ctx) => {
223
+ const sanitized = sanitizeRichtext(value);
224
+ if (!sanitized.valid) {
225
+ ctx.addIssue({
226
+ code: 'custom',
227
+ message: 'Expected richtext-json|string',
228
+ params: { expected: 'richtext-json|string' },
237
229
  });
238
- return nullable ? z.union([schema, nullable]) : schema;
239
230
  }
240
- case 'richtext': {
241
- const schema = z.any().transform((value, ctx) => {
242
- const sanitized = sanitizeRichtext(value);
243
- if (!sanitized.valid) {
244
- ctx.addIssue({
245
- code: 'custom',
246
- message: 'Expected richtext-json|string',
247
- params: { expected: 'richtext-json|string' },
248
- });
249
- }
250
- if (sanitized.size > options.maxTextLength) {
251
- ctx.addIssue({
252
- code: 'custom',
253
- message: `Expected richtext(max:${options.maxTextLength})`,
254
- params: { expected: `richtext(max:${options.maxTextLength})` },
255
- });
256
- }
257
- if (sanitized.dangerous) {
258
- ctx.addIssue({
259
- code: 'custom',
260
- message: 'Dangerous richtext content',
261
- params: { dangerous: true },
262
- });
263
- }
264
- return sanitized.value;
231
+ if (sanitized.size > options.maxTextLength) {
232
+ ctx.addIssue({
233
+ code: 'custom',
234
+ message: `Expected richtext(max:${options.maxTextLength})`,
235
+ params: { expected: `richtext(max:${options.maxTextLength})` },
265
236
  });
266
- return nullable ? z.union([schema, nullable]) : schema;
267
237
  }
268
- case 'number': {
269
- const schema = preprocessEmpty(finiteNumberSchema);
270
- return nullable ? z.union([schema, nullable]) : schema;
238
+ if (sanitized.dangerous) {
239
+ ctx.addIssue({
240
+ code: 'custom',
241
+ message: 'Dangerous richtext content',
242
+ params: { dangerous: true },
243
+ });
244
+ }
245
+ return sanitized.value;
246
+ });
247
+ return withNullable(inner, allowNull);
248
+ }
249
+ function numberSchema(branch, allowNull) {
250
+ const opts = branch.numberOptions;
251
+ const base = z.number().superRefine((val, ctx) => {
252
+ if (!Number.isFinite(val)) {
253
+ ctx.addIssue({ code: 'custom', message: 'Expected finite number' });
254
+ return;
271
255
  }
272
- case 'boolean': {
273
- const schema = preprocessEmpty(booleanSchema);
274
- return nullable ? z.union([schema, nullable]) : schema;
256
+ if (opts?.min !== undefined && val < opts.min) {
257
+ ctx.addIssue({ code: 'custom', message: `Expected number(min:${opts.min})` });
258
+ return;
275
259
  }
276
- case 'date': {
277
- const schema = preprocessEmpty(stringSchema
278
- .transform((value) => sanitizePlainString(value))
279
- .refine((value) => isIsoDateString(value), { message: 'Expected date(ISO)' }));
280
- return nullable ? z.union([schema, nullable]) : schema;
260
+ if (opts?.max !== undefined && val > opts.max) {
261
+ ctx.addIssue({ code: 'custom', message: `Expected number(max:${opts.max})` });
262
+ return;
281
263
  }
282
- case 'json':
283
- case 'tags': {
284
- const schema = preprocessEmpty(jsonObjectOrArraySchema);
285
- return nullable ? z.union([schema, nullable]) : schema;
264
+ if (opts?.step !== undefined) {
265
+ const step = opts.step;
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) {
273
+ ctx.addIssue({ code: 'custom', message: `Expected number(step:${step})` });
274
+ }
286
275
  }
287
- case 'file': {
288
- if (isAssetListBranch(branch)) {
289
- const schema = preprocessEmpty(z.any()
290
- .transform((rawValue, ctx) => {
291
- const normalized = normalizeAssetListValue(rawValue);
292
- if (!normalized) {
293
- ctx.addIssue({ code: 'custom', message: 'Expected url-string[]' });
294
- return z.NEVER;
295
- }
296
- return normalized;
297
- })
298
- .pipe(z.array(z.string().url())));
299
- return nullable ? z.union([schema, nullable]) : schema;
276
+ });
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);
288
+ }
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);
306
+ }
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
+ };
318
+ if (isAssetListBranch(branch)) {
319
+ const inner = z
320
+ .any()
321
+ .transform((raw, ctx) => {
322
+ const urls = collectAssetListUrls(raw);
323
+ if (urls === null) {
324
+ ctx.addIssue({ code: 'custom', message: 'Expected url-string[]' });
325
+ return z.NEVER;
300
326
  }
301
- const schema = preprocessEmpty(z.any()
302
- .transform((rawValue, ctx) => {
303
- const normalized = normalizeHttpUrl(rawValue);
304
- if (!normalized) {
305
- ctx.addIssue({ code: 'custom', message: 'Expected url-string' });
327
+ for (const url of urls) {
328
+ if (!verifyExtension(url, ctx))
306
329
  return z.NEVER;
307
- }
308
- return normalized;
309
- })
310
- .pipe(z.string().url()));
311
- return nullable ? z.union([schema, nullable]) : schema;
330
+ }
331
+ return urls;
332
+ })
333
+ .pipe(z.array(z.string().url()));
334
+ return withNullable(withEmptyPreprocessing(inner, allowNull), allowNull);
335
+ }
336
+ const inner = z
337
+ .any()
338
+ .transform((raw, ctx) => {
339
+ const url = parseHttpUrl(raw);
340
+ if (!url) {
341
+ ctx.addIssue({ code: 'custom', message: 'Expected url-string' });
342
+ return z.NEVER;
312
343
  }
313
- default:
314
- throw new Error(`Unhandled branch type: ${branch.type}`);
344
+ if (!verifyExtension(url, ctx))
345
+ return z.NEVER;
346
+ return url;
347
+ })
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}`);
315
366
  }
367
+ return builder(branch, options);
316
368
  }
317
- const compiledSchemaCache = new Map();
318
- function seedFingerprint(seed) {
319
- return JSON.stringify({
320
- slug: seed.slug,
321
- branches: seed.branches.map((branch) => ({
322
- alias: branch.alias,
323
- type: branch.type,
324
- format: branch.format ?? null,
325
- multiple: branch.multiple ?? false,
326
- requiredOnCreate: branch.requiredOnCreate ?? false,
327
- requiredOnUpdate: branch.requiredOnUpdate ?? false,
328
- })),
329
- });
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('|');
330
393
  }
331
394
  function compileSeedSchema(seed, options) {
332
- const key = JSON.stringify({
333
- fingerprint: seedFingerprint(seed),
334
- operation: options.operation,
335
- allowNull: options.allowNull,
336
- maxTextLength: options.maxTextLength,
337
- });
338
- const cached = compiledSchemaCache.get(key);
339
- if (cached)
340
- return cached;
341
- const required = new Set(requiredAliases(seed, options.operation));
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';
342
406
  const shape = {};
343
407
  for (const branch of seed.branches) {
344
- const baseSchema = buildBranchSchema(branch, options);
345
- shape[branch.alias] = required.has(branch.alias) ? baseSchema : baseSchema.optional();
408
+ const branchSchema = schemaForBranch(branch, options);
409
+ shape[branch.alias] = branch[requiredFlag] ? branchSchema : branchSchema.optional();
346
410
  }
347
411
  const compiled = z.object(shape).strict();
348
- compiledSchemaCache.set(key, compiled);
412
+ if (!hasRelation) {
413
+ const key = buildCacheKey(seed, options);
414
+ seedSchemaCache.set(key, compiled);
415
+ }
349
416
  return compiled;
350
417
  }
351
- function makeDetail(field, expected, received) {
352
- let receivedType = typeof received;
353
- if (received === null)
354
- receivedType = 'null';
355
- else if (Array.isArray(received))
356
- receivedType = 'array';
357
- return {
358
- field,
359
- expected,
360
- received: receivedType,
361
- message: `Field '${field}' expects type '${expected}' but received '${receivedType}'`,
362
- };
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;
363
436
  }
364
- function isIsoDateString(value) {
365
- if (!/^\d{4}-\d{2}-\d{2}/.test(value))
366
- return false;
367
- const parsed = Date.parse(value);
368
- if (Number.isNaN(parsed))
369
- return false;
370
- const d = new Date(parsed);
371
- return d.toISOString().startsWith(value.substring(0, 10));
437
+ function detectMissingRequired(seed, rawPayload, filtered, parsedData, parseSucceeded, options) {
438
+ const missing = [];
439
+ const details = [];
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);
447
+ details.push({
448
+ field: branch.alias,
449
+ expected: 'required-field',
450
+ received: 'missing',
451
+ message: `Field '${branch.alias}' is required for ${op}`,
452
+ });
453
+ continue;
454
+ }
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
+ });
500
+ }
501
+ return { filtered, unknown, details };
502
+ }
503
+ function processZodIssues(seed, issues, filtered, options) {
504
+ const details = [];
505
+ const unknown = [];
506
+ const dangerous = [];
507
+ for (const issue of issues) {
508
+ if (issue.code === 'unrecognized_keys') {
509
+ for (const alias of issue.keys) {
510
+ unknown.push(alias);
511
+ details.push({
512
+ field: alias,
513
+ expected: 'known-seed-alias',
514
+ received: 'unknown-alias',
515
+ message: `Field '${alias}' is not defined in seed '${seed.slug}'`,
516
+ });
517
+ }
518
+ continue;
519
+ }
520
+ if (issue.message === 'Required' && options.enforceRequiredFields) {
521
+ continue;
522
+ }
523
+ const field = String(issue.path[0] ?? 'payload');
524
+ const params = issue.params;
525
+ if (params?.dangerous === true) {
526
+ dangerous.push(field);
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
+ });
534
+ }
535
+ return { details, unknown, dangerous };
372
536
  }
537
+ // ──────────────────────────────────────────────────────────────────────────────
538
+ // Public entry points
539
+ // ──────────────────────────────────────────────────────────────────────────────
373
540
  /**
374
- * Common foundation for schema-driven validation and sanitization of payloads.
375
- * Used by the Public API and reusable in the Botanical Engine.
376
- *
377
- * Mandatory execution order at the time of writing:
378
- * validate raw hash (privacy policy) store
379
- * Hashing occurs AFTER validation, not before.
380
- * This ensures that the validated value is the original raw value,
381
- * 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.
382
546
  */
383
547
  export function validateAndSanitizeSeedPayload(seed, payload, options = {}) {
384
- const normalizedOptions = {
548
+ const resolved = {
385
549
  allowNull: options.allowNull ?? false,
386
550
  operation: options.operation ?? 'create',
387
551
  requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
388
552
  enforceRequiredFields: options.enforceRequiredFields ?? true,
389
553
  maxTextLength: options.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
554
+ idGenerator: options.idGenerator,
390
555
  };
391
- const details = [];
392
- const data = {};
393
- const unknownAliases = [];
394
- const dangerousFields = [];
395
- const requiredFieldsMissing = [];
396
- const allowedAliases = new Set(seed.branches.map((branch) => branch.alias));
397
- const filteredPayload = {};
398
- for (const [alias, rawValue] of Object.entries(payload)) {
399
- if (!allowedAliases.has(alias)) {
400
- unknownAliases.push(alias);
401
- details.push({
402
- field: alias,
403
- expected: 'known-seed-alias',
404
- received: 'unknown-alias',
405
- message: `Field '${alias}' is not defined in seed '${seed.slug}'`,
406
- });
407
- continue;
408
- }
409
- filteredPayload[alias] = rawValue;
410
- }
411
- const schema = compileSeedSchema(seed, normalizedOptions);
412
- const parsed = schema.safeParse(filteredPayload);
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 = {};
413
563
  if (parsed.success) {
414
- Object.assign(data, parsed.data);
564
+ data = { ...parsed.data };
415
565
  }
416
566
  else {
417
- for (const issue of parsed.error.issues) {
418
- if (issue.code === 'unrecognized_keys') {
419
- for (const alias of issue.keys) {
420
- unknownAliases.push(alias);
421
- details.push({
422
- field: alias,
423
- expected: 'known-seed-alias',
424
- received: 'unknown-alias',
425
- message: `Field '${alias}' is not defined in seed '${seed.slug}'`,
426
- });
427
- }
428
- continue;
429
- }
430
- if (issue.message === 'Required' && normalizedOptions.enforceRequiredFields) {
431
- continue; // Let enforceRequiredFields logic below handle it properly with detailed messages
432
- }
433
- const field = String(issue.path[0] ?? 'payload');
434
- const receivedValue = filteredPayload[field];
435
- const expected = typeof issue.message === 'string' && issue.message.startsWith('Expected ')
436
- ? issue.message.replace('Expected ', '')
437
- : 'valid-field-value';
438
- if (issue.params?.dangerous === true) {
439
- dangerousFields.push(field);
440
- }
441
- details.push(makeDetail(field, expected, receivedValue));
442
- }
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);
443
573
  }
444
- if (normalizedOptions.enforceRequiredFields) {
445
- for (const branch of seed.branches) {
446
- const isRequired = normalizedOptions.operation === 'create' ? branch.requiredOnCreate : branch.requiredOnUpdate;
447
- if (!isRequired)
448
- continue;
449
- const hasProvidedAlias = Object.hasOwn(payload, branch.alias);
450
- if (!hasProvidedAlias) {
451
- requiredFieldsMissing.push(branch.alias);
452
- details.push({
453
- field: branch.alias,
454
- expected: 'required-field',
455
- received: 'missing',
456
- message: `Field '${branch.alias}' is required for ${normalizedOptions.operation}`,
457
- });
458
- continue;
459
- }
460
- const valueToCheck = parsed.success ? data[branch.alias] : filteredPayload[branch.alias];
461
- if (isMissingRequiredValue(valueToCheck)) {
462
- requiredFieldsMissing.push(branch.alias);
463
- details.push({
464
- field: branch.alias,
465
- expected: 'required-field',
466
- received: 'empty',
467
- message: `Field '${branch.alias}' cannot be empty for ${normalizedOptions.operation}`,
468
- });
469
- }
470
- }
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);
471
579
  }
472
- if (normalizedOptions.requireAtLeastOneValidField && Object.keys(data).length === 0) {
473
- details.push({
580
+ if (resolved.requireAtLeastOneValidField && Object.keys(data).length === 0) {
581
+ accumulatedDetails.push({
474
582
  field: 'data',
475
583
  expected: 'at-least-one-valid-field',
476
584
  received: 'empty',
@@ -479,22 +587,16 @@ export function validateAndSanitizeSeedPayload(seed, payload, options = {}) {
479
587
  }
480
588
  return {
481
589
  data,
482
- details,
483
- unknownAliases: [...new Set(unknownAliases)],
484
- dangerousFields: [...new Set(dangerousFields)],
590
+ details: accumulatedDetails,
591
+ unknownAliases: [...unknownAliases],
592
+ dangerousFields: [...dangerousFields],
485
593
  requiredFieldsMissing,
486
594
  hasAnyValidField: Object.keys(data).length > 0,
487
595
  };
488
596
  }
489
597
  /**
490
- * Validates content status supported by the CMS.
598
+ * Type-guard for the content status enum.
491
599
  */
492
600
  export function isValidContentStatus(value) {
493
601
  return statusSchema.safeParse(value).success;
494
602
  }
495
- /**
496
- * Full Zod runtime compiler:
497
- * - compile per-seed/per-operation with cache
498
- * - strict unknown aliases
499
- * - required fields by operation
500
- */