@objectstack/rest 7.8.0 → 8.0.0

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
@@ -37,6 +37,9 @@ __export(index_exports, {
37
37
  });
38
38
  module.exports = __toCommonJS(index_exports);
39
39
 
40
+ // src/rest-server.ts
41
+ var import_core = require("@objectstack/core");
42
+
40
43
  // src/route-manager.ts
41
44
  var RouteManager = class {
42
45
  constructor(server) {
@@ -308,6 +311,32 @@ function mapDataError(error, object) {
308
311
  }
309
312
  };
310
313
  }
314
+ const unknownColumn = /has no column named\s+["'`]?([a-z0-9_]+)/i.exec(raw) || /no such column:\s*["'`]?([a-z0-9_.]+)/i.exec(raw) || /unknown column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+of relation\s+\S+\s+does not exist/i.exec(raw);
315
+ if (unknownColumn) {
316
+ const field = unknownColumn[1]?.split(".").pop();
317
+ return {
318
+ status: 400,
319
+ body: {
320
+ error: field ? `Unknown field '${field}'${object ? ` on object '${object}'` : ""}` : "Request references a field that does not exist",
321
+ code: "INVALID_FIELD",
322
+ ...field ? { field } : {},
323
+ ...object ? { object } : {}
324
+ }
325
+ };
326
+ }
327
+ const notNull = /not null constraint failed:\s*\S*?\.([a-z0-9_]+)/i.exec(raw) || /null value in column\s+["'`]([a-z0-9_]+)["'`]/i.exec(raw) || /column\s+["'`]([a-z0-9_]+)["'`]\s+cannot be null/i.exec(raw);
328
+ if (notNull) {
329
+ const field = notNull[1];
330
+ return {
331
+ status: 400,
332
+ body: {
333
+ error: `${field} is required`,
334
+ code: "VALIDATION_FAILED",
335
+ fields: [{ field, code: "required", message: `${field} is required` }],
336
+ ...object ? { object } : {}
337
+ }
338
+ };
339
+ }
311
340
  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");
312
341
  if (looksLikeUnknownObject) {
313
342
  return {
@@ -445,7 +474,16 @@ function rowsToCsv(fields, rows, includeHeader) {
445
474
  return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
446
475
  }
447
476
  var RestServer = class {
448
- constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
477
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider) {
478
+ /**
479
+ * Short-TTL cache for `hostname → environmentId` (P1-4). `resolveByHostname`
480
+ * is a control-plane lookup (typically a DB query) that otherwise runs on
481
+ * *every* unscoped request; caching it — including negative results, so
482
+ * unknown hosts don't hammer the registry — removes that per-request cost.
483
+ * The TTL is short so a newly-bound hostname becomes routable quickly.
484
+ */
485
+ this.hostnameCache = /* @__PURE__ */ new Map();
486
+ this.hostnameCacheTtlMs = 3e4;
449
487
  /**
450
488
  * Lazily load the OpenAPI spec JSON shipped by @objectstack/spec.
451
489
  * Cached after first read. Resilient to missing files / parse errors
@@ -466,6 +504,7 @@ var RestServer = class {
466
504
  this.approvalsServiceProvider = approvalsServiceProvider;
467
505
  this.sharingRulesServiceProvider = sharingRulesServiceProvider;
468
506
  this.i18nServiceProvider = i18nServiceProvider;
507
+ this.analyticsServiceProvider = analyticsServiceProvider;
469
508
  }
470
509
  /**
471
510
  * Resolve the protocol for a given request. When `environmentId` is present
@@ -489,13 +528,28 @@ var RestServer = class {
489
528
  * (and any other client) speak a single, uniform URL family without
490
529
  * duplicating route logic for the platform surface.
491
530
  */
531
+ /**
532
+ * Cached wrapper around `envRegistry.resolveByHostname` (P1-4). Returns the
533
+ * cached result while fresh; on a miss it queries the registry and caches the
534
+ * outcome (positive *and* negative) for {@link hostnameCacheTtlMs}. Registry
535
+ * errors are not cached so a transient control-plane blip self-heals on the
536
+ * next request.
537
+ */
538
+ async resolveHostnameCached(host) {
539
+ const now = Date.now();
540
+ const hit = this.hostnameCache.get(host);
541
+ if (hit && hit.expiresAt > now) return hit.value;
542
+ const result = await this.envRegistry.resolveByHostname(host) ?? null;
543
+ this.hostnameCache.set(host, { value: result, expiresAt: now + this.hostnameCacheTtlMs });
544
+ return result;
545
+ }
492
546
  async resolveProtocol(environmentId, req) {
493
547
  if (environmentId === "platform") return this.protocol;
494
548
  if (!environmentId && req && this.envRegistry && this.kernelManager) {
495
549
  const host = this.extractHostname(req);
496
550
  if (host) {
497
551
  try {
498
- const result = await this.envRegistry.resolveByHostname(host);
552
+ const result = await this.resolveHostnameCached(host);
499
553
  if (result?.environmentId) environmentId = result.environmentId;
500
554
  } catch {
501
555
  }
@@ -539,7 +593,7 @@ var RestServer = class {
539
593
  const host = this.extractHostname(req);
540
594
  if (host) {
541
595
  try {
542
- const result = await this.envRegistry.resolveByHostname(host);
596
+ const result = await this.resolveHostnameCached(host);
543
597
  if (result?.environmentId) environmentId = result.environmentId;
544
598
  } catch {
545
599
  }
@@ -610,7 +664,7 @@ var RestServer = class {
610
664
  const host = this.extractHostname(req);
611
665
  if (host) {
612
666
  try {
613
- const result = await this.envRegistry.resolveByHostname(host);
667
+ const result = await this.resolveHostnameCached(host);
614
668
  if (result?.environmentId) environmentId = result.environmentId;
615
669
  } catch {
616
670
  }
@@ -664,13 +718,27 @@ var RestServer = class {
664
718
  } else {
665
719
  return void 0;
666
720
  }
667
- const session = await api.getSession({ headers });
668
- if (!session?.user?.id) return void 0;
669
- const userId = session.user.id;
670
- const tenantId = session.session?.activeOrganizationId ?? void 0;
671
721
  const permissions = [];
672
722
  const systemPermissions = [];
673
723
  const roles = [];
724
+ let identityQl;
725
+ if (kernel) identityQl = await kernel.getServiceAsync("objectql").catch(() => void 0);
726
+ if (!identityQl && this.objectQLProvider) {
727
+ identityQl = await this.objectQLProvider(environmentId).catch(() => void 0);
728
+ }
729
+ let userId;
730
+ let tenantId;
731
+ const keyPrincipal = await (0, import_core.resolveApiKeyPrincipal)(identityQl, headers).catch(() => void 0);
732
+ if (keyPrincipal) {
733
+ userId = keyPrincipal.userId;
734
+ tenantId = keyPrincipal.tenantId;
735
+ for (const s of keyPrincipal.scopes) if (!permissions.includes(s)) permissions.push(s);
736
+ } else {
737
+ const session = await api.getSession({ headers });
738
+ if (!session?.user?.id) return void 0;
739
+ userId = session.user.id;
740
+ tenantId = session.session?.activeOrganizationId ?? void 0;
741
+ }
674
742
  try {
675
743
  let ql;
676
744
  if (kernel) {
@@ -1081,6 +1149,7 @@ var RestServer = class {
1081
1149
  this.registerSharingRuleEndpoints(bp);
1082
1150
  this.registerReportsEndpoints(bp);
1083
1151
  this.registerApprovalsEndpoints(bp);
1152
+ this.registerAnalyticsEndpoints(bp);
1084
1153
  if (this.config.api.enableCrud) {
1085
1154
  this.registerCrudEndpoints(bp);
1086
1155
  }
@@ -1121,6 +1190,13 @@ var RestServer = class {
1121
1190
  if (this.config.api.enableUi) {
1122
1191
  discovery.routes.ui = `${realBase}/ui`;
1123
1192
  }
1193
+ const mcpEnabled = globalThis?.process?.env?.OS_MCP_SERVER_ENABLED === "true";
1194
+ if (mcpEnabled) {
1195
+ const unscopedBase = isScoped ? basePath.replace(/\/(environments|projects)\/:environmentId$/, "") : basePath;
1196
+ discovery.routes.mcp = `${unscopedBase}/mcp`;
1197
+ } else {
1198
+ delete discovery.routes.mcp;
1199
+ }
1124
1200
  if (discovery.routes.auth) {
1125
1201
  const unscopedBase = isScoped ? basePath.replace(/\/projects\/:environmentId$/, "") : basePath;
1126
1202
  discovery.routes.auth = `${unscopedBase}/auth`;
@@ -1613,13 +1689,15 @@ var RestServer = class {
1613
1689
  const actorHeader = req.headers?.["x-actor"] ?? req.headers?.["X-Actor"] ?? req.user?.id ?? req.userId;
1614
1690
  const actor = typeof actorHeader === "string" ? actorHeader : void 0;
1615
1691
  const stateParam = typeof req.query?.state === "string" && req.query.state.toLowerCase() === "draft" ? "draft" : void 0;
1692
+ const dropStorage = req.query?.dropStorage === "true" || req.query?.dropStorage === "1";
1616
1693
  const result = await p.deleteMetaItem({
1617
1694
  type: req.params.type,
1618
1695
  name: req.params.name,
1619
1696
  ...environmentId ? { environmentId } : {},
1620
1697
  ...parentVersion !== void 0 ? { parentVersion } : {},
1621
1698
  ...actor ? { actor } : {},
1622
- ...stateParam ? { state: stateParam } : {}
1699
+ ...stateParam ? { state: stateParam } : {},
1700
+ ...dropStorage ? { dropStorage: true } : {}
1623
1701
  });
1624
1702
  res.json(result);
1625
1703
  } catch (error) {
@@ -2868,6 +2946,89 @@ var RestServer = class {
2868
2946
  * when no sharing service is configured so a deployment without the
2869
2947
  * `@objectstack/plugin-sharing` plugin fails cleanly.
2870
2948
  */
2949
+ /**
2950
+ * ADR-0021 — analytics dataset preview/query endpoint.
2951
+ *
2952
+ * POST {basePath}/analytics/dataset/query
2953
+ * body: { dataset?: <inline Dataset>, datasetName?: string, selection: DatasetSelection }
2954
+ *
2955
+ * Compiles the dataset (an inline draft for Studio preview, or a saved one
2956
+ * by name) and runs the selection through the analytics service's
2957
+ * `queryDataset`, threading the request ExecutionContext so tenant/RLS
2958
+ * scoping (ADR-0021 D-C) applies. Returns 501 when no analytics service
2959
+ * (or one without `queryDataset`) is configured, so a deployment without
2960
+ * `@objectstack/service-analytics` fails cleanly.
2961
+ */
2962
+ registerAnalyticsEndpoints(basePath) {
2963
+ const isScoped = basePath.includes("/environments/:environmentId");
2964
+ const resolveService = async (environmentId) => {
2965
+ if (!this.analyticsServiceProvider) return void 0;
2966
+ try {
2967
+ return await this.analyticsServiceProvider(environmentId);
2968
+ } catch {
2969
+ return void 0;
2970
+ }
2971
+ };
2972
+ this.routeManager.register({
2973
+ method: "POST",
2974
+ path: `${basePath}/analytics/dataset/query`,
2975
+ handler: async (req, res) => {
2976
+ try {
2977
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
2978
+ const context = await this.resolveExecCtx(environmentId, req);
2979
+ if (this.enforceAuth(req, res, context)) return;
2980
+ const svc = await resolveService(environmentId);
2981
+ if (!svc || typeof svc.queryDataset !== "function") {
2982
+ return res.status(501).json({
2983
+ code: "NOT_IMPLEMENTED",
2984
+ message: "Analytics dataset query is not available on this deployment (no analytics service with queryDataset)."
2985
+ });
2986
+ }
2987
+ const body = req.body ?? {};
2988
+ const selection = body.selection;
2989
+ if (!selection || !Array.isArray(selection.measures) || selection.measures.length === 0) {
2990
+ return res.status(400).json({
2991
+ code: "VALIDATION_FAILED",
2992
+ message: "body.selection.measures must be a non-empty array of measure names."
2993
+ });
2994
+ }
2995
+ let dataset = body.dataset;
2996
+ if (!dataset && body.datasetName) {
2997
+ const p = await this.resolveProtocol(environmentId, req);
2998
+ const items = await p.getMetaItems?.({ type: "dataset" }).catch(() => null);
2999
+ const list = Array.isArray(items?.items) ? items.items : Array.isArray(items) ? items : [];
3000
+ dataset = list.find((d) => d?.name === body.datasetName);
3001
+ if (!dataset) {
3002
+ return res.status(404).json({ code: "NOT_FOUND", message: `Dataset "${body.datasetName}" not found.` });
3003
+ }
3004
+ }
3005
+ if (!dataset) {
3006
+ return res.status(400).json({ code: "VALIDATION_FAILED", message: "Provide body.dataset (inline) or body.datasetName." });
3007
+ }
3008
+ try {
3009
+ const { DatasetSchema } = await import("@objectstack/spec/ui");
3010
+ dataset = DatasetSchema.parse(dataset);
3011
+ } catch (verr) {
3012
+ return res.status(400).json({
3013
+ code: "VALIDATION_FAILED",
3014
+ message: "Invalid dataset definition.",
3015
+ detail: String(verr?.message ?? verr).slice(0, 1e3)
3016
+ });
3017
+ }
3018
+ const result = await svc.queryDataset(dataset, selection, context ?? void 0);
3019
+ res.json(result);
3020
+ } catch (error) {
3021
+ const msg = String(error?.message ?? error ?? "");
3022
+ if (/not declared in the dataset|not backed by a declared relationship|not supported by the v1 dataset runtime|read-scope-sql/.test(msg)) {
3023
+ return res.status(400).json({ code: "DATASET_INVALID", message: msg.slice(0, 1e3) });
3024
+ }
3025
+ logError("[REST] Analytics dataset query error:", error);
3026
+ res.status(500).json({ code: "ANALYTICS_QUERY_FAILED", error: msg.slice(0, 500) });
3027
+ }
3028
+ },
3029
+ metadata: { summary: "Run a semantic-layer dataset (preview/query)", tags: ["analytics"] }
3030
+ });
3031
+ }
2871
3032
  registerSharingEndpoints(basePath) {
2872
3033
  const { crud } = this.config;
2873
3034
  const dataPath = `${basePath}${crud.dataPrefix}`;
@@ -3409,11 +3570,13 @@ var RestServer = class {
3409
3570
  return;
3410
3571
  }
3411
3572
  const q = req.query ?? {};
3573
+ const rawApprover = q.approverId ?? q.approver_id;
3574
+ const approverIds = (Array.isArray(rawApprover) ? rawApprover : rawApprover != null ? [rawApprover] : []).flatMap((s) => String(s).split(",")).map((s) => s.trim()).filter(Boolean);
3412
3575
  const rows = await svc.listRequests({
3413
3576
  object: q.object,
3414
3577
  recordId: q.recordId ?? q.record_id,
3415
3578
  status: q.status,
3416
- approverId: q.approverId ?? q.approver_id,
3579
+ approverId: approverIds.length ? approverIds : void 0,
3417
3580
  submitterId: q.submitterId ?? q.submitter_id
3418
3581
  }, context ?? {});
3419
3582
  res.json({ data: rows });
@@ -3508,6 +3671,73 @@ var RestServer = class {
3508
3671
  const dataPath = `${basePath}${crud.dataPrefix}`;
3509
3672
  const isScoped = basePath.includes("/environments/:environmentId");
3510
3673
  const operations = batch.operations;
3674
+ this.routeManager.register({
3675
+ method: "POST",
3676
+ path: `${basePath}/batch`,
3677
+ handler: async (req, res) => {
3678
+ try {
3679
+ const environmentId = isScoped ? req.params?.environmentId : void 0;
3680
+ const context = await this.resolveExecCtx(environmentId, req);
3681
+ if (this.enforceAuth(req, res, context)) return;
3682
+ const ql = this.objectQLProvider ? await this.objectQLProvider(environmentId) : void 0;
3683
+ if (!ql || typeof ql.transaction !== "function") {
3684
+ res.status(501).json({ error: "Transactional batch not supported by this runtime" });
3685
+ return;
3686
+ }
3687
+ const ops = Array.isArray(req.body?.operations) ? req.body.operations : [];
3688
+ const max = batch.maxBatchSize ?? 200;
3689
+ if (ops.length === 0) {
3690
+ res.json({ results: [] });
3691
+ return;
3692
+ }
3693
+ if (ops.length > max) {
3694
+ res.status(400).json({ error: `Batch too large (max ${max})` });
3695
+ return;
3696
+ }
3697
+ const resolveRefs = (data, out) => {
3698
+ if (!data || typeof data !== "object") return data;
3699
+ const result = Array.isArray(data) ? [] : {};
3700
+ for (const [k, v] of Object.entries(data)) {
3701
+ if (v && typeof v === "object" && "$ref" in v) {
3702
+ const ref = out[v.$ref];
3703
+ result[k] = (ref && (ref.id ?? ref._id)) ?? null;
3704
+ } else {
3705
+ result[k] = v;
3706
+ }
3707
+ }
3708
+ return result;
3709
+ };
3710
+ const results = await ql.transaction(async (trxCtx) => {
3711
+ const out = [];
3712
+ for (const op of ops) {
3713
+ const action = String(op?.action || "create");
3714
+ const object = String(op?.object || "");
3715
+ if (!object) throw new Error("Each operation requires an `object`");
3716
+ const data = resolveRefs(op.data, out);
3717
+ if (action === "create") {
3718
+ out.push(await ql.insert(object, data, { context: trxCtx }));
3719
+ } else if (action === "update") {
3720
+ const id = op.id ?? data?.id;
3721
+ out.push(await ql.update(object, { ...data, id }, { context: trxCtx }));
3722
+ } else if (action === "delete") {
3723
+ out.push(await ql.delete(object, { where: { id: op.id }, context: trxCtx }));
3724
+ } else {
3725
+ throw new Error(`Unknown batch action: ${action}`);
3726
+ }
3727
+ }
3728
+ return out;
3729
+ }, context);
3730
+ res.json({ results });
3731
+ } catch (error) {
3732
+ logError("[REST] Unhandled error:", error);
3733
+ sendError(res, error);
3734
+ }
3735
+ },
3736
+ metadata: {
3737
+ summary: "Cross-object transactional batch (atomic create/update/delete across objects)",
3738
+ tags: ["data", "batch"]
3739
+ }
3740
+ });
3511
3741
  if (batch.enableBatchEndpoint && this.protocol.batchData) {
3512
3742
  this.routeManager.register({
3513
3743
  method: "POST",
@@ -3907,6 +4137,13 @@ function createRestApiPlugin(config = {}) {
3907
4137
  return void 0;
3908
4138
  }
3909
4139
  };
4140
+ const analyticsServiceProvider = async (_environmentId) => {
4141
+ try {
4142
+ return ctx.getService("analytics");
4143
+ } catch {
4144
+ return void 0;
4145
+ }
4146
+ };
3910
4147
  if (!server) {
3911
4148
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
3912
4149
  return;
@@ -3917,7 +4154,7 @@ function createRestApiPlugin(config = {}) {
3917
4154
  }
3918
4155
  ctx.logger.info("Hydrating REST API from Protocol...");
3919
4156
  try {
3920
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
4157
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultEnvironmentIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider, analyticsServiceProvider);
3921
4158
  restServer.registerRoutes();
3922
4159
  ctx.logger.info("REST API successfully registered");
3923
4160
  } catch (err) {