bank20baht-validator 0.0.4 → 0.1.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/LICENSE +21 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +432 -17
- package/package.json +14 -9
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nattapong Promthong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyFlags, approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField, type FormDefinition, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
|
|
1
|
+
import { applyFlags, approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField, type FormDefinition, type Locale, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
|
|
2
2
|
export type SchemaDefinition = FormDefinition | WorkflowDefinition;
|
|
3
3
|
/** One violation. `ref` is the field key for forms, `formName.key` for workflows. */
|
|
4
4
|
export interface ValidationIssue {
|
|
@@ -24,14 +24,17 @@ export declare function isFormDefinition(schema: unknown): schema is FormDefinit
|
|
|
24
24
|
* views are client-side display only, the server always validates the full
|
|
25
25
|
* form (a view can never weaken a validation rule).
|
|
26
26
|
*/
|
|
27
|
-
export declare function validateFormData(form: FormDefinition, input: unknown, registry?: Registry): ValidationResult;
|
|
27
|
+
export declare function validateFormData(form: FormDefinition, input: unknown, registry?: Registry, locale?: Locale): ValidationResult;
|
|
28
28
|
/**
|
|
29
29
|
* Validate a whole workflow submission — mirrors what `<baht-workflow>`
|
|
30
30
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
31
31
|
* forms/fields are excluded, rule-derived `required` overrides the descriptor,
|
|
32
|
-
*
|
|
32
|
+
* approval steps demand a decision plus their comment policy, and a rejected
|
|
33
|
+
* approval with `onReject: end` (the default) stops validation there — the
|
|
34
|
+
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
35
|
+
* forms are never visited.
|
|
33
36
|
*/
|
|
34
|
-
export declare function validateWorkflowData(workflow: WorkflowDefinition, input: unknown, registry?: Registry): ValidationResult;
|
|
37
|
+
export declare function validateWorkflowData(workflow: WorkflowDefinition, input: unknown, registry?: Registry, locale?: Locale): ValidationResult;
|
|
35
38
|
/**
|
|
36
39
|
* Validate a submission body's `data` against whichever schema kind `id`
|
|
37
40
|
* resolved to. This is the single server entry point:
|
|
@@ -39,11 +42,16 @@ export declare function validateWorkflowData(workflow: WorkflowDefinition, input
|
|
|
39
42
|
* const schema = store.get(body.id);
|
|
40
43
|
* const result = validateSubmission(schema, body.data);
|
|
41
44
|
*/
|
|
42
|
-
export declare function validateSubmission(schema: SchemaDefinition, data: unknown, registry?: Registry): ValidationResult;
|
|
45
|
+
export declare function validateSubmission(schema: SchemaDefinition, data: unknown, registry?: Registry, locale?: Locale): ValidationResult;
|
|
43
46
|
/**
|
|
44
47
|
* Structural gate for storing builder output — the same checks the builder's
|
|
45
48
|
* Import applies, so the server only ever stores schemas the client could
|
|
46
49
|
* render. Throws with a human-readable message on bad input.
|
|
50
|
+
*
|
|
51
|
+
* Beyond shape it validates referential integrity (rule targets, condition
|
|
52
|
+
* refs, transition endpoints, onReject.goto) and compiles every `pattern`
|
|
53
|
+
* validator, so authoring defects surface at store time — not as a 500 or a
|
|
54
|
+
* submission that can never pass.
|
|
47
55
|
*/
|
|
48
56
|
export declare function parseSchema(json: unknown): SchemaDefinition;
|
|
49
57
|
export { applyFlags, approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, validateField, };
|
package/dist/index.js
CHANGED
|
@@ -6,8 +6,49 @@
|
|
|
6
6
|
// This package feeds the same JSON through the same engine functions, so a
|
|
7
7
|
// request that bypasses the frontend hits the exact same rules — no codegen,
|
|
8
8
|
// no recompile: swap the stored JSON and validation follows.
|
|
9
|
+
//
|
|
10
|
+
// Unlike the client, this package sits on a trust boundary: `input` is an
|
|
11
|
+
// untrusted request body and the schema may predate parseSchema hardening.
|
|
12
|
+
// Reads are own-property only, prototype-writing keys are rejected, and
|
|
13
|
+
// every value must have the shape the UI control could actually emit before
|
|
14
|
+
// the authored validators run.
|
|
9
15
|
// ---------------------------------------------------------------------------
|
|
10
|
-
import { applyFlags, approvalFields, defaultValue, evaluate, resolveViewFields, setLocale, t, validateField, } from 'bank20baht-ui/engine';
|
|
16
|
+
import { applyFlags, approvalFields, defaultValue, evaluate, getCatalog, resolveViewFields, setCatalog, setLocale, t, validateField, walkPath, } from 'bank20baht-ui/engine';
|
|
17
|
+
/**
|
|
18
|
+
* Run `fn` with the catalog switched to `locale`, restoring the previous
|
|
19
|
+
* catalog after. The engine catalog is module-global; validation is fully
|
|
20
|
+
* synchronous, so scoping the switch this way is race-free even on a server
|
|
21
|
+
* interleaving requests with different locales.
|
|
22
|
+
*/
|
|
23
|
+
function withLocale(locale, fn) {
|
|
24
|
+
if (locale === undefined)
|
|
25
|
+
return fn();
|
|
26
|
+
const prev = getCatalog();
|
|
27
|
+
setLocale(locale);
|
|
28
|
+
try {
|
|
29
|
+
return fn();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
setCatalog(prev);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Keys that write through Object.prototype — never valid as field/form names. */
|
|
36
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
37
|
+
const FIELD_TYPES = new Set([
|
|
38
|
+
'text',
|
|
39
|
+
'textarea',
|
|
40
|
+
'number',
|
|
41
|
+
'email',
|
|
42
|
+
'dropdown',
|
|
43
|
+
'multiselect',
|
|
44
|
+
'checkbox',
|
|
45
|
+
'radio',
|
|
46
|
+
'toggle',
|
|
47
|
+
'date',
|
|
48
|
+
'file',
|
|
49
|
+
'label',
|
|
50
|
+
'repeater',
|
|
51
|
+
]);
|
|
11
52
|
export function isWorkflowDefinition(schema) {
|
|
12
53
|
return (isRecord(schema) &&
|
|
13
54
|
typeof schema['workflowId'] === 'string' &&
|
|
@@ -27,6 +68,83 @@ function isRecord(v) {
|
|
|
27
68
|
function sorted(fields) {
|
|
28
69
|
return [...fields].sort((a, b) => a.order - b.order);
|
|
29
70
|
}
|
|
71
|
+
// ---- value-shape conformance -------------------------------------------------
|
|
72
|
+
/**
|
|
73
|
+
* Client/server parity gate: the UI can only emit values of the field's shape
|
|
74
|
+
* (a dropdown emits one of its option keys, a number input a number, a toggle
|
|
75
|
+
* a boolean). A direct API call can send anything — `{"$gt":""}` is not
|
|
76
|
+
* "empty", `123` skips the email regex — so wrong shapes are rejected before
|
|
77
|
+
* the authored validators run and never reach canonical data.
|
|
78
|
+
* Returns a message, or null when the shape is fine. Empty values pass —
|
|
79
|
+
* `required` owns those.
|
|
80
|
+
*/
|
|
81
|
+
function typeIssue(field, value) {
|
|
82
|
+
if (value === null || value === undefined)
|
|
83
|
+
return null;
|
|
84
|
+
// dynamic options (optionsFrom) can't be checked without the registry fetch
|
|
85
|
+
const optionKeys = field.optionsFrom ? null : field.options?.map((o) => o.key);
|
|
86
|
+
switch (field.type) {
|
|
87
|
+
case 'text':
|
|
88
|
+
case 'textarea':
|
|
89
|
+
case 'email':
|
|
90
|
+
case 'date':
|
|
91
|
+
return typeof value === 'string' ? null : t('pattern');
|
|
92
|
+
case 'number':
|
|
93
|
+
return typeof value === 'number' && Number.isFinite(value) ? null : t('pattern');
|
|
94
|
+
case 'checkbox':
|
|
95
|
+
case 'toggle':
|
|
96
|
+
return typeof value === 'boolean' ? null : t('pattern');
|
|
97
|
+
case 'dropdown':
|
|
98
|
+
case 'radio': {
|
|
99
|
+
if (typeof value !== 'string')
|
|
100
|
+
return t('pattern');
|
|
101
|
+
return optionKeys && !optionKeys.includes(value) ? t('pattern') : null;
|
|
102
|
+
}
|
|
103
|
+
case 'multiselect': {
|
|
104
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string'))
|
|
105
|
+
return t('pattern');
|
|
106
|
+
return optionKeys && value.some((v) => !optionKeys.includes(v))
|
|
107
|
+
? t('pattern')
|
|
108
|
+
: null;
|
|
109
|
+
}
|
|
110
|
+
case 'file': {
|
|
111
|
+
const isFileValue = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
112
|
+
if (field.multiple) {
|
|
113
|
+
return Array.isArray(value) && value.every(isFileValue) ? null : t('pattern');
|
|
114
|
+
}
|
|
115
|
+
return isFileValue(value) ? null : t('pattern');
|
|
116
|
+
}
|
|
117
|
+
case 'repeater':
|
|
118
|
+
return Array.isArray(value) ? null : t('pattern');
|
|
119
|
+
default:
|
|
120
|
+
return null; // 'label' never reaches here
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Shape issues for one field, recursing one level into repeater rows. */
|
|
124
|
+
function shapeIssues(field, value, ref) {
|
|
125
|
+
const message = typeIssue(field, value);
|
|
126
|
+
if (message !== null)
|
|
127
|
+
return [{ ref, message }];
|
|
128
|
+
const issues = [];
|
|
129
|
+
if (field.type === 'repeater' && Array.isArray(value)) {
|
|
130
|
+
value.forEach((row, i) => {
|
|
131
|
+
if (!isRecord(row)) {
|
|
132
|
+
issues.push({ ref: `${ref}[${i}]`, message: t('pattern') });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
for (const sub of field.fields ?? []) {
|
|
136
|
+
if (sub.type === 'label' || UNSAFE_KEYS.has(sub.key))
|
|
137
|
+
continue;
|
|
138
|
+
const v = Object.hasOwn(row, sub.key) ? row[sub.key] : undefined;
|
|
139
|
+
const m = typeIssue(sub, v);
|
|
140
|
+
if (m !== null)
|
|
141
|
+
issues.push({ ref: `${ref}[${i}].${sub.key}`, message: m });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return issues;
|
|
146
|
+
}
|
|
147
|
+
// ---- form validation -----------------------------------------------------------
|
|
30
148
|
/**
|
|
31
149
|
* Validate a standalone form submission — mirrors `<baht-form>.validate()`
|
|
32
150
|
* with no derived field states: every non-label field, missing values fall
|
|
@@ -34,18 +152,38 @@ function sorted(fields) {
|
|
|
34
152
|
* views are client-side display only, the server always validates the full
|
|
35
153
|
* form (a view can never weaken a validation rule).
|
|
36
154
|
*/
|
|
37
|
-
export function validateFormData(form, input, registry) {
|
|
155
|
+
export function validateFormData(form, input, registry, locale) {
|
|
156
|
+
return withLocale(locale, () => formDataResult(form, input, registry));
|
|
157
|
+
}
|
|
158
|
+
function formDataResult(form, input, registry) {
|
|
38
159
|
if (!isRecord(input)) {
|
|
39
160
|
return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
|
|
40
161
|
}
|
|
41
162
|
const issues = [];
|
|
42
|
-
const fields =
|
|
43
|
-
|
|
163
|
+
const fields = [];
|
|
164
|
+
for (const f of sorted(form.field_data)) {
|
|
165
|
+
if (f.type === 'label')
|
|
166
|
+
continue;
|
|
167
|
+
if (UNSAFE_KEYS.has(f.key)) {
|
|
168
|
+
issues.push({ ref: f.key, message: 'unsafe field key' });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
fields.push(f);
|
|
172
|
+
}
|
|
173
|
+
// canonical data first, so compare validators see every sibling.
|
|
174
|
+
// Own-property reads only: `'toString' in input` is true via the prototype
|
|
175
|
+
// chain and would smuggle the inherited function in as the value.
|
|
44
176
|
const data = {};
|
|
45
177
|
for (const field of fields) {
|
|
46
|
-
data[field.key] = field.key
|
|
178
|
+
data[field.key] = Object.hasOwn(input, field.key) ? input[field.key] : defaultValue(field);
|
|
47
179
|
}
|
|
48
180
|
for (const field of fields) {
|
|
181
|
+
const shape = shapeIssues(field, data[field.key], field.key);
|
|
182
|
+
if (shape.length > 0) {
|
|
183
|
+
issues.push(...shape);
|
|
184
|
+
data[field.key] = defaultValue(field); // junk never reaches canonical data
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
49
187
|
for (const err of validateField(field, data[field.key], registry, data)) {
|
|
50
188
|
issues.push({ ref: err.key, message: err.message });
|
|
51
189
|
}
|
|
@@ -56,9 +194,15 @@ export function validateFormData(form, input, registry) {
|
|
|
56
194
|
* Validate a whole workflow submission — mirrors what `<baht-workflow>`
|
|
57
195
|
* enforces across its steps: rules are evaluated first (`evaluate`), invisible
|
|
58
196
|
* forms/fields are excluded, rule-derived `required` overrides the descriptor,
|
|
59
|
-
*
|
|
197
|
+
* approval steps demand a decision plus their comment policy, and a rejected
|
|
198
|
+
* approval with `onReject: end` (the default) stops validation there — the
|
|
199
|
+
* client fires `baht-complete { status: 'rejected' }` immediately, so later
|
|
200
|
+
* forms are never visited.
|
|
60
201
|
*/
|
|
61
|
-
export function validateWorkflowData(workflow, input, registry) {
|
|
202
|
+
export function validateWorkflowData(workflow, input, registry, locale) {
|
|
203
|
+
return withLocale(locale, () => workflowDataResult(workflow, input, registry));
|
|
204
|
+
}
|
|
205
|
+
function workflowDataResult(workflow, input, registry) {
|
|
62
206
|
if (!isRecord(input)) {
|
|
63
207
|
return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
|
|
64
208
|
}
|
|
@@ -72,11 +216,43 @@ export function validateWorkflowData(workflow, input, registry) {
|
|
|
72
216
|
}
|
|
73
217
|
}
|
|
74
218
|
const derived = evaluate(workflow, input);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
219
|
+
// Schema-integrity errors (unknown rule targets) belong at parseSchema /
|
|
220
|
+
// store time — the client never surfaces them, so failing submissions on
|
|
221
|
+
// them would reject data the client happily emits. Only data-dependent
|
|
222
|
+
// errors (rule non-convergence) gate the submission.
|
|
223
|
+
const issues = derived.errors
|
|
224
|
+
.filter((e) => e.kind === 'data')
|
|
225
|
+
.map((e) => ({ ref: e.ref, message: e.message }));
|
|
226
|
+
// graph mode (spec 03 §6): only the forms on the path this data takes
|
|
227
|
+
// validate; off-path branches are as good as skipped, their data dropped
|
|
228
|
+
const pathList = workflow.transitions && workflow.transitions.length > 0
|
|
229
|
+
? walkPath(workflow, derived.data)
|
|
230
|
+
: null;
|
|
231
|
+
if (pathList) {
|
|
232
|
+
const path = new Set(pathList);
|
|
233
|
+
for (const name of Object.keys(derived.data)) {
|
|
234
|
+
if (!path.has(name))
|
|
235
|
+
delete derived.data[name];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// Forms in the order the client visits them: path order in graph mode,
|
|
239
|
+
// ascending `order` among visible forms in linear mode.
|
|
240
|
+
const byName = new Map(workflow.forms.map((f) => [f.formName, f]));
|
|
241
|
+
const ordered = pathList
|
|
242
|
+
? pathList.map((n) => byName.get(n)).filter((f) => f !== undefined)
|
|
243
|
+
: [...workflow.forms]
|
|
244
|
+
.sort((a, b) => a.order - b.order)
|
|
245
|
+
.filter((f) => derived.forms[f.formName]?.visible !== false);
|
|
246
|
+
// Rejection cut: everything after a rejected end-on-reject approval is
|
|
247
|
+
// unvisited on the client and must not be demanded here.
|
|
248
|
+
const cut = ordered.findIndex((form) => {
|
|
249
|
+
if (form.kind !== 'approval')
|
|
250
|
+
return false;
|
|
251
|
+
const onReject = form.approval?.onReject ?? { action: 'end' };
|
|
252
|
+
return onReject.action === 'end' && derived.data[form.formName]?.['decision'] === 'rejected';
|
|
253
|
+
});
|
|
254
|
+
const visited = cut >= 0 ? ordered.slice(0, cut + 1) : ordered;
|
|
255
|
+
for (const form of visited) {
|
|
80
256
|
if (form.kind === 'approval') {
|
|
81
257
|
issues.push(...approvalIssues(form, derived.data));
|
|
82
258
|
continue;
|
|
@@ -85,12 +261,22 @@ export function validateWorkflowData(workflow, input, registry) {
|
|
|
85
261
|
if (field.type === 'label')
|
|
86
262
|
continue;
|
|
87
263
|
const ref = `${form.formName}.${field.key}`;
|
|
264
|
+
if (UNSAFE_KEYS.has(field.key)) {
|
|
265
|
+
issues.push({ ref, message: 'unsafe field key' });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
88
268
|
const state = derived.fields[ref];
|
|
89
269
|
if (state && !state.visible)
|
|
90
270
|
continue; // hidden fields excluded (spec 02 §5)
|
|
271
|
+
const formData = derived.data[form.formName] ?? {};
|
|
272
|
+
const shape = shapeIssues(field, formData[field.key], ref);
|
|
273
|
+
if (shape.length > 0) {
|
|
274
|
+
issues.push(...shape);
|
|
275
|
+
formData[field.key] = defaultValue(field); // junk never reaches canonical data
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
91
278
|
const required = state ? state.required : !!field.required;
|
|
92
279
|
const descriptor = required === !!field.required ? field : { ...field, required };
|
|
93
|
-
const formData = derived.data[form.formName] ?? {};
|
|
94
280
|
for (const err of validateField(descriptor, formData[field.key], registry, formData)) {
|
|
95
281
|
issues.push({ ref, message: err.message });
|
|
96
282
|
}
|
|
@@ -120,11 +306,11 @@ function approvalIssues(form, data) {
|
|
|
120
306
|
* const schema = store.get(body.id);
|
|
121
307
|
* const result = validateSubmission(schema, body.data);
|
|
122
308
|
*/
|
|
123
|
-
export function validateSubmission(schema, data, registry) {
|
|
309
|
+
export function validateSubmission(schema, data, registry, locale) {
|
|
124
310
|
if (isWorkflowDefinition(schema))
|
|
125
|
-
return validateWorkflowData(schema, data, registry);
|
|
311
|
+
return validateWorkflowData(schema, data, registry, locale);
|
|
126
312
|
if (isFormDefinition(schema))
|
|
127
|
-
return validateFormData(schema, data, registry);
|
|
313
|
+
return validateFormData(schema, data, registry, locale);
|
|
128
314
|
return {
|
|
129
315
|
valid: false,
|
|
130
316
|
issues: [{ ref: '$', message: 'unrecognized schema: neither a form nor a workflow' }],
|
|
@@ -135,6 +321,11 @@ export function validateSubmission(schema, data, registry) {
|
|
|
135
321
|
* Structural gate for storing builder output — the same checks the builder's
|
|
136
322
|
* Import applies, so the server only ever stores schemas the client could
|
|
137
323
|
* render. Throws with a human-readable message on bad input.
|
|
324
|
+
*
|
|
325
|
+
* Beyond shape it validates referential integrity (rule targets, condition
|
|
326
|
+
* refs, transition endpoints, onReject.goto) and compiles every `pattern`
|
|
327
|
+
* validator, so authoring defects surface at store time — not as a 500 or a
|
|
328
|
+
* submission that can never pass.
|
|
138
329
|
*/
|
|
139
330
|
export function parseSchema(json) {
|
|
140
331
|
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
|
|
@@ -142,12 +333,21 @@ export function parseSchema(json) {
|
|
|
142
333
|
throw new Error('not an object');
|
|
143
334
|
if (isWorkflowDefinition(parsed)) {
|
|
144
335
|
for (const f of parsed.forms) {
|
|
145
|
-
if (
|
|
336
|
+
if (!isRecord(f))
|
|
337
|
+
throw new Error('invalid form: expected an object');
|
|
338
|
+
if (typeof f.formName !== 'string' || f.formName === '' || !Array.isArray(f.field_data)) {
|
|
146
339
|
throw new Error(`invalid form ${JSON.stringify(f.formId)}`);
|
|
147
340
|
}
|
|
341
|
+
if (UNSAFE_KEYS.has(f.formName) || f.formName.includes('.')) {
|
|
342
|
+
throw new Error(`invalid formName ${JSON.stringify(f.formName)}`);
|
|
343
|
+
}
|
|
344
|
+
if (typeof f.order !== 'number' || !Number.isFinite(f.order)) {
|
|
345
|
+
throw new Error(`form ${f.formName} needs a numeric order`);
|
|
346
|
+
}
|
|
148
347
|
if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
|
|
149
348
|
throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.formName}`);
|
|
150
349
|
}
|
|
350
|
+
assertValidFields(f);
|
|
151
351
|
assertValidViews(f);
|
|
152
352
|
assertValidRepeaters(f);
|
|
153
353
|
if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
|
|
@@ -158,15 +358,230 @@ export function parseSchema(json) {
|
|
|
158
358
|
if (new Set(names).size !== names.length) {
|
|
159
359
|
throw new Error('formName must be unique in a workflow');
|
|
160
360
|
}
|
|
361
|
+
const nameSet = new Set(names);
|
|
362
|
+
const refs = fieldRefs(parsed);
|
|
363
|
+
for (const f of parsed.forms) {
|
|
364
|
+
const approval = f.approval;
|
|
365
|
+
if (approval === undefined)
|
|
366
|
+
continue;
|
|
367
|
+
if (!isRecord(approval))
|
|
368
|
+
throw new Error(`invalid approval config on ${f.formName}`);
|
|
369
|
+
const onReject = approval.onReject;
|
|
370
|
+
if (onReject === undefined)
|
|
371
|
+
continue;
|
|
372
|
+
if (!isRecord(onReject) || (onReject.action !== 'end' && onReject.action !== 'goto')) {
|
|
373
|
+
throw new Error(`invalid onReject on ${f.formName}`);
|
|
374
|
+
}
|
|
375
|
+
if (onReject.action === 'goto' &&
|
|
376
|
+
(typeof onReject.form !== 'string' || !nameSet.has(onReject.form))) {
|
|
377
|
+
throw new Error(`onReject.goto targets unknown form ${JSON.stringify(onReject.form)} on ${f.formName}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
assertValidRules(parsed, nameSet, refs);
|
|
381
|
+
if (parsed.transitions !== undefined) {
|
|
382
|
+
if (!Array.isArray(parsed.transitions))
|
|
383
|
+
throw new Error('transitions must be an array');
|
|
384
|
+
for (const tr of parsed.transitions) {
|
|
385
|
+
if (!isRecord(tr) || typeof tr.from !== 'string' || typeof tr.to !== 'string') {
|
|
386
|
+
throw new Error('invalid transition: expected { from, to }');
|
|
387
|
+
}
|
|
388
|
+
if (!nameSet.has(tr.from)) {
|
|
389
|
+
throw new Error(`transition from unknown form ${JSON.stringify(tr.from)}`);
|
|
390
|
+
}
|
|
391
|
+
if (tr.to !== 'end' && !nameSet.has(tr.to)) {
|
|
392
|
+
throw new Error(`transition to unknown form ${JSON.stringify(tr.to)}`);
|
|
393
|
+
}
|
|
394
|
+
assertValidCondition(tr.when, refs, `transition ${tr.from} -> ${tr.to}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
161
397
|
return parsed;
|
|
162
398
|
}
|
|
163
399
|
if (isFormDefinition(parsed)) {
|
|
400
|
+
assertValidFields(parsed);
|
|
164
401
|
assertValidViews(parsed);
|
|
165
402
|
assertValidRepeaters(parsed);
|
|
166
403
|
return parsed;
|
|
167
404
|
}
|
|
168
405
|
throw new Error('missing workflowId/forms/rules (workflow) or formId/formName/field_data (form)');
|
|
169
406
|
}
|
|
407
|
+
// ---- parseSchema internals ----------------------------------------------------
|
|
408
|
+
/** Every addressable "formName.key" a rule/condition/expression may reference. */
|
|
409
|
+
function fieldRefs(wf) {
|
|
410
|
+
const refs = new Set();
|
|
411
|
+
for (const form of wf.forms) {
|
|
412
|
+
for (const field of [...form.field_data, ...approvalFields(form)]) {
|
|
413
|
+
if (field.type === 'label')
|
|
414
|
+
continue;
|
|
415
|
+
refs.add(`${form.formName}.${field.key}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return refs;
|
|
419
|
+
}
|
|
420
|
+
function assertValidFields(form) {
|
|
421
|
+
const seen = new Set();
|
|
422
|
+
for (const raw of form.field_data) {
|
|
423
|
+
if (!isRecord(raw))
|
|
424
|
+
throw new Error(`invalid field on ${form.formName}: expected an object`);
|
|
425
|
+
const field = raw;
|
|
426
|
+
assertFieldShape(field, form.formName);
|
|
427
|
+
if (field.key !== undefined) {
|
|
428
|
+
if (seen.has(field.key)) {
|
|
429
|
+
throw new Error(`duplicate field key ${JSON.stringify(field.key)} on ${form.formName}`);
|
|
430
|
+
}
|
|
431
|
+
seen.add(field.key);
|
|
432
|
+
}
|
|
433
|
+
if (field.fields !== undefined) {
|
|
434
|
+
if (!Array.isArray(field.fields)) {
|
|
435
|
+
throw new Error(`fields[] on ${form.formName}.${field.key} must be an array`);
|
|
436
|
+
}
|
|
437
|
+
for (const sub of field.fields) {
|
|
438
|
+
if (!isRecord(sub)) {
|
|
439
|
+
throw new Error(`invalid sub-field on ${form.formName}.${field.key}: expected an object`);
|
|
440
|
+
}
|
|
441
|
+
assertFieldShape(sub, form.formName);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function assertFieldShape(field, formName) {
|
|
447
|
+
if (typeof field.type !== 'string' || !FIELD_TYPES.has(field.type)) {
|
|
448
|
+
throw new Error(`unknown field type ${JSON.stringify(field.type)} on ${formName}`);
|
|
449
|
+
}
|
|
450
|
+
// labels are static blocks and may omit the key; everything else needs one
|
|
451
|
+
if (field.type !== 'label' || field.key !== undefined) {
|
|
452
|
+
if (typeof field.key !== 'string' || field.key === '') {
|
|
453
|
+
throw new Error(`field on ${formName} needs a non-empty string key`);
|
|
454
|
+
}
|
|
455
|
+
if (UNSAFE_KEYS.has(field.key) || field.key.includes('.')) {
|
|
456
|
+
throw new Error(`invalid field key ${JSON.stringify(field.key)} on ${formName}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (field.options !== undefined) {
|
|
460
|
+
if (!Array.isArray(field.options) ||
|
|
461
|
+
field.options.some((o) => !isRecord(o) || typeof o.key !== 'string')) {
|
|
462
|
+
throw new Error(`invalid options on ${formName}.${field.key}: expected { key, label } items`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (field.validators !== undefined) {
|
|
466
|
+
if (!Array.isArray(field.validators)) {
|
|
467
|
+
throw new Error(`validators on ${formName}.${field.key} must be an array`);
|
|
468
|
+
}
|
|
469
|
+
for (const v of field.validators) {
|
|
470
|
+
if (!isRecord(v) || typeof v.type !== 'string') {
|
|
471
|
+
throw new Error(`invalid validator on ${formName}.${field.key}`);
|
|
472
|
+
}
|
|
473
|
+
if (v.type === 'pattern') {
|
|
474
|
+
// compile now — an invalid regex would otherwise throw (or hang, if
|
|
475
|
+
// catastrophic) inside validateField on every future submission
|
|
476
|
+
try {
|
|
477
|
+
new RegExp(String(v.value));
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
throw new Error(`invalid pattern ${JSON.stringify(v.value)} on ${formName}.${field.key}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function assertValidRules(wf, formNames, refs) {
|
|
487
|
+
wf.rules.forEach((raw, i) => {
|
|
488
|
+
if (!isRecord(raw))
|
|
489
|
+
throw new Error(`invalid rule at index ${i}: expected an object`);
|
|
490
|
+
const rule = raw;
|
|
491
|
+
const where = `rule ${typeof rule.id === 'string' ? JSON.stringify(rule.id) : i}`;
|
|
492
|
+
if (rule.priority !== undefined && typeof rule.priority !== 'number') {
|
|
493
|
+
throw new Error(`${where}: priority must be a number`);
|
|
494
|
+
}
|
|
495
|
+
assertValidCondition(rule.when, refs, where);
|
|
496
|
+
for (const branch of ['then', 'else']) {
|
|
497
|
+
const actions = rule[branch];
|
|
498
|
+
if (actions === undefined)
|
|
499
|
+
continue;
|
|
500
|
+
if (!Array.isArray(actions))
|
|
501
|
+
throw new Error(`${where}: ${branch} must be an array`);
|
|
502
|
+
for (const rawAction of actions) {
|
|
503
|
+
if (!isRecord(rawAction) || typeof rawAction.action !== 'string') {
|
|
504
|
+
throw new Error(`${where}: invalid action`);
|
|
505
|
+
}
|
|
506
|
+
const target = rawAction.target;
|
|
507
|
+
if (!isRecord(target) || typeof target.form !== 'string') {
|
|
508
|
+
throw new Error(`${where}: action needs a target form`);
|
|
509
|
+
}
|
|
510
|
+
if (!formNames.has(target.form)) {
|
|
511
|
+
throw new Error(`${where} targets unknown form ${JSON.stringify(target.form)}`);
|
|
512
|
+
}
|
|
513
|
+
const formLevel = rawAction.action === 'showForm' ||
|
|
514
|
+
rawAction.action === 'hideForm' ||
|
|
515
|
+
rawAction.action === 'skipForm';
|
|
516
|
+
if (!formLevel) {
|
|
517
|
+
const ref = `${target.form}.${String(target.field)}`;
|
|
518
|
+
if (typeof target.field !== 'string' || !refs.has(ref)) {
|
|
519
|
+
throw new Error(`${where} targets unknown field ${JSON.stringify(ref)}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (rawAction.action === 'setValue') {
|
|
523
|
+
assertValidExpr(rawAction.value, refs, where);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
function assertValidCondition(cond, refs, where) {
|
|
530
|
+
if (cond === undefined)
|
|
531
|
+
return; // omitted = always true
|
|
532
|
+
if (!isRecord(cond))
|
|
533
|
+
throw new Error(`invalid condition in ${where}`);
|
|
534
|
+
if ('and' in cond || 'or' in cond) {
|
|
535
|
+
const list = cond['and'] ?? cond['or'];
|
|
536
|
+
if (!Array.isArray(list))
|
|
537
|
+
throw new Error(`invalid condition in ${where}`);
|
|
538
|
+
for (const c of list)
|
|
539
|
+
assertValidCondition(c, refs, where);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if ('not' in cond) {
|
|
543
|
+
assertValidCondition(cond['not'], refs, where);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (typeof cond['field'] !== 'string' || !refs.has(cond['field'])) {
|
|
547
|
+
throw new Error(`${where} references unknown field ${JSON.stringify(cond['field'])}`);
|
|
548
|
+
}
|
|
549
|
+
const valueField = cond['valueField'];
|
|
550
|
+
if (valueField !== undefined && (typeof valueField !== 'string' || !refs.has(valueField))) {
|
|
551
|
+
throw new Error(`${where} references unknown field ${JSON.stringify(valueField)}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function assertValidExpr(expr, refs, where) {
|
|
555
|
+
if (!isRecord(expr))
|
|
556
|
+
throw new Error(`${where}: invalid value expression`);
|
|
557
|
+
if ('ref' in expr) {
|
|
558
|
+
if (typeof expr['ref'] !== 'string' || !refs.has(expr['ref'])) {
|
|
559
|
+
throw new Error(`${where} references unknown field ${JSON.stringify(expr['ref'])}`);
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if ('const' in expr)
|
|
564
|
+
return;
|
|
565
|
+
if ('op' in expr) {
|
|
566
|
+
if (!Array.isArray(expr['args'])) {
|
|
567
|
+
throw new Error(`${where}: expression args must be an array`);
|
|
568
|
+
}
|
|
569
|
+
for (const a of expr['args'])
|
|
570
|
+
assertValidExpr(a, refs, where);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if ('if' in expr) {
|
|
574
|
+
const triple = expr['if'];
|
|
575
|
+
if (!Array.isArray(triple) || triple.length !== 3) {
|
|
576
|
+
throw new Error(`${where}: if expression needs [condition, then, else]`);
|
|
577
|
+
}
|
|
578
|
+
assertValidCondition(triple[0], refs, where);
|
|
579
|
+
assertValidExpr(triple[1], refs, where);
|
|
580
|
+
assertValidExpr(triple[2], refs, where);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
throw new Error(`${where}: invalid value expression`);
|
|
584
|
+
}
|
|
170
585
|
/**
|
|
171
586
|
* View integrity — views are client-side display projections, but a stored
|
|
172
587
|
* one must always resolve on the client: names unique and shaped right, every
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bank20baht-validator",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime validation for bahtui form/workflow JSON — the exact engine rules the client runs, on the server.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
"forms",
|
|
19
19
|
"workflow",
|
|
20
20
|
"validation",
|
|
21
|
-
"server"
|
|
22
|
-
"json-schema"
|
|
21
|
+
"server"
|
|
23
22
|
],
|
|
24
23
|
"publishConfig": {
|
|
25
24
|
"access": "public"
|
|
@@ -30,19 +29,25 @@
|
|
|
30
29
|
"exports": {
|
|
31
30
|
".": {
|
|
32
31
|
"types": "./dist/index.d.ts",
|
|
33
|
-
"import": "./dist/index.js"
|
|
34
|
-
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./package.json": "./package.json"
|
|
35
36
|
},
|
|
36
37
|
"scripts": {
|
|
37
38
|
"build": "tsc -p tsconfig.json",
|
|
38
39
|
"test": "vitest run",
|
|
39
40
|
"test:watch": "vitest"
|
|
40
41
|
},
|
|
41
|
-
"dependencies": {
|
|
42
|
-
"bank20baht-ui": "^0.0.10"
|
|
43
|
-
},
|
|
44
42
|
"devDependencies": {
|
|
43
|
+
"bank20baht-ui": "^0.1.0",
|
|
45
44
|
"typescript": "^5.8.3",
|
|
46
45
|
"vitest": "^3.2.4"
|
|
47
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"bank20baht-ui": ">=0.1.0 <0.2.0"
|
|
49
|
+
},
|
|
50
|
+
"main": "./dist/index.js",
|
|
51
|
+
"types": "./dist/index.d.ts",
|
|
52
|
+
"sideEffects": false
|
|
48
53
|
}
|