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 number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
- 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: **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
33 changes: 21 additions & 12 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,26 @@ export default class Serve extends Command {
}
}


// Add HTTP server plugin BEFORE config plugins so that the
// http-server service is available for any plugin that needs it
// during init/start (e.g. AuthPlugin).
// Skip if config already contains a HonoServerPlugin to avoid
// duplicate registration.
const configHasHonoServer = plugins.some(
(p: any) => p.name === 'com.objectstack.server.hono' || p.constructor?.name === 'HonoServerPlugin'
);

if (flags.server && !configHasHonoServer) {
try {
const { HonoServerPlugin } = await import('@objectstack/plugin-hono-server');
const serverPlugin = new HonoServerPlugin({ port });
await kernel.use(serverPlugin);
trackPlugin('HonoServer');
} catch (e: any) {
console.warn(chalk.yellow(` ⚠ HTTP server plugin not available: ${e.message}`));
}
}

if (plugins.length > 0) {
for (const plugin of plugins) {
try {
Expand Down Expand Up @@ -236,18 +255,8 @@ export default class Serve extends Command {
}
}

// Add HTTP server plugin if not disabled
// Register REST API and Dispatcher plugins (consume http.server + protocol services)
if (flags.server) {
try {
const { HonoServerPlugin } = await import('@objectstack/plugin-hono-server');
const serverPlugin = new HonoServerPlugin({ port });
await kernel.use(serverPlugin);
trackPlugin('HonoServer');
} catch (e: any) {
console.warn(chalk.yellow(` ⚠ HTTP server plugin not available: ${e.message}`));
}

// Register REST API plugin (consumes http.server + protocol services)
try {
const { createRestApiPlugin } = await import('@objectstack/rest');
await kernel.use(createRestApiPlugin());
Expand Down
44 changes: 42 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ describe('AuthPlugin', () => {
let mockContext: PluginContext;
let authPlugin: AuthPlugin;

/** Shared hook capture utilities for tests that need kernel:ready simulation */
const createHookCapture = () => {
const handlers = new Map<string, Array<(...args: any[]) => Promise<void>>>();
const hookFn = vi.fn((name: string, handler: (...args: any[]) => Promise<void>) => {
if (!handlers.has(name)) handlers.set(name, []);
handlers.get(name)!.push(handler);
});
const trigger = async (name: string) => {
for (const h of handlers.get(name) || []) await h();
};
return { handlers, hookFn, trigger };
};

beforeEach(() => {
mockContext = {
registerService: vi.fn(),
Expand Down Expand Up @@ -98,15 +111,26 @@ describe('AuthPlugin', () => {
});

describe('Start Phase', () => {
let hookCapture: ReturnType<typeof createHookCapture>;

beforeEach(async () => {
hookCapture = createHookCapture();
authPlugin = new AuthPlugin({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
});
// Capture hook registrations so we can trigger them in tests
mockContext.hook = hookCapture.hookFn;
await authPlugin.init(mockContext);
});

it('should register routes with HTTP server when enabled', async () => {
it('should register a kernel:ready hook for route registration', async () => {
await authPlugin.start(mockContext);

expect(mockContext.hook).toHaveBeenCalledWith('kernel:ready', expect.any(Function));
});

it('should register routes with HTTP server on kernel:ready', async () => {
const mockRawApp = {
all: vi.fn(),
};
Expand All @@ -128,6 +152,12 @@ describe('AuthPlugin', () => {

await authPlugin.start(mockContext);

// Routes should NOT be registered yet (deferred to kernel:ready)
expect(mockRawApp.all).not.toHaveBeenCalled();

// Simulate kernel:ready
await hookCapture.trigger('kernel:ready');

expect(mockContext.getService).toHaveBeenCalledWith('http-server');
expect(mockHttpServer.getRawApp).toHaveBeenCalled();
expect(mockRawApp.all).toHaveBeenCalledWith('/api/v1/auth/*', expect.any(Function));
Expand Down Expand Up @@ -157,6 +187,7 @@ describe('AuthPlugin', () => {
});

await authPlugin.start(mockContext);
await hookCapture.trigger('kernel:ready');

// Extract the registered route handler
const routeHandler = mockRawApp.all.mock.calls[0][1];
Expand Down Expand Up @@ -201,13 +232,15 @@ describe('AuthPlugin', () => {
await authPlugin.init(mockContext);
await authPlugin.start(mockContext);

expect(mockContext.getService).not.toHaveBeenCalledWith('http-server');
// Should not register kernel:ready hook for routes
expect(mockContext.hook).not.toHaveBeenCalledWith('kernel:ready', expect.any(Function));
});

it('should gracefully skip routes when http-server is not available', async () => {
mockContext.getService = vi.fn(() => null);

await authPlugin.start(mockContext);
await hookCapture.trigger('kernel:ready');

expect(mockContext.getService).toHaveBeenCalledWith('http-server');
expect(mockContext.logger.warn).toHaveBeenCalledWith(
Expand All @@ -222,6 +255,7 @@ describe('AuthPlugin', () => {
});

await authPlugin.start(mockContext);
await hookCapture.trigger('kernel:ready');

expect(mockContext.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('No HTTP server available')
Expand Down Expand Up @@ -258,6 +292,9 @@ describe('AuthPlugin', () => {

describe('Configuration Options', () => {
it('should use custom base path', async () => {
const { hookFn, trigger } = createHookCapture();
mockContext.hook = hookFn;

authPlugin = new AuthPlugin({
secret: 'test-secret-at-least-32-chars-long',
baseUrl: 'http://localhost:3000',
Expand All @@ -284,6 +321,9 @@ describe('AuthPlugin', () => {

await authPlugin.start(mockContext);

// Trigger kernel:ready to actually register routes
await trigger('kernel:ready');

expect(mockRawApp.all).toHaveBeenCalledWith(
'/custom/auth/*',
expect.any(Function)
Expand Down
40 changes: 23 additions & 17 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,25 +99,31 @@ export class AuthPlugin implements Plugin {
throw new Error('Auth manager not initialized');
}

// Register HTTP routes if enabled and HTTP server is available
// Defer HTTP route registration to kernel:ready hook.
// This ensures all plugins (including HonoServerPlugin) have completed
// their init and start phases before we attempt to look up the
// http-server service — making AuthPlugin resilient to plugin
// loading order.
if (this.options.registerRoutes) {
let httpServer: IHttpServer | null = null;
try {
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found — expected in MSW/mock mode
}
ctx.hook('kernel:ready', async () => {
let httpServer: IHttpServer | null = null;
try {
httpServer = ctx.getService<IHttpServer>('http-server');
} catch {
// Service not found — expected in MSW/mock mode
}

if (httpServer) {
// Route registration errors should propagate (server misconfiguration)
this.registerAuthRoutes(httpServer, ctx);
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
} else {
ctx.logger.warn(
'No HTTP server available — auth routes not registered. ' +
'Auth service is still available for MSW/mock environments via HttpDispatcher.'
);
}
if (httpServer) {
// Route registration errors should propagate (server misconfiguration)
this.registerAuthRoutes(httpServer, ctx);
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
} else {
ctx.logger.warn(
'No HTTP server available — auth routes not registered. ' +
'Auth service is still available for MSW/mock environments via HttpDispatcher.'
);
}
});
}

// Register auth middleware on ObjectQL engine (if available)
Expand Down