Skip to content

Commit 2eddaa8

Browse files
Copilothotlong
andcommitted
feat: make AuthPlugin universal - graceful HTTP server handling and mock auth fallback
AuthPlugin no longer requires HonoServerPlugin as a hard dependency. When http-server is unavailable (MSW/mock mode), it logs a warning and continues, keeping the auth service registered. HttpDispatcher.handleAuth() now provides mock fallback responses for core better-auth endpoints (sign-up, sign-in, sign-out, get-session) when no auth service or broker is available, preventing 404 errors in MSW/browser-only environments. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 8a4beec commit 2eddaa8

5 files changed

Lines changed: 160 additions & 11 deletions

File tree

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

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

Lines changed: 27 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,32 @@ 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('HTTP server not available'),
173+
expect.any(String)
174+
);
175+
// Should NOT throw
176+
});
177+
152178
it('should throw error if auth not initialized', async () => {
153179
const uninitializedPlugin = new AuthPlugin({
154180
secret: 'test-secret',

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class AuthPlugin implements Plugin {
4646
name = 'com.objectstack.auth';
4747
type = 'standard';
4848
version = '1.0.0';
49-
dependencies = ['com.objectstack.server.hono']; // Requires HTTP server
49+
dependencies: string[] = []; // HTTP server is optional; routes are registered only when available
5050

5151
private options: AuthPluginOptions;
5252
private authManager: AuthManager | null = null;
@@ -92,16 +92,23 @@ export class AuthPlugin implements Plugin {
9292
throw new Error('Auth manager not initialized');
9393
}
9494

95-
// Register HTTP routes if enabled
95+
// Register HTTP routes if enabled and HTTP server is available
9696
if (this.options.registerRoutes) {
9797
try {
9898
const httpServer = ctx.getService<IHttpServer>('http-server');
99-
this.registerAuthRoutes(httpServer, ctx);
100-
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
99+
if (httpServer) {
100+
this.registerAuthRoutes(httpServer, ctx);
101+
ctx.logger.info(`Auth routes registered at ${this.options.basePath}`);
102+
} else {
103+
ctx.logger.warn(
104+
'No HTTP server available — auth routes not registered. ' +
105+
'Auth service is still available for MSW/mock environments via HttpDispatcher.'
106+
);
107+
}
101108
} catch (error) {
109+
// Gracefully handle missing HTTP server (e.g. MSW/mock mode)
102110
const err = error instanceof Error ? error : new Error(String(error));
103-
ctx.logger.error('Failed to register auth routes:', err);
104-
throw err;
111+
ctx.logger.warn('HTTP server not available, skipping auth route registration:', err.message);
105112
}
106113
}
107114

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: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,74 @@ export class HttpDispatcher {
179179
return { handled: true, result: response };
180180
}
181181

182-
// 2. Legacy Login
182+
// 2. Legacy Login via broker
183183
const normalizedPath = path.replace(/^\/+/, '');
184184
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 } };
185+
try {
186+
const broker = this.ensureBroker();
187+
const data = await broker.call('auth.login', body, { request: context.request });
188+
return { handled: true, response: { status: 200, body: data } };
189+
} catch {
190+
// Broker not available — fall through to mock fallback
191+
}
192+
}
193+
194+
// 3. Mock fallback for MSW/test environments when no auth service is registered
195+
return this.mockAuthFallback(normalizedPath, method, body);
196+
}
197+
198+
/**
199+
* Provides mock auth responses for core better-auth endpoints when
200+
* AuthPlugin is not loaded (e.g. MSW/browser-only environments).
201+
* This ensures registration/sign-in flows do not 404 in mock mode.
202+
*/
203+
private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
204+
const m = method.toUpperCase();
205+
206+
// POST sign-up/email
207+
if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
208+
const id = `mock_${Date.now()}`;
209+
return {
210+
handled: true,
211+
response: {
212+
status: 200,
213+
body: {
214+
user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
215+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + 86400000).toISOString() },
216+
},
217+
},
218+
};
219+
}
220+
221+
// POST sign-in/email or login
222+
if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
223+
const id = `mock_${Date.now()}`;
224+
return {
225+
handled: true,
226+
response: {
227+
status: 200,
228+
body: {
229+
user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
230+
session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + 86400000).toISOString() },
231+
},
232+
},
233+
};
234+
}
235+
236+
// GET get-session
237+
if (path === 'get-session' && m === 'GET') {
238+
return {
239+
handled: true,
240+
response: { status: 200, body: { session: null, user: null } },
241+
};
242+
}
243+
244+
// POST sign-out
245+
if (path === 'sign-out' && m === 'POST') {
246+
return {
247+
handled: true,
248+
response: { status: 200, body: { success: true } },
249+
};
188250
}
189251

190252
return { handled: false };

0 commit comments

Comments
 (0)