@beechcms/core 0.5.0 → 0.6.0-preview.2

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 (141) 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/dashboard-layout.d.ts +154 -0
  32. package/dist/dashboard-layout.d.ts.map +1 -0
  33. package/dist/dashboard-layout.js +186 -0
  34. package/dist/dashboard-layout.repository.d.ts +18 -0
  35. package/dist/dashboard-layout.repository.d.ts.map +1 -0
  36. package/dist/dashboard-layout.repository.js +3 -0
  37. package/dist/dashboard-permissions.d.ts +3 -0
  38. package/dist/dashboard-permissions.d.ts.map +1 -0
  39. package/dist/dashboard-permissions.js +11 -0
  40. package/dist/dashboard-scopes.d.ts +12 -0
  41. package/dist/dashboard-scopes.d.ts.map +1 -0
  42. package/dist/dashboard-scopes.js +30 -0
  43. package/dist/define-seed.d.ts.map +1 -1
  44. package/dist/define-seed.js +2 -0
  45. package/dist/demo-data.repository.d.ts +5 -0
  46. package/dist/demo-data.repository.d.ts.map +1 -0
  47. package/dist/demo-data.repository.js +3 -0
  48. package/dist/engine.d.ts +76 -0
  49. package/dist/engine.d.ts.map +1 -1
  50. package/dist/engine.js +259 -5
  51. package/dist/file-types.d.ts +13 -0
  52. package/dist/file-types.d.ts.map +1 -0
  53. package/dist/file-types.js +85 -0
  54. package/dist/id-generator.d.ts +7 -0
  55. package/dist/id-generator.d.ts.map +1 -1
  56. package/dist/id-generator.js +5 -0
  57. package/dist/idempotency.repository.d.ts.map +1 -1
  58. package/dist/idempotency.repository.js +2 -0
  59. package/dist/index.d.ts +16 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +18 -1
  62. package/dist/layout-permissions.d.ts +3 -0
  63. package/dist/layout-permissions.d.ts.map +1 -0
  64. package/dist/layout-permissions.js +11 -0
  65. package/dist/media.repository.d.ts.map +1 -1
  66. package/dist/media.repository.js +2 -0
  67. package/dist/notifications/notification-service.d.ts.map +1 -1
  68. package/dist/notifications/notification-service.js +2 -0
  69. package/dist/notifications/notification.repository.d.ts.map +1 -1
  70. package/dist/notifications/notification.repository.js +2 -9
  71. package/dist/observability/activity-log.repository.d.ts.map +1 -1
  72. package/dist/observability/activity-log.repository.js +2 -0
  73. package/dist/observability/activity-logger.d.ts +2 -2
  74. package/dist/observability/activity-logger.d.ts.map +1 -1
  75. package/dist/observability/activity-logger.js +2 -11
  76. package/dist/observability/analytics.repository.d.ts.map +1 -1
  77. package/dist/observability/analytics.repository.js +2 -0
  78. package/dist/policies.d.ts.map +1 -1
  79. package/dist/policies.js +8 -3
  80. package/dist/rate-limit/rate-limiter.d.ts.map +1 -1
  81. package/dist/rate-limit/rate-limiter.js +2 -0
  82. package/dist/relations.d.ts +28 -0
  83. package/dist/relations.d.ts.map +1 -0
  84. package/dist/relations.js +25 -0
  85. package/dist/richtext-render.d.ts.map +1 -1
  86. package/dist/richtext-render.js +2 -0
  87. package/dist/richtext.d.ts.map +1 -1
  88. package/dist/richtext.js +2 -0
  89. package/dist/scheduler.interface.d.ts.map +1 -1
  90. package/dist/scheduler.interface.js +2 -0
  91. package/dist/scheduler.stub.d.ts.map +1 -1
  92. package/dist/scheduler.stub.js +2 -0
  93. package/dist/schema-mutator.d.ts +22 -0
  94. package/dist/schema-mutator.d.ts.map +1 -0
  95. package/dist/schema-mutator.js +3 -0
  96. package/dist/search/search.repository.d.ts.map +1 -1
  97. package/dist/search/search.repository.js +2 -0
  98. package/dist/seed-ddl-destructive.d.ts +40 -0
  99. package/dist/seed-ddl-destructive.d.ts.map +1 -0
  100. package/dist/seed-ddl-destructive.js +149 -0
  101. package/dist/seed-ddl.d.ts +40 -0
  102. package/dist/seed-ddl.d.ts.map +1 -0
  103. package/dist/seed-ddl.js +100 -0
  104. package/dist/seed-layout.d.ts +117 -0
  105. package/dist/seed-layout.d.ts.map +1 -0
  106. package/dist/seed-layout.js +169 -0
  107. package/dist/seed-layout.repository.d.ts +18 -0
  108. package/dist/seed-layout.repository.d.ts.map +1 -0
  109. package/dist/seed-layout.repository.js +3 -0
  110. package/dist/seed-registry.d.ts +23 -1
  111. package/dist/seed-registry.d.ts.map +1 -1
  112. package/dist/seed-registry.js +105 -0
  113. package/dist/seed-validation.d.ts +23 -0
  114. package/dist/seed-validation.d.ts.map +1 -0
  115. package/dist/seed-validation.js +224 -0
  116. package/dist/seed.repository.d.ts +37 -0
  117. package/dist/seed.repository.d.ts.map +1 -0
  118. package/dist/seed.repository.js +3 -0
  119. package/dist/seeds.d.ts.map +1 -1
  120. package/dist/seeds.js +2 -0
  121. package/dist/site-settings.repository.d.ts +16 -0
  122. package/dist/site-settings.repository.d.ts.map +1 -0
  123. package/dist/site-settings.repository.js +3 -0
  124. package/dist/slug-utils.d.ts.map +1 -1
  125. package/dist/slug-utils.js +2 -0
  126. package/dist/storage.d.ts +12 -38
  127. package/dist/storage.d.ts.map +1 -1
  128. package/dist/storage.js +2 -0
  129. package/dist/types.d.ts +81 -1
  130. package/dist/types.d.ts.map +1 -1
  131. package/dist/types.js +2 -0
  132. package/dist/validation.d.ts +23 -17
  133. package/dist/validation.d.ts.map +1 -1
  134. package/dist/validation.js +487 -381
  135. package/dist/webhook-validation.d.ts +3 -0
  136. package/dist/webhook-validation.d.ts.map +1 -0
  137. package/dist/webhook-validation.js +6 -0
  138. package/dist/widget/widget.repository.d.ts +32 -3
  139. package/dist/widget/widget.repository.d.ts.map +1 -1
  140. package/dist/widget/widget.repository.js +6 -1
  141. package/package.json +5 -4
@@ -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
- 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;
100
+ }
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 };
122
108
  }
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, '');
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
- };
178
- }
179
- function unwrapRichtextPayload(value) {
180
- if (isRichtextEnvelopeV1(value)) {
181
- return { inner: value.doc, wrapAsEnvelope: true };
182
- }
183
- return { inner: value, wrapAsEnvelope: false };
146
+ return { value: asObject, dangerous: state.dangerous, valid, size: serialized.length };
184
147
  }
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
- };
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);
195
153
  }
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);
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;
227
201
  }
228
- function getPreprocessEmpty(allowNull) {
229
- return (schema) => z.preprocess((val) => (val === '' || val === null ? (allowNull ? null : undefined) : val), schema.optional());
202
+ // ──────────────────────────────────────────────────────────────────────────────
203
+ // Schema builders per branch type
204
+ // ──────────────────────────────────────────────────────────────────────────────
205
+ function withNullable(schema, allowNull) {
206
+ return allowNull ? z.union([schema, z.null()]) : schema;
230
207
  }
231
- function buildTextSchema(options, nullable) {
232
- const schema = stringSchema
233
- .transform((value) => sanitizePlainString(value))
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))
234
216
  .refine((value) => value.length <= options.maxTextLength, {
235
217
  message: `Expected string(max:${options.maxTextLength})`,
236
218
  });
237
- return nullable ? z.union([schema, nullable]) : schema;
219
+ return withNullable(inner, allowNull);
238
220
  }
239
- function buildRichtextSchema(options, nullable) {
240
- const schema = z.any().transform((value, ctx) => {
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,327 @@ function buildRichtextSchema(options, nullable) {
262
244
  }
263
245
  return sanitized.value;
264
246
  });
265
- return nullable ? z.union([schema, nullable]) : schema;
247
+ return withNullable(inner, allowNull);
266
248
  }
267
- function buildNumberSchema(branch, allowNull, nullable) {
249
+ function numberSchema(branch, allowNull) {
268
250
  const opts = branch.numberOptions;
269
- const numSchema = z.number();
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 z.NEVER;
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 z.NEVER;
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 z.NEVER;
262
+ return;
282
263
  }
283
264
  if (opts?.step !== undefined) {
284
265
  const step = opts.step;
285
- const min = opts.min ?? 0;
286
- const valDecimals = (val.toString().split('.')[1] || '').length;
287
- const stepDecimals = (step.toString().split('.')[1] || '').length;
288
- const decimals = Math.max(valDecimals, stepDecimals);
289
- const multiplier = Math.pow(10, decimals);
290
- if ((Math.round((val - min) * multiplier) % Math.round(step * multiplier)) !== 0) {
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
- const schema = getPreprocessEmpty(allowNull)(refinedSchema);
296
- return nullable ? z.union([schema, nullable]) : schema;
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 buildDateSchema(allowNull, nullable) {
299
- const schema = getPreprocessEmpty(allowNull)(stringSchema
300
- .transform((value) => sanitizePlainString(value))
301
- .refine((value) => isIsoDateString(value), { message: 'Expected date(ISO)' }));
302
- return nullable ? z.union([schema, nullable]) : schema;
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);
303
292
  }
304
- function buildFileSchema(branch, allowNull, nullable) {
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
+ };
305
318
  if (isAssetListBranch(branch)) {
306
- const schema = getPreprocessEmpty(allowNull)(z.any()
307
- .transform((rawValue, ctx) => {
308
- const normalized = normalizeAssetListValue(rawValue);
309
- if (!normalized) {
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
- return normalized;
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 nullable ? z.union([schema, nullable]) : schema;
333
+ .pipe(z.array(z.string().url()));
334
+ return withNullable(withEmptyPreprocessing(inner, allowNull), allowNull);
317
335
  }
318
- const schema = getPreprocessEmpty(allowNull)(z.any()
319
- .transform((rawValue, ctx) => {
320
- const normalized = normalizeHttpUrl(rawValue);
321
- if (!normalized) {
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
- return normalized;
344
+ if (!verifyExtension(url, ctx))
345
+ return z.NEVER;
346
+ return url;
326
347
  })
327
- .pipe(z.string().url()));
328
- return nullable ? z.union([schema, nullable]) : schema;
329
- }
330
- function buildBranchSchema(branch, options) {
331
- const nullable = options.allowNull ? z.null() : null;
332
- switch (branch.type) {
333
- case 'text':
334
- return buildTextSchema(options, nullable);
335
- case 'richtext':
336
- return buildRichtextSchema(options, nullable);
337
- case 'number':
338
- return buildNumberSchema(branch, options.allowNull, nullable);
339
- case 'boolean': {
340
- const schema = getPreprocessEmpty(options.allowNull)(booleanSchema);
341
- return nullable ? z.union([schema, nullable]) : schema;
342
- }
343
- case 'date':
344
- return buildDateSchema(options.allowNull, nullable);
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}`);
354
- }
355
- }
356
- const compiledSchemaCache = new Map();
357
- function seedFingerprint(seed) {
358
- return JSON.stringify({
359
- slug: seed.slug,
360
- branches: seed.branches.map((branch) => ({
361
- alias: branch.alias,
362
- type: branch.type,
363
- format: branch.format ?? null,
364
- multiple: branch.multiple ?? false,
365
- requiredOnCreate: branch.requiredOnCreate ?? false,
366
- requiredOnUpdate: branch.requiredOnUpdate ?? false,
367
- numberOptions: branch.numberOptions ?? null,
368
- })),
369
- });
348
+ .pipe(z.string().url());
349
+ return withNullable(withEmptyPreprocessing(inner, allowNull), allowNull);
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
+ const REPEATER_DISALLOWED_SUBTYPES = new Set(['repeater', 'relation', 'file']);
354
+ function repeaterSchema(branch, options) {
355
+ const requiredFlag = options.operation === 'create' ? 'requiredOnCreate' : 'requiredOnUpdate';
356
+ const subBranches = (branch.fields ?? []).filter((sub) => !REPEATER_DISALLOWED_SUBTYPES.has(sub.type));
357
+ const shape = {};
358
+ for (const sub of subBranches) {
359
+ const subSchema = schemaForBranch(sub, options);
360
+ shape[sub.alias] = sub[requiredFlag] ? subSchema : subSchema.optional();
361
+ }
362
+ // z.object() strips unknown keys by default — old item shapes from a renamed/
363
+ // removed sub-field are dropped rather than rejected (sprint 10 §5.1).
364
+ const itemSchema = z.object(shape);
365
+ let arraySchema = z.array(itemSchema);
366
+ if (Number.isInteger(branch.minItems) && branch.minItems >= 0) {
367
+ arraySchema = arraySchema.min(branch.minItems, {
368
+ message: `Expected array(min:${branch.minItems})`,
369
+ });
370
+ }
371
+ if (Number.isInteger(branch.maxItems) && branch.maxItems >= 0) {
372
+ arraySchema = arraySchema.max(branch.maxItems, {
373
+ message: `Expected array(max:${branch.maxItems})`,
374
+ });
375
+ }
376
+ return withNullable(withEmptyPreprocessing(arraySchema, options.allowNull), options.allowNull);
377
+ }
378
+ const BRANCH_SCHEMA_BUILDERS = {
379
+ text: (_branch, options) => textSchema(options, options.allowNull),
380
+ richtext: (_branch, options) => richtextSchema(options, options.allowNull),
381
+ number: (branch, options) => numberSchema(branch, options.allowNull),
382
+ boolean: (_branch, options) => booleanSchema(options.allowNull),
383
+ date: (_branch, options) => dateSchema(options.allowNull),
384
+ json: (_branch, options) => jsonOrTagsSchema(options.allowNull),
385
+ tags: (_branch, options) => jsonOrTagsSchema(options.allowNull),
386
+ file: (branch, options) => fileSchema(branch, options.allowNull),
387
+ relation: (branch, options) => relationSchema(branch, options),
388
+ repeater: (branch, options) => repeaterSchema(branch, options),
389
+ };
390
+ function schemaForBranch(branch, options) {
391
+ const builder = BRANCH_SCHEMA_BUILDERS[branch.type];
392
+ if (!builder) {
393
+ throw new Error(`Unhandled branch type: ${branch.type}`);
394
+ }
395
+ return builder(branch, options);
396
+ }
397
+ // ──────────────────────────────────────────────────────────────────────────────
398
+ // Seed schema compilation with cache
399
+ // ──────────────────────────────────────────────────────────────────────────────
400
+ const seedSchemaCache = new Map();
401
+ function buildSeedFingerprint(seed) {
402
+ const parts = seed.branches.map((branch) => ({
403
+ a: branch.alias,
404
+ t: branch.type,
405
+ f: branch.format ?? null,
406
+ m: branch.multiple === true,
407
+ rc: branch.requiredOnCreate === true,
408
+ ru: branch.requiredOnUpdate === true,
409
+ n: branch.numberOptions ?? null,
410
+ fi: branch.fileOptions ?? null,
411
+ mi: branch.minItems ?? null,
412
+ ma: branch.maxItems ?? null,
413
+ sub: branch.fields?.map((sub) => ({
414
+ a: sub.alias,
415
+ t: sub.type,
416
+ rc: sub.requiredOnCreate === true,
417
+ ru: sub.requiredOnUpdate === true,
418
+ })) ?? null,
419
+ }));
420
+ return JSON.stringify({ s: seed.slug, b: parts });
421
+ }
422
+ function buildCacheKey(seed, options) {
423
+ return [
424
+ buildSeedFingerprint(seed),
425
+ options.operation,
426
+ options.allowNull ? '1' : '0',
427
+ String(options.maxTextLength),
428
+ ].join('|');
370
429
  }
371
430
  function compileSeedSchema(seed, options) {
372
- const key = JSON.stringify({
373
- fingerprint: seedFingerprint(seed),
374
- operation: options.operation,
375
- allowNull: options.allowNull,
376
- maxTextLength: options.maxTextLength,
377
- });
378
- const cached = compiledSchemaCache.get(key);
379
- if (cached)
380
- return cached;
381
- const required = new Set(requiredAliases(seed, options.operation));
431
+ // Seeds with relation branches cannot be safely cached because the schema
432
+ // captures the idGenerator by closure, and different generators (e.g.
433
+ // SystemIdGenerator vs SequentialIdGenerator) have different isValid() semantics.
434
+ const hasRelation = seed.branches.some((b) => b.type === 'relation');
435
+ if (!hasRelation) {
436
+ const key = buildCacheKey(seed, options);
437
+ const cached = seedSchemaCache.get(key);
438
+ if (cached)
439
+ return cached;
440
+ }
441
+ const requiredFlag = options.operation === 'create' ? 'requiredOnCreate' : 'requiredOnUpdate';
382
442
  const shape = {};
383
443
  for (const branch of seed.branches) {
384
- const baseSchema = buildBranchSchema(branch, options);
385
- shape[branch.alias] = required.has(branch.alias) ? baseSchema : baseSchema.optional();
444
+ const branchSchema = schemaForBranch(branch, options);
445
+ shape[branch.alias] = branch[requiredFlag] ? branchSchema : branchSchema.optional();
386
446
  }
387
447
  const compiled = z.object(shape).strict();
388
- compiledSchemaCache.set(key, compiled);
448
+ if (!hasRelation) {
449
+ const key = buildCacheKey(seed, options);
450
+ seedSchemaCache.set(key, compiled);
451
+ }
389
452
  return compiled;
390
453
  }
391
- function makeDetail(field, expected, received) {
392
- let receivedType = typeof received;
393
- if (received === null)
394
- receivedType = 'null';
395
- else if (Array.isArray(received))
396
- receivedType = 'array';
397
- return {
398
- field,
399
- expected,
400
- received: receivedType,
401
- message: `Field '${field}' expects type '${expected}' but received '${receivedType}'`,
402
- };
403
- }
404
- function isIsoDateString(value) {
405
- if (!/^\d{4}-\d{2}-\d{2}/.test(value))
406
- return false;
407
- const parsed = Date.parse(value);
408
- if (Number.isNaN(parsed))
409
- return false;
410
- const d = new Date(parsed);
411
- return d.toISOString().startsWith(value.substring(0, 10));
454
+ // ──────────────────────────────────────────────────────────────────────────────
455
+ // Required-field enforcement
456
+ // ──────────────────────────────────────────────────────────────────────────────
457
+ function isEffectivelyEmpty(value) {
458
+ if (value === null || value === undefined)
459
+ return true;
460
+ if (typeof value === 'string')
461
+ return cleanString(value).length === 0;
462
+ if (Array.isArray(value))
463
+ return value.length === 0;
464
+ if (isPlainObject(value)) {
465
+ if (isRichtextEnvelopeV1(value))
466
+ return isRichtextDocEmpty(value.doc);
467
+ if (isRichtextDocEmpty(value))
468
+ return true;
469
+ return Object.keys(value).length === 0;
470
+ }
471
+ return false;
412
472
  }
413
- function filterPayloadAliases(seed, payload) {
414
- const unknownAliases = [];
473
+ function detectMissingRequired(seed, rawPayload, filtered, parsedData, parseSucceeded, options) {
474
+ const missing = [];
415
475
  const details = [];
416
- const allowedAliases = new Set(seed.branches.map((branch) => branch.alias));
417
- const filteredPayload = {};
418
- for (const [alias, rawValue] of Object.entries(payload)) {
419
- if (!allowedAliases.has(alias)) {
420
- unknownAliases.push(alias);
476
+ const op = options.operation;
477
+ for (const branch of seed.branches) {
478
+ const isRequired = op === 'create' ? branch.requiredOnCreate : branch.requiredOnUpdate;
479
+ if (!isRequired)
480
+ continue;
481
+ if (!Object.hasOwn(rawPayload, branch.alias)) {
482
+ missing.push(branch.alias);
421
483
  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}'`,
484
+ field: branch.alias,
485
+ expected: 'required-field',
486
+ received: 'missing',
487
+ message: `Field '${branch.alias}' is required for ${op}`,
426
488
  });
427
489
  continue;
428
490
  }
429
- filteredPayload[alias] = rawValue;
491
+ const candidate = parseSucceeded ? parsedData[branch.alias] : filtered[branch.alias];
492
+ if (isEffectivelyEmpty(candidate)) {
493
+ missing.push(branch.alias);
494
+ details.push({
495
+ field: branch.alias,
496
+ expected: 'required-field',
497
+ received: 'empty',
498
+ message: `Field '${branch.alias}' cannot be empty for ${op}`,
499
+ });
500
+ }
430
501
  }
431
- return { filteredPayload, unknownAliases, unknownDetails: details };
502
+ return { missing, details };
503
+ }
504
+ // ──────────────────────────────────────────────────────────────────────────────
505
+ // Issue mapping
506
+ // ──────────────────────────────────────────────────────────────────────────────
507
+ function describeReceivedType(value) {
508
+ if (value === null)
509
+ return 'null';
510
+ if (Array.isArray(value))
511
+ return 'array';
512
+ return typeof value;
513
+ }
514
+ function expectedFromMessage(message) {
515
+ if (typeof message !== 'string')
516
+ return 'valid-field-value';
517
+ return message.startsWith('Expected ') ? message.slice('Expected '.length) : 'valid-field-value';
432
518
  }
433
- function collectZodIssueDetails(seed, issues, filteredPayload, normalizedOptions) {
434
- const unknownAliases = [];
519
+ function splitUnknownAliases(seed, payload) {
520
+ const knownAliases = new Set(seed.branches.map((branch) => branch.alias));
521
+ const filtered = {};
522
+ const unknown = [];
435
523
  const details = [];
436
- const dangerousFields = [];
524
+ for (const alias of Object.keys(payload)) {
525
+ if (knownAliases.has(alias)) {
526
+ filtered[alias] = payload[alias];
527
+ continue;
528
+ }
529
+ unknown.push(alias);
530
+ details.push({
531
+ field: alias,
532
+ expected: 'known-seed-alias',
533
+ received: 'unknown-alias',
534
+ message: `Field '${alias}' is not defined in seed '${seed.slug}'`,
535
+ });
536
+ }
537
+ return { filtered, unknown, details };
538
+ }
539
+ function flattenZodIssues(issues, parentPath = []) {
540
+ const result = [];
437
541
  for (const issue of issues) {
542
+ const issuePath = issue.path;
543
+ const currentPath = [...parentPath, ...issuePath];
544
+ if (issue.code === 'invalid_union' && 'errors' in issue) {
545
+ const unionErrors = issue.errors;
546
+ for (const subIssues of unionErrors) {
547
+ result.push(...flattenZodIssues(subIssues, currentPath));
548
+ }
549
+ }
550
+ else {
551
+ result.push({
552
+ ...issue,
553
+ path: currentPath,
554
+ });
555
+ }
556
+ }
557
+ return result;
558
+ }
559
+ function processZodIssues(seed, issues, filtered, options) {
560
+ const details = [];
561
+ const unknown = [];
562
+ const dangerous = [];
563
+ const flatIssues = flattenZodIssues(issues);
564
+ for (const issue of flatIssues) {
438
565
  if (issue.code === 'unrecognized_keys') {
439
566
  for (const alias of issue.keys) {
440
- unknownAliases.push(alias);
567
+ unknown.push(alias);
441
568
  details.push({
442
569
  field: alias,
443
570
  expected: 'known-seed-alias',
@@ -447,83 +574,68 @@ function collectZodIssueDetails(seed, issues, filteredPayload, normalizedOptions
447
574
  }
448
575
  continue;
449
576
  }
450
- if (issue.message === 'Required' && normalizedOptions.enforceRequiredFields) {
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
- });
577
+ if (issue.message === 'Required' && options.enforceRequiredFields) {
481
578
  continue;
482
579
  }
483
- const valueToCheck = isSuccess ? data[branch.alias] : filteredPayload[branch.alias];
484
- if (isMissingRequiredValue(valueToCheck)) {
485
- requiredFieldsMissing.push(branch.alias);
486
- details.push({
487
- field: branch.alias,
488
- expected: 'required-field',
489
- received: 'empty',
490
- message: `Field '${branch.alias}' cannot be empty for ${normalizedOptions.operation}`,
491
- });
580
+ const field = String(issue.path[0] ?? 'payload');
581
+ const params = issue.params;
582
+ if (params?.dangerous === true) {
583
+ dangerous.push(field);
492
584
  }
585
+ details.push({
586
+ field,
587
+ expected: expectedFromMessage(issue.message),
588
+ received: describeReceivedType(filtered[field]),
589
+ message: `Field '${field}' expects type '${expectedFromMessage(issue.message)}' but received '${describeReceivedType(filtered[field])}'`,
590
+ });
493
591
  }
494
- return { requiredFieldsMissing, requiredDetails: details };
592
+ return { details, unknown, dangerous };
495
593
  }
594
+ // ──────────────────────────────────────────────────────────────────────────────
595
+ // Public entry points
596
+ // ──────────────────────────────────────────────────────────────────────────────
496
597
  /**
497
- * Common foundation for schema-driven validation and sanitization of payloads.
498
- * Used by the Public API and reusable in the Botanical Engine.
499
- *
500
- * Mandatory execution order at the time of writing:
501
- * validate raw hash (privacy policy) store
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).
598
+ * Schema-driven validation and sanitization of a seed payload.
599
+ * - Drops unknown aliases (reports them as details).
600
+ * - Runs branch-specific schemas to coerce / sanitize known values.
601
+ * - Optionally enforces required-on-create / required-on-update branches.
602
+ * - Never throws on malformed input — returns a structured result.
505
603
  */
506
604
  export function validateAndSanitizeSeedPayload(seed, payload, options = {}) {
507
- const normalizedOptions = {
605
+ const resolved = {
508
606
  allowNull: options.allowNull ?? false,
509
607
  operation: options.operation ?? 'create',
510
608
  requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
511
609
  enforceRequiredFields: options.enforceRequiredFields ?? true,
512
610
  maxTextLength: options.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
611
+ idGenerator: options.idGenerator,
513
612
  };
514
- const { filteredPayload, unknownAliases: initialUnknown, unknownDetails } = filterPayloadAliases(seed, payload);
515
- const schema = compileSeedSchema(seed, normalizedOptions);
516
- const parsed = schema.safeParse(filteredPayload);
517
- const data = parsed.success ? { ...parsed.data } : {};
518
- const { unknownAliases: zodUnknown, zodDetails, dangerousFields } = parsed.success
519
- ? { unknownAliases: [], zodDetails: [], dangerousFields: [] }
520
- : collectZodIssueDetails(seed, parsed.error.issues, filteredPayload, normalizedOptions);
521
- const { requiredFieldsMissing, requiredDetails } = normalizedOptions.enforceRequiredFields
522
- ? enforceRequiredFields(seed, payload, filteredPayload, data, parsed.success, normalizedOptions)
523
- : { requiredFieldsMissing: [], requiredDetails: [] };
524
- const details = [...unknownDetails, ...zodDetails, ...requiredDetails];
525
- if (normalizedOptions.requireAtLeastOneValidField && Object.keys(data).length === 0) {
526
- details.push({
613
+ const { filtered, unknown: preUnknown, details: preDetails } = splitUnknownAliases(seed, payload);
614
+ const schema = compileSeedSchema(seed, resolved);
615
+ const parsed = schema.safeParse(filtered);
616
+ const accumulatedDetails = [...preDetails];
617
+ const unknownAliases = new Set(preUnknown);
618
+ const dangerousFields = new Set();
619
+ let data = {};
620
+ if (parsed.success) {
621
+ data = { ...parsed.data };
622
+ }
623
+ else {
624
+ const zodOutcome = processZodIssues(seed, parsed.error.issues, filtered, resolved);
625
+ accumulatedDetails.push(...zodOutcome.details);
626
+ for (const alias of zodOutcome.unknown)
627
+ unknownAliases.add(alias);
628
+ for (const field of zodOutcome.dangerous)
629
+ dangerousFields.add(field);
630
+ }
631
+ let requiredFieldsMissing = [];
632
+ if (resolved.enforceRequiredFields) {
633
+ const requiredOutcome = detectMissingRequired(seed, payload, filtered, data, parsed.success, resolved);
634
+ requiredFieldsMissing = requiredOutcome.missing;
635
+ accumulatedDetails.push(...requiredOutcome.details);
636
+ }
637
+ if (resolved.requireAtLeastOneValidField && Object.keys(data).length === 0) {
638
+ accumulatedDetails.push({
527
639
  field: 'data',
528
640
  expected: 'at-least-one-valid-field',
529
641
  received: 'empty',
@@ -532,22 +644,16 @@ export function validateAndSanitizeSeedPayload(seed, payload, options = {}) {
532
644
  }
533
645
  return {
534
646
  data,
535
- details,
536
- unknownAliases: [...new Set([...initialUnknown, ...zodUnknown])],
537
- dangerousFields: [...new Set(dangerousFields)],
647
+ details: accumulatedDetails,
648
+ unknownAliases: [...unknownAliases],
649
+ dangerousFields: [...dangerousFields],
538
650
  requiredFieldsMissing,
539
651
  hasAnyValidField: Object.keys(data).length > 0,
540
652
  };
541
653
  }
542
654
  /**
543
- * Validates content status supported by the CMS.
655
+ * Type-guard for the content status enum.
544
656
  */
545
657
  export function isValidContentStatus(value) {
546
658
  return statusSchema.safeParse(value).success;
547
659
  }
548
- /**
549
- * Full Zod runtime compiler:
550
- * - compile per-seed/per-operation with cache
551
- * - strict unknown aliases
552
- * - required fields by operation
553
- */