-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathrecord-validator.ts
More file actions
268 lines (246 loc) · 11.7 KB
/
Copy pathrecord-validator.ts
File metadata and controls
268 lines (246 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Record Validator
*
* Validates an incoming insert/update payload against the canonical
* `Field` metadata of an `ObjectSchema`. Implements ROADMAP §M10.4 —
* "Zod-at-rest" — but does not require constructing a Zod schema:
* we walk the field map directly, which is both faster and lets us
* produce per-field error envelopes shaped for REST consumption.
*
* Rules applied (in order, stop at first error per field):
*
* - `required` missing/null/empty-string is rejected (insert only;
* PATCH validates only fields actually supplied)
* - `maxLength` / `minLength` (text/textarea/email/url/phone/password)
* - `min` / `max` (number/currency/percent/rating/slider)
* - format email / url / phone (lightweight RFC-aware regex)
* - select / multiselect: value must appear in `options`
* - boolean / toggle: must coerce to boolean
* - date / datetime: must be ISO-parsable
*
* System-injected fields (`id`, `created_at`, `created_by`,
* `updated_at`, `updated_by`, and provenance-flagged `system`/`readonly`
* columns such as an injected `organization_id`) are never validated
* here — the engine and the audit plugin manage them.
*
* On failure, a `ValidationError` is thrown with `.fields[]` holding
* one entry per offending field. REST translates this into a
* `400 { code: 'VALIDATION_FAILED', message, fields }` envelope so
* the UI can highlight the specific input.
*/
// Lifecycle columns the engine always owns and the client never supplies. These
// are skipped by NAME because they are not author-declared business fields.
// NOTE: `organization_id` / `tenant_id` are intentionally NOT here (#1592) — the
// engine-injected tenant column is marked `system: true` and skipped via
// provenance below, while a genuinely DECLARED required `organization_id`
// business field (e.g. `sys_team`, a `managedBy: 'better-auth'` table where the
// column is not injected) must get a normal required-check instead of silently
// passing NULL through to the driver.
const SKIP_FIELDS = new Set<string>([
'id', 'created_at', 'created_by', 'updated_at', 'updated_by',
]);
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Permissive URL pattern: accept any scheme:// + non-empty body so that
// non-HTTP URIs used by drivers (libsql://, postgres://, mysql://, file://, s3://, …)
// pass field-level validation. Stricter per-field checks can be enforced
// via custom validators where needed.
const URL_RE = /^[a-z][a-z0-9+.\-]*:\/\/[^\s]+$/i;
const PHONE_RE = /^[+()\-\s\d.]{5,}$/;
export interface FieldValidationError {
field: string;
code:
| 'required'
| 'min_length'
| 'max_length'
| 'min_value'
| 'max_value'
| 'invalid_email'
| 'invalid_url'
| 'invalid_phone'
| 'invalid_number'
| 'invalid_boolean'
| 'invalid_date'
| 'invalid_time'
| 'invalid_option'
// Object-level validation rules (ADR-0020, see rule-validator.ts)
| 'invalid_transition'
| 'rule_violation'
| 'invalid_format'
| 'invalid_json'
| 'json_schema_violation';
message: string;
/** Allowed values for select/multiselect, when applicable. */
options?: string[];
}
export class ValidationError extends Error {
readonly code = 'VALIDATION_FAILED';
readonly fields: FieldValidationError[];
constructor(fields: FieldValidationError[]) {
super(
`Validation failed for ${fields.length} field(s): ` +
fields.map((f) => `${f.field} (${f.code})`).join(', '),
);
this.name = 'ValidationError';
this.fields = fields;
}
}
type Mode = 'insert' | 'update';
interface FieldDef {
name?: string;
type: string;
required?: boolean;
readonly?: boolean;
system?: boolean;
multiple?: boolean;
maxLength?: number;
minLength?: number;
min?: number;
max?: number;
options?: Array<{ value: string | number; label?: string } | string | number>;
}
function isMissing(v: unknown): boolean {
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
}
function optionValues(options: FieldDef['options']): string[] {
if (!Array.isArray(options)) return [];
return options.map((o) =>
typeof o === 'object' && o !== null ? String((o as any).value) : String(o),
);
}
function validateOne(name: string, def: FieldDef, value: unknown): FieldValidationError | null {
// ── required ────────────────────────────────────────────────────
// `autonumber` is runtime-owned: the value is generated by the engine /
// driver (the SQL driver assigns it from a persistent sequence AFTER this
// validation runs), so a missing value is never a client error — see #1603.
if (def.required && isMissing(value) && def.type !== 'autonumber') {
return { field: name, code: 'required', message: `${name} is required` };
}
if (isMissing(value)) return null; // nothing else to check
const t = def.type;
// ── string types ────────────────────────────────────────────────
if (t === 'text' || t === 'textarea' || t === 'email' || t === 'url' || t === 'phone' || t === 'password' || t === 'markdown' || t === 'html' || t === 'richtext' || t === 'code') {
const s = typeof value === 'string' ? value : String(value);
if (def.maxLength !== undefined && s.length > def.maxLength) {
return { field: name, code: 'max_length', message: `${name} must be ≤ ${def.maxLength} characters (got ${s.length})` };
}
if (def.minLength !== undefined && s.length < def.minLength) {
return { field: name, code: 'min_length', message: `${name} must be ≥ ${def.minLength} characters (got ${s.length})` };
}
if (t === 'email' && !EMAIL_RE.test(s)) {
return { field: name, code: 'invalid_email', message: `${name} must be a valid email address` };
}
if (t === 'url' && !URL_RE.test(s)) {
return { field: name, code: 'invalid_url', message: `${name} must be a valid URL (scheme://...)` };
}
if (t === 'phone' && !PHONE_RE.test(s)) {
return { field: name, code: 'invalid_phone', message: `${name} must be a valid phone number` };
}
return null;
}
// ── number types ────────────────────────────────────────────────
if (t === 'number' || t === 'currency' || t === 'percent' || t === 'rating' || t === 'slider') {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) {
return { field: name, code: 'invalid_number', message: `${name} must be a number` };
}
if (def.min !== undefined && n < def.min) {
return { field: name, code: 'min_value', message: `${name} must be ≥ ${def.min}` };
}
if (def.max !== undefined && n > def.max) {
return { field: name, code: 'max_value', message: `${name} must be ≤ ${def.max}` };
}
return null;
}
// ── boolean ────────────────────────────────────────────────────
if (t === 'boolean' || t === 'toggle') {
if (typeof value === 'boolean') return null;
if (value === 0 || value === 1 || value === '0' || value === '1' || value === 'true' || value === 'false') return null;
return { field: name, code: 'invalid_boolean', message: `${name} must be true or false` };
}
// ── date/datetime ───────────────────────────────────────────────
if (t === 'date' || t === 'datetime') {
if (value instanceof Date) return null;
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return null;
return { field: name, code: 'invalid_date', message: `${name} must be a valid ${t} (ISO-8601)` };
}
// ── time (time-of-day) ──────────────────────────────────────────
// A `Field.time` is a wall-clock time, NOT an instant — `Date.parse('14:30')`
// is NaN, so reusing the date branch rejected every valid time. Accept
// `HH:MM`, `HH:MM:SS`, optional fractional seconds and an optional Z/offset;
// also accept a Date or a full ISO datetime (callers that send a timestamp
// for a time field).
if (t === 'time') {
if (value instanceof Date) return null;
if (typeof value === 'string') {
const timeOfDay = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d+)?)?(Z|[+-]([01]\d|2[0-3]):?[0-5]\d)?$/;
// Accept a valid time-of-day, OR a full datetime that carries a real date
// component. NOT a bare `Date.parse` check — `Date.parse('14:60')` returns
// a (bogus) number in Node, which would let malformed times through.
const hasDate = /\d{4}-\d{2}-\d{2}/.test(value);
if (timeOfDay.test(value.trim()) || (hasDate && !Number.isNaN(Date.parse(value)))) return null;
}
return { field: name, code: 'invalid_time', message: `${name} must be a valid time (HH:MM or HH:MM:SS)` };
}
// ── select / multiselect / radio ────────────────────────────────
if (t === 'select' || t === 'radio') {
const allowed = optionValues(def.options);
if (allowed.length > 0 && !allowed.includes(String(value))) {
return { field: name, code: 'invalid_option', message: `${name} must be one of: ${allowed.join(', ')}`, options: allowed };
}
return null;
}
if (t === 'multiselect' || t === 'checkboxes' || t === 'tags') {
const allowed = optionValues(def.options);
if (allowed.length === 0) return null;
const arr = Array.isArray(value) ? value : [value];
for (const v of arr) {
if (!allowed.includes(String(v))) {
return { field: name, code: 'invalid_option', message: `${name}: "${v}" is not one of: ${allowed.join(', ')}`, options: allowed };
}
}
return null;
}
// Other types (lookup, file, formula, json, location, etc.) — no
// strict shape check at this layer; reference integrity is handled
// elsewhere (lookup) and the rest are opaque payloads.
return null;
}
/**
* Validate a payload against a list of declared fields. `objectSchema`
* comes from `ObjectQL.getRegistry().getObject(name)` and exposes a
* `fields` map of `{ [fieldName]: FieldDef }`.
*
* Returns void on success; throws `ValidationError` on failure.
*/
export function validateRecord(
objectSchema: { fields?: Record<string, FieldDef> } | undefined | null,
data: Record<string, unknown> | undefined | null,
mode: Mode,
): void {
if (!objectSchema?.fields || !data) return;
const errors: FieldValidationError[] = [];
const fields = objectSchema.fields;
if (mode === 'insert') {
// Walk all declared fields — required check applies even when
// the caller didn't supply the field at all.
for (const [name, def] of Object.entries(fields)) {
if (SKIP_FIELDS.has(name)) continue;
if (def.system || def.readonly) continue;
const err = validateOne(name, def, data[name]);
if (err) errors.push(err);
}
} else {
// Update — validate only supplied fields, skip required check.
for (const [name, value] of Object.entries(data)) {
if (SKIP_FIELDS.has(name)) continue;
const def = fields[name];
if (!def) continue;
if (def.system || def.readonly) continue;
// Clone def with required=false so PATCH-omitted-fields don't 400.
const err = validateOne(name, { ...def, required: false }, value);
if (err) errors.push(err);
}
}
if (errors.length > 0) throw new ValidationError(errors);
}