bank20baht-validator 0.3.5 → 0.3.7

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.
Files changed (2) hide show
  1. package/dist/index.js +352 -290
  2. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -54,16 +54,16 @@ const FIELD_TYPES = new Set([
54
54
  ]);
55
55
  export function isWorkflowDefinition(schema) {
56
56
  return (isRecord(schema) &&
57
- typeof schema['workflow_id'] === 'string' &&
58
- Array.isArray(schema['forms']) &&
59
- Array.isArray(schema['rules']));
57
+ typeof schema.workflow_id === 'string' &&
58
+ Array.isArray(schema.forms) &&
59
+ Array.isArray(schema.rules));
60
60
  }
61
61
  export function isFormDefinition(schema) {
62
62
  return (isRecord(schema) &&
63
63
  !isWorkflowDefinition(schema) &&
64
- typeof schema['form_id'] === 'string' &&
65
- typeof schema['form_name'] === 'string' &&
66
- Array.isArray(schema['fields']));
64
+ typeof schema.form_id === 'string' &&
65
+ typeof schema.form_name === 'string' &&
66
+ Array.isArray(schema.fields));
67
67
  }
68
68
  function isRecord(v) {
69
69
  return typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -81,6 +81,38 @@ function sorted(fields) {
81
81
  * Returns a message, or null when the shape is fine. Empty values pass —
82
82
  * `required` owns those.
83
83
  */
84
+ function shapeMessage(ok) {
85
+ return ok ? null : t('pattern');
86
+ }
87
+ // value shape follows date_mode; every entry must be a real ISO calendar
88
+ // day. `enable_time` also accepts an ISO local datetime (what the
89
+ // picker actually emits) - a bare day is still fine on that field too,
90
+ // for callers that only ever cared about the date part.
91
+ function dateIssue(field, value) {
92
+ const isDay = (v) => parseIsoDate(v) !== null || (field.enable_time === true && parseIsoDateTime(v) !== null);
93
+ const mode = field.date_mode ?? 'single';
94
+ if (mode === 'range') {
95
+ return shapeMessage(Array.isArray(value) && value.length === 2 && value.every(isDay));
96
+ }
97
+ if (mode === 'multiple')
98
+ return shapeMessage(Array.isArray(value) && value.every(isDay));
99
+ return shapeMessage(isDay(value));
100
+ }
101
+ function choiceIssue(value, optionKeys) {
102
+ if (typeof value !== 'string')
103
+ return t('pattern');
104
+ return shapeMessage(!optionKeys || optionKeys.includes(value));
105
+ }
106
+ function multiChoiceIssue(value, optionKeys) {
107
+ if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
108
+ return t('pattern');
109
+ return shapeMessage(!optionKeys || value.every((v) => optionKeys.includes(v)));
110
+ }
111
+ function fileIssue(field, value) {
112
+ if (field.multiple)
113
+ return shapeMessage(Array.isArray(value) && value.every(isRecord));
114
+ return shapeMessage(isRecord(value));
115
+ }
84
116
  function typeIssue(field, value) {
85
117
  if (value === null || value === undefined)
86
118
  return null;
@@ -91,54 +123,27 @@ function typeIssue(field, value) {
91
123
  case 'textarea':
92
124
  case 'email':
93
125
  case 'password':
94
- return typeof value === 'string' ? null : t('pattern');
95
- case 'date': {
96
- // value shape follows date_mode; every entry must be a real ISO calendar
97
- // day. `enable_time` also accepts an ISO local datetime (what the
98
- // picker actually emits) - a bare day is still fine on that field too,
99
- // for callers that only ever cared about the date part.
100
- const isDay = (v) => parseIsoDate(v) !== null || (field.enable_time && parseIsoDateTime(v) !== null);
101
- switch (field.date_mode ?? 'single') {
102
- case 'range':
103
- return Array.isArray(value) && value.length === 2 && value.every(isDay)
104
- ? null
105
- : t('pattern');
106
- case 'multiple':
107
- return Array.isArray(value) && value.every(isDay) ? null : t('pattern');
108
- default:
109
- return isDay(value) ? null : t('pattern');
110
- }
111
- }
126
+ return shapeMessage(typeof value === 'string');
127
+ case 'date':
128
+ return dateIssue(field, value);
112
129
  case 'number':
113
130
  case 'currency':
114
- return typeof value === 'number' && Number.isFinite(value) ? null : t('pattern');
131
+ return shapeMessage(typeof value === 'number' && Number.isFinite(value));
115
132
  case 'checkbox':
116
133
  case 'toggle':
117
- return typeof value === 'boolean' ? null : t('pattern');
134
+ return shapeMessage(typeof value === 'boolean');
118
135
  case 'dropdown':
119
- case 'radio': {
120
- if (typeof value !== 'string')
121
- return t('pattern');
122
- return optionKeys && !optionKeys.includes(value) ? t('pattern') : null;
123
- }
124
- case 'multiselect': {
125
- if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
126
- return t('pattern');
127
- return optionKeys && value.some((v) => !optionKeys.includes(v))
128
- ? t('pattern')
129
- : null;
130
- }
131
- case 'file': {
132
- const isFileValue = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
133
- if (field.multiple) {
134
- return Array.isArray(value) && value.every(isFileValue) ? null : t('pattern');
135
- }
136
- return isFileValue(value) ? null : t('pattern');
137
- }
136
+ case 'radio':
137
+ return choiceIssue(value, optionKeys);
138
+ case 'multiselect':
139
+ return multiChoiceIssue(value, optionKeys);
140
+ case 'file':
141
+ return fileIssue(field, value);
138
142
  case 'repeater':
139
- return Array.isArray(value) ? null : t('pattern');
140
- default:
141
- return null; // 'label' never reaches here
143
+ return shapeMessage(Array.isArray(value));
144
+ case 'label':
145
+ case 'section':
146
+ return null; // static blocks carry no value
142
147
  }
143
148
  }
144
149
  /** Shape issues for one field, recursing one level into repeater rows. */
@@ -230,9 +235,9 @@ function submitScriptIssues(form, data, registry, refPrefix = '') {
230
235
  setValue: (key, value) => {
231
236
  data[key] = value;
232
237
  },
233
- setState: () => { },
234
- resetState: () => { },
235
- setFieldMessage: () => { },
238
+ setState: () => undefined,
239
+ resetState: () => undefined,
240
+ setFieldMessage: () => undefined,
236
241
  addFormMessage: (m) => {
237
242
  messages.push(m);
238
243
  },
@@ -266,7 +271,8 @@ function submitScriptIssues(form, data, registry, refPrefix = '') {
266
271
  export function validateWorkflowData(workflow, input, registry, locale) {
267
272
  return withLocale(locale, () => workflowDataResult(workflow, input, registry));
268
273
  }
269
- function workflowDataResult(workflow, input, registry) {
274
+ /** Early-out for input that is not `{ formName: {…} }`, else null. */
275
+ function workflowInputRejection(input) {
270
276
  if (!isRecord(input)) {
271
277
  return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
272
278
  }
@@ -279,72 +285,96 @@ function workflowDataResult(workflow, input, registry) {
279
285
  };
280
286
  }
281
287
  }
282
- const derived = evaluate(workflow, input);
283
- // Schema-integrity errors (unknown rule targets) belong at parseSchema /
284
- // store time — the client never surfaces them, so failing submissions on
285
- // them would reject data the client happily emits. Only data-dependent
286
- // errors (rule non-convergence) gate the submission.
287
- const issues = derived.errors
288
- .filter((e) => e.kind === 'data')
289
- .map((e) => ({ ref: e.ref, message: e.message }));
290
- // graph mode (spec 03 §6): only the forms on the path this data takes
291
- // validate; off-path branches are as good as skipped, their data dropped
292
- const pathList = workflow.transitions && workflow.transitions.length > 0
293
- ? walkPath(workflow, derived.data)
294
- : null;
295
- if (pathList) {
296
- const path = new Set(pathList);
297
- for (const name of Object.keys(derived.data)) {
298
- if (!path.has(name))
299
- delete derived.data[name];
300
- }
301
- }
302
- // Forms in the order the client visits them: path order in graph mode,
303
- // ascending `order` among visible forms in linear mode.
288
+ return null;
289
+ }
290
+ /**
291
+ * Forms in the order the client visits them (path order in graph mode,
292
+ * ascending `order` among visible forms in linear mode), cut after a rejected
293
+ * end-on-reject approval: everything after it is unvisited on the client and
294
+ * must not be demanded here.
295
+ */
296
+ function visitedForms(workflow, derived, pathList) {
304
297
  const byName = new Map(workflow.forms.map((f) => [f.form_name, f]));
305
298
  const ordered = pathList
306
299
  ? pathList.map((n) => byName.get(n)).filter((f) => f !== undefined)
307
300
  : [...workflow.forms]
308
301
  .sort((a, b) => a.order - b.order)
309
302
  .filter((f) => derived.forms[f.form_name]?.visible !== false);
310
- // Rejection cut: everything after a rejected end-on-reject approval is
311
- // unvisited on the client and must not be demanded here.
312
303
  const cut = ordered.findIndex((form) => {
313
304
  if (form.kind !== 'approval')
314
305
  return false;
315
306
  const on_reject = form.approval?.on_reject ?? { action: 'end' };
316
- return on_reject.action === 'end' && derived.data[form.form_name]?.['decision'] === 'rejected';
307
+ return on_reject.action === 'end' && derived.data[form.form_name]?.decision === 'rejected';
317
308
  });
318
- const visited = cut >= 0 ? ordered.slice(0, cut + 1) : ordered;
319
- for (const form of visited) {
309
+ return cut >= 0 ? ordered.slice(0, cut + 1) : ordered;
310
+ }
311
+ function workflowFieldIssues(form, field, derived, registry) {
312
+ const ref = `${form.form_name}.${field.key}`;
313
+ if (UNSAFE_KEYS.has(field.key))
314
+ return [{ ref, message: 'unsafe field key' }];
315
+ const state = derived.fields[ref];
316
+ if (state && !state.visible)
317
+ return []; // hidden fields excluded (spec 02 §5)
318
+ const formData = derived.data[form.form_name] ?? {};
319
+ const shape = shapeIssues(field, formData[field.key], ref);
320
+ if (shape.length > 0) {
321
+ formData[field.key] = defaultValue(field); // junk never reaches canonical data
322
+ return shape;
323
+ }
324
+ const required = state ? state.required : !!field.required;
325
+ const descriptor = required === !!field.required ? field : { ...field, required };
326
+ return validateField(descriptor, formData[field.key], registry, formData).map((err) => ({
327
+ ref,
328
+ message: err.message,
329
+ }));
330
+ }
331
+ function formFieldIssues(form, derived, registry) {
332
+ const issues = [];
333
+ for (const field of sorted(form.fields)) {
334
+ if (field.type === 'label')
335
+ continue;
336
+ issues.push(...workflowFieldIssues(form, field, derived, registry));
337
+ }
338
+ return issues;
339
+ }
340
+ /**
341
+ * Graph mode (spec 03 §6): only the forms on the path this data takes
342
+ * validate; off-path branches are as good as skipped, their data dropped.
343
+ * Returns the path, or null in linear mode.
344
+ */
345
+ function pruneOffPath(workflow, derived) {
346
+ const pathList = workflow.transitions && workflow.transitions.length > 0
347
+ ? walkPath(workflow, derived.data)
348
+ : null;
349
+ if (pathList) {
350
+ const path = new Set(pathList);
351
+ for (const name of Object.keys(derived.data)) {
352
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- pruning off-path forms from a plain-object map keyed by form_name
353
+ if (!path.has(name))
354
+ delete derived.data[name];
355
+ }
356
+ }
357
+ return pathList;
358
+ }
359
+ function workflowDataResult(workflow, input, registry) {
360
+ const rejection = workflowInputRejection(input);
361
+ if (rejection)
362
+ return rejection;
363
+ const derived = evaluate(workflow, input);
364
+ // Schema-integrity errors (unknown rule targets) belong at parseSchema /
365
+ // store time — the client never surfaces them, so failing submissions on
366
+ // them would reject data the client happily emits. Only data-dependent
367
+ // errors (rule non-convergence) gate the submission.
368
+ const issues = derived.errors
369
+ .filter((e) => e.kind === 'data')
370
+ .map((e) => ({ ref: e.ref, message: e.message }));
371
+ const pathList = pruneOffPath(workflow, derived);
372
+ for (const form of visitedForms(workflow, derived, pathList)) {
320
373
  if (form.kind === 'approval') {
321
374
  issues.push(...approvalIssues(form, derived.data));
322
375
  continue;
323
376
  }
324
- for (const field of sorted(form.fields)) {
325
- if (field.type === 'label')
326
- continue;
327
- const ref = `${form.form_name}.${field.key}`;
328
- if (UNSAFE_KEYS.has(field.key)) {
329
- issues.push({ ref, message: 'unsafe field key' });
330
- continue;
331
- }
332
- const state = derived.fields[ref];
333
- if (state && !state.visible)
334
- continue; // hidden fields excluded (spec 02 §5)
335
- const formData = derived.data[form.form_name] ?? {};
336
- const shape = shapeIssues(field, formData[field.key], ref);
337
- if (shape.length > 0) {
338
- issues.push(...shape);
339
- formData[field.key] = defaultValue(field); // junk never reaches canonical data
340
- continue;
341
- }
342
- const required = state ? state.required : !!field.required;
343
- const descriptor = required === !!field.required ? field : { ...field, required };
344
- for (const err of validateField(descriptor, formData[field.key], registry, formData)) {
345
- issues.push({ ref, message: err.message });
346
- }
347
- }
377
+ issues.push(...formFieldIssues(form, derived, registry));
348
378
  if (hasScripts(form, 'submit')) {
349
379
  const formData = (derived.data[form.form_name] ??= {});
350
380
  issues.push(...submitScriptIssues(form, formData, registry, `${form.form_name}.`));
@@ -356,13 +386,14 @@ function workflowDataResult(workflow, input, registry) {
356
386
  function approvalIssues(form, data) {
357
387
  const issues = [];
358
388
  const values = data[form.form_name] ?? {};
359
- const decision = values['decision'];
389
+ const decision = values.decision;
360
390
  if (decision !== 'approved' && decision !== 'rejected') {
361
391
  issues.push({ ref: `${form.form_name}.decision`, message: t('decisionRequired') });
362
392
  }
363
393
  const mode = form.approval?.comment ?? 'optional';
364
394
  const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
365
- if (commentRequired && String(values['comment'] ?? '').trim() === '') {
395
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- mirrors <baht-workflow>.decide(): String(comment ?? '') on an untrusted value
396
+ if (commentRequired && String(values.comment ?? '').trim() === '') {
366
397
  issues.push({ ref: `${form.form_name}.comment`, message: t('commentRequired') });
367
398
  }
368
399
  return issues;
@@ -406,80 +437,91 @@ export function parseSchema(json) {
406
437
  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.`);
407
438
  }
408
439
  if (isWorkflowDefinition(parsed)) {
409
- for (const f of parsed.forms) {
410
- if (!isRecord(f))
411
- throw new Error('invalid form: expected an object');
412
- if (typeof f.form_name !== 'string' || f.form_name === '' || !Array.isArray(f.fields)) {
413
- throw new Error(`invalid form ${JSON.stringify(f.form_id)}`);
414
- }
415
- if (UNSAFE_KEYS.has(f.form_name) || f.form_name.includes('.')) {
416
- throw new Error(`invalid form_name ${JSON.stringify(f.form_name)}`);
417
- }
418
- if (typeof f.order !== 'number' || !Number.isFinite(f.order)) {
419
- throw new Error(`form ${f.form_name} needs a numeric order`);
420
- }
421
- if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
422
- throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.form_name}`);
423
- }
424
- assertValidFields(f);
425
- assertValidViews(f);
426
- assertValidRepeaters(f);
427
- assertValidScripts(f);
428
- if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
429
- throw new Error(`step view ${JSON.stringify(f.view)} on ${f.form_name} matches no view`);
430
- }
431
- }
432
- const names = parsed.forms.map((f) => f.form_name);
433
- if (new Set(names).size !== names.length) {
434
- throw new Error('formName must be unique in a workflow');
435
- }
436
- const nameSet = new Set(names);
437
- const refs = fieldRefs(parsed);
438
- for (const f of parsed.forms) {
439
- const approval = f.approval;
440
- if (approval === undefined)
441
- continue;
442
- if (!isRecord(approval))
443
- throw new Error(`invalid approval config on ${f.form_name}`);
444
- const on_reject = approval.on_reject;
445
- if (on_reject === undefined)
446
- continue;
447
- if (!isRecord(on_reject) || (on_reject.action !== 'end' && on_reject.action !== 'goto')) {
448
- throw new Error(`invalid on_reject on ${f.form_name}`);
449
- }
450
- if (on_reject.action === 'goto' &&
451
- (typeof on_reject.form !== 'string' || !nameSet.has(on_reject.form))) {
452
- throw new Error(`on_reject.goto targets unknown form ${JSON.stringify(on_reject.form)} on ${f.form_name}`);
453
- }
454
- }
455
- assertValidRules(parsed, nameSet, refs);
456
- if (parsed.transitions !== undefined) {
457
- if (!Array.isArray(parsed.transitions))
458
- throw new Error('transitions must be an array');
459
- for (const tr of parsed.transitions) {
460
- if (!isRecord(tr) || typeof tr.from !== 'string' || typeof tr.to !== 'string') {
461
- throw new Error('invalid transition: expected { from, to }');
462
- }
463
- if (!nameSet.has(tr.from)) {
464
- throw new Error(`transition from unknown form ${JSON.stringify(tr.from)}`);
465
- }
466
- if (tr.to !== 'end' && !nameSet.has(tr.to)) {
467
- throw new Error(`transition to unknown form ${JSON.stringify(tr.to)}`);
468
- }
469
- assertValidCondition(tr.when, refs, `transition ${tr.from} -> ${tr.to}`);
470
- }
471
- }
440
+ assertValidWorkflow(parsed);
472
441
  return parsed;
473
442
  }
474
443
  if (isFormDefinition(parsed)) {
475
- assertValidFields(parsed);
476
- assertValidViews(parsed);
477
- assertValidRepeaters(parsed);
478
- assertValidScripts(parsed);
444
+ assertFormParts(parsed);
479
445
  return parsed;
480
446
  }
481
447
  throw new Error('missing workflow_id/forms/rules (workflow) or form_id/form_name/fields (form)');
482
448
  }
449
+ function assertFormParts(form) {
450
+ assertValidFields(form);
451
+ assertValidViews(form);
452
+ assertValidRepeaters(form);
453
+ assertValidScripts(form);
454
+ }
455
+ function assertWorkflowForm(f) {
456
+ if (!isRecord(f))
457
+ throw new Error('invalid form: expected an object');
458
+ if (typeof f.form_name !== 'string' || f.form_name === '' || !Array.isArray(f.fields)) {
459
+ throw new Error(`invalid form ${JSON.stringify(f.form_id)}`);
460
+ }
461
+ if (UNSAFE_KEYS.has(f.form_name) || f.form_name.includes('.')) {
462
+ throw new Error(`invalid form_name ${JSON.stringify(f.form_name)}`);
463
+ }
464
+ if (typeof f.order !== 'number' || !Number.isFinite(f.order)) {
465
+ throw new Error(`form ${f.form_name} needs a numeric order`);
466
+ }
467
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
468
+ if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
469
+ throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.form_name}`);
470
+ }
471
+ assertFormParts(f);
472
+ if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
473
+ throw new Error(`step view ${JSON.stringify(f.view)} on ${f.form_name} matches no view`);
474
+ }
475
+ }
476
+ function assertApprovalConfig(f, nameSet) {
477
+ const approval = f.approval;
478
+ if (approval === undefined)
479
+ return;
480
+ if (!isRecord(approval))
481
+ throw new Error(`invalid approval config on ${f.form_name}`);
482
+ const on_reject = approval.on_reject;
483
+ if (on_reject === undefined)
484
+ return;
485
+ if (!isRecord(on_reject) || (on_reject.action !== 'end' && on_reject.action !== 'goto')) {
486
+ throw new Error(`invalid on_reject on ${f.form_name}`);
487
+ }
488
+ if (on_reject.action === 'goto' &&
489
+ (typeof on_reject.form !== 'string' || !nameSet.has(on_reject.form))) {
490
+ throw new Error(`on_reject.goto targets unknown form ${JSON.stringify(on_reject.form)} on ${f.form_name}`);
491
+ }
492
+ }
493
+ function assertTransitions(wf, nameSet, refs) {
494
+ if (wf.transitions === undefined)
495
+ return;
496
+ if (!Array.isArray(wf.transitions))
497
+ throw new Error('transitions must be an array');
498
+ for (const tr of wf.transitions) {
499
+ if (!isRecord(tr) || typeof tr.from !== 'string' || typeof tr.to !== 'string') {
500
+ throw new Error('invalid transition: expected { from, to }');
501
+ }
502
+ if (!nameSet.has(tr.from)) {
503
+ throw new Error(`transition from unknown form ${JSON.stringify(tr.from)}`);
504
+ }
505
+ if (tr.to !== 'end' && !nameSet.has(tr.to)) {
506
+ throw new Error(`transition to unknown form ${JSON.stringify(tr.to)}`);
507
+ }
508
+ assertValidCondition(tr.when, refs, `transition ${tr.from} -> ${tr.to}`);
509
+ }
510
+ }
511
+ function assertValidWorkflow(parsed) {
512
+ for (const f of parsed.forms)
513
+ assertWorkflowForm(f);
514
+ const names = parsed.forms.map((f) => f.form_name);
515
+ if (new Set(names).size !== names.length) {
516
+ throw new Error('formName must be unique in a workflow');
517
+ }
518
+ const nameSet = new Set(names);
519
+ const refs = fieldRefs(parsed);
520
+ for (const f of parsed.forms)
521
+ assertApprovalConfig(f, nameSet);
522
+ assertValidRules(parsed, nameSet, refs);
523
+ assertTransitions(parsed, nameSet, refs);
524
+ }
483
525
  // ---- parseSchema internals ----------------------------------------------------
484
526
  /** Every addressable "formName.key" a rule/condition/expression may reference. */
485
527
  function fieldRefs(wf) {
@@ -493,6 +535,19 @@ function fieldRefs(wf) {
493
535
  }
494
536
  return refs;
495
537
  }
538
+ function assertSubFields(field, form_name) {
539
+ if (field.fields === undefined)
540
+ return;
541
+ if (!Array.isArray(field.fields)) {
542
+ throw new Error(`fields[] on ${form_name}.${field.key} must be an array`);
543
+ }
544
+ for (const sub of field.fields) {
545
+ if (!isRecord(sub)) {
546
+ throw new Error(`invalid sub-field on ${form_name}.${field.key}: expected an object`);
547
+ }
548
+ assertFieldShape(sub, form_name);
549
+ }
550
+ }
496
551
  function assertValidFields(form) {
497
552
  const seen = new Set();
498
553
  for (const raw of form.fields) {
@@ -500,23 +555,14 @@ function assertValidFields(form) {
500
555
  throw new Error(`invalid field on ${form.form_name}: expected an object`);
501
556
  const field = raw;
502
557
  assertFieldShape(field, form.form_name);
558
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
503
559
  if (field.key !== undefined) {
504
560
  if (seen.has(field.key)) {
505
561
  throw new Error(`duplicate field key ${JSON.stringify(field.key)} on ${form.form_name}`);
506
562
  }
507
563
  seen.add(field.key);
508
564
  }
509
- if (field.fields !== undefined) {
510
- if (!Array.isArray(field.fields)) {
511
- throw new Error(`fields[] on ${form.form_name}.${field.key} must be an array`);
512
- }
513
- for (const sub of field.fields) {
514
- if (!isRecord(sub)) {
515
- throw new Error(`invalid sub-field on ${form.form_name}.${field.key}: expected an object`);
516
- }
517
- assertFieldShape(sub, form.form_name);
518
- }
519
- }
565
+ assertSubFields(field, form.form_name);
520
566
  }
521
567
  }
522
568
  /**
@@ -538,53 +584,62 @@ function hasNestedUnboundedQuantifier(pattern) {
538
584
  const unbounded = '(?:[*+]|\\{\\d+,\\})';
539
585
  return new RegExp(`\\((?:\\?:)?[^()|]${unbounded}\\??\\)${unbounded}`).test(stripped);
540
586
  }
587
+ function assertFieldKey(field, form_name) {
588
+ // labels are static blocks and may omit the key; everything else needs one
589
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
590
+ if (field.type === 'label' && field.key === undefined)
591
+ return;
592
+ if (typeof field.key !== 'string' || field.key === '') {
593
+ throw new Error(`field on ${form_name} needs a non-empty string key`);
594
+ }
595
+ if (UNSAFE_KEYS.has(field.key) || field.key.includes('.')) {
596
+ throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${form_name}`);
597
+ }
598
+ }
599
+ function assertPatternValidator(v, where) {
600
+ // compile now — an invalid regex would otherwise throw (or hang, if
601
+ // catastrophic) inside validateField on every future submission
602
+ const pattern = String(v.value);
603
+ try {
604
+ new RegExp(pattern);
605
+ }
606
+ catch {
607
+ throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${where}`);
608
+ }
609
+ if (hasNestedUnboundedQuantifier(pattern)) {
610
+ throw new Error(`pattern on ${where} nests unbounded quantifiers ` +
611
+ `(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
612
+ `an equivalent single quantifier matches the same strings`);
613
+ }
614
+ }
615
+ function assertFieldValidators(field, form_name) {
616
+ if (field.validators === undefined)
617
+ return;
618
+ const where = `${form_name}.${field.key}`;
619
+ if (!Array.isArray(field.validators)) {
620
+ throw new Error(`validators on ${where} must be an array`);
621
+ }
622
+ for (const v of field.validators) {
623
+ if (!isRecord(v) || typeof v.type !== 'string') {
624
+ throw new Error(`invalid validator on ${where}`);
625
+ }
626
+ if (v.type === 'pattern')
627
+ assertPatternValidator(v, where);
628
+ }
629
+ }
541
630
  function assertFieldShape(field, form_name) {
542
631
  if (typeof field.type !== 'string' || !FIELD_TYPES.has(field.type)) {
543
632
  throw new Error(`unknown field type ${JSON.stringify(field.type)} on ${form_name}`);
544
633
  }
545
- // labels are static blocks and may omit the key; everything else needs one
546
- if (field.type !== 'label' || field.key !== undefined) {
547
- if (typeof field.key !== 'string' || field.key === '') {
548
- throw new Error(`field on ${form_name} needs a non-empty string key`);
549
- }
550
- if (UNSAFE_KEYS.has(field.key) || field.key.includes('.')) {
551
- throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${form_name}`);
552
- }
553
- }
634
+ assertFieldKey(field, form_name);
554
635
  if (field.type === 'date')
555
636
  assertDateProps(field, form_name);
556
- if (field.options !== undefined) {
557
- if (!Array.isArray(field.options) ||
558
- field.options.some((o) => !isRecord(o) || typeof o.key !== 'string')) {
559
- throw new Error(`invalid options on ${form_name}.${field.key}: expected { key, label } items`);
560
- }
561
- }
562
- if (field.validators !== undefined) {
563
- if (!Array.isArray(field.validators)) {
564
- throw new Error(`validators on ${form_name}.${field.key} must be an array`);
565
- }
566
- for (const v of field.validators) {
567
- if (!isRecord(v) || typeof v.type !== 'string') {
568
- throw new Error(`invalid validator on ${form_name}.${field.key}`);
569
- }
570
- if (v.type === 'pattern') {
571
- // compile now — an invalid regex would otherwise throw (or hang, if
572
- // catastrophic) inside validateField on every future submission
573
- const pattern = String(v.value);
574
- try {
575
- new RegExp(pattern);
576
- }
577
- catch {
578
- throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${form_name}.${field.key}`);
579
- }
580
- if (hasNestedUnboundedQuantifier(pattern)) {
581
- throw new Error(`pattern on ${form_name}.${field.key} nests unbounded quantifiers ` +
582
- `(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
583
- `an equivalent single quantifier matches the same strings`);
584
- }
585
- }
586
- }
637
+ if (field.options !== undefined &&
638
+ (!Array.isArray(field.options) ||
639
+ field.options.some((o) => !isRecord(o) || typeof o.key !== 'string'))) {
640
+ throw new Error(`invalid options on ${form_name}.${field.key}: expected { key, label } items`);
587
641
  }
642
+ assertFieldValidators(field, form_name);
588
643
  }
589
644
  /** Calendar constraints must be well-formed or the picker silently ignores them. */
590
645
  function assertDateProps(field, form_name) {
@@ -612,47 +667,52 @@ function assertDateProps(field, form_name) {
612
667
  throw new Error(`disabled_weekdays on ${where} must be integers 0 (Sun) … 6 (Sat)`);
613
668
  }
614
669
  }
670
+ function assertRuleAction(rawAction, where, formNames, refs) {
671
+ if (!isRecord(rawAction) || typeof rawAction.action !== 'string') {
672
+ throw new Error(`${where}: invalid action`);
673
+ }
674
+ const target = rawAction.target;
675
+ if (!isRecord(target) || typeof target.form !== 'string') {
676
+ throw new Error(`${where}: action needs a target form`);
677
+ }
678
+ if (!formNames.has(target.form)) {
679
+ throw new Error(`${where} targets unknown form ${JSON.stringify(target.form)}`);
680
+ }
681
+ const formLevel = rawAction.action === 'showForm' ||
682
+ rawAction.action === 'hideForm' ||
683
+ rawAction.action === 'skipForm';
684
+ if (!formLevel) {
685
+ const ref = `${target.form}.${String(target.field)}`;
686
+ if (typeof target.field !== 'string' || !refs.has(ref)) {
687
+ throw new Error(`${where} targets unknown field ${JSON.stringify(ref)}`);
688
+ }
689
+ }
690
+ if (rawAction.action === 'setValue') {
691
+ assertValidExpr(rawAction.value, refs, where);
692
+ }
693
+ }
694
+ function assertRule(raw, i, formNames, refs) {
695
+ if (!isRecord(raw))
696
+ throw new Error(`invalid rule at index ${i}: expected an object`);
697
+ const rule = raw;
698
+ const where = `rule ${typeof rule.id === 'string' ? JSON.stringify(rule.id) : i}`;
699
+ if (rule.priority !== undefined && typeof rule.priority !== 'number') {
700
+ throw new Error(`${where}: priority must be a number`);
701
+ }
702
+ assertValidCondition(rule.when, refs, where);
703
+ for (const branch of ['then', 'else']) {
704
+ const actions = rule[branch];
705
+ if (actions === undefined)
706
+ continue;
707
+ if (!Array.isArray(actions))
708
+ throw new Error(`${where}: ${branch} must be an array`);
709
+ for (const rawAction of actions)
710
+ assertRuleAction(rawAction, where, formNames, refs);
711
+ }
712
+ }
615
713
  function assertValidRules(wf, formNames, refs) {
616
714
  wf.rules.forEach((raw, i) => {
617
- if (!isRecord(raw))
618
- throw new Error(`invalid rule at index ${i}: expected an object`);
619
- const rule = raw;
620
- const where = `rule ${typeof rule.id === 'string' ? JSON.stringify(rule.id) : i}`;
621
- if (rule.priority !== undefined && typeof rule.priority !== 'number') {
622
- throw new Error(`${where}: priority must be a number`);
623
- }
624
- assertValidCondition(rule.when, refs, where);
625
- for (const branch of ['then', 'else']) {
626
- const actions = rule[branch];
627
- if (actions === undefined)
628
- continue;
629
- if (!Array.isArray(actions))
630
- throw new Error(`${where}: ${branch} must be an array`);
631
- for (const rawAction of actions) {
632
- if (!isRecord(rawAction) || typeof rawAction.action !== 'string') {
633
- throw new Error(`${where}: invalid action`);
634
- }
635
- const target = rawAction.target;
636
- if (!isRecord(target) || typeof target.form !== 'string') {
637
- throw new Error(`${where}: action needs a target form`);
638
- }
639
- if (!formNames.has(target.form)) {
640
- throw new Error(`${where} targets unknown form ${JSON.stringify(target.form)}`);
641
- }
642
- const formLevel = rawAction.action === 'showForm' ||
643
- rawAction.action === 'hideForm' ||
644
- rawAction.action === 'skipForm';
645
- if (!formLevel) {
646
- const ref = `${target.form}.${String(target.field)}`;
647
- if (typeof target.field !== 'string' || !refs.has(ref)) {
648
- throw new Error(`${where} targets unknown field ${JSON.stringify(ref)}`);
649
- }
650
- }
651
- if (rawAction.action === 'setValue') {
652
- assertValidExpr(rawAction.value, refs, where);
653
- }
654
- }
655
- }
715
+ assertRule(raw, i, formNames, refs);
656
716
  });
657
717
  }
658
718
  function assertValidCondition(cond, refs, where) {
@@ -661,7 +721,7 @@ function assertValidCondition(cond, refs, where) {
661
721
  if (!isRecord(cond))
662
722
  throw new Error(`invalid condition in ${where}`);
663
723
  if ('and' in cond || 'or' in cond) {
664
- const list = cond['and'] ?? cond['or'];
724
+ const list = cond.and ?? cond.or;
665
725
  if (!Array.isArray(list))
666
726
  throw new Error(`invalid condition in ${where}`);
667
727
  for (const c of list)
@@ -669,13 +729,13 @@ function assertValidCondition(cond, refs, where) {
669
729
  return;
670
730
  }
671
731
  if ('not' in cond) {
672
- assertValidCondition(cond['not'], refs, where);
732
+ assertValidCondition(cond.not, refs, where);
673
733
  return;
674
734
  }
675
- if (typeof cond['field'] !== 'string' || !refs.has(cond['field'])) {
676
- throw new Error(`${where} references unknown field ${JSON.stringify(cond['field'])}`);
735
+ if (typeof cond.field !== 'string' || !refs.has(cond.field)) {
736
+ throw new Error(`${where} references unknown field ${JSON.stringify(cond.field)}`);
677
737
  }
678
- const value_field = cond['value_field'];
738
+ const value_field = cond.value_field;
679
739
  if (value_field !== undefined && (typeof value_field !== 'string' || !refs.has(value_field))) {
680
740
  throw new Error(`${where} references unknown field ${JSON.stringify(value_field)}`);
681
741
  }
@@ -684,23 +744,23 @@ function assertValidExpr(expr, refs, where) {
684
744
  if (!isRecord(expr))
685
745
  throw new Error(`${where}: invalid value expression`);
686
746
  if ('ref' in expr) {
687
- if (typeof expr['ref'] !== 'string' || !refs.has(expr['ref'])) {
688
- throw new Error(`${where} references unknown field ${JSON.stringify(expr['ref'])}`);
747
+ if (typeof expr.ref !== 'string' || !refs.has(expr.ref)) {
748
+ throw new Error(`${where} references unknown field ${JSON.stringify(expr.ref)}`);
689
749
  }
690
750
  return;
691
751
  }
692
752
  if ('const' in expr)
693
753
  return;
694
754
  if ('op' in expr) {
695
- if (!Array.isArray(expr['args'])) {
755
+ if (!Array.isArray(expr.args)) {
696
756
  throw new Error(`${where}: expression args must be an array`);
697
757
  }
698
- for (const a of expr['args'])
758
+ for (const a of expr.args)
699
759
  assertValidExpr(a, refs, where);
700
760
  return;
701
761
  }
702
762
  if ('if' in expr) {
703
- const triple = expr['if'];
763
+ const triple = expr.if;
704
764
  if (!Array.isArray(triple) || triple.length !== 3) {
705
765
  throw new Error(`${where}: if expression needs [condition, then, else]`);
706
766
  }
@@ -739,6 +799,24 @@ function assertValidRepeaters(form) {
739
799
  }
740
800
  const SCRIPT_EVENTS = new Set(['load', 'change', 'submit']);
741
801
  /** `scripts[]` (spec 01 §14): known `on`, a handler name, and a real field for `change`. */
802
+ function assertScript(s, form_name, keys) {
803
+ if (!isRecord(s))
804
+ throw new Error(`invalid script on ${form_name}: expected an object`);
805
+ if (typeof s.on !== 'string' || !SCRIPT_EVENTS.has(s.on)) {
806
+ throw new Error(`invalid script on=${JSON.stringify(s.on)} on ${form_name}`);
807
+ }
808
+ if (typeof s.handler !== 'string' || s.handler === '') {
809
+ throw new Error(`script on ${form_name} needs a handler name`);
810
+ }
811
+ if (s.on !== 'change')
812
+ return;
813
+ if (typeof s.field !== 'string' || s.field === '') {
814
+ throw new Error(`change script "${s.handler}" on ${form_name} needs a field`);
815
+ }
816
+ if (!keys.has(s.field)) {
817
+ throw new Error(`change script "${s.handler}" on ${form_name} targets unknown field ${JSON.stringify(s.field)}`);
818
+ }
819
+ }
742
820
  function assertValidScripts(form) {
743
821
  if (form.scripts === undefined)
744
822
  return;
@@ -746,24 +824,8 @@ function assertValidScripts(form) {
746
824
  throw new Error(`scripts must be an array on ${form.form_name}`);
747
825
  }
748
826
  const keys = new Set(form.fields.map((f) => f.key));
749
- for (const s of form.scripts) {
750
- if (!isRecord(s))
751
- throw new Error(`invalid script on ${form.form_name}: expected an object`);
752
- if (typeof s.on !== 'string' || !SCRIPT_EVENTS.has(s.on)) {
753
- throw new Error(`invalid script on=${JSON.stringify(s.on)} on ${form.form_name}`);
754
- }
755
- if (typeof s.handler !== 'string' || s.handler === '') {
756
- throw new Error(`script on ${form.form_name} needs a handler name`);
757
- }
758
- if (s.on === 'change') {
759
- if (typeof s.field !== 'string' || s.field === '') {
760
- throw new Error(`change script "${s.handler}" on ${form.form_name} needs a field`);
761
- }
762
- if (!keys.has(s.field)) {
763
- throw new Error(`change script "${s.handler}" on ${form.form_name} targets unknown field ${JSON.stringify(s.field)}`);
764
- }
765
- }
766
- }
827
+ for (const s of form.scripts)
828
+ assertScript(s, form.form_name, keys);
767
829
  }
768
830
  function assertValidViews(form) {
769
831
  if (form.views === undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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",
@@ -37,15 +37,16 @@
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
39
39
  "test": "vitest run",
40
- "test:watch": "vitest"
40
+ "test:watch": "vitest",
41
+ "test:coverage": "vitest run --coverage"
41
42
  },
42
43
  "devDependencies": {
43
- "bank20baht-ui": "^0.8.0",
44
+ "bank20baht-ui": "^0.10.0",
44
45
  "typescript": "^5.8.3",
45
46
  "vitest": "^3.2.4"
46
47
  },
47
48
  "peerDependencies": {
48
- "bank20baht-ui": ">=0.5.0 <0.9.0"
49
+ "bank20baht-ui": ">=0.5.0 <0.11.0"
49
50
  },
50
51
  "main": "./dist/index.js",
51
52
  "types": "./dist/index.d.ts",