@objectstack/rest 4.0.4 → 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.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -238,11 +248,360 @@ var RouteGroupBuilder = class {
238
248
  };
239
249
 
240
250
  // src/rest-server.ts
251
+ var logError = (...args) => globalThis.console?.error(...args);
252
+ function mapDataError(error, object) {
253
+ if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
254
+ return {
255
+ status: 403,
256
+ body: {
257
+ error: error?.message ?? "Permission denied",
258
+ code: "PERMISSION_DENIED",
259
+ ...object ? { object } : {}
260
+ }
261
+ };
262
+ }
263
+ const raw = String(error?.message ?? error ?? "");
264
+ const lower = raw.toLowerCase();
265
+ if (raw.includes("[ProjectKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
266
+ const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
267
+ const isFailed = lower.includes("status='failed'");
268
+ return {
269
+ status: isProvisioning ? 503 : isFailed ? 502 : 404,
270
+ body: {
271
+ error: raw,
272
+ code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
273
+ }
274
+ };
275
+ }
276
+ 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");
277
+ if (looksLikeUnknownObject) {
278
+ return {
279
+ status: 404,
280
+ body: {
281
+ error: object ? `Object '${object}' is not registered` : "Object not found",
282
+ code: "object_not_found",
283
+ object
284
+ }
285
+ };
286
+ }
287
+ return { status: 400, body: { error: raw || "Bad request" } };
288
+ }
289
+ function isExpectedDataStatus(status) {
290
+ return status === 403 || status === 404 || status === 502 || status === 503;
291
+ }
241
292
  var RestServer = class {
242
- constructor(server, protocol, config = {}) {
293
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
243
294
  this.protocol = protocol;
244
295
  this.config = this.normalizeConfig(config);
245
296
  this.routeManager = new RouteManager(server);
297
+ this.kernelManager = kernelManager;
298
+ this.envRegistry = envRegistry;
299
+ this.defaultProjectIdProvider = defaultProjectIdProvider;
300
+ this.authServiceProvider = authServiceProvider;
301
+ this.objectQLProvider = objectQLProvider;
302
+ }
303
+ /**
304
+ * Resolve the protocol for a given request. When `projectId` is present
305
+ * and a KernelManager is wired, fetch the per-project kernel's
306
+ * `protocol` service so metadata / data / UI reads hit the project's
307
+ * own registry and datastore.
308
+ *
309
+ * When `projectId` is absent on an unscoped route and an `envRegistry`
310
+ * is wired (runtime mode), the resolution chain is:
311
+ * 1. Hostname → projectId (`envRegistry.resolveByHostname`)
312
+ * 2. `X-Project-Id` header → projectId (`envRegistry.resolveById`)
313
+ * 3. Default-project fallback (`defaultProjectIdProvider`, set by
314
+ * `createSingleProjectPlugin`)
315
+ * 4. Control-plane protocol captured at boot.
316
+ *
317
+ * Special case: `projectId === 'platform'` is a reserved virtual id used
318
+ * by Studio to address the control plane through the regular project
319
+ * URL shape (`/projects/platform/...`). It is NOT a row in the projects
320
+ * table, so we must never call `KernelManager.getOrCreate('platform')`.
321
+ * Instead, return the control-plane protocol directly. This lets Studio
322
+ * (and any other client) speak a single, uniform URL family without
323
+ * duplicating route logic for the platform surface.
324
+ */
325
+ async resolveProtocol(projectId, req) {
326
+ if (projectId === "platform") return this.protocol;
327
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
328
+ const host = this.extractHostname(req);
329
+ if (host) {
330
+ try {
331
+ const result = await this.envRegistry.resolveByHostname(host);
332
+ if (result?.projectId) projectId = result.projectId;
333
+ } catch {
334
+ }
335
+ }
336
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
337
+ const headerVal = this.extractProjectIdHeader(req);
338
+ if (headerVal) {
339
+ try {
340
+ const driver = await this.envRegistry.resolveById(headerVal);
341
+ if (driver) projectId = headerVal;
342
+ } catch {
343
+ }
344
+ }
345
+ }
346
+ }
347
+ if (!projectId && this.defaultProjectIdProvider) {
348
+ try {
349
+ const def = this.defaultProjectIdProvider();
350
+ if (def) projectId = def;
351
+ } catch {
352
+ }
353
+ }
354
+ if (!projectId || !this.kernelManager) return this.protocol;
355
+ const kernel = await this.kernelManager.getOrCreate(projectId);
356
+ return kernel.getServiceAsync("protocol");
357
+ }
358
+ /**
359
+ * Resolve the i18n service for the request's project (or control plane
360
+ * when no project id is in scope). Returns `undefined` when no service is
361
+ * registered, so callers can short-circuit and skip translation rather
362
+ * than failing.
363
+ *
364
+ * Mirrors `resolveProtocol`'s lookup chain: explicit `projectId` from the
365
+ * route → kernel-managed `i18n` service. Control-plane / unscoped
366
+ * requests intentionally return `undefined` because the platform kernel
367
+ * does not own per-app translation bundles.
368
+ */
369
+ async resolveI18nService(projectId) {
370
+ if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
371
+ try {
372
+ const kernel = await this.kernelManager.getOrCreate(projectId);
373
+ return await kernel.getServiceAsync("i18n");
374
+ } catch {
375
+ return void 0;
376
+ }
377
+ }
378
+ /**
379
+ * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
380
+ * the better-auth session via the project's `auth` service. Returns
381
+ * `undefined` for anonymous requests so callers can pass `context` as-is
382
+ * to the protocol layer (the SecurityPlugin treats undefined as anon).
383
+ */
384
+ async resolveExecCtx(projectId, req) {
385
+ try {
386
+ let authService;
387
+ let kernel;
388
+ if (projectId && projectId !== "platform" && this.kernelManager) {
389
+ kernel = await this.kernelManager.getOrCreate(projectId);
390
+ authService = await kernel.getServiceAsync("auth").catch(() => void 0);
391
+ }
392
+ if (!authService && this.defaultProjectIdProvider && this.kernelManager) {
393
+ try {
394
+ const def = this.defaultProjectIdProvider();
395
+ if (def) {
396
+ kernel = await this.kernelManager.getOrCreate(def);
397
+ authService = await kernel.getServiceAsync("auth").catch(() => void 0);
398
+ }
399
+ } catch {
400
+ }
401
+ }
402
+ if (!authService && this.authServiceProvider) {
403
+ authService = await this.authServiceProvider(projectId).catch(() => void 0);
404
+ }
405
+ if (!authService) return void 0;
406
+ let api = authService.api;
407
+ if (!api && typeof authService.getApi === "function") {
408
+ api = await authService.getApi();
409
+ }
410
+ if (!api?.getSession) return void 0;
411
+ const rawHeaders = req?.headers;
412
+ let headers;
413
+ if (rawHeaders && typeof rawHeaders.get === "function") {
414
+ headers = rawHeaders;
415
+ } else if (rawHeaders && typeof rawHeaders === "object") {
416
+ headers = new globalThis.Headers();
417
+ for (const [k, v] of Object.entries(rawHeaders)) {
418
+ if (Array.isArray(v)) v.forEach((x) => headers.append(k, String(x)));
419
+ else if (v != null) headers.set(k, String(v));
420
+ }
421
+ } else {
422
+ return void 0;
423
+ }
424
+ const session = await api.getSession({ headers });
425
+ if (!session?.user?.id) return void 0;
426
+ const userId = session.user.id;
427
+ const tenantId = session.session?.activeOrganizationId ?? void 0;
428
+ const permissions = [];
429
+ const roles = [];
430
+ try {
431
+ let ql;
432
+ if (kernel) {
433
+ ql = await kernel.getServiceAsync("objectql").catch(() => void 0);
434
+ }
435
+ if (!ql && this.objectQLProvider) {
436
+ ql = await this.objectQLProvider(projectId).catch(() => void 0);
437
+ }
438
+ if (ql && typeof ql.find === "function") {
439
+ const sysOpts = { context: { isSystem: true } };
440
+ const memberRows = await ql.find("sys_member", {
441
+ where: tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId },
442
+ limit: 50,
443
+ ...sysOpts
444
+ }).catch(() => []);
445
+ for (const m of memberRows ?? []) {
446
+ if (typeof m.role === "string") {
447
+ for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
448
+ if (!roles.includes(r)) roles.push(r);
449
+ }
450
+ }
451
+ }
452
+ const upsRows = await ql.find("sys_user_permission_set", {
453
+ where: { user_id: userId },
454
+ limit: 100,
455
+ ...sysOpts
456
+ }).catch(() => []);
457
+ const psIds = /* @__PURE__ */ new Set();
458
+ for (const r of upsRows ?? []) {
459
+ const orgScope = r.organization_id ?? null;
460
+ if (!orgScope || tenantId && orgScope === tenantId) {
461
+ const pid = r.permission_set_id ?? r.permissionSetId;
462
+ if (pid) psIds.add(pid);
463
+ }
464
+ }
465
+ if (psIds.size > 0) {
466
+ const psRows = await ql.find("sys_permission_set", {
467
+ where: { id: { $in: Array.from(psIds) } },
468
+ limit: 500,
469
+ ...sysOpts
470
+ }).catch(() => []);
471
+ for (const ps of psRows ?? []) {
472
+ if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
473
+ }
474
+ }
475
+ }
476
+ } catch {
477
+ }
478
+ return {
479
+ userId,
480
+ tenantId,
481
+ roles,
482
+ permissions,
483
+ isSystem: false
484
+ };
485
+ } catch {
486
+ return void 0;
487
+ }
488
+ }
489
+ /**
490
+ * Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
491
+ * `II18nService` instance. Returns `undefined` when no locales are
492
+ * registered so callers can avoid translation work.
493
+ */
494
+ buildTranslationBundle(i18n) {
495
+ if (!i18n || typeof i18n.getLocales !== "function" || typeof i18n.getTranslations !== "function") {
496
+ return void 0;
497
+ }
498
+ const locales = i18n.getLocales();
499
+ if (!locales.length) return void 0;
500
+ const bundle = {};
501
+ for (const locale of locales) {
502
+ const data = i18n.getTranslations(locale);
503
+ if (data && typeof data === "object") bundle[locale] = data;
504
+ }
505
+ return Object.keys(bundle).length ? bundle : void 0;
506
+ }
507
+ /**
508
+ * Parse the highest-priority locale from an `Accept-Language` header.
509
+ * Falls back to a `?locale=` query parameter, then to the i18n service's
510
+ * default locale. Returns `undefined` when no preference is expressed
511
+ * (callers will then return untranslated metadata).
512
+ */
513
+ extractLocale(req, i18n) {
514
+ const headers = req?.headers;
515
+ let header;
516
+ if (headers) {
517
+ header = typeof headers.get === "function" ? headers.get("accept-language") ?? void 0 : headers["accept-language"] ?? headers["Accept-Language"];
518
+ }
519
+ if (typeof header === "string" && header.length > 0) {
520
+ const top = header.split(",")[0]?.split(";")[0]?.trim();
521
+ if (top) return top;
522
+ }
523
+ const queryLocale = req?.query?.locale;
524
+ if (typeof queryLocale === "string" && queryLocale.length > 0) return queryLocale;
525
+ if (i18n && typeof i18n.getDefaultLocale === "function") {
526
+ const def = i18n.getDefaultLocale();
527
+ if (typeof def === "string" && def.length > 0) return def;
528
+ }
529
+ return void 0;
530
+ }
531
+ /**
532
+ * Translate a single metadata document (view or action) when an i18n
533
+ * service is registered for the request's project and the requested
534
+ * locale yields a match. Falls through unchanged for unsupported types
535
+ * or missing translations.
536
+ */
537
+ async translateMetaItem(req, type, projectId, item) {
538
+ if (!item || typeof item !== "object") return item;
539
+ if (type !== "view" && type !== "action") return item;
540
+ const i18n = await this.resolveI18nService(projectId);
541
+ const bundle = this.buildTranslationBundle(i18n);
542
+ if (!bundle) return item;
543
+ const locale = this.extractLocale(req, i18n);
544
+ if (!locale) return item;
545
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
546
+ return translateMetadataDocument(type, item, bundle, { locale });
547
+ }
548
+ /**
549
+ * Translate a list of metadata documents using `translateMetaItem`.
550
+ */
551
+ async translateMetaItems(req, type, projectId, items) {
552
+ if (!Array.isArray(items)) return items;
553
+ if (type !== "view" && type !== "action") return items;
554
+ const i18n = await this.resolveI18nService(projectId);
555
+ const bundle = this.buildTranslationBundle(i18n);
556
+ if (!bundle) return items;
557
+ const locale = this.extractLocale(req, i18n);
558
+ if (!locale) return items;
559
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
560
+ return items.map((item) => translateMetadataDocument(type, item, bundle, { locale }));
561
+ }
562
+ /**
563
+ * Pull the request hostname (without port) from a Node-style `req` or
564
+ * a Fetch-style request wrapper. Returns undefined when no Host header
565
+ * is available.
566
+ */
567
+ extractHostname(req) {
568
+ const headers = req?.headers;
569
+ let host;
570
+ if (headers) {
571
+ if (typeof headers.get === "function") {
572
+ host = headers.get("host") ?? void 0;
573
+ } else {
574
+ host = headers.host ?? headers.Host;
575
+ }
576
+ }
577
+ if (!host && typeof req?.hostname === "string") host = req.hostname;
578
+ if (!host && typeof req?.url === "string") {
579
+ try {
580
+ host = new globalThis.URL(req.url).host;
581
+ } catch {
582
+ }
583
+ }
584
+ if (!host) return void 0;
585
+ return String(host).split(":")[0].toLowerCase();
586
+ }
587
+ /**
588
+ * Pull the `X-Project-Id` header from a Node- or Fetch-style request.
589
+ * Header names are case-insensitive; we probe both casings to cover
590
+ * adapters that don't normalize headers (e.g. raw Node http).
591
+ */
592
+ extractProjectIdHeader(req) {
593
+ const headers = req?.headers;
594
+ if (!headers) return void 0;
595
+ let val;
596
+ if (typeof headers.get === "function") {
597
+ val = headers.get("x-project-id") ?? headers.get("X-Project-Id");
598
+ } else {
599
+ val = headers["x-project-id"] ?? headers["X-Project-Id"];
600
+ }
601
+ if (Array.isArray(val)) val = val[0];
602
+ if (typeof val !== "string") return void 0;
603
+ const trimmed = val.trim();
604
+ return trimmed.length > 0 ? trimmed : void 0;
246
605
  }
247
606
  /**
248
607
  * Normalize configuration with defaults
@@ -263,6 +622,8 @@ var RestServer = class {
263
622
  enableUi: api.enableUi ?? true,
264
623
  enableBatch: api.enableBatch ?? true,
265
624
  enableDiscovery: api.enableDiscovery ?? true,
625
+ enableProjectScoping: api.enableProjectScoping ?? false,
626
+ projectResolution: api.projectResolution ?? "auto",
266
627
  documentation: api.documentation,
267
628
  responseFormat: api.responseFormat
268
629
  },
@@ -315,51 +676,88 @@ var RestServer = class {
315
676
  const { api } = this.config;
316
677
  return api.apiPath ?? `${api.basePath}/${api.version}`;
317
678
  }
679
+ /**
680
+ * Get the project-scoped base path for a given unscoped base.
681
+ * Example: `/api/v1` → `/api/v1/projects/:projectId`.
682
+ */
683
+ getScopedBasePath(basePath) {
684
+ return `${basePath}/projects/:projectId`;
685
+ }
318
686
  /**
319
687
  * Register all REST API routes
688
+ *
689
+ * When `enableProjectScoping` is true, routes are registered under
690
+ * `/api/v1/projects/:projectId/...`. The `projectResolution` strategy
691
+ * controls whether unscoped legacy routes remain available:
692
+ * - `required` → only scoped routes registered.
693
+ * - `optional` / `auto` → both scoped and unscoped routes registered.
320
694
  */
321
695
  registerRoutes() {
322
696
  const basePath = this.getApiBasePath();
323
- if (this.config.api.enableDiscovery) {
324
- this.registerDiscoveryEndpoints(basePath);
325
- }
326
- if (this.config.api.enableMetadata) {
327
- this.registerMetadataEndpoints(basePath);
328
- }
329
- if (this.config.api.enableUi) {
330
- this.registerUiEndpoints(basePath);
331
- }
332
- if (this.config.api.enableCrud) {
333
- this.registerCrudEndpoints(basePath);
334
- }
335
- if (this.config.api.enableBatch) {
336
- this.registerBatchEndpoints(basePath);
697
+ const { enableProjectScoping, projectResolution } = this.config.api;
698
+ const registerForBase = (bp) => {
699
+ if (this.config.api.enableDiscovery) {
700
+ this.registerDiscoveryEndpoints(bp);
701
+ }
702
+ if (this.config.api.enableMetadata) {
703
+ this.registerMetadataEndpoints(bp);
704
+ }
705
+ if (this.config.api.enableUi) {
706
+ this.registerUiEndpoints(bp);
707
+ }
708
+ if (this.config.api.enableCrud) {
709
+ this.registerCrudEndpoints(bp);
710
+ }
711
+ if (this.config.api.enableBatch) {
712
+ this.registerBatchEndpoints(bp);
713
+ }
714
+ };
715
+ if (enableProjectScoping) {
716
+ const scopedBase = this.getScopedBasePath(basePath);
717
+ if (projectResolution === "required") {
718
+ registerForBase(scopedBase);
719
+ } else {
720
+ registerForBase(basePath);
721
+ registerForBase(scopedBase);
722
+ }
723
+ } else {
724
+ registerForBase(basePath);
337
725
  }
338
726
  }
339
727
  /**
340
728
  * Register discovery endpoints
341
729
  */
342
730
  registerDiscoveryEndpoints(basePath) {
343
- const discoveryHandler = async (_req, res) => {
731
+ const isScoped = basePath.includes("/projects/:projectId");
732
+ const discoveryHandler = async (req, res) => {
344
733
  try {
345
734
  const discovery = await this.protocol.getDiscovery();
346
735
  discovery.version = this.config.api.version;
736
+ const realBase = isScoped ? basePath.replace(":projectId", req.params?.projectId ?? ":projectId") : basePath;
347
737
  if (discovery.routes) {
348
738
  if (this.config.api.enableCrud) {
349
- discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`;
739
+ discovery.routes.data = `${realBase}${this.config.crud.dataPrefix}`;
350
740
  }
351
741
  if (this.config.api.enableMetadata) {
352
- discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`;
742
+ discovery.routes.metadata = `${realBase}${this.config.metadata.prefix}`;
353
743
  }
354
744
  if (this.config.api.enableUi) {
355
- discovery.routes.ui = `${basePath}/ui`;
745
+ discovery.routes.ui = `${realBase}/ui`;
356
746
  }
357
747
  if (discovery.routes.auth) {
358
- discovery.routes.auth = `${basePath}/auth`;
748
+ const unscopedBase = isScoped ? basePath.replace(/\/projects\/:projectId$/, "") : basePath;
749
+ discovery.routes.auth = `${unscopedBase}/auth`;
359
750
  }
360
751
  }
752
+ discovery.scoping = {
753
+ enabled: this.config.api.enableProjectScoping,
754
+ resolution: this.config.api.projectResolution,
755
+ scoped: isScoped,
756
+ projectId: isScoped ? req.params?.projectId : void 0
757
+ };
361
758
  res.json(discovery);
362
759
  } catch (error) {
760
+ logError("[REST] Unhandled error:", error);
363
761
  res.status(500).json({ error: error.message });
364
762
  }
365
763
  };
@@ -388,15 +786,19 @@ var RestServer = class {
388
786
  registerMetadataEndpoints(basePath) {
389
787
  const { metadata } = this.config;
390
788
  const metaPath = `${basePath}${metadata.prefix}`;
789
+ const isScoped = basePath.includes("/projects/:projectId");
391
790
  if (metadata.endpoints.types !== false) {
392
791
  this.routeManager.register({
393
792
  method: "GET",
394
793
  path: metaPath,
395
- handler: async (_req, res) => {
794
+ handler: async (req, res) => {
396
795
  try {
397
- const types = await this.protocol.getMetaTypes();
796
+ const projectId = isScoped ? req.params?.projectId : void 0;
797
+ const p = await this.resolveProtocol(projectId, req);
798
+ const types = await p.getMetaTypes();
398
799
  res.json(types);
399
800
  } catch (error) {
801
+ logError("[REST] Unhandled error:", error);
400
802
  res.status(500).json({ error: error.message });
401
803
  }
402
804
  },
@@ -413,9 +815,18 @@ var RestServer = class {
413
815
  handler: async (req, res) => {
414
816
  try {
415
817
  const packageId = req.query?.package || void 0;
416
- const items = await this.protocol.getMetaItems({ type: req.params.type, packageId });
417
- res.json(items);
818
+ const projectId = isScoped ? req.params?.projectId : void 0;
819
+ const p = await this.resolveProtocol(projectId, req);
820
+ const items = await p.getMetaItems({
821
+ type: req.params.type,
822
+ packageId,
823
+ ...projectId ? { projectId } : {}
824
+ });
825
+ const translated = await this.translateMetaItems(req, req.params.type, projectId, items);
826
+ res.header("Vary", "Accept-Language");
827
+ res.json(translated);
418
828
  } catch (error) {
829
+ logError("[REST] Unhandled error:", error);
419
830
  res.status(404).json({ error: error.message });
420
831
  }
421
832
  },
@@ -431,15 +842,18 @@ var RestServer = class {
431
842
  path: `${metaPath}/:type/:name`,
432
843
  handler: async (req, res) => {
433
844
  try {
434
- if (metadata.enableCache && this.protocol.getMetaItemCached) {
845
+ const projectId = isScoped ? req.params?.projectId : void 0;
846
+ const p = await this.resolveProtocol(projectId, req);
847
+ if (metadata.enableCache && p.getMetaItemCached) {
435
848
  const cacheRequest = {
436
849
  ifNoneMatch: req.headers["if-none-match"],
437
850
  ifModifiedSince: req.headers["if-modified-since"]
438
851
  };
439
- const result = await this.protocol.getMetaItemCached({
852
+ const result = await p.getMetaItemCached({
440
853
  type: req.params.type,
441
854
  name: req.params.name,
442
- cacheRequest
855
+ cacheRequest,
856
+ ...projectId ? { projectId } : {}
443
857
  });
444
858
  if (result.notModified) {
445
859
  res.status(304).send();
@@ -457,13 +871,20 @@ var RestServer = class {
457
871
  const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : "";
458
872
  res.header("Cache-Control", directives + maxAge);
459
873
  }
460
- res.json(result.data);
874
+ res.header("Vary", "Accept-Language");
875
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, result.data));
461
876
  } else {
462
877
  const packageId = req.query?.package || void 0;
463
- const item = await this.protocol.getMetaItem({ type: req.params.type, name: req.params.name, packageId });
464
- res.json(item);
878
+ const item = await p.getMetaItem({
879
+ type: req.params.type,
880
+ name: req.params.name,
881
+ packageId
882
+ });
883
+ res.header("Vary", "Accept-Language");
884
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
465
885
  }
466
886
  } catch (error) {
887
+ logError("[REST] Unhandled error:", error);
467
888
  res.status(404).json({ error: error.message });
468
889
  }
469
890
  },
@@ -478,17 +899,21 @@ var RestServer = class {
478
899
  path: `${metaPath}/:type/:name`,
479
900
  handler: async (req, res) => {
480
901
  try {
481
- if (!this.protocol.saveMetaItem) {
902
+ const projectId = isScoped ? req.params?.projectId : void 0;
903
+ const p = await this.resolveProtocol(projectId, req);
904
+ if (!p.saveMetaItem) {
482
905
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
483
906
  return;
484
907
  }
485
- const result = await this.protocol.saveMetaItem({
908
+ const result = await p.saveMetaItem({
486
909
  type: req.params.type,
487
910
  name: req.params.name,
488
- item: req.body
911
+ item: req.body,
912
+ ...projectId ? { projectId } : {}
489
913
  });
490
914
  res.json(result);
491
915
  } catch (error) {
916
+ logError("[REST] Unhandled error:", error);
492
917
  res.status(400).json({ error: error.message });
493
918
  }
494
919
  },
@@ -503,21 +928,26 @@ var RestServer = class {
503
928
  */
504
929
  registerUiEndpoints(basePath) {
505
930
  const uiPath = `${basePath}/ui`;
931
+ const isScoped = basePath.includes("/projects/:projectId");
506
932
  this.routeManager.register({
507
933
  method: "GET",
508
934
  path: `${uiPath}/view/:object/:type`,
509
935
  handler: async (req, res) => {
510
936
  try {
511
- if (this.protocol.getUiView) {
512
- const view = await this.protocol.getUiView({
937
+ const projectId = isScoped ? req.params?.projectId : void 0;
938
+ const p = await this.resolveProtocol(projectId, req);
939
+ if (p.getUiView) {
940
+ const view = await p.getUiView({
513
941
  object: req.params.object,
514
- type: req.params.type
942
+ type: req.params.type,
943
+ ...projectId ? { projectId } : {}
515
944
  });
516
945
  res.json(view);
517
946
  } else {
518
947
  res.status(501).json({ error: "UI View resolution not supported by protocol implementation" });
519
948
  }
520
949
  } catch (error) {
950
+ logError("[REST] Unhandled error:", error);
521
951
  res.status(404).json({ error: error.message });
522
952
  }
523
953
  },
@@ -533,6 +963,7 @@ var RestServer = class {
533
963
  registerCrudEndpoints(basePath) {
534
964
  const { crud } = this.config;
535
965
  const dataPath = `${basePath}${crud.dataPrefix}`;
966
+ const isScoped = basePath.includes("/projects/:projectId");
536
967
  const operations = crud.operations;
537
968
  if (operations.list) {
538
969
  this.routeManager.register({
@@ -540,13 +971,24 @@ var RestServer = class {
540
971
  path: `${dataPath}/:object`,
541
972
  handler: async (req, res) => {
542
973
  try {
543
- const result = await this.protocol.findData({
974
+ const projectId = isScoped ? req.params?.projectId : void 0;
975
+ const p = await this.resolveProtocol(projectId, req);
976
+ const context = await this.resolveExecCtx(projectId, req);
977
+ const result = await p.findData({
544
978
  object: req.params.object,
545
- query: req.query
979
+ query: req.query,
980
+ ...projectId ? { projectId } : {},
981
+ ...context ? { context } : {}
546
982
  });
547
983
  res.json(result);
548
984
  } catch (error) {
549
- res.status(400).json({ error: error.message });
985
+ const mapped = mapDataError(error, req.params?.object);
986
+ if (mapped.status === 404 || mapped.status === 503 || mapped.status === 502) {
987
+ res.status(mapped.status).json(mapped.body);
988
+ } else {
989
+ logError("[REST] Unhandled error:", error);
990
+ res.status(mapped.status).json(mapped.body);
991
+ }
550
992
  }
551
993
  },
552
994
  metadata: {
@@ -561,16 +1003,23 @@ var RestServer = class {
561
1003
  path: `${dataPath}/:object/:id`,
562
1004
  handler: async (req, res) => {
563
1005
  try {
1006
+ const projectId = isScoped ? req.params?.projectId : void 0;
1007
+ const p = await this.resolveProtocol(projectId, req);
564
1008
  const { select, expand } = req.query || {};
565
- const result = await this.protocol.getData({
1009
+ const context = await this.resolveExecCtx(projectId, req);
1010
+ const result = await p.getData({
566
1011
  object: req.params.object,
567
1012
  id: req.params.id,
568
1013
  ...select != null ? { select } : {},
569
- ...expand != null ? { expand } : {}
1014
+ ...expand != null ? { expand } : {},
1015
+ ...projectId ? { projectId } : {},
1016
+ ...context ? { context } : {}
570
1017
  });
571
1018
  res.json(result);
572
1019
  } catch (error) {
573
- res.status(404).json({ error: error.message });
1020
+ const mapped = mapDataError(error, req.params?.object);
1021
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1022
+ res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
574
1023
  }
575
1024
  },
576
1025
  metadata: {
@@ -585,13 +1034,20 @@ var RestServer = class {
585
1034
  path: `${dataPath}/:object`,
586
1035
  handler: async (req, res) => {
587
1036
  try {
588
- const result = await this.protocol.createData({
1037
+ const projectId = isScoped ? req.params?.projectId : void 0;
1038
+ const p = await this.resolveProtocol(projectId, req);
1039
+ const context = await this.resolveExecCtx(projectId, req);
1040
+ const result = await p.createData({
589
1041
  object: req.params.object,
590
- data: req.body
1042
+ data: req.body,
1043
+ ...projectId ? { projectId } : {},
1044
+ ...context ? { context } : {}
591
1045
  });
592
1046
  res.status(201).json(result);
593
1047
  } catch (error) {
594
- res.status(400).json({ error: error.message });
1048
+ const mapped = mapDataError(error, req.params?.object);
1049
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1050
+ res.status(mapped.status).json(mapped.body);
595
1051
  }
596
1052
  },
597
1053
  metadata: {
@@ -606,14 +1062,21 @@ var RestServer = class {
606
1062
  path: `${dataPath}/:object/:id`,
607
1063
  handler: async (req, res) => {
608
1064
  try {
609
- const result = await this.protocol.updateData({
1065
+ const projectId = isScoped ? req.params?.projectId : void 0;
1066
+ const p = await this.resolveProtocol(projectId, req);
1067
+ const context = await this.resolveExecCtx(projectId, req);
1068
+ const result = await p.updateData({
610
1069
  object: req.params.object,
611
1070
  id: req.params.id,
612
- data: req.body
1071
+ data: req.body,
1072
+ ...projectId ? { projectId } : {},
1073
+ ...context ? { context } : {}
613
1074
  });
614
1075
  res.json(result);
615
1076
  } catch (error) {
616
- res.status(400).json({ error: error.message });
1077
+ const mapped = mapDataError(error, req.params?.object);
1078
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1079
+ res.status(mapped.status).json(mapped.body);
617
1080
  }
618
1081
  },
619
1082
  metadata: {
@@ -628,13 +1091,20 @@ var RestServer = class {
628
1091
  path: `${dataPath}/:object/:id`,
629
1092
  handler: async (req, res) => {
630
1093
  try {
631
- const result = await this.protocol.deleteData({
1094
+ const projectId = isScoped ? req.params?.projectId : void 0;
1095
+ const p = await this.resolveProtocol(projectId, req);
1096
+ const context = await this.resolveExecCtx(projectId, req);
1097
+ const result = await p.deleteData({
632
1098
  object: req.params.object,
633
- id: req.params.id
1099
+ id: req.params.id,
1100
+ ...projectId ? { projectId } : {},
1101
+ ...context ? { context } : {}
634
1102
  });
635
1103
  res.json(result);
636
1104
  } catch (error) {
637
- res.status(400).json({ error: error.message });
1105
+ const mapped = mapDataError(error, req.params?.object);
1106
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1107
+ res.status(mapped.status).json(mapped.body);
638
1108
  }
639
1109
  },
640
1110
  metadata: {
@@ -650,6 +1120,7 @@ var RestServer = class {
650
1120
  registerBatchEndpoints(basePath) {
651
1121
  const { crud, batch } = this.config;
652
1122
  const dataPath = `${basePath}${crud.dataPrefix}`;
1123
+ const isScoped = basePath.includes("/projects/:projectId");
653
1124
  const operations = batch.operations;
654
1125
  if (batch.enableBatchEndpoint && this.protocol.batchData) {
655
1126
  this.routeManager.register({
@@ -657,12 +1128,16 @@ var RestServer = class {
657
1128
  path: `${dataPath}/:object/batch`,
658
1129
  handler: async (req, res) => {
659
1130
  try {
660
- const result = await this.protocol.batchData({
1131
+ const projectId = isScoped ? req.params?.projectId : void 0;
1132
+ const p = await this.resolveProtocol(projectId, req);
1133
+ const result = await p.batchData({
661
1134
  object: req.params.object,
662
- request: req.body
1135
+ request: req.body,
1136
+ ...projectId ? { projectId } : {}
663
1137
  });
664
1138
  res.json(result);
665
1139
  } catch (error) {
1140
+ logError("[REST] Unhandled error:", error);
666
1141
  res.status(400).json({ error: error.message });
667
1142
  }
668
1143
  },
@@ -678,12 +1153,16 @@ var RestServer = class {
678
1153
  path: `${dataPath}/:object/createMany`,
679
1154
  handler: async (req, res) => {
680
1155
  try {
681
- const result = await this.protocol.createManyData({
1156
+ const projectId = isScoped ? req.params?.projectId : void 0;
1157
+ const p = await this.resolveProtocol(projectId, req);
1158
+ const result = await p.createManyData({
682
1159
  object: req.params.object,
683
- records: req.body || []
1160
+ records: req.body || [],
1161
+ ...projectId ? { projectId } : {}
684
1162
  });
685
1163
  res.status(201).json(result);
686
1164
  } catch (error) {
1165
+ logError("[REST] Unhandled error:", error);
687
1166
  res.status(400).json({ error: error.message });
688
1167
  }
689
1168
  },
@@ -699,12 +1178,16 @@ var RestServer = class {
699
1178
  path: `${dataPath}/:object/updateMany`,
700
1179
  handler: async (req, res) => {
701
1180
  try {
702
- const result = await this.protocol.updateManyData({
1181
+ const projectId = isScoped ? req.params?.projectId : void 0;
1182
+ const p = await this.resolveProtocol(projectId, req);
1183
+ const result = await p.updateManyData({
703
1184
  object: req.params.object,
704
- ...req.body
1185
+ ...req.body,
1186
+ ...projectId ? { projectId } : {}
705
1187
  });
706
1188
  res.json(result);
707
1189
  } catch (error) {
1190
+ logError("[REST] Unhandled error:", error);
708
1191
  res.status(400).json({ error: error.message });
709
1192
  }
710
1193
  },
@@ -720,12 +1203,16 @@ var RestServer = class {
720
1203
  path: `${dataPath}/:object/deleteMany`,
721
1204
  handler: async (req, res) => {
722
1205
  try {
723
- const result = await this.protocol.deleteManyData({
1206
+ const projectId = isScoped ? req.params?.projectId : void 0;
1207
+ const p = await this.resolveProtocol(projectId, req);
1208
+ const result = await p.deleteManyData({
724
1209
  object: req.params.object,
725
- ...req.body
1210
+ ...req.body,
1211
+ ...projectId ? { projectId } : {}
726
1212
  });
727
1213
  res.json(result);
728
1214
  } catch (error) {
1215
+ logError("[REST] Unhandled error:", error);
729
1216
  res.status(400).json({ error: error.message });
730
1217
  }
731
1218
  },
@@ -750,6 +1237,123 @@ var RestServer = class {
750
1237
  }
751
1238
  };
752
1239
 
1240
+ // src/package-routes.ts
1241
+ function registerPackageRoutes(server, packageService, basePath = "/api/v1", options = {}) {
1242
+ const packagesPath = `${basePath}/packages`;
1243
+ server.post(packagesPath, async (req, res) => {
1244
+ try {
1245
+ const { manifest, metadata } = req.body || {};
1246
+ if (!manifest || !metadata) {
1247
+ res.status(400).json({ error: "Missing required fields: manifest, metadata" });
1248
+ return;
1249
+ }
1250
+ if (!manifest.id || !manifest.version) {
1251
+ res.status(400).json({ error: "Invalid manifest: id and version are required" });
1252
+ return;
1253
+ }
1254
+ const result = await packageService.publish({ manifest, metadata });
1255
+ if (result.success) {
1256
+ res.json({
1257
+ success: true,
1258
+ message: `Published ${manifest.id}@${manifest.version}`,
1259
+ package: {
1260
+ id: manifest.id,
1261
+ version: manifest.version
1262
+ }
1263
+ });
1264
+ return;
1265
+ }
1266
+ res.status(400).json({ success: false, error: result.error });
1267
+ } catch (error) {
1268
+ res.status(500).json({ error: error.message });
1269
+ }
1270
+ });
1271
+ server.get(packagesPath, async (_req, res) => {
1272
+ try {
1273
+ const packagesMap = /* @__PURE__ */ new Map();
1274
+ if (options.protocol && typeof options.protocol.getMetaItems === "function") {
1275
+ try {
1276
+ const result = await options.protocol.getMetaItems({ type: "package" });
1277
+ if (result?.items) {
1278
+ for (const item of result.items) {
1279
+ const id = item.manifest?.id || item.id;
1280
+ if (id) {
1281
+ packagesMap.set(id, {
1282
+ ...item,
1283
+ source: "registry"
1284
+ });
1285
+ }
1286
+ }
1287
+ }
1288
+ } catch {
1289
+ }
1290
+ }
1291
+ try {
1292
+ const dbPackages = await packageService.list();
1293
+ for (const pkg of dbPackages) {
1294
+ const id = pkg.manifest?.id || pkg.id;
1295
+ if (id) {
1296
+ packagesMap.set(id, {
1297
+ ...packagesMap.get(id),
1298
+ ...pkg,
1299
+ source: packagesMap.has(id) ? "both" : "database"
1300
+ });
1301
+ }
1302
+ }
1303
+ } catch {
1304
+ }
1305
+ const packages = Array.from(packagesMap.values());
1306
+ res.json({ packages, total: packages.length });
1307
+ } catch (error) {
1308
+ res.status(500).json({ error: error.message });
1309
+ }
1310
+ });
1311
+ server.get(`${packagesPath}/:id`, async (req, res) => {
1312
+ try {
1313
+ const packageId = req.params.id;
1314
+ const version = req.query?.version || "latest";
1315
+ const pkg = await packageService.get(packageId, version);
1316
+ if (pkg) {
1317
+ res.json({ package: { ...pkg, source: "database" } });
1318
+ return;
1319
+ }
1320
+ if (options.protocol && typeof options.protocol.getMetaItems === "function") {
1321
+ try {
1322
+ const result = await options.protocol.getMetaItems({ type: "package" });
1323
+ const match = result?.items?.find(
1324
+ (item) => (item.manifest?.id || item.id) === packageId
1325
+ );
1326
+ if (match) {
1327
+ res.json({ package: { ...match, source: "registry" } });
1328
+ return;
1329
+ }
1330
+ } catch {
1331
+ }
1332
+ }
1333
+ res.status(404).json({ error: "Package not found" });
1334
+ } catch (error) {
1335
+ res.status(500).json({ error: error.message });
1336
+ }
1337
+ });
1338
+ server.delete(`${packagesPath}/:id`, async (req, res) => {
1339
+ try {
1340
+ const packageId = req.params.id;
1341
+ const version = req.query?.version;
1342
+ const result = await packageService.delete(packageId, version);
1343
+ if (result.success) {
1344
+ res.json({
1345
+ success: true,
1346
+ message: `Deleted ${packageId}${version ? `@${version}` : ""}`
1347
+ });
1348
+ return;
1349
+ }
1350
+ res.status(400).json({ success: false });
1351
+ } catch (error) {
1352
+ res.status(500).json({ error: error.message });
1353
+ }
1354
+ });
1355
+ }
1356
+
753
1357
  // src/rest-api-plugin.ts
754
1358
  function createRestApiPlugin(config = {}) {
755
1359
  return {
@@ -770,6 +1374,39 @@ function createRestApiPlugin(config = {}) {
770
1374
  protocol = ctx.getService(protocolService);
771
1375
  } catch (e) {
772
1376
  }
1377
+ let kernelManager;
1378
+ const kernelManagerService = config.kernelManagerServiceName || "kernel-manager";
1379
+ try {
1380
+ kernelManager = ctx.getService(kernelManagerService);
1381
+ } catch (e) {
1382
+ }
1383
+ let envRegistry;
1384
+ try {
1385
+ envRegistry = ctx.getService("env-registry");
1386
+ } catch (e) {
1387
+ }
1388
+ const defaultProjectIdProvider = () => {
1389
+ try {
1390
+ const dp = ctx.getService("default-project");
1391
+ return dp?.projectId;
1392
+ } catch {
1393
+ return void 0;
1394
+ }
1395
+ };
1396
+ const authServiceProvider = async (_projectId) => {
1397
+ try {
1398
+ return ctx.getService("auth");
1399
+ } catch {
1400
+ return void 0;
1401
+ }
1402
+ };
1403
+ const objectQLProvider = async (_projectId) => {
1404
+ try {
1405
+ return ctx.getService("objectql");
1406
+ } catch {
1407
+ return void 0;
1408
+ }
1409
+ };
773
1410
  if (!server) {
774
1411
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
775
1412
  return;
@@ -780,13 +1417,38 @@ function createRestApiPlugin(config = {}) {
780
1417
  }
781
1418
  ctx.logger.info("Hydrating REST API from Protocol...");
782
1419
  try {
783
- const restServer = new RestServer(server, protocol, config.api);
1420
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
784
1421
  restServer.registerRoutes();
785
1422
  ctx.logger.info("REST API successfully registered");
786
1423
  } catch (err) {
787
1424
  ctx.logger.error("Failed to register REST API routes", { error: err.message });
788
1425
  throw err;
789
1426
  }
1427
+ try {
1428
+ const packageService = ctx.getService("package");
1429
+ if (packageService) {
1430
+ const basePath = config.api?.api?.basePath || "/api";
1431
+ const version = config.api?.api?.version || "v1";
1432
+ const versionedBase = `${basePath}/${version}`;
1433
+ const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false;
1434
+ const projectResolution = config.api?.api?.projectResolution ?? "auto";
1435
+ if (enableProjectScoping && projectResolution === "required") {
1436
+ registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, {
1437
+ protocol
1438
+ });
1439
+ } else {
1440
+ registerPackageRoutes(server, packageService, versionedBase, { protocol });
1441
+ if (enableProjectScoping) {
1442
+ registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, {
1443
+ protocol
1444
+ });
1445
+ }
1446
+ }
1447
+ ctx.logger.info("Package management routes registered");
1448
+ }
1449
+ } catch (e) {
1450
+ ctx.logger.debug("Package service not available, package routes skipped");
1451
+ }
790
1452
  }
791
1453
  };
792
1454
  }