@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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +10 -0
- package/dist/index.cjs +152 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +24 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +152 -22
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/http-dispatcher.test.ts +270 -0
- package/src/http-dispatcher.ts +189 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/runtime",
|
|
3
|
-
"version": "3.2.
|
|
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.
|
|
19
|
-
"@objectstack/
|
|
20
|
-
"@objectstack/
|
|
21
|
-
"@objectstack/
|
|
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
|
});
|
package/src/http-dispatcher.ts
CHANGED
|
@@ -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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
864
|
-
*
|
|
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
|
|
867
|
-
//
|
|
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(
|
|
871
|
-
if (svc
|
|
872
|
-
} catch {
|
|
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(
|
|
878
|
-
if (svc
|
|
879
|
-
} catch {
|
|
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
|
-
|
|
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();
|