Skip to content

Commit 4930f6e

Browse files
Copilothotlong
andcommitted
feat(plugin-auth): add schema mappings for organization, twoFactor, magicLink plugins
- Add AUTH_ORGANIZATION_SCHEMA, AUTH_MEMBER_SCHEMA, AUTH_INVITATION_SCHEMA, AUTH_TEAM_SCHEMA, AUTH_TEAM_MEMBER_SCHEMA for organization plugin tables - Add AUTH_TWO_FACTOR_SCHEMA and AUTH_TWO_FACTOR_USER_FIELDS for 2FA plugin - Add AUTH_ORG_SESSION_FIELDS for org plugin session extensions - Add buildOrganizationPluginSchema() and buildTwoFactorPluginSchema() helpers - Wire up plugin registration in AuthManager.buildPluginList() based on AuthPluginConfig flags (organization, twoFactor, magicLink) - Add 14 new tests for plugin schema configs and plugin registration - Update README documentation with plugin table mappings Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 2356af4 commit 4930f6e

5 files changed

Lines changed: 491 additions & 1 deletion

File tree

packages/plugins/plugin-auth/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,11 +215,23 @@ 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

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)
231+
232+
*Two-Factor plugin (when `plugins.twoFactor: true`):*
233+
- `sys_two_factor` (← `twoFactor`) - 2FA secrets (id, secret, backup_codes, user_id)
234+
223235
**Schema Mapping (modelName + fields):**
224236

225237
better-auth uses camelCase field names internally (`emailVerified`, `userId`, `createdAt`, etc.)
@@ -237,6 +249,8 @@ import {
237249
AUTH_SESSION_CONFIG,
238250
AUTH_ACCOUNT_CONFIG,
239251
AUTH_VERIFICATION_CONFIG,
252+
buildOrganizationPluginSchema,
253+
buildTwoFactorPluginSchema,
240254
} from '@objectstack/plugin-auth';
241255

242256
// Applied to the betterAuth() config:
@@ -246,7 +260,10 @@ const auth = betterAuth({
246260
session: { ...AUTH_SESSION_CONFIG, expiresIn: 604800 },
247261
account: { ...AUTH_ACCOUNT_CONFIG },
248262
verification: { ...AUTH_VERIFICATION_CONFIG },
249-
// ...
263+
plugins: [
264+
organization({ schema: buildOrganizationPluginSchema() }),
265+
twoFactor({ schema: buildTwoFactorPluginSchema() }),
266+
],
250267
});
251268
```
252269

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

Lines changed: 127 additions & 0 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', () => {
@@ -244,4 +258,117 @@ describe('AuthManager', () => {
244258
warnSpy.mockRestore();
245259
});
246260
});
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+
});
247374
});

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import { betterAuth } from 'better-auth';
44
import type { Auth, BetterAuthOptions } from 'better-auth';
5+
import { organization } from 'better-auth/plugins/organization';
6+
import { twoFactor } from 'better-auth/plugins/two-factor';
7+
import { magicLink } from 'better-auth/plugins/magic-link';
58
import type { AuthConfig } from '@objectstack/spec/system';
69
import type { IDataEngine } from '@objectstack/core';
710
import { createObjectQLAdapterFactory } from './objectql-adapter.js';
@@ -10,6 +13,8 @@ import {
1013
AUTH_SESSION_CONFIG,
1114
AUTH_ACCOUNT_CONFIG,
1215
AUTH_VERIFICATION_CONFIG,
16+
buildOrganizationPluginSchema,
17+
buildTwoFactorPluginSchema,
1318
} from './auth-schema-config.js';
1419

1520
/**
@@ -104,11 +109,54 @@ export class AuthManager {
104109
expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default
105110
updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default
106111
},
112+
113+
// better-auth plugins — registered based on AuthPluginConfig flags
114+
plugins: this.buildPluginList(),
107115
};
108116

109117
return betterAuth(betterAuthConfig);
110118
}
111119

120+
/**
121+
* Build the list of better-auth plugins based on AuthPluginConfig flags.
122+
*
123+
* Each plugin that introduces its own database tables is configured with
124+
* a `schema` option containing the appropriate snake_case field mappings,
125+
* so that `createAdapterFactory` transforms them automatically.
126+
*/
127+
private buildPluginList(): any[] {
128+
const pluginConfig = this.config.plugins;
129+
const plugins: any[] = [];
130+
131+
if (pluginConfig?.organization) {
132+
plugins.push(organization({
133+
schema: buildOrganizationPluginSchema(),
134+
}));
135+
}
136+
137+
if (pluginConfig?.twoFactor) {
138+
plugins.push(twoFactor({
139+
schema: buildTwoFactorPluginSchema(),
140+
}));
141+
}
142+
143+
if (pluginConfig?.magicLink) {
144+
// magic-link reuses the `verification` table — no extra schema mapping needed.
145+
// The sendMagicLink callback must be provided by the application at a higher level.
146+
// Here we provide a no-op default that logs a warning; real applications should
147+
// override this via AuthManagerOptions or a config extension point.
148+
plugins.push(magicLink({
149+
sendMagicLink: async ({ email, url }) => {
150+
console.warn(
151+
`[AuthManager] Magic-link requested for ${email} but no sendMagicLink handler configured. URL: ${url}`,
152+
);
153+
},
154+
}));
155+
}
156+
157+
return plugins;
158+
}
159+
112160
/**
113161
* Create database configuration using ObjectQL adapter
114162
*

0 commit comments

Comments
 (0)