Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/auth-validationerror-4xx-mapping.md
Original file line number Diff line number Diff line change
@@ -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.
102 changes: 102 additions & 0 deletions packages/plugins/plugin-auth/src/objectql-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, unknown>>;
constructor(fields: Array<Record<string, unknown>>) {
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' });
});
});
76 changes: 75 additions & 1 deletion packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,80 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
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<never> {
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<A extends Record<string, any>>(adapter: A): A {
const out: Record<string, any> = {};
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -233,7 +307,7 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
supportsDates: false,
supportsJSON: true,
},
adapter: () => ({
adapter: () => withValidationErrorMapping({
create: async <T extends Record<string, any>>(
{ model, data, select: _select }: { model: string; data: T; select?: string[] },
): Promise<T> => {
Expand Down