@objectstack/runtime 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,16 +1,23 @@
1
1
  {
2
2
  "name": "@objectstack/runtime",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "license": "Apache-2.0",
5
5
  "description": "ObjectStack Core Runtime & Query Engine",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
9
16
  "dependencies": {
10
17
  "zod": "^3.24.1",
11
- "@objectstack/core": "1.0.10",
12
- "@objectstack/spec": "1.0.10",
13
- "@objectstack/types": "1.0.10"
18
+ "@objectstack/core": "1.0.12",
19
+ "@objectstack/spec": "1.0.12",
20
+ "@objectstack/types": "1.0.12"
14
21
  },
15
22
  "devDependencies": {
16
23
  "typescript": "^5.0.0",
package/src/app-plugin.ts CHANGED
@@ -99,22 +99,45 @@ export class AppPlugin implements Plugin {
99
99
  }
100
100
 
101
101
  // Data Seeding
102
- // Check for 'data' in manifest (Legacy or Stack Definition)
102
+ // Collect seed data from multiple locations (top-level `data` preferred, `manifest.data` for backward compat)
103
+ const seedDatasets: any[] = [];
104
+
105
+ // 1. Top-level `data` field (new standard location on ObjectStackDefinition)
106
+ if (Array.isArray(this.bundle.data)) {
107
+ seedDatasets.push(...this.bundle.data);
108
+ }
109
+
110
+ // 2. Legacy: `manifest.data` (backward compatibility)
103
111
  const manifest = this.bundle.manifest || this.bundle;
104
112
  if (manifest && Array.isArray(manifest.data)) {
105
- ctx.logger.info(`[AppPlugin] Found initial data for ${appId}`, { count: manifest.data.length });
106
- for (const dataset of manifest.data) {
113
+ seedDatasets.push(...manifest.data);
114
+ }
115
+
116
+ // Resolve short object names to FQN using the package's namespace.
117
+ // e.g., seed `object: 'task'` in namespace 'todo' → 'todo__task'
118
+ // Reserved namespaces ('base', 'system') are not prefixed.
119
+ const namespace = (this.bundle.manifest || this.bundle)?.namespace as string | undefined;
120
+ const RESERVED_NS = new Set(['base', 'system']);
121
+ const toFQN = (name: string) => {
122
+ if (name.includes('__') || !namespace || RESERVED_NS.has(namespace)) return name;
123
+ return `${namespace}__${name}`;
124
+ };
125
+
126
+ if (seedDatasets.length > 0) {
127
+ ctx.logger.info(`[AppPlugin] Found ${seedDatasets.length} seed datasets for ${appId}`);
128
+ for (const dataset of seedDatasets) {
107
129
  if (dataset.object && Array.isArray(dataset.records)) {
108
- ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${dataset.object}`);
130
+ const objectFQN = toFQN(dataset.object);
131
+ ctx.logger.info(`[Seeder] Seeding ${dataset.records.length} records for ${objectFQN}`);
109
132
  for (const record of dataset.records) {
110
133
  try {
111
134
  // Use ObjectQL engine to insert data
112
135
  // This ensures driver resolution and hook execution
113
136
  // Use 'insert' which corresponds to 'create' in driver
114
- await ql.insert(dataset.object, record);
137
+ await ql.insert(objectFQN, record);
115
138
  } catch (err: any) {
116
139
  // Ignore duplicate errors if needed, or log/warn
117
- ctx.logger.warn(`[Seeder] Failed to insert ${dataset.object} record:`, { error: err.message });
140
+ ctx.logger.warn(`[Seeder] Failed to insert ${objectFQN} record:`, { error: err.message });
118
141
  }
119
142
  }
120
143
  }
@@ -0,0 +1,58 @@
1
+
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import { HttpDispatcher } from './http-dispatcher';
4
+ import { ObjectKernel } from '@objectstack/core';
5
+
6
+ describe('HttpDispatcher Root Handling', () => {
7
+ let kernel: ObjectKernel;
8
+ let dispatcher: HttpDispatcher;
9
+
10
+ beforeEach(() => {
11
+ // Mock minimal Kernel structure
12
+ kernel = {
13
+ services: {},
14
+ broker: {
15
+ call: vi.fn(),
16
+ },
17
+ context: {
18
+ getService: vi.fn(),
19
+ }
20
+ } as any;
21
+
22
+ dispatcher = new HttpDispatcher(kernel);
23
+ });
24
+
25
+ it('should handled GET request to root path ("") correctly', async () => {
26
+ const context = { request: {} };
27
+ const method = 'GET';
28
+ // MSW passes empty string when stripping base URL
29
+ const path = '';
30
+ const body = undefined;
31
+ const query = {};
32
+
33
+ const result = await dispatcher.dispatch(method, path, body, query, context);
34
+
35
+ expect(result.handled).toBe(true);
36
+ expect(result.response).toBeDefined();
37
+ expect(result.response?.status).toBe(200);
38
+
39
+ const data = result.response?.body?.data;
40
+ expect(data).toBeDefined();
41
+ // getDiscoveryInfo returns 'name' not 'apiName'
42
+ expect(data.name).toBe('ObjectOS');
43
+ expect(data.version).toBe('1.0.0');
44
+ expect(data.routes).toBeDefined();
45
+ // Since we passed empty prefix in dispatch code (hardcoded), routes should be relative
46
+ expect(data.routes.metadata).toBe('/meta');
47
+ });
48
+
49
+ it('should NOT handle POST request to root path ("")', async () => {
50
+ const context = { request: {} };
51
+ const method = 'POST';
52
+ const path = '';
53
+
54
+ const result = await dispatcher.dispatch(method, path, {}, {}, context);
55
+
56
+ expect(result.handled).toBe(false);
57
+ });
58
+ });
@@ -57,19 +57,25 @@ export class HttpDispatcher {
57
57
  const hasAnalytics = !!services[CoreServiceName.enum.analytics];
58
58
  const hasHub = !!services[CoreServiceName.enum.hub];
59
59
 
60
- return {
61
- name: 'ObjectOS',
62
- version: '1.0.0',
63
- environment: getEnv('NODE_ENV', 'development'),
64
- routes: {
60
+ const routes = {
65
61
  data: `${prefix}/data`,
66
- metadata: `${prefix}/metadata`,
62
+ metadata: `${prefix}/meta`,
63
+ packages: `${prefix}/packages`,
67
64
  auth: `${prefix}/auth`,
65
+ ui: `${prefix}/ui`,
68
66
  graphql: hasGraphQL ? `${prefix}/graphql` : undefined,
69
67
  storage: hasFiles ? `${prefix}/storage` : undefined,
70
68
  analytics: hasAnalytics ? `${prefix}/analytics` : undefined,
71
69
  hub: hasHub ? `${prefix}/hub` : undefined,
72
- },
70
+ automation: `${prefix}/automation`,
71
+ };
72
+
73
+ return {
74
+ name: 'ObjectOS',
75
+ version: '1.0.0',
76
+ environment: getEnv('NODE_ENV', 'development'),
77
+ routes,
78
+ endpoints: routes, // Alias for backward compatibility with some clients
73
79
  features: {
74
80
  graphql: hasGraphQL,
75
81
  search: hasSearch,
@@ -131,15 +137,26 @@ export class HttpDispatcher {
131
137
  * Standard: /metadata/:type/:name
132
138
  * Fallback for backward compat: /metadata (all objects), /metadata/:objectName (get object)
133
139
  */
134
- async handleMetadata(path: string, context: HttpProtocolContext, method?: string, body?: any): Promise<HttpDispatcherResult> {
140
+ async handleMetadata(path: string, context: HttpProtocolContext, method?: string, body?: any, query?: any): Promise<HttpDispatcherResult> {
135
141
  const broker = this.ensureBroker();
136
142
  const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
137
143
 
138
144
  // GET /metadata/types
139
145
  if (parts[0] === 'types') {
140
- // This would normally come from a registry service
141
- // For now we mock the types supported by core
142
- return { handled: true, response: this.success({ types: ['objects', 'apps', 'plugins'] }) };
146
+ // Try protocol service for dynamic types
147
+ const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
148
+ if (protocol && typeof protocol.getMetaTypes === 'function') {
149
+ const result = await protocol.getMetaTypes({});
150
+ return { handled: true, response: this.success(result) };
151
+ }
152
+ // Fallback: ask broker for registered types
153
+ try {
154
+ const data = await broker.call('metadata.types', {}, { request: context.request });
155
+ return { handled: true, response: this.success(data) };
156
+ } catch {
157
+ // Last resort: hardcoded defaults
158
+ return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
159
+ }
143
160
  }
144
161
 
145
162
  // /metadata/:type/:name
@@ -172,12 +189,30 @@ export class HttpDispatcher {
172
189
 
173
190
  try {
174
191
  // Try specific calls based on type
175
- if (type === 'objects') {
192
+ if (type === 'objects' || type === 'object') {
176
193
  const data = await broker.call('metadata.getObject', { objectName: name }, { request: context.request });
177
194
  return { handled: true, response: this.success(data) };
178
195
  }
179
- // Generic call for other types if supported
180
- const data = await broker.call(`metadata.get${this.capitalize(type.slice(0, -1))}`, { name }, { request: context.request });
196
+
197
+ // If type is singular (e.g. 'app'), use it directly
198
+ // If plural (e.g. 'apps'), slice it
199
+ const singularType = type.endsWith('s') ? type.slice(0, -1) : type;
200
+
201
+ // Try Protocol Service First (Preferred)
202
+ const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
203
+ if (protocol && typeof protocol.getMetaItem === 'function') {
204
+ try {
205
+ const data = await protocol.getMetaItem({ type: singularType, name });
206
+ return { handled: true, response: this.success(data) };
207
+ } catch (e: any) {
208
+ // Protocol might throw if not found or not supported
209
+ // Fallback to broker?
210
+ }
211
+ }
212
+
213
+ // Generic call for other types if supported via Broker (Legacy)
214
+ const method = `metadata.get${this.capitalize(singularType)}`;
215
+ const data = await broker.call(method, { name }, { request: context.request });
181
216
  return { handled: true, response: this.success(data) };
182
217
  } catch (e: any) {
183
218
  // Fallback: treat first part as object name if only 1 part (handled below)
@@ -189,27 +224,61 @@ export class HttpDispatcher {
189
224
  // GET /metadata/:type (List items of type) OR /metadata/:objectName (Legacy)
190
225
  if (parts.length === 1) {
191
226
  const typeOrName = parts[0];
227
+ // Extract optional package filter from query string
228
+ const packageId = query?.package || undefined;
192
229
 
193
- // Heuristic: if it maps to a known type, list it. Else treat as object name.
194
- if (['objects', 'apps', 'plugins'].includes(typeOrName)) {
195
- if (typeOrName === 'objects') {
196
- const data = await broker.call('metadata.objects', {}, { request: context.request });
197
- return { handled: true, response: this.success(data) };
198
- }
199
- // Try generic list
200
- const data = await broker.call(`metadata.${typeOrName}`, {}, { request: context.request });
201
- return { handled: true, response: this.success(data) };
230
+ // Try protocol service first for any type
231
+ const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
232
+ if (protocol && typeof protocol.getMetaItems === 'function') {
233
+ try {
234
+ const data = await protocol.getMetaItems({ type: typeOrName, packageId });
235
+ // Return any valid response from protocol (including empty items arrays)
236
+ if (data && (data.items !== undefined || Array.isArray(data))) {
237
+ return { handled: true, response: this.success(data) };
238
+ }
239
+ } catch {
240
+ // Protocol doesn't know this type, fall through
241
+ }
202
242
  }
203
243
 
204
- // Legacy: /metadata/:objectName
205
- const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request });
206
- return { handled: true, response: this.success(data) };
244
+ // Try broker for the type
245
+ try {
246
+ if (typeOrName === 'objects') {
247
+ const data = await broker.call('metadata.objects', { packageId }, { request: context.request });
248
+ return { handled: true, response: this.success(data) };
249
+ }
250
+ const data = await broker.call(`metadata.${typeOrName}`, { packageId }, { request: context.request });
251
+ if (data !== null && data !== undefined) {
252
+ return { handled: true, response: this.success(data) };
253
+ }
254
+ } catch {
255
+ // Broker doesn't support this action, fall through
256
+ }
257
+
258
+ // Legacy: /metadata/:objectName (treat as single object lookup)
259
+ try {
260
+ const data = await broker.call('metadata.getObject', { objectName: typeOrName }, { request: context.request });
261
+ return { handled: true, response: this.success(data) };
262
+ } catch (e: any) {
263
+ return { handled: true, response: this.error(e.message, 404) };
264
+ }
207
265
  }
208
266
 
209
- // GET /metadata (List Objects - Default)
267
+ // GET /metadata return available metadata types
210
268
  if (parts.length === 0) {
211
- const data = await broker.call('metadata.objects', {}, { request: context.request });
212
- return { handled: true, response: this.success(data) };
269
+ // Try protocol service for dynamic types
270
+ const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
271
+ if (protocol && typeof protocol.getMetaTypes === 'function') {
272
+ const result = await protocol.getMetaTypes({});
273
+ return { handled: true, response: this.success(result) };
274
+ }
275
+ // Fallback: ask broker for registered types
276
+ try {
277
+ const data = await broker.call('metadata.types', {}, { request: context.request });
278
+ return { handled: true, response: this.success(data) };
279
+ } catch {
280
+ return { handled: true, response: this.success({ types: ['object', 'app', 'plugin'] }) };
281
+ }
213
282
  }
214
283
 
215
284
  return { handled: false };
@@ -236,14 +305,13 @@ export class HttpDispatcher {
236
305
 
237
306
  // POST /data/:object/query
238
307
  if (action === 'query' && m === 'POST') {
308
+ // Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
239
309
  const result = await broker.call('data.query', { object: objectName, ...body }, { request: context.request });
240
- return { handled: true, response: this.success(result.data, { count: result.count, limit: body.limit, skip: body.skip }) };
310
+ return { handled: true, response: this.success(result) };
241
311
  }
242
312
 
243
313
  // POST /data/:object/batch
244
314
  if (action === 'batch' && m === 'POST') {
245
- // Spec complaint: forward the whole body { operation, records, options }
246
- // Implementation in Kernel should handle the 'operation' field
247
315
  const result = await broker.call('data.batch', { object: objectName, ...body }, { request: context.request });
248
316
  return { handled: true, response: this.success(result) };
249
317
  }
@@ -251,35 +319,39 @@ export class HttpDispatcher {
251
319
  // GET /data/:object/:id
252
320
  if (parts.length === 2 && m === 'GET') {
253
321
  const id = parts[1];
254
- const data = await broker.call('data.get', { object: objectName, id, ...query }, { request: context.request });
255
- return { handled: true, response: this.success(data) };
322
+ // Spec: broker returns GetDataResponse = { object, id, record }
323
+ const result = await broker.call('data.get', { object: objectName, id, ...query }, { request: context.request });
324
+ return { handled: true, response: this.success(result) };
256
325
  }
257
326
 
258
327
  // PATCH /data/:object/:id
259
328
  if (parts.length === 2 && m === 'PATCH') {
260
329
  const id = parts[1];
261
- const data = await broker.call('data.update', { object: objectName, id, data: body }, { request: context.request });
262
- return { handled: true, response: this.success(data) };
330
+ // Spec: broker returns UpdateDataResponse = { object, id, record }
331
+ const result = await broker.call('data.update', { object: objectName, id, data: body }, { request: context.request });
332
+ return { handled: true, response: this.success(result) };
263
333
  }
264
334
 
265
335
  // DELETE /data/:object/:id
266
336
  if (parts.length === 2 && m === 'DELETE') {
267
337
  const id = parts[1];
268
- await broker.call('data.delete', { object: objectName, id }, { request: context.request });
269
- return { handled: true, response: this.success({ id, deleted: true }) };
338
+ // Spec: broker returns DeleteDataResponse = { object, id, deleted }
339
+ const result = await broker.call('data.delete', { object: objectName, id }, { request: context.request });
340
+ return { handled: true, response: this.success(result) };
270
341
  }
271
342
  } else {
272
343
  // GET /data/:object (List)
273
344
  if (m === 'GET') {
345
+ // Spec: broker returns FindDataResponse = { object, records, total?, hasMore? }
274
346
  const result = await broker.call('data.query', { object: objectName, filters: query }, { request: context.request });
275
- return { handled: true, response: this.success(result.data, { count: result.count }) };
347
+ return { handled: true, response: this.success(result) };
276
348
  }
277
349
 
278
350
  // POST /data/:object (Create)
279
351
  if (m === 'POST') {
280
- const data = await broker.call('data.create', { object: objectName, data: body }, { request: context.request });
281
- // Note: ideally 201
282
- const res = this.success(data);
352
+ // Spec: broker returns CreateDataResponse = { object, id, record }
353
+ const result = await broker.call('data.create', { object: objectName, data: body }, { request: context.request });
354
+ const res = this.success(result);
283
355
  res.status = 201;
284
356
  return { handled: true, response: res };
285
357
  }
@@ -321,6 +393,138 @@ export class HttpDispatcher {
321
393
  return { handled: false };
322
394
  }
323
395
 
396
+ /**
397
+ * Handles Package Management requests
398
+ *
399
+ * REST Endpoints:
400
+ * - GET /packages → list all installed packages
401
+ * - GET /packages/:id → get a specific package
402
+ * - POST /packages → install a new package
403
+ * - DELETE /packages/:id → uninstall a package
404
+ * - PATCH /packages/:id/enable → enable a package
405
+ * - PATCH /packages/:id/disable → disable a package
406
+ *
407
+ * Uses ObjectQL SchemaRegistry directly (via the 'objectql' service)
408
+ * with broker fallback for backward compatibility.
409
+ */
410
+ async handlePackages(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
411
+ const m = method.toUpperCase();
412
+ const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
413
+
414
+ // Try to get SchemaRegistry from the ObjectQL service
415
+ const qlService = this.getObjectQLService();
416
+ const registry = qlService?.registry;
417
+
418
+ // If no registry available, try broker as fallback
419
+ if (!registry) {
420
+ if (this.kernel.broker) {
421
+ return this.handlePackagesViaBroker(parts, m, body, query, context);
422
+ }
423
+ return { handled: true, response: this.error('Package service not available', 503) };
424
+ }
425
+
426
+ try {
427
+ // GET /packages → list packages
428
+ if (parts.length === 0 && m === 'GET') {
429
+ let packages = registry.getAllPackages();
430
+ // Apply optional filters
431
+ if (query?.status) {
432
+ packages = packages.filter((p: any) => p.status === query.status);
433
+ }
434
+ if (query?.type) {
435
+ packages = packages.filter((p: any) => p.manifest?.type === query.type);
436
+ }
437
+ return { handled: true, response: this.success({ packages, total: packages.length }) };
438
+ }
439
+
440
+ // POST /packages → install package
441
+ if (parts.length === 0 && m === 'POST') {
442
+ const pkg = registry.installPackage(body.manifest || body, body.settings);
443
+ const res = this.success(pkg);
444
+ res.status = 201;
445
+ return { handled: true, response: res };
446
+ }
447
+
448
+ // PATCH /packages/:id/enable
449
+ if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
450
+ const id = decodeURIComponent(parts[0]);
451
+ const pkg = registry.enablePackage(id);
452
+ if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
453
+ return { handled: true, response: this.success(pkg) };
454
+ }
455
+
456
+ // PATCH /packages/:id/disable
457
+ if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
458
+ const id = decodeURIComponent(parts[0]);
459
+ const pkg = registry.disablePackage(id);
460
+ if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
461
+ return { handled: true, response: this.success(pkg) };
462
+ }
463
+
464
+ // GET /packages/:id → get package
465
+ if (parts.length === 1 && m === 'GET') {
466
+ const id = decodeURIComponent(parts[0]);
467
+ const pkg = registry.getPackage(id);
468
+ if (!pkg) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
469
+ return { handled: true, response: this.success(pkg) };
470
+ }
471
+
472
+ // DELETE /packages/:id → uninstall package
473
+ if (parts.length === 1 && m === 'DELETE') {
474
+ const id = decodeURIComponent(parts[0]);
475
+ const success = registry.uninstallPackage(id);
476
+ if (!success) return { handled: true, response: this.error(`Package '${id}' not found`, 404) };
477
+ return { handled: true, response: this.success({ success: true }) };
478
+ }
479
+ } catch (e: any) {
480
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
481
+ }
482
+
483
+ return { handled: false };
484
+ }
485
+
486
+ /**
487
+ * Fallback: handle packages via broker (for backward compatibility)
488
+ */
489
+ private async handlePackagesViaBroker(parts: string[], m: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
490
+ const broker = this.kernel.broker;
491
+ try {
492
+ if (parts.length === 0 && m === 'GET') {
493
+ const result = await broker.call('package.list', query || {}, { request: context.request });
494
+ return { handled: true, response: this.success(result) };
495
+ }
496
+ if (parts.length === 0 && m === 'POST') {
497
+ const result = await broker.call('package.install', body, { request: context.request });
498
+ const res = this.success(result);
499
+ res.status = 201;
500
+ return { handled: true, response: res };
501
+ }
502
+ if (parts.length === 2 && parts[1] === 'enable' && m === 'PATCH') {
503
+ const id = decodeURIComponent(parts[0]);
504
+ const result = await broker.call('package.enable', { id }, { request: context.request });
505
+ return { handled: true, response: this.success(result) };
506
+ }
507
+ if (parts.length === 2 && parts[1] === 'disable' && m === 'PATCH') {
508
+ const id = decodeURIComponent(parts[0]);
509
+ const result = await broker.call('package.disable', { id }, { request: context.request });
510
+ return { handled: true, response: this.success(result) };
511
+ }
512
+ if (parts.length === 1 && m === 'GET') {
513
+ const id = decodeURIComponent(parts[0]);
514
+ const result = await broker.call('package.get', { id }, { request: context.request });
515
+ return { handled: true, response: this.success(result) };
516
+ }
517
+ if (parts.length === 1 && m === 'DELETE') {
518
+ const id = decodeURIComponent(parts[0]);
519
+ const result = await broker.call('package.uninstall', { id }, { request: context.request });
520
+ return { handled: true, response: this.success(result) };
521
+ }
522
+ } catch (e: any) {
523
+ return { handled: true, response: this.error(e.message, e.statusCode || 500) };
524
+ }
525
+ return { handled: false };
526
+ }
527
+
324
528
  /**
325
529
  * Handles Hub requests
326
530
  * path: sub-path after /hub/
@@ -450,6 +654,36 @@ export class HttpDispatcher {
450
654
  return { handled: false };
451
655
  }
452
656
 
657
+ /**
658
+ * Handles UI requests
659
+ * path: sub-path after /ui/
660
+ */
661
+ async handleUi(path: string, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> {
662
+ const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
663
+
664
+ // GET /ui/view/:object (with optional type param)
665
+ if (parts[0] === 'view' && parts[1]) {
666
+ const objectName = parts[1];
667
+ // Support both path param /view/obj/list AND query param /view/obj?type=list
668
+ const type = parts[2] || query?.type || 'list';
669
+
670
+ const protocol = this.kernel?.context?.getService ? this.kernel.context.getService('protocol') : null;
671
+
672
+ if (protocol && typeof protocol.getUiView === 'function') {
673
+ try {
674
+ const result = await protocol.getUiView({ object: objectName, type });
675
+ return { handled: true, response: this.success(result) };
676
+ } catch (e: any) {
677
+ return { handled: true, response: this.error(e.message, 500) };
678
+ }
679
+ } else {
680
+ return { handled: true, response: this.error('Protocol service not available', 503) };
681
+ }
682
+ }
683
+
684
+ return { handled: false };
685
+ }
686
+
453
687
  /**
454
688
  * Handles Automation requests
455
689
  * path: sub-path after /automation/
@@ -488,6 +722,31 @@ export class HttpDispatcher {
488
722
  return services[name];
489
723
  }
490
724
 
725
+ /**
726
+ * Get the ObjectQL service which provides access to SchemaRegistry.
727
+ * Tries multiple access patterns since kernel structure varies.
728
+ */
729
+ private getObjectQLService(): any {
730
+ // 1. Try via kernel.getService
731
+ if (typeof this.kernel.getService === 'function') {
732
+ try {
733
+ const svc = this.kernel.getService('objectql');
734
+ if (svc?.registry) return svc;
735
+ } catch { /* ignore */ }
736
+ }
737
+ // 2. Try via kernel context
738
+ if (this.kernel?.context?.getService) {
739
+ try {
740
+ const svc = this.kernel.context.getService('objectql');
741
+ if (svc?.registry) return svc;
742
+ } catch { /* ignore */ }
743
+ }
744
+ // 3. Try via services map
745
+ const services = this.getServicesMap();
746
+ if (services['objectql']?.registry) return services['objectql'];
747
+ return null;
748
+ }
749
+
491
750
  private capitalize(s: string) {
492
751
  return s.charAt(0).toUpperCase() + s.slice(1);
493
752
  }
@@ -499,13 +758,24 @@ export class HttpDispatcher {
499
758
  async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
500
759
  const cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths
501
760
 
761
+ // 0. Root Discovery Endpoint (GET /)
762
+ // Handles request to base URL (e.g. /api/v1) which MSW strips to empty string
763
+ if (cleanPath === '' && method === 'GET') {
764
+ // We use '' as prefix since we are internal dispatcher
765
+ const info = this.getDiscoveryInfo('');
766
+ return {
767
+ handled: true,
768
+ response: this.success(info)
769
+ };
770
+ }
771
+
502
772
  // 1. System Protocols (Prefix-based)
503
773
  if (cleanPath.startsWith('/auth')) {
504
774
  return this.handleAuth(cleanPath.substring(5), method, body, context);
505
775
  }
506
776
 
507
- if (cleanPath.startsWith('/metadata')) {
508
- return this.handleMetadata(cleanPath.substring(9), context);
777
+ if (cleanPath.startsWith('/meta')) {
778
+ return this.handleMetadata(cleanPath.substring(5), context, method, body, query);
509
779
  }
510
780
 
511
781
  if (cleanPath.startsWith('/data')) {
@@ -521,6 +791,10 @@ export class HttpDispatcher {
521
791
  return this.handleStorage(cleanPath.substring(8), method, body, context); // body here is file/stream for upload
522
792
  }
523
793
 
794
+ if (cleanPath.startsWith('/ui')) {
795
+ return this.handleUi(cleanPath.substring(3), query, context);
796
+ }
797
+
524
798
  if (cleanPath.startsWith('/automation')) {
525
799
  return this.handleAutomation(cleanPath.substring(11), method, body, context);
526
800
  }
@@ -533,6 +807,10 @@ export class HttpDispatcher {
533
807
  return this.handleHub(cleanPath.substring(4), method, body, query, context);
534
808
  }
535
809
 
810
+ if (cleanPath.startsWith('/packages')) {
811
+ return this.handlePackages(cleanPath.substring(9), method, body, query, context);
812
+ }
813
+
536
814
  // OpenAPI Specification
537
815
  if (cleanPath === '/openapi.json' && method === 'GET') {
538
816
  const broker = this.ensureBroker();