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.
- package/dist/index.js +352 -290
- 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
|
|
58
|
-
Array.isArray(schema
|
|
59
|
-
Array.isArray(schema
|
|
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
|
|
65
|
-
typeof schema
|
|
66
|
-
Array.isArray(schema
|
|
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'
|
|
95
|
-
case 'date':
|
|
96
|
-
|
|
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)
|
|
131
|
+
return shapeMessage(typeof value === 'number' && Number.isFinite(value));
|
|
115
132
|
case 'checkbox':
|
|
116
133
|
case 'toggle':
|
|
117
|
-
return typeof value === 'boolean'
|
|
134
|
+
return shapeMessage(typeof value === 'boolean');
|
|
118
135
|
case 'dropdown':
|
|
119
|
-
case 'radio':
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
|
|
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)
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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]?.
|
|
307
|
+
return on_reject.action === 'end' && derived.data[form.form_name]?.decision === 'rejected';
|
|
317
308
|
});
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
field.options.some((o) => !isRecord(o) || typeof o.key !== 'string')) {
|
|
559
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
732
|
+
assertValidCondition(cond.not, refs, where);
|
|
673
733
|
return;
|
|
674
734
|
}
|
|
675
|
-
if (typeof cond
|
|
676
|
-
throw new Error(`${where} references unknown field ${JSON.stringify(cond
|
|
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
|
|
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
|
|
688
|
-
throw new Error(`${where} references unknown field ${JSON.stringify(expr
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
49
|
+
"bank20baht-ui": ">=0.5.0 <0.11.0"
|
|
49
50
|
},
|
|
50
51
|
"main": "./dist/index.js",
|
|
51
52
|
"types": "./dist/index.d.ts",
|