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.
- package/dist/index.js +73 -38
- 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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
49
|
+
"bank20baht-ui": ">=0.12.2 <0.13.0"
|
|
50
50
|
},
|
|
51
51
|
"main": "./dist/index.js",
|
|
52
52
|
"types": "./dist/index.d.ts",
|