Skip to content

Commit d0fea33

Browse files
baozhoutaoclaude
andauthored
fix(auth): map ObjectQL ValidationError to 4xx on better-auth paths (#3398) (#3399)
* fix(auth): map ObjectQL ValidationError to 4xx on better-auth paths (#3398) better-auth only maps its own APIErrors to structured HTTP responses; a ValidationError thrown from the objectql-adapter (e.g. an invalid `image` on update-user) propagated to better-call's router as an unhandled fault and surfaced as a raw 500 with an empty body. Add the auth-path analogue of the REST layer's mapDataError: detect the ObjectQL validation envelope at the adapter boundary (duck-typed by code/name, so no hard dependency on @objectstack/objectql and no cross-realm instanceof) and re-throw it as APIError('BAD_REQUEST', …) so update-user & friends answer with a 400 carrying the human message plus per-field detail. Applied via withValidationErrorMapping over the production adapter factory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(changeset): auth ValidationError 4xx mapping (#3398) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 76f18c2 commit d0fea33

3 files changed

Lines changed: 195 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): map ObjectQL `ValidationError` to a 4xx on the better-auth paths (#3398)
6+
7+
A field-level validation failure raised by the ObjectQL record-validator
8+
(e.g. an invalid `image` on `POST /api/v1/auth/update-user`) surfaced to the
9+
HTTP client as a **raw 500 with an empty body**. better-auth only maps its own
10+
`APIError`s to structured responses; any other error thrown from an adapter
11+
method propagates to better-call's router as an unhandled fault → `500 {}`.
12+
13+
Added the auth-path analogue of the REST layer's `mapDataError`: the objectql
14+
adapter now detects the ObjectQL validation envelope at its boundary (duck-typed
15+
by `code` / `name`, so plugin-auth keeps no hard dependency on
16+
`@objectstack/objectql` and cross-realm `instanceof` can't bite) and re-throws
17+
it as `APIError('BAD_REQUEST', …)`. `update-user` and friends now answer with a
18+
`400 { code: 'VALIDATION_FAILED', message, fields }` instead of an opaque 500.

packages/plugins/plugin-auth/src/objectql-adapter.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import {
55
createObjectQLAdapter,
66
createObjectQLAdapterFactory,
77
withSystemContext,
8+
withValidationErrorMapping,
89
AUTH_MODEL_TO_PROTOCOL,
910
resolveProtocolName,
1011
} from './objectql-adapter';
12+
import { isAPIError } from 'better-auth/api';
1113
import {
1214
AUTH_USER_CONFIG,
1315
AUTH_SESSION_CONFIG,
@@ -382,3 +384,103 @@ describe('createObjectQLAdapter – reads bypass control-plane org-scope (regres
382384
expect(mockEngine.find).toHaveBeenCalledWith('sys_account', expect.objectContaining({ context: { isSystem: true } }));
383385
});
384386
});
387+
388+
describe('withValidationErrorMapping – ObjectQL ValidationError → better-auth APIError', () => {
389+
// Faithful mimic of ObjectQL's record-validator ValidationError
390+
// (packages/objectql/src/validation/record-validator.ts): plugin-auth does
391+
// not depend on @objectstack/objectql, and the mapping is duck-typed by
392+
// `code` / `name`, so a same-shape stand-in exercises the exact code path.
393+
class FakeValidationError extends Error {
394+
readonly code = 'VALIDATION_FAILED';
395+
readonly fields: Array<Record<string, unknown>>;
396+
constructor(fields: Array<Record<string, unknown>>) {
397+
super(fields.map((f) => f.message).join('; '));
398+
this.name = 'ValidationError';
399+
this.fields = fields;
400+
}
401+
}
402+
403+
const IMAGE_ERR = () =>
404+
new FakeValidationError([
405+
{ field: 'image', code: 'invalid_url', message: 'image must be a valid URL (scheme://...)' },
406+
]);
407+
408+
it('maps a thrown ValidationError to a 400 APIError carrying message + fields', async () => {
409+
const adapter = withValidationErrorMapping({
410+
update: async () => {
411+
throw IMAGE_ERR();
412+
},
413+
});
414+
415+
let caught: any;
416+
try {
417+
await adapter.update();
418+
} catch (e) {
419+
caught = e;
420+
}
421+
422+
expect(caught).toBeDefined();
423+
expect(isAPIError(caught)).toBe(true);
424+
// better-call maps the 'BAD_REQUEST' status string to HTTP 400.
425+
expect(caught.statusCode).toBe(400);
426+
expect(caught.body).toMatchObject({
427+
code: 'VALIDATION_FAILED',
428+
message: 'image must be a valid URL (scheme://...)',
429+
});
430+
expect(caught.body.fields).toEqual([
431+
{ field: 'image', code: 'invalid_url', message: 'image must be a valid URL (scheme://...)' },
432+
]);
433+
});
434+
435+
it('re-throws non-validation errors verbatim (not remapped to an APIError)', async () => {
436+
const boom = new Error('driver exploded');
437+
const adapter = withValidationErrorMapping({
438+
update: async () => {
439+
throw boom;
440+
},
441+
});
442+
443+
await expect(adapter.update()).rejects.toBe(boom);
444+
});
445+
446+
it('passes successful results through untouched and leaves non-function props alone', async () => {
447+
const adapter = withValidationErrorMapping({
448+
create: async (x: number) => x + 1,
449+
options: { adapterId: 'objectql' },
450+
});
451+
452+
await expect(adapter.create(41)).resolves.toBe(42);
453+
expect(adapter.options).toEqual({ adapterId: 'objectql' });
454+
});
455+
456+
it('factory adapter surfaces engine ValidationError on update as a 400 APIError', async () => {
457+
// End-to-end through the production factory: a real ObjectQL engine that
458+
// rejects an invalid `image` on update must reach better-auth as a 400,
459+
// not a raw 500 (the update-user regression).
460+
const engine = {
461+
insert: vi.fn(),
462+
findOne: vi.fn().mockResolvedValue({ id: 'u1' }),
463+
find: vi.fn(),
464+
count: vi.fn(),
465+
update: vi.fn().mockRejectedValue(IMAGE_ERR()),
466+
delete: vi.fn(),
467+
} as unknown as IDataEngine;
468+
469+
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any);
470+
471+
let caught: any;
472+
try {
473+
await adapter.update({
474+
model: 'user',
475+
where: [{ field: 'id', value: 'u1', operator: 'eq', connector: 'AND' }],
476+
update: { image: 'notaurl' },
477+
});
478+
} catch (e) {
479+
caught = e;
480+
}
481+
482+
expect(isAPIError(caught)).toBe(true);
483+
expect(caught.statusCode).toBe(400);
484+
expect(caught.body).toMatchObject({ code: 'VALIDATION_FAILED' });
485+
});
486+
});

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,80 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
140140
return filter;
141141
}
142142

143+
// ---------------------------------------------------------------------------
144+
// ObjectQL → better-auth error mapping
145+
// ---------------------------------------------------------------------------
146+
147+
/**
148+
* ObjectQL's record-validator (packages/objectql/src/validation/record-validator.ts)
149+
* throws a `ValidationError` — `code: 'VALIDATION_FAILED'`, a human `.message`,
150+
* and per-field `.fields[]` — when an incoming insert/update payload fails
151+
* field-level validation (e.g. a non-URL `image` on `POST /api/v1/auth/update-user`).
152+
*
153+
* better-auth only maps its OWN `APIError`s to clean HTTP responses; any other
154+
* error thrown from an adapter method propagates to better-call's router as an
155+
* unhandled fault → a raw **500 with an empty body**, so the client never learns
156+
* why the write was rejected.
157+
*
158+
* This is the auth-path analogue of the REST data layer's `mapDataError`
159+
* (packages/rest/src/rest-server.ts): we detect the ObjectQL validation envelope
160+
* (by `code` / `name`, so plugin-auth needs no hard dependency on
161+
* `@objectstack/objectql` and cross-realm `instanceof` can't bite) and re-throw
162+
* it as a better-auth `APIError('BAD_REQUEST', …)`, giving the endpoint a 400
163+
* that carries the validation message plus per-field detail.
164+
*/
165+
function isObjectQLValidationError(
166+
err: unknown,
167+
): err is { code?: string; name?: string; message?: string; fields?: unknown } {
168+
if (!err || typeof err !== 'object') return false;
169+
const e = err as { code?: unknown; name?: unknown };
170+
return e.code === 'VALIDATION_FAILED' || e.name === 'ValidationError';
171+
}
172+
173+
/**
174+
* Re-throw `err` as a better-auth `APIError` when it is an ObjectQL validation
175+
* failure; otherwise re-throw it verbatim. Always throws — the return type is
176+
* `never`.
177+
*/
178+
async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
179+
if (isObjectQLValidationError(err)) {
180+
const { APIError } = await import('better-auth/api');
181+
const fields = (err as { fields?: unknown }).fields;
182+
throw new APIError('BAD_REQUEST', {
183+
message:
184+
typeof err.message === 'string' && err.message.trim()
185+
? err.message
186+
: 'Validation failed',
187+
code: 'VALIDATION_FAILED',
188+
...(Array.isArray(fields) ? { fields } : {}),
189+
});
190+
}
191+
throw err;
192+
}
193+
194+
/**
195+
* Wrap every function-valued method of a better-auth adapter so an ObjectQL
196+
* `ValidationError` thrown from the underlying engine surfaces as a 4xx
197+
* `APIError` instead of an opaque 500. Non-function properties pass through
198+
* untouched, and every non-validation error is re-thrown verbatim.
199+
*/
200+
export function withValidationErrorMapping<A extends Record<string, any>>(adapter: A): A {
201+
const out: Record<string, any> = {};
202+
for (const [key, value] of Object.entries(adapter)) {
203+
out[key] =
204+
typeof value === 'function'
205+
? async (...args: any[]) => {
206+
try {
207+
return await value(...args);
208+
} catch (err) {
209+
await rethrowAsBetterAuthError(err); // always throws
210+
}
211+
}
212+
: value;
213+
}
214+
return out as A;
215+
}
216+
143217
// ---------------------------------------------------------------------------
144218
// Adapter factory
145219
// ---------------------------------------------------------------------------
@@ -233,7 +307,7 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
233307
supportsDates: false,
234308
supportsJSON: true,
235309
},
236-
adapter: () => ({
310+
adapter: () => withValidationErrorMapping({
237311
create: async <T extends Record<string, any>>(
238312
{ model, data, select: _select }: { model: string; data: T; select?: string[] },
239313
): Promise<T> => {

0 commit comments

Comments
 (0)