|
3 | 3 | import { ObjectKernel, getEnv } from '@objectstack/core'; |
4 | 4 | import { CoreServiceName } from '@objectstack/spec/system'; |
5 | 5 |
|
| 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 | + |
6 | 18 | export interface HttpProtocolContext { |
7 | 19 | request: any; |
8 | 20 | response?: any; |
@@ -179,12 +191,80 @@ export class HttpDispatcher { |
179 | 191 | return { handled: true, result: response }; |
180 | 192 | } |
181 | 193 |
|
182 | | - // 2. Legacy Login |
| 194 | + // 2. Legacy Login via broker |
183 | 195 | const normalizedPath = path.replace(/^\/+/, ''); |
184 | 196 | 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 | + }; |
188 | 268 | } |
189 | 269 |
|
190 | 270 | return { handled: false }; |
@@ -486,7 +566,7 @@ export class HttpDispatcher { |
486 | 566 | * GET /labels/:object/:locale → getFieldLabels (both from path) |
487 | 567 | * GET /labels/:object?locale=xx → getFieldLabels (locale from query) |
488 | 568 | */ |
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> { |
490 | 570 | const i18nService = await this.getService(CoreServiceName.enum.i18n); |
491 | 571 | if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) }; |
492 | 572 |
|
|
0 commit comments