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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
15 changes: 14 additions & 1 deletion content/docs/guides/authentication.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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**:
Comment on lines +586 to +587

CopilotAIMar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence says better-auth uses “camelCase model and field names”, but the model names shown (user, session, etc.) are not camelCase—only the field names are. Adjust wording to avoid confusion (e.g., “better-auth uses fixed model names like user and camelCase field names like emailVerified / userId”).

Copilot uses AI. Check for mistakes.

```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`
Expand Down
75 changes: 56 additions & 19 deletions packages/plugins/plugin-auth/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -215,38 +215,75 @@ 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.)

**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`):
*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)

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

**Schema Mapping (modelName + fields):**

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,
buildOrganizationPluginSchema,
buildTwoFactorPluginSchema,
} 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 },
Comment on lines +245 to +260

CopilotAIMar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the README example, createObjectQLAdapterFactory(dataEngine) is used but not imported. This snippet won’t compile as-is; include createObjectQLAdapterFactory in the import list (or show a separate import) so readers can copy/paste successfully.

Copilot uses AI. Check for mistakes.
account: { ...AUTH_ACCOUNT_CONFIG },
verification: { ...AUTH_VERIFICATION_CONFIG },
plugins: [
organization({ schema: buildOrganizationPluginSchema() }),
twoFactor({ schema: buildTwoFactorPluginSchema() }),
],
});
```

> **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

Expand Down
211 changes: 196 additions & 15 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -107,7 +121,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(),
Expand All@@ -128,7 +142,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' }),
Expand All@@ -153,21 +167,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,
});

// 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');
manager.getAuthInstance();

// 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', () => {
Expand All@@ -190,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'],
);
});
});
});
Loading