@objectstack/runtime 3.2.0 → 3.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "ObjectStack Core Runtime & Query Engine",
6
6
  "type": "module",
@@ -15,10 +15,10 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "zod": "^4.3.6",
18
- "@objectstack/core": "3.2.0",
19
- "@objectstack/rest": "3.2.0",
20
- "@objectstack/spec": "3.2.0",
21
- "@objectstack/types": "3.2.0"
18
+ "@objectstack/core": "3.2.1",
19
+ "@objectstack/spec": "3.2.1",
20
+ "@objectstack/types": "3.2.1",
21
+ "@objectstack/rest": "3.2.1"
22
22
  },
23
23
  "devDependencies": {
24
24
  "typescript": "^5.0.0",
@@ -305,6 +305,59 @@ describe('HttpDispatcher', () => {
305
305
  });
306
306
  });
307
307
 
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
+
308
361
  describe('handleStorage with async service', () => {
309
362
  it('should resolve storage service from Promise', async () => {
310
363
  const mockStorage = {
@@ -439,6 +492,108 @@ describe('HttpDispatcher', () => {
439
492
  });
440
493
  });
441
494
 
495
+ // ═══════════════════════════════════════════════════════════════
496
+ // getServiceAsync preferred path
497
+ // ═══════════════════════════════════════════════════════════════
498
+
499
+ describe('getServiceAsync preferred path', () => {
500
+ it('should prefer getServiceAsync over getService for analytics', async () => {
501
+ const asyncAnalytics = {
502
+ query: vi.fn().mockResolvedValue({ rows: [1], total: 1 }),
503
+ };
504
+ (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAnalytics);
505
+ (kernel as any).getService = vi.fn().mockImplementation(() => {
506
+ throw new Error("Service 'analytics' is async - use await");
507
+ });
508
+
509
+ const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
510
+ expect(result.handled).toBe(true);
511
+ expect(asyncAnalytics.query).toHaveBeenCalled();
512
+ expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('analytics');
513
+ });
514
+
515
+ it('should prefer getServiceAsync over getService for auth', async () => {
516
+ const asyncAuth = {
517
+ handler: vi.fn().mockResolvedValue({ user: { id: '1' } }),
518
+ };
519
+ (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuth);
520
+ (kernel as any).getService = vi.fn().mockImplementation(() => {
521
+ throw new Error("Service 'auth' is async - use await");
522
+ });
523
+
524
+ const result = await dispatcher.handleAuth('', 'POST', {}, { request: {}, response: {} });
525
+ expect(result.handled).toBe(true);
526
+ expect(asyncAuth.handler).toHaveBeenCalled();
527
+ expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('auth');
528
+ });
529
+
530
+ it('should prefer getServiceAsync over getService for automation', async () => {
531
+ const asyncAuto = {
532
+ listFlows: vi.fn().mockResolvedValue(['flow_async']),
533
+ };
534
+ (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncAuto);
535
+
536
+ const result = await dispatcher.handleAutomation('', 'GET', {}, { request: {} });
537
+ expect(result.handled).toBe(true);
538
+ expect(result.response?.body?.data?.flows).toEqual(['flow_async']);
539
+ expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('automation');
540
+ });
541
+
542
+ it('should prefer getServiceAsync over getService for file-storage', async () => {
543
+ const asyncStorage = {
544
+ upload: vi.fn().mockResolvedValue({ id: 'file_1', url: '/files/1' }),
545
+ };
546
+ (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(asyncStorage);
547
+
548
+ const result = await dispatcher.handleStorage('/upload', 'POST', { name: 'test.txt' }, { request: {} });
549
+ expect(result.handled).toBe(true);
550
+ expect(result.response?.status).toBe(200);
551
+ expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('file-storage');
552
+ });
553
+
554
+ it('should resolve protocol service via getServiceAsync for handleMetadata', async () => {
555
+ const asyncProtocol = {
556
+ saveMetaItem: vi.fn().mockResolvedValue({ success: true }),
557
+ };
558
+ (kernel as any).getServiceAsync = vi.fn().mockImplementation((name: string) => {
559
+ if (name === 'protocol') return Promise.resolve(asyncProtocol);
560
+ return Promise.resolve(null);
561
+ });
562
+ // Remove context.getService to ensure getServiceAsync is used
563
+ (kernel as any).context = {};
564
+
565
+ const result = await dispatcher.handleMetadata('/objects/my_obj', { request: {} }, 'PUT', { label: 'Test' });
566
+ expect(result.handled).toBe(true);
567
+ expect(result.response?.status).toBe(200);
568
+ expect(asyncProtocol.saveMetaItem).toHaveBeenCalled();
569
+ expect((kernel as any).getServiceAsync).toHaveBeenCalledWith('protocol');
570
+ });
571
+
572
+ it('should fall through when getServiceAsync returns null', async () => {
573
+ (kernel as any).getServiceAsync = vi.fn().mockResolvedValue(null);
574
+ const syncAnalytics = {
575
+ query: vi.fn().mockResolvedValue({ rows: [], total: 0 }),
576
+ };
577
+ (kernel as any).services = new Map([['analytics', syncAnalytics]]);
578
+
579
+ const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
580
+ expect(result.handled).toBe(true);
581
+ expect(syncAnalytics.query).toHaveBeenCalled();
582
+ });
583
+
584
+ it('should fall through when getServiceAsync throws', async () => {
585
+ (kernel as any).getServiceAsync = vi.fn().mockRejectedValue(new Error('not found'));
586
+ const syncAnalytics = {
587
+ query: vi.fn().mockResolvedValue({ rows: [], total: 0 }),
588
+ };
589
+ (kernel as any).services = new Map([['analytics', syncAnalytics]]);
590
+
591
+ const result = await dispatcher.handleAnalytics('query', 'POST', {}, { request: {} });
592
+ expect(result.handled).toBe(true);
593
+ expect(syncAnalytics.query).toHaveBeenCalled();
594
+ });
595
+ });
596
+
442
597
  // ═══════════════════════════════════════════════════════════════
443
598
  // handleData — expand/populate parameter flow
444
599
  // ═══════════════════════════════════════════════════════════════
@@ -684,4 +839,119 @@ describe('HttpDispatcher', () => {
684
839
  );
685
840
  });
686
841
  });
842
+
843
+ // ═══════════════════════════════════════════════════════════════
844
+ // handleI18n — i18n route dispatching
845
+ // ═══════════════════════════════════════════════════════════════
846
+
847
+ describe('handleI18n', () => {
848
+ let mockI18nService: any;
849
+
850
+ beforeEach(() => {
851
+ mockI18nService = {
852
+ getLocales: vi.fn().mockReturnValue(['en', 'zh-CN', 'ja']),
853
+ getTranslations: vi.fn().mockReturnValue({ 'o.account.label': '客户', 'o.account.fields.name': '名称' }),
854
+ getFieldLabels: vi.fn().mockReturnValue({ name: '名称', industry: '行业' }),
855
+ };
856
+
857
+ (kernel as any).getService = vi.fn().mockImplementation((name: string) => {
858
+ if (name === 'i18n') return mockI18nService;
859
+ return null;
860
+ });
861
+ });
862
+
863
+ it('should list locales via GET /locales', async () => {
864
+ const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
865
+ expect(result.handled).toBe(true);
866
+ expect(result.response?.status).toBe(200);
867
+ expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
868
+ expect(mockI18nService.getLocales).toHaveBeenCalled();
869
+ });
870
+
871
+ it('should get translations via GET /translations/:locale', async () => {
872
+ const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} });
873
+ expect(result.handled).toBe(true);
874
+ expect(result.response?.status).toBe(200);
875
+ expect(result.response?.body?.data?.locale).toBe('zh-CN');
876
+ expect(result.response?.body?.data?.translations).toEqual({ 'o.account.label': '客户', 'o.account.fields.name': '名称' });
877
+ expect(mockI18nService.getTranslations).toHaveBeenCalledWith('zh-CN');
878
+ });
879
+
880
+ it('should get translations via GET /translations?locale=zh-CN (query param)', async () => {
881
+ const result = await dispatcher.handleI18n('/translations', 'GET', { locale: 'zh-CN' }, { request: {} });
882
+ expect(result.handled).toBe(true);
883
+ expect(result.response?.status).toBe(200);
884
+ expect(result.response?.body?.data?.locale).toBe('zh-CN');
885
+ expect(mockI18nService.getTranslations).toHaveBeenCalledWith('zh-CN');
886
+ });
887
+
888
+ it('should return 400 when translations requested without locale', async () => {
889
+ const result = await dispatcher.handleI18n('/translations', 'GET', {}, { request: {} });
890
+ expect(result.handled).toBe(true);
891
+ expect(result.response?.status).toBe(400);
892
+ expect(result.response?.body?.error?.message).toBe('Missing locale parameter');
893
+ });
894
+
895
+ it('should get field labels via GET /labels/:object/:locale', async () => {
896
+ const result = await dispatcher.handleI18n('/labels/account/zh-CN', 'GET', {}, { request: {} });
897
+ expect(result.handled).toBe(true);
898
+ expect(result.response?.status).toBe(200);
899
+ expect(result.response?.body?.data?.object).toBe('account');
900
+ expect(result.response?.body?.data?.locale).toBe('zh-CN');
901
+ expect(result.response?.body?.data?.labels).toEqual({ name: '名称', industry: '行业' });
902
+ expect(mockI18nService.getFieldLabels).toHaveBeenCalledWith('account', 'zh-CN');
903
+ });
904
+
905
+ it('should get field labels via GET /labels/:object?locale=zh-CN (query param)', async () => {
906
+ const result = await dispatcher.handleI18n('/labels/account', 'GET', { locale: 'zh-CN' }, { request: {} });
907
+ expect(result.handled).toBe(true);
908
+ expect(result.response?.status).toBe(200);
909
+ expect(result.response?.body?.data?.object).toBe('account');
910
+ expect(mockI18nService.getFieldLabels).toHaveBeenCalledWith('account', 'zh-CN');
911
+ });
912
+
913
+ it('should return 400 when labels requested without locale', async () => {
914
+ const result = await dispatcher.handleI18n('/labels/account', 'GET', {}, { request: {} });
915
+ expect(result.handled).toBe(true);
916
+ expect(result.response?.status).toBe(400);
917
+ expect(result.response?.body?.error?.message).toBe('Missing locale parameter');
918
+ });
919
+
920
+ it('should fallback to deriving labels from translations when getFieldLabels is missing', async () => {
921
+ delete mockI18nService.getFieldLabels;
922
+ mockI18nService.getTranslations.mockReturnValue({
923
+ 'o.contact.fields.first_name': 'First Name',
924
+ 'o.contact.fields.email': 'Email',
925
+ 'o.contact.label': 'Contact',
926
+ });
927
+
928
+ const result = await dispatcher.handleI18n('/labels/contact/en', 'GET', {}, { request: {} });
929
+ expect(result.handled).toBe(true);
930
+ expect(result.response?.status).toBe(200);
931
+ expect(result.response?.body?.data?.labels).toEqual({
932
+ first_name: 'First Name',
933
+ email: 'Email',
934
+ });
935
+ });
936
+
937
+ it('should return 501 when i18n service is not available', async () => {
938
+ (kernel as any).getService = vi.fn().mockResolvedValue(null);
939
+ (kernel as any).services = new Map();
940
+
941
+ const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
942
+ expect(result.handled).toBe(true);
943
+ expect(result.response?.status).toBe(501);
944
+ });
945
+
946
+ it('should return unhandled for non-GET methods', async () => {
947
+ const result = await dispatcher.handleI18n('/locales', 'POST', {}, { request: {} });
948
+ expect(result.handled).toBe(false);
949
+ });
950
+
951
+ it('should dispatch /i18n routes via dispatch()', async () => {
952
+ const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} });
953
+ expect(result.handled).toBe(true);
954
+ expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
955
+ });
956
+ });
687
957
  });
@@ -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) };
@@ -475,6 +555,64 @@ export class HttpDispatcher {
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();