@objectstack/runtime 3.2.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,18 @@
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 };
@@ -202,7 +282,7 @@ export class HttpDispatcher {
202
282
  // GET /metadata/types
203
283
  if (parts[0] === 'types') {
204
284
  // Try protocol service for dynamic types
205
- const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
285
+ const protocol = await this.resolveService('protocol');
206
286
  if (protocol && typeof protocol.getMetaTypes === 'function') {
207
287
  const result = await protocol.getMetaTypes({});
208
288
  return { handled: true, response: this.success(result) };
@@ -242,7 +322,7 @@ export class HttpDispatcher {
242
322
  // PUT /metadata/:type/:name (Save)
243
323
  if (method === 'PUT' && body) {
244
324
  // Try to get the protocol service directly
245
- const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
325
+ const protocol = await this.resolveService('protocol');
246
326
 
247
327
  if (protocol && typeof protocol.saveMetaItem === 'function') {
248
328
  try {
@@ -275,7 +355,7 @@ export class HttpDispatcher {
275
355
  const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
276
356
 
277
357
  // Try Protocol Service First (Preferred)
278
- const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
358
+ const protocol = await this.resolveService('protocol');
279
359
  if (protocol && typeof protocol.getMetaItem === 'function') {
280
360
  try {
281
361
  const data = await protocol.getMetaItem({ type: singularType, name });
@@ -304,7 +384,7 @@ export class HttpDispatcher {
304
384
  const packageId = query?.package || undefined;
305
385
 
306
386
  // Try protocol service first for any type
307
- const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
387
+ const protocol = await this.resolveService('protocol');
308
388
  if (protocol && typeof protocol.getMetaItems === 'function') {
309
389
  try {
310
390
  const data = await protocol.getMetaItems({ type: typeOrName, packageId });
@@ -343,7 +423,7 @@ export class HttpDispatcher {
343
423
  // GET /metadata — return available metadata types
344
424
  if (parts.length === 0) {
345
425
  // Try protocol service for dynamic types
346
- const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
426
+ const protocol = await this.resolveService('protocol');
347
427
  if (protocol && typeof protocol.getMetaTypes === 'function') {
348
428
  const result = await protocol.getMetaTypes({});
349
429
  return { handled: true, response: this.success(result) };
@@ -446,7 +526,7 @@ export class HttpDispatcher {
446
526
  * Handles Analytics requests
447
527
  * path: sub-path after /analytics/
448
528
  */
449
- async handleAnalytics(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
529
+ async handleAnalytics(path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
450
530
  const analyticsService = await this.getService(CoreServiceName.enum.analytics);
451
531
  if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
452
532
 
@@ -455,26 +535,84 @@ export class HttpDispatcher {
455
535
 
456
536
  // POST /analytics/query
457
537
  if (subPath === 'query' && m === 'POST') {
458
- const result = await analyticsService.query(body, { request: context.request });
538
+ const result = await analyticsService.query(body);
459
539
  return { handled: true, response: this.success(result) };
460
540
  }
461
541
 
462
542
  // GET /analytics/meta
463
543
  if (subPath === 'meta' && m === 'GET') {
464
- const result = await analyticsService.getMetadata({ request: context.request });
544
+ const result = await analyticsService.getMeta();
465
545
  return { handled: true, response: this.success(result) };
466
546
  }
467
547
 
468
548
  // POST /analytics/sql (Dry-run or debug)
469
549
  if (subPath === 'sql' && m === 'POST') {
470
550
  // Assuming service has generateSql method
471
- const result = await analyticsService.generateSql(body, { request: context.request });
551
+ const result = await analyticsService.generateSql(body);
472
552
  return { handled: true, response: this.success(result) };
473
553
  }
474
554
 
475
555
  return { handled: false };
476
556
  }
477
557
 
558
+ /**
559
+ * Handles i18n requests
560
+ * path: sub-path after /i18n/
561
+ *
562
+ * Routes:
563
+ * GET /locales → getLocales
564
+ * GET /translations/:locale → getTranslations (locale from path)
565
+ * GET /translations?locale=xx → getTranslations (locale from query)
566
+ * GET /labels/:object/:locale → getFieldLabels (both from path)
567
+ * GET /labels/:object?locale=xx → getFieldLabels (locale from query)
568
+ */
569
+ async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
570
+ const i18nService = await this.getService(CoreServiceName.enum.i18n);
571
+ if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };
572
+
573
+ const m = method.toUpperCase();
574
+ const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
575
+
576
+ if (m !== 'GET') return { handled: false };
577
+
578
+ // GET /i18n/locales
579
+ if (parts[0] === 'locales' && parts.length === 1) {
580
+ const locales = i18nService.getLocales();
581
+ return { handled: true, response: this.success({ locales }) };
582
+ }
583
+
584
+ // GET /i18n/translations/:locale OR /i18n/translations?locale=xx
585
+ if (parts[0] === 'translations') {
586
+ const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
587
+ if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
588
+ const translations = i18nService.getTranslations(locale);
589
+ return { handled: true, response: this.success({ locale, translations }) };
590
+ }
591
+
592
+ // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
593
+ if (parts[0] === 'labels' && parts.length >= 2) {
594
+ const objectName = decodeURIComponent(parts[1]);
595
+ const locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
596
+ if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
597
+ if (typeof i18nService.getFieldLabels === 'function') {
598
+ const labels = i18nService.getFieldLabels(objectName, locale);
599
+ return { handled: true, response: this.success({ object: objectName, locale, labels }) };
600
+ }
601
+ // Fallback: derive field labels from full translation bundle
602
+ const translations = i18nService.getTranslations(locale);
603
+ const prefix = `o.${objectName}.fields.`;
604
+ const labels: Record<string, string> = {};
605
+ for (const [key, value] of Object.entries(translations)) {
606
+ if (key.startsWith(prefix)) {
607
+ labels[key.substring(prefix.length)] = value as string;
608
+ }
609
+ }
610
+ return { handled: true, response: this.success({ object: objectName, locale, labels }) };
611
+ }
612
+
613
+ return { handled: false };
614
+ }
615
+
478
616
  /**
479
617
  * Handles Package Management requests
480
618
  *
@@ -708,7 +846,7 @@ export class HttpDispatcher {
708
846
  // Support both path param /view/obj/list AND query param /view/obj?type=list
709
847
  const type = parts[2] || query?.type || 'list';
710
848
 
711
- const protocol = this.kernel?.context?.getService ? await this.kernel.context.getService('protocol') : null;
849
+ const protocol = await this.resolveService('protocol');
712
850
 
713
851
  if (protocol && typeof protocol.getUiView === 'function') {
714
852
  try {
@@ -852,35 +990,54 @@ export class HttpDispatcher {
852
990
  }
853
991
 
854
992
  private async getService(name: CoreServiceName) {
855
- if (typeof this.kernel.getService === 'function') {
856
- return await this.kernel.getService(name);
857
- }
858
- const services = this.getServicesMap();
859
- return services[name];
993
+ return this.resolveService(name);
860
994
  }
861
995
 
862
996
  /**
863
- * Get the ObjectQL service which provides access to SchemaRegistry.
864
- * Tries multiple access patterns since kernel structure varies.
997
+ * Resolve any service by name, supporting async factories.
998
+ * Fallback chain: getServiceAsync getService (sync) context.getService → services map.
999
+ * Only returns when a non-null service is found; otherwise falls through to the next step.
865
1000
  */
866
- private async getObjectQLService(): Promise<any> {
867
- // 1. Try via kernel.getService
1001
+ private async resolveService(name: string) {
1002
+ // Prefer async resolution to support factory-based services (e.g. auth, analytics, protocol)
1003
+ if (typeof this.kernel.getServiceAsync === 'function') {
1004
+ try {
1005
+ const svc = await this.kernel.getServiceAsync(name);
1006
+ if (svc != null) return svc;
1007
+ } catch {
1008
+ // Service not registered or async resolution failed — fall through
1009
+ }
1010
+ }
868
1011
  if (typeof this.kernel.getService === 'function') {
869
1012
  try {
870
- const svc = await this.kernel.getService('objectql');
871
- if (svc?.registry) return svc;
872
- } catch { /* ignore */ }
1013
+ const svc = await this.kernel.getService(name);
1014
+ if (svc != null) return svc;
1015
+ } catch {
1016
+ // Service not registered or sync resolution threw "is async" — fall through
1017
+ }
873
1018
  }
874
- // 2. Try via kernel context
875
1019
  if (this.kernel?.context?.getService) {
876
1020
  try {
877
- const svc = await this.kernel.context.getService('objectql');
878
- if (svc?.registry) return svc;
879
- } catch { /* ignore */ }
1021
+ const svc = await this.kernel.context.getService(name);
1022
+ if (svc != null) return svc;
1023
+ } catch {
1024
+ // Service not registered — fall through
1025
+ }
880
1026
  }
881
- // 3. Try via services map
882
1027
  const services = this.getServicesMap();
883
- if (services['objectql']?.registry) return services['objectql'];
1028
+ return services[name];
1029
+ }
1030
+
1031
+ /**
1032
+ * Get the ObjectQL service which provides access to SchemaRegistry.
1033
+ * Tries multiple access patterns since kernel structure varies.
1034
+ */
1035
+ private async getObjectQLService(): Promise<any> {
1036
+ // 1. Try via resolveService (handles async factories, sync, context, and map)
1037
+ try {
1038
+ const svc = await this.resolveService('objectql');
1039
+ if (svc?.registry) return svc;
1040
+ } catch { /* service not available */ }
884
1041
  return null;
885
1042
  }
886
1043
 
@@ -944,6 +1101,10 @@ export class HttpDispatcher {
944
1101
  return this.handlePackages(cleanPath.substring(9), method, body, query, context);
945
1102
  }
946
1103
 
1104
+ if (cleanPath.startsWith('/i18n')) {
1105
+ return this.handleI18n(cleanPath.substring(5), method, query, context);
1106
+ }
1107
+
947
1108
  // OpenAPI Specification
948
1109
  if (cleanPath === '/openapi.json' && method === 'GET') {
949
1110
  const broker = this.ensureBroker();