bank20baht-validator 0.4.2 → 0.5.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 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,16 +54,20 @@ 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) |
68
72
  | `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
69
73
 
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,7 @@ 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;
46
55
  /**
47
56
  * Structural gate for storing builder output — the same checks the builder's
48
57
  * 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, validateField, walkPath, normalizeLegacySchema, } from 'bank20baht-ui/engine';
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';
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
@@ -127,10 +127,24 @@ function tagsShapeIssue(field, value, optionKeys) {
127
127
  }
128
128
  return null;
129
129
  }
130
+ /** The serializable ref an uploader returns (spec 01 §13), with a link-safe url. */
131
+ function isUploadedFile(v) {
132
+ return (isRecord(v) &&
133
+ typeof v.name === 'string' &&
134
+ typeof v.size === 'number' &&
135
+ Number.isFinite(v.size) &&
136
+ v.size >= 0 &&
137
+ typeof v.url === 'string' &&
138
+ isSafeUrl(v.url) &&
139
+ (v.type === undefined || typeof v.type === 'string'));
140
+ }
141
+ function canonicalRef({ name, size, url, type }) {
142
+ return type === undefined ? { name, size, url } : { name, size, url, type };
143
+ }
130
144
  function fileIssue(field, value) {
131
145
  if (field.multiple)
132
- return shapeMessage(Array.isArray(value) && value.every(isRecord));
133
- return shapeMessage(isRecord(value));
146
+ return shapeMessage(Array.isArray(value) && value.every(isUploadedFile));
147
+ return shapeMessage(isUploadedFile(value));
134
148
  }
135
149
  function typeIssue(field, value) {
136
150
  if (value === null || value === undefined)
@@ -179,6 +193,10 @@ function shapeIssues(field, value, ref) {
179
193
  issues.push({ ref: `${ref}[${i}]`, message: t('pattern') });
180
194
  return;
181
195
  }
196
+ if (Object.hasOwn(row, '__proto__')) {
197
+ issues.push({ ref: `${ref}[${i}]`, message: t('pattern') });
198
+ return;
199
+ }
182
200
  for (const sub of field.fields ?? []) {
183
201
  if (sub.type === 'label' || UNSAFE_KEYS.has(sub.key))
184
202
  continue;
@@ -199,8 +217,66 @@ function shapeIssues(field, value, ref) {
199
217
  * views are client-side display only, the server always validates the full
200
218
  * form (a view can never weaken a validation rule).
201
219
  */
202
- export function validateFormData(form, input, registry, locale) {
203
- return withLocale(locale, () => formDataResult(form, input, registry));
220
+ export function validateFormData(form, input, registry, locale, options) {
221
+ const reg = serverRegistry([form], registry, options);
222
+ return withLocale(locale, () => formDataResult(form, input, reg));
223
+ }
224
+ function ownEntry(map, name) {
225
+ return map && Object.hasOwn(map, name) ? map[name] : undefined;
226
+ }
227
+ function guardedScript(name, handler) {
228
+ return (g, detail) => {
229
+ try {
230
+ return handler(g, detail);
231
+ }
232
+ catch (err) {
233
+ console.error(`[bank20baht-validator] submit script "${name}" threw`, err);
234
+ g.addErrorMessage(`submit script "${name}" failed`);
235
+ return false;
236
+ }
237
+ };
238
+ }
239
+ /**
240
+ * The registry the engine sees. In strict mode every `custom` validator and
241
+ * `submit` script the schema names resolves to something that fails closed:
242
+ * the caller's own entry (scripts wrapped so a throw is an issue), else a stub
243
+ * that reports the missing name. Own entries only, so `toString` never
244
+ * resolves through Object.prototype.
245
+ */
246
+ function serverRegistry(forms, registry, options) {
247
+ if (options?.strict === false)
248
+ return registry;
249
+ const validators = new Map();
250
+ const scripts = new Map();
251
+ for (const form of forms) {
252
+ const fields = form.fields.flatMap((f) => [
253
+ f,
254
+ ...(f.type === 'repeater' ? (f.fields ?? []) : []),
255
+ ]);
256
+ for (const spec of fields.flatMap((f) => f.validators ?? [])) {
257
+ if (spec.type !== 'custom')
258
+ continue;
259
+ const name = spec.fn ?? '';
260
+ validators.set(name, ownEntry(registry?.validators, name) ?? (() => `unknown custom validator "${name}"`));
261
+ }
262
+ for (const spec of form.scripts ?? []) {
263
+ if (spec.on !== 'submit')
264
+ continue;
265
+ const name = spec.handler;
266
+ const handler = ownEntry(registry?.scripts, name);
267
+ scripts.set(name, handler
268
+ ? guardedScript(name, handler)
269
+ : (g) => {
270
+ g.addErrorMessage(`submit script "${name}" is not registered`);
271
+ return false;
272
+ });
273
+ }
274
+ }
275
+ return {
276
+ ...registry,
277
+ validators: Object.fromEntries(validators),
278
+ scripts: Object.fromEntries(scripts),
279
+ };
204
280
  }
205
281
  /**
206
282
  * A tags value as the client emits it: trimmed, case-insensitive deduped
@@ -219,30 +295,39 @@ function canonicalTags(field, v) {
219
295
  return normalizeTagsValue(v, field.options_from ? undefined : field.options);
220
296
  }
221
297
  /**
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.
298
+ * Canonical value for one field, as the client would emit it: `tags` via
299
+ * `canonicalTags` (the transform `validateField` applies internally, so the
300
+ * persisted `data` matches byte-for-byte); a file ref keeps only its ref
301
+ * fields; `-0` becomes `0`; a repeater row is rebuilt from its declared
302
+ * columns only (a repeater never nests, spec 01 §10). Every other value
303
+ * passes through. Runs after `shapeIssues`, or on rule input where a
304
+ * wrong-shaped value is left for `shapeIssues` to reject.
229
305
  */
230
306
  function normalizeCanonicalValue(field, raw) {
231
307
  if (field.type === 'tags')
232
308
  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
- });
309
+ if (field.type === 'number' || field.type === 'currency')
310
+ return Object.is(raw, -0) ? 0 : raw;
311
+ if (field.type === 'file') {
312
+ if (isUploadedFile(raw))
313
+ return canonicalRef(raw);
314
+ return Array.isArray(raw) && raw.every(isUploadedFile) ? raw.map(canonicalRef) : raw;
315
+ }
316
+ if (field.type === 'repeater' && Array.isArray(raw)) {
317
+ return raw.map((row) => canonicalRow(field, row));
318
+ }
319
+ return raw;
320
+ }
321
+ function canonicalRow(field, row) {
322
+ if (!isRecord(row))
323
+ return row;
324
+ const next = {};
325
+ for (const sub of field.fields ?? []) {
326
+ if (sub.type === 'label' || UNSAFE_KEYS.has(sub.key) || !Object.hasOwn(row, sub.key))
327
+ continue;
328
+ next[sub.key] = normalizeCanonicalValue(sub, row[sub.key]);
329
+ }
330
+ return next;
246
331
  }
247
332
  /**
248
333
  * Copy of the workflow input with every tags value (and tags column)
@@ -362,8 +447,9 @@ function submitScriptIssues(form, data, registry, refPrefix = '') {
362
447
  * client fires `baht-complete { status: 'rejected' }` immediately, so later
363
448
  * forms are never visited.
364
449
  */
365
- export function validateWorkflowData(workflow, input, registry, locale) {
366
- return withLocale(locale, () => workflowDataResult(workflow, input, registry));
450
+ export function validateWorkflowData(workflow, input, registry, locale, options) {
451
+ const reg = serverRegistry(workflow.forms, registry, options);
452
+ return withLocale(locale, () => workflowDataResult(workflow, input, reg));
367
453
  }
368
454
  /** Early-out for input that is not `{ formName: {…} }`, else null. */
369
455
  function workflowInputRejection(input) {
@@ -439,7 +525,7 @@ function rowStatesOf(form, field, derived) {
439
525
  }
440
526
  function formFieldIssues(form, derived, registry) {
441
527
  const issues = [];
442
- for (const field of sorted(form.fields)) {
528
+ for (const field of sorted([...form.fields, ...approvalFields(form)])) {
443
529
  if (field.type === 'label')
444
530
  continue;
445
531
  issues.push(...workflowFieldIssues(form, field, derived, registry));
@@ -479,11 +565,11 @@ function workflowDataResult(workflow, input, registry) {
479
565
  .map((e) => ({ ref: e.ref, message: e.message }));
480
566
  const pathList = pruneOffPath(workflow, derived);
481
567
  for (const form of visitedForms(workflow, derived, pathList)) {
568
+ issues.push(...formFieldIssues(form, derived, registry));
482
569
  if (form.kind === 'approval') {
483
570
  issues.push(...approvalIssues(form, derived.data));
484
571
  continue;
485
572
  }
486
- issues.push(...formFieldIssues(form, derived, registry));
487
573
  if (hasScripts(form, 'submit')) {
488
574
  const formData = (derived.data[form.form_name] ??= {});
489
575
  issues.push(...submitScriptIssues(form, formData, registry, `${form.form_name}.`));
@@ -501,8 +587,9 @@ function approvalIssues(form, data) {
501
587
  }
502
588
  const mode = form.approval?.comment ?? 'optional';
503
589
  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() === '') {
590
+ // the comment's shape was already checked; a wrong shape was reset to null
591
+ const comment = typeof values.comment === 'string' ? values.comment : '';
592
+ if (commentRequired && isBlank(comment)) {
506
593
  issues.push({ ref: `${form.form_name}.comment`, message: t('commentRequired') });
507
594
  }
508
595
  return issues;
@@ -514,11 +601,12 @@ function approvalIssues(form, data) {
514
601
  * const schema = store.get(body.id);
515
602
  * const result = validateSubmission(schema, body.data);
516
603
  */
517
- export function validateSubmission(schema, data, registry, locale) {
518
- if (isWorkflowDefinition(schema))
519
- return validateWorkflowData(schema, data, registry, locale);
604
+ export function validateSubmission(schema, data, registry, locale, options) {
605
+ if (isWorkflowDefinition(schema)) {
606
+ return validateWorkflowData(schema, data, registry, locale, options);
607
+ }
520
608
  if (isFormDefinition(schema))
521
- return validateFormData(schema, data, registry, locale);
609
+ return validateFormData(schema, data, registry, locale, options);
522
610
  return {
523
611
  valid: false,
524
612
  issues: [{ ref: '$', message: 'unrecognized schema: neither a form nor a workflow' }],
@@ -684,25 +772,6 @@ function assertValidFields(form) {
684
772
  assertSubFields(field, form.form_name);
685
773
  }
686
774
  }
687
- /**
688
- * True for the textbook ReDoS shape: an unbounded quantifier (`* + {n,}`)
689
- * applied to a group whose ENTIRE body is one unboundedly-quantified atom —
690
- * `(a+)+`, `(\d*)*`, `(?:\w+){2,}`. That exact nesting is always either
691
- * redundant (matches the same language as a single quantifier) or exponential
692
- * to backtrack, so rejecting it has no legitimate false positives. It is
693
- * deliberately NARROW: longer bodies like `(abc+)+` are safe (the literal
694
- * prefix removes the cross-iteration ambiguity) and other catastrophic shapes
695
- * (e.g. overlapping alternation `(a|a)*`) are out of scope — patterns are
696
- * builder-authored, so the goal is catching the classic authoring mistake at
697
- * save time, not sandboxing hostile regexes.
698
- */
699
- function hasNestedUnboundedQuantifier(pattern) {
700
- // Strip escaped chars and character classes first so `\+`, `[+]` etc.
701
- // aren't mistaken for quantifiers or group delimiters.
702
- const stripped = pattern.replace(/\\./g, 'x').replace(/\[[^\]]*\]/g, 'C');
703
- const unbounded = '(?:[*+]|\\{\\d+,\\})';
704
- return new RegExp(`\\((?:\\?:)?[^()|]${unbounded}\\??\\)${unbounded}`).test(stripped);
705
- }
706
775
  function assertFieldKey(field, form_name) {
707
776
  // labels are static blocks and may omit the key; everything else needs one
708
777
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
@@ -725,10 +794,10 @@ function assertPatternValidator(v, where) {
725
794
  catch {
726
795
  throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${where}`);
727
796
  }
728
- if (hasNestedUnboundedQuantifier(pattern)) {
729
- throw new Error(`pattern on ${where} nests unbounded quantifiers ` +
730
- `(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
731
- `an equivalent single quantifier matches the same strings`);
797
+ if (isUnsafePattern(pattern)) {
798
+ throw new Error(`pattern on ${where} nests or overlaps unbounded quantifiers ` +
799
+ `(e.g. "(a+)+", "(\\w|\\d)+") — this backtracks catastrophically on hostile input; ` +
800
+ `rewrite it so each character can match only one way`);
732
801
  }
733
802
  }
734
803
  function assertFieldValidators(field, form_name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
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.0",
45
45
  "typescript": "^5.8.3",
46
46
  "vitest": "^3.2.4"
47
47
  },
48
48
  "peerDependencies": {
49
- "bank20baht-ui": ">=0.12.1 <0.13.0"
49
+ "bank20baht-ui": ">=0.13.0 <0.14.0"
50
50
  },
51
51
  "main": "./dist/index.js",
52
52
  "types": "./dist/index.d.ts",