From 42900792573399c34ef7ce40dcf0d2029a234b6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:30:25 +0000 Subject: [PATCH 1/5] Initial plan From abe5a1c5a484c2934996fa4447d87388cc7e3a59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:52:00 +0000 Subject: [PATCH 2/5] feat(plugin-auth): use better-auth modelName/fields mapping for snake_case compatibility - Add auth-schema-config.ts with model/field mapping constants for all 4 core auth models - Refactor objectql-adapter.ts to use createAdapterFactory from better-auth/adapters - Update auth-manager.ts to pass user/session/account/verification config with modelName and fields - Add tests for schema config, factory adapter, and config verification - Update README and authentication guide documentation - Keep legacy createObjectQLAdapter for backward compatibility Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- content/docs/guides/authentication.mdx | 15 +- packages/plugins/plugin-auth/README.md | 60 ++-- .../plugin-auth/src/auth-manager.test.ts | 84 ++++- .../plugins/plugin-auth/src/auth-manager.ts | 44 ++- .../plugin-auth/src/auth-schema-config.ts | 135 ++++++++ packages/plugins/plugin-auth/src/index.ts | 1 + .../plugin-auth/src/objectql-adapter.test.ts | 76 ++++- .../plugin-auth/src/objectql-adapter.ts | 315 +++++++++++------- 8 files changed, 559 insertions(+), 171 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/auth-schema-config.ts diff --git a/content/docs/guides/authentication.mdx b/content/docs/guides/authentication.mdx index 78e16fb3a3..aa303cda4b 100644 --- a/content/docs/guides/authentication.mdx +++ b/content/docs/guides/authentication.mdx @@ -583,7 +583,20 @@ The plugin uses ObjectStack's `sys_` prefix convention for protocol object names - Object names: `sys_user`, `sys_session`, `sys_account`, `sys_verification` (protocol names) - Field names: `email_verified`, `created_at`, `user_id` (snake_case) -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. +better-auth internally uses camelCase model and field names (`user`, `emailVerified`, `userId`). +The plugin bridges this gap using better-auth's official **`modelName` / `fields` schema customisation API**: + +```typescript +// Declared in the betterAuth() config via AUTH_*_CONFIG constants: +user: { modelName: 'sys_user', fields: { emailVerified: 'email_verified', … } }, +session: { modelName: 'sys_session', fields: { userId: 'user_id', expiresAt: 'expires_at', … } }, +account: { modelName: 'sys_account', fields: { providerId: 'provider_id', accountId: 'account_id', … } }, +verification: { modelName: 'sys_verification', fields: { expiresAt: 'expires_at', … } }, +``` + +The ObjectQL adapter factory (`createObjectQLAdapterFactory`) then uses better-auth's `createAdapterFactory` +which automatically transforms all data and where-clauses using these mappings — no manual +camelCase ↔ snake_case conversion is needed in the adapter. > **Upgrade note:** If you have custom adapters or plugins that reference auth objects by name, > update them to use `sys_user`, `sys_session`, `sys_account`, `sys_verification` diff --git a/packages/plugins/plugin-auth/README.md b/packages/plugins/plugin-auth/README.md index df6f9ee1d4..7d171c9d5e 100644 --- a/packages/plugins/plugin-auth/README.md +++ b/packages/plugins/plugin-auth/README.md @@ -220,33 +220,53 @@ The adapter automatically maps better-auth model names to protocol names: - `sys_account` (← better-auth `account`) - OAuth provider accounts (id, provider_id, account_id, user_id, tokens, etc.) - `sys_verification` (← better-auth `verification`) - Verification tokens (id, value, identifier, expires_at, etc.) -**Adapter:** -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`): +**Schema Mapping (modelName + fields):** -```typescript -// Better-auth → ObjectQL Adapter (handles model name mapping + field transformation) -import { createObjectQLAdapter, AUTH_MODEL_TO_PROTOCOL } from '@objectstack/plugin-auth'; - -const adapter = createObjectQLAdapter(dataEngine); +better-auth uses camelCase field names internally (`emailVerified`, `userId`, `createdAt`, etc.) +while ObjectStack's protocol layer uses snake_case (`email_verified`, `user_id`, `created_at`). -// Mapping: { user: 'sys_user', session: 'sys_session', account: 'sys_account', verification: 'sys_verification' } -console.log(AUTH_MODEL_TO_PROTOCOL); +The plugin leverages better-auth's official `modelName` / `fields` schema customisation API +to declare the mapping at configuration time. The `createAdapterFactory` wrapper then +transforms data and where-clauses automatically — no runtime camelCase ↔ snake_case +conversion is needed in the adapter itself. -// better-auth requires a DBAdapterInstance (factory function), not a raw adapter object. -// Passing a plain object falls through to the Kysely adapter path and fails silently. -// Wrap the adapter in a factory function: +```typescript +// Schema mapping constants (auth-schema-config.ts) +import { + AUTH_USER_CONFIG, + AUTH_SESSION_CONFIG, + AUTH_ACCOUNT_CONFIG, + AUTH_VERIFICATION_CONFIG, +} from '@objectstack/plugin-auth'; + +// Applied to the betterAuth() config: const auth = betterAuth({ - database: (options) => ({ - id: 'objectql', - ...adapter, - transaction: async (cb) => cb(adapter), - }), - // ... other config + database: createObjectQLAdapterFactory(dataEngine), + user: { ...AUTH_USER_CONFIG }, + session: { ...AUTH_SESSION_CONFIG, expiresIn: 604800 }, + account: { ...AUTH_ACCOUNT_CONFIG }, + verification: { ...AUTH_VERIFICATION_CONFIG }, + // ... }); ``` -> **Note:** `AuthManager` handles this wrapping automatically when you provide a `dataEngine`. -> You only need the factory pattern above when using `createObjectQLAdapter()` directly. +**Adapter Factory:** +The `createObjectQLAdapterFactory()` function uses better-auth's `createAdapterFactory` to +bridge ObjectQL's IDataEngine with better-auth. Model-name and field-name transformations +are applied by the factory wrapper so the adapter code stays simple: + +```typescript +import { createObjectQLAdapterFactory } from '@objectstack/plugin-auth'; + +const adapterFactory = createObjectQLAdapterFactory(dataEngine); +// adapterFactory is (options: BetterAuthOptions) => DBAdapter +``` + +> **Note:** `AuthManager` handles all of this automatically when you provide a `dataEngine`. +> You only need the factory/config above when using the adapter directly. + +A legacy `createObjectQLAdapter()` function (with manual model-name mapping via +`AUTH_MODEL_TO_PROTOCOL`) is still exported for backward compatibility. ## Development diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index fd700b5c82..bafda5b0b3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -107,7 +107,7 @@ describe('AuthManager', () => { }); describe('createDatabaseConfig – adapter wrapping', () => { - it('should pass a function (DBAdapterInstance) to betterAuth when dataEngine is provided', () => { + it('should pass a function (AdapterFactory) to betterAuth when dataEngine is provided', () => { const mockDataEngine = { insert: vi.fn(), findOne: vi.fn(), @@ -128,7 +128,7 @@ describe('AuthManager', () => { // We need to trigger the lazy init first }); - it('should provide a factory function as database config that returns adapter with id and transaction', () => { + it('should provide a factory function as database config', () => { const mockDataEngine = { insert: vi.fn().mockResolvedValue({ id: '1' }), findOne: vi.fn().mockResolvedValue({ id: '1' }), @@ -153,21 +153,75 @@ describe('AuthManager', () => { // Trigger lazy initialisation manager.getAuthInstance(); - // The database config should be a function (DBAdapterInstance) + // The database config should be a function (AdapterFactory) expect(typeof capturedConfig.database).toBe('function'); + }); + + it('should include modelName and fields mapping for user, session, account, verification', () => { + const mockDataEngine = { + insert: vi.fn().mockResolvedValue({ id: '1' }), + findOne: vi.fn().mockResolvedValue({ id: '1' }), + find: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + update: vi.fn().mockResolvedValue({ id: '1' }), + delete: vi.fn().mockResolvedValue(undefined), + }; + + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + dataEngine: mockDataEngine as any, + }); + + manager.getAuthInstance(); - // Calling the factory should return an adapter object - const adapterResult = capturedConfig.database({}); - expect(adapterResult).toHaveProperty('id', 'objectql'); - expect(typeof adapterResult.create).toBe('function'); - expect(typeof adapterResult.findOne).toBe('function'); - expect(typeof adapterResult.findMany).toBe('function'); - expect(typeof adapterResult.count).toBe('function'); - expect(typeof adapterResult.update).toBe('function'); - expect(typeof adapterResult.delete).toBe('function'); - expect(typeof adapterResult.deleteMany).toBe('function'); - expect(typeof adapterResult.updateMany).toBe('function'); - expect(typeof adapterResult.transaction).toBe('function'); + // Verify user model config + expect(capturedConfig.user).toBeDefined(); + expect(capturedConfig.user.modelName).toBe('sys_user'); + expect(capturedConfig.user.fields).toEqual(expect.objectContaining({ + emailVerified: 'email_verified', + createdAt: 'created_at', + updatedAt: 'updated_at', + })); + + // Verify session model config (merged with session timing config) + expect(capturedConfig.session).toBeDefined(); + expect(capturedConfig.session.modelName).toBe('sys_session'); + expect(capturedConfig.session.fields).toEqual(expect.objectContaining({ + userId: 'user_id', + expiresAt: 'expires_at', + ipAddress: 'ip_address', + userAgent: 'user_agent', + })); + + // Verify account model config + expect(capturedConfig.account).toBeDefined(); + expect(capturedConfig.account.modelName).toBe('sys_account'); + expect(capturedConfig.account.fields).toEqual(expect.objectContaining({ + userId: 'user_id', + providerId: 'provider_id', + accountId: 'account_id', + accessToken: 'access_token', + refreshToken: 'refresh_token', + idToken: 'id_token', + accessTokenExpiresAt: 'access_token_expires_at', + refreshTokenExpiresAt: 'refresh_token_expires_at', + })); + + // Verify verification model config + expect(capturedConfig.verification).toBeDefined(); + expect(capturedConfig.verification.modelName).toBe('sys_verification'); + expect(capturedConfig.verification.fields).toEqual(expect.objectContaining({ + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + })); }); it('should return undefined (in-memory fallback) when no dataEngine is provided', () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 8092ee1938..5fe0666819 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -4,7 +4,13 @@ import { betterAuth } from 'better-auth'; import type { Auth, BetterAuthOptions } from 'better-auth'; import type { AuthConfig } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; -import { createObjectQLAdapter } from './objectql-adapter.js'; +import { createObjectQLAdapterFactory } from './objectql-adapter.js'; +import { + AUTH_USER_CONFIG, + AUTH_SESSION_CONFIG, + AUTH_ACCOUNT_CONFIG, + AUTH_VERIFICATION_CONFIG, +} from './auth-schema-config.js'; /** * Extended options for AuthManager @@ -71,10 +77,22 @@ export class AuthManager { basePath: '/', // ← 关键修复!告诉 better-auth 路径已被剥离 // Database adapter configuration - // For now, we configure a basic setup that will be enhanced - // when database URL is provided and drizzle-orm is available database: this.createDatabaseConfig(), + // Model/field mapping: camelCase (better-auth) → snake_case (ObjectStack) + // These declarations tell better-auth the actual table/column names used + // by ObjectStack's protocol layer, enabling automatic transformation via + // createAdapterFactory. + user: { + ...AUTH_USER_CONFIG, + }, + account: { + ...AUTH_ACCOUNT_CONFIG, + }, + verification: { + ...AUTH_VERIFICATION_CONFIG, + }, + // Email configuration emailAndPassword: { enabled: true, @@ -82,6 +100,7 @@ export class AuthManager { // Session configuration session: { + ...AUTH_SESSION_CONFIG, expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default }, @@ -103,19 +122,14 @@ export class AuthManager { * so it is correctly recognised as a `DBAdapterInstance`. */ private createDatabaseConfig(): any { - // Use ObjectQL adapter if dataEngine is provided + // Use ObjectQL adapter factory if dataEngine is provided if (this.config.dataEngine) { - const adapter = createObjectQLAdapter(this.config.dataEngine); - // Return a DBAdapterInstance factory function - return (_options: any) => ({ - id: 'objectql', - ...adapter, - // ObjectQL does not yet expose a separate transaction context, - // so we pass the adapter itself. better-auth patches this - // automatically when missing, but providing it avoids a - // runtime warning from getBaseAdapter(). - transaction: async (cb: (trx: any) => Promise): Promise => cb(adapter), - }); + // createObjectQLAdapterFactory returns an AdapterFactory + // (options => DBAdapter) which better-auth invokes via getBaseAdapter(). + // The factory is created by better-auth's createAdapterFactory and + // automatically applies modelName/fields transformations declared in + // the betterAuth config above. + return createObjectQLAdapterFactory(this.config.dataEngine); } // Fallback warning if no dataEngine is provided diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts new file mode 100644 index 0000000000..b6066dce0b --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { SystemObjectName } from '@objectstack/spec/system'; + +/** + * better-auth ↔ ObjectStack Schema Mapping + * + * better-auth uses camelCase field names internally (e.g. `emailVerified`, `userId`) + * while ObjectStack's protocol layer uses snake_case (e.g. `email_verified`, `user_id`). + * + * These constants declare the `modelName` and `fields` mappings for each core auth + * model, following better-auth's official schema customisation API + * ({@link https://www.better-auth.com/docs/concepts/database}). + * + * The mappings serve two purposes: + * 1. `modelName` — maps the default model name to the ObjectStack protocol name + * (e.g. `user` → `sys_user`). + * 2. `fields` — maps camelCase field names to their snake_case database column + * equivalents. Only fields whose names differ need to be listed; fields that + * are already identical (e.g. `email`, `name`, `token`) are omitted. + * + * These mappings are consumed by: + * - The `betterAuth()` configuration in {@link AuthManager} so that + * `getAuthTables()` builds the correct schema. + * - The ObjectQL adapter factory (via `createAdapterFactory`) which uses the + * schema to transform data and where-clauses automatically. + */ + +// --------------------------------------------------------------------------- +// User model +// --------------------------------------------------------------------------- + +/** + * better-auth `user` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | emailVerified | email_verified | + * | createdAt | created_at | + * | updatedAt | updated_at | + */ +export const AUTH_USER_CONFIG = { + modelName: SystemObjectName.USER, // 'sys_user' + fields: { + emailVerified: 'email_verified', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +} as const; + +// --------------------------------------------------------------------------- +// Session model +// --------------------------------------------------------------------------- + +/** + * better-auth `session` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | userId | user_id | + * | expiresAt | expires_at | + * | createdAt | created_at | + * | updatedAt | updated_at | + * | ipAddress | ip_address | + * | userAgent | user_agent | + */ +export const AUTH_SESSION_CONFIG = { + modelName: SystemObjectName.SESSION, // 'sys_session' + fields: { + userId: 'user_id', + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + ipAddress: 'ip_address', + userAgent: 'user_agent', + }, +} as const; + +// --------------------------------------------------------------------------- +// Account model +// --------------------------------------------------------------------------- + +/** + * better-auth `account` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:--------------------------|:-------------------------------| + * | userId | user_id | + * | providerId | provider_id | + * | accountId | account_id | + * | accessToken | access_token | + * | refreshToken | refresh_token | + * | idToken | id_token | + * | accessTokenExpiresAt | access_token_expires_at | + * | refreshTokenExpiresAt | refresh_token_expires_at | + * | createdAt | created_at | + * | updatedAt | updated_at | + */ +export const AUTH_ACCOUNT_CONFIG = { + modelName: SystemObjectName.ACCOUNT, // 'sys_account' + fields: { + userId: 'user_id', + providerId: 'provider_id', + accountId: 'account_id', + accessToken: 'access_token', + refreshToken: 'refresh_token', + idToken: 'id_token', + accessTokenExpiresAt: 'access_token_expires_at', + refreshTokenExpiresAt: 'refresh_token_expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +} as const; + +// --------------------------------------------------------------------------- +// Verification model +// --------------------------------------------------------------------------- + +/** + * better-auth `verification` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | expiresAt | expires_at | + * | createdAt | created_at | + * | updatedAt | updated_at | + */ +export const AUTH_VERIFICATION_CONFIG = { + modelName: SystemObjectName.VERIFICATION, // 'sys_verification' + fields: { + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +} as const; diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index aa48cf347a..7a1b0cddbb 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -11,5 +11,6 @@ export * from './auth-plugin.js'; export * from './auth-manager.js'; export * from './objectql-adapter.js'; +export * from './auth-schema-config.js'; export * from './objects/index.js'; export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system'; diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index 95dc40e477..7847b7428b 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -3,9 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createObjectQLAdapter, + createObjectQLAdapterFactory, AUTH_MODEL_TO_PROTOCOL, resolveProtocolName, } from './objectql-adapter'; +import { + AUTH_USER_CONFIG, + AUTH_SESSION_CONFIG, + AUTH_ACCOUNT_CONFIG, + AUTH_VERIFICATION_CONFIG, +} from './auth-schema-config'; import { SystemObjectName } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; @@ -39,7 +46,74 @@ describe('resolveProtocolName', () => { }); }); -describe('createObjectQLAdapter – model name mapping', () => { +describe('AUTH_*_CONFIG schema mappings', () => { + it('should define correct modelName for all core models', () => { + expect(AUTH_USER_CONFIG.modelName).toBe('sys_user'); + expect(AUTH_SESSION_CONFIG.modelName).toBe('sys_session'); + expect(AUTH_ACCOUNT_CONFIG.modelName).toBe('sys_account'); + expect(AUTH_VERIFICATION_CONFIG.modelName).toBe('sys_verification'); + }); + + it('should map user camelCase fields to snake_case', () => { + expect(AUTH_USER_CONFIG.fields).toEqual({ + emailVerified: 'email_verified', + createdAt: 'created_at', + updatedAt: 'updated_at', + }); + }); + + it('should map session camelCase fields to snake_case', () => { + expect(AUTH_SESSION_CONFIG.fields).toEqual({ + userId: 'user_id', + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + ipAddress: 'ip_address', + userAgent: 'user_agent', + }); + }); + + it('should map account camelCase fields to snake_case', () => { + expect(AUTH_ACCOUNT_CONFIG.fields).toEqual({ + userId: 'user_id', + providerId: 'provider_id', + accountId: 'account_id', + accessToken: 'access_token', + refreshToken: 'refresh_token', + idToken: 'id_token', + accessTokenExpiresAt: 'access_token_expires_at', + refreshTokenExpiresAt: 'refresh_token_expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }); + }); + + it('should map verification camelCase fields to snake_case', () => { + expect(AUTH_VERIFICATION_CONFIG.fields).toEqual({ + expiresAt: 'expires_at', + createdAt: 'created_at', + updatedAt: 'updated_at', + }); + }); +}); + +describe('createObjectQLAdapterFactory', () => { + it('should return a function (adapter factory)', () => { + const mockEngine = { + insert: vi.fn(), + findOne: vi.fn(), + find: vi.fn(), + count: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as unknown as IDataEngine; + + const factory = createObjectQLAdapterFactory(mockEngine); + expect(typeof factory).toBe('function'); + }); +}); + +describe('createObjectQLAdapter – legacy model name mapping', () => { let mockEngine: IDataEngine; beforeEach(() => { diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index e90aaa3c0f..fd96e476be 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { IDataEngine } from '@objectstack/core'; +import { createAdapterFactory } from 'better-auth/adapters'; import type { CleanedWhere } from 'better-auth/adapters'; import { SystemObjectName } from '@objectstack/spec/system'; @@ -25,178 +26,254 @@ export function resolveProtocolName(model: string): string { return AUTH_MODEL_TO_PROTOCOL[model] ?? model; } +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + /** - * ObjectQL Adapter for better-auth - * - * Bridges better-auth's database adapter interface with ObjectQL's IDataEngine. - * This allows better-auth to use ObjectQL for data persistence instead of - * third-party ORMs like drizzle-orm. - * - * Model names from better-auth (e.g. 'user') are automatically mapped to - * ObjectStack protocol names (e.g. 'sys_user') via {@link AUTH_MODEL_TO_PROTOCOL}. - * - * @param dataEngine - ObjectQL data engine instance - * @returns better-auth CustomAdapter + * Convert better-auth where clause to ObjectQL query format. + * + * Field names in the incoming {@link CleanedWhere} are expected to already be + * in snake_case (transformed by `createAdapterFactory`). */ -export function createObjectQLAdapter(dataEngine: IDataEngine) { - /** - * Convert better-auth where clause to ObjectQL query format - */ - function convertWhere(where: CleanedWhere[]): Record { - const filter: Record = {}; - - for (const condition of where) { - // Use field names as-is (no conversion needed) - const fieldName = condition.field; - - if (condition.operator === 'eq') { - filter[fieldName] = condition.value; - } else if (condition.operator === 'ne') { - filter[fieldName] = { $ne: condition.value }; - } else if (condition.operator === 'in') { - filter[fieldName] = { $in: condition.value }; - } else if (condition.operator === 'gt') { - filter[fieldName] = { $gt: condition.value }; - } else if (condition.operator === 'gte') { - filter[fieldName] = { $gte: condition.value }; - } else if (condition.operator === 'lt') { - filter[fieldName] = { $lt: condition.value }; - } else if (condition.operator === 'lte') { - filter[fieldName] = { $lte: condition.value }; - } else if (condition.operator === 'contains') { - filter[fieldName] = { $regex: condition.value }; - } +function convertWhere(where: CleanedWhere[]): Record { + const filter: Record = {}; + + for (const condition of where) { + const fieldName = condition.field; + + if (condition.operator === 'eq') { + filter[fieldName] = condition.value; + } else if (condition.operator === 'ne') { + filter[fieldName] = { $ne: condition.value }; + } else if (condition.operator === 'in') { + filter[fieldName] = { $in: condition.value }; + } else if (condition.operator === 'gt') { + filter[fieldName] = { $gt: condition.value }; + } else if (condition.operator === 'gte') { + filter[fieldName] = { $gte: condition.value }; + } else if (condition.operator === 'lt') { + filter[fieldName] = { $lt: condition.value }; + } else if (condition.operator === 'lte') { + filter[fieldName] = { $lte: condition.value }; + } else if (condition.operator === 'contains') { + filter[fieldName] = { $regex: condition.value }; } - - return filter; } + return filter; +} + +// --------------------------------------------------------------------------- +// Adapter factory +// --------------------------------------------------------------------------- + +/** + * Create an ObjectQL adapter **factory** for better-auth. + * + * Uses better-auth's official `createAdapterFactory` so that model-name and + * field-name transformations (declared via `modelName` / `fields` in the + * betterAuth config) are applied **automatically** before any data reaches + * ObjectQL. This eliminates the need for manual camelCase ↔ snake_case + * conversion inside the adapter. + * + * The returned value is an `AdapterFactory` – a function of type + * `(options: BetterAuthOptions) => DBAdapter` – which is the shape expected + * by `betterAuth({ database: … })`. + * + * @param dataEngine - ObjectQL data engine instance + * @returns better-auth AdapterFactory + */ +export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { + return createAdapterFactory({ + config: { + adapterId: 'objectql', + // ObjectQL natively supports these types — no extra conversion needed + supportsBooleans: true, + supportsDates: true, + supportsJSON: true, + }, + adapter: () => ({ + create: async >( + { model, data }: { model: string; data: T; select?: string[] }, + ): Promise => { + const result = await dataEngine.insert(model, data); + return result as T; + }, + + findOne: async ( + { model, where, select }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }, + ): Promise => { + const filter = convertWhere(where); + + // Note: join is not currently supported by ObjectQL's findOne operation + const result = await dataEngine.findOne(model, { filter, select }); + + return result ? (result as T) : null; + }, + + findMany: async ( + { model, where, limit, offset, sortBy }: { + model: string; where?: CleanedWhere[]; limit: number; + offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any; + }, + ): Promise => { + const filter = where ? convertWhere(where) : {}; + + const sort = sortBy + ? [{ field: sortBy.field, order: sortBy.direction as 'asc' | 'desc' }] + : undefined; + + const results = await dataEngine.find(model, { + filter, + limit: limit || 100, + skip: offset, + sort, + }); + + return results as T[]; + }, + + count: async ( + { model, where }: { model: string; where?: CleanedWhere[] }, + ): Promise => { + const filter = where ? convertWhere(where) : {}; + return await dataEngine.count(model, { filter }); + }, + + update: async ( + { model, where, update }: { model: string; where: CleanedWhere[]; update: T }, + ): Promise => { + const filter = convertWhere(where); + + // ObjectQL requires an ID for updates – find the record first + const record = await dataEngine.findOne(model, { filter }); + if (!record) return null; + + const result = await dataEngine.update(model, { ...(update as any), id: record.id }); + return result ? (result as T) : null; + }, + + updateMany: async ( + { model, where, update }: { model: string; where: CleanedWhere[]; update: Record }, + ): Promise => { + const filter = convertWhere(where); + + // Sequential updates: ObjectQL requires an ID per update + const records = await dataEngine.find(model, { filter }); + for (const record of records) { + await dataEngine.update(model, { ...update, id: record.id }); + } + return records.length; + }, + + delete: async ( + { model, where }: { model: string; where: CleanedWhere[] }, + ): Promise => { + const filter = convertWhere(where); + + const record = await dataEngine.findOne(model, { filter }); + if (!record) return; + + await dataEngine.delete(model, { filter: { id: record.id } }); + }, + + deleteMany: async ( + { model, where }: { model: string; where: CleanedWhere[] }, + ): Promise => { + const filter = convertWhere(where); + + const records = await dataEngine.find(model, { filter }); + for (const record of records) { + await dataEngine.delete(model, { filter: { id: record.id } }); + } + return records.length; + }, + }), + }); +} + +// --------------------------------------------------------------------------- +// Legacy adapter (kept for backward compatibility) +// --------------------------------------------------------------------------- + +/** + * Create a raw ObjectQL adapter for better-auth (without factory wrapping). + * + * > **Prefer {@link createObjectQLAdapterFactory}** for production use. + * > The factory version leverages `createAdapterFactory` and automatically + * > handles model-name + field-name transformations declared in the + * > better-auth config. + * + * This function is retained for direct / low-level usage where callers + * manage field-name conversion themselves. + * + * @param dataEngine - ObjectQL data engine instance + * @returns better-auth CustomAdapter (raw, without factory wrapping) + */ +export function createObjectQLAdapter(dataEngine: IDataEngine) { return { create: async >({ model, data, select: _select }: { model: string; data: T; select?: string[] }): Promise => { const objectName = resolveProtocolName(model); - - // Note: select parameter is currently not supported by ObjectQL's insert operation - // The full record is always returned after insertion const result = await dataEngine.insert(objectName, data); return result as T; }, - + findOne: async ({ model, where, select, join: _join }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }): Promise => { const objectName = resolveProtocolName(model); const filter = convertWhere(where); - - // Note: join parameter is not currently supported by ObjectQL's findOne operation - // Joins/populate functionality is planned for future ObjectQL releases - // For now, related data must be fetched separately - - const result = await dataEngine.findOne(objectName, { - filter, - select, - }); - + const result = await dataEngine.findOne(objectName, { filter, select }); return result ? result as T : null; }, - + findMany: async ({ model, where, limit, offset, sortBy, join: _join }: { model: string; where?: CleanedWhere[]; limit: number; offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any }): Promise => { const objectName = resolveProtocolName(model); const filter = where ? convertWhere(where) : {}; - - // Note: join parameter is not currently supported by ObjectQL's find operation - // Joins/populate functionality is planned for future ObjectQL releases - - const sort = sortBy ? [{ - field: sortBy.field, - order: sortBy.direction as 'asc' | 'desc', - }] : undefined; - - const results = await dataEngine.find(objectName, { - filter, - limit: limit || 100, - skip: offset, - sort, - }); - + const sort = sortBy ? [{ field: sortBy.field, order: sortBy.direction as 'asc' | 'desc' }] : undefined; + const results = await dataEngine.find(objectName, { filter, limit: limit || 100, skip: offset, sort }); return results as T[]; }, - + count: async ({ model, where }: { model: string; where?: CleanedWhere[] }): Promise => { const objectName = resolveProtocolName(model); const filter = where ? convertWhere(where) : {}; - return await dataEngine.count(objectName, { filter }); }, - + update: async ({ model, where, update }: { model: string; where: CleanedWhere[]; update: Record }): Promise => { const objectName = resolveProtocolName(model); const filter = convertWhere(where); - - // Find the record first to get its ID const record = await dataEngine.findOne(objectName, { filter }); - if (!record) { - return null; - } - - const result = await dataEngine.update(objectName, { - ...update, - id: record.id, - }); - + if (!record) return null; + const result = await dataEngine.update(objectName, { ...update, id: record.id }); return result ? result as T : null; }, - + updateMany: async ({ model, where, update }: { model: string; where: CleanedWhere[]; update: Record }): Promise => { const objectName = resolveProtocolName(model); const filter = convertWhere(where); - - // Note: Sequential updates are used here because ObjectQL's IDataEngine interface - // requires an ID for updates. A future optimization could use a bulk update - // operation if ObjectQL adds support for filter-based updates without IDs. - - // Find all matching records const records = await dataEngine.find(objectName, { filter }); - - // Update each record for (const record of records) { - await dataEngine.update(objectName, { - ...update, - id: record.id, - }); + await dataEngine.update(objectName, { ...update, id: record.id }); } - return records.length; }, - + delete: async ({ model, where }: { model: string; where: CleanedWhere[] }): Promise => { const objectName = resolveProtocolName(model); const filter = convertWhere(where); - - // Note: We need to find the record first to get its ID because ObjectQL's - // delete operation requires an ID. Direct filter-based delete would be more - // efficient if supported by ObjectQL in the future. const record = await dataEngine.findOne(objectName, { filter }); - if (!record) { - return; - } - + if (!record) return; await dataEngine.delete(objectName, { filter: { id: record.id } }); }, - + deleteMany: async ({ model, where }: { model: string; where: CleanedWhere[] }): Promise => { const objectName = resolveProtocolName(model); const filter = convertWhere(where); - - // Note: Sequential deletes are used here because ObjectQL's delete operation - // requires an ID in the filter. A future optimization could use a single - // delete call with the original filter if ObjectQL supports it. - - // Find all matching records const records = await dataEngine.find(objectName, { filter }); - - // Delete each record for (const record of records) { await dataEngine.delete(objectName, { filter: { id: record.id } }); } - return records.length; }, }; From 2356af48a6b20a940b4b166da2b6aa3582b9d51b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:54:28 +0000 Subject: [PATCH 3/5] fix: address review - prefix unused params with underscore and update ROADMAP Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 1 + packages/plugins/plugin-auth/src/objectql-adapter.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4eaa1cc32..b88c506145 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -329,6 +329,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow **Migration (v3.x → v4.0):** - 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. - 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. +- 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. - 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. - 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. - v4.0: Legacy un-prefixed aliases will be fully removed. diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.ts b/packages/plugins/plugin-auth/src/objectql-adapter.ts index fd96e476be..073c825e2b 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.ts @@ -95,25 +95,24 @@ export function createObjectQLAdapterFactory(dataEngine: IDataEngine) { }, adapter: () => ({ create: async >( - { model, data }: { model: string; data: T; select?: string[] }, + { model, data, select: _select }: { model: string; data: T; select?: string[] }, ): Promise => { const result = await dataEngine.insert(model, data); return result as T; }, findOne: async ( - { model, where, select }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }, + { model, where, select, join: _join }: { model: string; where: CleanedWhere[]; select?: string[]; join?: any }, ): Promise => { const filter = convertWhere(where); - // Note: join is not currently supported by ObjectQL's findOne operation const result = await dataEngine.findOne(model, { filter, select }); return result ? (result as T) : null; }, findMany: async ( - { model, where, limit, offset, sortBy }: { + { model, where, limit, offset, sortBy, join: _join }: { model: string; where?: CleanedWhere[]; limit: number; offset?: number; sortBy?: { field: string; direction: 'asc' | 'desc' }; join?: any; }, From 4930f6e0e0f65a6c0705f3c58f6dbd9314fc3d95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:28:14 +0000 Subject: [PATCH 4/5] 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> --- packages/plugins/plugin-auth/README.md | 19 +- .../plugin-auth/src/auth-manager.test.ts | 127 +++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 48 +++++ .../plugin-auth/src/auth-schema-config.ts | 204 ++++++++++++++++++ .../plugin-auth/src/objectql-adapter.test.ts | 94 ++++++++ 5 files changed, 491 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-auth/README.md b/packages/plugins/plugin-auth/README.md index 7d171c9d5e..00ee80d6f3 100644 --- a/packages/plugins/plugin-auth/README.md +++ b/packages/plugins/plugin-auth/README.md @@ -215,11 +215,23 @@ export const AuthUser = ObjectSchema.create({ **Database Objects:** Uses ObjectStack `sys_` prefixed protocol names with snake_case field naming. The adapter automatically maps better-auth model names to protocol names: + +*Core models:* - `sys_user` (← better-auth `user`) - User accounts (id, email, name, email_verified, created_at, etc.) - `sys_session` (← better-auth `session`) - Active sessions (id, token, user_id, expires_at, ip_address, etc.) - `sys_account` (← better-auth `account`) - OAuth provider accounts (id, provider_id, account_id, user_id, tokens, etc.) - `sys_verification` (← better-auth `verification`) - Verification tokens (id, value, identifier, expires_at, etc.) +*Organization plugin (when `plugins.organization: true`):* +- `sys_organization` (← `organization`) - Organizations (id, name, slug, logo, created_at, etc.) +- `sys_member` (← `member`) - Organization members (id, organization_id, user_id, role, created_at) +- `sys_invitation` (← `invitation`) - Invitations (id, organization_id, inviter_id, email, role, expires_at, etc.) +- `sys_team` (← `team`) - Teams (id, name, organization_id, created_at, etc.) +- `sys_team_member` (← `teamMember`) - Team members (id, team_id, user_id, created_at) + +*Two-Factor plugin (when `plugins.twoFactor: true`):* +- `sys_two_factor` (← `twoFactor`) - 2FA secrets (id, secret, backup_codes, user_id) + **Schema Mapping (modelName + fields):** better-auth uses camelCase field names internally (`emailVerified`, `userId`, `createdAt`, etc.) @@ -237,6 +249,8 @@ import { AUTH_SESSION_CONFIG, AUTH_ACCOUNT_CONFIG, AUTH_VERIFICATION_CONFIG, + buildOrganizationPluginSchema, + buildTwoFactorPluginSchema, } from '@objectstack/plugin-auth'; // Applied to the betterAuth() config: @@ -246,7 +260,10 @@ const auth = betterAuth({ session: { ...AUTH_SESSION_CONFIG, expiresIn: 604800 }, account: { ...AUTH_ACCOUNT_CONFIG }, verification: { ...AUTH_VERIFICATION_CONFIG }, - // ... + plugins: [ + organization({ schema: buildOrganizationPluginSchema() }), + twoFactor({ schema: buildTwoFactorPluginSchema() }), + ], }); ``` diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index bafda5b0b3..1d17e9b82a 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -11,6 +11,20 @@ vi.mock('better-auth', () => ({ })), })); +// Mock plugin imports — we only need to verify they are called with the +// correct schema options; the actual plugin logic is tested by better-auth. +vi.mock('better-auth/plugins/organization', () => ({ + organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })), +})); + +vi.mock('better-auth/plugins/two-factor', () => ({ + twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })), +})); + +vi.mock('better-auth/plugins/magic-link', () => ({ + magicLink: vi.fn((_opts?: any) => ({ id: 'magic-link' })), +})); + import { betterAuth } from 'better-auth'; describe('AuthManager', () => { @@ -244,4 +258,117 @@ describe('AuthManager', () => { warnSpy.mockRestore(); }); }); + + describe('plugin registration', () => { + it('should not include any plugins when no plugin config is provided', () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + }); + manager.getAuthInstance(); + warnSpy.mockRestore(); + + expect(capturedConfig.plugins).toEqual([]); + }); + + it('should register organization plugin with schema mapping when enabled', () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + }); + manager.getAuthInstance(); + warnSpy.mockRestore(); + + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + expect(orgPlugin).toBeDefined(); + // Verify schema was passed to organization() call + expect(orgPlugin._opts.schema.organization.modelName).toBe('sys_organization'); + expect(orgPlugin._opts.schema.member.modelName).toBe('sys_member'); + expect(orgPlugin._opts.schema.invitation.modelName).toBe('sys_invitation'); + expect(orgPlugin._opts.schema.team.modelName).toBe('sys_team'); + expect(orgPlugin._opts.schema.teamMember.modelName).toBe('sys_team_member'); + expect(orgPlugin._opts.schema.session.fields.activeOrganizationId).toBe('active_organization_id'); + }); + + it('should register twoFactor plugin with schema mapping when enabled', () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { twoFactor: true }, + }); + manager.getAuthInstance(); + warnSpy.mockRestore(); + + const tfPlugin = capturedConfig.plugins.find((p: any) => p.id === 'two-factor'); + expect(tfPlugin).toBeDefined(); + expect(tfPlugin._opts.schema.twoFactor.modelName).toBe('sys_two_factor'); + expect(tfPlugin._opts.schema.twoFactor.fields.backupCodes).toBe('backup_codes'); + expect(tfPlugin._opts.schema.twoFactor.fields.userId).toBe('user_id'); + expect(tfPlugin._opts.schema.user.fields.twoFactorEnabled).toBe('two_factor_enabled'); + }); + + it('should register magicLink plugin when enabled', () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { magicLink: true }, + }); + manager.getAuthInstance(); + warnSpy.mockRestore(); + + const mlPlugin = capturedConfig.plugins.find((p: any) => p.id === 'magic-link'); + expect(mlPlugin).toBeDefined(); + }); + + it('should register multiple plugins when multiple flags are enabled', () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true, twoFactor: true, magicLink: true }, + }); + manager.getAuthInstance(); + warnSpy.mockRestore(); + + expect(capturedConfig.plugins).toHaveLength(3); + expect(capturedConfig.plugins.map((p: any) => p.id).sort()).toEqual( + ['magic-link', 'organization', 'two-factor'], + ); + }); + }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 5fe0666819..6520036376 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2,6 +2,9 @@ import { betterAuth } from 'better-auth'; import type { Auth, BetterAuthOptions } from 'better-auth'; +import { organization } from 'better-auth/plugins/organization'; +import { twoFactor } from 'better-auth/plugins/two-factor'; +import { magicLink } from 'better-auth/plugins/magic-link'; import type { AuthConfig } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; import { createObjectQLAdapterFactory } from './objectql-adapter.js'; @@ -10,6 +13,8 @@ import { AUTH_SESSION_CONFIG, AUTH_ACCOUNT_CONFIG, AUTH_VERIFICATION_CONFIG, + buildOrganizationPluginSchema, + buildTwoFactorPluginSchema, } from './auth-schema-config.js'; /** @@ -104,11 +109,54 @@ export class AuthManager { expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default }, + + // better-auth plugins — registered based on AuthPluginConfig flags + plugins: this.buildPluginList(), }; return betterAuth(betterAuthConfig); } + /** + * Build the list of better-auth plugins based on AuthPluginConfig flags. + * + * Each plugin that introduces its own database tables is configured with + * a `schema` option containing the appropriate snake_case field mappings, + * so that `createAdapterFactory` transforms them automatically. + */ + private buildPluginList(): any[] { + const pluginConfig = this.config.plugins; + const plugins: any[] = []; + + if (pluginConfig?.organization) { + plugins.push(organization({ + schema: buildOrganizationPluginSchema(), + })); + } + + if (pluginConfig?.twoFactor) { + plugins.push(twoFactor({ + schema: buildTwoFactorPluginSchema(), + })); + } + + if (pluginConfig?.magicLink) { + // magic-link reuses the `verification` table — no extra schema mapping needed. + // The sendMagicLink callback must be provided by the application at a higher level. + // Here we provide a no-op default that logs a warning; real applications should + // override this via AuthManagerOptions or a config extension point. + plugins.push(magicLink({ + sendMagicLink: async ({ email, url }) => { + console.warn( + `[AuthManager] Magic-link requested for ${email} but no sendMagicLink handler configured. URL: ${url}`, + ); + }, + })); + } + + return plugins; + } + /** * Create database configuration using ObjectQL adapter * diff --git a/packages/plugins/plugin-auth/src/auth-schema-config.ts b/packages/plugins/plugin-auth/src/auth-schema-config.ts index b6066dce0b..ca99f7387c 100644 --- a/packages/plugins/plugin-auth/src/auth-schema-config.ts +++ b/packages/plugins/plugin-auth/src/auth-schema-config.ts @@ -133,3 +133,207 @@ export const AUTH_VERIFICATION_CONFIG = { updatedAt: 'updated_at', }, } as const; + +// =========================================================================== +// Plugin Table Mappings +// =========================================================================== +// +// better-auth plugins (organization, two-factor, etc.) introduce additional +// tables with their own camelCase field names. The mappings below are passed +// to the plugin's `schema` option so that `createAdapterFactory` transforms +// them to snake_case automatically, just like the core models above. +// =========================================================================== + +// --------------------------------------------------------------------------- +// Organization plugin – organization table +// --------------------------------------------------------------------------- + +/** + * better-auth Organization plugin `organization` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | createdAt | created_at | + * | updatedAt | updated_at | + */ +export const AUTH_ORGANIZATION_SCHEMA = { + modelName: 'sys_organization', + fields: { + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +} as const; + +// --------------------------------------------------------------------------- +// Organization plugin – member table +// --------------------------------------------------------------------------- + +/** + * better-auth Organization plugin `member` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | organizationId | organization_id | + * | userId | user_id | + * | createdAt | created_at | + */ +export const AUTH_MEMBER_SCHEMA = { + modelName: 'sys_member', + fields: { + organizationId: 'organization_id', + userId: 'user_id', + createdAt: 'created_at', + }, +} as const; + +// --------------------------------------------------------------------------- +// Organization plugin – invitation table +// --------------------------------------------------------------------------- + +/** + * better-auth Organization plugin `invitation` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | organizationId | organization_id | + * | inviterId | inviter_id | + * | expiresAt | expires_at | + * | createdAt | created_at | + * | teamId | team_id | + */ +export const AUTH_INVITATION_SCHEMA = { + modelName: 'sys_invitation', + fields: { + organizationId: 'organization_id', + inviterId: 'inviter_id', + expiresAt: 'expires_at', + createdAt: 'created_at', + teamId: 'team_id', + }, +} as const; + +// --------------------------------------------------------------------------- +// Organization plugin – session additional fields +// --------------------------------------------------------------------------- + +/** + * Organization plugin adds `activeOrganizationId` (and optionally + * `activeTeamId`) to the session model. These field mappings are + * injected via the organization plugin's `schema.session.fields`. + */ +export const AUTH_ORG_SESSION_FIELDS = { + activeOrganizationId: 'active_organization_id', + activeTeamId: 'active_team_id', +} as const; + +// --------------------------------------------------------------------------- +// Organization plugin – team table (optional, when teams enabled) +// --------------------------------------------------------------------------- + +/** + * better-auth Organization plugin `team` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | organizationId | organization_id | + * | createdAt | created_at | + * | updatedAt | updated_at | + */ +export const AUTH_TEAM_SCHEMA = { + modelName: 'sys_team', + fields: { + organizationId: 'organization_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, +} as const; + +// --------------------------------------------------------------------------- +// Organization plugin – teamMember table (optional, when teams enabled) +// --------------------------------------------------------------------------- + +/** + * better-auth Organization plugin `teamMember` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | teamId | team_id | + * | userId | user_id | + * | createdAt | created_at | + */ +export const AUTH_TEAM_MEMBER_SCHEMA = { + modelName: 'sys_team_member', + fields: { + teamId: 'team_id', + userId: 'user_id', + createdAt: 'created_at', + }, +} as const; + +// --------------------------------------------------------------------------- +// Two-Factor plugin – twoFactor table +// --------------------------------------------------------------------------- + +/** + * better-auth Two-Factor plugin `twoFactor` model mapping. + * + * | camelCase (better-auth) | snake_case (ObjectStack) | + * |:------------------------|:-------------------------| + * | backupCodes | backup_codes | + * | userId | user_id | + */ +export const AUTH_TWO_FACTOR_SCHEMA = { + modelName: 'sys_two_factor', + fields: { + backupCodes: 'backup_codes', + userId: 'user_id', + }, +} as const; + +/** + * Two-Factor plugin adds a `twoFactorEnabled` field to the user model. + */ +export const AUTH_TWO_FACTOR_USER_FIELDS = { + twoFactorEnabled: 'two_factor_enabled', +} as const; + +/** + * Builds the `schema` option for better-auth's `twoFactor()` plugin. + * + * @returns An object suitable for `twoFactor({ schema: … })` + */ +export function buildTwoFactorPluginSchema() { + return { + twoFactor: AUTH_TWO_FACTOR_SCHEMA, + user: { + fields: AUTH_TWO_FACTOR_USER_FIELDS, + }, + }; +} + +// --------------------------------------------------------------------------- +// Helper: build organization plugin schema option +// --------------------------------------------------------------------------- + +/** + * Builds the `schema` option for better-auth's `organization()` plugin. + * + * The organization plugin accepts a `schema` sub-option that allows + * customising model names and field names for each table it manages. + * This helper assembles the correct snake_case mappings from the + * individual `AUTH_*_SCHEMA` constants above. + * + * @returns An object suitable for `organization({ schema: … })` + */ +export function buildOrganizationPluginSchema() { + return { + organization: AUTH_ORGANIZATION_SCHEMA, + member: AUTH_MEMBER_SCHEMA, + invitation: AUTH_INVITATION_SCHEMA, + team: AUTH_TEAM_SCHEMA, + teamMember: AUTH_TEAM_MEMBER_SCHEMA, + session: { + fields: AUTH_ORG_SESSION_FIELDS, + }, + }; +} diff --git a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts index 7847b7428b..0dbe419cd6 100644 --- a/packages/plugins/plugin-auth/src/objectql-adapter.test.ts +++ b/packages/plugins/plugin-auth/src/objectql-adapter.test.ts @@ -12,6 +12,15 @@ import { AUTH_SESSION_CONFIG, AUTH_ACCOUNT_CONFIG, AUTH_VERIFICATION_CONFIG, + AUTH_ORGANIZATION_SCHEMA, + AUTH_MEMBER_SCHEMA, + AUTH_INVITATION_SCHEMA, + AUTH_TEAM_SCHEMA, + AUTH_TEAM_MEMBER_SCHEMA, + AUTH_TWO_FACTOR_SCHEMA, + AUTH_ORG_SESSION_FIELDS, + buildOrganizationPluginSchema, + buildTwoFactorPluginSchema, } from './auth-schema-config'; import { SystemObjectName } from '@objectstack/spec/system'; import type { IDataEngine } from '@objectstack/core'; @@ -97,6 +106,91 @@ describe('AUTH_*_CONFIG schema mappings', () => { }); }); +describe('AUTH_*_SCHEMA plugin table mappings', () => { + it('should define organization model mapping', () => { + expect(AUTH_ORGANIZATION_SCHEMA.modelName).toBe('sys_organization'); + expect(AUTH_ORGANIZATION_SCHEMA.fields).toEqual({ + createdAt: 'created_at', + updatedAt: 'updated_at', + }); + }); + + it('should define member model mapping', () => { + expect(AUTH_MEMBER_SCHEMA.modelName).toBe('sys_member'); + expect(AUTH_MEMBER_SCHEMA.fields).toEqual({ + organizationId: 'organization_id', + userId: 'user_id', + createdAt: 'created_at', + }); + }); + + it('should define invitation model mapping', () => { + expect(AUTH_INVITATION_SCHEMA.modelName).toBe('sys_invitation'); + expect(AUTH_INVITATION_SCHEMA.fields).toEqual({ + organizationId: 'organization_id', + inviterId: 'inviter_id', + expiresAt: 'expires_at', + createdAt: 'created_at', + teamId: 'team_id', + }); + }); + + it('should define team model mapping', () => { + expect(AUTH_TEAM_SCHEMA.modelName).toBe('sys_team'); + expect(AUTH_TEAM_SCHEMA.fields).toEqual({ + organizationId: 'organization_id', + createdAt: 'created_at', + updatedAt: 'updated_at', + }); + }); + + it('should define team member model mapping', () => { + expect(AUTH_TEAM_MEMBER_SCHEMA.modelName).toBe('sys_team_member'); + expect(AUTH_TEAM_MEMBER_SCHEMA.fields).toEqual({ + teamId: 'team_id', + userId: 'user_id', + createdAt: 'created_at', + }); + }); + + it('should define two-factor model mapping', () => { + expect(AUTH_TWO_FACTOR_SCHEMA.modelName).toBe('sys_two_factor'); + expect(AUTH_TWO_FACTOR_SCHEMA.fields).toEqual({ + backupCodes: 'backup_codes', + userId: 'user_id', + }); + }); + + it('should define org session additional fields', () => { + expect(AUTH_ORG_SESSION_FIELDS).toEqual({ + activeOrganizationId: 'active_organization_id', + activeTeamId: 'active_team_id', + }); + }); +}); + +describe('buildOrganizationPluginSchema', () => { + it('should compose all org plugin table schemas', () => { + const schema = buildOrganizationPluginSchema(); + expect(schema.organization).toBe(AUTH_ORGANIZATION_SCHEMA); + expect(schema.member).toBe(AUTH_MEMBER_SCHEMA); + expect(schema.invitation).toBe(AUTH_INVITATION_SCHEMA); + expect(schema.team).toBe(AUTH_TEAM_SCHEMA); + expect(schema.teamMember).toBe(AUTH_TEAM_MEMBER_SCHEMA); + expect(schema.session.fields).toBe(AUTH_ORG_SESSION_FIELDS); + }); +}); + +describe('buildTwoFactorPluginSchema', () => { + it('should compose two-factor model + user field schema', () => { + const schema = buildTwoFactorPluginSchema(); + expect(schema.twoFactor).toBe(AUTH_TWO_FACTOR_SCHEMA); + expect(schema.user.fields).toEqual({ + twoFactorEnabled: 'two_factor_enabled', + }); + }); +}); + describe('createObjectQLAdapterFactory', () => { it('should return a function (adapter factory)', () => { const mockEngine = { From 6ec0c990ef5ed0caa79d3669d5ea663c51ac2192 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:30:16 +0000 Subject: [PATCH 5/5] fix: remove trailing whitespace in auth-manager.ts Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../plugins/plugin-auth/src/auth-manager.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 6520036376..7cd1d87b40 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -26,7 +26,7 @@ export interface AuthManagerOptions extends Partial { * If not provided, one will be created from config */ authInstance?: Auth; - + /** * ObjectQL Data Engine instance * Required for database operations using ObjectQL instead of third-party ORMs @@ -36,7 +36,7 @@ export interface AuthManagerOptions extends Partial { /** * Authentication Manager - * + * * Wraps better-auth and provides authentication services for ObjectStack. * Supports multiple authentication methods: * - Email/password @@ -52,7 +52,7 @@ export class AuthManager { constructor(config: AuthManagerOptions) { this.config = config; - + // Use provided auth instance if (config.authInstance) { this.auth = config.authInstance; @@ -80,10 +80,10 @@ export class AuthManager { secret: this.config.secret || this.generateSecret(), baseURL: this.config.baseUrl || 'http://localhost:3000', basePath: '/', // ← 关键修复!告诉 better-auth 路径已被剥离 - + // Database adapter configuration database: this.createDatabaseConfig(), - + // Model/field mapping: camelCase (better-auth) → snake_case (ObjectStack) // These declarations tell better-auth the actual table/column names used // by ObjectStack's protocol layer, enabling automatic transformation via @@ -97,19 +97,19 @@ export class AuthManager { verification: { ...AUTH_VERIFICATION_CONFIG, }, - + // Email configuration emailAndPassword: { enabled: true, }, - + // Session configuration session: { ...AUTH_SESSION_CONFIG, expiresIn: this.config.session?.expiresIn || 60 * 60 * 24 * 7, // 7 days default updateAge: this.config.session?.updateAge || 60 * 60 * 24, // 1 day default }, - + // better-auth plugins — registered based on AuthPluginConfig flags plugins: this.buildPluginList(), }; @@ -127,13 +127,13 @@ export class AuthManager { private buildPluginList(): any[] { const pluginConfig = this.config.plugins; const plugins: any[] = []; - + if (pluginConfig?.organization) { plugins.push(organization({ schema: buildOrganizationPluginSchema(), })); } - + if (pluginConfig?.twoFactor) { plugins.push(twoFactor({ schema: buildTwoFactorPluginSchema(), @@ -153,7 +153,7 @@ export class AuthManager { }, })); } - + return plugins; } @@ -179,14 +179,14 @@ export class AuthManager { // the betterAuth config above. return createObjectQLAdapterFactory(this.config.dataEngine); } - + // Fallback warning if no dataEngine is provided console.warn( '⚠️ WARNING: No dataEngine provided to AuthManager! ' + 'Using in-memory storage. This is NOT suitable for production. ' + 'Please provide a dataEngine instance (e.g., ObjectQL) in AuthManagerOptions.' ); - + // Return a minimal in-memory configuration as fallback // This allows the system to work in development/testing without a real database return undefined; // better-auth will use its default in-memory adapter @@ -197,22 +197,22 @@ export class AuthManager { */ private generateSecret(): string { const envSecret = process.env.AUTH_SECRET; - + if (!envSecret) { // In production, a secret MUST be provided // For development/testing, we'll use a fallback but warn about it const fallbackSecret = 'dev-secret-' + Date.now(); - + console.warn( '⚠️ WARNING: No AUTH_SECRET environment variable set! ' + 'Using a temporary development secret. ' + 'This is NOT secure for production use. ' + 'Please set AUTH_SECRET in your environment variables.' ); - + return fallbackSecret; } - + return envSecret; } @@ -231,7 +231,7 @@ export class AuthManager { * better-auth catches internal errors (database / adapter / ORM) and * returns a 500 Response instead of throwing. We therefore inspect the * response status and log server errors so they are not silently swallowed. - * + * * @param request - Web standard Request object * @returns Web standard Response object */