bank20baht-validator 0.0.1

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/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # bank20baht-validator
2
+
3
+ Server-side validation for [`bank20baht-ui`](https://www.npmjs.com/package/bank20baht-ui) form/workflow JSON — by re-running **the exact engine code the client runs**, at request time.
4
+
5
+ No JSON-Schema translation, no codegen, no drift: the schema your builder exported is the validator. Store new JSON, validation follows immediately — no recompile, no redeploy.
6
+
7
+ ```bash
8
+ bun add bank20baht-validator # or npm i bank20baht-validator
9
+ ```
10
+
11
+ ## Quick start (Elysia)
12
+
13
+ ```ts
14
+ import { Elysia, t } from 'elysia';
15
+ import { parseSchema, validateSubmission } from 'bank20baht-validator';
16
+
17
+ // any storage works — this is just JSON at rest
18
+ const schemas = new Map([['todo-form', parseSchema(todoFormJson)]]);
19
+
20
+ new Elysia()
21
+ .post(
22
+ '/api/submissions',
23
+ ({ body, status }) => {
24
+ const schema = schemas.get(body.id);
25
+ if (!schema) return status(404, { error: `unknown schema "${body.id}"` });
26
+
27
+ const result = validateSubmission(schema, body.data);
28
+ if (!result.valid) return status(422, { valid: false, errors: result.issues });
29
+
30
+ // result.data is canonical: defaults filled, hidden fields cleared —
31
+ // identical to what the client emits on baht-submit. Persist THIS.
32
+ return status(201, { valid: true, data: result.data });
33
+ },
34
+ // one endpoint serves every schema — data stays open on purpose
35
+ { body: t.Object({ id: t.String(), data: t.Any() }) },
36
+ )
37
+ .listen(3001);
38
+ ```
39
+
40
+ A request that bypasses your frontend gets the same verdict the frontend would have shown, message for message:
41
+
42
+ ```
43
+ POST /api/submissions {"id":"todo-form","data":{"title":"ab"}}
44
+ → 422 {"errors":[{"ref":"title","message":"Must be at least 3 characters"}]}
45
+ ```
46
+
47
+ ## What it enforces
48
+
49
+ - Every declarative validator of the field descriptors: `required`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, and `custom` (via a `Registry`).
50
+ - **Workflow rules first** (`evaluate` from `bank20baht-ui/engine`): fields hidden by rules are excluded and smuggled values are cleared; `required` derived from rules is enforced; `setValue` results are computed.
51
+ - Approval steps: a `decision` must be present, and the comment policy (`required` / `requireOnReject`) is honored.
52
+
53
+ ## API
54
+
55
+ | export | what it does |
56
+ | --- | --- |
57
+ | `validateSubmission(schema, data, registry?)` | dispatches on schema kind — the single server entry point |
58
+ | `validateFormData(form, data, registry?)` | mirrors `<baht-form>.validate()` for a standalone form |
59
+ | `validateWorkflowData(workflow, data, registry?)` | evaluates rules, then validates every visible form |
60
+ | `parseSchema(json)` | structural gate for storing builder output (throws on bad input) |
61
+ | `isFormDefinition` / `isWorkflowDefinition` | schema kind guards |
62
+
63
+ All results are `{ valid, issues, data }`; issues are `{ ref, message }` where `ref` is the field key (forms) or `formName.key` (workflows).
@@ -0,0 +1,48 @@
1
+ import { approvalFields, defaultValue, evaluate, validateField, type FormDefinition, type NestedData, type Registry, type WorkflowDefinition, type WorkflowForm } from 'bank20baht-ui/engine';
2
+ export type SchemaDefinition = FormDefinition | WorkflowDefinition;
3
+ /** One violation. `ref` is the field key for forms, `formName.key` for workflows. */
4
+ export interface ValidationIssue {
5
+ ref: string;
6
+ message: string;
7
+ }
8
+ export interface ValidationResult {
9
+ valid: boolean;
10
+ issues: ValidationIssue[];
11
+ /**
12
+ * Canonical data the server should persist: input overlaid on field
13
+ * defaults; for workflows also with hidden fields cleared and setValue
14
+ * rules applied — identical to what the client emits on `baht-submit`.
15
+ */
16
+ data: Record<string, unknown> | NestedData;
17
+ }
18
+ export declare function isWorkflowDefinition(schema: unknown): schema is WorkflowDefinition;
19
+ export declare function isFormDefinition(schema: unknown): schema is FormDefinition;
20
+ /**
21
+ * Validate a standalone form submission — mirrors `<baht-form>.validate()`
22
+ * with no derived field states: every non-label field, missing values fall
23
+ * back to the field default before checking.
24
+ */
25
+ export declare function validateFormData(form: FormDefinition, input: unknown, registry?: Registry): ValidationResult;
26
+ /**
27
+ * Validate a whole workflow submission — mirrors what `<baht-workflow>`
28
+ * enforces across its steps: rules are evaluated first (`evaluate`), invisible
29
+ * forms/fields are excluded, rule-derived `required` overrides the descriptor,
30
+ * and approval steps demand a decision plus their comment policy.
31
+ */
32
+ export declare function validateWorkflowData(workflow: WorkflowDefinition, input: unknown, registry?: Registry): ValidationResult;
33
+ /**
34
+ * Validate a submission body's `data` against whichever schema kind `id`
35
+ * resolved to. This is the single server entry point:
36
+ *
37
+ * const schema = store.get(body.id);
38
+ * const result = validateSubmission(schema, body.data);
39
+ */
40
+ export declare function validateSubmission(schema: SchemaDefinition, data: unknown, registry?: Registry): ValidationResult;
41
+ /**
42
+ * Structural gate for storing builder output — the same checks the builder's
43
+ * Import applies, so the server only ever stores schemas the client could
44
+ * render. Throws with a human-readable message on bad input.
45
+ */
46
+ export declare function parseSchema(json: unknown): SchemaDefinition;
47
+ export { approvalFields, defaultValue, evaluate, validateField };
48
+ export type { FormDefinition, NestedData, Registry, WorkflowDefinition, WorkflowForm };
package/dist/index.js ADDED
@@ -0,0 +1,161 @@
1
+ // ---------------------------------------------------------------------------
2
+ // bank20baht-validator — run the client's validation on the server, at runtime.
3
+ //
4
+ // The builder exports plain JSON (FormDefinition / WorkflowDefinition). The
5
+ // client validates it with bank20baht-ui/engine (`evaluate` + `validateField`).
6
+ // This package feeds the same JSON through the same engine functions, so a
7
+ // request that bypasses the frontend hits the exact same rules — no codegen,
8
+ // no recompile: swap the stored JSON and validation follows.
9
+ // ---------------------------------------------------------------------------
10
+ import { approvalFields, defaultValue, evaluate, validateField, } from 'bank20baht-ui/engine';
11
+ export function isWorkflowDefinition(schema) {
12
+ return (isRecord(schema) &&
13
+ typeof schema['workflowId'] === 'string' &&
14
+ Array.isArray(schema['forms']) &&
15
+ Array.isArray(schema['rules']));
16
+ }
17
+ export function isFormDefinition(schema) {
18
+ return (isRecord(schema) &&
19
+ !isWorkflowDefinition(schema) &&
20
+ typeof schema['formId'] === 'string' &&
21
+ typeof schema['formName'] === 'string' &&
22
+ Array.isArray(schema['field_data']));
23
+ }
24
+ function isRecord(v) {
25
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
26
+ }
27
+ function sorted(fields) {
28
+ return [...fields].sort((a, b) => a.order - b.order);
29
+ }
30
+ /**
31
+ * Validate a standalone form submission — mirrors `<baht-form>.validate()`
32
+ * with no derived field states: every non-label field, missing values fall
33
+ * back to the field default before checking.
34
+ */
35
+ export function validateFormData(form, input, registry) {
36
+ if (!isRecord(input)) {
37
+ return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
38
+ }
39
+ const issues = [];
40
+ const data = {};
41
+ for (const field of sorted(form.field_data)) {
42
+ if (field.type === 'label')
43
+ continue;
44
+ const value = field.key in input ? input[field.key] : defaultValue(field);
45
+ data[field.key] = value;
46
+ for (const err of validateField(field, value, registry)) {
47
+ issues.push({ ref: err.key, message: err.message });
48
+ }
49
+ }
50
+ return { valid: issues.length === 0, issues, data };
51
+ }
52
+ /**
53
+ * Validate a whole workflow submission — mirrors what `<baht-workflow>`
54
+ * enforces across its steps: rules are evaluated first (`evaluate`), invisible
55
+ * forms/fields are excluded, rule-derived `required` overrides the descriptor,
56
+ * and approval steps demand a decision plus their comment policy.
57
+ */
58
+ export function validateWorkflowData(workflow, input, registry) {
59
+ if (!isRecord(input)) {
60
+ return { valid: false, issues: [{ ref: '$', message: 'data must be an object' }], data: {} };
61
+ }
62
+ for (const [name, values] of Object.entries(input)) {
63
+ if (values !== undefined && !isRecord(values)) {
64
+ return {
65
+ valid: false,
66
+ issues: [{ ref: name, message: 'form data must be an object' }],
67
+ data: {},
68
+ };
69
+ }
70
+ }
71
+ const derived = evaluate(workflow, input);
72
+ const issues = [...derived.errors];
73
+ const forms = [...workflow.forms].sort((a, b) => a.order - b.order);
74
+ for (const form of forms) {
75
+ if (derived.forms[form.formName]?.visible === false)
76
+ continue;
77
+ if (form.kind === 'approval') {
78
+ issues.push(...approvalIssues(form, derived.data));
79
+ continue;
80
+ }
81
+ for (const field of sorted(form.field_data)) {
82
+ if (field.type === 'label')
83
+ continue;
84
+ const ref = `${form.formName}.${field.key}`;
85
+ const state = derived.fields[ref];
86
+ if (state && !state.visible)
87
+ continue; // hidden fields excluded (spec 02 §5)
88
+ const required = state ? state.required : !!field.required;
89
+ const descriptor = required === !!field.required ? field : { ...field, required };
90
+ const value = derived.data[form.formName]?.[field.key];
91
+ for (const err of validateField(descriptor, value, registry)) {
92
+ issues.push({ ref, message: err.message });
93
+ }
94
+ }
95
+ }
96
+ return { valid: issues.length === 0, issues, data: derived.data };
97
+ }
98
+ /** Approval step checks — same gate `<baht-workflow>.decide()` applies. */
99
+ function approvalIssues(form, data) {
100
+ const issues = [];
101
+ const values = data[form.formName] ?? {};
102
+ const decision = values['decision'];
103
+ if (decision !== 'approved' && decision !== 'rejected') {
104
+ issues.push({ ref: `${form.formName}.decision`, message: 'A decision is required.' });
105
+ }
106
+ const mode = form.approval?.comment ?? 'optional';
107
+ const commentRequired = mode === 'required' || (mode === 'requireOnReject' && decision === 'rejected');
108
+ if (commentRequired && String(values['comment'] ?? '').trim() === '') {
109
+ issues.push({ ref: `${form.formName}.comment`, message: 'A comment is required.' });
110
+ }
111
+ return issues;
112
+ }
113
+ /**
114
+ * Validate a submission body's `data` against whichever schema kind `id`
115
+ * resolved to. This is the single server entry point:
116
+ *
117
+ * const schema = store.get(body.id);
118
+ * const result = validateSubmission(schema, body.data);
119
+ */
120
+ export function validateSubmission(schema, data, registry) {
121
+ if (isWorkflowDefinition(schema))
122
+ return validateWorkflowData(schema, data, registry);
123
+ if (isFormDefinition(schema))
124
+ return validateFormData(schema, data, registry);
125
+ return {
126
+ valid: false,
127
+ issues: [{ ref: '$', message: 'unrecognized schema: neither a form nor a workflow' }],
128
+ data: {},
129
+ };
130
+ }
131
+ /**
132
+ * Structural gate for storing builder output — the same checks the builder's
133
+ * Import applies, so the server only ever stores schemas the client could
134
+ * render. Throws with a human-readable message on bad input.
135
+ */
136
+ export function parseSchema(json) {
137
+ const parsed = typeof json === 'string' ? JSON.parse(json) : json;
138
+ if (!isRecord(parsed))
139
+ throw new Error('not an object');
140
+ if (isWorkflowDefinition(parsed)) {
141
+ for (const f of parsed.forms) {
142
+ if (typeof f.formName !== 'string' || !Array.isArray(f.field_data)) {
143
+ throw new Error(`invalid form ${JSON.stringify(f.formId)}`);
144
+ }
145
+ if (f.kind !== undefined && f.kind !== 'form' && f.kind !== 'approval') {
146
+ throw new Error(`invalid form kind ${JSON.stringify(f.kind)} on ${f.formName}`);
147
+ }
148
+ }
149
+ const names = parsed.forms.map((f) => f.formName);
150
+ if (new Set(names).size !== names.length) {
151
+ throw new Error('formName must be unique in a workflow');
152
+ }
153
+ return parsed;
154
+ }
155
+ if (isFormDefinition(parsed))
156
+ return parsed;
157
+ throw new Error('missing workflowId/forms/rules (workflow) or formId/formName/field_data (form)');
158
+ }
159
+ // Re-exported so servers can type stores and registries without a direct
160
+ // dependency on bank20baht-ui.
161
+ export { approvalFields, defaultValue, evaluate, validateField };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "bank20baht-validator",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Runtime validation for bahtui form/workflow JSON — the exact engine rules the client runs, on the server.",
6
+ "license": "MIT",
7
+ "author": "Nattapong Promthong",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/bank20baht/bahtui.git",
11
+ "directory": "packages/validator"
12
+ },
13
+ "homepage": "https://github.com/bank20baht/bahtui#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/bank20baht/bahtui/issues"
16
+ },
17
+ "keywords": [
18
+ "forms",
19
+ "workflow",
20
+ "validation",
21
+ "server",
22
+ "json-schema"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest"
40
+ },
41
+ "dependencies": {
42
+ "bank20baht-ui": "^0.0.6"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.8.3",
46
+ "vitest": "^3.2.4"
47
+ }
48
+ }