Skip to content

Commit 91041d8

Browse files
os-zhuangclaude
andcommitted
feat(auth): bridge schema-less plugin models in the factory adapter (sso)
Make createObjectQLAdapterFactory resolve the table name (resolveProtocolName) AND map camelCase<->snake fields for models remapped via AUTH_MODEL_TO_PROTOCOL — i.e. better-auth plugins like @better-auth/sso that expose no `schema` option. Scoped by `objectName !== model`, so core / schema-declared models are byte-for-byte unchanged. This makes the @better-auth/sso integration (sys_sso_provider) functional at the data layer. Verified: 29 unit tests pass — 3 new sso-bridging tests drive the REAL better-auth adapter wrapper (sso plugin loaded): insert maps ssoProvider + oidcConfig -> sys_sso_provider + oidc_config, reads map back to camelCase, where fields are snaked; the 26 existing core-path tests are unchanged. Remaining: full browser E2E (login via a live external OIDC IdP -> JIT user + sys_account.provider_id + default role). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cf381a7 commit 91041d8

2 files changed

Lines changed: 111 additions & 26 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from './auth-schema-config';
2525
import { SystemObjectName } from '@objectstack/spec/system';
2626
import type { IDataEngine } from '@objectstack/core';
27+
import { sso } from '@better-auth/sso';
2728

2829
describe('AUTH_MODEL_TO_PROTOCOL mapping', () => {
2930
it('should map all four core better-auth models to sys_ protocol names', () => {
@@ -279,3 +280,46 @@ describe('createObjectQLAdapter – legacy model name mapping', () => {
279280
expect(mockEngine.insert).toHaveBeenCalledWith('organization', { name: 'Acme' });
280281
});
281282
});
283+
284+
describe('createObjectQLAdapterFactory – schema-less plugin bridging (@better-auth/sso)', () => {
285+
// The sso plugin exposes no `schema` option, so its `ssoProvider` table +
286+
// camelCase fields are bridged at the adapter layer. Pass the plugin so
287+
// better-auth's wrapper recognises the model (it validates against the
288+
// merged schema before delegating to our adapter methods).
289+
const makeAdapter = (findOneRow: any = { id: '1', provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' }) => {
290+
const engine = {
291+
insert: vi.fn().mockImplementation((_m: string, d: any) => Promise.resolve({ id: '1', ...d })),
292+
findOne: vi.fn().mockResolvedValue(findOneRow),
293+
find: vi.fn().mockResolvedValue([]),
294+
count: vi.fn().mockResolvedValue(0),
295+
update: vi.fn().mockResolvedValue({ id: '1' }),
296+
delete: vi.fn().mockResolvedValue(undefined),
297+
} as unknown as IDataEngine;
298+
const adapter: any = (createObjectQLAdapterFactory(engine) as any)({ plugins: [sso()] } as any);
299+
return { engine, adapter };
300+
};
301+
302+
it('resolveProtocolName bridges ssoProvider -> sys_sso_provider', () => {
303+
expect(resolveProtocolName('ssoProvider')).toBe('sys_sso_provider');
304+
expect(AUTH_MODEL_TO_PROTOCOL.ssoProvider).toBe('sys_sso_provider');
305+
});
306+
307+
it('maps the ssoProvider model + camelCase fields to sys_sso_provider snake columns on insert', async () => {
308+
const { engine, adapter } = makeAdapter();
309+
await adapter.create({ model: 'ssoProvider', data: { providerId: 'okta', oidcConfig: '{"clientId":"x"}', domain: 'acme.com' } });
310+
const [tbl, payload] = (engine.insert as any).mock.calls[0];
311+
expect(tbl).toBe('sys_sso_provider');
312+
expect(payload).toMatchObject({ provider_id: 'okta', oidc_config: '{"clientId":"x"}', domain: 'acme.com' });
313+
expect(payload).not.toHaveProperty('oidcConfig');
314+
});
315+
316+
it('maps snake columns back to camelCase on read', async () => {
317+
const { adapter } = makeAdapter();
318+
const row: any = await adapter.findOne({
319+
model: 'ssoProvider',
320+
where: [{ field: 'providerId', value: 'okta', operator: 'eq', connector: 'AND' }],
321+
});
322+
expect(row).toMatchObject({ providerId: 'okta', oidcConfig: '{"clientId":"x"}' });
323+
expect(row).not.toHaveProperty('oidc_config');
324+
});
325+
});

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

Lines changed: 67 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@ function convertWhere(where: CleanedWhere[]): Record<string, any> {
161161
* @returns better-auth AdapterFactory
162162
*/
163163
export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
164+
// Field-name bridging for better-auth plugins that expose NO `schema` option
165+
// (e.g. @better-auth/sso): when a model is remapped via AUTH_MODEL_TO_PROTOCOL,
166+
// its camelCase model fields are also converted to snake_case columns on the
167+
// way in and back to camelCase on the way out. SCOPED by `objectName !== model`
168+
// so core / schema-declared models are byte-for-byte untouched.
169+
const camelToSnake = (s: string): string => s.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase());
170+
const snakeToCamel = (s: string): string => s.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
171+
const remapKeys = (obj: Record<string, any>, fn: (k: string) => string): Record<string, any> => {
172+
const out: Record<string, any> = {};
173+
for (const k of Object.keys(obj)) out[fn(k)] = obj[k];
174+
return out;
175+
};
176+
const remapWhere = (where: CleanedWhere[]): CleanedWhere[] =>
177+
where.map((c) => ({ ...c, field: camelToSnake(c.field) }));
178+
164179
return createAdapterFactory({
165180
config: {
166181
adapterId: 'objectql',
@@ -177,18 +192,25 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
177192
create: async <T extends Record<string, any>>(
178193
{ model, data, select: _select }: { model: string; data: T; select?: string[] },
179194
): Promise<T> => {
180-
const result = await dataEngine.insert(model, data);
181-
return normaliseLegacyDates(model, result) as T;
195+
const objectName = resolveProtocolName(model);
196+
const bridged = objectName !== model;
197+
const result = await dataEngine.insert(objectName, bridged ? remapKeys(data, camelToSnake) : data);
198+
const norm = normaliseLegacyDates(model, result);
199+
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
182200
},
183201

184202
findOne: async <T>(
185203
{ model, where, select, join: _join }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any },
186204
): Promise<T | null> => {
187-
const filter = convertWhere(where);
188-
189-
const result = await dataEngine.findOne(model, { where: filter, fields: select });
190-
191-
return result ? (normaliseLegacyDates(model, result) as T) : null;
205+
const objectName = resolveProtocolName(model);
206+
const bridged = objectName !== model;
207+
const filter = convertWhere(bridged ? remapWhere(where) : where);
208+
const fields = bridged && select ? select.map(camelToSnake) : select;
209+
210+
const result = await dataEngine.findOne(objectName, { where: filter, fields });
211+
if (!result) return null;
212+
const norm = normaliseLegacyDates(model, result);
213+
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
192214
},
193215

194216
findMany: async <T>(
@@ -197,74 +219,93 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) {
197219
offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any;
198220
},
199221
): Promise<T[]> => {
200-
const filter = where ? convertWhere(where) : {};
222+
const objectName = resolveProtocolName(model);
223+
const bridged = objectName !== model;
224+
const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
201225

202226
const orderBy = sortBy
203-
? [{ field: sortBy.field, order: sortBy.direction as 'asc' | 'desc' }]
227+
? [{ field: bridged ? camelToSnake(sortBy.field) : sortBy.field, order: sortBy.direction as 'asc' | 'desc' }]
204228
: undefined;
205229

206-
const results = await dataEngine.find(model, {
230+
const results = await dataEngine.find(objectName, {
207231
where: filter,
208232
limit: limit || 100,
209233
offset,
210234
orderBy,
211235
});
212236

213-
return results.map((r) => normaliseLegacyDates(model, r as Record<string, any>)) as T[];
237+
return results.map((r) => {
238+
const norm = normaliseLegacyDates(model, r as Record<string, any>);
239+
return bridged ? remapKeys(norm, snakeToCamel) : norm;
240+
}) as T[];
214241
},
215242

216243
count: async (
217244
{ model, where }: { model: string; where?: CleanedWhere[] },
218245
): Promise<number> => {
219-
const filter = where ? convertWhere(where) : {};
220-
return await dataEngine.count(model, { where: filter });
246+
const objectName = resolveProtocolName(model);
247+
const bridged = objectName !== model;
248+
const filter = where ? convertWhere(bridged ? remapWhere(where) : where) : {};
249+
return await dataEngine.count(objectName, { where: filter });
221250
},
222251

223252
update: async <T>(
224253
{ model, where, update }: { model: string; where: CleanedWhere[]; update: T },
225254
): Promise<T | null> => {
226-
const filter = convertWhere(where);
255+
const objectName = resolveProtocolName(model);
256+
const bridged = objectName !== model;
257+
const filter = convertWhere(bridged ? remapWhere(where) : where);
227258

228259
// ObjectQL requires an ID for updates – find the record first
229-
const record = await dataEngine.findOne(model, { where: filter });
260+
const record = await dataEngine.findOne(objectName, { where: filter });
230261
if (!record) return null;
231262

232-
const result = await dataEngine.update(model, { ...(update as any), id: record.id });
233-
return result ? (normaliseLegacyDates(model, result) as T) : null;
263+
const patch = bridged ? remapKeys(update as any, camelToSnake) : (update as any);
264+
const result = await dataEngine.update(objectName, { ...patch, id: record.id });
265+
if (!result) return null;
266+
const norm = normaliseLegacyDates(model, result);
267+
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
234268
},
235269

236270
updateMany: async (
237271
{ model, where, update }: { model: string; where: CleanedWhere[]; update: Record<string, any> },
238272
): Promise<number> => {
239-
const filter = convertWhere(where);
273+
const objectName = resolveProtocolName(model);
274+
const bridged = objectName !== model;
275+
const filter = convertWhere(bridged ? remapWhere(where) : where);
240276

241277
// Sequential updates: ObjectQL requires an ID per update
242-
const records = await dataEngine.find(model, { where: filter });
278+
const records = await dataEngine.find(objectName, { where: filter });
279+
const patch = bridged ? remapKeys(update, camelToSnake) : update;
243280
for (const record of records) {
244-
await dataEngine.update(model, { ...update, id: record.id });
281+
await dataEngine.update(objectName, { ...patch, id: record.id });
245282
}
246283
return records.length;
247284
},
248285

249286
delete: async (
250287
{ model, where }: { model: string; where: CleanedWhere[] },
251288
): Promise<void> => {
252-
const filter = convertWhere(where);
289+
const objectName = resolveProtocolName(model);
290+
const bridged = objectName !== model;
291+
const filter = convertWhere(bridged ? remapWhere(where) : where);
253292

254-
const record = await dataEngine.findOne(model, { where: filter });
293+
const record = await dataEngine.findOne(objectName, { where: filter });
255294
if (!record) return;
256295

257-
await dataEngine.delete(model, { where: { id: record.id } });
296+
await dataEngine.delete(objectName, { where: { id: record.id } });
258297
},
259298

260299
deleteMany: async (
261300
{ model, where }: { model: string; where: CleanedWhere[] },
262301
): Promise<number> => {
263-
const filter = convertWhere(where);
302+
const objectName = resolveProtocolName(model);
303+
const bridged = objectName !== model;
304+
const filter = convertWhere(bridged ? remapWhere(where) : where);
264305

265-
const records = await dataEngine.find(model, { where: filter });
306+
const records = await dataEngine.find(objectName, { where: filter });
266307
for (const record of records) {
267-
await dataEngine.delete(model, { where: { id: record.id } });
308+
await dataEngine.delete(objectName, { where: { id: record.id } });
268309
}
269310
return records.length;
270311
},

0 commit comments

Comments
 (0)