bank20baht-validator 0.0.5 → 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 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
- * and approval steps demand a decision plus their comment policy.
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, walkPath, } 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 = sorted(form.field_data).filter((f) => f.type !== 'label');
43
- // canonical data first, so compare validators see every sibling
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 in input ? input[field.key] : defaultValue(field);
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
- * and approval steps demand a decision plus their comment policy.
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,24 +216,43 @@ export function validateWorkflowData(workflow, input, registry) {
72
216
  }
73
217
  }
74
218
  const derived = evaluate(workflow, input);
75
- const issues = [...derived.errors];
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 }));
76
226
  // graph mode (spec 03 §6): only the forms on the path this data takes
77
227
  // validate; off-path branches are as good as skipped, their data dropped
78
- const path = workflow.transitions && workflow.transitions.length > 0
79
- ? new Set(walkPath(workflow, derived.data))
228
+ const pathList = workflow.transitions && workflow.transitions.length > 0
229
+ ? walkPath(workflow, derived.data)
80
230
  : null;
81
- if (path) {
231
+ if (pathList) {
232
+ const path = new Set(pathList);
82
233
  for (const name of Object.keys(derived.data)) {
83
234
  if (!path.has(name))
84
235
  delete derived.data[name];
85
236
  }
86
237
  }
87
- const forms = [...workflow.forms].sort((a, b) => a.order - b.order);
88
- for (const form of forms) {
89
- if (derived.forms[form.formName]?.visible === false)
90
- continue;
91
- if (path && !path.has(form.formName))
92
- continue;
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) {
93
256
  if (form.kind === 'approval') {
94
257
  issues.push(...approvalIssues(form, derived.data));
95
258
  continue;
@@ -98,12 +261,22 @@ export function validateWorkflowData(workflow, input, registry) {
98
261
  if (field.type === 'label')
99
262
  continue;
100
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
+ }
101
268
  const state = derived.fields[ref];
102
269
  if (state && !state.visible)
103
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
+ }
104
278
  const required = state ? state.required : !!field.required;
105
279
  const descriptor = required === !!field.required ? field : { ...field, required };
106
- const formData = derived.data[form.formName] ?? {};
107
280
  for (const err of validateField(descriptor, formData[field.key], registry, formData)) {
108
281
  issues.push({ ref, message: err.message });
109
282
  }
@@ -133,11 +306,11 @@ function approvalIssues(form, data) {
133
306
  * const schema = store.get(body.id);
134
307
  * const result = validateSubmission(schema, body.data);
135
308
  */
136
- export function validateSubmission(schema, data, registry) {
309
+ export function validateSubmission(schema, data, registry, locale) {
137
310
  if (isWorkflowDefinition(schema))
138
- return validateWorkflowData(schema, data, registry);
311
+ return validateWorkflowData(schema, data, registry, locale);
139
312
  if (isFormDefinition(schema))
140
- return validateFormData(schema, data, registry);
313
+ return validateFormData(schema, data, registry, locale);
141
314
  return {
142
315
  valid: false,
143
316
  issues: [{ ref: '$', message: 'unrecognized schema: neither a form nor a workflow' }],
@@ -148,6 +321,11 @@ export function validateSubmission(schema, data, registry) {
148
321
  * Structural gate for storing builder output — the same checks the builder's
149
322
  * Import applies, so the server only ever stores schemas the client could
150
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.
151
329
  */
152
330
  export function parseSchema(json) {
153
331
  const parsed = typeof json === 'string' ? JSON.parse(json) : json;
@@ -155,12 +333,21 @@ export function parseSchema(json) {
155
333
  throw new Error('not an object');
156
334
  if (isWorkflowDefinition(parsed)) {
157
335
  for (const f of parsed.forms) {
158
- if (typeof f.formName !== 'string' || !Array.isArray(f.field_data)) {
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)) {
159
339
  throw new Error(`invalid form ${JSON.stringify(f.formId)}`);
160
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
+ }
161
347
  if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
162
348
  throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.formName}`);
163
349
  }
350
+ assertValidFields(f);
164
351
  assertValidViews(f);
165
352
  assertValidRepeaters(f);
166
353
  if (f.view !== undefined && !f.views?.some((v) => v.name === f.view)) {
@@ -171,10 +358,29 @@ export function parseSchema(json) {
171
358
  if (new Set(names).size !== names.length) {
172
359
  throw new Error('formName must be unique in a workflow');
173
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);
174
381
  if (parsed.transitions !== undefined) {
175
382
  if (!Array.isArray(parsed.transitions))
176
383
  throw new Error('transitions must be an array');
177
- const nameSet = new Set(names);
178
384
  for (const tr of parsed.transitions) {
179
385
  if (!isRecord(tr) || typeof tr.from !== 'string' || typeof tr.to !== 'string') {
180
386
  throw new Error('invalid transition: expected { from, to }');
@@ -185,17 +391,197 @@ export function parseSchema(json) {
185
391
  if (tr.to !== 'end' && !nameSet.has(tr.to)) {
186
392
  throw new Error(`transition to unknown form ${JSON.stringify(tr.to)}`);
187
393
  }
394
+ assertValidCondition(tr.when, refs, `transition ${tr.from} -> ${tr.to}`);
188
395
  }
189
396
  }
190
397
  return parsed;
191
398
  }
192
399
  if (isFormDefinition(parsed)) {
400
+ assertValidFields(parsed);
193
401
  assertValidViews(parsed);
194
402
  assertValidRepeaters(parsed);
195
403
  return parsed;
196
404
  }
197
405
  throw new Error('missing workflowId/forms/rules (workflow) or formId/formName/field_data (form)');
198
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
+ }
199
585
  /**
200
586
  * View integrity — views are client-side display projections, but a stored
201
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.5",
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.11"
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
  }