bank20baht-validator 0.4.3 → 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 +10 -5
- package/dist/index.d.ts +27 -3
- package/dist/index.js +236 -44
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
|
|
|
46
46
|
|
|
47
47
|
## Compatibility
|
|
48
48
|
|
|
49
|
-
Peers on `bank20baht-ui` **>=0.
|
|
49
|
+
Peers on `bank20baht-ui` **>=0.13 <0.14** (the engine and the `FieldType` union come from there; see `peerDependencies`). In a workspace that also builds `bank20baht-ui`, keep the peer range in
|
|
50
50
|
step with the core version or the validator's `tsc` build resolves a stale copy from npm.
|
|
51
51
|
|
|
52
52
|
## What it enforces
|
|
@@ -54,17 +54,22 @@ step with the core version or the validator's `tsc` build resolves a stale copy
|
|
|
54
54
|
- Every declarative validator of the field descriptors: `required`, `min_length`, `max_length`, `min`, `max`, `pattern`, `email`, and `custom` (via a `Registry`).
|
|
55
55
|
- Value **shape** per field type before any validator runs — a `password`/`text` must be a string, a `number` a finite number, a `dropdown` one of its option keys — so `{"$gt":""}` from a raw API call is rejected, not treated as "non-empty".
|
|
56
56
|
- **Workflow rules first** (`evaluate` from `bank20baht-ui/engine`): fields hidden by rules are excluded and smuggled values are cleared; `required` derived from rules is enforced; `setValue` results are computed.
|
|
57
|
-
- Approval steps: a `decision` must be present, and the comment policy (`required` / `requireOnReject`) is honored.
|
|
57
|
+
- Approval steps: a `decision` must be present, the comment must be a string, and the comment policy (`required` / `requireOnReject`) is honored (a blank or zero-width comment does not count).
|
|
58
|
+
- **Canonical data holds only what the UI could emit:** repeater rows keep their declared columns only (a row with its own `__proto__` key is rejected), file values must be uploaded refs `{ name, size, url, type? }` with an `http(s)` or relative `url` and keep only those keys, `-0` is stored as `0`.
|
|
59
|
+
- **Strict by default (0.5.0):** a `custom` validator or `submit` script with no own entry in the registry, and a script that throws, are issues instead of being skipped. Pass `{ strict: false }` as the fifth argument to get the client's warn-and-skip behaviour (previews, docs tooling).
|
|
58
60
|
- **Client `submit` scripts** (spec 01 §14): re-run headlessly with the same `registry.scripts` — `g.setValue()` lands in canonical data, `g.addErrorMessage()` becomes an issue on `$` (`form_name.$` in a workflow), returning `false` blocks. `load` / `change` scripts are UI-only and never run here, so a field a client script hides is still validated: keep submit-relevant logic in rules or validators. `parseSchema` rejects a `scripts[]` entry with an unknown `on`, no `handler`, or a `change` hook on a field that does not exist.
|
|
59
61
|
|
|
60
62
|
## API
|
|
61
63
|
|
|
62
64
|
| export | what it does |
|
|
63
65
|
| --- | --- |
|
|
64
|
-
| `validateSubmission(schema, data, registry?)` | dispatches on schema kind — the single server entry point |
|
|
65
|
-
| `validateFormData(form, data, registry?)` | mirrors `<baht-form>.validate()` for a standalone form |
|
|
66
|
-
| `validateWorkflowData(workflow, data, registry?)` | evaluates rules, then validates every visible form |
|
|
66
|
+
| `validateSubmission(schema, data, registry?, locale?, options?)` | dispatches on schema kind — the single server entry point |
|
|
67
|
+
| `validateFormData(form, data, registry?, locale?, options?)` | mirrors `<baht-form>.validate()` for a standalone form |
|
|
68
|
+
| `validateWorkflowData(workflow, data, registry?, locale?, options?)` | evaluates rules, then validates every visible form |
|
|
69
|
+
|
|
70
|
+
`options` is `{ strict?: boolean }` (default `true`, see above).
|
|
67
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`) |
|
|
68
73
|
| `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
|
|
69
74
|
|
|
70
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
|
@@ -5,6 +5,15 @@ export interface ValidationIssue {
|
|
|
5
5
|
ref: string;
|
|
6
6
|
message: string;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* `strict` (default true) fails closed on anything the server cannot check:
|
|
10
|
+
* a `custom` validator or a `submit` script with no registry entry, and a
|
|
11
|
+
* script that throws, each become an issue. `strict: false` restores the
|
|
12
|
+
* client's warn-and-skip behaviour for callers that only preview.
|
|
13
|
+
*/
|
|
14
|
+
export interface ValidateOptions {
|
|
15
|
+
strict?: boolean;
|
|
16
|
+
}
|
|
8
17
|
export interface ValidationResult {
|
|
9
18
|
valid: boolean;
|
|
10
19
|
issues: ValidationIssue[];
|
|
@@ -24,7 +33,7 @@ export declare function isFormDefinition(schema: unknown): schema is FormDefinit
|
|
|
24
33
|
* views are client-side display only, the server always validates the full
|
|
25
34
|
* form (a view can never weaken a validation rule).
|
|
26
35
|
*/
|
|
27
|
-
export declare function validateFormData(form: FormDefinition, input: unknown, registry?: Registry, locale?: Locale): ValidationResult;
|
|
36
|
+
export declare function validateFormData(form: FormDefinition, input: unknown, registry?: Registry, locale?: Locale, options?: ValidateOptions): ValidationResult;
|
|
28
37
|
/**
|
|
29
38
|
* Validate a whole workflow submission — mirrors what `<baht-workflow>`
|
|
30
39
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
@@ -34,7 +43,7 @@ export declare function validateFormData(form: FormDefinition, input: unknown, r
|
|
|
34
43
|
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
35
44
|
* forms are never visited.
|
|
36
45
|
*/
|
|
37
|
-
export declare function validateWorkflowData(workflow: WorkflowDefinition, input: unknown, registry?: Registry, locale?: Locale): ValidationResult;
|
|
46
|
+
export declare function validateWorkflowData(workflow: WorkflowDefinition, input: unknown, registry?: Registry, locale?: Locale, options?: ValidateOptions): ValidationResult;
|
|
38
47
|
/**
|
|
39
48
|
* Validate a submission body's `data` against whichever schema kind `id`
|
|
40
49
|
* resolved to. This is the single server entry point:
|
|
@@ -42,7 +51,22 @@ export declare function validateWorkflowData(workflow: WorkflowDefinition, input
|
|
|
42
51
|
* const schema = store.get(body.id);
|
|
43
52
|
* const result = validateSubmission(schema, body.data);
|
|
44
53
|
*/
|
|
45
|
-
export declare function validateSubmission(schema: SchemaDefinition, data: unknown, registry?: Registry, locale?: Locale): ValidationResult;
|
|
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[]>;
|
|
46
70
|
/**
|
|
47
71
|
* Structural gate for storing builder output — the same checks the builder's
|
|
48
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, 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) &&
|
|
@@ -127,10 +135,24 @@ function tagsShapeIssue(field, value, optionKeys) {
|
|
|
127
135
|
}
|
|
128
136
|
return null;
|
|
129
137
|
}
|
|
138
|
+
/** The serializable ref an uploader returns (spec 01 §13), with a link-safe url. */
|
|
139
|
+
function isUploadedFile(v) {
|
|
140
|
+
return (isRecord(v) &&
|
|
141
|
+
typeof v.name === 'string' &&
|
|
142
|
+
typeof v.size === 'number' &&
|
|
143
|
+
Number.isFinite(v.size) &&
|
|
144
|
+
v.size >= 0 &&
|
|
145
|
+
typeof v.url === 'string' &&
|
|
146
|
+
isSafeUrl(v.url) &&
|
|
147
|
+
(v.type === undefined || typeof v.type === 'string'));
|
|
148
|
+
}
|
|
149
|
+
function canonicalRef({ name, size, url, type }) {
|
|
150
|
+
return type === undefined ? { name, size, url } : { name, size, url, type };
|
|
151
|
+
}
|
|
130
152
|
function fileIssue(field, value) {
|
|
131
153
|
if (field.multiple)
|
|
132
|
-
return shapeMessage(Array.isArray(value) && value.every(
|
|
133
|
-
return shapeMessage(
|
|
154
|
+
return shapeMessage(Array.isArray(value) && value.every(isUploadedFile));
|
|
155
|
+
return shapeMessage(isUploadedFile(value));
|
|
134
156
|
}
|
|
135
157
|
function typeIssue(field, value) {
|
|
136
158
|
if (value === null || value === undefined)
|
|
@@ -142,11 +164,13 @@ function typeIssue(field, value) {
|
|
|
142
164
|
case 'textarea':
|
|
143
165
|
case 'email':
|
|
144
166
|
case 'password':
|
|
167
|
+
case 'reference':
|
|
145
168
|
return shapeMessage(typeof value === 'string');
|
|
146
169
|
case 'date':
|
|
147
170
|
return dateIssue(field, value);
|
|
148
171
|
case 'number':
|
|
149
172
|
case 'currency':
|
|
173
|
+
case 'range':
|
|
150
174
|
return shapeMessage(typeof value === 'number' && Number.isFinite(value));
|
|
151
175
|
case 'checkbox':
|
|
152
176
|
case 'toggle':
|
|
@@ -158,6 +182,11 @@ function typeIssue(field, value) {
|
|
|
158
182
|
return multiChoiceIssue(value, optionKeys);
|
|
159
183
|
case 'tags':
|
|
160
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));
|
|
161
190
|
case 'file':
|
|
162
191
|
return fileIssue(field, value);
|
|
163
192
|
case 'repeater':
|
|
@@ -179,8 +208,12 @@ function shapeIssues(field, value, ref) {
|
|
|
179
208
|
issues.push({ ref: `${ref}[${i}]`, message: t('pattern') });
|
|
180
209
|
return;
|
|
181
210
|
}
|
|
211
|
+
if (Object.hasOwn(row, '__proto__')) {
|
|
212
|
+
issues.push({ ref: `${ref}[${i}]`, message: t('pattern') });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
182
215
|
for (const sub of field.fields ?? []) {
|
|
183
|
-
if (sub
|
|
216
|
+
if (isStatic(sub) || UNSAFE_KEYS.has(sub.key))
|
|
184
217
|
continue;
|
|
185
218
|
const v = Object.hasOwn(row, sub.key) ? row[sub.key] : undefined;
|
|
186
219
|
const m = typeIssue(sub, v);
|
|
@@ -199,8 +232,66 @@ function shapeIssues(field, value, ref) {
|
|
|
199
232
|
* views are client-side display only, the server always validates the full
|
|
200
233
|
* form (a view can never weaken a validation rule).
|
|
201
234
|
*/
|
|
202
|
-
export function validateFormData(form, input, registry, locale) {
|
|
203
|
-
|
|
235
|
+
export function validateFormData(form, input, registry, locale, options) {
|
|
236
|
+
const reg = serverRegistry([form], registry, options);
|
|
237
|
+
return withLocale(locale, () => formDataResult(form, input, reg));
|
|
238
|
+
}
|
|
239
|
+
function ownEntry(map, name) {
|
|
240
|
+
return map && Object.hasOwn(map, name) ? map[name] : undefined;
|
|
241
|
+
}
|
|
242
|
+
function guardedScript(name, handler) {
|
|
243
|
+
return (g, detail) => {
|
|
244
|
+
try {
|
|
245
|
+
return handler(g, detail);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
console.error(`[bank20baht-validator] submit script "${name}" threw`, err);
|
|
249
|
+
g.addErrorMessage(`submit script "${name}" failed`);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* The registry the engine sees. In strict mode every `custom` validator and
|
|
256
|
+
* `submit` script the schema names resolves to something that fails closed:
|
|
257
|
+
* the caller's own entry (scripts wrapped so a throw is an issue), else a stub
|
|
258
|
+
* that reports the missing name. Own entries only, so `toString` never
|
|
259
|
+
* resolves through Object.prototype.
|
|
260
|
+
*/
|
|
261
|
+
function serverRegistry(forms, registry, options) {
|
|
262
|
+
if (options?.strict === false)
|
|
263
|
+
return registry;
|
|
264
|
+
const validators = new Map();
|
|
265
|
+
const scripts = new Map();
|
|
266
|
+
for (const form of forms) {
|
|
267
|
+
const fields = form.fields.flatMap((f) => [
|
|
268
|
+
f,
|
|
269
|
+
...(f.type === 'repeater' ? (f.fields ?? []) : []),
|
|
270
|
+
]);
|
|
271
|
+
for (const spec of fields.flatMap((f) => f.validators ?? [])) {
|
|
272
|
+
if (spec.type !== 'custom')
|
|
273
|
+
continue;
|
|
274
|
+
const name = spec.fn ?? '';
|
|
275
|
+
validators.set(name, ownEntry(registry?.validators, name) ?? (() => `unknown custom validator "${name}"`));
|
|
276
|
+
}
|
|
277
|
+
for (const spec of form.scripts ?? []) {
|
|
278
|
+
if (spec.on !== 'submit')
|
|
279
|
+
continue;
|
|
280
|
+
const name = spec.handler;
|
|
281
|
+
const handler = ownEntry(registry?.scripts, name);
|
|
282
|
+
scripts.set(name, handler
|
|
283
|
+
? guardedScript(name, handler)
|
|
284
|
+
: (g) => {
|
|
285
|
+
g.addErrorMessage(`submit script "${name}" is not registered`);
|
|
286
|
+
return false;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
...registry,
|
|
292
|
+
validators: Object.fromEntries(validators),
|
|
293
|
+
scripts: Object.fromEntries(scripts),
|
|
294
|
+
};
|
|
204
295
|
}
|
|
205
296
|
/**
|
|
206
297
|
* A tags value as the client emits it: trimmed, case-insensitive deduped
|
|
@@ -219,30 +310,39 @@ function canonicalTags(field, v) {
|
|
|
219
310
|
return normalizeTagsValue(v, field.options_from ? undefined : field.options);
|
|
220
311
|
}
|
|
221
312
|
/**
|
|
222
|
-
* Canonical value for one field: `tags` via
|
|
223
|
-
* transform `validateField` applies internally
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* repeater never nests, spec 01 §10). Every other
|
|
227
|
-
*
|
|
228
|
-
*
|
|
313
|
+
* Canonical value for one field, as the client would emit it: `tags` via
|
|
314
|
+
* `canonicalTags` (the transform `validateField` applies internally, so the
|
|
315
|
+
* persisted `data` matches byte-for-byte); a file ref keeps only its ref
|
|
316
|
+
* fields; `-0` becomes `0`; a repeater row is rebuilt from its declared
|
|
317
|
+
* columns only (a repeater never nests, spec 01 §10). Every other value
|
|
318
|
+
* passes through. Runs after `shapeIssues`, or on rule input where a
|
|
319
|
+
* wrong-shaped value is left for `shapeIssues` to reject.
|
|
229
320
|
*/
|
|
230
321
|
function normalizeCanonicalValue(field, raw) {
|
|
231
322
|
if (field.type === 'tags')
|
|
232
323
|
return canonicalTags(field, raw);
|
|
233
|
-
if (field.type
|
|
234
|
-
return raw;
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
return
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
324
|
+
if (field.type === 'number' || field.type === 'currency')
|
|
325
|
+
return Object.is(raw, -0) ? 0 : raw;
|
|
326
|
+
if (field.type === 'file') {
|
|
327
|
+
if (isUploadedFile(raw))
|
|
328
|
+
return canonicalRef(raw);
|
|
329
|
+
return Array.isArray(raw) && raw.every(isUploadedFile) ? raw.map(canonicalRef) : raw;
|
|
330
|
+
}
|
|
331
|
+
if (field.type === 'repeater' && Array.isArray(raw)) {
|
|
332
|
+
return raw.map((row) => canonicalRow(field, row));
|
|
333
|
+
}
|
|
334
|
+
return raw;
|
|
335
|
+
}
|
|
336
|
+
function canonicalRow(field, row) {
|
|
337
|
+
if (!isRecord(row))
|
|
338
|
+
return row;
|
|
339
|
+
const next = {};
|
|
340
|
+
for (const sub of field.fields ?? []) {
|
|
341
|
+
if (isStatic(sub) || UNSAFE_KEYS.has(sub.key) || !Object.hasOwn(row, sub.key))
|
|
342
|
+
continue;
|
|
343
|
+
next[sub.key] = normalizeCanonicalValue(sub, row[sub.key]);
|
|
344
|
+
}
|
|
345
|
+
return next;
|
|
246
346
|
}
|
|
247
347
|
/**
|
|
248
348
|
* Copy of the workflow input with every tags value (and tags column)
|
|
@@ -288,7 +388,7 @@ function formDataResult(form, input, registry) {
|
|
|
288
388
|
const issues = [];
|
|
289
389
|
const fields = [];
|
|
290
390
|
for (const f of sorted(form.fields)) {
|
|
291
|
-
if (f
|
|
391
|
+
if (isStatic(f))
|
|
292
392
|
continue;
|
|
293
393
|
if (UNSAFE_KEYS.has(f.key)) {
|
|
294
394
|
issues.push({ ref: f.key, message: 'unsafe field key' });
|
|
@@ -362,8 +462,9 @@ function submitScriptIssues(form, data, registry, refPrefix = '') {
|
|
|
362
462
|
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
363
463
|
* forms are never visited.
|
|
364
464
|
*/
|
|
365
|
-
export function validateWorkflowData(workflow, input, registry, locale) {
|
|
366
|
-
|
|
465
|
+
export function validateWorkflowData(workflow, input, registry, locale, options) {
|
|
466
|
+
const reg = serverRegistry(workflow.forms, registry, options);
|
|
467
|
+
return withLocale(locale, () => workflowDataResult(workflow, input, reg));
|
|
367
468
|
}
|
|
368
469
|
/** Early-out for input that is not `{ formName: {…} }`, else null. */
|
|
369
470
|
function workflowInputRejection(input) {
|
|
@@ -439,8 +540,8 @@ function rowStatesOf(form, field, derived) {
|
|
|
439
540
|
}
|
|
440
541
|
function formFieldIssues(form, derived, registry) {
|
|
441
542
|
const issues = [];
|
|
442
|
-
for (const field of sorted(form.fields)) {
|
|
443
|
-
if (field
|
|
543
|
+
for (const field of sorted([...form.fields, ...approvalFields(form)])) {
|
|
544
|
+
if (isStatic(field))
|
|
444
545
|
continue;
|
|
445
546
|
issues.push(...workflowFieldIssues(form, field, derived, registry));
|
|
446
547
|
}
|
|
@@ -479,11 +580,11 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
479
580
|
.map((e) => ({ ref: e.ref, message: e.message }));
|
|
480
581
|
const pathList = pruneOffPath(workflow, derived);
|
|
481
582
|
for (const form of visitedForms(workflow, derived, pathList)) {
|
|
583
|
+
issues.push(...formFieldIssues(form, derived, registry));
|
|
482
584
|
if (form.kind === 'approval') {
|
|
483
585
|
issues.push(...approvalIssues(form, derived.data));
|
|
484
586
|
continue;
|
|
485
587
|
}
|
|
486
|
-
issues.push(...formFieldIssues(form, derived, registry));
|
|
487
588
|
if (hasScripts(form, 'submit')) {
|
|
488
589
|
const formData = (derived.data[form.form_name] ??= {});
|
|
489
590
|
issues.push(...submitScriptIssues(form, formData, registry, `${form.form_name}.`));
|
|
@@ -501,8 +602,9 @@ function approvalIssues(form, data) {
|
|
|
501
602
|
}
|
|
502
603
|
const mode = form.approval?.comment ?? 'optional';
|
|
503
604
|
const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
|
|
504
|
-
//
|
|
505
|
-
|
|
605
|
+
// the comment's shape was already checked; a wrong shape was reset to null
|
|
606
|
+
const comment = typeof values.comment === 'string' ? values.comment : '';
|
|
607
|
+
if (commentRequired && isBlank(comment)) {
|
|
506
608
|
issues.push({ ref: `${form.form_name}.comment`, message: t('commentRequired') });
|
|
507
609
|
}
|
|
508
610
|
return issues;
|
|
@@ -514,17 +616,79 @@ function approvalIssues(form, data) {
|
|
|
514
616
|
* const schema = store.get(body.id);
|
|
515
617
|
* const result = validateSubmission(schema, body.data);
|
|
516
618
|
*/
|
|
517
|
-
export function validateSubmission(schema, data, registry, locale) {
|
|
518
|
-
if (isWorkflowDefinition(schema))
|
|
519
|
-
return validateWorkflowData(schema, data, registry, locale);
|
|
619
|
+
export function validateSubmission(schema, data, registry, locale, options) {
|
|
620
|
+
if (isWorkflowDefinition(schema)) {
|
|
621
|
+
return validateWorkflowData(schema, data, registry, locale, options);
|
|
622
|
+
}
|
|
520
623
|
if (isFormDefinition(schema))
|
|
521
|
-
return validateFormData(schema, data, registry, locale);
|
|
624
|
+
return validateFormData(schema, data, registry, locale, options);
|
|
522
625
|
return {
|
|
523
626
|
valid: false,
|
|
524
627
|
issues: [{ ref: '$', message: 'unrecognized schema: neither a form nor a workflow' }],
|
|
525
628
|
data: {},
|
|
526
629
|
};
|
|
527
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
|
+
}
|
|
528
692
|
/**
|
|
529
693
|
* Structural gate for storing builder output — the same checks the builder's
|
|
530
694
|
* Import applies, so the server only ever stores schemas the client could
|
|
@@ -637,7 +801,7 @@ function fieldRefs(wf) {
|
|
|
637
801
|
const refs = new Set();
|
|
638
802
|
for (const form of wf.forms) {
|
|
639
803
|
for (const field of [...form.fields, ...approvalFields(form)]) {
|
|
640
|
-
if (field
|
|
804
|
+
if (isStatic(field))
|
|
641
805
|
continue;
|
|
642
806
|
refs.add(`${form.form_name}.${field.key}`);
|
|
643
807
|
for (const ref of rowRefsOf(form.form_name, field))
|
|
@@ -685,9 +849,9 @@ function assertValidFields(form) {
|
|
|
685
849
|
}
|
|
686
850
|
}
|
|
687
851
|
function assertFieldKey(field, form_name) {
|
|
688
|
-
// 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
|
|
689
853
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
|
|
690
|
-
if (field
|
|
854
|
+
if (isStatic(field) && field.key === undefined)
|
|
691
855
|
return;
|
|
692
856
|
if (typeof field.key !== 'string' || field.key === '') {
|
|
693
857
|
throw new Error(`field on ${form_name} needs a non-empty string key`);
|
|
@@ -707,9 +871,9 @@ function assertPatternValidator(v, where) {
|
|
|
707
871
|
throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${where}`);
|
|
708
872
|
}
|
|
709
873
|
if (isUnsafePattern(pattern)) {
|
|
710
|
-
throw new Error(`pattern on ${where} nests unbounded quantifiers ` +
|
|
711
|
-
`(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
|
|
712
|
-
`
|
|
874
|
+
throw new Error(`pattern on ${where} nests or overlaps unbounded quantifiers ` +
|
|
875
|
+
`(e.g. "(a+)+", "(\\w|\\d)+") — this backtracks catastrophically on hostile input; ` +
|
|
876
|
+
`rewrite it so each character can match only one way`);
|
|
713
877
|
}
|
|
714
878
|
}
|
|
715
879
|
function assertFieldValidators(field, form_name) {
|
|
@@ -734,6 +898,13 @@ function assertFieldShape(field, form_name) {
|
|
|
734
898
|
assertFieldKey(field, form_name);
|
|
735
899
|
if (field.type === 'date')
|
|
736
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
|
+
}
|
|
737
908
|
if (field.options !== undefined &&
|
|
738
909
|
(!Array.isArray(field.options) ||
|
|
739
910
|
field.options.some((o) => !isRecord(o) || typeof o.key !== 'string'))) {
|
|
@@ -742,6 +913,27 @@ function assertFieldShape(field, form_name) {
|
|
|
742
913
|
assertFieldValidators(field, form_name);
|
|
743
914
|
}
|
|
744
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
|
+
}
|
|
745
937
|
function assertDateProps(field, form_name) {
|
|
746
938
|
const where = `${form_name}.${field.key}`;
|
|
747
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.
|
|
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.
|
|
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.
|
|
49
|
+
"bank20baht-ui": ">=0.13.3 <0.14.0"
|
|
50
50
|
},
|
|
51
51
|
"main": "./dist/index.js",
|
|
52
52
|
"types": "./dist/index.d.ts",
|