@objectstack/rest 7.9.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`;
@@ -2870,6 +2946,89 @@ var RestServer = class {
2870
2946
  * when no sharing service is configured so a deployment without the
2871
2947
  * `@objectstack/plugin-sharing` plugin fails cleanly.
2872
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
+ }
2873
3032
  registerSharingEndpoints(basePath) {
2874
3033
  const { crud } = this.config;
2875
3034
  const dataPath = `${basePath}${crud.dataPrefix}`;
@@ -3411,11 +3570,13 @@ var RestServer = class {
3411
3570
  return;
3412
3571
  }
3413
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);
3414
3575
  const rows = await svc.listRequests({
3415
3576
  object: q.object,
3416
3577
  recordId: q.recordId ?? q.record_id,
3417
3578
  status: q.status,
3418
- approverId: q.approverId ?? q.approver_id,
3579
+ approverId: approverIds.length ? approverIds : void 0,
3419
3580
  submitterId: q.submitterId ?? q.submitter_id
3420
3581
  }, context ?? {});
3421
3582
  res.json({ data: rows });
@@ -3510,6 +3671,73 @@ var RestServer = class {
3510
3671
  const dataPath = `${basePath}${crud.dataPrefix}`;
3511
3672
  const isScoped = basePath.includes("/environments/:environmentId");
3512
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
+ });
3513
3741
  if (batch.enableBatchEndpoint && this.protocol.batchData) {
3514
3742
  this.routeManager.register({
3515
3743
  method: "POST",
@@ -3909,6 +4137,13 @@ function createRestApiPlugin(config = {}) {
3909
4137
  return void 0;
3910
4138
  }
3911
4139
  };
4140
+ const analyticsServiceProvider = async (_environmentId) => {
4141
+ try {
4142
+ return ctx.getService("analytics");
4143
+ } catch {
4144
+ return void 0;
4145
+ }
4146
+ };
3912
4147
  if (!server) {
3913
4148
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
3914
4149
  return;
@@ -3919,7 +4154,7 @@ function createRestApiPlugin(config = {}) {
3919
4154
  }
3920
4155
  ctx.logger.info("Hydrating REST API from Protocol...");
3921
4156
  try {
3922
- 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);
3923
4158
  restServer.registerRoutes();
3924
4159
  ctx.logger.info("REST API successfully registered");
3925
4160
  } catch (err) {