bank20baht-validator 0.5.0 → 0.5.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 +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +112 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ step with the core version or the validator's `tsc` build resolves a stale copy
|
|
|
69
69
|
|
|
70
70
|
`options` is `{ strict?: boolean }` (default `true`, see above).
|
|
71
71
|
| `parseSchema(json)` | structural gate for storing builder output (throws on bad input) |
|
|
72
|
+
| `verifyReferences(schema, data, registry?)` | async: resolves every `reference` field id through `registry.references[name].resolve` and returns issues for records that do not exist (call after `validateSubmission`) |
|
|
72
73
|
| `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
|
|
73
74
|
|
|
74
75
|
All results are `{ valid, issues, data }`; issues are `{ ref, message }` where `ref` is the field key (forms) or `form_name.key` (workflows).
|
package/dist/index.d.ts
CHANGED
|
@@ -52,6 +52,21 @@ export declare function validateWorkflowData(workflow: WorkflowDefinition, input
|
|
|
52
52
|
* const result = validateSubmission(schema, body.data);
|
|
53
53
|
*/
|
|
54
54
|
export declare function validateSubmission(schema: SchemaDefinition, data: unknown, registry?: Registry, locale?: Locale, options?: ValidateOptions): ValidationResult;
|
|
55
|
+
/**
|
|
56
|
+
* Do the records picked in `reference` fields exist? `validateSubmission` only checks that a
|
|
57
|
+
* reference value is an id string — existence needs the lookup, which is async. Call this
|
|
58
|
+
* after it, with the same registry the client uses:
|
|
59
|
+
*
|
|
60
|
+
* const result = validateSubmission(schema, body.data, registry);
|
|
61
|
+
* const missing = await verifyReferences(schema, result.data, registry);
|
|
62
|
+
*
|
|
63
|
+
* Each distinct `lookup + id` is resolved once (in parallel). An id `resolve` returns `null`
|
|
64
|
+
* for is an issue on that field ("Record not found"); a lookup the schema names but the
|
|
65
|
+
* registry lacks is an issue too; a lookup without `resolve` is trusted. Workflow refs are
|
|
66
|
+
* `form_name.key`. Top-level fields only (reference fields inside repeater rows are not
|
|
67
|
+
* resolved).
|
|
68
|
+
*/
|
|
69
|
+
export declare function verifyReferences(schema: SchemaDefinition, data: unknown, registry?: Registry): Promise<ValidationIssue[]>;
|
|
55
70
|
/**
|
|
56
71
|
* Structural gate for storing builder output — the same checks the builder's
|
|
57
72
|
* Import applies, so the server only ever stores schemas the client could
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// every value must have the shape the UI control could actually emit before
|
|
14
14
|
// the authored validators run.
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
|
-
import { applyFlags, approvalFields, conditionRowRefs, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, normalizeTagsValue, parseDisabledEntry, parseIsoDate, parseIsoDateTime, parseRowRef, resolveViewFields, setCatalog, setLocale, t, isBlank, isSafeUrl, isUnsafePattern, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
|
|
16
|
+
import { applyFlags, approvalFields, conditionRowRefs, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, normalizeTagsValue, parseDisabledEntry, parseIsoDate, parseIsoDateTime, parseRowRef, rangeBounds, ratingMax, resolveViewFields, setCatalog, setLocale, t, isBlank, isSafeUrl, isUnsafePattern, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
|
|
17
17
|
/**
|
|
18
18
|
* Run `fn` with the catalog switched to `locale`, restoring the previous
|
|
19
19
|
* catalog after. The engine catalog is module-global; validation is fully
|
|
@@ -34,6 +34,10 @@ function withLocale(locale, fn) {
|
|
|
34
34
|
}
|
|
35
35
|
/** Keys that write through Object.prototype — never valid as field/form names. */
|
|
36
36
|
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
37
|
+
/** Static blocks (`label`, `section`): no value, never in the payload, never validated. */
|
|
38
|
+
function isStatic(field) {
|
|
39
|
+
return field.type === 'label' || field.type === 'section';
|
|
40
|
+
}
|
|
37
41
|
const DATE_MODES = new Set(['single', 'range', 'multiple']);
|
|
38
42
|
const FIELD_TYPES = new Set([
|
|
39
43
|
'text',
|
|
@@ -50,8 +54,12 @@ const FIELD_TYPES = new Set([
|
|
|
50
54
|
'date',
|
|
51
55
|
'file',
|
|
52
56
|
'label',
|
|
57
|
+
'section',
|
|
53
58
|
'repeater',
|
|
54
59
|
'tags',
|
|
60
|
+
'rating',
|
|
61
|
+
'range',
|
|
62
|
+
'reference',
|
|
55
63
|
]);
|
|
56
64
|
export function isWorkflowDefinition(schema) {
|
|
57
65
|
return (isRecord(schema) &&
|
|
@@ -156,11 +164,13 @@ function typeIssue(field, value) {
|
|
|
156
164
|
case 'textarea':
|
|
157
165
|
case 'email':
|
|
158
166
|
case 'password':
|
|
167
|
+
case 'reference':
|
|
159
168
|
return shapeMessage(typeof value === 'string');
|
|
160
169
|
case 'date':
|
|
161
170
|
return dateIssue(field, value);
|
|
162
171
|
case 'number':
|
|
163
172
|
case 'currency':
|
|
173
|
+
case 'range':
|
|
164
174
|
return shapeMessage(typeof value === 'number' && Number.isFinite(value));
|
|
165
175
|
case 'checkbox':
|
|
166
176
|
case 'toggle':
|
|
@@ -172,6 +182,11 @@ function typeIssue(field, value) {
|
|
|
172
182
|
return multiChoiceIssue(value, optionKeys);
|
|
173
183
|
case 'tags':
|
|
174
184
|
return tagsShapeIssue(field, value, optionKeys);
|
|
185
|
+
case 'rating':
|
|
186
|
+
return shapeMessage(typeof value === 'number' &&
|
|
187
|
+
Number.isInteger(value) &&
|
|
188
|
+
value >= 1 &&
|
|
189
|
+
value <= ratingMax(field));
|
|
175
190
|
case 'file':
|
|
176
191
|
return fileIssue(field, value);
|
|
177
192
|
case 'repeater':
|
|
@@ -198,7 +213,7 @@ function shapeIssues(field, value, ref) {
|
|
|
198
213
|
return;
|
|
199
214
|
}
|
|
200
215
|
for (const sub of field.fields ?? []) {
|
|
201
|
-
if (sub
|
|
216
|
+
if (isStatic(sub) || UNSAFE_KEYS.has(sub.key))
|
|
202
217
|
continue;
|
|
203
218
|
const v = Object.hasOwn(row, sub.key) ? row[sub.key] : undefined;
|
|
204
219
|
const m = typeIssue(sub, v);
|
|
@@ -323,7 +338,7 @@ function canonicalRow(field, row) {
|
|
|
323
338
|
return row;
|
|
324
339
|
const next = {};
|
|
325
340
|
for (const sub of field.fields ?? []) {
|
|
326
|
-
if (sub
|
|
341
|
+
if (isStatic(sub) || UNSAFE_KEYS.has(sub.key) || !Object.hasOwn(row, sub.key))
|
|
327
342
|
continue;
|
|
328
343
|
next[sub.key] = normalizeCanonicalValue(sub, row[sub.key]);
|
|
329
344
|
}
|
|
@@ -373,7 +388,7 @@ function formDataResult(form, input, registry) {
|
|
|
373
388
|
const issues = [];
|
|
374
389
|
const fields = [];
|
|
375
390
|
for (const f of sorted(form.fields)) {
|
|
376
|
-
if (f
|
|
391
|
+
if (isStatic(f))
|
|
377
392
|
continue;
|
|
378
393
|
if (UNSAFE_KEYS.has(f.key)) {
|
|
379
394
|
issues.push({ ref: f.key, message: 'unsafe field key' });
|
|
@@ -526,7 +541,7 @@ function rowStatesOf(form, field, derived) {
|
|
|
526
541
|
function formFieldIssues(form, derived, registry) {
|
|
527
542
|
const issues = [];
|
|
528
543
|
for (const field of sorted([...form.fields, ...approvalFields(form)])) {
|
|
529
|
-
if (field
|
|
544
|
+
if (isStatic(field))
|
|
530
545
|
continue;
|
|
531
546
|
issues.push(...workflowFieldIssues(form, field, derived, registry));
|
|
532
547
|
}
|
|
@@ -613,6 +628,67 @@ export function validateSubmission(schema, data, registry, locale, options) {
|
|
|
613
628
|
data: {},
|
|
614
629
|
};
|
|
615
630
|
}
|
|
631
|
+
/**
|
|
632
|
+
* Do the records picked in `reference` fields exist? `validateSubmission` only checks that a
|
|
633
|
+
* reference value is an id string — existence needs the lookup, which is async. Call this
|
|
634
|
+
* after it, with the same registry the client uses:
|
|
635
|
+
*
|
|
636
|
+
* const result = validateSubmission(schema, body.data, registry);
|
|
637
|
+
* const missing = await verifyReferences(schema, result.data, registry);
|
|
638
|
+
*
|
|
639
|
+
* Each distinct `lookup + id` is resolved once (in parallel). An id `resolve` returns `null`
|
|
640
|
+
* for is an issue on that field ("Record not found"); a lookup the schema names but the
|
|
641
|
+
* registry lacks is an issue too; a lookup without `resolve` is trusted. Workflow refs are
|
|
642
|
+
* `form_name.key`. Top-level fields only (reference fields inside repeater rows are not
|
|
643
|
+
* resolved).
|
|
644
|
+
*/
|
|
645
|
+
export async function verifyReferences(schema, data, registry) {
|
|
646
|
+
const checks = [];
|
|
647
|
+
const collect = (fields, values, prefix) => {
|
|
648
|
+
if (!isRecord(values))
|
|
649
|
+
return;
|
|
650
|
+
for (const field of fields) {
|
|
651
|
+
if (field.type !== 'reference' || !Object.hasOwn(values, field.key))
|
|
652
|
+
continue;
|
|
653
|
+
const id = values[field.key];
|
|
654
|
+
if (typeof id === 'string' && id !== '') {
|
|
655
|
+
checks.push({ ref: `${prefix}${field.key}`, lookup: field.reference ?? '', id });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
if (isWorkflowDefinition(schema)) {
|
|
660
|
+
for (const form of schema.forms) {
|
|
661
|
+
collect(form.fields, isRecord(data) ? data[form.form_name] : undefined, `${form.form_name}.`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else if (isFormDefinition(schema)) {
|
|
665
|
+
collect(schema.fields, data, '');
|
|
666
|
+
}
|
|
667
|
+
const lookups = registry?.references ?? {};
|
|
668
|
+
const found = new Map();
|
|
669
|
+
const exists = (lookup, id) => {
|
|
670
|
+
const resolve = Object.hasOwn(lookups, lookup) ? lookups[lookup]?.resolve : undefined;
|
|
671
|
+
if (!resolve)
|
|
672
|
+
return null;
|
|
673
|
+
const key = `${lookup}\u0000${id}`;
|
|
674
|
+
let pending = found.get(key);
|
|
675
|
+
if (!pending) {
|
|
676
|
+
pending = Promise.resolve(resolve(id)).then((record) => record !== null, () => false);
|
|
677
|
+
found.set(key, pending);
|
|
678
|
+
}
|
|
679
|
+
return pending;
|
|
680
|
+
};
|
|
681
|
+
const issues = await Promise.all(checks.map(async ({ ref, lookup, id }) => {
|
|
682
|
+
if (!Object.hasOwn(lookups, lookup)) {
|
|
683
|
+
return { ref, message: `unknown reference lookup ${JSON.stringify(lookup)}` };
|
|
684
|
+
}
|
|
685
|
+
const pending = exists(lookup, id);
|
|
686
|
+
if (pending === null)
|
|
687
|
+
return null; // no resolver: trusted
|
|
688
|
+
return (await pending) ? null : { ref, message: t('referenceNotFound') };
|
|
689
|
+
}));
|
|
690
|
+
return issues.filter((issue) => issue !== null);
|
|
691
|
+
}
|
|
616
692
|
/**
|
|
617
693
|
* Structural gate for storing builder output — the same checks the builder's
|
|
618
694
|
* Import applies, so the server only ever stores schemas the client could
|
|
@@ -725,7 +801,7 @@ function fieldRefs(wf) {
|
|
|
725
801
|
const refs = new Set();
|
|
726
802
|
for (const form of wf.forms) {
|
|
727
803
|
for (const field of [...form.fields, ...approvalFields(form)]) {
|
|
728
|
-
if (field
|
|
804
|
+
if (isStatic(field))
|
|
729
805
|
continue;
|
|
730
806
|
refs.add(`${form.form_name}.${field.key}`);
|
|
731
807
|
for (const ref of rowRefsOf(form.form_name, field))
|
|
@@ -773,9 +849,9 @@ function assertValidFields(form) {
|
|
|
773
849
|
}
|
|
774
850
|
}
|
|
775
851
|
function assertFieldKey(field, form_name) {
|
|
776
|
-
// labels are static blocks and may omit the key; everything else needs one
|
|
852
|
+
// labels/sections are static blocks and may omit the key; everything else needs one
|
|
777
853
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
|
|
778
|
-
if (field
|
|
854
|
+
if (isStatic(field) && field.key === undefined)
|
|
779
855
|
return;
|
|
780
856
|
if (typeof field.key !== 'string' || field.key === '') {
|
|
781
857
|
throw new Error(`field on ${form_name} needs a non-empty string key`);
|
|
@@ -822,6 +898,13 @@ function assertFieldShape(field, form_name) {
|
|
|
822
898
|
assertFieldKey(field, form_name);
|
|
823
899
|
if (field.type === 'date')
|
|
824
900
|
assertDateProps(field, form_name);
|
|
901
|
+
if (field.type === 'rating')
|
|
902
|
+
assertRatingProps(field, form_name);
|
|
903
|
+
if (field.type === 'range')
|
|
904
|
+
assertRangeProps(field, form_name);
|
|
905
|
+
if (field.type === 'reference' && (typeof field.reference !== 'string' || !field.reference)) {
|
|
906
|
+
throw new Error(`reference on ${form_name}.${field.key} must name a registry lookup (registry.references)`);
|
|
907
|
+
}
|
|
825
908
|
if (field.options !== undefined &&
|
|
826
909
|
(!Array.isArray(field.options) ||
|
|
827
910
|
field.options.some((o) => !isRecord(o) || typeof o.key !== 'string'))) {
|
|
@@ -830,6 +913,27 @@ function assertFieldShape(field, form_name) {
|
|
|
830
913
|
assertFieldValidators(field, form_name);
|
|
831
914
|
}
|
|
832
915
|
/** Calendar constraints must be well-formed or the picker silently ignores them. */
|
|
916
|
+
function assertRatingProps(field, form_name) {
|
|
917
|
+
const max = field.max;
|
|
918
|
+
if (max === undefined)
|
|
919
|
+
return;
|
|
920
|
+
if (typeof max !== 'number' || !Number.isInteger(max) || max < 1 || max > 10) {
|
|
921
|
+
throw new Error(`max on ${form_name}.${field.key} must be a whole number from 1 to 10 (star count)`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function assertRangeProps(field, form_name) {
|
|
925
|
+
const where = `${form_name}.${field.key}`;
|
|
926
|
+
const { min, max, step } = field;
|
|
927
|
+
const notNumber = (v) => v !== undefined && (typeof v !== 'number' || !Number.isFinite(v));
|
|
928
|
+
if (notNumber(min) || notNumber(max))
|
|
929
|
+
throw new Error(`min and max on ${where} must be numbers`);
|
|
930
|
+
const bounds = rangeBounds(field);
|
|
931
|
+
if (bounds.min >= bounds.max)
|
|
932
|
+
throw new Error(`min on ${where} must be below max`);
|
|
933
|
+
if (step !== undefined && (typeof step !== 'number' || !(step > 0))) {
|
|
934
|
+
throw new Error(`step on ${where} must be a positive number`);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
833
937
|
function assertDateProps(field, form_name) {
|
|
834
938
|
const where = `${form_name}.${field.key}`;
|
|
835
939
|
if (field.date_mode !== undefined && !DATE_MODES.has(field.date_mode)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-validator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime validation for bahtui form/workflow JSON \u2014 the exact engine rules the client runs, on the server.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"bank20baht-ui": "^0.13.
|
|
44
|
+
"bank20baht-ui": "^0.13.3",
|
|
45
45
|
"typescript": "^5.8.3",
|
|
46
46
|
"vitest": "^3.2.4"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"bank20baht-ui": ">=0.13.
|
|
49
|
+
"bank20baht-ui": ">=0.13.3 <0.14.0"
|
|
50
50
|
},
|
|
51
51
|
"main": "./dist/index.js",
|
|
52
52
|
"types": "./dist/index.d.ts",
|