@objectstack/runtime 4.0.3 → 4.0.5

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.
@@ -1,1563 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectKernel, getEnv, resolveLocale } from '@objectstack/core';
4
- import { CoreServiceName } from '@objectstack/spec/system';
5
- import { pluralToSingular } from '@objectstack/spec/shared';
6
-
7
- /** Browser-safe UUID generator — prefers Web Crypto, falls back to RFC 4122 v4 */
8
- function randomUUID(): string {
9
- if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
10
- return globalThis.crypto.randomUUID();
11
- }
12
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
13
- const r = (Math.random() * 16) | 0;
14
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
15
- return v.toString(16);
16
- });
17
- }
18
-
19
- export interface HttpProtocolContext {
20
- request: any;
21
- response?: any;
22
- }
23
-
24
- export interface HttpDispatcherResult {
25
- handled: boolean;
26
- response?: {
27
- status: number;
28
- body?: any;
29
- headers?: Record<string, string>;
30
- };
31
- result?: any; // For flexible return types or direct response objects (Response/NextResponse)
32
- }
33
-
34
- /**
35
- * @deprecated Use `createDispatcherPlugin()` from `@objectstack/runtime` instead.
36
- * This class will be removed in v2. Prefer the plugin-based approach:
37
- * ```ts
38
- * import { createDispatcherPlugin } from '@objectstack/runtime';
39
- * kernel.use(createDispatcherPlugin({ prefix: '/api/v1' }));
40
- * ```
41
- */
42
- export class HttpDispatcher {
43
- private kernel: any; // Casting to any to access dynamic props like broker, services, graphql
44
-
45
- constructor(kernel: ObjectKernel) {
46
- this.kernel = kernel;
47
- }
48
-
49
- private success(data: any, meta?: any) {
50
- return {
51
- status: 200,
52
- body: { success: true, data, meta }
53
- };
54
- }
55
-
56
- private error(message: string, code: number = 500, details?: any) {
57
- return {
58
- status: code,
59
- body: { success: false, error: { message, code, details } }
60
- };
61
- }
62
-
63
- /**
64
- * 404 Route Not Found — no route is registered for this path.
65
- */
66
- private routeNotFound(route: string) {
67
- return {
68
- status: 404,
69
- body: {
70
- success: false,
71
- error: {
72
- code: 404,
73
- message: `Route Not Found: ${route}`,
74
- type: 'ROUTE_NOT_FOUND' as const,
75
- route,
76
- hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
77
- },
78
- },
79
- };
80
- }
81
-
82
- private ensureBroker() {
83
- if (!this.kernel.broker) {
84
- throw { statusCode: 500, message: 'Kernel Broker not available' };
85
- }
86
- return this.kernel.broker;
87
- }
88
-
89
- /**
90
- * Generates the discovery JSON response for the API root.
91
- *
92
- * Uses the same async `resolveService()` fallback chain that request
93
- * handlers use, so the reported service status is always consistent
94
- * with the actual runtime availability.
95
- */
96
- async getDiscoveryInfo(prefix: string) {
97
- // Resolve all services through the same async fallback chain
98
- // that request handlers (handleI18n, handleAuth, …) use.
99
- const [
100
- authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc,
101
- analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc,
102
- uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc,
103
- ] = await Promise.all([
104
- this.resolveService(CoreServiceName.enum.auth),
105
- this.resolveService(CoreServiceName.enum.graphql),
106
- this.resolveService(CoreServiceName.enum.search),
107
- this.resolveService(CoreServiceName.enum.realtime),
108
- this.resolveService(CoreServiceName.enum['file-storage']),
109
- this.resolveService(CoreServiceName.enum.analytics),
110
- this.resolveService(CoreServiceName.enum.workflow),
111
- this.resolveService(CoreServiceName.enum.ai),
112
- this.resolveService(CoreServiceName.enum.notification),
113
- this.resolveService(CoreServiceName.enum.i18n),
114
- this.resolveService(CoreServiceName.enum.ui),
115
- this.resolveService(CoreServiceName.enum.automation),
116
- this.resolveService(CoreServiceName.enum.cache),
117
- this.resolveService(CoreServiceName.enum.queue),
118
- this.resolveService(CoreServiceName.enum.job),
119
- ]);
120
-
121
- const hasAuth = !!authSvc;
122
- const hasGraphQL = !!(graphqlSvc || this.kernel.graphql);
123
- const hasSearch = !!searchSvc;
124
- const hasWebSockets = !!realtimeSvc;
125
- const hasFiles = !!filesSvc;
126
- const hasAnalytics = !!analyticsSvc;
127
- const hasWorkflow = !!workflowSvc;
128
- const hasAi = !!aiSvc;
129
- const hasNotification = !!notificationSvc;
130
- const hasI18n = !!i18nSvc;
131
- const hasUi = !!uiSvc;
132
- const hasAutomation = !!automationSvc;
133
- const hasCache = !!cacheSvc;
134
- const hasQueue = !!queueSvc;
135
- const hasJob = !!jobSvc;
136
-
137
- // Routes are only exposed when a plugin provides the service
138
- const routes = {
139
- data: `${prefix}/data`,
140
- metadata: `${prefix}/meta`,
141
- packages: `${prefix}/packages`,
142
- auth: hasAuth ? `${prefix}/auth` : undefined,
143
- ui: hasUi ? `${prefix}/ui` : undefined,
144
- graphql: hasGraphQL ? `${prefix}/graphql` : undefined,
145
- storage: hasFiles ? `${prefix}/storage` : undefined,
146
- analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
147
- automation: hasAutomation ? `${prefix}/automation` : undefined,
148
- workflow: hasWorkflow ? `${prefix}/workflow` : undefined,
149
- realtime: hasWebSockets ? `${prefix}/realtime` : undefined,
150
- notifications: hasNotification ? `${prefix}/notifications` : undefined,
151
- ai: hasAi ? `${prefix}/ai` : undefined,
152
- i18n: hasI18n ? `${prefix}/i18n` : undefined,
153
- };
154
-
155
- // Build per-service status map
156
- // handlerReady: true means the dispatcher has a real, bound handler for this route.
157
- // handlerReady: false means the route is present in the discovery table but may not
158
- // yet have a concrete implementation or may be served by a stub.
159
- const svcAvailable = (route?: string, provider?: string) => ({
160
- enabled: true, status: 'available' as const, handlerReady: true, route, provider,
161
- });
162
- const svcUnavailable = (name: string) => ({
163
- enabled: false, status: 'unavailable' as const, handlerReady: false,
164
- message: `Install a ${name} plugin to enable`,
165
- });
166
-
167
- // Derive locale info from actual i18n service when available
168
- let locale = { default: 'en', supported: ['en'], timezone: 'UTC' };
169
- if (hasI18n && i18nSvc) {
170
- const defaultLocale = typeof i18nSvc.getDefaultLocale === 'function'
171
- ? i18nSvc.getDefaultLocale() : 'en';
172
- const locales = typeof i18nSvc.getLocales === 'function'
173
- ? i18nSvc.getLocales() : [];
174
- locale = {
175
- default: defaultLocale,
176
- supported: locales.length > 0 ? locales : [defaultLocale],
177
- timezone: 'UTC',
178
- };
179
- }
180
-
181
- return {
182
- name: 'ObjectOS',
183
- version: '1.0.0',
184
- environment: getEnv('NODE_ENV', 'development'),
185
- routes,
186
- endpoints: routes, // Alias for backward compatibility with some clients
187
- features: {
188
- graphql: hasGraphQL,
189
- search: hasSearch,
190
- websockets: hasWebSockets,
191
- files: hasFiles,
192
- analytics: hasAnalytics,
193
- ai: hasAi,
194
- workflow: hasWorkflow,
195
- notifications: hasNotification,
196
- i18n: hasI18n,
197
- },
198
- services: {
199
- // Kernel-provided (always available via protocol implementation)
200
- metadata: { enabled: true, status: 'degraded' as const, handlerReady: true, route: routes.metadata, provider: 'kernel', message: 'In-memory registry; DB persistence pending' },
201
- data: svcAvailable(routes.data, 'kernel'),
202
- // Plugin-provided — only available when a plugin registers the service
203
- auth: hasAuth ? svcAvailable(routes.auth) : svcUnavailable('auth'),
204
- automation: hasAutomation ? svcAvailable(routes.automation) : svcUnavailable('automation'),
205
- analytics: hasAnalytics ? svcAvailable(routes.analytics) : svcUnavailable('analytics'),
206
- cache: hasCache ? svcAvailable() : svcUnavailable('cache'),
207
- queue: hasQueue ? svcAvailable() : svcUnavailable('queue'),
208
- job: hasJob ? svcAvailable() : svcUnavailable('job'),
209
- ui: hasUi ? svcAvailable(routes.ui) : svcUnavailable('ui'),
210
- workflow: hasWorkflow ? svcAvailable(routes.workflow) : svcUnavailable('workflow'),
211
- realtime: hasWebSockets ? svcAvailable(routes.realtime) : svcUnavailable('realtime'),
212
- notification: hasNotification ? svcAvailable(routes.notifications) : svcUnavailable('notification'),
213
- ai: hasAi ? svcAvailable(routes.ai) : svcUnavailable('ai'),
214
- i18n: hasI18n ? svcAvailable(routes.i18n) : svcUnavailable('i18n'),
215
- graphql: hasGraphQL ? svcAvailable(routes.graphql) : svcUnavailable('graphql'),
216
- 'file-storage': hasFiles ? svcAvailable(routes.storage) : svcUnavailable('file-storage'),
217
- search: hasSearch ? svcAvailable() : svcUnavailable('search'),
218
- },
219
- locale,
220
- };
221
- }
222
-
223
- /**
224
- * Handles GraphQL requests
225
- */
226
- async handleGraphQL(body: { query: string; variables?: any }, context: HttpProtocolContext) {
227
- if (!body || !body.query) {
228
- throw { statusCode: 400, message: 'Missing query in request body' };
229
- }
230
-
231
- if (typeof this.kernel.graphql !== 'function') {
232
- throw { statusCode: 501, message: 'GraphQL service not available' };
233
- }
234
-
235
- return this.kernel.graphql(body.query, body.variables, {
236
- request: context.request
237
- });
238
- }
239
-
240
- /**
241
- * Handles Auth requests
242
- * path: sub-path after /auth/
243
- */
244
- async handleAuth(path: string, method: string, body: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
245
- // 1. Try generic Auth Service
246
- const authService = await this.getService(CoreServiceName.enum.auth);
247
- if (authService && typeof authService.handler === 'function') {
248
- const response = await authService.handler(context.request, context.response);
249
- return { handled: true, result: response };
250
- }
251
-
252
- // 2. Legacy Login via broker
253
- const normalizedPath = path.replace(/^\/+/, '');
254
- if (normalizedPath === 'login' && method.toUpperCase() === 'POST') {
255
- try {
256
- const broker = this.ensureBroker();
257
- const data = await broker.call('auth.login', body, { request: context.request });
258
- return { handled: true, response: { status: 200, body: data } };
259
- } catch (error: any) {
260
- // Only fall through to mock when the broker is truly unavailable
261
- // (ensureBroker throws statusCode 500 when kernel.broker is null)
262
- const statusCode = error?.statusCode ?? error?.status;
263
- if (statusCode !== 500 || !error?.message?.includes('Broker not available')) {
264
- throw error;
265
- }
266
- }
267
- }
268
-
269
- // 3. Mock fallback for MSW/test environments when no auth service is registered
270
- return this.mockAuthFallback(normalizedPath, method, body);
271
- }
272
-
273
- /**
274
- * Provides mock auth responses for core better-auth endpoints when
275
- * AuthPlugin is not loaded (e.g. MSW/browser-only environments).
276
- * This ensures registration/sign-in flows do not 404 in mock mode.
277
- */
278
- private mockAuthFallback(path: string, method: string, body: any): HttpDispatcherResult {
279
- const m = method.toUpperCase();
280
- const MOCK_SESSION_EXPIRY_MS = 86_400_000; // 24 hours
281
-
282
- // POST sign-up/email
283
- if ((path === 'sign-up/email' || path === 'register') && m === 'POST') {
284
- const id = `mock_${randomUUID()}`;
285
- return {
286
- handled: true,
287
- response: {
288
- status: 200,
289
- body: {
290
- user: { id, name: body?.name || 'Mock User', email: body?.email || 'mock@test.local', emailVerified: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
291
- session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
292
- },
293
- },
294
- };
295
- }
296
-
297
- // POST sign-in/email or login
298
- if ((path === 'sign-in/email' || path === 'login') && m === 'POST') {
299
- const id = `mock_${randomUUID()}`;
300
- return {
301
- handled: true,
302
- response: {
303
- status: 200,
304
- body: {
305
- user: { id, name: 'Mock User', email: body?.email || 'mock@test.local', emailVerified: true, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
306
- session: { id: `session_${id}`, userId: id, token: `mock_token_${id}`, expiresAt: new Date(Date.now() + MOCK_SESSION_EXPIRY_MS).toISOString() },
307
- },
308
- },
309
- };
310
- }
311
-
312
- // GET get-session
313
- if (path === 'get-session' && m === 'GET') {
314
- return {
315
- handled: true,
316
- response: { status: 200, body: { session: null, user: null } },
317
- };
318
- }
319
-
320
- // POST sign-out
321
- if (path === 'sign-out' && m === 'POST') {
322
- return {
323
- handled: true,
324
- response: { status: 200, body: { success: true } },
325
- };
326
- }
327
-
328
- return { handled: false };
329
- }
330
-
331
- /**
332
- * Handles Metadata requests
333
- * Standard: /metadata/:type/:name
334
- * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
335
- */
336
- async handleMetadata(path: string, context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise<HttpDispatcherResult> {
337
- // Broker is used as a fallback — not required upfront.
338
- // This allows metadata to be served when only the protocol service
339
- // or ObjectQL service is available (e.g. lightweight / serverless setups).
340
- const broker = this.kernel.broker ?? null;
341
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
342
-
343
- // GET /metadata/types
344
- if (parts[0] === 'types') {
345
- // PRIORITY 1: Try MetadataService directly (includes both typeRegistry with agent/tool AND runtime-registered types)
346
- console.log('[HttpDispatcher] Attempting to resolve MetadataService...');
347
- console.log('[HttpDispatcher] Available kernel methods:', {
348
- hasGetServiceAsync: typeof this.kernel.getServiceAsync === 'function',
349
- hasGetService: typeof this.kernel.getService === 'function',
350
- hasContext: !!this.kernel.context,
351
- hasContextGetService: typeof this.kernel.context?.getService === 'function',
352
- });
353
-
354
- // Try all service resolution paths with detailed logging
355
- let metadataService: any = null;
356
-
357
- // Path 1: kernel.getServiceAsync
358
- if (typeof this.kernel.getServiceAsync === 'function') {
359
- try {
360
- metadataService = await this.kernel.getServiceAsync('metadata');
361
- console.log('[HttpDispatcher] kernel.getServiceAsync("metadata") returned:', !!metadataService);
362
- } catch (e: any) {
363
- console.log('[HttpDispatcher] kernel.getServiceAsync("metadata") failed:', e.message);
364
- }
365
- }
366
-
367
- // Path 2: kernel.getService (if not found via async)
368
- if (!metadataService && typeof this.kernel.getService === 'function') {
369
- try {
370
- metadataService = await this.kernel.getService('metadata');
371
- console.log('[HttpDispatcher] kernel.getService("metadata") returned:', !!metadataService);
372
- } catch (e: any) {
373
- console.log('[HttpDispatcher] kernel.getService("metadata") failed:', e.message);
374
- }
375
- }
376
-
377
- // Path 3: kernel.context.getService (if not found)
378
- if (!metadataService && this.kernel.context?.getService) {
379
- try {
380
- metadataService = await this.kernel.context.getService('metadata');
381
- console.log('[HttpDispatcher] kernel.context.getService("metadata") returned:', !!metadataService);
382
- } catch (e: any) {
383
- console.log('[HttpDispatcher] kernel.context.getService("metadata") failed:', e.message);
384
- }
385
- }
386
-
387
- console.log('[HttpDispatcher] Final metadataService:', !!metadataService, 'has getRegisteredTypes:', typeof (metadataService as any)?.getRegisteredTypes);
388
-
389
- if (metadataService && typeof (metadataService as any).getRegisteredTypes === 'function') {
390
- try {
391
- const types = await (metadataService as any).getRegisteredTypes();
392
- console.log('[HttpDispatcher] MetadataService.getRegisteredTypes() returned:', types);
393
- return { handled: true, response: this.success({ types }) };
394
- } catch (e: any) {
395
- // Log error but continue to fallbacks
396
- console.warn('[HttpDispatcher] MetadataService.getRegisteredTypes() failed:', e.message, e.stack);
397
- }
398
- } else {
399
- console.log('[HttpDispatcher] MetadataService not available or missing getRegisteredTypes, falling back to protocol service');
400
- }
401
- // PRIORITY 2: Try protocol service (returns SchemaRegistry types only - missing agent/tool)
402
- const protocol = await this.resolveService('protocol');
403
- if (protocol && typeof protocol.getMetaTypes === 'function') {
404
- const result = await protocol.getMetaTypes({});
405
- console.log('[HttpDispatcher] Protocol service returned types:', result);
406
- return { handled: true, response: this.success(result) };
407
- }
408
- // PRIORITY 3: ask broker for registered types
409
- if (broker) {
410
- try {
411
- const data = await broker.call('metadata.types', {}, { request: context.request });
412
- console.log('[HttpDispatcher] Broker returned types:', data);
413
- return { handled: true, response: this.success(data) };
414
- } catch (e) {
415
- console.log('[HttpDispatcher] Broker call failed:', e);
416
- // fall through to hardcoded defaults
417
- }
418
- }
419
- // Last resort: hardcoded defaults
420
- console.warn('[HttpDispatcher] Falling back to hardcoded defaults for metadata types');
421
- return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
422
- }
423
-
424
- // GET /metadata/:type/:name/published → get published version
425
- if (parts.length === 3 && parts[2] === 'published' && (!method || method === 'GET')) {
426
- const [type, name] = parts;
427
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
428
- if (metadataService && typeof (metadataService as any).getPublished === 'function') {
429
- const data = await (metadataService as any).getPublished(type, name);
430
- if (data === undefined) return { handled: true, response: this.error('Not found', 404) };
431
- return { handled: true, response: this.success(data) };
432
- }
433
- // Broker fallback
434
- if (broker) {
435
- try {
436
- const data = await broker.call('metadata.getPublished', { type, name }, { request: context.request });
437
- return { handled: true, response: this.success(data) };
438
- } catch (e: any) {
439
- return { handled: true, response: this.error(e.message, 404) };
440
- }
441
- }
442
- return { handled: true, response: this.error('Not found', 404) };
443
- }
444
-
445
- // /metadata/:type/:name
446
- if (parts.length === 2) {
447
- const [type, name] = parts;
448
- // Extract optional package filter from query string
449
- const packageId = query?.package || undefined;
450
-
451
- // PUT /metadata/:type/:name (Save)
452
- if (method === 'PUT' && body) {
453
- // Try to get the protocol service directly
454
- const protocol = await this.resolveService('protocol');
455
-
456
- if (protocol && typeof protocol.saveMetaItem === 'function') {
457
- try {
458
- const result = await protocol.saveMetaItem({ type, name, item: body });
459
- return { handled: true, response: this.success(result) };
460
- } catch (e: any) {
461
- return { handled: true, response: this.error(e.message, 400) };
462
- }
463
- }
464
-
465
- // Fallback to broker if protocol not available (legacy)
466
- if (broker) {
467
- try {
468
- const data = await broker.call('metadata.saveItem', { type, name, item: body }, { request: context.request });
469
- return { handled: true, response: this.success(data) };
470
- } catch (e: any) {
471
- return { handled: true, response: this.error(e.message || 'Save not supported', 501) };
472
- }
473
- }
474
- return { handled: true, response: this.error('Save not supported', 501) };
475
- }
476
-
477
- try {
478
- // Try specific calls based on type
479
- if (type === 'objects' || type === 'object') {
480
- if (broker) {
481
- const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request });
482
- return { handled: true, response: this.success(data) };
483
- }
484
- // Try ObjectQL service directly when broker is unavailable
485
- const qlService = await this.getObjectQLService();
486
- if (qlService?.registry) {
487
- const data = qlService.registry.getObject(name);
488
- if (data) return { handled: true, response: this.success(data) };
489
- }
490
- return { handled: true, response: this.error('Not found', 404) };
491
- }
492
-
493
- // Normalize plural URL paths to singular registry type names
494
- const singularType = pluralToSingular(type);
495
-
496
- // Try Protocol Service First (Preferred)
497
- const protocol = await this.resolveService('protocol');
498
- if (protocol && typeof protocol.getMetaItem === 'function') {
499
- try {
500
- const data = await protocol.getMetaItem({ type: singularType, name, packageId });
501
- return { handled: true, response: this.success(data) };
502
- } catch (e: any) {
503
- // Protocol might throw if not found or not supported
504
- // Fallback to broker?
505
- }
506
- }
507
-
508
- // Generic call for other types if supported via Broker (Legacy)
509
- if (broker) {
510
- const method = `metadata.get${this.capitalize(singularType)}`;
511
- const data = await broker.call(method, { name }, { request: context.request });
512
- return { handled: true, response: this.success(data) };
513
- }
514
- return { handled: true, response: this.error('Not found', 404) };
515
- } catch (e: any) {
516
- // Fallback: treat first part as object name if only 1 part (handled below)
517
- // But here we are deep in 2 parts. Must be an error.
518
- return { handled: true, response: this.error(e.message, 404) };
519
- }
520
- }
521
-
522
- // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy)
523
- if (parts.length === 1) {
524
- const typeOrName = parts[0];
525
- // Extract optional package filter from query string
526
- const packageId = query?.package || undefined;
527
-
528
- // Try protocol service first for any type
529
- const protocol = await this.resolveService('protocol');
530
- if (protocol && typeof protocol.getMetaItems === 'function') {
531
- try {
532
- const data = await protocol.getMetaItems({ type: typeOrName, packageId });
533
- // Return any valid response from protocol (including empty items arrays)
534
- if (data && (data.items !== undefined || Array.isArray(data))) {
535
- return { handled: true, response: this.success(data) };
536
- }
537
- } catch {
538
- // Protocol doesn't know this type, fall through
539
- }
540
- }
541
-
542
- // Try MetadataService directly for runtime-registered metadata (agents, tools, etc.)
543
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
544
- if (metadataService && typeof (metadataService as any).list === 'function') {
545
- try {
546
- const items = await (metadataService as any).list(typeOrName);
547
- if (items && items.length > 0) {
548
- return { handled: true, response: this.success({ type: typeOrName, items }) };
549
- }
550
- } catch (e: any) {
551
- // MetadataService doesn't know this type or failed, continue to other fallbacks
552
- // Sanitize typeOrName to prevent log injection (CodeQL warning)
553
- const sanitizedType = String(typeOrName).replace(/[\r\n\t]/g, '');
554
- console.debug(`[HttpDispatcher] MetadataService.list() failed for type:`, sanitizedType, 'error:', e.message);
555
- }
556
- }
557
-
558
- // Try broker for the type
559
- if (broker) {
560
- try {
561
- if (typeOrName === 'objects') {
562
- const data = await broker.call('metadata.objects', { packageId }, { request: context.request });
563
- return { handled: true, response: this.success(data) };
564
- }
565
- const data = await broker.call(`metadata.${typeOrName}`, { packageId }, { request: context.request });
566
- if (data !== null && data !== undefined) {
567
- return { handled: true, response: this.success(data) };
568
- }
569
- } catch {
570
- // Broker doesn't support this action, fall through
571
- }
572
-
573
- // Legacy: /metadata/:objectName (treat as single object lookup)
574
- try {
575
- const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request });
576
- return { handled: true, response: this.success(data) };
577
- } catch (e: any) {
578
- return { handled: true, response: this.error(e.message, 404) };
579
- }
580
- }
581
-
582
- // No broker — try ObjectQL registry directly for object lookups
583
- const qlService = await this.getObjectQLService();
584
- if (qlService?.registry) {
585
- if (typeOrName === 'objects') {
586
- const objs = qlService.registry.getAllObjects(packageId);
587
- return { handled: true, response: this.success({ type: 'object', items: objs }) };
588
- }
589
- // Try listing items of the given type
590
- const items = qlService.registry.listItems?.(typeOrName, packageId);
591
- if (items && items.length > 0) {
592
- return { handled: true, response: this.success({ type: typeOrName, items }) };
593
- }
594
- // Legacy: treat as object name
595
- const obj = qlService.registry.getObject(typeOrName);
596
- if (obj) return { handled: true, response: this.success(obj) };
597
- }
598
- return { handled: true, response: this.error('Not found', 404) };
599
- }
600
-
601
- // GET /metadata — return available metadata types
602
- if (parts.length === 0) {
603
- // Try protocol service for dynamic types
604
- const protocol = await this.resolveService('protocol');
605
- if (protocol && typeof protocol.getMetaTypes === 'function') {
606
- const result = await protocol.getMetaTypes({});
607
- return { handled: true, response: this.success(result) };
608
- }
609
- // Fallback: ask broker for registered types
610
- if (broker) {
611
- try {
612
- const data = await broker.call('metadata.types', {}, { request: context.request });
613
- return { handled: true, response: this.success(data) };
614
- } catch {
615
- // fall through to hardcoded defaults
616
- }
617
- }
618
- return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
619
- }
620
-
621
- return { handled: false };
622
- }
623
-
624
- /**
625
- * Handles Data requests
626
- * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query")
627
- */
628
- async handleData(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
629
- const broker = this.ensureBroker();
630
- const parts = path.replace(/^\/+/, '').split('/');
631
- const objectName = parts[0];
632
-
633
- if (!objectName) {
634
- return { handled: true, response: this.error('Object name required', 400) };
635
- }
636
-
637
- const m = method.toUpperCase();
638
-
639
- // 1. Custom Actions (query, batch)
640
- if (parts.length > 1) {
641
- const action = parts[1];
642
-
643
- // POST /data/:object/query
644
- if (action === 'query' && m === 'POST') {
645
- // Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
646
- const result = await broker.call('data.query', { object: objectName, ...body }, { request: context.request });
647
- return { handled: true, response: this.success(result) };
648
- }
649
-
650
- // POST /data/:object/batch
651
- if (action === 'batch' && m === 'POST') {
652
- const result = await broker.call('data.batch', { object: objectName, ...body }, { request: context.request });
653
- return { handled: true, response: this.success(result) };
654
- }
655
-
656
- // GET /data/:object/:id
657
- if (parts.length === 2 && m === 'GET') {
658
- const id = parts[1];
659
- // Spec: Only select/expand are allowlisted query params for GET by ID.
660
- // All other query parameters are discarded to prevent parameter pollution.
661
- const { select, expand } = query || {};
662
- const allowedParams: Record<string, unknown> = {};
663
- if (select != null) allowedParams.select = select;
664
- if (expand != null) allowedParams.expand = expand;
665
- // Spec: broker returns GetDataResponse = { object, id, record }
666
- const result = await broker.call('data.get', { object: objectName, id, ...allowedParams }, { request: context.request });
667
- return { handled: true, response: this.success(result) };
668
- }
669
-
670
- // PATCH /data/:object/:id
671
- if (parts.length === 2 && m === 'PATCH') {
672
- const id = parts[1];
673
- // Spec: broker returns UpdateDataResponse = { object, id, record }
674
- const result = await broker.call('data.update', { object: objectName, id, data: body }, { request: context.request });
675
- return { handled: true, response: this.success(result) };
676
- }
677
-
678
- // DELETE /data/:object/:id
679
- if (parts.length === 2 && m === 'DELETE') {
680
- const id = parts[1];
681
- // Spec: broker returns DeleteDataResponse = { object, id, deleted }
682
- const result = await broker.call('data.delete', { object: objectName, id }, { request: context.request });
683
- return { handled: true, response: this.success(result) };
684
- }
685
- } else {
686
- // GET /data/:object (List)
687
- if (m === 'GET') {
688
- // ── Normalize HTTP transport params → Spec canonical (QueryAST) ──
689
- // HTTP GET query params use transport-level names (filter, sort, top,
690
- // skip, select, expand) which are normalized here to canonical
691
- // QueryAST field names (where, orderBy, limit, offset, fields,
692
- // expand) before forwarding to the broker layer.
693
- // The protocol.ts findData() method performs a deeper normalization
694
- // pass, but pre-normalizing here ensures the broker always receives
695
- // Spec-canonical keys.
696
- const normalized: Record<string, unknown> = { ...query };
697
-
698
- // filter/filters → where
699
- // Note: `filter` is the canonical HTTP *transport* parameter name
700
- // (see HttpFindQueryParamsSchema). It is normalized here to the
701
- // canonical *QueryAST* field name `where` before broker dispatch.
702
- // `filters` (plural) is a deprecated alias for `filter`.
703
- if (normalized.filter != null || normalized.filters != null) {
704
- normalized.where = normalized.where ?? normalized.filter ?? normalized.filters;
705
- delete normalized.filter;
706
- delete normalized.filters;
707
- }
708
- // select → fields
709
- if (normalized.select != null && normalized.fields == null) {
710
- normalized.fields = normalized.select;
711
- delete normalized.select;
712
- }
713
- // sort → orderBy
714
- if (normalized.sort != null && normalized.orderBy == null) {
715
- normalized.orderBy = normalized.sort;
716
- delete normalized.sort;
717
- }
718
- // top → limit
719
- if (normalized.top != null && normalized.limit == null) {
720
- normalized.limit = normalized.top;
721
- delete normalized.top;
722
- }
723
- // skip → offset
724
- if (normalized.skip != null && normalized.offset == null) {
725
- normalized.offset = normalized.skip;
726
- delete normalized.skip;
727
- }
728
-
729
- // Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
730
- const result = await broker.call('data.query', { object: objectName, query: normalized }, { request: context.request });
731
- return { handled: true, response: this.success(result) };
732
- }
733
-
734
- // POST /data/:object (Create)
735
- if (m === 'POST') {
736
- // Spec: broker returns CreateDataResponse = { object, id, record }
737
- const result = await broker.call('data.create', { object: objectName, data: body }, { request: context.request });
738
- const res = this.success(result);
739
- res.status = 201;
740
- return { handled: true, response: res };
741
- }
742
- }
743
-
744
- return { handled: false };
745
- }
746
-
747
- /**
748
- * Handles Analytics requests
749
- * path: sub-path after /analytics/
750
- */
751
- async handleAnalytics(path: string, method: string, body: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
752
- const analyticsService = await this.getService(CoreServiceName.enum.analytics);
753
- if (!analyticsService) return { handled: false }; // 404 handled by caller if unhandled
754
-
755
- const m = method.toUpperCase();
756
- const subPath = path.replace(/^\/+/, '');
757
-
758
- // POST /analytics/query
759
- if (subPath === 'query' && m === 'POST') {
760
- const result = await analyticsService.query(body);
761
- return { handled: true, response: this.success(result) };
762
- }
763
-
764
- // GET /analytics/meta
765
- if (subPath === 'meta' && m === 'GET') {
766
- const result = await analyticsService.getMeta();
767
- return { handled: true, response: this.success(result) };
768
- }
769
-
770
- // POST /analytics/sql (Dry-run or debug)
771
- if (subPath === 'sql' && m === 'POST') {
772
- // Assuming service has generateSql method
773
- const result = await analyticsService.generateSql(body);
774
- return { handled: true, response: this.success(result) };
775
- }
776
-
777
- return { handled: false };
778
- }
779
-
780
- /**
781
- * Handles i18n requests
782
- * path: sub-path after /i18n/
783
- *
784
- * Routes:
785
- * GET /locales → getLocales
786
- * GET /translations/:locale → getTranslations (locale from path)
787
- * GET /translations?locale=xx → getTranslations (locale from query)
788
- * GET /labels/:object/:locale → getFieldLabels (both from path)
789
- * GET /labels/:object?locale=xx → getFieldLabels (locale from query)
790
- */
791
- async handleI18n(path: string, method: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
792
- const i18nService = await this.getService(CoreServiceName.enum.i18n);
793
- if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };
794
-
795
- const m = method.toUpperCase();
796
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
797
-
798
- if (m !== 'GET') return { handled: false };
799
-
800
- // GET /i18n/locales
801
- if (parts[0] === 'locales' && parts.length === 1) {
802
- const locales = i18nService.getLocales();
803
- return { handled: true, response: this.success({ locales }) };
804
- }
805
-
806
- // GET /i18n/translations/:locale OR /i18n/translations?locale=xx
807
- if (parts[0] === 'translations') {
808
- const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
809
- if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
810
-
811
- let translations = i18nService.getTranslations(locale);
812
-
813
- // Locale fallback: try resolving to an available locale when
814
- // the exact code yields empty translations (e.g. zh → zh-CN).
815
- if (Object.keys(translations).length === 0) {
816
- const availableLocales = typeof i18nService.getLocales === 'function'
817
- ? i18nService.getLocales() : [];
818
- const resolved = resolveLocale(locale, availableLocales);
819
- if (resolved && resolved !== locale) {
820
- translations = i18nService.getTranslations(resolved);
821
- return { handled: true, response: this.success({ locale: resolved, requestedLocale: locale, translations }) };
822
- }
823
- }
824
-
825
- return { handled: true, response: this.success({ locale, translations }) };
826
- }
827
-
828
- // GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
829
- if (parts[0] === 'labels' && parts.length >= 2) {
830
- const objectName = decodeURIComponent(parts[1]);
831
- let locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
832
- if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
833
-
834
- // Locale fallback for labels endpoint
835
- const availableLocales = typeof i18nService.getLocales === 'function'
836
- ? i18nService.getLocales() : [];
837
- const resolved = resolveLocale(locale, availableLocales);
838
- if (resolved) locale = resolved;
839
-
840
- if (typeof i18nService.getFieldLabels === 'function') {
841
- const labels = i18nService.getFieldLabels(objectName, locale);
842
- return { handled: true, response: this.success({ object: objectName, locale, labels }) };
843
- }
844
- // Fallback: derive field labels from full translation bundle
845
- const translations = i18nService.getTranslations(locale);
846
- const prefix = `o.${objectName}.fields.`;
847
- const labels: Record<string, string> = {};
848
- for (const [key, value] of Object.entries(translations)) {
849
- if (key.startsWith(prefix)) {
850
- labels[key.substring(prefix.length)] = value as string;
851
- }
852
- }
853
- return { handled: true, response: this.success({ object: objectName, locale, labels }) };
854
- }
855
-
856
- return { handled: false };
857
- }
858
-
859
- /**
860
- * Handles Package Management requests
861
- *
862
- * REST Endpoints:
863
- * - GET /packages → list all installed packages
864
- * - GET /packages/:id → get a specific package
865
- * - POST /packages → install a new package
866
- * - DELETE /packages/:id → uninstall a package
867
- * - PATCH /packages/:id/enable → enable a package
868
- * - PATCH /packages/:id/disable → disable a package
869
- * - POST /packages/:id/publish → publish a package (metadata snapshot)
870
- * - POST /packages/:id/revert → revert a package to last published state
871
- *
872
- * Uses ObjectQL SchemaRegistry directly (via the 'objectql' service)
873
- * with broker fallback for backward compatibility.
874
- */
875
- async handlePackages(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
876
- const m = method.toUpperCase();
877
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
878
-
879
- // Try to get SchemaRegistry from the ObjectQL service
880
- const qlService = await this.getObjectQLService();
881
- const registry = qlService?.registry;
882
-
883
- // If no registry available, try broker as fallback
884
- if (!registry) {
885
- if (this.kernel.broker) {
886
- return this.handlePackagesViaBroker(parts, m, body, query, context);
887
- }
888
- return { handled: true, response: this.error('Package service not available', 503) };
889
- }
890
-
891
- try {
892
- // GET /packages → list packages
893
- if (parts.length === 0 && m === 'GET') {
894
- let packages = registry.getAllPackages();
895
- // Apply optional filters
896
- if (query?.status) {
897
- packages = packages.filter((p: any) => p.status === query.status);
898
- }
899
- if (query?.type) {
900
- packages = packages.filter((p: any) => p.manifest?.type === query.type);
901
- }
902
- return { handled: true, response: this.success({ packages, total: packages.length }) };
903
- }
904
-
905
- // POST /packages → install package
906
- if (parts.length === 0 && m === 'POST') {
907
- const pkg = registry.installPackage(body.manifest || body, body.settings);
908
- const res = this.success(pkg);
909
- res.status = 201;
910
- return { handled: true, response: res };
911
- }
912
-
913
- // PATCH /packages/:id/enable
914
- if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
915
- const id = decodeURIComponent(parts[0]);
916
- const pkg = registry.enablePackage(id);
917
- if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
918
- return { handled: true, response: this.success(pkg) };
919
- }
920
-
921
- // PATCH /packages/:id/disable
922
- if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
923
- const id = decodeURIComponent(parts[0]);
924
- const pkg = registry.disablePackage(id);
925
- if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
926
- return { handled: true, response: this.success(pkg) };
927
- }
928
-
929
- // POST /packages/:id/publish → publish package metadata
930
- if (parts.length === 2 && parts[1] === 'publish' && m === 'POST') {
931
- const id = decodeURIComponent(parts[0]);
932
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
933
- if (metadataService && typeof (metadataService as any).publishPackage === 'function') {
934
- const result = await (metadataService as any).publishPackage(id, body || {});
935
- return { handled: true, response: this.success(result) };
936
- }
937
- // Broker fallback
938
- if (this.kernel.broker) {
939
- const result = await this.kernel.broker.call('metadata.publishPackage', { packageId: id, ...body }, { request: context.request });
940
- return { handled: true, response: this.success(result) };
941
- }
942
- return { handled: true, response: this.error('Metadata service not available', 503) };
943
- }
944
-
945
- // POST /packages/:id/revert → revert package to last published state
946
- if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') {
947
- const id = decodeURIComponent(parts[0]);
948
- const metadataService = await this.getService(CoreServiceName.enum.metadata);
949
- if (metadataService && typeof (metadataService as any).revertPackage === 'function') {
950
- await (metadataService as any).revertPackage(id);
951
- return { handled: true, response: this.success({ success: true }) };
952
- }
953
- // Broker fallback
954
- if (this.kernel.broker) {
955
- await this.kernel.broker.call('metadata.revertPackage', { packageId: id }, { request: context.request });
956
- return { handled: true, response: this.success({ success: true }) };
957
- }
958
- return { handled: true, response: this.error('Metadata service not available', 503) };
959
- }
960
-
961
- // GET /packages/:id → get package
962
- if (parts.length === 1 && m === 'GET') {
963
- const id = decodeURIComponent(parts[0]);
964
- const pkg = registry.getPackage(id);
965
- if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
966
- return { handled: true, response: this.success(pkg) };
967
- }
968
-
969
- // DELETE /packages/:id → uninstall package
970
- if (parts.length === 1 && m === 'DELETE') {
971
- const id = decodeURIComponent(parts[0]);
972
- const success = registry.uninstallPackage(id);
973
- if (!success) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
974
- return { handled: true, response: this.success({ success: true }) };
975
- }
976
- } catch (e: any) {
977
- return { handled: true, response: this.error(e.message, e.statusCode || 500) };
978
- }
979
-
980
- return { handled: false };
981
- }
982
-
983
- /**
984
- * Fallback: handle packages via broker (for backward compatibility)
985
- */
986
- private async handlePackagesViaBroker(parts: string[], m: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
987
- const broker = this.kernel.broker;
988
- try {
989
- if (parts.length === 0 && m === 'GET') {
990
- const result = await broker.call('package.list', query || {}, { request: context.request });
991
- return { handled: true, response: this.success(result) };
992
- }
993
- if (parts.length === 0 && m === 'POST') {
994
- const result = await broker.call('package.install', body, { request: context.request });
995
- const res = this.success(result);
996
- res.status = 201;
997
- return { handled: true, response: res };
998
- }
999
- if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
1000
- const id = decodeURIComponent(parts[0]);
1001
- const result = await broker.call('package.enable', { id }, { request: context.request });
1002
- return { handled: true, response: this.success(result) };
1003
- }
1004
- if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
1005
- const id = decodeURIComponent(parts[0]);
1006
- const result = await broker.call('package.disable', { id }, { request: context.request });
1007
- return { handled: true, response: this.success(result) };
1008
- }
1009
- if (parts.length === 1 && m === 'GET') {
1010
- const id = decodeURIComponent(parts[0]);
1011
- const result = await broker.call('package.get', { id }, { request: context.request });
1012
- return { handled: true, response: this.success(result) };
1013
- }
1014
- if (parts.length === 1 && m === 'DELETE') {
1015
- const id = decodeURIComponent(parts[0]);
1016
- const result = await broker.call('package.uninstall', { id }, { request: context.request });
1017
- return { handled: true, response: this.success(result) };
1018
- }
1019
- } catch (e: any) {
1020
- return { handled: true, response: this.error(e.message, e.statusCode || 500) };
1021
- }
1022
- return { handled: false };
1023
- }
1024
-
1025
- /**
1026
- * Handles Storage requests
1027
- * path: sub-path after /storage/
1028
- */
1029
- async handleStorage(path: string, method: string, file: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1030
- const storageService = await this.getService(CoreServiceName.enum['file-storage']) || this.kernel.services?.['file-storage'];
1031
- if (!storageService) {
1032
- return { handled: true, response: this.error('File storage not configured', 501) };
1033
- }
1034
-
1035
- const m = method.toUpperCase();
1036
- const parts = path.replace(/^\/+/, '').split('/');
1037
-
1038
- // POST /storage/upload
1039
- if (parts[0] === 'upload' && m === 'POST') {
1040
- if (!file) {
1041
- return { handled: true, response: this.error('No file provided', 400) };
1042
- }
1043
- const result = await storageService.upload(file, { request: context.request });
1044
- return { handled: true, response: this.success(result) };
1045
- }
1046
-
1047
- // GET /storage/file/:id
1048
- if (parts[0] === 'file' && parts[1] && m === 'GET') {
1049
- const id = parts[1];
1050
- const result = await storageService.download(id, { request: context.request });
1051
-
1052
- // Result can be URL (redirect), Stream/Blob, or metadata
1053
- if (result.url && result.redirect) {
1054
- // Must be handled by adapter to do actual redirect
1055
- return { handled: true, result: { type: 'redirect', url: result.url } };
1056
- }
1057
-
1058
- if (result.stream) {
1059
- // Must be handled by adapter to pipe stream
1060
- return {
1061
- handled: true,
1062
- result: {
1063
- type: 'stream',
1064
- stream: result.stream,
1065
- headers: {
1066
- 'Content-Type': result.mimeType || 'application/octet-stream',
1067
- 'Content-Length': result.size
1068
- }
1069
- }
1070
- };
1071
- }
1072
-
1073
- return { handled: true, response: this.success(result) };
1074
- }
1075
-
1076
- return { handled: false };
1077
- }
1078
-
1079
- /**
1080
- * Handles UI requests
1081
- * path: sub-path after /ui/
1082
- */
1083
- async handleUi(path: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1084
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
1085
-
1086
- // GET /ui/view/:object (with optional type param)
1087
- if (parts[0] === 'view' && parts[1]) {
1088
- const objectName = parts[1];
1089
- // Support both path param /view/obj/list AND query param /view/obj?type=list
1090
- const type = parts[2] || query?.type || 'list';
1091
-
1092
- const protocol = await this.resolveService('protocol');
1093
-
1094
- if (protocol && typeof protocol.getUiView === 'function') {
1095
- try {
1096
- const result = await protocol.getUiView({ object: objectName, type });
1097
- return { handled: true, response: this.success(result) };
1098
- } catch (e: any) {
1099
- return { handled: true, response: this.error(e.message, 500) };
1100
- }
1101
- } else {
1102
- return { handled: true, response: this.error('Protocol service not available', 503) };
1103
- }
1104
- }
1105
-
1106
- return { handled: false };
1107
- }
1108
-
1109
- /**
1110
- * Handles Automation requests
1111
- * path: sub-path after /automation/
1112
- *
1113
- * Routes:
1114
- * GET / → listFlows
1115
- * GET /:name → getFlow
1116
- * POST / → createFlow (registerFlow)
1117
- * PUT /:name → updateFlow
1118
- * DELETE /:name → deleteFlow (unregisterFlow)
1119
- * POST /:name/trigger → execute (legacy: trigger/:name also supported)
1120
- * POST /:name/toggle → toggleFlow
1121
- * GET /:name/runs → listRuns
1122
- * GET /:name/runs/:runId → getRun
1123
- */
1124
- async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
1125
- const automationService = await this.getService(CoreServiceName.enum.automation);
1126
- if (!automationService) return { handled: false };
1127
-
1128
- const m = method.toUpperCase();
1129
- const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
1130
-
1131
- // Legacy: POST /automation/trigger/:name
1132
- if (parts[0] === 'trigger' && parts[1] && m === 'POST') {
1133
- const triggerName = parts[1];
1134
- if (typeof automationService.trigger === 'function') {
1135
- const result = await automationService.trigger(triggerName, body, { request: context.request });
1136
- return { handled: true, response: this.success(result) };
1137
- }
1138
- // Fallback to execute
1139
- if (typeof automationService.execute === 'function') {
1140
- const result = await automationService.execute(triggerName, body);
1141
- return { handled: true, response: this.success(result) };
1142
- }
1143
- }
1144
-
1145
- // GET / → listFlows
1146
- if (parts.length === 0 && m === 'GET') {
1147
- if (typeof automationService.listFlows === 'function') {
1148
- const names = await automationService.listFlows();
1149
- return { handled: true, response: this.success({ flows: names, total: names.length, hasMore: false }) };
1150
- }
1151
- }
1152
-
1153
- // POST / → createFlow
1154
- if (parts.length === 0 && m === 'POST') {
1155
- if (typeof automationService.registerFlow === 'function') {
1156
- automationService.registerFlow(body?.name, body);
1157
- return { handled: true, response: this.success(body) };
1158
- }
1159
- }
1160
-
1161
- // Routes with :name
1162
- if (parts.length >= 1) {
1163
- const name = parts[0];
1164
-
1165
- // POST /:name/trigger → execute
1166
- if (parts[1] === 'trigger' && m === 'POST') {
1167
- if (typeof automationService.execute === 'function') {
1168
- const result = await automationService.execute(name, body);
1169
- return { handled: true, response: this.success(result) };
1170
- }
1171
- }
1172
-
1173
- // POST /:name/toggle → toggleFlow
1174
- if (parts[1] === 'toggle' && m === 'POST') {
1175
- if (typeof automationService.toggleFlow === 'function') {
1176
- await automationService.toggleFlow(name, body?.enabled ?? true);
1177
- return { handled: true, response: this.success({ name, enabled: body?.enabled ?? true }) };
1178
- }
1179
- }
1180
-
1181
- // GET /:name/runs/:runId → getRun
1182
- if (parts[1] === 'runs' && parts[2] && m === 'GET') {
1183
- if (typeof automationService.getRun === 'function') {
1184
- const run = await automationService.getRun(parts[2]);
1185
- if (!run) return { handled: true, response: this.error('Execution not found', 404) };
1186
- return { handled: true, response: this.success(run) };
1187
- }
1188
- }
1189
-
1190
- // GET /:name/runs → listRuns
1191
- if (parts[1] === 'runs' && !parts[2] && m === 'GET') {
1192
- if (typeof automationService.listRuns === 'function') {
1193
- const options = query ? { limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor } : undefined;
1194
- const runs = await automationService.listRuns(name, options);
1195
- return { handled: true, response: this.success({ runs, hasMore: false }) };
1196
- }
1197
- }
1198
-
1199
- // GET /:name → getFlow (no sub-path)
1200
- if (parts.length === 1 && m === 'GET') {
1201
- if (typeof automationService.getFlow === 'function') {
1202
- const flow = await automationService.getFlow(name);
1203
- if (!flow) return { handled: true, response: this.error('Flow not found', 404) };
1204
- return { handled: true, response: this.success(flow) };
1205
- }
1206
- }
1207
-
1208
- // PUT /:name → updateFlow
1209
- if (parts.length === 1 && m === 'PUT') {
1210
- if (typeof automationService.registerFlow === 'function') {
1211
- automationService.registerFlow(name, body?.definition ?? body);
1212
- return { handled: true, response: this.success(body?.definition ?? body) };
1213
- }
1214
- }
1215
-
1216
- // DELETE /:name → deleteFlow
1217
- if (parts.length === 1 && m === 'DELETE') {
1218
- if (typeof automationService.unregisterFlow === 'function') {
1219
- automationService.unregisterFlow(name);
1220
- return { handled: true, response: this.success({ name, deleted: true }) };
1221
- }
1222
- }
1223
- }
1224
-
1225
- return { handled: false };
1226
- }
1227
-
1228
- private getServicesMap(): Record<string, any> {
1229
- if (this.kernel.services instanceof Map) {
1230
- return Object.fromEntries(this.kernel.services);
1231
- }
1232
- return this.kernel.services || {};
1233
- }
1234
-
1235
- private async getService(name: CoreServiceName) {
1236
- return this.resolveService(name);
1237
- }
1238
-
1239
- /**
1240
- * Resolve any service by name, supporting async factories.
1241
- * Fallback chain: getServiceAsync → getService (sync) → context.getService → services map.
1242
- * Only returns when a non-null service is found; otherwise falls through to the next step.
1243
- */
1244
- private async resolveService(name: string) {
1245
- // Prefer async resolution to support factory-based services (e.g. auth, analytics, protocol)
1246
- if (typeof this.kernel.getServiceAsync === 'function') {
1247
- try {
1248
- const svc = await this.kernel.getServiceAsync(name);
1249
- if (svc != null) return svc;
1250
- } catch {
1251
- // Service not registered or async resolution failed — fall through
1252
- }
1253
- }
1254
- if (typeof this.kernel.getService === 'function') {
1255
- try {
1256
- const svc = await this.kernel.getService(name);
1257
- if (svc != null) return svc;
1258
- } catch {
1259
- // Service not registered or sync resolution threw "is async" — fall through
1260
- }
1261
- }
1262
- if (this.kernel?.context?.getService) {
1263
- try {
1264
- const svc = await this.kernel.context.getService(name);
1265
- if (svc != null) return svc;
1266
- } catch {
1267
- // Service not registered — fall through
1268
- }
1269
- }
1270
- const services = this.getServicesMap();
1271
- return services[name];
1272
- }
1273
-
1274
- /**
1275
- * Get the ObjectQL service which provides access to SchemaRegistry.
1276
- * Tries multiple access patterns since kernel structure varies.
1277
- */
1278
- private async getObjectQLService(): Promise<any> {
1279
- // 1. Try via resolveService (handles async factories, sync, context, and map)
1280
- try {
1281
- const svc = await this.resolveService('objectql');
1282
- if (svc?.registry) return svc;
1283
- } catch { /* service not available */ }
1284
- return null;
1285
- }
1286
-
1287
- private capitalize(s: string) {
1288
- return s.charAt(0).toUpperCase() + s.slice(1);
1289
- }
1290
-
1291
- /**
1292
- * Handle AI service routes (/ai/chat, /ai/models, /ai/conversations, etc.)
1293
- * Resolves the AI service and its built-in route handlers, then dispatches.
1294
- */
1295
- async handleAI(subPath: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1296
- let aiService: any;
1297
- try {
1298
- aiService = await this.resolveService('ai');
1299
- } catch {
1300
- // AI service not registered
1301
- }
1302
-
1303
- if (!aiService) {
1304
- return {
1305
- handled: true,
1306
- response: {
1307
- status: 404,
1308
- body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
1309
- },
1310
- };
1311
- }
1312
-
1313
- // The AI service exposes route definitions via buildAIRoutes.
1314
- // We match the request path against known AI route patterns.
1315
- const fullPath = `/api/v1${subPath}`;
1316
-
1317
- // Build a simple param-extracting matcher for route patterns like /api/v1/ai/conversations/:id
1318
- const matchRoute = (pattern: string, path: string): Record<string, string> | null => {
1319
- const patternParts = pattern.split('/');
1320
- const pathParts = path.split('/');
1321
- if (patternParts.length !== pathParts.length) return null;
1322
- const params: Record<string, string> = {};
1323
- for (let i = 0; i < patternParts.length; i++) {
1324
- if (patternParts[i].startsWith(':')) {
1325
- params[patternParts[i].substring(1)] = pathParts[i];
1326
- } else if (patternParts[i] !== pathParts[i]) {
1327
- return null;
1328
- }
1329
- }
1330
- return params;
1331
- };
1332
-
1333
- // Try to get route definitions from the AI service's cached routes
1334
- const routes = (this.kernel as any).__aiRoutes as Array<{
1335
- method: string; path: string; handler: (req: any) => Promise<any>;
1336
- }> | undefined;
1337
-
1338
- if (!routes) {
1339
- return {
1340
- handled: true,
1341
- response: {
1342
- status: 503,
1343
- body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
1344
- },
1345
- };
1346
- }
1347
-
1348
- for (const route of routes) {
1349
- if (route.method !== method) continue;
1350
- const params = matchRoute(route.path, fullPath);
1351
- if (params === null) continue;
1352
-
1353
- const result = await route.handler({ body, params, query });
1354
-
1355
- if (result.stream && result.events) {
1356
- // Return a streaming result for the adapter to handle
1357
- return {
1358
- handled: true,
1359
- result: {
1360
- type: 'stream',
1361
- contentType: result.vercelDataStream
1362
- ? 'text/plain; charset=utf-8'
1363
- : 'text/event-stream',
1364
- events: result.events,
1365
- vercelDataStream: result.vercelDataStream,
1366
- headers: {
1367
- 'Content-Type': result.vercelDataStream
1368
- ? 'text/plain; charset=utf-8'
1369
- : 'text/event-stream',
1370
- 'Cache-Control': 'no-cache',
1371
- 'Connection': 'keep-alive',
1372
- },
1373
- },
1374
- };
1375
- }
1376
-
1377
- return {
1378
- handled: true,
1379
- response: {
1380
- status: result.status,
1381
- body: result.body,
1382
- },
1383
- };
1384
- }
1385
-
1386
- return {
1387
- handled: true,
1388
- response: this.routeNotFound(subPath),
1389
- };
1390
- }
1391
-
1392
- /**
1393
- * Main Dispatcher Entry Point
1394
- * Routes the request to the appropriate handler based on path and precedence
1395
- */
1396
- async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise<HttpDispatcherResult> {
1397
- const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
1398
-
1399
- // 0. Discovery Endpoint (GET /discovery or GET /)
1400
- // Standard route: /discovery (protocol-compliant)
1401
- // Legacy route: / (empty path, for backward compatibility — MSW strips base URL)
1402
- if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') {
1403
- const info = await this.getDiscoveryInfo(prefix ?? '');
1404
- return {
1405
- handled: true,
1406
- response: this.success(info)
1407
- };
1408
- }
1409
-
1410
- // 0b. Health Endpoint (GET /health)
1411
- if (cleanPath === '/health' && method === 'GET') {
1412
- return {
1413
- handled: true,
1414
- response: this.success({
1415
- status: 'ok',
1416
- timestamp: new Date().toISOString(),
1417
- version: '1.0.0',
1418
- uptime: typeof process !== 'undefined' ? process.uptime() : undefined,
1419
- }),
1420
- };
1421
- }
1422
-
1423
- // 1. System Protocols (Prefix-based)
1424
- if (cleanPath.startsWith('/auth')) {
1425
- return this.handleAuth(cleanPath.substring(5), method, body, context);
1426
- }
1427
-
1428
- if (cleanPath.startsWith('/meta')) {
1429
- return this.handleMetadata(cleanPath.substring(5), context, method, body, query);
1430
- }
1431
-
1432
- if (cleanPath.startsWith('/data')) {
1433
- return this.handleData(cleanPath.substring(5), method, body, query, context);
1434
- }
1435
-
1436
- if (cleanPath.startsWith('/graphql')) {
1437
- if (method === 'POST') return this.handleGraphQL(body, context);
1438
- // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it
1439
- }
1440
-
1441
- if (cleanPath.startsWith('/storage')) {
1442
- return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload
1443
- }
1444
-
1445
- if (cleanPath.startsWith('/ui')) {
1446
- return this.handleUi(cleanPath.substring(3), query, context);
1447
- }
1448
-
1449
- if (cleanPath.startsWith('/automation')) {
1450
- return this.handleAutomation(cleanPath.substring(11), method, body, context, query);
1451
- }
1452
-
1453
- if (cleanPath.startsWith('/analytics')) {
1454
- return this.handleAnalytics(cleanPath.substring(10), method, body, context);
1455
- }
1456
-
1457
- if (cleanPath.startsWith('/packages')) {
1458
- return this.handlePackages(cleanPath.substring(9), method, body, query, context);
1459
- }
1460
-
1461
- if (cleanPath.startsWith('/i18n')) {
1462
- return this.handleI18n(cleanPath.substring(5), method, query, context);
1463
- }
1464
-
1465
- // AI Service — delegate to the registered AI route handlers
1466
- if (cleanPath.startsWith('/ai')) {
1467
- return this.handleAI(cleanPath, method, body, query, context);
1468
- }
1469
-
1470
- // OpenAPI Specification
1471
- if (cleanPath === '/openapi.json' && method === 'GET') {
1472
- const broker = this.ensureBroker();
1473
- try {
1474
- const result = await broker.call('metadata.generateOpenApi', {}, { request: context.request });
1475
- return { handled: true, response: this.success(result) };
1476
- } catch (e) {
1477
- // If not implemented, fall through or return 404
1478
- }
1479
- }
1480
-
1481
- // 2. Custom API Endpoints (Registry lookup)
1482
- // Check if there is a custom endpoint defined for this path
1483
- const result = await this.handleApiEndpoint(cleanPath, method, body, query, context);
1484
- if (result.handled) return result;
1485
-
1486
- // 3. Fallback — return semantic 404 with diagnostic info
1487
- return {
1488
- handled: true,
1489
- response: this.routeNotFound(cleanPath),
1490
- };
1491
- }
1492
-
1493
- /**
1494
- * Handles Custom API Endpoints defined in metadata
1495
- */
1496
- async handleApiEndpoint(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1497
- const broker = this.ensureBroker();
1498
- try {
1499
- // Attempt to find a matching endpoint in the registry
1500
- // This assumes a 'metadata.matchEndpoint' action exists in the kernel/registry
1501
- // path should include initial slash e.g. /api/v1/customers
1502
- const endpoint = await broker.call('metadata.matchEndpoint', { path, method });
1503
-
1504
- if (endpoint) {
1505
- // Execute the endpoint target logic
1506
- if (endpoint.type === 'flow') {
1507
- const result = await broker.call('automation.runFlow', {
1508
- flowId: endpoint.target,
1509
- inputs: { ...query, ...body, _request: context.request }
1510
- });
1511
- return { handled: true, response: this.success(result) };
1512
- }
1513
-
1514
- if (endpoint.type === 'script') {
1515
- const result = await broker.call('automation.runScript', {
1516
- scriptName: endpoint.target,
1517
- context: { ...query, ...body, request: context.request }
1518
- }, { request: context.request });
1519
- return { handled: true, response: this.success(result) };
1520
- }
1521
-
1522
- if (endpoint.type === 'object_operation') {
1523
- // e.g. Proxy to an object action
1524
- if (endpoint.objectParams) {
1525
- const { object, operation } = endpoint.objectParams;
1526
- // Map standard CRUD operations
1527
- if (operation === 'find') {
1528
- const result = await broker.call('data.query', { object, query }, { request: context.request });
1529
- // Spec: FindDataResponse = { object, records, total?, hasMore? }
1530
- return { handled: true, response: this.success(result.records, { total: result.total }) };
1531
- }
1532
- if (operation === 'get' && query.id) {
1533
- const result = await broker.call('data.get', { object, id: query.id }, { request: context.request });
1534
- return { handled: true, response: this.success(result) };
1535
- }
1536
- if (operation === 'create') {
1537
- const result = await broker.call('data.create', { object, data: body }, { request: context.request });
1538
- return { handled: true, response: this.success(result) };
1539
- }
1540
- }
1541
- }
1542
-
1543
- if (endpoint.type === 'proxy') {
1544
- // Simple proxy implementation (requires a network call, which usually is done by a service but here we can stub return)
1545
- // In real implementation this might fetch(endpoint.target)
1546
- // For now, return target info
1547
- return {
1548
- handled: true,
1549
- response: {
1550
- status: 200,
1551
- body: { proxy: true, target: endpoint.target, note: 'Proxy execution requires http-client service' }
1552
- }
1553
- };
1554
- }
1555
- }
1556
- } catch (e) {
1557
- // If matchEndpoint fails (e.g. not found), we just return not handled
1558
- // so we can fallback to 404 or other handlers
1559
- }
1560
-
1561
- return { handled: false };
1562
- }
1563
- }