diff --git a/ROADMAP.md b/ROADMAP.md index 65aef3f617..b65335187e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. --- diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index c8a5bfb177..0f6fd1c79e 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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 { @@ -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()); diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 54ac2386ec..3aa3e1bad3 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -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 Promise>>(); + const hookFn = vi.fn((name: string, handler: (...args: any[]) => Promise) => { + 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(), @@ -98,15 +111,26 @@ describe('AuthPlugin', () => { }); describe('Start Phase', () => { + let hookCapture: ReturnType; + 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(), }; @@ -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)); @@ -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]; @@ -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( @@ -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') @@ -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', @@ -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) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 3369c1b3f6..fb1910567a 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -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('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('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)