diff --git a/ROADMAP.md b/ROADMAP.md index 10b65dc764..23c4765f00 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,6 +49,7 @@ the ecosystem for enterprise workloads. | Client SDK (TypeScript) | ✅ | `@objectstack/client` | | React Hooks | ✅ | `@objectstack/client-react` | | Authentication (better-auth) | ✅ | `@objectstack/plugin-auth` | +| Auth in MSW/Mock Mode | ✅ | `@objectstack/plugin-auth` + `@objectstack/runtime` | | RBAC / RLS / FLS Security | ✅ | `@objectstack/plugin-security` | | CLI (16 commands) | ✅ | `@objectstack/cli` | | Dev Mode Plugin | ✅ | `@objectstack/plugin-dev` | diff --git a/content/docs/guides/authentication.mdx b/content/docs/guides/authentication.mdx index fe22d97bc5..78e16fb3a3 100644 --- a/content/docs/guides/authentication.mdx +++ b/content/docs/guides/authentication.mdx @@ -18,6 +18,7 @@ Complete guide to implementing authentication in ObjectStack applications using 7. [Client Integration](#client-integration) 8. [API Reference](#api-reference) 9. [Best Practices](#best-practices) +10. [MSW/Mock Mode](#mswmock-mode) --- @@ -89,7 +90,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; const kernel = new ObjectKernel({ plugins: [ - // HTTP server (required for auth routes) + // HTTP server (optional — auth works without it in MSW/mock mode) new HonoServerPlugin({ port: 3000, }), @@ -107,6 +108,10 @@ await kernel.start(); That's it! Your authentication endpoints are now available at `/api/v1/auth/*`. +> **MSW/Mock Mode:** AuthPlugin does **not** require an HTTP server. When HonoServerPlugin +> is absent, the plugin gracefully skips route registration and still registers the `auth` +> service. See [MSW/Mock Mode](#mswmock-mode) below for details. + ### 3. ObjectQL Data Persistence The plugin automatically uses ObjectQL for data persistence. No additional database configuration is required - it works with your existing ObjectQL setup. @@ -586,6 +591,73 @@ better-auth internally uses model names like `user` and `session`. The ObjectQL --- +## MSW/Mock Mode + +AuthPlugin is designed to work in **both** server and MSW/mock (browser-only) environments. This means you can develop and test authentication flows without running a real HTTP server. + +### How It Works + +- **Server mode** (HonoServerPlugin active): AuthPlugin registers HTTP routes at `/api/v1/auth/*` and forwards all requests to better-auth. +- **MSW/mock mode** (no HTTP server): AuthPlugin gracefully skips route registration but still registers the `auth` service. The `HttpDispatcher` provides mock fallback responses for core auth endpoints. + +### Minimal Configuration for Mock Mode + +```typescript +import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { AuthPlugin } from '@objectstack/plugin-auth'; + +const kernel = new ObjectKernel(); + +await kernel.use(new ObjectQLPlugin()); +await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory')); + +// AuthPlugin works without HonoServerPlugin — no HTTP server needed +await kernel.use(new AuthPlugin({ + secret: 'INSECURE_DEV_ONLY_mock_secret_do_not_use_in_production', + baseUrl: 'http://localhost:5173', +})); + +await kernel.bootstrap(); +``` + +> ⚠️ **Warning:** The secret above is for **local development only**. In production, always use a strong random secret from an environment variable (`process.env.AUTH_SECRET`). + +### Mock Fallback Endpoints + +When no auth service handler is registered and the legacy broker login is unavailable, `HttpDispatcher.handleAuth()` automatically provides mock responses for: + +| Endpoint | Method | Description | +|:---|:---:|:---| +| `sign-up/email` | POST | Returns mock user + session | +| `sign-in/email` | POST | Returns mock user + session | +| `login` | POST | Legacy login — returns mock user + session | +| `register` | POST | Alias for sign-up | +| `get-session` | GET | Returns `{ session: null, user: null }` | +| `sign-out` | POST | Returns `{ success: true }` | + +This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments. + +> **Note:** In server mode with AuthPlugin loaded, the auth service handler takes priority and the mock fallback is never reached. The mock fallback only activates when AuthPlugin is not loaded (e.g. browser-only Studio builds where `better-auth` is unavailable). + +### Studio Kernel Factory + +The Studio app runs in the browser, where the Node-only `better-auth` library cannot be bundled. Instead of loading `AuthPlugin` directly, Studio relies on `HttpDispatcher`'s built-in mock fallback to handle auth endpoints in MSW mode: + +```typescript +// apps/studio/src/mocks/createKernel.ts +// No AuthPlugin needed — HttpDispatcher provides mock auth endpoints automatically +const kernel = new ObjectKernel(); +await kernel.use(new ObjectQLPlugin()); +await kernel.use(new DriverPlugin(driver, 'memory')); +// ... +await kernel.use(new MSWPlugin({ /* ... */ })); +await kernel.bootstrap(); +``` + +--- + ## Next Steps - See [Security Guide](/docs/guides/security) for authorization and permissions diff --git a/packages/plugins/plugin-auth/src/auth-plugin.test.ts b/packages/plugins/plugin-auth/src/auth-plugin.test.ts index 44e20bae88..f8ee2678c0 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.test.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.test.ts @@ -34,7 +34,7 @@ describe('AuthPlugin', () => { expect(authPlugin.name).toBe('com.objectstack.auth'); expect(authPlugin.type).toBe('standard'); expect(authPlugin.version).toBe('1.0.0'); - expect(authPlugin.dependencies).toContain('com.objectstack.server.hono'); + expect(authPlugin.dependencies).toEqual([]); }); }); @@ -149,6 +149,33 @@ describe('AuthPlugin', () => { expect(mockContext.getService).not.toHaveBeenCalledWith('http-server'); }); + it('should gracefully skip routes when http-server is not available', async () => { + mockContext.getService = vi.fn(() => null); + + await authPlugin.start(mockContext); + + expect(mockContext.getService).toHaveBeenCalledWith('http-server'); + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('No HTTP server available') + ); + // Should NOT throw — auth service is still registered from init() + }); + + it('should gracefully handle http-server getService throwing', async () => { + mockContext.getService = vi.fn(() => { + throw new Error('Service not found: http-server'); + }); + + await authPlugin.start(mockContext); + + expect(mockContext.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('No HTTP server available') + ); + // Auth service should still be registered from init() + expect(mockContext.registerService).toHaveBeenCalledWith('auth', expect.anything()); + // Should NOT throw + }); + it('should throw error if auth not initialized', async () => { const uninitializedPlugin = new AuthPlugin({ secret: 'test-secret', diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 0b827feedb..2bae195c0e 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -27,6 +27,13 @@ export interface AuthPluginOptions extends Partial { * * Provides authentication and identity services for ObjectStack applications. * + * **Dual-Mode Operation:** + * - **Server mode** (HonoServerPlugin active): Registers HTTP routes at basePath, + * forwarding all auth requests to better-auth's universal handler. + * - **MSW/Mock mode** (no HTTP server): Gracefully skips route registration but + * still registers the `auth` service, allowing HttpDispatcher.handleAuth() to + * simulate auth flows (sign-up, sign-in, etc.) for development and testing. + * * Features: * - Session management * - User registration/login @@ -35,8 +42,8 @@ export interface AuthPluginOptions extends Partial { * - 2FA, passkeys, magic links * * This plugin registers: - * - `auth` service (auth manager instance) - * - HTTP routes for authentication endpoints + * - `auth` service (auth manager instance) — always + * - HTTP routes for authentication endpoints — only when HTTP server is available * * Integrates with better-auth library to provide comprehensive * authentication capabilities including email/password, OAuth, 2FA, @@ -46,7 +53,7 @@ export class AuthPlugin implements Plugin { name = 'com.objectstack.auth'; type = 'standard'; version = '1.0.0'; - dependencies = ['com.objectstack.server.hono']; // Requires HTTP server + dependencies: string[] = []; // HTTP server is optional; routes are registered only when available private options: AuthPluginOptions; private authManager: AuthManager | null = null; @@ -92,16 +99,24 @@ export class AuthPlugin implements Plugin { throw new Error('Auth manager not initialized'); } - // Register HTTP routes if enabled + // Register HTTP routes if enabled and HTTP server is available if (this.options.registerRoutes) { + let httpServer: IHttpServer | null = null; try { - const httpServer = ctx.getService('http-server'); + 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}`); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - ctx.logger.error('Failed to register auth routes:', err); - throw err; + } else { + ctx.logger.warn( + 'No HTTP server available — auth routes not registered. ' + + 'Auth service is still available for MSW/mock environments via HttpDispatcher.' + ); } } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 0e1d0b3128..5a3f4aa631 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -305,6 +305,59 @@ describe('HttpDispatcher', () => { }); }); + describe('handleAuth mock fallback (MSW/test mode)', () => { + beforeEach(() => { + // No auth service, no broker — simulates MSW/mock mode + (kernel as any).getService = vi.fn().mockResolvedValue(null); + (kernel as any).services = new Map(); + (kernel as any).broker = null; + }); + + it('should mock sign-up/email endpoint', async () => { + const result = await dispatcher.handleAuth('/sign-up/email', 'POST', { email: 'test@example.com', name: 'Test' }, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body.user).toBeDefined(); + expect(result.response?.body.user.email).toBe('test@example.com'); + expect(result.response?.body.session).toBeDefined(); + }); + + it('should mock sign-in/email endpoint', async () => { + const result = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'test@example.com' }, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body.user).toBeDefined(); + expect(result.response?.body.session).toBeDefined(); + }); + + it('should mock get-session endpoint', async () => { + const result = await dispatcher.handleAuth('/get-session', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body).toEqual({ session: null, user: null }); + }); + + it('should mock sign-out endpoint', async () => { + const result = await dispatcher.handleAuth('/sign-out', 'POST', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body).toEqual({ success: true }); + }); + + it('should mock login fallback when broker unavailable', async () => { + const result = await dispatcher.handleAuth('/login', 'POST', { email: 'test@example.com' }, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body.user).toBeDefined(); + expect(result.response?.body.session).toBeDefined(); + }); + + it('should return unhandled for unknown auth path in mock mode', async () => { + const result = await dispatcher.handleAuth('/unknown', 'GET', {}, { request: {} }); + expect(result.handled).toBe(false); + }); + }); + describe('handleStorage with async service', () => { it('should resolve storage service from Promise', async () => { const mockStorage = { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index fd66b36675..1d54e04dc2 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -3,6 +3,18 @@ import { ObjectKernel, getEnv } from '@objectstack/core'; import { CoreServiceName } from '@objectstack/spec/system'; +/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */ +function randomUUID(): string { + if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + export interface HttpProtocolContext { request: any; response?: any; @@ -179,12 +191,80 @@ export class HttpDispatcher { return { handled: true, result: response }; } - // 2. Legacy Login + // 2. Legacy Login via broker const normalizedPath = path.replace(/^\/+/, ''); if (normalizedPath === 'login' && method.toUpperCase() === 'POST') { - const broker = this.ensureBroker(); - const data = await broker.call('auth.login', body, { request: context.request }); - return { handled: true, response: { status: 200, body: data } }; + try { + const broker = this.ensureBroker(); + const data = await broker.call('auth.login', body, { request: context.request }); + return { handled: true, response: { status: 200, body: data } }; + } catch (error: any) { + // Only fall through to mock when the broker is truly unavailable + // (ensureBroker throws statusCode 500 when kernel.broker is null) + const statusCode = error?.statusCode ?? error?.status; + if (statusCode !== 500 || !error?.message?.includes('Broker not available')) { + throw error; + } + } + } + + // 3. Mock fallback for MSW/test environments when no auth service is registered + return this.mockAuthFallback(normalizedPath, method, body); + } + + /** + * Provides mock auth responses for core better-auth endpoints when + * AuthPlugin is not loaded (e.g. MSW/browser-only environments). + * This ensures registration/sign-in flows do not 404 in mock mode. + */ + private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult { + const m = method.toUpperCase(); + const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours + + // POST sign-up/email + if ((path === 'sign-up/email' || path === 'register') && m === 'POST') { + const id = `mock_${randomUUID()}`; + return { + handled: true, + response: { + status: 200, + body: { + user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, + }, + }, + }; + } + + // POST sign-in/email or login + if ((path === 'sign-in/email' || path === 'login') && m === 'POST') { + const id = `mock_${randomUUID()}`; + return { + handled: true, + response: { + status: 200, + body: { + user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() }, + }, + }, + }; + } + + // GET get-session + if (path === 'get-session' && m === 'GET') { + return { + handled: true, + response: { status: 200, body: { session: null, user: null } }, + }; + } + + // POST sign-out + if (path === 'sign-out' && m === 'POST') { + return { + handled: true, + response: { status: 200, body: { success: true } }, + }; } return { handled: false }; @@ -486,7 +566,7 @@ export class HttpDispatcher { * GET /labels/:object/:locale → getFieldLabels (both from path) * GET /labels/:object?locale=xx → getFieldLabels (locale from query) */ - async handleI18n(path: string, method: string, query: any, context: HttpProtocolContext): Promise { + async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise { const i18nService = await this.getService(CoreServiceName.enum.i18n); if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };