@objectstack/rest 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.
package/dist/index.js CHANGED
@@ -209,11 +209,360 @@ var RouteGroupBuilder = class {
209
209
  };
210
210
 
211
211
  // src/rest-server.ts
212
+ var logError = (...args) => globalThis.console?.error(...args);
213
+ function mapDataError(error, object) {
214
+ if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
215
+ return {
216
+ status: 403,
217
+ body: {
218
+ error: error?.message ?? "Permission denied",
219
+ code: "PERMISSION_DENIED",
220
+ ...object ? { object } : {}
221
+ }
222
+ };
223
+ }
224
+ const raw = String(error?.message ?? error ?? "");
225
+ const lower = raw.toLowerCase();
226
+ if (raw.includes("[ProjectKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
227
+ const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
228
+ const isFailed = lower.includes("status='failed'");
229
+ return {
230
+ status: isProvisioning ? 503 : isFailed ? 502 : 404,
231
+ body: {
232
+ error: raw,
233
+ code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
234
+ }
235
+ };
236
+ }
237
+ const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
238
+ if (looksLikeUnknownObject) {
239
+ return {
240
+ status: 404,
241
+ body: {
242
+ error: object ? `Object '${object}' is not registered` : "Object not found",
243
+ code: "object_not_found",
244
+ object
245
+ }
246
+ };
247
+ }
248
+ return { status: 400, body: { error: raw || "Bad request" } };
249
+ }
250
+ function isExpectedDataStatus(status) {
251
+ return status === 403 || status === 404 || status === 502 || status === 503;
252
+ }
212
253
  var RestServer = class {
213
- constructor(server, protocol, config = {}) {
254
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
214
255
  this.protocol = protocol;
215
256
  this.config = this.normalizeConfig(config);
216
257
  this.routeManager = new RouteManager(server);
258
+ this.kernelManager = kernelManager;
259
+ this.envRegistry = envRegistry;
260
+ this.defaultProjectIdProvider = defaultProjectIdProvider;
261
+ this.authServiceProvider = authServiceProvider;
262
+ this.objectQLProvider = objectQLProvider;
263
+ }
264
+ /**
265
+ * Resolve the protocol for a given request. When `projectId` is present
266
+ * and a KernelManager is wired, fetch the per-project kernel's
267
+ * `protocol` service so metadata / data / UI reads hit the project's
268
+ * own registry and datastore.
269
+ *
270
+ * When `projectId` is absent on an unscoped route and an `envRegistry`
271
+ * is wired (runtime mode), the resolution chain is:
272
+ * 1. Hostname → projectId (`envRegistry.resolveByHostname`)
273
+ * 2. `X-Project-Id` header → projectId (`envRegistry.resolveById`)
274
+ * 3. Default-project fallback (`defaultProjectIdProvider`, set by
275
+ * `createSingleProjectPlugin`)
276
+ * 4. Control-plane protocol captured at boot.
277
+ *
278
+ * Special case: `projectId === 'platform'` is a reserved virtual id used
279
+ * by Studio to address the control plane through the regular project
280
+ * URL shape (`/projects/platform/...`). It is NOT a row in the projects
281
+ * table, so we must never call `KernelManager.getOrCreate('platform')`.
282
+ * Instead, return the control-plane protocol directly. This lets Studio
283
+ * (and any other client) speak a single, uniform URL family without
284
+ * duplicating route logic for the platform surface.
285
+ */
286
+ async resolveProtocol(projectId, req) {
287
+ if (projectId === "platform") return this.protocol;
288
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
289
+ const host = this.extractHostname(req);
290
+ if (host) {
291
+ try {
292
+ const result = await this.envRegistry.resolveByHostname(host);
293
+ if (result?.projectId) projectId = result.projectId;
294
+ } catch {
295
+ }
296
+ }
297
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
298
+ const headerVal = this.extractProjectIdHeader(req);
299
+ if (headerVal) {
300
+ try {
301
+ const driver = await this.envRegistry.resolveById(headerVal);
302
+ if (driver) projectId = headerVal;
303
+ } catch {
304
+ }
305
+ }
306
+ }
307
+ }
308
+ if (!projectId && this.defaultProjectIdProvider) {
309
+ try {
310
+ const def = this.defaultProjectIdProvider();
311
+ if (def) projectId = def;
312
+ } catch {
313
+ }
314
+ }
315
+ if (!projectId || !this.kernelManager) return this.protocol;
316
+ const kernel = await this.kernelManager.getOrCreate(projectId);
317
+ return kernel.getServiceAsync("protocol");
318
+ }
319
+ /**
320
+ * Resolve the i18n service for the request's project (or control plane
321
+ * when no project id is in scope). Returns `undefined` when no service is
322
+ * registered, so callers can short-circuit and skip translation rather
323
+ * than failing.
324
+ *
325
+ * Mirrors `resolveProtocol`'s lookup chain: explicit `projectId` from the
326
+ * route → kernel-managed `i18n` service. Control-plane / unscoped
327
+ * requests intentionally return `undefined` because the platform kernel
328
+ * does not own per-app translation bundles.
329
+ */
330
+ async resolveI18nService(projectId) {
331
+ if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
332
+ try {
333
+ const kernel = await this.kernelManager.getOrCreate(projectId);
334
+ return await kernel.getServiceAsync("i18n");
335
+ } catch {
336
+ return void 0;
337
+ }
338
+ }
339
+ /**
340
+ * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
341
+ * the better-auth session via the project's `auth` service. Returns
342
+ * `undefined` for anonymous requests so callers can pass `context` as-is
343
+ * to the protocol layer (the SecurityPlugin treats undefined as anon).
344
+ */
345
+ async resolveExecCtx(projectId, req) {
346
+ try {
347
+ let authService;
348
+ let kernel;
349
+ if (projectId && projectId !== "platform" && this.kernelManager) {
350
+ kernel = await this.kernelManager.getOrCreate(projectId);
351
+ authService = await kernel.getServiceAsync("auth").catch(() => void 0);
352
+ }
353
+ if (!authService && this.defaultProjectIdProvider && this.kernelManager) {
354
+ try {
355
+ const def = this.defaultProjectIdProvider();
356
+ if (def) {
357
+ kernel = await this.kernelManager.getOrCreate(def);
358
+ authService = await kernel.getServiceAsync("auth").catch(() => void 0);
359
+ }
360
+ } catch {
361
+ }
362
+ }
363
+ if (!authService && this.authServiceProvider) {
364
+ authService = await this.authServiceProvider(projectId).catch(() => void 0);
365
+ }
366
+ if (!authService) return void 0;
367
+ let api = authService.api;
368
+ if (!api && typeof authService.getApi === "function") {
369
+ api = await authService.getApi();
370
+ }
371
+ if (!api?.getSession) return void 0;
372
+ const rawHeaders = req?.headers;
373
+ let headers;
374
+ if (rawHeaders && typeof rawHeaders.get === "function") {
375
+ headers = rawHeaders;
376
+ } else if (rawHeaders && typeof rawHeaders === "object") {
377
+ headers = new globalThis.Headers();
378
+ for (const [k, v] of Object.entries(rawHeaders)) {
379
+ if (Array.isArray(v)) v.forEach((x) => headers.append(k, String(x)));
380
+ else if (v != null) headers.set(k, String(v));
381
+ }
382
+ } else {
383
+ return void 0;
384
+ }
385
+ const session = await api.getSession({ headers });
386
+ if (!session?.user?.id) return void 0;
387
+ const userId = session.user.id;
388
+ const tenantId = session.session?.activeOrganizationId ?? void 0;
389
+ const permissions = [];
390
+ const roles = [];
391
+ try {
392
+ let ql;
393
+ if (kernel) {
394
+ ql = await kernel.getServiceAsync("objectql").catch(() => void 0);
395
+ }
396
+ if (!ql && this.objectQLProvider) {
397
+ ql = await this.objectQLProvider(projectId).catch(() => void 0);
398
+ }
399
+ if (ql && typeof ql.find === "function") {
400
+ const sysOpts = { context: { isSystem: true } };
401
+ const memberRows = await ql.find("sys_member", {
402
+ where: tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId },
403
+ limit: 50,
404
+ ...sysOpts
405
+ }).catch(() => []);
406
+ for (const m of memberRows ?? []) {
407
+ if (typeof m.role === "string") {
408
+ for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
409
+ if (!roles.includes(r)) roles.push(r);
410
+ }
411
+ }
412
+ }
413
+ const upsRows = await ql.find("sys_user_permission_set", {
414
+ where: { user_id: userId },
415
+ limit: 100,
416
+ ...sysOpts
417
+ }).catch(() => []);
418
+ const psIds = /* @__PURE__ */ new Set();
419
+ for (const r of upsRows ?? []) {
420
+ const orgScope = r.organization_id ?? null;
421
+ if (!orgScope || tenantId && orgScope === tenantId) {
422
+ const pid = r.permission_set_id ?? r.permissionSetId;
423
+ if (pid) psIds.add(pid);
424
+ }
425
+ }
426
+ if (psIds.size > 0) {
427
+ const psRows = await ql.find("sys_permission_set", {
428
+ where: { id: { $in: Array.from(psIds) } },
429
+ limit: 500,
430
+ ...sysOpts
431
+ }).catch(() => []);
432
+ for (const ps of psRows ?? []) {
433
+ if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
434
+ }
435
+ }
436
+ }
437
+ } catch {
438
+ }
439
+ return {
440
+ userId,
441
+ tenantId,
442
+ roles,
443
+ permissions,
444
+ isSystem: false
445
+ };
446
+ } catch {
447
+ return void 0;
448
+ }
449
+ }
450
+ /**
451
+ * Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
452
+ * `II18nService` instance. Returns `undefined` when no locales are
453
+ * registered so callers can avoid translation work.
454
+ */
455
+ buildTranslationBundle(i18n) {
456
+ if (!i18n || typeof i18n.getLocales !== "function" || typeof i18n.getTranslations !== "function") {
457
+ return void 0;
458
+ }
459
+ const locales = i18n.getLocales();
460
+ if (!locales.length) return void 0;
461
+ const bundle = {};
462
+ for (const locale of locales) {
463
+ const data = i18n.getTranslations(locale);
464
+ if (data && typeof data === "object") bundle[locale] = data;
465
+ }
466
+ return Object.keys(bundle).length ? bundle : void 0;
467
+ }
468
+ /**
469
+ * Parse the highest-priority locale from an `Accept-Language` header.
470
+ * Falls back to a `?locale=` query parameter, then to the i18n service's
471
+ * default locale. Returns `undefined` when no preference is expressed
472
+ * (callers will then return untranslated metadata).
473
+ */
474
+ extractLocale(req, i18n) {
475
+ const headers = req?.headers;
476
+ let header;
477
+ if (headers) {
478
+ header = typeof headers.get === "function" ? headers.get("accept-language") ?? void 0 : headers["accept-language"] ?? headers["Accept-Language"];
479
+ }
480
+ if (typeof header === "string" && header.length > 0) {
481
+ const top = header.split(",")[0]?.split(";")[0]?.trim();
482
+ if (top) return top;
483
+ }
484
+ const queryLocale = req?.query?.locale;
485
+ if (typeof queryLocale === "string" && queryLocale.length > 0) return queryLocale;
486
+ if (i18n && typeof i18n.getDefaultLocale === "function") {
487
+ const def = i18n.getDefaultLocale();
488
+ if (typeof def === "string" && def.length > 0) return def;
489
+ }
490
+ return void 0;
491
+ }
492
+ /**
493
+ * Translate a single metadata document (view or action) when an i18n
494
+ * service is registered for the request's project and the requested
495
+ * locale yields a match. Falls through unchanged for unsupported types
496
+ * or missing translations.
497
+ */
498
+ async translateMetaItem(req, type, projectId, item) {
499
+ if (!item || typeof item !== "object") return item;
500
+ if (type !== "view" && type !== "action") return item;
501
+ const i18n = await this.resolveI18nService(projectId);
502
+ const bundle = this.buildTranslationBundle(i18n);
503
+ if (!bundle) return item;
504
+ const locale = this.extractLocale(req, i18n);
505
+ if (!locale) return item;
506
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
507
+ return translateMetadataDocument(type, item, bundle, { locale });
508
+ }
509
+ /**
510
+ * Translate a list of metadata documents using `translateMetaItem`.
511
+ */
512
+ async translateMetaItems(req, type, projectId, items) {
513
+ if (!Array.isArray(items)) return items;
514
+ if (type !== "view" && type !== "action") return items;
515
+ const i18n = await this.resolveI18nService(projectId);
516
+ const bundle = this.buildTranslationBundle(i18n);
517
+ if (!bundle) return items;
518
+ const locale = this.extractLocale(req, i18n);
519
+ if (!locale) return items;
520
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
521
+ return items.map((item) => translateMetadataDocument(type, item, bundle, { locale }));
522
+ }
523
+ /**
524
+ * Pull the request hostname (without port) from a Node-style `req` or
525
+ * a Fetch-style request wrapper. Returns undefined when no Host header
526
+ * is available.
527
+ */
528
+ extractHostname(req) {
529
+ const headers = req?.headers;
530
+ let host;
531
+ if (headers) {
532
+ if (typeof headers.get === "function") {
533
+ host = headers.get("host") ?? void 0;
534
+ } else {
535
+ host = headers.host ?? headers.Host;
536
+ }
537
+ }
538
+ if (!host && typeof req?.hostname === "string") host = req.hostname;
539
+ if (!host && typeof req?.url === "string") {
540
+ try {
541
+ host = new globalThis.URL(req.url).host;
542
+ } catch {
543
+ }
544
+ }
545
+ if (!host) return void 0;
546
+ return String(host).split(":")[0].toLowerCase();
547
+ }
548
+ /**
549
+ * Pull the `X-Project-Id` header from a Node- or Fetch-style request.
550
+ * Header names are case-insensitive; we probe both casings to cover
551
+ * adapters that don't normalize headers (e.g. raw Node http).
552
+ */
553
+ extractProjectIdHeader(req) {
554
+ const headers = req?.headers;
555
+ if (!headers) return void 0;
556
+ let val;
557
+ if (typeof headers.get === "function") {
558
+ val = headers.get("x-project-id") ?? headers.get("X-Project-Id");
559
+ } else {
560
+ val = headers["x-project-id"] ?? headers["X-Project-Id"];
561
+ }
562
+ if (Array.isArray(val)) val = val[0];
563
+ if (typeof val !== "string") return void 0;
564
+ const trimmed = val.trim();
565
+ return trimmed.length > 0 ? trimmed : void 0;
217
566
  }
218
567
  /**
219
568
  * Normalize configuration with defaults
@@ -234,6 +583,8 @@ var RestServer = class {
234
583
  enableUi: api.enableUi ?? true,
235
584
  enableBatch: api.enableBatch ?? true,
236
585
  enableDiscovery: api.enableDiscovery ?? true,
586
+ enableProjectScoping: api.enableProjectScoping ?? false,
587
+ projectResolution: api.projectResolution ?? "auto",
237
588
  documentation: api.documentation,
238
589
  responseFormat: api.responseFormat
239
590
  },
@@ -286,51 +637,88 @@ var RestServer = class {
286
637
  const { api } = this.config;
287
638
  return api.apiPath ?? `${api.basePath}/${api.version}`;
288
639
  }
640
+ /**
641
+ * Get the project-scoped base path for a given unscoped base.
642
+ * Example: `/api/v1` → `/api/v1/projects/:projectId`.
643
+ */
644
+ getScopedBasePath(basePath) {
645
+ return `${basePath}/projects/:projectId`;
646
+ }
289
647
  /**
290
648
  * Register all REST API routes
649
+ *
650
+ * When `enableProjectScoping` is true, routes are registered under
651
+ * `/api/v1/projects/:projectId/...`. The `projectResolution` strategy
652
+ * controls whether unscoped legacy routes remain available:
653
+ * - `required` → only scoped routes registered.
654
+ * - `optional` / `auto` → both scoped and unscoped routes registered.
291
655
  */
292
656
  registerRoutes() {
293
657
  const basePath = this.getApiBasePath();
294
- if (this.config.api.enableDiscovery) {
295
- this.registerDiscoveryEndpoints(basePath);
296
- }
297
- if (this.config.api.enableMetadata) {
298
- this.registerMetadataEndpoints(basePath);
299
- }
300
- if (this.config.api.enableUi) {
301
- this.registerUiEndpoints(basePath);
302
- }
303
- if (this.config.api.enableCrud) {
304
- this.registerCrudEndpoints(basePath);
305
- }
306
- if (this.config.api.enableBatch) {
307
- this.registerBatchEndpoints(basePath);
658
+ const { enableProjectScoping, projectResolution } = this.config.api;
659
+ const registerForBase = (bp) => {
660
+ if (this.config.api.enableDiscovery) {
661
+ this.registerDiscoveryEndpoints(bp);
662
+ }
663
+ if (this.config.api.enableMetadata) {
664
+ this.registerMetadataEndpoints(bp);
665
+ }
666
+ if (this.config.api.enableUi) {
667
+ this.registerUiEndpoints(bp);
668
+ }
669
+ if (this.config.api.enableCrud) {
670
+ this.registerCrudEndpoints(bp);
671
+ }
672
+ if (this.config.api.enableBatch) {
673
+ this.registerBatchEndpoints(bp);
674
+ }
675
+ };
676
+ if (enableProjectScoping) {
677
+ const scopedBase = this.getScopedBasePath(basePath);
678
+ if (projectResolution === "required") {
679
+ registerForBase(scopedBase);
680
+ } else {
681
+ registerForBase(basePath);
682
+ registerForBase(scopedBase);
683
+ }
684
+ } else {
685
+ registerForBase(basePath);
308
686
  }
309
687
  }
310
688
  /**
311
689
  * Register discovery endpoints
312
690
  */
313
691
  registerDiscoveryEndpoints(basePath) {
314
- const discoveryHandler = async (_req, res) => {
692
+ const isScoped = basePath.includes("/projects/:projectId");
693
+ const discoveryHandler = async (req, res) => {
315
694
  try {
316
695
  const discovery = await this.protocol.getDiscovery();
317
696
  discovery.version = this.config.api.version;
697
+ const realBase = isScoped ? basePath.replace(":projectId", req.params?.projectId ?? ":projectId") : basePath;
318
698
  if (discovery.routes) {
319
699
  if (this.config.api.enableCrud) {
320
- discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`;
700
+ discovery.routes.data = `${realBase}${this.config.crud.dataPrefix}`;
321
701
  }
322
702
  if (this.config.api.enableMetadata) {
323
- discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`;
703
+ discovery.routes.metadata = `${realBase}${this.config.metadata.prefix}`;
324
704
  }
325
705
  if (this.config.api.enableUi) {
326
- discovery.routes.ui = `${basePath}/ui`;
706
+ discovery.routes.ui = `${realBase}/ui`;
327
707
  }
328
708
  if (discovery.routes.auth) {
329
- discovery.routes.auth = `${basePath}/auth`;
709
+ const unscopedBase = isScoped ? basePath.replace(/\/projects\/:projectId$/, "") : basePath;
710
+ discovery.routes.auth = `${unscopedBase}/auth`;
330
711
  }
331
712
  }
713
+ discovery.scoping = {
714
+ enabled: this.config.api.enableProjectScoping,
715
+ resolution: this.config.api.projectResolution,
716
+ scoped: isScoped,
717
+ projectId: isScoped ? req.params?.projectId : void 0
718
+ };
332
719
  res.json(discovery);
333
720
  } catch (error) {
721
+ logError("[REST] Unhandled error:", error);
334
722
  res.status(500).json({ error: error.message });
335
723
  }
336
724
  };
@@ -359,15 +747,19 @@ var RestServer = class {
359
747
  registerMetadataEndpoints(basePath) {
360
748
  const { metadata } = this.config;
361
749
  const metaPath = `${basePath}${metadata.prefix}`;
750
+ const isScoped = basePath.includes("/projects/:projectId");
362
751
  if (metadata.endpoints.types !== false) {
363
752
  this.routeManager.register({
364
753
  method: "GET",
365
754
  path: metaPath,
366
- handler: async (_req, res) => {
755
+ handler: async (req, res) => {
367
756
  try {
368
- const types = await this.protocol.getMetaTypes();
757
+ const projectId = isScoped ? req.params?.projectId : void 0;
758
+ const p = await this.resolveProtocol(projectId, req);
759
+ const types = await p.getMetaTypes();
369
760
  res.json(types);
370
761
  } catch (error) {
762
+ logError("[REST] Unhandled error:", error);
371
763
  res.status(500).json({ error: error.message });
372
764
  }
373
765
  },
@@ -384,9 +776,18 @@ var RestServer = class {
384
776
  handler: async (req, res) => {
385
777
  try {
386
778
  const packageId = req.query?.package || void 0;
387
- const items = await this.protocol.getMetaItems({ type: req.params.type, packageId });
388
- res.json(items);
779
+ const projectId = isScoped ? req.params?.projectId : void 0;
780
+ const p = await this.resolveProtocol(projectId, req);
781
+ const items = await p.getMetaItems({
782
+ type: req.params.type,
783
+ packageId,
784
+ ...projectId ? { projectId } : {}
785
+ });
786
+ const translated = await this.translateMetaItems(req, req.params.type, projectId, items);
787
+ res.header("Vary", "Accept-Language");
788
+ res.json(translated);
389
789
  } catch (error) {
790
+ logError("[REST] Unhandled error:", error);
390
791
  res.status(404).json({ error: error.message });
391
792
  }
392
793
  },
@@ -402,15 +803,18 @@ var RestServer = class {
402
803
  path: `${metaPath}/:type/:name`,
403
804
  handler: async (req, res) => {
404
805
  try {
405
- if (metadata.enableCache && this.protocol.getMetaItemCached) {
806
+ const projectId = isScoped ? req.params?.projectId : void 0;
807
+ const p = await this.resolveProtocol(projectId, req);
808
+ if (metadata.enableCache && p.getMetaItemCached) {
406
809
  const cacheRequest = {
407
810
  ifNoneMatch: req.headers["if-none-match"],
408
811
  ifModifiedSince: req.headers["if-modified-since"]
409
812
  };
410
- const result = await this.protocol.getMetaItemCached({
813
+ const result = await p.getMetaItemCached({
411
814
  type: req.params.type,
412
815
  name: req.params.name,
413
- cacheRequest
816
+ cacheRequest,
817
+ ...projectId ? { projectId } : {}
414
818
  });
415
819
  if (result.notModified) {
416
820
  res.status(304).send();
@@ -428,13 +832,20 @@ var RestServer = class {
428
832
  const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : "";
429
833
  res.header("Cache-Control", directives + maxAge);
430
834
  }
431
- res.json(result.data);
835
+ res.header("Vary", "Accept-Language");
836
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, result.data));
432
837
  } else {
433
838
  const packageId = req.query?.package || void 0;
434
- const item = await this.protocol.getMetaItem({ type: req.params.type, name: req.params.name, packageId });
435
- res.json(item);
839
+ const item = await p.getMetaItem({
840
+ type: req.params.type,
841
+ name: req.params.name,
842
+ packageId
843
+ });
844
+ res.header("Vary", "Accept-Language");
845
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
436
846
  }
437
847
  } catch (error) {
848
+ logError("[REST] Unhandled error:", error);
438
849
  res.status(404).json({ error: error.message });
439
850
  }
440
851
  },
@@ -449,17 +860,21 @@ var RestServer = class {
449
860
  path: `${metaPath}/:type/:name`,
450
861
  handler: async (req, res) => {
451
862
  try {
452
- if (!this.protocol.saveMetaItem) {
863
+ const projectId = isScoped ? req.params?.projectId : void 0;
864
+ const p = await this.resolveProtocol(projectId, req);
865
+ if (!p.saveMetaItem) {
453
866
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
454
867
  return;
455
868
  }
456
- const result = await this.protocol.saveMetaItem({
869
+ const result = await p.saveMetaItem({
457
870
  type: req.params.type,
458
871
  name: req.params.name,
459
- item: req.body
872
+ item: req.body,
873
+ ...projectId ? { projectId } : {}
460
874
  });
461
875
  res.json(result);
462
876
  } catch (error) {
877
+ logError("[REST] Unhandled error:", error);
463
878
  res.status(400).json({ error: error.message });
464
879
  }
465
880
  },
@@ -474,21 +889,26 @@ var RestServer = class {
474
889
  */
475
890
  registerUiEndpoints(basePath) {
476
891
  const uiPath = `${basePath}/ui`;
892
+ const isScoped = basePath.includes("/projects/:projectId");
477
893
  this.routeManager.register({
478
894
  method: "GET",
479
895
  path: `${uiPath}/view/:object/:type`,
480
896
  handler: async (req, res) => {
481
897
  try {
482
- if (this.protocol.getUiView) {
483
- const view = await this.protocol.getUiView({
898
+ const projectId = isScoped ? req.params?.projectId : void 0;
899
+ const p = await this.resolveProtocol(projectId, req);
900
+ if (p.getUiView) {
901
+ const view = await p.getUiView({
484
902
  object: req.params.object,
485
- type: req.params.type
903
+ type: req.params.type,
904
+ ...projectId ? { projectId } : {}
486
905
  });
487
906
  res.json(view);
488
907
  } else {
489
908
  res.status(501).json({ error: "UI View resolution not supported by protocol implementation" });
490
909
  }
491
910
  } catch (error) {
911
+ logError("[REST] Unhandled error:", error);
492
912
  res.status(404).json({ error: error.message });
493
913
  }
494
914
  },
@@ -504,6 +924,7 @@ var RestServer = class {
504
924
  registerCrudEndpoints(basePath) {
505
925
  const { crud } = this.config;
506
926
  const dataPath = `${basePath}${crud.dataPrefix}`;
927
+ const isScoped = basePath.includes("/projects/:projectId");
507
928
  const operations = crud.operations;
508
929
  if (operations.list) {
509
930
  this.routeManager.register({
@@ -511,13 +932,24 @@ var RestServer = class {
511
932
  path: `${dataPath}/:object`,
512
933
  handler: async (req, res) => {
513
934
  try {
514
- const result = await this.protocol.findData({
935
+ const projectId = isScoped ? req.params?.projectId : void 0;
936
+ const p = await this.resolveProtocol(projectId, req);
937
+ const context = await this.resolveExecCtx(projectId, req);
938
+ const result = await p.findData({
515
939
  object: req.params.object,
516
- query: req.query
940
+ query: req.query,
941
+ ...projectId ? { projectId } : {},
942
+ ...context ? { context } : {}
517
943
  });
518
944
  res.json(result);
519
945
  } catch (error) {
520
- res.status(400).json({ error: error.message });
946
+ const mapped = mapDataError(error, req.params?.object);
947
+ if (mapped.status === 404 || mapped.status === 503 || mapped.status === 502) {
948
+ res.status(mapped.status).json(mapped.body);
949
+ } else {
950
+ logError("[REST] Unhandled error:", error);
951
+ res.status(mapped.status).json(mapped.body);
952
+ }
521
953
  }
522
954
  },
523
955
  metadata: {
@@ -532,16 +964,23 @@ var RestServer = class {
532
964
  path: `${dataPath}/:object/:id`,
533
965
  handler: async (req, res) => {
534
966
  try {
967
+ const projectId = isScoped ? req.params?.projectId : void 0;
968
+ const p = await this.resolveProtocol(projectId, req);
535
969
  const { select, expand } = req.query || {};
536
- const result = await this.protocol.getData({
970
+ const context = await this.resolveExecCtx(projectId, req);
971
+ const result = await p.getData({
537
972
  object: req.params.object,
538
973
  id: req.params.id,
539
974
  ...select != null ? { select } : {},
540
- ...expand != null ? { expand } : {}
975
+ ...expand != null ? { expand } : {},
976
+ ...projectId ? { projectId } : {},
977
+ ...context ? { context } : {}
541
978
  });
542
979
  res.json(result);
543
980
  } catch (error) {
544
- res.status(404).json({ error: error.message });
981
+ const mapped = mapDataError(error, req.params?.object);
982
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
983
+ res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
545
984
  }
546
985
  },
547
986
  metadata: {
@@ -556,13 +995,20 @@ var RestServer = class {
556
995
  path: `${dataPath}/:object`,
557
996
  handler: async (req, res) => {
558
997
  try {
559
- const result = await this.protocol.createData({
998
+ const projectId = isScoped ? req.params?.projectId : void 0;
999
+ const p = await this.resolveProtocol(projectId, req);
1000
+ const context = await this.resolveExecCtx(projectId, req);
1001
+ const result = await p.createData({
560
1002
  object: req.params.object,
561
- data: req.body
1003
+ data: req.body,
1004
+ ...projectId ? { projectId } : {},
1005
+ ...context ? { context } : {}
562
1006
  });
563
1007
  res.status(201).json(result);
564
1008
  } catch (error) {
565
- res.status(400).json({ error: error.message });
1009
+ const mapped = mapDataError(error, req.params?.object);
1010
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1011
+ res.status(mapped.status).json(mapped.body);
566
1012
  }
567
1013
  },
568
1014
  metadata: {
@@ -577,14 +1023,21 @@ var RestServer = class {
577
1023
  path: `${dataPath}/:object/:id`,
578
1024
  handler: async (req, res) => {
579
1025
  try {
580
- const result = await this.protocol.updateData({
1026
+ const projectId = isScoped ? req.params?.projectId : void 0;
1027
+ const p = await this.resolveProtocol(projectId, req);
1028
+ const context = await this.resolveExecCtx(projectId, req);
1029
+ const result = await p.updateData({
581
1030
  object: req.params.object,
582
1031
  id: req.params.id,
583
- data: req.body
1032
+ data: req.body,
1033
+ ...projectId ? { projectId } : {},
1034
+ ...context ? { context } : {}
584
1035
  });
585
1036
  res.json(result);
586
1037
  } catch (error) {
587
- res.status(400).json({ error: error.message });
1038
+ const mapped = mapDataError(error, req.params?.object);
1039
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1040
+ res.status(mapped.status).json(mapped.body);
588
1041
  }
589
1042
  },
590
1043
  metadata: {
@@ -599,13 +1052,20 @@ var RestServer = class {
599
1052
  path: `${dataPath}/:object/:id`,
600
1053
  handler: async (req, res) => {
601
1054
  try {
602
- const result = await this.protocol.deleteData({
1055
+ const projectId = isScoped ? req.params?.projectId : void 0;
1056
+ const p = await this.resolveProtocol(projectId, req);
1057
+ const context = await this.resolveExecCtx(projectId, req);
1058
+ const result = await p.deleteData({
603
1059
  object: req.params.object,
604
- id: req.params.id
1060
+ id: req.params.id,
1061
+ ...projectId ? { projectId } : {},
1062
+ ...context ? { context } : {}
605
1063
  });
606
1064
  res.json(result);
607
1065
  } catch (error) {
608
- res.status(400).json({ error: error.message });
1066
+ const mapped = mapDataError(error, req.params?.object);
1067
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1068
+ res.status(mapped.status).json(mapped.body);
609
1069
  }
610
1070
  },
611
1071
  metadata: {
@@ -621,6 +1081,7 @@ var RestServer = class {
621
1081
  registerBatchEndpoints(basePath) {
622
1082
  const { crud, batch } = this.config;
623
1083
  const dataPath = `${basePath}${crud.dataPrefix}`;
1084
+ const isScoped = basePath.includes("/projects/:projectId");
624
1085
  const operations = batch.operations;
625
1086
  if (batch.enableBatchEndpoint && this.protocol.batchData) {
626
1087
  this.routeManager.register({
@@ -628,12 +1089,16 @@ var RestServer = class {
628
1089
  path: `${dataPath}/:object/batch`,
629
1090
  handler: async (req, res) => {
630
1091
  try {
631
- const result = await this.protocol.batchData({
1092
+ const projectId = isScoped ? req.params?.projectId : void 0;
1093
+ const p = await this.resolveProtocol(projectId, req);
1094
+ const result = await p.batchData({
632
1095
  object: req.params.object,
633
- request: req.body
1096
+ request: req.body,
1097
+ ...projectId ? { projectId } : {}
634
1098
  });
635
1099
  res.json(result);
636
1100
  } catch (error) {
1101
+ logError("[REST] Unhandled error:", error);
637
1102
  res.status(400).json({ error: error.message });
638
1103
  }
639
1104
  },
@@ -649,12 +1114,16 @@ var RestServer = class {
649
1114
  path: `${dataPath}/:object/createMany`,
650
1115
  handler: async (req, res) => {
651
1116
  try {
652
- const result = await this.protocol.createManyData({
1117
+ const projectId = isScoped ? req.params?.projectId : void 0;
1118
+ const p = await this.resolveProtocol(projectId, req);
1119
+ const result = await p.createManyData({
653
1120
  object: req.params.object,
654
- records: req.body || []
1121
+ records: req.body || [],
1122
+ ...projectId ? { projectId } : {}
655
1123
  });
656
1124
  res.status(201).json(result);
657
1125
  } catch (error) {
1126
+ logError("[REST] Unhandled error:", error);
658
1127
  res.status(400).json({ error: error.message });
659
1128
  }
660
1129
  },
@@ -670,12 +1139,16 @@ var RestServer = class {
670
1139
  path: `${dataPath}/:object/updateMany`,
671
1140
  handler: async (req, res) => {
672
1141
  try {
673
- const result = await this.protocol.updateManyData({
1142
+ const projectId = isScoped ? req.params?.projectId : void 0;
1143
+ const p = await this.resolveProtocol(projectId, req);
1144
+ const result = await p.updateManyData({
674
1145
  object: req.params.object,
675
- ...req.body
1146
+ ...req.body,
1147
+ ...projectId ? { projectId } : {}
676
1148
  });
677
1149
  res.json(result);
678
1150
  } catch (error) {
1151
+ logError("[REST] Unhandled error:", error);
679
1152
  res.status(400).json({ error: error.message });
680
1153
  }
681
1154
  },
@@ -691,12 +1164,16 @@ var RestServer = class {
691
1164
  path: `${dataPath}/:object/deleteMany`,
692
1165
  handler: async (req, res) => {
693
1166
  try {
694
- const result = await this.protocol.deleteManyData({
1167
+ const projectId = isScoped ? req.params?.projectId : void 0;
1168
+ const p = await this.resolveProtocol(projectId, req);
1169
+ const result = await p.deleteManyData({
695
1170
  object: req.params.object,
696
- ...req.body
1171
+ ...req.body,
1172
+ ...projectId ? { projectId } : {}
697
1173
  });
698
1174
  res.json(result);
699
1175
  } catch (error) {
1176
+ logError("[REST] Unhandled error:", error);
700
1177
  res.status(400).json({ error: error.message });
701
1178
  }
702
1179
  },
@@ -721,6 +1198,123 @@ var RestServer = class {
721
1198
  }
722
1199
  };
723
1200
 
1201
+ // src/package-routes.ts
1202
+ function registerPackageRoutes(server, packageService, basePath = "/api/v1", options = {}) {
1203
+ const packagesPath = `${basePath}/packages`;
1204
+ server.post(packagesPath, async (req, res) => {
1205
+ try {
1206
+ const { manifest, metadata } = req.body || {};
1207
+ if (!manifest || !metadata) {
1208
+ res.status(400).json({ error: "Missing required fields: manifest, metadata" });
1209
+ return;
1210
+ }
1211
+ if (!manifest.id || !manifest.version) {
1212
+ res.status(400).json({ error: "Invalid manifest: id and version are required" });
1213
+ return;
1214
+ }
1215
+ const result = await packageService.publish({ manifest, metadata });
1216
+ if (result.success) {
1217
+ res.json({
1218
+ success: true,
1219
+ message: `Published ${manifest.id}@${manifest.version}`,
1220
+ package: {
1221
+ id: manifest.id,
1222
+ version: manifest.version
1223
+ }
1224
+ });
1225
+ return;
1226
+ }
1227
+ res.status(400).json({ success: false, error: result.error });
1228
+ } catch (error) {
1229
+ res.status(500).json({ error: error.message });
1230
+ }
1231
+ });
1232
+ server.get(packagesPath, async (_req, res) => {
1233
+ try {
1234
+ const packagesMap = /* @__PURE__ */ new Map();
1235
+ if (options.protocol && typeof options.protocol.getMetaItems === "function") {
1236
+ try {
1237
+ const result = await options.protocol.getMetaItems({ type: "package" });
1238
+ if (result?.items) {
1239
+ for (const item of result.items) {
1240
+ const id = item.manifest?.id || item.id;
1241
+ if (id) {
1242
+ packagesMap.set(id, {
1243
+ ...item,
1244
+ source: "registry"
1245
+ });
1246
+ }
1247
+ }
1248
+ }
1249
+ } catch {
1250
+ }
1251
+ }
1252
+ try {
1253
+ const dbPackages = await packageService.list();
1254
+ for (const pkg of dbPackages) {
1255
+ const id = pkg.manifest?.id || pkg.id;
1256
+ if (id) {
1257
+ packagesMap.set(id, {
1258
+ ...packagesMap.get(id),
1259
+ ...pkg,
1260
+ source: packagesMap.has(id) ? "both" : "database"
1261
+ });
1262
+ }
1263
+ }
1264
+ } catch {
1265
+ }
1266
+ const packages = Array.from(packagesMap.values());
1267
+ res.json({ packages, total: packages.length });
1268
+ } catch (error) {
1269
+ res.status(500).json({ error: error.message });
1270
+ }
1271
+ });
1272
+ server.get(`${packagesPath}/:id`, async (req, res) => {
1273
+ try {
1274
+ const packageId = req.params.id;
1275
+ const version = req.query?.version || "latest";
1276
+ const pkg = await packageService.get(packageId, version);
1277
+ if (pkg) {
1278
+ res.json({ package: { ...pkg, source: "database" } });
1279
+ return;
1280
+ }
1281
+ if (options.protocol && typeof options.protocol.getMetaItems === "function") {
1282
+ try {
1283
+ const result = await options.protocol.getMetaItems({ type: "package" });
1284
+ const match = result?.items?.find(
1285
+ (item) => (item.manifest?.id || item.id) === packageId
1286
+ );
1287
+ if (match) {
1288
+ res.json({ package: { ...match, source: "registry" } });
1289
+ return;
1290
+ }
1291
+ } catch {
1292
+ }
1293
+ }
1294
+ res.status(404).json({ error: "Package not found" });
1295
+ } catch (error) {
1296
+ res.status(500).json({ error: error.message });
1297
+ }
1298
+ });
1299
+ server.delete(`${packagesPath}/:id`, async (req, res) => {
1300
+ try {
1301
+ const packageId = req.params.id;
1302
+ const version = req.query?.version;
1303
+ const result = await packageService.delete(packageId, version);
1304
+ if (result.success) {
1305
+ res.json({
1306
+ success: true,
1307
+ message: `Deleted ${packageId}${version ? `@${version}` : ""}`
1308
+ });
1309
+ return;
1310
+ }
1311
+ res.status(400).json({ success: false });
1312
+ } catch (error) {
1313
+ res.status(500).json({ error: error.message });
1314
+ }
1315
+ });
1316
+ }
1317
+
724
1318
  // src/rest-api-plugin.ts
725
1319
  function createRestApiPlugin(config = {}) {
726
1320
  return {
@@ -741,6 +1335,39 @@ function createRestApiPlugin(config = {}) {
741
1335
  protocol = ctx.getService(protocolService);
742
1336
  } catch (e) {
743
1337
  }
1338
+ let kernelManager;
1339
+ const kernelManagerService = config.kernelManagerServiceName || "kernel-manager";
1340
+ try {
1341
+ kernelManager = ctx.getService(kernelManagerService);
1342
+ } catch (e) {
1343
+ }
1344
+ let envRegistry;
1345
+ try {
1346
+ envRegistry = ctx.getService("env-registry");
1347
+ } catch (e) {
1348
+ }
1349
+ const defaultProjectIdProvider = () => {
1350
+ try {
1351
+ const dp = ctx.getService("default-project");
1352
+ return dp?.projectId;
1353
+ } catch {
1354
+ return void 0;
1355
+ }
1356
+ };
1357
+ const authServiceProvider = async (_projectId) => {
1358
+ try {
1359
+ return ctx.getService("auth");
1360
+ } catch {
1361
+ return void 0;
1362
+ }
1363
+ };
1364
+ const objectQLProvider = async (_projectId) => {
1365
+ try {
1366
+ return ctx.getService("objectql");
1367
+ } catch {
1368
+ return void 0;
1369
+ }
1370
+ };
744
1371
  if (!server) {
745
1372
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
746
1373
  return;
@@ -751,13 +1378,38 @@ function createRestApiPlugin(config = {}) {
751
1378
  }
752
1379
  ctx.logger.info("Hydrating REST API from Protocol...");
753
1380
  try {
754
- const restServer = new RestServer(server, protocol, config.api);
1381
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
755
1382
  restServer.registerRoutes();
756
1383
  ctx.logger.info("REST API successfully registered");
757
1384
  } catch (err) {
758
1385
  ctx.logger.error("Failed to register REST API routes", { error: err.message });
759
1386
  throw err;
760
1387
  }
1388
+ try {
1389
+ const packageService = ctx.getService("package");
1390
+ if (packageService) {
1391
+ const basePath = config.api?.api?.basePath || "/api";
1392
+ const version = config.api?.api?.version || "v1";
1393
+ const versionedBase = `${basePath}/${version}`;
1394
+ const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false;
1395
+ const projectResolution = config.api?.api?.projectResolution ?? "auto";
1396
+ if (enableProjectScoping && projectResolution === "required") {
1397
+ registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, {
1398
+ protocol
1399
+ });
1400
+ } else {
1401
+ registerPackageRoutes(server, packageService, versionedBase, { protocol });
1402
+ if (enableProjectScoping) {
1403
+ registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, {
1404
+ protocol
1405
+ });
1406
+ }
1407
+ }
1408
+ ctx.logger.info("Package management routes registered");
1409
+ }
1410
+ } catch (e) {
1411
+ ctx.logger.debug("Package service not available, package routes skipped");
1412
+ }
761
1413
  }
762
1414
  };
763
1415
  }