diff --git a/.changeset/auth-validationerror-4xx-mapping.md b/.changeset/auth-validationerror-4xx-mapping.md new file mode 100644 index 000000000..11b220a6b --- /dev/null +++ b/.changeset/auth-validationerror-4xx-mapping.md @@ -0,0 +1,18 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): map ObjectQL `ValidationError` to a 4xx on the better-auth paths (#3398) + +A field-level validation failure raised by the ObjectQL record-validator +(e.g. an invalid `image` on `POST /api/v1/auth/update-user`) surfaced to the +HTTP client as a **raw 500 with an empty body**. better-auth only maps its own +`APIError`s to structured responses; any other error thrown from an adapter +method propagates to better-call's router as an unhandled fault → `500 {}`. + +Added the auth-path analogue of the REST layer's `mapDataError`: the objectql +adapter now detects the ObjectQL validation envelope at its boundary (duck-typed +by `code` / `name`, so plugin-auth keeps no hard dependency on +`@objectstack/objectql` and cross-realm `instanceof` can't bite) and re-throws +it as `APIError('BAD_REQUEST', …)`. `update-user` and friends now answer with a +`400 { code: 'VALIDATION_FAILED', message, fields }` instead of an opaque 500. diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index 8f43f83b7..b97701ec9 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -5,9 +5,11 @@ import { createObjectQLAdapter, createObjectQLAdapterFactory, withSystemContext, + withValidationErrorMapping, AUTH_MODEL_TO_PROTOCOL, resolveProtocolName, } from './objectql-adapter'; +import { isAPIError } from 'better-auth/api'; import { AUTH_USER_CONFIG, AUTH_SESSION_CONFIG, @@ -382,3 +384,103 @@ describe('createObjectQLAdapter – reads bypass control-plane org-scope (regres expect(mockEngine.find).toHaveBeenCalledWith('sys_account', expect.objectContaining({ context: { isSystem: true } })); }); }); + +describe('withValidationErrorMapping – ObjectQL ValidationError → better-auth APIError', () => { + // Faithful mimic of ObjectQL's record-validator ValidationError + // (packages/objectql/src/validation/record-validator.ts): plugin-auth does + // not depend on @objectstack/objectql, and the mapping is duck-typed by + // `code` / `name`, so a same-shape stand-in exercises the exact code path. + class FakeValidationError extends Error { + readonly code = 'VALIDATION_FAILED'; + readonly fields: Array>; + constructor(fields: Array>) { + super(fields.map((f) => f.message).join('; ')); + this.name = 'ValidationError'; + this.fields = fields; + } + } + + const IMAGE_ERR = () => + new FakeValidationError([ + { field: 'image', code: 'invalid_url', message: 'image must be a valid URL (scheme://...)' }, + ]); + + it('maps a thrown ValidationError to a 400 APIError carrying message + fields', async () => { + const adapter = withValidationErrorMapping({ + update: async () => { + throw IMAGE_ERR(); + }, + }); + + let caught: any; + try { + await adapter.update(); + } catch (e) { + caught = e; + } + + expect(caught).toBeDefined(); + expect(isAPIError(caught)).toBe(true); + // better-call maps the 'BAD_REQUEST' status string to HTTP 400. + expect(caught.statusCode).toBe(400); + expect(caught.body).toMatchObject({ + code: 'VALIDATION_FAILED', + message: 'image must be a valid URL (scheme://...)', + }); + expect(caught.body.fields).toEqual([ + { field: 'image', code: 'invalid_url', message: 'image must be a valid URL (scheme://...)' }, + ]); + }); + + it('re-throws non-validation errors verbatim (not remapped to an APIError)', async () => { + const boom = new Error('driver exploded'); + const adapter = withValidationErrorMapping({ + update: async () => { + throw boom; + }, + }); + + await expect(adapter.update()).rejects.toBe(boom); + }); + + it('passes successful results through untouched and leaves non-function props alone', async () => { + const adapter = withValidationErrorMapping({ + create: async (x: number) => x + 1, + options: { adapterId: 'objectql' }, + }); + + await expect(adapter.create(41)).resolves.toBe(42); + expect(adapter.options).toEqual({ adapterId: 'objectql' }); + }); + + it('factory adapter surfaces engine ValidationError on update as a 400 APIError', async () => { + // End-to-end through the production factory: a real ObjectQL engine that + // rejects an invalid `image` on update must reach better-auth as a 400, + // not a raw 500 (the update-user regression). + const engine = { + insert: vi.fn(), + findOne: vi.fn().mockResolvedValue({ id: 'u1' }), + find: vi.fn(), + count: vi.fn(), + update: vi.fn().mockRejectedValue(IMAGE_ERR()), + delete: vi.fn(), + } as unknown as IDataEngine; + + const adapter: any = (createObjectQLAdapterFactory(engine) as any)({} as any); + + let caught: any; + try { + await adapter.update({ + model: 'user', + where: [{ field: 'id', value: 'u1', operator: 'eq', connector: 'AND' }], + update: { image: 'notaurl' }, + }); + } catch (e) { + caught = e; + } + + expect(isAPIError(caught)).toBe(true); + expect(caught.statusCode).toBe(400); + expect(caught.body).toMatchObject({ code: 'VALIDATION_FAILED' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index 24d8d2a57..325a74b8c 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -140,6 +140,80 @@ function convertWhere(where: CleanedWhere[]): Record { return filter; } +// --------------------------------------------------------------------------- +// ObjectQL → better-auth error mapping +// --------------------------------------------------------------------------- + +/** + * ObjectQL's record-validator (packages/objectql/src/validation/record-validator.ts) + * throws a `ValidationError` — `code: 'VALIDATION_FAILED'`, a human `.message`, + * and per-field `.fields[]` — when an incoming insert/update payload fails + * field-level validation (e.g. a non-URL `image` on `POST /api/v1/auth/update-user`). + * + * better-auth only maps its OWN `APIError`s to clean HTTP responses; any other + * error thrown from an adapter method propagates to better-call's router as an + * unhandled fault → a raw **500 with an empty body**, so the client never learns + * why the write was rejected. + * + * This is the auth-path analogue of the REST data layer's `mapDataError` + * (packages/rest/src/rest-server.ts): we detect the ObjectQL validation envelope + * (by `code` / `name`, so plugin-auth needs no hard dependency on + * `@objectstack/objectql` and cross-realm `instanceof` can't bite) and re-throw + * it as a better-auth `APIError('BAD_REQUEST', …)`, giving the endpoint a 400 + * that carries the validation message plus per-field detail. + */ +function isObjectQLValidationError( + err: unknown, +): err is { code?: string; name?: string; message?: string; fields?: unknown } { + if (!err || typeof err !== 'object') return false; + const e = err as { code?: unknown; name?: unknown }; + return e.code === 'VALIDATION_FAILED' || e.name === 'ValidationError'; +} + +/** + * Re-throw `err` as a better-auth `APIError` when it is an ObjectQL validation + * failure; otherwise re-throw it verbatim. Always throws — the return type is + * `never`. + */ +async function rethrowAsBetterAuthError(err: unknown): Promise { + if (isObjectQLValidationError(err)) { + const { APIError } = await import('better-auth/api'); + const fields = (err as { fields?: unknown }).fields; + throw new APIError('BAD_REQUEST', { + message: + typeof err.message === 'string' && err.message.trim() + ? err.message + : 'Validation failed', + code: 'VALIDATION_FAILED', + ...(Array.isArray(fields) ? { fields } : {}), + }); + } + throw err; +} + +/** + * Wrap every function-valued method of a better-auth adapter so an ObjectQL + * `ValidationError` thrown from the underlying engine surfaces as a 4xx + * `APIError` instead of an opaque 500. Non-function properties pass through + * untouched, and every non-validation error is re-thrown verbatim. + */ +export function withValidationErrorMapping>(adapter: A): A { + const out: Record = {}; + for (const [key, value] of Object.entries(adapter)) { + out[key] = + typeof value === 'function' + ? async (...args: any[]) => { + try { + return await value(...args); + } catch (err) { + await rethrowAsBetterAuthError(err); // always throws + } + } + : value; + } + return out as A; +} + // --------------------------------------------------------------------------- // Adapter factory // --------------------------------------------------------------------------- @@ -233,7 +307,7 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) { supportsDates: false, supportsJSON: true, }, - adapter: () => ({ + adapter: () => withValidationErrorMapping({ create: async >( { model, data, select: _select }: { model: string; data: T; select?: string[] }, ): Promise => {