bank20baht-validator 0.1.3 → 0.2.0
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 +9 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +119 -71
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -44,9 +44,16 @@ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
|
|
|
44
44
|
→ 422 {"errors":[{"ref":"title","message":"Must be at least 3 characters"}]}
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
+
## Compatibility
|
|
48
|
+
|
|
49
|
+
Peers on `bank20baht-ui` **0.2.x** (the `FieldType` union — including `password` since 0.2.2 —
|
|
50
|
+
comes from there). In a workspace that also builds `bank20baht-ui`, keep the peer range in
|
|
51
|
+
step with the core version or the validator's `tsc` build resolves a stale copy from npm.
|
|
52
|
+
|
|
47
53
|
## What it enforces
|
|
48
54
|
|
|
49
|
-
- Every declarative validator of the field descriptors: `required`, `
|
|
55
|
+
- Every declarative validator of the field descriptors: `required`, `min_length`, `max_length`, `min`, `max`, `pattern`, `email`, and `custom` (via a `Registry`).
|
|
56
|
+
- 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".
|
|
50
57
|
- **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.
|
|
51
58
|
- Approval steps: a `decision` must be present, and the comment policy (`required` / `requireOnReject`) is honored.
|
|
52
59
|
|
|
@@ -60,4 +67,4 @@ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
|
|
|
60
67
|
| `parseSchema(json)` | structural gate for storing builder output (throws on bad input) |
|
|
61
68
|
| `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
|
|
62
69
|
|
|
63
|
-
All results are `{ valid, issues, data }`; issues are `{ ref, message }` where `ref` is the field key (forms) or `
|
|
70
|
+
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyFlags, approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField, type FormDefinition, type Locale, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
|
|
2
2
|
export type SchemaDefinition = FormDefinition | WorkflowDefinition;
|
|
3
|
-
/** One violation. `ref` is the field key for forms, `
|
|
3
|
+
/** One violation. `ref` is the field key for forms, `form_name.key` for workflows. */
|
|
4
4
|
export interface ValidationIssue {
|
|
5
5
|
ref: string;
|
|
6
6
|
message: string;
|
|
@@ -30,7 +30,7 @@ export declare function validateFormData(form: FormDefinition, input: unknown, r
|
|
|
30
30
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
31
31
|
* forms/fields are excluded, rule-derived `required` overrides the descriptor,
|
|
32
32
|
* approval steps demand a decision plus their comment policy, and a rejected
|
|
33
|
-
* approval with `
|
|
33
|
+
* approval with `on_reject: end` (the default) stops validation there — the
|
|
34
34
|
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
35
35
|
* forms are never visited.
|
|
36
36
|
*/
|
|
@@ -49,7 +49,7 @@ export declare function validateSubmission(schema: SchemaDefinition, data: unkno
|
|
|
49
49
|
* render. Throws with a human-readable message on bad input.
|
|
50
50
|
*
|
|
51
51
|
* Beyond shape it validates referential integrity (rule targets, condition
|
|
52
|
-
* refs, transition endpoints,
|
|
52
|
+
* refs, transition endpoints, on_reject.goto) and compiles every `pattern`
|
|
53
53
|
* validator, so authoring defects surface at store time — not as a 500 or a
|
|
54
54
|
* submission that can never pass.
|
|
55
55
|
*/
|
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, defaultValue, evaluate, getCatalog, resolveViewFields, setCatalog, setLocale, t, validateField, walkPath, } from 'bank20baht-ui/engine';
|
|
16
|
+
import { applyFlags, approvalFields, defaultValue, evaluate, getCatalog, parseDisabledEntry, parseIsoDate, resolveViewFields, setCatalog, setLocale, t, 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,7 @@ 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
|
+
const DATE_MODES = new Set(['single', 'range', 'multiple']);
|
|
37
38
|
const FIELD_TYPES = new Set([
|
|
38
39
|
'text',
|
|
39
40
|
'textarea',
|
|
@@ -52,16 +53,16 @@ const FIELD_TYPES = new Set([
|
|
|
52
53
|
]);
|
|
53
54
|
export function isWorkflowDefinition(schema) {
|
|
54
55
|
return (isRecord(schema) &&
|
|
55
|
-
typeof schema['
|
|
56
|
+
typeof schema['workflow_id'] === 'string' &&
|
|
56
57
|
Array.isArray(schema['forms']) &&
|
|
57
58
|
Array.isArray(schema['rules']));
|
|
58
59
|
}
|
|
59
60
|
export function isFormDefinition(schema) {
|
|
60
61
|
return (isRecord(schema) &&
|
|
61
62
|
!isWorkflowDefinition(schema) &&
|
|
62
|
-
typeof schema['
|
|
63
|
-
typeof schema['
|
|
64
|
-
Array.isArray(schema['
|
|
63
|
+
typeof schema['form_id'] === 'string' &&
|
|
64
|
+
typeof schema['form_name'] === 'string' &&
|
|
65
|
+
Array.isArray(schema['fields']));
|
|
65
66
|
}
|
|
66
67
|
function isRecord(v) {
|
|
67
68
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -82,15 +83,28 @@ function sorted(fields) {
|
|
|
82
83
|
function typeIssue(field, value) {
|
|
83
84
|
if (value === null || value === undefined)
|
|
84
85
|
return null;
|
|
85
|
-
// dynamic options (
|
|
86
|
-
const optionKeys = field.
|
|
86
|
+
// dynamic options (options_from) can't be checked without the registry fetch
|
|
87
|
+
const optionKeys = field.options_from ? null : field.options?.map((o) => o.key);
|
|
87
88
|
switch (field.type) {
|
|
88
89
|
case 'text':
|
|
89
90
|
case 'textarea':
|
|
90
91
|
case 'email':
|
|
91
92
|
case 'password':
|
|
92
|
-
case 'date':
|
|
93
93
|
return typeof value === 'string' ? null : t('pattern');
|
|
94
|
+
case 'date': {
|
|
95
|
+
// value shape follows date_mode; every day must be a real ISO calendar day
|
|
96
|
+
const isDay = (v) => parseIsoDate(v) !== null;
|
|
97
|
+
switch (field.date_mode ?? 'single') {
|
|
98
|
+
case 'range':
|
|
99
|
+
return Array.isArray(value) && value.length === 2 && value.every(isDay)
|
|
100
|
+
? null
|
|
101
|
+
: t('pattern');
|
|
102
|
+
case 'multiple':
|
|
103
|
+
return Array.isArray(value) && value.every(isDay) ? null : t('pattern');
|
|
104
|
+
default:
|
|
105
|
+
return isDay(value) ? null : t('pattern');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
94
108
|
case 'number':
|
|
95
109
|
return typeof value === 'number' && Number.isFinite(value) ? null : t('pattern');
|
|
96
110
|
case 'checkbox':
|
|
@@ -163,7 +177,7 @@ function formDataResult(form, input, registry) {
|
|
|
163
177
|
}
|
|
164
178
|
const issues = [];
|
|
165
179
|
const fields = [];
|
|
166
|
-
for (const f of sorted(form.
|
|
180
|
+
for (const f of sorted(form.fields)) {
|
|
167
181
|
if (f.type === 'label')
|
|
168
182
|
continue;
|
|
169
183
|
if (UNSAFE_KEYS.has(f.key)) {
|
|
@@ -197,7 +211,7 @@ function formDataResult(form, input, registry) {
|
|
|
197
211
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
198
212
|
* forms/fields are excluded, rule-derived `required` overrides the descriptor,
|
|
199
213
|
* approval steps demand a decision plus their comment policy, and a rejected
|
|
200
|
-
* approval with `
|
|
214
|
+
* approval with `on_reject: end` (the default) stops validation there — the
|
|
201
215
|
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
202
216
|
* forms are never visited.
|
|
203
217
|
*/
|
|
@@ -239,19 +253,19 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
239
253
|
}
|
|
240
254
|
// Forms in the order the client visits them: path order in graph mode,
|
|
241
255
|
// ascending `order` among visible forms in linear mode.
|
|
242
|
-
const byName = new Map(workflow.forms.map((f) => [f.
|
|
256
|
+
const byName = new Map(workflow.forms.map((f) => [f.form_name, f]));
|
|
243
257
|
const ordered = pathList
|
|
244
258
|
? pathList.map((n) => byName.get(n)).filter((f) => f !== undefined)
|
|
245
259
|
: [...workflow.forms]
|
|
246
260
|
.sort((a, b) => a.order - b.order)
|
|
247
|
-
.filter((f) => derived.forms[f.
|
|
261
|
+
.filter((f) => derived.forms[f.form_name]?.visible !== false);
|
|
248
262
|
// Rejection cut: everything after a rejected end-on-reject approval is
|
|
249
263
|
// unvisited on the client and must not be demanded here.
|
|
250
264
|
const cut = ordered.findIndex((form) => {
|
|
251
265
|
if (form.kind !== 'approval')
|
|
252
266
|
return false;
|
|
253
|
-
const
|
|
254
|
-
return
|
|
267
|
+
const on_reject = form.approval?.on_reject ?? { action: 'end' };
|
|
268
|
+
return on_reject.action === 'end' && derived.data[form.form_name]?.['decision'] === 'rejected';
|
|
255
269
|
});
|
|
256
270
|
const visited = cut >= 0 ? ordered.slice(0, cut + 1) : ordered;
|
|
257
271
|
for (const form of visited) {
|
|
@@ -259,10 +273,10 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
259
273
|
issues.push(...approvalIssues(form, derived.data));
|
|
260
274
|
continue;
|
|
261
275
|
}
|
|
262
|
-
for (const field of sorted(form.
|
|
276
|
+
for (const field of sorted(form.fields)) {
|
|
263
277
|
if (field.type === 'label')
|
|
264
278
|
continue;
|
|
265
|
-
const ref = `${form.
|
|
279
|
+
const ref = `${form.form_name}.${field.key}`;
|
|
266
280
|
if (UNSAFE_KEYS.has(field.key)) {
|
|
267
281
|
issues.push({ ref, message: 'unsafe field key' });
|
|
268
282
|
continue;
|
|
@@ -270,7 +284,7 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
270
284
|
const state = derived.fields[ref];
|
|
271
285
|
if (state && !state.visible)
|
|
272
286
|
continue; // hidden fields excluded (spec 02 §5)
|
|
273
|
-
const formData = derived.data[form.
|
|
287
|
+
const formData = derived.data[form.form_name] ?? {};
|
|
274
288
|
const shape = shapeIssues(field, formData[field.key], ref);
|
|
275
289
|
if (shape.length > 0) {
|
|
276
290
|
issues.push(...shape);
|
|
@@ -289,15 +303,15 @@ function workflowDataResult(workflow, input, registry) {
|
|
|
289
303
|
/** Approval step checks — same gate `<baht-workflow>.decide()` applies. */
|
|
290
304
|
function approvalIssues(form, data) {
|
|
291
305
|
const issues = [];
|
|
292
|
-
const values = data[form.
|
|
306
|
+
const values = data[form.form_name] ?? {};
|
|
293
307
|
const decision = values['decision'];
|
|
294
308
|
if (decision !== 'approved' && decision !== 'rejected') {
|
|
295
|
-
issues.push({ ref: `${form.
|
|
309
|
+
issues.push({ ref: `${form.form_name}.decision`, message: t('decisionRequired') });
|
|
296
310
|
}
|
|
297
311
|
const mode = form.approval?.comment ?? 'optional';
|
|
298
312
|
const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
|
|
299
313
|
if (commentRequired && String(values['comment'] ?? '').trim() === '') {
|
|
300
|
-
issues.push({ ref: `${form.
|
|
314
|
+
issues.push({ ref: `${form.form_name}.comment`, message: t('commentRequired') });
|
|
301
315
|
}
|
|
302
316
|
return issues;
|
|
303
317
|
}
|
|
@@ -325,38 +339,44 @@ export function validateSubmission(schema, data, registry, locale) {
|
|
|
325
339
|
* render. Throws with a human-readable message on bad input.
|
|
326
340
|
*
|
|
327
341
|
* Beyond shape it validates referential integrity (rule targets, condition
|
|
328
|
-
* refs, transition endpoints,
|
|
342
|
+
* refs, transition endpoints, on_reject.goto) and compiles every `pattern`
|
|
329
343
|
* validator, so authoring defects surface at store time — not as a 500 or a
|
|
330
344
|
* submission that can never pass.
|
|
331
345
|
*/
|
|
332
346
|
export function parseSchema(json) {
|
|
333
|
-
const
|
|
334
|
-
if (!isRecord(
|
|
347
|
+
const raw = typeof json === 'string' ? JSON.parse(json) : json;
|
|
348
|
+
if (!isRecord(raw))
|
|
335
349
|
throw new Error('not an object');
|
|
350
|
+
// pre-0.3 JSON (formId / field_data / maxLength …) still parses — keys are
|
|
351
|
+
// migrated forward; the returned schema is always the current spelling.
|
|
352
|
+
const { schema: parsed, renamed } = normalizeLegacySchema(raw);
|
|
353
|
+
if (renamed.length > 0) {
|
|
354
|
+
console.warn(`[bank20baht-validator] legacy camelCase schema keys migrated (${renamed.length}): ${renamed.slice(0, 5).join(', ')}${renamed.length > 5 ? ', …' : ''} — re-save the schema with snake_case keys.`);
|
|
355
|
+
}
|
|
336
356
|
if (isWorkflowDefinition(parsed)) {
|
|
337
357
|
for (const f of parsed.forms) {
|
|
338
358
|
if (!isRecord(f))
|
|
339
359
|
throw new Error('invalid form: expected an object');
|
|
340
|
-
if (typeof f.
|
|
341
|
-
throw new Error(`invalid form ${JSON.stringify(f.
|
|
360
|
+
if (typeof f.form_name !== 'string' || f.form_name === '' || !Array.isArray(f.fields)) {
|
|
361
|
+
throw new Error(`invalid form ${JSON.stringify(f.form_id)}`);
|
|
342
362
|
}
|
|
343
|
-
if (UNSAFE_KEYS.has(f.
|
|
344
|
-
throw new Error(`invalid
|
|
363
|
+
if (UNSAFE_KEYS.has(f.form_name) || f.form_name.includes('.')) {
|
|
364
|
+
throw new Error(`invalid form_name ${JSON.stringify(f.form_name)}`);
|
|
345
365
|
}
|
|
346
366
|
if (typeof f.order !== 'number' || !Number.isFinite(f.order)) {
|
|
347
|
-
throw new Error(`form ${f.
|
|
367
|
+
throw new Error(`form ${f.form_name} needs a numeric order`);
|
|
348
368
|
}
|
|
349
369
|
if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
|
|
350
|
-
throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.
|
|
370
|
+
throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.form_name}`);
|
|
351
371
|
}
|
|
352
372
|
assertValidFields(f);
|
|
353
373
|
assertValidViews(f);
|
|
354
374
|
assertValidRepeaters(f);
|
|
355
375
|
if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
|
|
356
|
-
throw new Error(`step view ${JSON.stringify(f.view)} on ${f.
|
|
376
|
+
throw new Error(`step view ${JSON.stringify(f.view)} on ${f.form_name} matches no view`);
|
|
357
377
|
}
|
|
358
378
|
}
|
|
359
|
-
const names = parsed.forms.map((f) => f.
|
|
379
|
+
const names = parsed.forms.map((f) => f.form_name);
|
|
360
380
|
if (new Set(names).size !== names.length) {
|
|
361
381
|
throw new Error('formName must be unique in a workflow');
|
|
362
382
|
}
|
|
@@ -367,16 +387,16 @@ export function parseSchema(json) {
|
|
|
367
387
|
if (approval === undefined)
|
|
368
388
|
continue;
|
|
369
389
|
if (!isRecord(approval))
|
|
370
|
-
throw new Error(`invalid approval config on ${f.
|
|
371
|
-
const
|
|
372
|
-
if (
|
|
390
|
+
throw new Error(`invalid approval config on ${f.form_name}`);
|
|
391
|
+
const on_reject = approval.on_reject;
|
|
392
|
+
if (on_reject === undefined)
|
|
373
393
|
continue;
|
|
374
|
-
if (!isRecord(
|
|
375
|
-
throw new Error(`invalid
|
|
394
|
+
if (!isRecord(on_reject) || (on_reject.action !== 'end' && on_reject.action !== 'goto')) {
|
|
395
|
+
throw new Error(`invalid on_reject on ${f.form_name}`);
|
|
376
396
|
}
|
|
377
|
-
if (
|
|
378
|
-
(typeof
|
|
379
|
-
throw new Error(`
|
|
397
|
+
if (on_reject.action === 'goto' &&
|
|
398
|
+
(typeof on_reject.form !== 'string' || !nameSet.has(on_reject.form))) {
|
|
399
|
+
throw new Error(`on_reject.goto targets unknown form ${JSON.stringify(on_reject.form)} on ${f.form_name}`);
|
|
380
400
|
}
|
|
381
401
|
}
|
|
382
402
|
assertValidRules(parsed, nameSet, refs);
|
|
@@ -404,43 +424,43 @@ export function parseSchema(json) {
|
|
|
404
424
|
assertValidRepeaters(parsed);
|
|
405
425
|
return parsed;
|
|
406
426
|
}
|
|
407
|
-
throw new Error('missing
|
|
427
|
+
throw new Error('missing workflow_id/forms/rules (workflow) or form_id/form_name/fields (form)');
|
|
408
428
|
}
|
|
409
429
|
// ---- parseSchema internals ----------------------------------------------------
|
|
410
430
|
/** Every addressable "formName.key" a rule/condition/expression may reference. */
|
|
411
431
|
function fieldRefs(wf) {
|
|
412
432
|
const refs = new Set();
|
|
413
433
|
for (const form of wf.forms) {
|
|
414
|
-
for (const field of [...form.
|
|
434
|
+
for (const field of [...form.fields, ...approvalFields(form)]) {
|
|
415
435
|
if (field.type === 'label')
|
|
416
436
|
continue;
|
|
417
|
-
refs.add(`${form.
|
|
437
|
+
refs.add(`${form.form_name}.${field.key}`);
|
|
418
438
|
}
|
|
419
439
|
}
|
|
420
440
|
return refs;
|
|
421
441
|
}
|
|
422
442
|
function assertValidFields(form) {
|
|
423
443
|
const seen = new Set();
|
|
424
|
-
for (const raw of form.
|
|
444
|
+
for (const raw of form.fields) {
|
|
425
445
|
if (!isRecord(raw))
|
|
426
|
-
throw new Error(`invalid field on ${form.
|
|
446
|
+
throw new Error(`invalid field on ${form.form_name}: expected an object`);
|
|
427
447
|
const field = raw;
|
|
428
|
-
assertFieldShape(field, form.
|
|
448
|
+
assertFieldShape(field, form.form_name);
|
|
429
449
|
if (field.key !== undefined) {
|
|
430
450
|
if (seen.has(field.key)) {
|
|
431
|
-
throw new Error(`duplicate field key ${JSON.stringify(field.key)} on ${form.
|
|
451
|
+
throw new Error(`duplicate field key ${JSON.stringify(field.key)} on ${form.form_name}`);
|
|
432
452
|
}
|
|
433
453
|
seen.add(field.key);
|
|
434
454
|
}
|
|
435
455
|
if (field.fields !== undefined) {
|
|
436
456
|
if (!Array.isArray(field.fields)) {
|
|
437
|
-
throw new Error(`fields[] on ${form.
|
|
457
|
+
throw new Error(`fields[] on ${form.form_name}.${field.key} must be an array`);
|
|
438
458
|
}
|
|
439
459
|
for (const sub of field.fields) {
|
|
440
460
|
if (!isRecord(sub)) {
|
|
441
|
-
throw new Error(`invalid sub-field on ${form.
|
|
461
|
+
throw new Error(`invalid sub-field on ${form.form_name}.${field.key}: expected an object`);
|
|
442
462
|
}
|
|
443
|
-
assertFieldShape(sub, form.
|
|
463
|
+
assertFieldShape(sub, form.form_name);
|
|
444
464
|
}
|
|
445
465
|
}
|
|
446
466
|
}
|
|
@@ -464,32 +484,34 @@ function hasNestedUnboundedQuantifier(pattern) {
|
|
|
464
484
|
const unbounded = '(?:[*+]|\\{\\d+,\\})';
|
|
465
485
|
return new RegExp(`\\((?:\\?:)?[^()|]${unbounded}\\??\\)${unbounded}`).test(stripped);
|
|
466
486
|
}
|
|
467
|
-
function assertFieldShape(field,
|
|
487
|
+
function assertFieldShape(field, form_name) {
|
|
468
488
|
if (typeof field.type !== 'string' || !FIELD_TYPES.has(field.type)) {
|
|
469
|
-
throw new Error(`unknown field type ${JSON.stringify(field.type)} on ${
|
|
489
|
+
throw new Error(`unknown field type ${JSON.stringify(field.type)} on ${form_name}`);
|
|
470
490
|
}
|
|
471
491
|
// labels are static blocks and may omit the key; everything else needs one
|
|
472
492
|
if (field.type !== 'label' || field.key !== undefined) {
|
|
473
493
|
if (typeof field.key !== 'string' || field.key === '') {
|
|
474
|
-
throw new Error(`field on ${
|
|
494
|
+
throw new Error(`field on ${form_name} needs a non-empty string key`);
|
|
475
495
|
}
|
|
476
496
|
if (UNSAFE_KEYS.has(field.key) || field.key.includes('.')) {
|
|
477
|
-
throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${
|
|
497
|
+
throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${form_name}`);
|
|
478
498
|
}
|
|
479
499
|
}
|
|
500
|
+
if (field.type === 'date')
|
|
501
|
+
assertDateProps(field, form_name);
|
|
480
502
|
if (field.options !== undefined) {
|
|
481
503
|
if (!Array.isArray(field.options) ||
|
|
482
504
|
field.options.some((o) => !isRecord(o) || typeof o.key !== 'string')) {
|
|
483
|
-
throw new Error(`invalid options on ${
|
|
505
|
+
throw new Error(`invalid options on ${form_name}.${field.key}: expected { key, label } items`);
|
|
484
506
|
}
|
|
485
507
|
}
|
|
486
508
|
if (field.validators !== undefined) {
|
|
487
509
|
if (!Array.isArray(field.validators)) {
|
|
488
|
-
throw new Error(`validators on ${
|
|
510
|
+
throw new Error(`validators on ${form_name}.${field.key} must be an array`);
|
|
489
511
|
}
|
|
490
512
|
for (const v of field.validators) {
|
|
491
513
|
if (!isRecord(v) || typeof v.type !== 'string') {
|
|
492
|
-
throw new Error(`invalid validator on ${
|
|
514
|
+
throw new Error(`invalid validator on ${form_name}.${field.key}`);
|
|
493
515
|
}
|
|
494
516
|
if (v.type === 'pattern') {
|
|
495
517
|
// compile now — an invalid regex would otherwise throw (or hang, if
|
|
@@ -499,10 +521,10 @@ function assertFieldShape(field, formName) {
|
|
|
499
521
|
new RegExp(pattern);
|
|
500
522
|
}
|
|
501
523
|
catch {
|
|
502
|
-
throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${
|
|
524
|
+
throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${form_name}.${field.key}`);
|
|
503
525
|
}
|
|
504
526
|
if (hasNestedUnboundedQuantifier(pattern)) {
|
|
505
|
-
throw new Error(`pattern on ${
|
|
527
|
+
throw new Error(`pattern on ${form_name}.${field.key} nests unbounded quantifiers ` +
|
|
506
528
|
`(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
|
|
507
529
|
`an equivalent single quantifier matches the same strings`);
|
|
508
530
|
}
|
|
@@ -510,6 +532,32 @@ function assertFieldShape(field, formName) {
|
|
|
510
532
|
}
|
|
511
533
|
}
|
|
512
534
|
}
|
|
535
|
+
/** Calendar constraints must be well-formed or the picker silently ignores them. */
|
|
536
|
+
function assertDateProps(field, form_name) {
|
|
537
|
+
const where = `${form_name}.${field.key}`;
|
|
538
|
+
if (field.date_mode !== undefined && !DATE_MODES.has(field.date_mode)) {
|
|
539
|
+
throw new Error(`invalid date_mode ${JSON.stringify(field.date_mode)} on ${where}`);
|
|
540
|
+
}
|
|
541
|
+
for (const bound of ['min', 'max']) {
|
|
542
|
+
const v = field[bound];
|
|
543
|
+
if (v !== undefined && parseIsoDate(v) === null) {
|
|
544
|
+
throw new Error(`${bound} on ${where} must be an ISO date (yyyy-mm-dd)`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (field.disabled_dates !== undefined) {
|
|
548
|
+
const bad = !Array.isArray(field.disabled_dates) ||
|
|
549
|
+
field.disabled_dates.some((d) => typeof d !== 'string' || parseDisabledEntry(d) === null);
|
|
550
|
+
if (bad) {
|
|
551
|
+
throw new Error(`disabled_dates on ${where} must be ISO days ("2026-04-13") or intervals ("2026-04-13/2026-04-15")`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (field.disabled_weekdays !== undefined) {
|
|
555
|
+
const bad = !Array.isArray(field.disabled_weekdays) ||
|
|
556
|
+
field.disabled_weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6);
|
|
557
|
+
if (bad)
|
|
558
|
+
throw new Error(`disabled_weekdays on ${where} must be integers 0 (Sun) … 6 (Sat)`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
513
561
|
function assertValidRules(wf, formNames, refs) {
|
|
514
562
|
wf.rules.forEach((raw, i) => {
|
|
515
563
|
if (!isRecord(raw))
|
|
@@ -573,9 +621,9 @@ function assertValidCondition(cond, refs, where) {
|
|
|
573
621
|
if (typeof cond['field'] !== 'string' || !refs.has(cond['field'])) {
|
|
574
622
|
throw new Error(`${where} references unknown field ${JSON.stringify(cond['field'])}`);
|
|
575
623
|
}
|
|
576
|
-
const
|
|
577
|
-
if (
|
|
578
|
-
throw new Error(`${where} references unknown field ${JSON.stringify(
|
|
624
|
+
const value_field = cond['value_field'];
|
|
625
|
+
if (value_field !== undefined && (typeof value_field !== 'string' || !refs.has(value_field))) {
|
|
626
|
+
throw new Error(`${where} references unknown field ${JSON.stringify(value_field)}`);
|
|
579
627
|
}
|
|
580
628
|
}
|
|
581
629
|
function assertValidExpr(expr, refs, where) {
|
|
@@ -612,7 +660,7 @@ function assertValidExpr(expr, refs, where) {
|
|
|
612
660
|
/**
|
|
613
661
|
* View integrity — views are client-side display projections, but a stored
|
|
614
662
|
* one must always resolve on the client: names unique and shaped right, every
|
|
615
|
-
* listed key present in
|
|
663
|
+
* listed key present in fields. Catches builder typos at store time
|
|
616
664
|
* instead of as a silently empty form.
|
|
617
665
|
*/
|
|
618
666
|
/**
|
|
@@ -620,18 +668,18 @@ function assertValidExpr(expr, refs, where) {
|
|
|
620
668
|
* unique keys, and repeaters never nest.
|
|
621
669
|
*/
|
|
622
670
|
function assertValidRepeaters(form) {
|
|
623
|
-
for (const field of form.
|
|
671
|
+
for (const field of form.fields) {
|
|
624
672
|
if (field.type !== 'repeater')
|
|
625
673
|
continue;
|
|
626
674
|
if (!Array.isArray(field.fields) || field.fields.length === 0) {
|
|
627
|
-
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.
|
|
675
|
+
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.form_name} needs fields[]`);
|
|
628
676
|
}
|
|
629
677
|
const keys = field.fields.map((f) => f.key);
|
|
630
678
|
if (new Set(keys).size !== keys.length) {
|
|
631
|
-
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.
|
|
679
|
+
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.form_name} has duplicate sub-keys`);
|
|
632
680
|
}
|
|
633
681
|
if (field.fields.some((f) => f.type === 'repeater')) {
|
|
634
|
-
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.
|
|
682
|
+
throw new Error(`repeater ${JSON.stringify(field.key)} on ${form.form_name} nests a repeater`);
|
|
635
683
|
}
|
|
636
684
|
}
|
|
637
685
|
}
|
|
@@ -639,20 +687,20 @@ function assertValidViews(form) {
|
|
|
639
687
|
if (form.views === undefined)
|
|
640
688
|
return;
|
|
641
689
|
if (!Array.isArray(form.views))
|
|
642
|
-
throw new Error(`views must be an array on ${form.
|
|
643
|
-
const keys = new Set(form.
|
|
690
|
+
throw new Error(`views must be an array on ${form.form_name}`);
|
|
691
|
+
const keys = new Set(form.fields.map((f) => f.key));
|
|
644
692
|
const names = new Set();
|
|
645
693
|
for (const view of form.views) {
|
|
646
694
|
if (!isRecord(view) || typeof view.name !== 'string' || !Array.isArray(view.fields)) {
|
|
647
|
-
throw new Error(`invalid view on ${form.
|
|
695
|
+
throw new Error(`invalid view on ${form.form_name}: expected { name, fields[] }`);
|
|
648
696
|
}
|
|
649
697
|
if (names.has(view.name)) {
|
|
650
|
-
throw new Error(`view names must be unique on ${form.
|
|
698
|
+
throw new Error(`view names must be unique on ${form.form_name}: ${JSON.stringify(view.name)}`);
|
|
651
699
|
}
|
|
652
700
|
names.add(view.name);
|
|
653
701
|
for (const key of view.fields) {
|
|
654
702
|
if (typeof key !== 'string' || !keys.has(key)) {
|
|
655
|
-
throw new Error(`view ${JSON.stringify(view.name)} on ${form.
|
|
703
|
+
throw new Error(`view ${JSON.stringify(view.name)} on ${form.form_name} references unknown field ${JSON.stringify(key)}`);
|
|
656
704
|
}
|
|
657
705
|
}
|
|
658
706
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime validation for bahtui form/workflow JSON — the exact engine rules the client runs, on the server.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"test:watch": "vitest"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"bank20baht-ui": "^0.
|
|
43
|
+
"bank20baht-ui": "^0.3.0",
|
|
44
44
|
"typescript": "^5.8.3",
|
|
45
45
|
"vitest": "^3.2.4"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"bank20baht-ui": ">=0.
|
|
48
|
+
"bank20baht-ui": ">=0.3.0 <0.4.0"
|
|
49
49
|
},
|
|
50
50
|
"main": "./dist/index.js",
|
|
51
51
|
"types": "./dist/index.d.ts",
|