bank20baht-validator 0.4.1 → 0.4.3

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 +73 -38
  2. package/package.json +2 -2
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, createScriptContext, defaultValue, evaluate, hasScripts, runScripts, getCatalog, normalizeTagsValue, parseDisabledEntry, parseIsoDate, parseIsoDateTime, 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, 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
@@ -418,10 +418,24 @@ function workflowFieldIssues(form, field, derived, registry) {
418
418
  formData[field.key] = normalizeCanonicalValue(field, formData[field.key]);
419
419
  const required = state ? state.required : !!field.required;
420
420
  const descriptor = required === !!field.required ? field : { ...field, required };
421
- return validateField(descriptor, formData[field.key], registry, formData).map((err) => ({
422
- ref,
423
- message: err.message,
424
- }));
421
+ const rowStates = field.type === 'repeater' ? rowStatesOf(form, field, derived) : undefined;
422
+ return validateField(descriptor, formData[field.key], registry, formData, rowStates).map((err) => ({ ref: issueRef(form, field, err.key), message: err.message }));
423
+ }
424
+ /** A repeater's row errors keep their `key[i].sub` ref (spec 01 §10); the rest sit on the field. */
425
+ function issueRef(form, field, errKey) {
426
+ return errKey.startsWith(`${field.key}[`)
427
+ ? `${form.form_name}.${errKey}`
428
+ : `${form.form_name}.${field.key}`;
429
+ }
430
+ /** The rule-derived states of one repeater's row sub-fields, keyed `key[i].sub` (spec 02 §2a). */
431
+ function rowStatesOf(form, field, derived) {
432
+ const prefix = `${form.form_name}.${field.key}[`;
433
+ const states = {};
434
+ for (const [ref, state] of Object.entries(derived.fields)) {
435
+ if (ref.startsWith(prefix))
436
+ states[ref.slice(form.form_name.length + 1)] = state;
437
+ }
438
+ return states;
425
439
  }
426
440
  function formFieldIssues(form, derived, registry) {
427
441
  const issues = [];
@@ -600,7 +614,7 @@ function assertTransitions(wf, nameSet, refs) {
600
614
  if (tr.to !== 'end' && !nameSet.has(tr.to)) {
601
615
  throw new Error(`transition to unknown form ${JSON.stringify(tr.to)}`);
602
616
  }
603
- assertValidCondition(tr.when, refs, `transition ${tr.from} -> ${tr.to}`);
617
+ assertValidCondition(tr.when, refs, `transition ${tr.from} -> ${tr.to}`, false);
604
618
  }
605
619
  }
606
620
  function assertValidWorkflow(parsed) {
@@ -626,10 +640,20 @@ function fieldRefs(wf) {
626
640
  if (field.type === 'label')
627
641
  continue;
628
642
  refs.add(`${form.form_name}.${field.key}`);
643
+ for (const ref of rowRefsOf(form.form_name, field))
644
+ refs.add(ref);
629
645
  }
630
646
  }
631
647
  return refs;
632
648
  }
649
+ /** `Form.repeater[].sub`: the same-row references rules may use (spec 02 §2a). */
650
+ function rowRefsOf(form_name, field) {
651
+ if (field.type !== 'repeater')
652
+ return [];
653
+ return (field.fields ?? [])
654
+ .filter((sub) => sub.type !== 'label' && sub.type !== 'section' && sub.type !== 'repeater')
655
+ .map((sub) => `${form_name}.${field.key}[].${sub.key}`);
656
+ }
633
657
  function assertSubFields(field, form_name) {
634
658
  if (field.fields === undefined)
635
659
  return;
@@ -660,25 +684,6 @@ function assertValidFields(form) {
660
684
  assertSubFields(field, form.form_name);
661
685
  }
662
686
  }
663
- /**
664
- * True for the textbook ReDoS shape: an unbounded quantifier (`* + {n,}`)
665
- * applied to a group whose ENTIRE body is one unboundedly-quantified atom —
666
- * `(a+)+`, `(\d*)*`, `(?:\w+){2,}`. That exact nesting is always either
667
- * redundant (matches the same language as a single quantifier) or exponential
668
- * to backtrack, so rejecting it has no legitimate false positives. It is
669
- * deliberately NARROW: longer bodies like `(abc+)+` are safe (the literal
670
- * prefix removes the cross-iteration ambiguity) and other catastrophic shapes
671
- * (e.g. overlapping alternation `(a|a)*`) are out of scope — patterns are
672
- * builder-authored, so the goal is catching the classic authoring mistake at
673
- * save time, not sandboxing hostile regexes.
674
- */
675
- function hasNestedUnboundedQuantifier(pattern) {
676
- // Strip escaped chars and character classes first so `\+`, `[+]` etc.
677
- // aren't mistaken for quantifiers or group delimiters.
678
- const stripped = pattern.replace(/\\./g, 'x').replace(/\[[^\]]*\]/g, 'C');
679
- const unbounded = '(?:[*+]|\\{\\d+,\\})';
680
- return new RegExp(`\\((?:\\?:)?[^()|]${unbounded}\\??\\)${unbounded}`).test(stripped);
681
- }
682
687
  function assertFieldKey(field, form_name) {
683
688
  // labels are static blocks and may omit the key; everything else needs one
684
689
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: value is untrusted JSON
@@ -701,7 +706,7 @@ function assertPatternValidator(v, where) {
701
706
  catch {
702
707
  throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${where}`);
703
708
  }
704
- if (hasNestedUnboundedQuantifier(pattern)) {
709
+ if (isUnsafePattern(pattern)) {
705
710
  throw new Error(`pattern on ${where} nests unbounded quantifiers ` +
706
711
  `(e.g. "(a+)+") — this backtracks catastrophically on hostile input; ` +
707
712
  `an equivalent single quantifier matches the same strings`);
@@ -783,6 +788,9 @@ function assertRuleAction(rawAction, where, formNames, refs) {
783
788
  }
784
789
  }
785
790
  if (rawAction.action === 'setValue') {
791
+ if (parseRowRef(`${target.form}.${String(target.field)}`)) {
792
+ throw new Error(`${where}: setValue cannot target a repeater row field`);
793
+ }
786
794
  assertValidExpr(rawAction.value, refs, where);
787
795
  }
788
796
  }
@@ -804,13 +812,38 @@ function assertRule(raw, i, formNames, refs) {
804
812
  for (const rawAction of actions)
805
813
  assertRuleAction(rawAction, where, formNames, refs);
806
814
  }
815
+ assertSingleRowScope(rule, where);
816
+ }
817
+ /** A row-scoped rule is evaluated per row of ONE repeater (spec 02 §2a). */
818
+ function assertSingleRowScope(rule, where) {
819
+ const scoped = new Set(conditionRowRefs(rule.when).map((r) => `${r.form}.${r.repeater}`));
820
+ for (const branch of [rule.then, rule.else]) {
821
+ for (const action of Array.isArray(branch) ? branch : []) {
822
+ const row = actionRowRef(action);
823
+ if (row)
824
+ scoped.add(`${row.form}.${row.repeater}`);
825
+ }
826
+ }
827
+ if (scoped.size > 1)
828
+ throw new Error(`${where}: a rule may be row-scoped to one repeater only`);
829
+ }
830
+ /** The row-scoped ref an action targets, if any. */
831
+ function actionRowRef(action) {
832
+ const target = isRecord(action) && isRecord(action.target) ? action.target : undefined;
833
+ if (!target || typeof target.field !== 'string')
834
+ return null;
835
+ return parseRowRef(`${String(target.form)}.${target.field}`);
807
836
  }
808
837
  function assertValidRules(wf, formNames, refs) {
809
838
  wf.rules.forEach((raw, i) => {
810
839
  assertRule(raw, i, formNames, refs);
811
840
  });
812
841
  }
813
- function assertValidCondition(cond, refs, where) {
842
+ /**
843
+ * `allowRow`: a row-scoped ref (`Form.repeater[].sub`) only means something in
844
+ * a rule's `when` (spec 02 §2a) — transitions and value expressions have no row.
845
+ */
846
+ function assertValidCondition(cond, refs, where, allowRow = true) {
814
847
  if (cond === undefined)
815
848
  return; // omitted = always true
816
849
  if (!isRecord(cond))
@@ -820,28 +853,30 @@ function assertValidCondition(cond, refs, where) {
820
853
  if (!Array.isArray(list))
821
854
  throw new Error(`invalid condition in ${where}`);
822
855
  for (const c of list)
823
- assertValidCondition(c, refs, where);
856
+ assertValidCondition(c, refs, where, allowRow);
824
857
  return;
825
858
  }
826
859
  if ('not' in cond) {
827
- assertValidCondition(cond.not, refs, where);
860
+ assertValidCondition(cond.not, refs, where, allowRow);
828
861
  return;
829
862
  }
830
- if (typeof cond.field !== 'string' || !refs.has(cond.field)) {
831
- throw new Error(`${where} references unknown field ${JSON.stringify(cond.field)}`);
863
+ assertKnownRef(cond.field, refs, where, allowRow);
864
+ if (cond.value_field !== undefined)
865
+ assertKnownRef(cond.value_field, refs, where, allowRow);
866
+ }
867
+ function assertKnownRef(ref, refs, where, allowRow) {
868
+ if (typeof ref !== 'string' || !refs.has(ref)) {
869
+ throw new Error(`${where} references unknown field ${JSON.stringify(ref)}`);
832
870
  }
833
- const value_field = cond.value_field;
834
- if (value_field !== undefined && (typeof value_field !== 'string' || !refs.has(value_field))) {
835
- throw new Error(`${where} references unknown field ${JSON.stringify(value_field)}`);
871
+ if (!allowRow && parseRowRef(ref)) {
872
+ throw new Error(`${where}: a row-scoped ref is only valid in a rule condition`);
836
873
  }
837
874
  }
838
875
  function assertValidExpr(expr, refs, where) {
839
876
  if (!isRecord(expr))
840
877
  throw new Error(`${where}: invalid value expression`);
841
878
  if ('ref' in expr) {
842
- if (typeof expr.ref !== 'string' || !refs.has(expr.ref)) {
843
- throw new Error(`${where} references unknown field ${JSON.stringify(expr.ref)}`);
844
- }
879
+ assertKnownRef(expr.ref, refs, where, false);
845
880
  return;
846
881
  }
847
882
  if ('const' in expr)
@@ -859,7 +894,7 @@ function assertValidExpr(expr, refs, where) {
859
894
  if (!Array.isArray(triple) || triple.length !== 3) {
860
895
  throw new Error(`${where}: if expression needs [condition, then, else]`);
861
896
  }
862
- assertValidCondition(triple[0], refs, where);
897
+ assertValidCondition(triple[0], refs, where, false);
863
898
  assertValidExpr(triple[1], refs, where);
864
899
  assertValidExpr(triple[2], refs, where);
865
900
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bank20baht-validator",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -46,7 +46,7 @@
46
46
  "vitest": "^3.2.4"
47
47
  },
48
48
  "peerDependencies": {
49
- "bank20baht-ui": ">=0.11.0 <0.13.0"
49
+ "bank20baht-ui": ">=0.12.2 <0.13.0"
50
50
  },
51
51
  "main": "./dist/index.js",
52
52
  "types": "./dist/index.d.ts",