@plasius/schema 1.0.18 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +140 -1
- package/dist/index.cjs +1934 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +391 -0
- package/dist/index.d.ts +391 -0
- package/dist/index.js +1883 -0
- package/dist/index.js.map +1 -0
- package/package.json +18 -6
- package/.eslintrc.cjs +0 -7
- package/.github/workflows/cd.yml +0 -186
- package/.github/workflows/ci.yml +0 -16
- package/.nvmrc +0 -1
- package/.vscode/launch.json +0 -15
- package/CHANGELOG.md +0 -86
- package/CODE_OF_CONDUCT.md +0 -79
- package/CONTRIBUTING.md +0 -201
- package/CONTRIBUTORS.md +0 -27
- package/SECURITY.md +0 -17
- package/docs/adrs/adr-0001: schema.md +0 -45
- package/docs/adrs/adr-template.md +0 -67
- package/legal/CLA-REGISTRY.csv +0 -2
- package/legal/CLA.md +0 -22
- package/legal/CORPORATE_CLA.md +0 -57
- package/legal/INDIVIDUAL_CLA.md +0 -91
- package/sbom.cdx.json +0 -66
- package/src/components.ts +0 -39
- package/src/field.builder.ts +0 -119
- package/src/field.ts +0 -14
- package/src/index.ts +0 -7
- package/src/infer.ts +0 -34
- package/src/pii.ts +0 -165
- package/src/schema.ts +0 -826
- package/src/types.ts +0 -156
- package/src/validation/countryCode.ISO3166.ts +0 -256
- package/src/validation/currencyCode.ISO4217.ts +0 -191
- package/src/validation/dateTime.ISO8601.ts +0 -9
- package/src/validation/email.RFC5322.ts +0 -9
- package/src/validation/generalText.OWASP.ts +0 -39
- package/src/validation/index.ts +0 -13
- package/src/validation/name.OWASP.ts +0 -25
- package/src/validation/percentage.ISO80000-1.ts +0 -8
- package/src/validation/phone.E.164.ts +0 -9
- package/src/validation/richtext.OWASP.ts +0 -34
- package/src/validation/url.WHATWG.ts +0 -16
- package/src/validation/user.MS-GOOGLE-APPLE.ts +0 -31
- package/src/validation/uuid.RFC4122.ts +0 -10
- package/src/validation/version.SEMVER2.0.0.ts +0 -8
- package/tests/pii.test.ts +0 -139
- package/tests/schema.test.ts +0 -501
- package/tests/test-utils.ts +0 -97
- package/tests/validate.test.ts +0 -97
- package/tests/validation.test.ts +0 -98
- package/tsconfig.build.json +0 -19
- package/tsconfig.json +0 -7
- package/tsup.config.ts +0 -10
- package/vitest.config.js +0 -20
package/src/schema.ts
DELETED
|
@@ -1,826 +0,0 @@
|
|
|
1
|
-
import { Infer } from "./infer.js";
|
|
2
|
-
import { FieldType, Schema, SchemaOptions, SchemaShape } from "./types.js";
|
|
3
|
-
import { field } from "./field.js";
|
|
4
|
-
import { PIIAction, PIIClassification, PIILogHandling } from "./pii.js";
|
|
5
|
-
import {
|
|
6
|
-
enforcePIIField,
|
|
7
|
-
prepareForStorage as piiPrepareForStorage,
|
|
8
|
-
prepareForRead as piiPrepareForRead,
|
|
9
|
-
sanitizeForLog as piiSanitizeForLog,
|
|
10
|
-
getPiiAudit as piiGetPiiAudit,
|
|
11
|
-
scrubPiiForDelete as piiScrubPiiForDelete,
|
|
12
|
-
} from "./pii.js";
|
|
13
|
-
import { FieldBuilder } from "./field.builder.js";
|
|
14
|
-
|
|
15
|
-
const globalSchemaRegistry = new Map<string, Schema<any>>();
|
|
16
|
-
|
|
17
|
-
function validateEnum(
|
|
18
|
-
parentKey: string,
|
|
19
|
-
value: any,
|
|
20
|
-
enumValues?: Array<string | number>
|
|
21
|
-
): string | undefined {
|
|
22
|
-
if (!enumValues) return;
|
|
23
|
-
|
|
24
|
-
const values = Array.isArray(enumValues)
|
|
25
|
-
? enumValues
|
|
26
|
-
: Array.from(enumValues);
|
|
27
|
-
|
|
28
|
-
if (Array.isArray(value)) {
|
|
29
|
-
const invalid = value.filter((v) => !values.includes(v));
|
|
30
|
-
if (invalid.length > 0) {
|
|
31
|
-
return `Field ${parentKey} contains invalid enum values: ${invalid.join(
|
|
32
|
-
", "
|
|
33
|
-
)}`;
|
|
34
|
-
}
|
|
35
|
-
} else {
|
|
36
|
-
if (!values.includes(value)) {
|
|
37
|
-
return `Field ${parentKey} must be one of: ${values.join(", ")}`;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Helper: some builders may store enum/optional/validator under different keys
|
|
43
|
-
// schema.ts
|
|
44
|
-
function getEnumValues(def: any): Array<string | number> | undefined {
|
|
45
|
-
const src = Array.isArray(def?.enum)
|
|
46
|
-
? def.enum
|
|
47
|
-
: Array.isArray(def?.enumValues)
|
|
48
|
-
? def.enumValues
|
|
49
|
-
: Array.isArray(def?._enum)
|
|
50
|
-
? def._enum
|
|
51
|
-
: Array.isArray(def?._enumValues)
|
|
52
|
-
? def._enumValues
|
|
53
|
-
: def?._enumSet instanceof Set
|
|
54
|
-
? Array.from(def._enumSet)
|
|
55
|
-
: undefined;
|
|
56
|
-
|
|
57
|
-
if (!src) return undefined;
|
|
58
|
-
const ok = src.every(
|
|
59
|
-
(v: unknown) => typeof v === "string" || typeof v === "number"
|
|
60
|
-
);
|
|
61
|
-
return ok ? (src as Array<string | number>) : undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function isOptional(def: any): boolean {
|
|
65
|
-
return (def?.isRequired ?? false) === false;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function getValidator(def: any): ((v: any) => boolean) | undefined {
|
|
69
|
-
return def?._validator ?? undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function getShape(def: any): Record<string, any> | undefined {
|
|
73
|
-
return def?._shape ?? def?.shape ?? undefined;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
77
|
-
// Validation helpers (extracted from validate())
|
|
78
|
-
// ──────────────────────────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
function checkMissingRequired(
|
|
81
|
-
parentKey: string,
|
|
82
|
-
key: string,
|
|
83
|
-
value: any,
|
|
84
|
-
def: any,
|
|
85
|
-
errors: string[]
|
|
86
|
-
): { missing: boolean } {
|
|
87
|
-
if (value === undefined || value === null) {
|
|
88
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
89
|
-
if (!isOptional(def)) {
|
|
90
|
-
errors.push(`Missing required field: ${path}`);
|
|
91
|
-
}
|
|
92
|
-
return { missing: true };
|
|
93
|
-
}
|
|
94
|
-
return { missing: false };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function checkImmutable(
|
|
98
|
-
parentKey: string,
|
|
99
|
-
key: string,
|
|
100
|
-
value: any,
|
|
101
|
-
def: any,
|
|
102
|
-
existing: Record<string, any> | undefined,
|
|
103
|
-
errors: string[]
|
|
104
|
-
): { immutableViolation: boolean } {
|
|
105
|
-
if (
|
|
106
|
-
def.isImmutable &&
|
|
107
|
-
existing &&
|
|
108
|
-
existing[key] !== undefined &&
|
|
109
|
-
value !== existing[key]
|
|
110
|
-
) {
|
|
111
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
112
|
-
errors.push(`Field is immutable: ${path}`);
|
|
113
|
-
return { immutableViolation: true };
|
|
114
|
-
}
|
|
115
|
-
return { immutableViolation: false };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function runCustomValidator(
|
|
119
|
-
parentKey: string,
|
|
120
|
-
key: string,
|
|
121
|
-
value: any,
|
|
122
|
-
def: any,
|
|
123
|
-
errors: string[]
|
|
124
|
-
): { invalid: boolean } {
|
|
125
|
-
const validator = getValidator(def);
|
|
126
|
-
if (validator && value !== undefined && value !== null) {
|
|
127
|
-
const valid = validator(value);
|
|
128
|
-
if (!valid) {
|
|
129
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
130
|
-
errors.push(`Invalid value for field: ${path}`);
|
|
131
|
-
return { invalid: true };
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
return { invalid: false };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function validateStringField(
|
|
138
|
-
parentKey: string,
|
|
139
|
-
key: string,
|
|
140
|
-
value: any,
|
|
141
|
-
def: any,
|
|
142
|
-
errors: string[]
|
|
143
|
-
) {
|
|
144
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
145
|
-
if (typeof value !== "string") {
|
|
146
|
-
errors.push(`Field ${path} must be string`);
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const enumValues = getEnumValues(def);
|
|
151
|
-
if (Array.isArray(enumValues)) {
|
|
152
|
-
const enumError = validateEnum(path, value, enumValues);
|
|
153
|
-
if (enumError) {
|
|
154
|
-
errors.push(enumError);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function validateNumberField(
|
|
160
|
-
parentKey: string,
|
|
161
|
-
key: string,
|
|
162
|
-
value: any,
|
|
163
|
-
_def: any,
|
|
164
|
-
errors: string[]
|
|
165
|
-
) {
|
|
166
|
-
const enumPath = parentKey ? `${parentKey}.${key}` : key;
|
|
167
|
-
if (typeof value !== "number") {
|
|
168
|
-
errors.push(`Field ${enumPath} must be number`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function validateBooleanField(
|
|
173
|
-
parentKey: string,
|
|
174
|
-
key: string,
|
|
175
|
-
value: any,
|
|
176
|
-
_def: any,
|
|
177
|
-
errors: string[]
|
|
178
|
-
) {
|
|
179
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
180
|
-
if (typeof value !== "boolean") {
|
|
181
|
-
errors.push(`Field ${path} must be boolean`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function validateObjectChildren(
|
|
186
|
-
parentKey: string,
|
|
187
|
-
obj: any,
|
|
188
|
-
shape: Record<string, any>,
|
|
189
|
-
errors: string[]
|
|
190
|
-
) {
|
|
191
|
-
for (const [childKey, childDef] of Object.entries(shape) as [
|
|
192
|
-
string,
|
|
193
|
-
FieldBuilder<any>
|
|
194
|
-
][]) {
|
|
195
|
-
const childValue = obj[childKey];
|
|
196
|
-
|
|
197
|
-
// Required check
|
|
198
|
-
const { missing } = checkMissingRequired(
|
|
199
|
-
parentKey,
|
|
200
|
-
childKey,
|
|
201
|
-
childValue,
|
|
202
|
-
childDef,
|
|
203
|
-
errors
|
|
204
|
-
);
|
|
205
|
-
if (missing) continue;
|
|
206
|
-
|
|
207
|
-
// Custom validator (per-field)
|
|
208
|
-
const { invalid } = runCustomValidator(
|
|
209
|
-
parentKey,
|
|
210
|
-
childKey,
|
|
211
|
-
childValue,
|
|
212
|
-
childDef,
|
|
213
|
-
errors
|
|
214
|
-
);
|
|
215
|
-
if (invalid) continue;
|
|
216
|
-
|
|
217
|
-
// Type-specific validation (string/number/boolean/object/array/ref)
|
|
218
|
-
// This will recurse into nested objects via validateObjectField → validateObjectChildren
|
|
219
|
-
validateByType(parentKey, childKey, childValue, childDef, errors);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function validateObjectField(
|
|
224
|
-
parentKey: string,
|
|
225
|
-
key: string,
|
|
226
|
-
value: any,
|
|
227
|
-
def: any,
|
|
228
|
-
errors: string[]
|
|
229
|
-
) {
|
|
230
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
231
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
232
|
-
errors.push(`Field ${path} must be object`);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const objShape = getShape(def);
|
|
236
|
-
if (objShape) validateObjectChildren(path, value, objShape, errors);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function validateArrayOfStrings(
|
|
240
|
-
parentKey: string,
|
|
241
|
-
key: string,
|
|
242
|
-
arr: any[],
|
|
243
|
-
itemDef: any,
|
|
244
|
-
errors: string[]
|
|
245
|
-
) {
|
|
246
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
247
|
-
if (!arr.every((v) => typeof v === "string")) {
|
|
248
|
-
errors.push(`Field ${path} must be string[]`);
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
const enumValues = getEnumValues(itemDef);
|
|
252
|
-
if (Array.isArray(enumValues)) {
|
|
253
|
-
const enumError = validateEnum(path, arr, enumValues);
|
|
254
|
-
if (enumError) {
|
|
255
|
-
errors.push(enumError);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
function validateArrayOfNumbers(
|
|
261
|
-
parentKey: string,
|
|
262
|
-
key: string,
|
|
263
|
-
arr: any[],
|
|
264
|
-
itemDef: any,
|
|
265
|
-
errors: string[]
|
|
266
|
-
) {
|
|
267
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
268
|
-
if (!arr.every((v) => typeof v === "number")) {
|
|
269
|
-
errors.push(`Field ${path} must be number[]`);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const enumValues = getEnumValues(itemDef);
|
|
273
|
-
if (Array.isArray(enumValues)) {
|
|
274
|
-
const enumError = validateEnum(path, arr, enumValues);
|
|
275
|
-
if (enumError) {
|
|
276
|
-
errors.push(enumError);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function validateArrayOfBooleans(
|
|
282
|
-
parentKey: string,
|
|
283
|
-
key: string,
|
|
284
|
-
arr: any[],
|
|
285
|
-
_itemDef: any,
|
|
286
|
-
errors: string[]
|
|
287
|
-
) {
|
|
288
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
289
|
-
if (!arr.every((v) => typeof v === "boolean")) {
|
|
290
|
-
errors.push(`Field ${path} must be boolean[]`);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function validateArrayOfObjects(
|
|
295
|
-
parentKey: string,
|
|
296
|
-
key: string,
|
|
297
|
-
arr: any[],
|
|
298
|
-
itemDef: any,
|
|
299
|
-
errors: string[]
|
|
300
|
-
) {
|
|
301
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
!Array.isArray(arr) ||
|
|
305
|
-
!arr.every((v) => typeof v === "object" && v !== null && !Array.isArray(v))
|
|
306
|
-
) {
|
|
307
|
-
errors.push(`Field ${path} must be object[]`);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const itemShape = getShape(itemDef);
|
|
312
|
-
if (!itemShape) return;
|
|
313
|
-
|
|
314
|
-
arr.forEach((item, idx) => {
|
|
315
|
-
const itemParent = `${path}[${idx}]`;
|
|
316
|
-
for (const [childKey, childDef] of Object.entries(itemShape) as [
|
|
317
|
-
string,
|
|
318
|
-
FieldBuilder<any>
|
|
319
|
-
][]) {
|
|
320
|
-
const childValue = (item as any)[childKey];
|
|
321
|
-
|
|
322
|
-
// Required check (path-aware)
|
|
323
|
-
const { missing } = checkMissingRequired(
|
|
324
|
-
itemParent,
|
|
325
|
-
childKey,
|
|
326
|
-
childValue,
|
|
327
|
-
childDef,
|
|
328
|
-
errors
|
|
329
|
-
);
|
|
330
|
-
if (missing) continue;
|
|
331
|
-
|
|
332
|
-
// Custom validator (path-aware)
|
|
333
|
-
const { invalid } = runCustomValidator(
|
|
334
|
-
itemParent,
|
|
335
|
-
childKey,
|
|
336
|
-
childValue,
|
|
337
|
-
childDef,
|
|
338
|
-
errors
|
|
339
|
-
);
|
|
340
|
-
if (invalid) continue;
|
|
341
|
-
|
|
342
|
-
// Type-specific validation (recurses into nested object/array/ref)
|
|
343
|
-
validateByType(itemParent, childKey, childValue, childDef, errors);
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function validateArrayField(
|
|
349
|
-
parentKey: string,
|
|
350
|
-
key: string,
|
|
351
|
-
value: any,
|
|
352
|
-
def: any,
|
|
353
|
-
errors: string[]
|
|
354
|
-
) {
|
|
355
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
356
|
-
if (!Array.isArray(value)) {
|
|
357
|
-
errors.push(`Field ${key} must be an array`);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
const itemType = def.itemType?.type;
|
|
361
|
-
if (itemType === "string")
|
|
362
|
-
return validateArrayOfStrings(parentKey, key, value, def.itemType, errors);
|
|
363
|
-
if (itemType === "number")
|
|
364
|
-
return validateArrayOfNumbers(parentKey, key, value, def.itemType, errors);
|
|
365
|
-
if (itemType === "boolean")
|
|
366
|
-
return validateArrayOfBooleans(parentKey, key, value, def.itemType, errors);
|
|
367
|
-
if (itemType === "object")
|
|
368
|
-
return validateArrayOfObjects(parentKey, key, value, def.itemType, errors);
|
|
369
|
-
if (itemType === "ref") {
|
|
370
|
-
const expectedType = (def.itemType as any).refType;
|
|
371
|
-
value.forEach((ref: any, idx: number) => {
|
|
372
|
-
if (
|
|
373
|
-
!ref ||
|
|
374
|
-
typeof ref !== "object" ||
|
|
375
|
-
ref === null ||
|
|
376
|
-
typeof ref.type !== "string" ||
|
|
377
|
-
typeof ref.id !== "string" ||
|
|
378
|
-
(expectedType && ref.type !== expectedType)
|
|
379
|
-
) {
|
|
380
|
-
errors.push(
|
|
381
|
-
`Field ${path}[${idx}] must be a reference object with type: ${expectedType}`
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
});
|
|
385
|
-
const refShape = getShape(def.itemType);
|
|
386
|
-
if (refShape) {
|
|
387
|
-
value.forEach((ref: any, idx: number) => {
|
|
388
|
-
if (ref && typeof ref === "object" && ref !== null) {
|
|
389
|
-
for (const [childKey, childDef] of Object.entries(refShape) as [
|
|
390
|
-
string,
|
|
391
|
-
FieldBuilder<any>
|
|
392
|
-
][]) {
|
|
393
|
-
const childValue = ref[childKey];
|
|
394
|
-
if (
|
|
395
|
-
(childValue === undefined || childValue === null) &&
|
|
396
|
-
!isOptional(childDef)
|
|
397
|
-
) {
|
|
398
|
-
errors.push(
|
|
399
|
-
`Missing required field: ${path}[${idx}].${childKey}`
|
|
400
|
-
);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
const childValidator = getValidator(childDef);
|
|
404
|
-
if (
|
|
405
|
-
childValidator &&
|
|
406
|
-
childValue !== undefined &&
|
|
407
|
-
childValue !== null
|
|
408
|
-
) {
|
|
409
|
-
const valid = childValidator(childValue);
|
|
410
|
-
if (!valid)
|
|
411
|
-
errors.push(
|
|
412
|
-
`Invalid value for field: ${path}[${idx}].${childKey}`
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
errors.push(`Field ${path} has unsupported array item type`);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function validateRefField(
|
|
425
|
-
parentKey: string,
|
|
426
|
-
key: string,
|
|
427
|
-
value: any,
|
|
428
|
-
_def: any,
|
|
429
|
-
errors: string[]
|
|
430
|
-
) {
|
|
431
|
-
if (
|
|
432
|
-
typeof value !== "object" ||
|
|
433
|
-
value === null ||
|
|
434
|
-
typeof value.type !== "string" ||
|
|
435
|
-
typeof value.id !== "string"
|
|
436
|
-
) {
|
|
437
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
438
|
-
errors.push(`Field ${path} must be { type: string; id: string }`);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function validateByType(
|
|
443
|
-
parentKey: string,
|
|
444
|
-
key: string,
|
|
445
|
-
value: any,
|
|
446
|
-
def: any,
|
|
447
|
-
errors: string[]
|
|
448
|
-
) {
|
|
449
|
-
const path = parentKey ? `${parentKey}.${key}` : key;
|
|
450
|
-
switch (def.type) {
|
|
451
|
-
case "string":
|
|
452
|
-
return validateStringField(parentKey, key, value, def, errors);
|
|
453
|
-
case "number":
|
|
454
|
-
return validateNumberField(parentKey, key, value, def, errors);
|
|
455
|
-
case "boolean":
|
|
456
|
-
return validateBooleanField(parentKey, key, value, def, errors);
|
|
457
|
-
case "object":
|
|
458
|
-
return validateObjectField(parentKey, key, value, def, errors);
|
|
459
|
-
case "array":
|
|
460
|
-
return validateArrayField(parentKey, key, value, def, errors);
|
|
461
|
-
case "ref":
|
|
462
|
-
return validateRefField(parentKey, key, value, def, errors);
|
|
463
|
-
default:
|
|
464
|
-
errors.push(`Unknown type for field ${path}: ${def.type}`);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
export function createSchema<S extends SchemaShape>(
|
|
469
|
-
_shape: S,
|
|
470
|
-
entityType: string,
|
|
471
|
-
options: SchemaOptions = {
|
|
472
|
-
version: "1.0",
|
|
473
|
-
table: "",
|
|
474
|
-
schemaValidator: () => true,
|
|
475
|
-
piiEnforcement: "none",
|
|
476
|
-
}
|
|
477
|
-
): Schema<S> {
|
|
478
|
-
const systemFields = {
|
|
479
|
-
type: field.string().immutable().system(),
|
|
480
|
-
version: field.string().immutable().system(),
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
const version = options.version || "1.0";
|
|
484
|
-
const store = options.table || "";
|
|
485
|
-
const schema: Schema<S> = {
|
|
486
|
-
// 🔗 Define the schema shape
|
|
487
|
-
_shape: {
|
|
488
|
-
...systemFields,
|
|
489
|
-
..._shape,
|
|
490
|
-
} as S,
|
|
491
|
-
|
|
492
|
-
// 🔗 Metadata about the schema
|
|
493
|
-
meta: { entityType, version },
|
|
494
|
-
|
|
495
|
-
// 🔗 Validate input against the schema
|
|
496
|
-
validate(input: unknown, existing?: Record<string, any>) {
|
|
497
|
-
const errors: string[] = [];
|
|
498
|
-
const result: any = {};
|
|
499
|
-
|
|
500
|
-
if (typeof input !== "object" || input === null) {
|
|
501
|
-
return { valid: false, errors: ["Input must be an object"] } as any;
|
|
502
|
-
}
|
|
503
|
-
// Work on a non-mutating copy that includes system defaults for first-time objects
|
|
504
|
-
const working: Record<string, any> = { ...(input as any) };
|
|
505
|
-
if (working.type == null) working.type = entityType;
|
|
506
|
-
if (working.version == null) working.version = version;
|
|
507
|
-
|
|
508
|
-
for (const key in schema._shape) {
|
|
509
|
-
const def = schema._shape[key];
|
|
510
|
-
const value = working[key];
|
|
511
|
-
|
|
512
|
-
if (!def) {
|
|
513
|
-
errors.push(`Field definition missing for: ${key}`);
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// 1) Required
|
|
518
|
-
const { missing } = checkMissingRequired("", key, value, def, errors);
|
|
519
|
-
if (missing) continue;
|
|
520
|
-
|
|
521
|
-
// 2) Immutable
|
|
522
|
-
const { immutableViolation } = checkImmutable(
|
|
523
|
-
"",
|
|
524
|
-
key,
|
|
525
|
-
value,
|
|
526
|
-
def,
|
|
527
|
-
existing,
|
|
528
|
-
errors
|
|
529
|
-
);
|
|
530
|
-
if (immutableViolation) continue;
|
|
531
|
-
|
|
532
|
-
// 3) PII enforcement (may short-circuit)
|
|
533
|
-
const { shortCircuit } = enforcePIIField(
|
|
534
|
-
"",
|
|
535
|
-
key,
|
|
536
|
-
value,
|
|
537
|
-
def,
|
|
538
|
-
options.piiEnforcement ?? "none",
|
|
539
|
-
errors,
|
|
540
|
-
console
|
|
541
|
-
);
|
|
542
|
-
if (shortCircuit) continue;
|
|
543
|
-
|
|
544
|
-
// 4) Custom validator
|
|
545
|
-
const { invalid } = runCustomValidator("", key, value, def, errors);
|
|
546
|
-
if (invalid) continue;
|
|
547
|
-
|
|
548
|
-
// 5) Type-specific validation
|
|
549
|
-
validateByType("", key, value, def, errors);
|
|
550
|
-
|
|
551
|
-
// Assign value regardless; storage transforms happen elsewhere
|
|
552
|
-
result[key] = value;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (errors.length === 0 && options.schemaValidator) {
|
|
557
|
-
const castValue = result as Infer<S>;
|
|
558
|
-
if (!options.schemaValidator(castValue)) {
|
|
559
|
-
errors.push("Schema-level validation failed.");
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
return {
|
|
564
|
-
valid: errors.length === 0,
|
|
565
|
-
value: result as Infer<S>,
|
|
566
|
-
errors,
|
|
567
|
-
};
|
|
568
|
-
},
|
|
569
|
-
|
|
570
|
-
// specific validator for a schema to allow conditional validation
|
|
571
|
-
schemaValidator: options.schemaValidator!, // <== expose it here!
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Recursively validates entity references defined in this schema.
|
|
575
|
-
*
|
|
576
|
-
* Traverses fields of type `ref` and arrays of `ref` and resolves each target
|
|
577
|
-
* entity using the provided `resolveEntity` function. When `autoValidate` is
|
|
578
|
-
* enabled (default) and the field's `refPolicy` is `eager`, the referenced
|
|
579
|
-
* entity's schema is fetched and validated via `validateComposition` up to
|
|
580
|
-
* `maxDepth` levels.
|
|
581
|
-
*
|
|
582
|
-
* Skips fields not listed in `onlyFields` when provided. Prevents cycles via
|
|
583
|
-
* a `visited` set in `validatorContext`.
|
|
584
|
-
*
|
|
585
|
-
* @param entity The root entity to validate (must include `type` and `id`).
|
|
586
|
-
* @param options Options controlling traversal and resolution behavior.
|
|
587
|
-
* @param options.resolveEntity Function to resolve a referenced entity by type and id.
|
|
588
|
-
* @param options.validatorContext Internal context (visited set) to prevent cycles.
|
|
589
|
-
* @param options.maxDepth Maximum depth for recursive validation (default: 5).
|
|
590
|
-
* @param options.onlyFields Optional whitelist of field names to validate.
|
|
591
|
-
* @param options.log Optional logger for traversal/debug output.
|
|
592
|
-
*
|
|
593
|
-
* @throws Error if a broken reference is encountered (target cannot be resolved).
|
|
594
|
-
*/
|
|
595
|
-
async validateComposition(entity, options) {
|
|
596
|
-
const {
|
|
597
|
-
resolveEntity,
|
|
598
|
-
validatorContext = { visited: new Set() },
|
|
599
|
-
maxDepth = 5,
|
|
600
|
-
log,
|
|
601
|
-
} = options;
|
|
602
|
-
|
|
603
|
-
const entityKey = `${(entity as any).type}:${(entity as any).id}`;
|
|
604
|
-
if (validatorContext.visited.has(entityKey)) {
|
|
605
|
-
log?.(`Skipping already visited entity ${entityKey}`);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
validatorContext.visited.add(entityKey);
|
|
609
|
-
log?.(`Validating composition for entity ${entityKey}`);
|
|
610
|
-
|
|
611
|
-
for (const [key, def] of Object.entries(schema._shape)) {
|
|
612
|
-
// NEW: skip if not in onlyFields
|
|
613
|
-
if (options.onlyFields && !options.onlyFields.includes(key)) {
|
|
614
|
-
log?.(`Skipping field ${key} (not in onlyFields)`);
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const refType = (def as any).refType as string;
|
|
619
|
-
const autoValidate = (def as any).autoValidate !== false;
|
|
620
|
-
const refPolicy = (def as any).refPolicy ?? "eager";
|
|
621
|
-
const value = (entity as any)[key];
|
|
622
|
-
if (!value) continue;
|
|
623
|
-
|
|
624
|
-
if (def.type === "ref") {
|
|
625
|
-
const ref = value as { type: string; id: string };
|
|
626
|
-
const target = await options.resolveEntity(refType, ref.id);
|
|
627
|
-
if (!target)
|
|
628
|
-
throw new Error(
|
|
629
|
-
`Broken reference: ${refType} ${ref.id} in field ${key}`
|
|
630
|
-
);
|
|
631
|
-
log?.(`Resolved ${refType} ${ref.id} from field ${key}`);
|
|
632
|
-
|
|
633
|
-
if (autoValidate && refPolicy === "eager") {
|
|
634
|
-
const targetSchema = getSchemaForType(refType);
|
|
635
|
-
if (options.maxDepth! > 0 && targetSchema) {
|
|
636
|
-
await targetSchema.validateComposition(target, {
|
|
637
|
-
...options,
|
|
638
|
-
maxDepth: options.maxDepth! - 1,
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
// Handle array of refs: type === "array" and itemType?.type === "ref"
|
|
643
|
-
} else if (def.type === "array" && def.itemType?.type === "ref") {
|
|
644
|
-
const refs = value as Array<{ type: string; id: string }>;
|
|
645
|
-
for (const ref of refs) {
|
|
646
|
-
const target = await options.resolveEntity(refType, ref.id);
|
|
647
|
-
if (!target)
|
|
648
|
-
throw new Error(
|
|
649
|
-
`Broken reference: ${refType} ${ref.id} in field ${key}`
|
|
650
|
-
);
|
|
651
|
-
log?.(`Resolved ${refType} ${ref.id} from field ${key}`);
|
|
652
|
-
|
|
653
|
-
if (autoValidate && refPolicy === "eager") {
|
|
654
|
-
const targetSchema = getSchemaForType(refType);
|
|
655
|
-
if (options.maxDepth! > 0 && targetSchema) {
|
|
656
|
-
await targetSchema.validateComposition(target, {
|
|
657
|
-
...options,
|
|
658
|
-
maxDepth: options.maxDepth! - 1,
|
|
659
|
-
});
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
},
|
|
666
|
-
|
|
667
|
-
/**
|
|
668
|
-
* Returns the configured table name for this schema.
|
|
669
|
-
*
|
|
670
|
-
* @throws Error if no store/table name has been defined for this schema.
|
|
671
|
-
*/
|
|
672
|
-
tableName(): string {
|
|
673
|
-
if (!store || store === "") {
|
|
674
|
-
throw new Error("Store is not defined for this schema");
|
|
675
|
-
}
|
|
676
|
-
return store;
|
|
677
|
-
},
|
|
678
|
-
|
|
679
|
-
/**
|
|
680
|
-
* Transforms an input object for persistence by applying PII protection
|
|
681
|
-
* according to field annotations (e.g., encryption and hashing).
|
|
682
|
-
*
|
|
683
|
-
* @param input The raw entity data.
|
|
684
|
-
* @param encryptFn Function used to encrypt sensitive values.
|
|
685
|
-
* @param hashFn Function used to hash sensitive values.
|
|
686
|
-
* @returns A new object safe to store.
|
|
687
|
-
*/
|
|
688
|
-
prepareForStorage(
|
|
689
|
-
input: Record<string, any>,
|
|
690
|
-
encryptFn: (value: any) => string,
|
|
691
|
-
hashFn: (value: any) => string
|
|
692
|
-
): Record<string, any> {
|
|
693
|
-
return piiPrepareForStorage(_shape, input, encryptFn, hashFn);
|
|
694
|
-
},
|
|
695
|
-
|
|
696
|
-
/**
|
|
697
|
-
* Reverses storage transformations for read paths (e.g., decrypts values)
|
|
698
|
-
* according to PII annotations, returning a consumer-friendly object.
|
|
699
|
-
*
|
|
700
|
-
* @param stored Data retrieved from storage.
|
|
701
|
-
* @param decryptFn Function used to decrypt values that were encrypted on write.
|
|
702
|
-
* @returns A new object suitable for application consumption.
|
|
703
|
-
*/
|
|
704
|
-
prepareForRead(
|
|
705
|
-
stored: Record<string, any>,
|
|
706
|
-
decryptFn: (value: string) => any
|
|
707
|
-
): Record<string, any> {
|
|
708
|
-
return piiPrepareForRead(_shape, stored, decryptFn);
|
|
709
|
-
},
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Produces a log-safe copy of the provided data by redacting or pseudonymizing
|
|
713
|
-
* PII fields in accordance with field annotations.
|
|
714
|
-
*
|
|
715
|
-
* @param data Arbitrary data to sanitize for logging.
|
|
716
|
-
* @param pseudonymFn Function producing stable pseudonyms for sensitive values.
|
|
717
|
-
* @returns A copy safe to emit to logs.
|
|
718
|
-
*/
|
|
719
|
-
sanitizeForLog(
|
|
720
|
-
data: Record<string, any>,
|
|
721
|
-
pseudonymFn: (value: any) => string
|
|
722
|
-
): Record<string, any> {
|
|
723
|
-
return piiSanitizeForLog(_shape, data, pseudonymFn);
|
|
724
|
-
},
|
|
725
|
-
|
|
726
|
-
/**
|
|
727
|
-
* Returns a list of fields annotated with PII metadata for auditing purposes.
|
|
728
|
-
* Each entry includes classification, required action, and optional log policy.
|
|
729
|
-
*/
|
|
730
|
-
getPiiAudit(): Array<{
|
|
731
|
-
field: string;
|
|
732
|
-
classification: PIIClassification;
|
|
733
|
-
action: PIIAction;
|
|
734
|
-
logHandling?: PIILogHandling;
|
|
735
|
-
purpose?: string;
|
|
736
|
-
}> {
|
|
737
|
-
return piiGetPiiAudit(_shape);
|
|
738
|
-
},
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Produces a copy of stored data suitable for data deletion flows by scrubbing
|
|
742
|
-
* or blanking PII per field annotations.
|
|
743
|
-
*
|
|
744
|
-
* @param stored Data as persisted.
|
|
745
|
-
* @returns A copy with PII removed or neutralized for deletion.
|
|
746
|
-
*/
|
|
747
|
-
scrubPiiForDelete(stored: Record<string, any>): Record<string, any> {
|
|
748
|
-
return piiScrubPiiForDelete(_shape, stored);
|
|
749
|
-
},
|
|
750
|
-
|
|
751
|
-
/**
|
|
752
|
-
* Returns a normalized description of the schema suitable for documentation
|
|
753
|
-
* or UI rendering (type, optionality, enum values, PII flags, etc.).
|
|
754
|
-
*/
|
|
755
|
-
describe() {
|
|
756
|
-
const description: Record<string, any> = {};
|
|
757
|
-
for (const [key, def] of Object.entries(schema._shape)) {
|
|
758
|
-
description[key] = {
|
|
759
|
-
type: def.type,
|
|
760
|
-
optional: !!def.optional,
|
|
761
|
-
description: def._description ?? "",
|
|
762
|
-
version: def._version ?? "",
|
|
763
|
-
enum: getEnumValues(def as any),
|
|
764
|
-
refType: (def as any).refType ?? undefined,
|
|
765
|
-
pii: def._pii ?? undefined,
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
return {
|
|
770
|
-
entityType,
|
|
771
|
-
version,
|
|
772
|
-
shape: description,
|
|
773
|
-
};
|
|
774
|
-
},
|
|
775
|
-
};
|
|
776
|
-
|
|
777
|
-
// Register the schema globally
|
|
778
|
-
globalSchemaRegistry.set(entityType, schema);
|
|
779
|
-
return schema;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
/**
|
|
783
|
-
* Retrieves a previously registered schema by its `entityType` from the
|
|
784
|
-
* in-process global schema registry.
|
|
785
|
-
*/
|
|
786
|
-
export function getSchemaForType(type: string): Schema<any> | undefined {
|
|
787
|
-
return globalSchemaRegistry.get(type);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Returns all schemas registered in the in-process global registry.
|
|
792
|
-
*/
|
|
793
|
-
export function getAllSchemas(): Schema<any>[] {
|
|
794
|
-
return Array.from(globalSchemaRegistry.values());
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/**
|
|
798
|
-
* Renders a schema into a simplified descriptor for front-end consumption.
|
|
799
|
-
* Intended for documentation and admin tooling rather than validation.
|
|
800
|
-
*/
|
|
801
|
-
export function renderSchemaDescription(
|
|
802
|
-
schema: Schema<any>
|
|
803
|
-
): {
|
|
804
|
-
title: string;
|
|
805
|
-
fields: Array<{
|
|
806
|
-
name: string;
|
|
807
|
-
type: FieldType;
|
|
808
|
-
optional: boolean;
|
|
809
|
-
description: string;
|
|
810
|
-
deprecated: boolean;
|
|
811
|
-
pii?: string;
|
|
812
|
-
}>;
|
|
813
|
-
} {
|
|
814
|
-
const meta = schema.describe();
|
|
815
|
-
return {
|
|
816
|
-
title: `${meta.entityType} (v${meta.version})`,
|
|
817
|
-
fields: Object.entries(meta.shape).map(([name, def]) => ({
|
|
818
|
-
name,
|
|
819
|
-
type: def.type,
|
|
820
|
-
optional: def.optional,
|
|
821
|
-
description: def.description,
|
|
822
|
-
deprecated: def.deprecated,
|
|
823
|
-
pii: def.pii ? def.pii.classification : undefined,
|
|
824
|
-
})),
|
|
825
|
-
};
|
|
826
|
-
}
|