Skip to content

Commit 6d593b0

Browse files
hotlongCopilot
andcommitted
feat(objectql,rest): Zod-at-rest validation + structured error envelope (M10.4)
Adds field-level validation at the engine boundary so REST callers get a clean 400 envelope instead of a raw SQL/driver dump. - packages/objectql/src/validation/record-validator.ts * New ValidationError class with .fields[] array * validateRecord(schema, data, mode) walks the canonical Field metadata, enforcing required/maxLength/minLength/min/max, email/url/phone formats, select/multiselect option membership, boolean/date coercion * insert mode enforces required; update mode validates only supplied fields (PATCH semantics) * Skips system fields (id/created_at/etc) and readonly/system fields - packages/objectql/src/engine.ts * insert: validates each row after applyFieldDefaults * update: validates patch payload after beforeUpdate hook - packages/rest/src/rest-server.ts * mapDataError: VALIDATION_FAILED branch → 400 { code, fields[] } * SQL-leak detector: rewrites driver dumps as 500 DATABASE_ERROR (and 409 UNIQUE_VIOLATION for unique-constraint violations) * Suppresses [REST] Unhandled error log for VALIDATION_FAILED Verified end-to-end via curl on examples/app-crm: - POST {} → 400 with 5 required-field entries - POST {email:'bad'} → 400 invalid_email - POST {status:'bogus'} → 400 invalid_option with allowed list - PATCH {email:'bad'} → 400 invalid_email (partial mode) - PATCH {email:'ok@x'} → 200 + audit row written Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a27cb6a commit 6d593b0

4 files changed

Lines changed: 288 additions & 5 deletions

File tree

packages/objectql/src/engine.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SchemaRegistry, computeFQN } from './registry.js';
1818
import { ExpressionEngine } from '@objectstack/formula';
1919
import type { Expression } from '@objectstack/spec';
2020
import { bindHooksToEngine } from './hook-binder.js';
21+
import { validateRecord } from './validation/record-validator.js';
2122

2223
interface FormulaPlanEntry { name: string; expression: Expression; }
2324

@@ -1422,11 +1423,13 @@ export class ObjectQL implements IDataEngine {
14221423
try {
14231424
let result;
14241425
const nowSnap = new Date();
1426+
const schemaForValidation = this._registry.getObject(object);
14251427
if (Array.isArray(hookContext.input.data)) {
14261428
// Bulk Create — apply defaults per row
14271429
const rows = (hookContext.input.data as any[]).map((row) =>
14281430
this.applyFieldDefaults(object, row as Record<string, unknown>, opCtx.context, nowSnap),
14291431
);
1432+
for (const r of rows) validateRecord(schemaForValidation, r, 'insert');
14301433
if (driver.bulkCreate) {
14311434
result = await driver.bulkCreate(object, rows, hookContext.input.options as any);
14321435
} else {
@@ -1440,6 +1443,7 @@ export class ObjectQL implements IDataEngine {
14401443
opCtx.context,
14411444
nowSnap,
14421445
);
1446+
validateRecord(schemaForValidation, row, 'insert');
14431447
result = await driver.create(object, row, hookContext.input.options as any);
14441448
}
14451449

@@ -1527,8 +1531,10 @@ export class ObjectQL implements IDataEngine {
15271531
try {
15281532
let result;
15291533
if (hookContext.input.id) {
1534+
validateRecord(this._registry.getObject(object), hookContext.input.data as Record<string, unknown>, 'update');
15301535
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
15311536
} else if (options?.multi && driver.updateMany) {
1537+
validateRecord(this._registry.getObject(object), hookContext.input.data as Record<string, unknown>, 'update');
15321538
const ast: QueryAST = { object, where: options.where };
15331539
result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
15341540
} else {

packages/objectql/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ export { bindHooksToEngine } from './hook-binder.js';
2424
export type { BindHooksOptions, BindHooksResult } from './hook-binder.js';
2525
export { wrapDeclarativeHook } from './hook-wrappers.js';
2626
export type { WrapDeclarativeOptions } from './hook-wrappers.js';
27+
28+
// Export Validation
29+
export { ValidationError, validateRecord } from './validation/record-validator.js';
30+
export type { FieldValidationError } from './validation/record-validator.js';
2731
export {
2832
InMemoryHookMetricsRecorder,
2933
noopHookMetricsRecorder,
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Record Validator
5+
*
6+
* Validates an incoming insert/update payload against the canonical
7+
* `Field` metadata of an `ObjectSchema`. Implements ROADMAP §M10.4 —
8+
* "Zod-at-rest" — but does not require constructing a Zod schema:
9+
* we walk the field map directly, which is both faster and lets us
10+
* produce per-field error envelopes shaped for REST consumption.
11+
*
12+
* Rules applied (in order, stop at first error per field):
13+
*
14+
* - `required` missing/null/empty-string is rejected (insert only;
15+
* PATCH validates only fields actually supplied)
16+
* - `maxLength` / `minLength` (text/textarea/email/url/phone/password)
17+
* - `min` / `max` (number/currency/percent/rating/slider)
18+
* - format email / url / phone (lightweight RFC-aware regex)
19+
* - select / multiselect: value must appear in `options`
20+
* - boolean / toggle: must coerce to boolean
21+
* - date / datetime: must be ISO-parsable
22+
*
23+
* System-injected fields (`id`, `created_at`, `created_by`,
24+
* `updated_at`, `updated_by`, `organization_id`) are never validated
25+
* here — the engine and the audit plugin manage them.
26+
*
27+
* On failure, a `ValidationError` is thrown with `.fields[]` holding
28+
* one entry per offending field. REST translates this into a
29+
* `400 { code: 'VALIDATION_FAILED', message, fields }` envelope so
30+
* the UI can highlight the specific input.
31+
*/
32+
33+
const SKIP_FIELDS = new Set<string>([
34+
'id', 'created_at', 'created_by', 'updated_at', 'updated_by',
35+
'organization_id', 'tenant_id',
36+
]);
37+
38+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
39+
const URL_RE = /^https?:\/\/[^\s]+$/i;
40+
const PHONE_RE = /^[+()\-\s\d.]{5,}$/;
41+
42+
export interface FieldValidationError {
43+
field: string;
44+
code:
45+
| 'required'
46+
| 'min_length'
47+
| 'max_length'
48+
| 'min_value'
49+
| 'max_value'
50+
| 'invalid_email'
51+
| 'invalid_url'
52+
| 'invalid_phone'
53+
| 'invalid_number'
54+
| 'invalid_boolean'
55+
| 'invalid_date'
56+
| 'invalid_option';
57+
message: string;
58+
/** Allowed values for select/multiselect, when applicable. */
59+
options?: string[];
60+
}
61+
62+
export class ValidationError extends Error {
63+
readonly code = 'VALIDATION_FAILED';
64+
readonly fields: FieldValidationError[];
65+
constructor(fields: FieldValidationError[]) {
66+
super(
67+
`Validation failed for ${fields.length} field(s): ` +
68+
fields.map((f) => `${f.field} (${f.code})`).join(', '),
69+
);
70+
this.name = 'ValidationError';
71+
this.fields = fields;
72+
}
73+
}
74+
75+
type Mode = 'insert' | 'update';
76+
77+
interface FieldDef {
78+
name?: string;
79+
type: string;
80+
required?: boolean;
81+
readonly?: boolean;
82+
system?: boolean;
83+
multiple?: boolean;
84+
maxLength?: number;
85+
minLength?: number;
86+
min?: number;
87+
max?: number;
88+
options?: Array<{ value: string | number; label?: string } | string | number>;
89+
}
90+
91+
function isMissing(v: unknown): boolean {
92+
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
93+
}
94+
95+
function optionValues(options: FieldDef['options']): string[] {
96+
if (!Array.isArray(options)) return [];
97+
return options.map((o) =>
98+
typeof o === 'object' && o !== null ? String((o as any).value) : String(o),
99+
);
100+
}
101+
102+
function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null {
103+
// ── required ────────────────────────────────────────────────────
104+
if (def.required && isMissing(value)) {
105+
return { field: name, code: 'required', message: `${name} is required` };
106+
}
107+
if (isMissing(value)) return null; // nothing else to check
108+
109+
const t = def.type;
110+
111+
// ── string types ────────────────────────────────────────────────
112+
if (t === 'text' || t === 'textarea' || t === 'email' || t === 'url' || t === 'phone' || t === 'password' || t === 'markdown' || t === 'html' || t === 'richtext' || t === 'code') {
113+
const s = typeof value === 'string' ? value : String(value);
114+
if (def.maxLength !== undefined && s.length > def.maxLength) {
115+
return { field: name, code: 'max_length', message: `${name} must be ≤ ${def.maxLength} characters (got ${s.length})` };
116+
}
117+
if (def.minLength !== undefined && s.length < def.minLength) {
118+
return { field: name, code: 'min_length', message: `${name} must be ≥ ${def.minLength} characters (got ${s.length})` };
119+
}
120+
if (t === 'email' && !EMAIL_RE.test(s)) {
121+
return { field: name, code: 'invalid_email', message: `${name} must be a valid email address` };
122+
}
123+
if (t === 'url' && !URL_RE.test(s)) {
124+
return { field: name, code: 'invalid_url', message: `${name} must be a valid URL (http:// or https://)` };
125+
}
126+
if (t === 'phone' && !PHONE_RE.test(s)) {
127+
return { field: name, code: 'invalid_phone', message: `${name} must be a valid phone number` };
128+
}
129+
return null;
130+
}
131+
132+
// ── number types ────────────────────────────────────────────────
133+
if (t === 'number' || t === 'currency' || t === 'percent' || t === 'rating' || t === 'slider') {
134+
const n = typeof value === 'number' ? value : Number(value);
135+
if (!Number.isFinite(n)) {
136+
return { field: name, code: 'invalid_number', message: `${name} must be a number` };
137+
}
138+
if (def.min !== undefined && n < def.min) {
139+
return { field: name, code: 'min_value', message: `${name} must be ≥ ${def.min}` };
140+
}
141+
if (def.max !== undefined && n > def.max) {
142+
return { field: name, code: 'max_value', message: `${name} must be ≤ ${def.max}` };
143+
}
144+
return null;
145+
}
146+
147+
// ── boolean ────────────────────────────────────────────────────
148+
if (t === 'boolean' || t === 'toggle') {
149+
if (typeof value === 'boolean') return null;
150+
if (value === 0 || value === 1 || value === '0' || value === '1' || value === 'true' || value === 'false') return null;
151+
return { field: name, code: 'invalid_boolean', message: `${name} must be true or false` };
152+
}
153+
154+
// ── date/datetime ───────────────────────────────────────────────
155+
if (t === 'date' || t === 'datetime' || t === 'time') {
156+
if (value instanceof Date) return null;
157+
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null;
158+
return { field: name, code: 'invalid_date', message: `${name} must be a valid ${t} (ISO-8601)` };
159+
}
160+
161+
// ── select / multiselect / radio ────────────────────────────────
162+
if (t === 'select' || t === 'radio') {
163+
const allowed = optionValues(def.options);
164+
if (allowed.length > 0 && !allowed.includes(String(value))) {
165+
return { field: name, code: 'invalid_option', message: `${name} must be one of: ${allowed.join(', ')}`, options: allowed };
166+
}
167+
return null;
168+
}
169+
if (t === 'multiselect' || t === 'checkboxes' || t === 'tags') {
170+
const allowed = optionValues(def.options);
171+
if (allowed.length === 0) return null;
172+
const arr = Array.isArray(value) ? value : [value];
173+
for (const v of arr) {
174+
if (!allowed.includes(String(v))) {
175+
return { field: name, code: 'invalid_option', message: `${name}: "${v}" is not one of: ${allowed.join(', ')}`, options: allowed };
176+
}
177+
}
178+
return null;
179+
}
180+
181+
// Other types (lookup, file, formula, json, location, etc.) — no
182+
// strict shape check at this layer; reference integrity is handled
183+
// elsewhere (lookup) and the rest are opaque payloads.
184+
return null;
185+
}
186+
187+
/**
188+
* Validate a payload against a list of declared fields. `objectSchema`
189+
* comes from `ObjectQL.getRegistry().getObject(name)` and exposes a
190+
* `fields` map of `{ [fieldName]: FieldDef }`.
191+
*
192+
* Returns void on success; throws `ValidationError` on failure.
193+
*/
194+
export function validateRecord(
195+
objectSchema: { fields?: Record<string, FieldDef> } | undefined | null,
196+
data: Record<string, unknown> | undefined | null,
197+
mode: Mode,
198+
): void {
199+
if (!objectSchema?.fields || !data) return;
200+
201+
const errors: FieldValidationError[] = [];
202+
const fields = objectSchema.fields;
203+
204+
if (mode === 'insert') {
205+
// Walk all declared fields — required check applies even when
206+
// the caller didn't supply the field at all.
207+
for (const [name, def] of Object.entries(fields)) {
208+
if (SKIP_FIELDS.has(name)) continue;
209+
if (def.system || def.readonly) continue;
210+
const err = validateOne(name, def, data[name]);
211+
if (err) errors.push(err);
212+
}
213+
} else {
214+
// Update — validate only supplied fields, skip required check.
215+
for (const [name, value] of Object.entries(data)) {
216+
if (SKIP_FIELDS.has(name)) continue;
217+
const def = fields[name];
218+
if (!def) continue;
219+
if (def.system || def.readonly) continue;
220+
// Clone def with required=false so PATCH-omitted-fields don't 400.
221+
const err = validateOne(name, { ...def, required: false }, value);
222+
if (err) errors.push(err);
223+
}
224+
}
225+
226+
if (errors.length > 0) throw new ValidationError(errors);
227+
}

packages/rest/src/rest-server.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ const logError = (...args: unknown[]) => (globalThis as any).console?.error(...a
2323
* returns a misleading 404.
2424
*/
2525
function mapDataError(error: any, object?: string): { status: number; body: Record<string, unknown> } {
26+
// Validation failures → 400 with per-field envelope. Handled FIRST
27+
// because the validator throws a typed error before any SQL ever
28+
// runs, and we want callers to differentiate "your payload was
29+
// invalid" (fixable client-side) from generic 400s.
30+
if (error?.code === 'VALIDATION_FAILED' || error?.name === 'ValidationError') {
31+
return {
32+
status: 400,
33+
body: {
34+
error: error?.message ?? 'Validation failed',
35+
code: 'VALIDATION_FAILED',
36+
fields: Array.isArray(error?.fields) ? error.fields : [],
37+
...(object ? { object } : {}),
38+
},
39+
};
40+
}
2641
// Short-circuit: explicit security denial → 403. Match by `code` /
2742
// `name` to avoid pulling a runtime dependency on plugin-security.
2843
if (
@@ -84,6 +99,37 @@ function mapDataError(error: any, object?: string): { status: number; body: Reco
8499
},
85100
};
86101
}
102+
// Default: do NOT leak raw SQL or driver internals. If the message
103+
// looks like a SQL/driver dump, replace it with a generic envelope
104+
// and rely on server logs for the full diagnostic.
105+
const looksLikeSqlLeak =
106+
lower.includes('sqlite_') ||
107+
lower.includes('sqlstate') ||
108+
lower.startsWith('insert into ') ||
109+
lower.startsWith('update ') ||
110+
lower.startsWith('select ') ||
111+
lower.startsWith('delete from ') ||
112+
lower.includes('constraint failed') ||
113+
lower.includes('unique constraint') ||
114+
lower.includes('foreign key');
115+
if (looksLikeSqlLeak) {
116+
// Surface unique-constraint violations as a structured 409 so
117+
// the UI can map them to "this value already exists".
118+
if (lower.includes('unique constraint') || lower.includes('unique violation')) {
119+
return {
120+
status: 409,
121+
body: {
122+
error: 'A record with this value already exists',
123+
code: 'UNIQUE_VIOLATION',
124+
...(object ? { object } : {}),
125+
},
126+
};
127+
}
128+
return {
129+
status: 500,
130+
body: { error: 'Internal data error', code: 'DATABASE_ERROR' },
131+
};
132+
}
87133
return { status: 400, body: { error: raw || 'Bad request' } };
88134
}
89135

@@ -97,7 +143,7 @@ function mapDataError(error: any, object?: string): { status: number; body: Reco
97143
* sys_project.metadata.provisioningError if needed.
98144
*/
99145
function isExpectedDataStatus(status: number): boolean {
100-
return status === 403 || status === 404 || status === 502 || status === 503;
146+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
101147
}
102148

103149
/**
@@ -1241,7 +1287,7 @@ export class RestServer {
12411287
res.json(result);
12421288
} catch (error: any) {
12431289
const mapped = mapDataError(error, req.params?.object);
1244-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1290+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
12451291
res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
12461292
}
12471293
},
@@ -1272,7 +1318,7 @@ export class RestServer {
12721318
res.status(201).json(result);
12731319
} catch (error: any) {
12741320
const mapped = mapDataError(error, req.params?.object);
1275-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1321+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
12761322
res.status(mapped.status).json(mapped.body);
12771323
}
12781324
},
@@ -1304,7 +1350,7 @@ export class RestServer {
13041350
res.json(result);
13051351
} catch (error: any) {
13061352
const mapped = mapDataError(error, req.params?.object);
1307-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1353+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
13081354
res.status(mapped.status).json(mapped.body);
13091355
}
13101356
},
@@ -1335,7 +1381,7 @@ export class RestServer {
13351381
res.json(result);
13361382
} catch (error: any) {
13371383
const mapped = mapDataError(error, req.params?.object);
1338-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1384+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
13391385
res.status(mapped.status).json(mapped.body);
13401386
}
13411387
},

0 commit comments

Comments
 (0)