Skip to content

Commit 2df82ab

Browse files
authored
Merge pull request #863 from objectstack-ai/copilot/load-authplugin-in-server-modes
2 parents 3e908da + 711e696 commit 2df82ab

File tree

6 files changed

+264
-16
lines changed

6 files changed

+264
-16
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ the ecosystem for enterprise workloads.
4949
| Client SDK (TypeScript) || `@objectstack/client` |
5050
| React Hooks || `@objectstack/client-react` |
5151
| Authentication (better-auth) || `@objectstack/plugin-auth` |
52+
| Auth in MSW/Mock Mode || `@objectstack/plugin-auth` + `@objectstack/runtime` |
5253
| RBAC / RLS / FLS Security || `@objectstack/plugin-security` |
5354
| CLI (16 commands) || `@objectstack/cli` |
5455
| Dev Mode Plugin || `@objectstack/plugin-dev` |

content/docs/guides/authentication.mdx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Complete guide to implementing authentication in ObjectStack applications using
1818
7. [Client Integration](#client-integration)
1919
8. [API Reference](#api-reference)
2020
9. [Best Practices](#best-practices)
21+
10. [MSW/Mock Mode](#mswmock-mode)
2122

2223
---
2324

@@ -89,7 +90,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server';
8990

9091
const kernel = new ObjectKernel({
9192
plugins: [
92-
// HTTP server (required for auth routes)
93+
// HTTP server (optional — auth works without it in MSW/mock mode)
9394
new HonoServerPlugin({
9495
port: 3000,
9596
}),
@@ -107,6 +108,10 @@ await kernel.start();
107108

108109
That's it! Your authentication endpoints are now available at `/api/v1/auth/*`.
109110

111+
> **MSW/Mock Mode:** AuthPlugin does **not** require an HTTP server. When HonoServerPlugin
112+
> is absent, the plugin gracefully skips route registration and still registers the `auth`
113+
> service. See [MSW/Mock Mode](#mswmock-mode) below for details.
114+
110115
### 3. ObjectQL Data Persistence
111116

112117
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
586591
587592
---
588593

594+
## MSW/Mock Mode
595+
596+
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.
597+
598+
### How It Works
599+
600+
- **Server mode** (HonoServerPlugin active): AuthPlugin registers HTTP routes at `/api/v1/auth/*` and forwards all requests to better-auth.
601+
- **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.
602+
603+
### Minimal Configuration for Mock Mode
604+
605+
```typescript
606+
import { ObjectKernel, DriverPlugin, AppPlugin } from '@objectstack/runtime';
607+
import { ObjectQLPlugin } from '@objectstack/objectql';
608+
import { InMemoryDriver } from '@objectstack/driver-memory';
609+
import { AuthPlugin } from '@objectstack/plugin-auth';
610+
611+
const kernel = new ObjectKernel();
612+
613+
await kernel.use(new ObjectQLPlugin());
614+
await kernel.use(new DriverPlugin(new InMemoryDriver(), 'memory'));
615+
616+
// AuthPlugin works without HonoServerPlugin — no HTTP server needed
617+
await kernel.use(new AuthPlugin({
618+
secret: 'INSECURE_DEV_ONLY_mock_secret_do_not_use_in_production',
619+
baseUrl: 'http://localhost:5173',
620+
}));
621+
622+
await kernel.bootstrap();
623+
```
624+
625+
> ⚠️ **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`).
626+
627+
### Mock Fallback Endpoints
628+
629+
When no auth service handler is registered and the legacy broker login is unavailable, `HttpDispatcher.handleAuth()` automatically provides mock responses for:
630+
631+
| Endpoint | Method | Description |
632+
|:---|:---:|:---|
633+
| `sign-up/email` | POST | Returns mock user + session |
634+
| `sign-in/email` | POST | Returns mock user + session |
635+
| `login` | POST | Legacy login — returns mock user + session |
636+
| `register` | POST | Alias for sign-up |
637+
| `get-session` | GET | Returns `{ session: null, user: null }` |
638+
| `sign-out` | POST | Returns `{ success: true }` |
639+
640+
This ensures that registration and sign-in flows do not return 404 errors in MSW/browser-only environments.
641+
642+
> **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).
643+
644+
### Studio Kernel Factory
645+
646+
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:
647+
648+
```typescript
649+
// apps/studio/src/mocks/createKernel.ts
650+
// No AuthPlugin needed — HttpDispatcher provides mock auth endpoints automatically
651+
const kernel = new ObjectKernel();
652+
await kernel.use(new ObjectQLPlugin());
653+
await kernel.use(new DriverPlugin(driver, 'memory'));
654+
// ...
655+
await kernel.use(new MSWPlugin({ /* ... */ }));
656+
await kernel.bootstrap();
657+
```
658+
659+
---
660+
589661
## Next Steps
590662

591663
- See [Security Guide](/docs/guides/security) for authorization and permissions

packages/plugins/plugin-auth/src/auth-plugin.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('AuthPlugin', () => {
3434
expect(authPlugin.name).toBe('com.objectstack.auth');
3535
expect(authPlugin.type).toBe('standard');
3636
expect(authPlugin.version).toBe('1.0.0');
37-
expect(authPlugin.dependencies).toContain('com.objectstack.server.hono');
37+
expect(authPlugin.dependencies).toEqual([]);
3838
});
3939
});
4040

@@ -149,6 +149,33 @@ describe('AuthPlugin', () => {
149149
expect(mockContext.getService).not.toHaveBeenCalledWith('http-server');
150150
});
151151

152+
it('should gracefully skip routes when http-server is not available', async () => {
153+
mockContext.getService = vi.fn(() => null);
154+
155+
await authPlugin.start(mockContext);
156+
157+
expect(mockContext.getService).toHaveBeenCalledWith('http-server');
158+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
159+
expect.stringContaining('No HTTP server available')
160+
);
161+
// Should NOT throw — auth service is still registered from init()
162+
});
163+
164+
it('should gracefully handle http-server getService throwing', async () => {
165+
mockContext.getService = vi.fn(() => {
166+
throw new Error('Service not found: http-server');
167+
});
168+
169+
await authPlugin.start(mockContext);
170+
171+
expect(mockContext.logger.warn).toHaveBeenCalledWith(
172+
expect.stringContaining('No HTTP server available')
173+
);
174+
// Auth service should still be registered from init()
175+
expect(mockContext.registerService).toHaveBeenCalledWith('auth', expect.anything());
176+
// Should NOT throw
177+
});
178+
152179
it('should throw error if auth not initialized', async () => {
153180
const uninitializedPlugin = new AuthPlugin({
154181
secret: 'test-secret',

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
2727
*
2828
* Provides authentication and identity services for ObjectStack applications.
2929
*
30+
* **Dual-Mode Operation:**
31+
* - **Server mode** (HonoServerPlugin active): Registers HTTP routes at basePath,
32+
* forwarding all auth requests to better-auth's universal handler.
33+
* - **MSW/Mock mode** (no HTTP server): Gracefully skips route registration but
34+
* still registers the `auth` service, allowing HttpDispatcher.handleAuth() to
35+
* simulate auth flows (sign-up, sign-in, etc.) for development and testing.
36+
*
3037
* Features:
3138
* - Session management
3239
* - User registration/login
@@ -35,8 +42,8 @@ export interface AuthPluginOptions extends Partial<AuthConfig> {
3542
* - 2FA, passkeys, magic links
3643
*
3744
* This plugin registers:
38-
* - `auth` service (auth manager instance)
39-
* - HTTP routes for authentication endpoints
45+
* - `auth` service (auth manager instance) — always
46+
* - HTTP routes for authentication endpoints — only when HTTP server is available
4047
*
4148
* Integrates with better-auth library to provide comprehensive
4249
* authentication capabilities including email/password, OAuth, 2FA,
@@ -46,7 +53,7 @@ export class AuthPlugin implements Plugin {
4653
name = 'com.objectstack.auth';
4754
type = 'standard';
4855
version = '1.0.0';
49-
dependencies = ['com.objectstack.server.hono']; // Requires HTTP server
56+
dependencies: string[] = []; // HTTP server is optional; routes are registered only when available
5057

5158
private options: AuthPluginOptions;
5259
private authManager: AuthManager | null = null;
@@ -92,16 +99,24 @@ export class AuthPlugin implements Plugin {
9299
throw new Error('Auth manager not initialized');
93100
}
94101

95-
// Register HTTP routes if enabled
102+
// Register HTTP routes if enabled and HTTP server is available
96103
if (this.options.registerRoutes) {
104+
let httpServer: IHttpServer | null = null;
97105
try {
98-
const httpServer = ctx.getService<IHttpServer>('http-server');
106+
httpServer = ctx.getService<IHttpServer>('http-server');
107+
} catch {
108+
// Service not found — expected in MSW/mock mode
109+
}
110+
111+
if (httpServer) {
112+
// Route registration errors should propagate (server misconfiguration)
99113
this.registerAuthRoutes(httpServer, ctx);
100114
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
101-
} catch (error) {
102-
const err = error instanceof Error ? error : new Error(String(error));
103-
ctx.logger.error('Failed to register auth routes:', err);
104-
throw err;
115+
} else {
116+
ctx.logger.warn(
117+
'No HTTP server available — auth routes not registered. ' +
118+
'Auth service is still available for MSW/mock environments via HttpDispatcher.'
119+
);
105120
}
106121
}
107122

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,59 @@ describe('HttpDispatcher', () => {
305305
});
306306
});
307307

308+
describe('handleAuth mock fallback (MSW/test mode)', () => {
309+
beforeEach(() => {
310+
// No auth service, no broker — simulates MSW/mock mode
311+
(kernel as any).getService = vi.fn().mockResolvedValue(null);
312+
(kernel as any).services = new Map();
313+
(kernel as any).broker = null;
314+
});
315+
316+
it('should mock sign-up/email endpoint', async () => {
317+
const result = await dispatcher.handleAuth('/sign-up/email', 'POST', { email: 'test@example.com', name: 'Test' }, { request: {} });
318+
expect(result.handled).toBe(true);
319+
expect(result.response?.status).toBe(200);
320+
expect(result.response?.body.user).toBeDefined();
321+
expect(result.response?.body.user.email).toBe('test@example.com');
322+
expect(result.response?.body.session).toBeDefined();
323+
});
324+
325+
it('should mock sign-in/email endpoint', async () => {
326+
const result = await dispatcher.handleAuth('/sign-in/email', 'POST', { email: 'test@example.com' }, { request: {} });
327+
expect(result.handled).toBe(true);
328+
expect(result.response?.status).toBe(200);
329+
expect(result.response?.body.user).toBeDefined();
330+
expect(result.response?.body.session).toBeDefined();
331+
});
332+
333+
it('should mock get-session endpoint', async () => {
334+
const result = await dispatcher.handleAuth('/get-session', 'GET', {}, { request: {} });
335+
expect(result.handled).toBe(true);
336+
expect(result.response?.status).toBe(200);
337+
expect(result.response?.body).toEqual({ session: null, user: null });
338+
});
339+
340+
it('should mock sign-out endpoint', async () => {
341+
const result = await dispatcher.handleAuth('/sign-out', 'POST', {}, { request: {} });
342+
expect(result.handled).toBe(true);
343+
expect(result.response?.status).toBe(200);
344+
expect(result.response?.body).toEqual({ success: true });
345+
});
346+
347+
it('should mock login fallback when broker unavailable', async () => {
348+
const result = await dispatcher.handleAuth('/login', 'POST', { email: 'test@example.com' }, { request: {} });
349+
expect(result.handled).toBe(true);
350+
expect(result.response?.status).toBe(200);
351+
expect(result.response?.body.user).toBeDefined();
352+
expect(result.response?.body.session).toBeDefined();
353+
});
354+
355+
it('should return unhandled for unknown auth path in mock mode', async () => {
356+
const result = await dispatcher.handleAuth('/unknown', 'GET', {}, { request: {} });
357+
expect(result.handled).toBe(false);
358+
});
359+
});
360+
308361
describe('handleStorage with async service', () => {
309362
it('should resolve storage service from Promise', async () => {
310363
const mockStorage = {

packages/runtime/src/http-dispatcher.ts

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
import { ObjectKernel, getEnv } from '@objectstack/core';
44
import { CoreServiceName } from '@objectstack/spec/system';
55

6+
/** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
7+
function randomUUID(): string {
8+
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
9+
return globalThis.crypto.randomUUID();
10+
}
11+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
12+
const r = (Math.random() * 16) | 0;
13+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
14+
return v.toString(16);
15+
});
16+
}
17+
618
export interface HttpProtocolContext {
719
request: any;
820
response?: any;
@@ -179,12 +191,80 @@ export class HttpDispatcher {
179191
return { handled: true, result: response };
180192
}
181193

182-
// 2. Legacy Login
194+
// 2. Legacy Login via broker
183195
const normalizedPath = path.replace(/^\/+/, '');
184196
if (normalizedPath === 'login' && method.toUpperCase() === 'POST') {
185-
const broker = this.ensureBroker();
186-
const data = await broker.call('auth.login', body, { request: context.request });
187-
return { handled: true, response: { status: 200, body: data } };
197+
try {
198+
const broker = this.ensureBroker();
199+
const data = await broker.call('auth.login', body, { request: context.request });
200+
return { handled: true, response: { status: 200, body: data } };
201+
} catch (error: any) {
202+
// Only fall through to mock when the broker is truly unavailable
203+
// (ensureBroker throws statusCode 500 when kernel.broker is null)
204+
const statusCode = error?.statusCode ?? error?.status;
205+
if (statusCode !== 500 || !error?.message?.includes('Broker not available')) {
206+
throw error;
207+
}
208+
}
209+
}
210+
211+
// 3. Mock fallback for MSW/test environments when no auth service is registered
212+
return this.mockAuthFallback(normalizedPath, method, body);
213+
}
214+
215+
/**
216+
* Provides mock auth responses for core better-auth endpoints when
217+
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
218+
* This ensures registration/sign-in flows do not 404 in mock mode.
219+
*/
220+
private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
221+
const m = method.toUpperCase();
222+
const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours
223+
224+
// POST sign-up/email
225+
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
226+
const id = `mock_${randomUUID()}`;
227+
return {
228+
handled: true,
229+
response: {
230+
status: 200,
231+
body: {
232+
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
233+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
234+
},
235+
},
236+
};
237+
}
238+
239+
// POST sign-in/email or login
240+
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
241+
const id = `mock_${randomUUID()}`;
242+
return {
243+
handled: true,
244+
response: {
245+
status: 200,
246+
body: {
247+
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
248+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
249+
},
250+
},
251+
};
252+
}
253+
254+
// GET get-session
255+
if (path === 'get-session' && m === 'GET') {
256+
return {
257+
handled: true,
258+
response: { status: 200, body: { session: null, user: null } },
259+
};
260+
}
261+
262+
// POST sign-out
263+
if (path === 'sign-out' && m === 'POST') {
264+
return {
265+
handled: true,
266+
response: { status: 200, body: { success: true } },
267+
};
188268
}
189269

190270
return { handled: false };
@@ -486,7 +566,7 @@ export class HttpDispatcher {
486566
* GET /labels/:object/:locale → getFieldLabels (both from path)
487567
* GET /labels/:object?locale=xx → getFieldLabels (locale from query)
488568
*/
489-
async handleI18n(path: string, method: string, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
569+
async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
490570
const i18nService = await this.getService(CoreServiceName.enum.i18n);
491571
if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };
492572

0 commit comments

Comments
 (0)