Skip to content

Commit 64b4c2e

Browse files
authored
Merge pull request #897 from objectstack-ai/copilot/enhancement-better-auth-model-mapping
2 parents 06110cf + 6ec0c99 commit 64b4c2e

9 files changed

Lines changed: 1061 additions & 183 deletions

File tree

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
329329
**Migration (v3.x → v4.0):**
330330
- v3.x: The `SystemObjectName` constants now emit `sys_`-prefixed names. Implementations using `StorageNameMapping.resolveTableName()` can set `tableName` to preserve legacy physical table names during the transition.
331331
- v3.x: The `@objectstack/plugin-auth` ObjectQL adapter now includes `AUTH_MODEL_TO_PROTOCOL` mapping to translate better-auth's hardcoded model names (`user`, `session`, `account`, `verification`) to protocol names (`sys_user`, `sys_session`, `sys_account`, `sys_verification`). Custom adapters must adopt the same mapping.
332+
- v3.x: **Enhancement**`AuthManager` now uses better-auth's official `modelName` / `fields` schema customisation API (`AUTH_USER_CONFIG`, `AUTH_SESSION_CONFIG`, `AUTH_ACCOUNT_CONFIG`, `AUTH_VERIFICATION_CONFIG`) to declare camelCase → snake_case field mappings. The ObjectQL adapter uses `createAdapterFactory` from `better-auth/adapters` to apply these transformations automatically, eliminating the need for manual field-name conversion. The legacy `createObjectQLAdapter()` is retained for backward compatibility.
332333
- v3.x: **Bug fix**`AuthManager.createDatabaseConfig()` now wraps the ObjectQL adapter as a `DBAdapterInstance` factory function (`(options) => DBAdapter`). Previously the raw adapter object was passed, which fell through to the Kysely adapter path and failed silently. `AuthManager.handleRequest()` and `AuthPlugin.registerAuthRoutes()` now inspect `response.status >= 500` and log the error body, since better-auth catches internal errors and returns 500 Responses without throwing.
333334
- v3.x: **Bug fix**`AuthPlugin` now defers HTTP route registration to a `kernel:ready` hook instead of doing it synchronously in `start()`. This makes the plugin resilient to plugin loading order — the `http-server` service is guaranteed to be available after all plugins complete their init/start phases. The CLI `serve` command also registers `HonoServerPlugin` before config plugins (with duplicate detection) for the same reason.
334335
- v3.x: **Bug fix** — Studio `useApiDiscovery` hook no longer hardcodes auth endpoints as `/api/auth/...`. The `discover()` callback now fetches `/api/v1/discovery` and reads `routes.auth` to dynamically construct auth endpoint paths (falling back to `/api/v1/auth`). The session endpoint is corrected from `/session` to `/get-session` to align with better-auth's `AuthEndpointPaths.getSession`.

content/docs/guides/authentication.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,20 @@ The plugin uses ObjectStack's `sys_` prefix convention for protocol object names
583583
- Object names: `sys_user`, `sys_session`, `sys_account`, `sys_verification` (protocol names)
584584
- Field names: `email_verified`, `created_at`, `user_id` (snake_case)
585585

586-
better-auth internally uses model names like `user` and `session`. The ObjectQL adapter (`AUTH_MODEL_TO_PROTOCOL` mapping) automatically translates these to `sys_`-prefixed protocol names, providing seamless integration.
586+
better-auth internally uses camelCase model and field names (`user`, `emailVerified`, `userId`).
587+
The plugin bridges this gap using better-auth's official **`modelName` / `fields` schema customisation API**:
588+
589+
```typescript
590+
// Declared in the betterAuth() config via AUTH_*_CONFIG constants:
591+
user: { modelName: 'sys_user', fields: { emailVerified: 'email_verified', … } },
592+
session: { modelName: 'sys_session', fields: { userId: 'user_id', expiresAt: 'expires_at', … } },
593+
account: { modelName: 'sys_account', fields: { providerId: 'provider_id', accountId: 'account_id', … } },
594+
verification: { modelName: 'sys_verification', fields: { expiresAt: 'expires_at', … } },
595+
```
596+
597+
The ObjectQL adapter factory (`createObjectQLAdapterFactory`) then uses better-auth's `createAdapterFactory`
598+
which automatically transforms all data and where-clauses using these mappings — no manual
599+
camelCase ↔ snake_case conversion is needed in the adapter.
587600

588601
> **Upgrade note:** If you have custom adapters or plugins that reference auth objects by name,
589602
> update them to use `sys_user`, `sys_session`, `sys_account`, `sys_verification`

packages/plugins/plugin-auth/README.md

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -215,38 +215,75 @@ export const AuthUser = ObjectSchema.create({
215215
**Database Objects:**
216216
Uses ObjectStack `sys_` prefixed protocol names with snake_case field naming.
217217
The adapter automatically maps better-auth model names to protocol names:
218+
219+
*Core models:*
218220
- `sys_user` (← better-auth `user`) - User accounts (id, email, name, email_verified, created_at, etc.)
219221
- `sys_session` (← better-auth `session`) - Active sessions (id, token, user_id, expires_at, ip_address, etc.)
220222
- `sys_account` (← better-auth `account`) - OAuth provider accounts (id, provider_id, account_id, user_id, tokens, etc.)
221223
- `sys_verification` (← better-auth `verification`) - Verification tokens (id, value, identifier, expires_at, etc.)
222224

223-
**Adapter:**
224-
The `createObjectQLAdapter()` function bridges better-auth's database interface to ObjectQL's IDataEngine. It includes a model→protocol name mapping (`AUTH_MODEL_TO_PROTOCOL`) that translates better-auth's hardcoded model names (e.g. `user`) to ObjectStack protocol names (e.g. `sys_user`):
225+
*Organization plugin (when `plugins.organization: true`):*
226+
- `sys_organization` (← `organization`) - Organizations (id, name, slug, logo, created_at, etc.)
227+
- `sys_member` (← `member`) - Organization members (id, organization_id, user_id, role, created_at)
228+
- `sys_invitation` (← `invitation`) - Invitations (id, organization_id, inviter_id, email, role, expires_at, etc.)
229+
- `sys_team` (← `team`) - Teams (id, name, organization_id, created_at, etc.)
230+
- `sys_team_member` (← `teamMember`) - Team members (id, team_id, user_id, created_at)
225231

226-
```typescript
227-
// Better-auth → ObjectQL Adapter (handles model name mapping + field transformation)
228-
import { createObjectQLAdapter, AUTH_MODEL_TO_PROTOCOL } from '@objectstack/plugin-auth';
232+
*Two-Factor plugin (when `plugins.twoFactor: true`):*
233+
- `sys_two_factor` (← `twoFactor`) - 2FA secrets (id, secret, backup_codes, user_id)
234+
235+
**Schema Mapping (modelName + fields):**
229236

230-
const adapter = createObjectQLAdapter(dataEngine);
237+
better-auth uses camelCase field names internally (`emailVerified`, `userId`, `createdAt`, etc.)
238+
while ObjectStack's protocol layer uses snake_case (`email_verified`, `user_id`, `created_at`).
231239

232-
// Mapping: { user: 'sys_user', session: 'sys_session', account: 'sys_account', verification: 'sys_verification' }
233-
console.log(AUTH_MODEL_TO_PROTOCOL);
240+
The plugin leverages better-auth's official `modelName` / `fields` schema customisation API
241+
to declare the mapping at configuration time. The `createAdapterFactory` wrapper then
242+
transforms data and where-clauses automatically — no runtime camelCase ↔ snake_case
243+
conversion is needed in the adapter itself.
234244

235-
// better-auth requires a DBAdapterInstance (factory function), not a raw adapter object.
236-
// Passing a plain object falls through to the Kysely adapter path and fails silently.
237-
// Wrap the adapter in a factory function:
245+
```typescript
246+
// Schema mapping constants (auth-schema-config.ts)
247+
import {
248+
AUTH_USER_CONFIG,
249+
AUTH_SESSION_CONFIG,
250+
AUTH_ACCOUNT_CONFIG,
251+
AUTH_VERIFICATION_CONFIG,
252+
buildOrganizationPluginSchema,
253+
buildTwoFactorPluginSchema,
254+
} from '@objectstack/plugin-auth';
255+
256+
// Applied to the betterAuth() config:
238257
const auth = betterAuth({
239-
database: (options) => ({
240-
id: 'objectql',
241-
...adapter,
242-
transaction: async (cb) => cb(adapter),
243-
}),
244-
// ... other config
258+
database: createObjectQLAdapterFactory(dataEngine),
259+
user: { ...AUTH_USER_CONFIG },
260+
session: { ...AUTH_SESSION_CONFIG, expiresIn: 604800 },
261+
account: { ...AUTH_ACCOUNT_CONFIG },
262+
verification: { ...AUTH_VERIFICATION_CONFIG },
263+
plugins: [
264+
organization({ schema: buildOrganizationPluginSchema() }),
265+
twoFactor({ schema: buildTwoFactorPluginSchema() }),
266+
],
245267
});
246268
```
247269

248-
> **Note:** `AuthManager` handles this wrapping automatically when you provide a `dataEngine`.
249-
> You only need the factory pattern above when using `createObjectQLAdapter()` directly.
270+
**Adapter Factory:**
271+
The `createObjectQLAdapterFactory()` function uses better-auth's `createAdapterFactory` to
272+
bridge ObjectQL's IDataEngine with better-auth. Model-name and field-name transformations
273+
are applied by the factory wrapper so the adapter code stays simple:
274+
275+
```typescript
276+
import { createObjectQLAdapterFactory } from '@objectstack/plugin-auth';
277+
278+
const adapterFactory = createObjectQLAdapterFactory(dataEngine);
279+
// adapterFactory is (options: BetterAuthOptions) => DBAdapter
280+
```
281+
282+
> **Note:** `AuthManager` handles all of this automatically when you provide a `dataEngine`.
283+
> You only need the factory/config above when using the adapter directly.
284+
285+
A legacy `createObjectQLAdapter()` function (with manual model-name mapping via
286+
`AUTH_MODEL_TO_PROTOCOL`) is still exported for backward compatibility.
250287

251288
## Development
252289

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 196 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ vi.mock('better-auth', () => ({
1111
})),
1212
}));
1313

14+
// Mock plugin imports — we only need to verify they are called with the
15+
// correct schema options; the actual plugin logic is tested by better-auth.
16+
vi.mock('better-auth/plugins/organization', () => ({
17+
organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })),
18+
}));
19+
20+
vi.mock('better-auth/plugins/two-factor', () => ({
21+
twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })),
22+
}));
23+
24+
vi.mock('better-auth/plugins/magic-link', () => ({
25+
magicLink: vi.fn((_opts?: any) => ({ id: 'magic-link' })),
26+
}));
27+
1428
import { betterAuth } from 'better-auth';
1529

1630
describe('AuthManager', () => {
@@ -107,7 +121,7 @@ describe('AuthManager', () => {
107121
});
108122

109123
describe('createDatabaseConfig – adapter wrapping', () => {
110-
it('should pass a function (DBAdapterInstance) to betterAuth when dataEngine is provided', () => {
124+
it('should pass a function (AdapterFactory) to betterAuth when dataEngine is provided', () => {
111125
const mockDataEngine = {
112126
insert: vi.fn(),
113127
findOne: vi.fn(),
@@ -128,7 +142,7 @@ describe('AuthManager', () => {
128142
// We need to trigger the lazy init first
129143
});
130144

131-
it('should provide a factory function as database config that returns adapter with id and transaction', () => {
145+
it('should provide a factory function as database config', () => {
132146
const mockDataEngine = {
133147
insert: vi.fn().mockResolvedValue({ id: '1' }),
134148
findOne: vi.fn().mockResolvedValue({ id: '1' }),
@@ -153,21 +167,75 @@ describe('AuthManager', () => {
153167
// Trigger lazy initialisation
154168
manager.getAuthInstance();
155169

156-
// The database config should be a function (DBAdapterInstance)
170+
// The database config should be a function (AdapterFactory)
157171
expect(typeof capturedConfig.database).toBe('function');
172+
});
173+
174+
it('should include modelName and fields mapping for user, session, account, verification', () => {
175+
const mockDataEngine = {
176+
insert: vi.fn().mockResolvedValue({ id: '1' }),
177+
findOne: vi.fn().mockResolvedValue({ id: '1' }),
178+
find: vi.fn().mockResolvedValue([]),
179+
count: vi.fn().mockResolvedValue(0),
180+
update: vi.fn().mockResolvedValue({ id: '1' }),
181+
delete: vi.fn().mockResolvedValue(undefined),
182+
};
183+
184+
let capturedConfig: any;
185+
(betterAuth as any).mockImplementation((config: any) => {
186+
capturedConfig = config;
187+
return { handler: vi.fn(), api: {} };
188+
});
189+
190+
const manager = new AuthManager({
191+
secret: 'test-secret-at-least-32-chars-long',
192+
baseUrl: 'http://localhost:3000',
193+
dataEngine: mockDataEngine as any,
194+
});
158195

159-
// Calling the factory should return an adapter object
160-
const adapterResult = capturedConfig.database({});
161-
expect(adapterResult).toHaveProperty('id', 'objectql');
162-
expect(typeof adapterResult.create).toBe('function');
163-
expect(typeof adapterResult.findOne).toBe('function');
164-
expect(typeof adapterResult.findMany).toBe('function');
165-
expect(typeof adapterResult.count).toBe('function');
166-
expect(typeof adapterResult.update).toBe('function');
167-
expect(typeof adapterResult.delete).toBe('function');
168-
expect(typeof adapterResult.deleteMany).toBe('function');
169-
expect(typeof adapterResult.updateMany).toBe('function');
170-
expect(typeof adapterResult.transaction).toBe('function');
196+
manager.getAuthInstance();
197+
198+
// Verify user model config
199+
expect(capturedConfig.user).toBeDefined();
200+
expect(capturedConfig.user.modelName).toBe('sys_user');
201+
expect(capturedConfig.user.fields).toEqual(expect.objectContaining({
202+
emailVerified: 'email_verified',
203+
createdAt: 'created_at',
204+
updatedAt: 'updated_at',
205+
}));
206+
207+
// Verify session model config (merged with session timing config)
208+
expect(capturedConfig.session).toBeDefined();
209+
expect(capturedConfig.session.modelName).toBe('sys_session');
210+
expect(capturedConfig.session.fields).toEqual(expect.objectContaining({
211+
userId: 'user_id',
212+
expiresAt: 'expires_at',
213+
ipAddress: 'ip_address',
214+
userAgent: 'user_agent',
215+
}));
216+
217+
// Verify account model config
218+
expect(capturedConfig.account).toBeDefined();
219+
expect(capturedConfig.account.modelName).toBe('sys_account');
220+
expect(capturedConfig.account.fields).toEqual(expect.objectContaining({
221+
userId: 'user_id',
222+
providerId: 'provider_id',
223+
accountId: 'account_id',
224+
accessToken: 'access_token',
225+
refreshToken: 'refresh_token',
226+
idToken: 'id_token',
227+
accessTokenExpiresAt: 'access_token_expires_at',
228+
refreshTokenExpiresAt: 'refresh_token_expires_at',
229+
}));
230+
231+
// Verify verification model config
232+
expect(capturedConfig.verification).toBeDefined();
233+
expect(capturedConfig.verification.modelName).toBe('sys_verification');
234+
expect(capturedConfig.verification.fields).toEqual(expect.objectContaining({
235+
expiresAt: 'expires_at',
236+
createdAt: 'created_at',
237+
updatedAt: 'updated_at',
238+
}));
171239
});
172240

173241
it('should return undefined (in-memory fallback) when no dataEngine is provided', () => {
@@ -190,4 +258,117 @@ describe('AuthManager', () => {
190258
warnSpy.mockRestore();
191259
});
192260
});
261+
262+
describe('plugin registration', () => {
263+
it('should not include any plugins when no plugin config is provided', () => {
264+
let capturedConfig: any;
265+
(betterAuth as any).mockImplementation((config: any) => {
266+
capturedConfig = config;
267+
return { handler: vi.fn(), api: {} };
268+
});
269+
270+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
271+
const manager = new AuthManager({
272+
secret: 'test-secret-at-least-32-chars-long',
273+
baseUrl: 'http://localhost:3000',
274+
});
275+
manager.getAuthInstance();
276+
warnSpy.mockRestore();
277+
278+
expect(capturedConfig.plugins).toEqual([]);
279+
});
280+
281+
it('should register organization plugin with schema mapping when enabled', () => {
282+
let capturedConfig: any;
283+
(betterAuth as any).mockImplementation((config: any) => {
284+
capturedConfig = config;
285+
return { handler: vi.fn(), api: {} };
286+
});
287+
288+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
289+
const manager = new AuthManager({
290+
secret: 'test-secret-at-least-32-chars-long',
291+
baseUrl: 'http://localhost:3000',
292+
plugins: { organization: true },
293+
});
294+
manager.getAuthInstance();
295+
warnSpy.mockRestore();
296+
297+
const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization');
298+
expect(orgPlugin).toBeDefined();
299+
// Verify schema was passed to organization() call
300+
expect(orgPlugin._opts.schema.organization.modelName).toBe('sys_organization');
301+
expect(orgPlugin._opts.schema.member.modelName).toBe('sys_member');
302+
expect(orgPlugin._opts.schema.invitation.modelName).toBe('sys_invitation');
303+
expect(orgPlugin._opts.schema.team.modelName).toBe('sys_team');
304+
expect(orgPlugin._opts.schema.teamMember.modelName).toBe('sys_team_member');
305+
expect(orgPlugin._opts.schema.session.fields.activeOrganizationId).toBe('active_organization_id');
306+
});
307+
308+
it('should register twoFactor plugin with schema mapping when enabled', () => {
309+
let capturedConfig: any;
310+
(betterAuth as any).mockImplementation((config: any) => {
311+
capturedConfig = config;
312+
return { handler: vi.fn(), api: {} };
313+
});
314+
315+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
316+
const manager = new AuthManager({
317+
secret: 'test-secret-at-least-32-chars-long',
318+
baseUrl: 'http://localhost:3000',
319+
plugins: { twoFactor: true },
320+
});
321+
manager.getAuthInstance();
322+
warnSpy.mockRestore();
323+
324+
const tfPlugin = capturedConfig.plugins.find((p: any) => p.id === 'two-factor');
325+
expect(tfPlugin).toBeDefined();
326+
expect(tfPlugin._opts.schema.twoFactor.modelName).toBe('sys_two_factor');
327+
expect(tfPlugin._opts.schema.twoFactor.fields.backupCodes).toBe('backup_codes');
328+
expect(tfPlugin._opts.schema.twoFactor.fields.userId).toBe('user_id');
329+
expect(tfPlugin._opts.schema.user.fields.twoFactorEnabled).toBe('two_factor_enabled');
330+
});
331+
332+
it('should register magicLink plugin when enabled', () => {
333+
let capturedConfig: any;
334+
(betterAuth as any).mockImplementation((config: any) => {
335+
capturedConfig = config;
336+
return { handler: vi.fn(), api: {} };
337+
});
338+
339+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
340+
const manager = new AuthManager({
341+
secret: 'test-secret-at-least-32-chars-long',
342+
baseUrl: 'http://localhost:3000',
343+
plugins: { magicLink: true },
344+
});
345+
manager.getAuthInstance();
346+
warnSpy.mockRestore();
347+
348+
const mlPlugin = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
349+
expect(mlPlugin).toBeDefined();
350+
});
351+
352+
it('should register multiple plugins when multiple flags are enabled', () => {
353+
let capturedConfig: any;
354+
(betterAuth as any).mockImplementation((config: any) => {
355+
capturedConfig = config;
356+
return { handler: vi.fn(), api: {} };
357+
});
358+
359+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
360+
const manager = new AuthManager({
361+
secret: 'test-secret-at-least-32-chars-long',
362+
baseUrl: 'http://localhost:3000',
363+
plugins: { organization: true, twoFactor: true, magicLink: true },
364+
});
365+
manager.getAuthInstance();
366+
warnSpy.mockRestore();
367+
368+
expect(capturedConfig.plugins).toHaveLength(3);
369+
expect(capturedConfig.plugins.map((p: any) => p.id).sort()).toEqual(
370+
['magic-link', 'organization', 'two-factor'],
371+
);
372+
});
373+
});
193374
});

0 commit comments

Comments
 (0)