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 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.11 <0.12** (the engine and the `FieldType` union come from there; see `peerDependencies`). In a workspace that also builds `bank20baht-ui`, keep the peer range in
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(isRecord));
133
- return shapeMessage(isRecord(value));
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.type === 'label' || UNSAFE_KEYS.has(sub.key))
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
- return withLocale(locale, () => formDataResult(form, input, registry));
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 `canonicalTags` (the same
223
- * transform `validateField` applies internally when checking it, applied
224
- * here too so the persisted `data` matches byte-for-byte); `repeater`
225
- * recurses one row deep to normalise any `tags` COLUMN the same way (a
226
- * repeater never nests, spec 01 §10). Every other type passes through.
227
- * Only own keys are ever assigned, so a `__proto__` key stays a plain
228
- * data property.
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 !== 'repeater' || !Array.isArray(raw))
234
- return raw;
235
- return raw.map((row) => {
236
- if (!isRecord(row))
237
- return row;
238
- const next = { ...row };
239
- for (const sub of field.fields ?? []) {
240
- if (sub.type === 'tags' && Object.hasOwn(next, sub.key)) {
241
- next[sub.key] = canonicalTags(sub, next[sub.key]);
242
- }
243
- }
244
- return next;
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.type === 'label')
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
- return withLocale(locale, () => workflowDataResult(workflow, input, registry));
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.type === 'label')
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
- // eslint-disable-next-line @typescript-eslint/no-base-to-string -- mirrors <baht-workflow>.decide(): String(comment ?? '') on an untrusted value
505
- if (commentRequired && String(values.comment ?? '').trim() === '') {
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.type === 'label')
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.type === 'label' && field.key === undefined)
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
- `an equivalent single quantifier matches the same strings`);
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.4.3",
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.12.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.12.2 <0.13.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",