@objectstack/rest 4.0.5 → 4.1.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.js CHANGED
@@ -211,6 +211,17 @@ var RouteGroupBuilder = class {
211
211
  // src/rest-server.ts
212
212
  var logError = (...args) => globalThis.console?.error(...args);
213
213
  function mapDataError(error, object) {
214
+ if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
215
+ return {
216
+ status: 400,
217
+ body: {
218
+ error: error?.message ?? "Validation failed",
219
+ code: "VALIDATION_FAILED",
220
+ fields: Array.isArray(error?.fields) ? error.fields : [],
221
+ ...object ? { object } : {}
222
+ }
223
+ };
224
+ }
214
225
  if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
215
226
  return {
216
227
  status: 403,
@@ -234,6 +245,16 @@ function mapDataError(error, object) {
234
245
  }
235
246
  };
236
247
  }
248
+ if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
249
+ return {
250
+ status: 404,
251
+ body: {
252
+ error: raw,
253
+ code: "RECORD_NOT_FOUND",
254
+ ...object ? { object } : {}
255
+ }
256
+ };
257
+ }
237
258
  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
259
  if (looksLikeUnknownObject) {
239
260
  return {
@@ -245,13 +266,132 @@ function mapDataError(error, object) {
245
266
  }
246
267
  };
247
268
  }
269
+ const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
270
+ if (looksLikeSqlLeak) {
271
+ if (lower.includes("unique constraint") || lower.includes("unique violation")) {
272
+ return {
273
+ status: 409,
274
+ body: {
275
+ error: "A record with this value already exists",
276
+ code: "UNIQUE_VIOLATION",
277
+ ...object ? { object } : {}
278
+ }
279
+ };
280
+ }
281
+ return {
282
+ status: 500,
283
+ body: { error: "Internal data error", code: "DATABASE_ERROR" }
284
+ };
285
+ }
248
286
  return { status: 400, body: { error: raw || "Bad request" } };
249
287
  }
288
+ function sendError(res, error, object) {
289
+ if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
290
+ const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
291
+ res.status(error.status).json({
292
+ error: safeMsg,
293
+ ...error.code ? { code: error.code } : {}
294
+ });
295
+ return;
296
+ }
297
+ const mapped = mapDataError(error, object);
298
+ res.status(mapped.status).json(mapped.body);
299
+ }
250
300
  function isExpectedDataStatus(status) {
251
- return status === 403 || status === 404 || status === 502 || status === 503;
301
+ return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
302
+ }
303
+ function parseCsvToRows(csv, mapping = {}) {
304
+ const text = csv.replace(/^\uFEFF/, "");
305
+ const cells = [];
306
+ let cur = "";
307
+ let row = [];
308
+ let inQuotes = false;
309
+ for (let i = 0; i < text.length; i++) {
310
+ const ch = text[i];
311
+ if (inQuotes) {
312
+ if (ch === '"') {
313
+ if (text[i + 1] === '"') {
314
+ cur += '"';
315
+ i++;
316
+ } else {
317
+ inQuotes = false;
318
+ }
319
+ } else {
320
+ cur += ch;
321
+ }
322
+ continue;
323
+ }
324
+ if (ch === '"') {
325
+ inQuotes = true;
326
+ continue;
327
+ }
328
+ if (ch === ",") {
329
+ row.push(cur);
330
+ cur = "";
331
+ continue;
332
+ }
333
+ if (ch === "\r") {
334
+ continue;
335
+ }
336
+ if (ch === "\n") {
337
+ row.push(cur);
338
+ cur = "";
339
+ cells.push(row);
340
+ row = [];
341
+ continue;
342
+ }
343
+ cur += ch;
344
+ }
345
+ if (cur.length > 0 || row.length > 0) {
346
+ row.push(cur);
347
+ cells.push(row);
348
+ }
349
+ while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
350
+ if (cells.length < 2) return [];
351
+ const header = cells[0].map((h) => h.trim());
352
+ const fields = header.map((h) => mapping[h] ?? h);
353
+ const out = [];
354
+ for (let r = 1; r < cells.length; r++) {
355
+ const row2 = cells[r];
356
+ const obj = {};
357
+ for (let c = 0; c < fields.length; c++) {
358
+ const key = fields[c];
359
+ if (!key) continue;
360
+ const raw = row2[c] ?? "";
361
+ obj[key] = raw;
362
+ }
363
+ out.push(obj);
364
+ }
365
+ return out;
366
+ }
367
+ function formatCsvCell(value) {
368
+ if (value === null || value === void 0) return "";
369
+ let s;
370
+ if (typeof value === "string") s = value;
371
+ else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") s = String(value);
372
+ else if (value instanceof Date) s = value.toISOString();
373
+ else {
374
+ try {
375
+ s = JSON.stringify(value);
376
+ } catch {
377
+ s = String(value);
378
+ }
379
+ }
380
+ if (/[",\r\n]/.test(s)) {
381
+ return `"${s.replace(/"/g, '""')}"`;
382
+ }
383
+ return s;
384
+ }
385
+ function rowsToCsv(fields, rows, includeHeader) {
386
+ const lines = [];
387
+ if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
388
+ for (const row of rows) {
389
+ lines.push(fields.map((f) => formatCsvCell(row?.[f])).join(","));
390
+ }
391
+ return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
252
392
  }
253
393
  var RestServer = class {
254
- constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
394
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
255
395
  this.protocol = protocol;
256
396
  this.config = this.normalizeConfig(config);
257
397
  this.routeManager = new RouteManager(server);
@@ -260,6 +400,11 @@ var RestServer = class {
260
400
  this.defaultProjectIdProvider = defaultProjectIdProvider;
261
401
  this.authServiceProvider = authServiceProvider;
262
402
  this.objectQLProvider = objectQLProvider;
403
+ this.emailServiceProvider = emailServiceProvider;
404
+ this.sharingServiceProvider = sharingServiceProvider;
405
+ this.reportsServiceProvider = reportsServiceProvider;
406
+ this.approvalsServiceProvider = approvalsServiceProvider;
407
+ this.sharingRulesServiceProvider = sharingRulesServiceProvider;
263
408
  }
264
409
  /**
265
410
  * Resolve the protocol for a given request. When `projectId` is present
@@ -336,6 +481,25 @@ var RestServer = class {
336
481
  return void 0;
337
482
  }
338
483
  }
484
+ /**
485
+ * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
486
+ * Returns `true` if the response was sent and the caller should stop
487
+ * processing. Returns `false` to continue.
488
+ *
489
+ * The check is intentionally narrow: only `context?.userId` counts as
490
+ * "authenticated". `isSystem` flags are never set on inbound HTTP
491
+ * requests (they're internal-only), so they cannot bypass this gate.
492
+ */
493
+ enforceAuth(req, res, context) {
494
+ if (!this.config.api.requireAuth) return false;
495
+ if (context?.userId) return false;
496
+ if (req?.method === "OPTIONS") return false;
497
+ res.status(401).json({
498
+ error: "unauthenticated",
499
+ message: "Authentication is required to access this endpoint."
500
+ });
501
+ return true;
502
+ }
339
503
  /**
340
504
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
341
505
  * the better-auth session via the project's `auth` service. Returns
@@ -344,6 +508,26 @@ var RestServer = class {
344
508
  */
345
509
  async resolveExecCtx(projectId, req) {
346
510
  try {
511
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
512
+ const host = this.extractHostname(req);
513
+ if (host) {
514
+ try {
515
+ const result = await this.envRegistry.resolveByHostname(host);
516
+ if (result?.projectId) projectId = result.projectId;
517
+ } catch {
518
+ }
519
+ }
520
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
521
+ const headerVal = this.extractProjectIdHeader(req);
522
+ if (headerVal) {
523
+ try {
524
+ const driver = await this.envRegistry.resolveById(headerVal);
525
+ if (driver) projectId = headerVal;
526
+ } catch {
527
+ }
528
+ }
529
+ }
530
+ }
347
531
  let authService;
348
532
  let kernel;
349
533
  if (projectId && projectId !== "platform" && this.kernelManager) {
@@ -583,8 +767,10 @@ var RestServer = class {
583
767
  enableUi: api.enableUi ?? true,
584
768
  enableBatch: api.enableBatch ?? true,
585
769
  enableDiscovery: api.enableDiscovery ?? true,
770
+ enableSearch: api.enableSearch ?? true,
586
771
  enableProjectScoping: api.enableProjectScoping ?? false,
587
772
  projectResolution: api.projectResolution ?? "auto",
773
+ requireAuth: api.requireAuth ?? false,
588
774
  documentation: api.documentation,
589
775
  responseFormat: api.responseFormat
590
776
  },
@@ -666,9 +852,18 @@ var RestServer = class {
666
852
  if (this.config.api.enableUi) {
667
853
  this.registerUiEndpoints(bp);
668
854
  }
855
+ if (this.config.api.enableSearch ?? true) {
856
+ this.registerSearchEndpoints(bp);
857
+ }
858
+ this.registerEmailEndpoints(bp);
859
+ this.registerSharingEndpoints(bp);
860
+ this.registerSharingRuleEndpoints(bp);
861
+ this.registerReportsEndpoints(bp);
862
+ this.registerApprovalsEndpoints(bp);
669
863
  if (this.config.api.enableCrud) {
670
864
  this.registerCrudEndpoints(bp);
671
865
  }
866
+ this.registerDataActionEndpoints(bp);
672
867
  if (this.config.api.enableBatch) {
673
868
  this.registerBatchEndpoints(bp);
674
869
  }
@@ -719,7 +914,7 @@ var RestServer = class {
719
914
  res.json(discovery);
720
915
  } catch (error) {
721
916
  logError("[REST] Unhandled error:", error);
722
- res.status(500).json({ error: error.message });
917
+ sendError(res, error);
723
918
  }
724
919
  };
725
920
  this.routeManager.register({
@@ -760,7 +955,7 @@ var RestServer = class {
760
955
  res.json(types);
761
956
  } catch (error) {
762
957
  logError("[REST] Unhandled error:", error);
763
- res.status(500).json({ error: error.message });
958
+ sendError(res, error);
764
959
  }
765
960
  },
766
961
  metadata: {
@@ -788,7 +983,7 @@ var RestServer = class {
788
983
  res.json(translated);
789
984
  } catch (error) {
790
985
  logError("[REST] Unhandled error:", error);
791
- res.status(404).json({ error: error.message });
986
+ sendError(res, error);
792
987
  }
793
988
  },
794
989
  metadata: {
@@ -846,7 +1041,7 @@ var RestServer = class {
846
1041
  }
847
1042
  } catch (error) {
848
1043
  logError("[REST] Unhandled error:", error);
849
- res.status(404).json({ error: error.message });
1044
+ sendError(res, error);
850
1045
  }
851
1046
  },
852
1047
  metadata: {
@@ -866,16 +1061,18 @@ var RestServer = class {
866
1061
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
867
1062
  return;
868
1063
  }
1064
+ const body = req.body ?? {};
1065
+ const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
869
1066
  const result = await p.saveMetaItem({
870
1067
  type: req.params.type,
871
1068
  name: req.params.name,
872
- item: req.body,
1069
+ item,
873
1070
  ...projectId ? { projectId } : {}
874
1071
  });
875
1072
  res.json(result);
876
1073
  } catch (error) {
877
1074
  logError("[REST] Unhandled error:", error);
878
- res.status(400).json({ error: error.message });
1075
+ sendError(res, error);
879
1076
  }
880
1077
  },
881
1078
  metadata: {
@@ -883,6 +1080,92 @@ var RestServer = class {
883
1080
  tags: ["metadata"]
884
1081
  }
885
1082
  });
1083
+ this.routeManager.register({
1084
+ method: "DELETE",
1085
+ path: `${metaPath}/:type/:name`,
1086
+ handler: async (req, res) => {
1087
+ try {
1088
+ const projectId = isScoped ? req.params?.projectId : void 0;
1089
+ const p = await this.resolveProtocol(projectId, req);
1090
+ if (!p.deleteMetaItem) {
1091
+ res.status(501).json({
1092
+ error: "Reset operation not supported by protocol implementation"
1093
+ });
1094
+ return;
1095
+ }
1096
+ const result = await p.deleteMetaItem({
1097
+ type: req.params.type,
1098
+ name: req.params.name,
1099
+ ...projectId ? { projectId } : {}
1100
+ });
1101
+ res.json(result);
1102
+ } catch (error) {
1103
+ logError("[REST] Unhandled error:", error);
1104
+ sendError(res, error);
1105
+ }
1106
+ },
1107
+ metadata: {
1108
+ summary: "Reset metadata item to artifact default (deletes customization overlay)",
1109
+ tags: ["metadata"]
1110
+ }
1111
+ });
1112
+ if (metadata.endpoints.item !== false) {
1113
+ this.routeManager.register({
1114
+ method: "GET",
1115
+ path: `${metaPath}/:type/:section/:name`,
1116
+ handler: async (req, res) => {
1117
+ try {
1118
+ const projectId = isScoped ? req.params?.projectId : void 0;
1119
+ const p = await this.resolveProtocol(projectId, req);
1120
+ const compoundName = `${req.params.section}/${req.params.name}`;
1121
+ const packageId = req.query?.package || void 0;
1122
+ const item = await p.getMetaItem({
1123
+ type: req.params.type,
1124
+ name: compoundName,
1125
+ packageId
1126
+ });
1127
+ res.header("Vary", "Accept-Language");
1128
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
1129
+ } catch (error) {
1130
+ logError("[REST] Unhandled error:", error);
1131
+ sendError(res, error);
1132
+ }
1133
+ },
1134
+ metadata: {
1135
+ summary: "Get specific metadata item by compound name",
1136
+ tags: ["metadata"]
1137
+ }
1138
+ });
1139
+ }
1140
+ this.routeManager.register({
1141
+ method: "PUT",
1142
+ path: `${metaPath}/:type/:section/:name`,
1143
+ handler: async (req, res) => {
1144
+ try {
1145
+ const projectId = isScoped ? req.params?.projectId : void 0;
1146
+ const p = await this.resolveProtocol(projectId, req);
1147
+ if (!p.saveMetaItem) {
1148
+ res.status(501).json({ error: "Save operation not supported by protocol implementation" });
1149
+ return;
1150
+ }
1151
+ const compoundName = `${req.params.section}/${req.params.name}`;
1152
+ const result = await p.saveMetaItem({
1153
+ type: req.params.type,
1154
+ name: compoundName,
1155
+ item: req.body,
1156
+ ...projectId ? { projectId } : {}
1157
+ });
1158
+ res.json(result);
1159
+ } catch (error) {
1160
+ logError("[REST] Unhandled error:", error);
1161
+ sendError(res, error);
1162
+ }
1163
+ },
1164
+ metadata: {
1165
+ summary: "Save specific metadata item by compound name",
1166
+ tags: ["metadata"]
1167
+ }
1168
+ });
886
1169
  }
887
1170
  /**
888
1171
  * Register UI endpoints
@@ -909,7 +1192,7 @@ var RestServer = class {
909
1192
  }
910
1193
  } catch (error) {
911
1194
  logError("[REST] Unhandled error:", error);
912
- res.status(404).json({ error: error.message });
1195
+ sendError(res, error, req.params?.object);
913
1196
  }
914
1197
  },
915
1198
  metadata: {
@@ -935,6 +1218,7 @@ var RestServer = class {
935
1218
  const projectId = isScoped ? req.params?.projectId : void 0;
936
1219
  const p = await this.resolveProtocol(projectId, req);
937
1220
  const context = await this.resolveExecCtx(projectId, req);
1221
+ if (this.enforceAuth(req, res, context)) return;
938
1222
  const result = await p.findData({
939
1223
  object: req.params.object,
940
1224
  query: req.query,
@@ -968,6 +1252,7 @@ var RestServer = class {
968
1252
  const p = await this.resolveProtocol(projectId, req);
969
1253
  const { select, expand } = req.query || {};
970
1254
  const context = await this.resolveExecCtx(projectId, req);
1255
+ if (this.enforceAuth(req, res, context)) return;
971
1256
  const result = await p.getData({
972
1257
  object: req.params.object,
973
1258
  id: req.params.id,
@@ -979,7 +1264,7 @@ var RestServer = class {
979
1264
  res.json(result);
980
1265
  } catch (error) {
981
1266
  const mapped = mapDataError(error, req.params?.object);
982
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1267
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
983
1268
  res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
984
1269
  }
985
1270
  },
@@ -998,6 +1283,7 @@ var RestServer = class {
998
1283
  const projectId = isScoped ? req.params?.projectId : void 0;
999
1284
  const p = await this.resolveProtocol(projectId, req);
1000
1285
  const context = await this.resolveExecCtx(projectId, req);
1286
+ if (this.enforceAuth(req, res, context)) return;
1001
1287
  const result = await p.createData({
1002
1288
  object: req.params.object,
1003
1289
  data: req.body,
@@ -1007,7 +1293,7 @@ var RestServer = class {
1007
1293
  res.status(201).json(result);
1008
1294
  } catch (error) {
1009
1295
  const mapped = mapDataError(error, req.params?.object);
1010
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1296
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1011
1297
  res.status(mapped.status).json(mapped.body);
1012
1298
  }
1013
1299
  },
@@ -1017,6 +1303,35 @@ var RestServer = class {
1017
1303
  }
1018
1304
  });
1019
1305
  }
1306
+ if (operations.list) {
1307
+ this.routeManager.register({
1308
+ method: "POST",
1309
+ path: `${dataPath}/:object/query`,
1310
+ handler: async (req, res) => {
1311
+ try {
1312
+ const projectId = isScoped ? req.params?.projectId : void 0;
1313
+ const p = await this.resolveProtocol(projectId, req);
1314
+ const context = await this.resolveExecCtx(projectId, req);
1315
+ if (this.enforceAuth(req, res, context)) return;
1316
+ const result = await p.findData({
1317
+ object: req.params.object,
1318
+ query: req.body || {},
1319
+ ...projectId ? { projectId } : {},
1320
+ ...context ? { context } : {}
1321
+ });
1322
+ res.json(result);
1323
+ } catch (error) {
1324
+ const mapped = mapDataError(error, req.params?.object);
1325
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1326
+ res.status(mapped.status).json(mapped.body);
1327
+ }
1328
+ },
1329
+ metadata: {
1330
+ summary: "Advanced query (QueryAST in body)",
1331
+ tags: ["data", "crud"]
1332
+ }
1333
+ });
1334
+ }
1020
1335
  if (operations.update) {
1021
1336
  this.routeManager.register({
1022
1337
  method: "PATCH",
@@ -1026,6 +1341,7 @@ var RestServer = class {
1026
1341
  const projectId = isScoped ? req.params?.projectId : void 0;
1027
1342
  const p = await this.resolveProtocol(projectId, req);
1028
1343
  const context = await this.resolveExecCtx(projectId, req);
1344
+ if (this.enforceAuth(req, res, context)) return;
1029
1345
  const result = await p.updateData({
1030
1346
  object: req.params.object,
1031
1347
  id: req.params.id,
@@ -1036,7 +1352,7 @@ var RestServer = class {
1036
1352
  res.json(result);
1037
1353
  } catch (error) {
1038
1354
  const mapped = mapDataError(error, req.params?.object);
1039
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1355
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1040
1356
  res.status(mapped.status).json(mapped.body);
1041
1357
  }
1042
1358
  },
@@ -1055,6 +1371,7 @@ var RestServer = class {
1055
1371
  const projectId = isScoped ? req.params?.projectId : void 0;
1056
1372
  const p = await this.resolveProtocol(projectId, req);
1057
1373
  const context = await this.resolveExecCtx(projectId, req);
1374
+ if (this.enforceAuth(req, res, context)) return;
1058
1375
  const result = await p.deleteData({
1059
1376
  object: req.params.object,
1060
1377
  id: req.params.id,
@@ -1064,7 +1381,7 @@ var RestServer = class {
1064
1381
  res.json(result);
1065
1382
  } catch (error) {
1066
1383
  const mapped = mapDataError(error, req.params?.object);
1067
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1384
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1068
1385
  res.status(mapped.status).json(mapped.body);
1069
1386
  }
1070
1387
  },
@@ -1076,88 +1393,1263 @@ var RestServer = class {
1076
1393
  }
1077
1394
  }
1078
1395
  /**
1079
- * Register batch operation endpoints
1396
+ * Register object-specific action endpoints that don't fit the
1397
+ * generic CRUD shape. These are domain operations (Salesforce
1398
+ * convertLead, etc.) where the protocol implementation does its own
1399
+ * multi-record orchestration and we just need a thin HTTP route.
1400
+ *
1401
+ * POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
1080
1402
  */
1081
- registerBatchEndpoints(basePath) {
1082
- const { crud, batch } = this.config;
1083
- const dataPath = `${basePath}${crud.dataPrefix}`;
1403
+ registerDataActionEndpoints(basePath) {
1084
1404
  const isScoped = basePath.includes("/projects/:projectId");
1085
- const operations = batch.operations;
1086
- if (batch.enableBatchEndpoint && this.protocol.batchData) {
1087
- this.routeManager.register({
1088
- method: "POST",
1089
- path: `${dataPath}/:object/batch`,
1090
- handler: async (req, res) => {
1091
- try {
1092
- const projectId = isScoped ? req.params?.projectId : void 0;
1093
- const p = await this.resolveProtocol(projectId, req);
1094
- const result = await p.batchData({
1095
- object: req.params.object,
1096
- request: req.body,
1097
- ...projectId ? { projectId } : {}
1098
- });
1099
- res.json(result);
1100
- } catch (error) {
1101
- logError("[REST] Unhandled error:", error);
1102
- res.status(400).json({ error: error.message });
1405
+ const { crud } = this.config;
1406
+ const dataPath = `${basePath}${crud.dataPrefix}`;
1407
+ this.routeManager.register({
1408
+ method: "POST",
1409
+ path: `${dataPath}/lead/:id/convert`,
1410
+ handler: async (req, res) => {
1411
+ try {
1412
+ const projectId = isScoped ? req.params?.projectId : void 0;
1413
+ const p = await this.resolveProtocol(projectId, req);
1414
+ const context = await this.resolveExecCtx(projectId, req);
1415
+ if (this.enforceAuth(req, res, context)) return;
1416
+ const convertLead = p.convertLead;
1417
+ if (typeof convertLead !== "function") {
1418
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
1419
+ return;
1103
1420
  }
1104
- },
1105
- metadata: {
1106
- summary: "Batch operations",
1107
- tags: ["data", "batch"]
1421
+ const body = req.body ?? {};
1422
+ const result = await convertLead.call(p, {
1423
+ leadId: req.params.id,
1424
+ accountId: body.accountId,
1425
+ contactId: body.contactId,
1426
+ createOpportunity: body.createOpportunity,
1427
+ opportunity: body.opportunity,
1428
+ convertedStatus: body.convertedStatus,
1429
+ ...context ? { context } : {}
1430
+ });
1431
+ res.json(result);
1432
+ } catch (error) {
1433
+ logError("[REST] Unhandled error:", error);
1434
+ sendError(res, error, "lead");
1108
1435
  }
1109
- });
1110
- }
1111
- if (operations.createMany && this.protocol.createManyData) {
1112
- this.routeManager.register({
1113
- method: "POST",
1114
- path: `${dataPath}/:object/createMany`,
1115
- handler: async (req, res) => {
1116
- try {
1117
- const projectId = isScoped ? req.params?.projectId : void 0;
1118
- const p = await this.resolveProtocol(projectId, req);
1119
- const result = await p.createManyData({
1120
- object: req.params.object,
1121
- records: req.body || [],
1122
- ...projectId ? { projectId } : {}
1436
+ },
1437
+ metadata: {
1438
+ summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
1439
+ tags: ["data", "lead"]
1440
+ }
1441
+ });
1442
+ this.routeManager.register({
1443
+ method: "POST",
1444
+ path: `${dataPath}/:object/import`,
1445
+ handler: async (req, res) => {
1446
+ try {
1447
+ const projectId = isScoped ? req.params?.projectId : void 0;
1448
+ const p = await this.resolveProtocol(projectId, req);
1449
+ const context = await this.resolveExecCtx(projectId, req);
1450
+ if (this.enforceAuth(req, res, context)) return;
1451
+ const objectName = String(req.params.object || "");
1452
+ if (!objectName) {
1453
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1454
+ return;
1455
+ }
1456
+ const body = req.body ?? {};
1457
+ const dryRun = body.dryRun === true;
1458
+ const mapping = body.mapping ?? {};
1459
+ let rows = [];
1460
+ if (body.format === "json" && Array.isArray(body.rows)) {
1461
+ rows = body.rows;
1462
+ } else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
1463
+ rows = parseCsvToRows(body.csv, mapping);
1464
+ } else if (Array.isArray(body)) {
1465
+ rows = body;
1466
+ } else {
1467
+ res.status(400).json({
1468
+ code: "INVALID_REQUEST",
1469
+ error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
1123
1470
  });
1124
- res.status(201).json(result);
1125
- } catch (error) {
1126
- logError("[REST] Unhandled error:", error);
1127
- res.status(400).json({ error: error.message });
1471
+ return;
1128
1472
  }
1129
- },
1130
- metadata: {
1131
- summary: "Create multiple records",
1132
- tags: ["data", "batch"]
1133
- }
1134
- });
1135
- }
1136
- if (operations.updateMany && this.protocol.updateManyData) {
1137
- this.routeManager.register({
1138
- method: "POST",
1139
- path: `${dataPath}/:object/updateMany`,
1140
- handler: async (req, res) => {
1141
- try {
1142
- const projectId = isScoped ? req.params?.projectId : void 0;
1143
- const p = await this.resolveProtocol(projectId, req);
1144
- const result = await p.updateManyData({
1145
- object: req.params.object,
1146
- ...req.body,
1147
- ...projectId ? { projectId } : {}
1473
+ const max = 5e3;
1474
+ if (rows.length > max) {
1475
+ res.status(413).json({
1476
+ code: "PAYLOAD_TOO_LARGE",
1477
+ error: `Import limit is ${max} rows per request (got ${rows.length})`
1148
1478
  });
1149
- res.json(result);
1150
- } catch (error) {
1151
- logError("[REST] Unhandled error:", error);
1152
- res.status(400).json({ error: error.message });
1479
+ return;
1153
1480
  }
1154
- },
1155
- metadata: {
1156
- summary: "Update multiple records",
1157
- tags: ["data", "batch"]
1158
- }
1159
- });
1160
- }
1481
+ const results = [];
1482
+ let okCount = 0;
1483
+ let errCount = 0;
1484
+ for (let i = 0; i < rows.length; i++) {
1485
+ const data = rows[i];
1486
+ try {
1487
+ if (dryRun) {
1488
+ const validate = p.validate;
1489
+ if (typeof validate === "function") {
1490
+ await validate.call(p, { object: objectName, data, context });
1491
+ }
1492
+ results.push({ row: i + 1, ok: true });
1493
+ okCount++;
1494
+ } else {
1495
+ const created = await p.createData({ object: objectName, data, context });
1496
+ const id = created?.id ?? created?.record?.id;
1497
+ results.push({ row: i + 1, ok: true, id });
1498
+ okCount++;
1499
+ }
1500
+ } catch (err) {
1501
+ errCount++;
1502
+ const code = err?.code ?? "IMPORT_ROW_FAILED";
1503
+ const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
1504
+ results.push({ row: i + 1, ok: false, error: message, code });
1505
+ }
1506
+ }
1507
+ res.json({
1508
+ object: objectName,
1509
+ dryRun,
1510
+ total: rows.length,
1511
+ ok: okCount,
1512
+ errors: errCount,
1513
+ results
1514
+ });
1515
+ } catch (error) {
1516
+ logError("[REST] Unhandled error:", error);
1517
+ sendError(res, error, String(req.params?.object || ""));
1518
+ }
1519
+ },
1520
+ metadata: {
1521
+ summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
1522
+ tags: ["data", "import"]
1523
+ }
1524
+ });
1525
+ this.routeManager.register({
1526
+ method: "GET",
1527
+ path: `${dataPath}/:object/export`,
1528
+ handler: async (req, res) => {
1529
+ try {
1530
+ const projectId = isScoped ? req.params?.projectId : void 0;
1531
+ const p = await this.resolveProtocol(projectId, req);
1532
+ const context = await this.resolveExecCtx(projectId, req);
1533
+ if (this.enforceAuth(req, res, context)) return;
1534
+ const objectName = String(req.params.object || "");
1535
+ if (!objectName) {
1536
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1537
+ return;
1538
+ }
1539
+ const q = req.query ?? {};
1540
+ const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
1541
+ const HARD_CAP = 5e4;
1542
+ const MAX_CHUNK = 5e3;
1543
+ const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
1544
+ const limit = Math.min(requestedLimit, HARD_CAP);
1545
+ const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
1546
+ let filter = void 0;
1547
+ if (typeof q.filter === "string" && q.filter.length > 0) {
1548
+ try {
1549
+ filter = JSON.parse(q.filter);
1550
+ } catch {
1551
+ res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
1552
+ return;
1553
+ }
1554
+ } else if (q.filter && typeof q.filter === "object") {
1555
+ filter = q.filter;
1556
+ }
1557
+ let orderby = void 0;
1558
+ if (typeof q.orderby === "string" && q.orderby.length > 0) {
1559
+ if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
1560
+ try {
1561
+ orderby = JSON.parse(q.orderby);
1562
+ } catch {
1563
+ }
1564
+ } else {
1565
+ const obj = {};
1566
+ for (const part of q.orderby.split(",")) {
1567
+ const [field, dir] = part.split(":").map((s) => s.trim());
1568
+ if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
1569
+ }
1570
+ if (Object.keys(obj).length > 0) orderby = obj;
1571
+ }
1572
+ }
1573
+ let fields;
1574
+ if (typeof q.fields === "string" && q.fields.length > 0) {
1575
+ fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
1576
+ } else if (Array.isArray(q.fields)) {
1577
+ fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
1578
+ }
1579
+ if (!fields || fields.length === 0) {
1580
+ try {
1581
+ const schema = await p.getObjectSchema?.(objectName, projectId);
1582
+ const schemaFields = schema?.fields;
1583
+ if (Array.isArray(schemaFields)) {
1584
+ fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
1585
+ }
1586
+ } catch {
1587
+ }
1588
+ }
1589
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1590
+ const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
1591
+ if (format === "csv") {
1592
+ res.header("Content-Type", "text/csv; charset=utf-8");
1593
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
1594
+ } else {
1595
+ res.header("Content-Type", "application/json; charset=utf-8");
1596
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
1597
+ }
1598
+ res.header("X-Export-Format", format);
1599
+ res.header("X-Export-Limit", String(limit));
1600
+ res.header("Cache-Control", "no-store");
1601
+ let exported = 0;
1602
+ let firstChunk = true;
1603
+ let skip = 0;
1604
+ if (format === "json") res.write("[");
1605
+ while (exported < limit) {
1606
+ const take = Math.min(chunkSize, limit - exported);
1607
+ const findArgs = {
1608
+ object: objectName,
1609
+ query: {
1610
+ ...filter ? { $filter: filter } : {},
1611
+ ...orderby ? { $orderby: orderby } : {},
1612
+ $top: take,
1613
+ $skip: skip
1614
+ },
1615
+ ...projectId ? { projectId } : {},
1616
+ ...context ? { context } : {}
1617
+ };
1618
+ const result = await p.findData(findArgs);
1619
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
1620
+ if (rows.length === 0) break;
1621
+ if (format === "csv") {
1622
+ if ((!fields || fields.length === 0) && firstChunk) {
1623
+ fields = Object.keys(rows[0] ?? {});
1624
+ }
1625
+ const text = rowsToCsv(fields ?? [], rows, firstChunk);
1626
+ res.write(text);
1627
+ } else {
1628
+ for (let i = 0; i < rows.length; i++) {
1629
+ const prefix = firstChunk && i === 0 ? "" : ",";
1630
+ res.write(prefix + JSON.stringify(rows[i]));
1631
+ }
1632
+ }
1633
+ firstChunk = false;
1634
+ exported += rows.length;
1635
+ skip += rows.length;
1636
+ if (rows.length < take) break;
1637
+ }
1638
+ if (format === "json") res.write("]");
1639
+ res.end();
1640
+ } catch (error) {
1641
+ logError("[REST] Unhandled error:", error);
1642
+ try {
1643
+ sendError(res, error, String(req.params?.object || ""));
1644
+ } catch {
1645
+ try {
1646
+ res.end();
1647
+ } catch {
1648
+ }
1649
+ }
1650
+ }
1651
+ },
1652
+ metadata: {
1653
+ summary: "Streaming export of object rows (CSV or JSON)",
1654
+ tags: ["data", "export"]
1655
+ }
1656
+ });
1657
+ }
1658
+ /**
1659
+ * Register global cross-object search endpoint (M10.5).
1660
+ * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
1661
+ */
1662
+ registerSearchEndpoints(basePath) {
1663
+ const isScoped = basePath.includes("/projects/:projectId");
1664
+ this.routeManager.register({
1665
+ method: "GET",
1666
+ path: `${basePath}/search`,
1667
+ handler: async (req, res) => {
1668
+ try {
1669
+ const projectId = isScoped ? req.params?.projectId : void 0;
1670
+ const p = await this.resolveProtocol(projectId, req);
1671
+ const context = await this.resolveExecCtx(projectId, req);
1672
+ if (this.enforceAuth(req, res, context)) return;
1673
+ const searchAll = p.searchAll;
1674
+ if (typeof searchAll !== "function") {
1675
+ res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
1676
+ return;
1677
+ }
1678
+ const q = String(req.query?.q ?? req.query?.query ?? "");
1679
+ const objectsParam = req.query?.objects;
1680
+ const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
1681
+ const result = await searchAll.call(p, {
1682
+ q,
1683
+ objects,
1684
+ limit: req.query?.limit ? Number(req.query.limit) : void 0,
1685
+ perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
1686
+ ...context ? { context } : {}
1687
+ });
1688
+ res.json(result);
1689
+ } catch (error) {
1690
+ const mapped = mapDataError(error);
1691
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
1692
+ logError("[REST] Unhandled error:", error);
1693
+ }
1694
+ res.status(mapped.status).json(mapped.body);
1695
+ }
1696
+ },
1697
+ metadata: {
1698
+ summary: "Global cross-object search",
1699
+ tags: ["search"]
1700
+ }
1701
+ });
1702
+ }
1703
+ /**
1704
+ * Register email endpoints (M11.B1 / M10.7).
1705
+ *
1706
+ * POST {basePath}/email/send — send a transactional email via the
1707
+ * `IEmailService` provider registered by EmailServicePlugin. Returns
1708
+ * 501 when no provider is wired so deployments without email
1709
+ * configured fail cleanly.
1710
+ *
1711
+ * Request body:
1712
+ * {
1713
+ * to: "a@b.com" | ["a@b.com", { name, address }],
1714
+ * from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
1715
+ * subject: string,
1716
+ * text?: string, html?: string, // at least one required
1717
+ * attachments?: [{ filename, content, contentType?, cid? }],
1718
+ * headers?: { [name]: value },
1719
+ * relatedObject?: string, relatedId?: string,
1720
+ * }
1721
+ */
1722
+ registerEmailEndpoints(basePath) {
1723
+ const isScoped = basePath.includes("/projects/:projectId");
1724
+ this.routeManager.register({
1725
+ method: "POST",
1726
+ path: `${basePath}/email/send`,
1727
+ handler: async (req, res) => {
1728
+ try {
1729
+ const projectId = isScoped ? req.params?.projectId : void 0;
1730
+ const context = await this.resolveExecCtx(projectId, req);
1731
+ if (this.enforceAuth(req, res, context)) return;
1732
+ if (!this.emailServiceProvider) {
1733
+ res.status(501).json({
1734
+ code: "NOT_IMPLEMENTED",
1735
+ message: "Email service is not configured on this deployment"
1736
+ });
1737
+ return;
1738
+ }
1739
+ const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
1740
+ if (!emailService || typeof emailService.send !== "function") {
1741
+ res.status(501).json({
1742
+ code: "NOT_IMPLEMENTED",
1743
+ message: "Email service is not configured on this deployment"
1744
+ });
1745
+ return;
1746
+ }
1747
+ const body = req.body ?? {};
1748
+ if (!body || typeof body !== "object") {
1749
+ res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
1750
+ return;
1751
+ }
1752
+ const input = {
1753
+ ...body,
1754
+ ...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
1755
+ };
1756
+ try {
1757
+ const result = await emailService.send(input);
1758
+ if (result?.status === "sent") {
1759
+ res.status(200).json(result);
1760
+ } else {
1761
+ res.status(200).json(result);
1762
+ }
1763
+ } catch (err) {
1764
+ const message = String(err?.message ?? err ?? "send failed");
1765
+ if (message.startsWith("VALIDATION_FAILED")) {
1766
+ res.status(400).json({
1767
+ code: "VALIDATION_FAILED",
1768
+ error: message.replace(/^VALIDATION_FAILED:\s*/, "")
1769
+ });
1770
+ return;
1771
+ }
1772
+ throw err;
1773
+ }
1774
+ } catch (error) {
1775
+ logError("[REST] Email send unhandled error:", error);
1776
+ res.status(500).json({
1777
+ code: "EMAIL_SEND_FAILED",
1778
+ error: String(error?.message ?? error ?? "send failed").slice(0, 500)
1779
+ });
1780
+ }
1781
+ },
1782
+ metadata: {
1783
+ summary: "Send a transactional email via the configured EmailService",
1784
+ tags: ["email"]
1785
+ }
1786
+ });
1787
+ }
1788
+ /**
1789
+ * Register record-level sharing endpoints (M11.C17).
1790
+ *
1791
+ * Surfaces `ISharingService` over HTTP so the UI can list, create
1792
+ * and revoke per-record grants without going through ObjectQL. The
1793
+ * three routes mirror the share-management drawer in Salesforce /
1794
+ * ServiceNow:
1795
+ *
1796
+ * GET {basePath}/data/:object/:id/shares
1797
+ * POST {basePath}/data/:object/:id/shares
1798
+ * DELETE {basePath}/data/:object/:id/shares/:shareId
1799
+ *
1800
+ * All three resolve via `sharingServiceProvider`; routes return 501
1801
+ * when no sharing service is configured so a deployment without the
1802
+ * `@objectstack/plugin-sharing` plugin fails cleanly.
1803
+ */
1804
+ registerSharingEndpoints(basePath) {
1805
+ const { crud } = this.config;
1806
+ const dataPath = `${basePath}${crud.dataPrefix}`;
1807
+ const isScoped = basePath.includes("/projects/:projectId");
1808
+ const resolveService = async (projectId) => {
1809
+ if (!this.sharingServiceProvider) return void 0;
1810
+ try {
1811
+ return await this.sharingServiceProvider(projectId);
1812
+ } catch {
1813
+ return void 0;
1814
+ }
1815
+ };
1816
+ const respond501 = (res) => res.status(501).json({
1817
+ code: "NOT_IMPLEMENTED",
1818
+ message: "Sharing service is not configured on this deployment"
1819
+ });
1820
+ this.routeManager.register({
1821
+ method: "GET",
1822
+ path: `${dataPath}/:object/:id/shares`,
1823
+ handler: async (req, res) => {
1824
+ try {
1825
+ const projectId = isScoped ? req.params?.projectId : void 0;
1826
+ const context = await this.resolveExecCtx(projectId, req);
1827
+ if (this.enforceAuth(req, res, context)) return;
1828
+ const svc = await resolveService(projectId);
1829
+ if (!svc) return respond501(res);
1830
+ const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
1831
+ res.json({ data: rows });
1832
+ } catch (error) {
1833
+ logError("[REST] List shares error:", error);
1834
+ res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1835
+ }
1836
+ },
1837
+ metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
1838
+ });
1839
+ this.routeManager.register({
1840
+ method: "POST",
1841
+ path: `${dataPath}/:object/:id/shares`,
1842
+ handler: async (req, res) => {
1843
+ try {
1844
+ const projectId = isScoped ? req.params?.projectId : void 0;
1845
+ const context = await this.resolveExecCtx(projectId, req);
1846
+ if (this.enforceAuth(req, res, context)) return;
1847
+ const svc = await resolveService(projectId);
1848
+ if (!svc) return respond501(res);
1849
+ const body = req.body ?? {};
1850
+ const input = {
1851
+ object: req.params.object,
1852
+ recordId: req.params.id,
1853
+ recipientType: body.recipientType ?? body.recipient_type,
1854
+ recipientId: body.recipientId ?? body.recipient_id,
1855
+ accessLevel: body.accessLevel ?? body.access_level,
1856
+ source: body.source,
1857
+ sourceId: body.sourceId ?? body.source_id,
1858
+ reason: body.reason
1859
+ };
1860
+ try {
1861
+ const row = await svc.grant(input, context ?? {});
1862
+ res.status(201).json(row);
1863
+ } catch (err) {
1864
+ const msg = String(err?.message ?? err ?? "");
1865
+ if (msg.startsWith("VALIDATION_FAILED")) {
1866
+ res.status(400).json({
1867
+ code: "VALIDATION_FAILED",
1868
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
1869
+ });
1870
+ return;
1871
+ }
1872
+ throw err;
1873
+ }
1874
+ } catch (error) {
1875
+ logError("[REST] Grant share error:", error);
1876
+ res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1877
+ }
1878
+ },
1879
+ metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
1880
+ });
1881
+ this.routeManager.register({
1882
+ method: "DELETE",
1883
+ path: `${dataPath}/:object/:id/shares/:shareId`,
1884
+ handler: async (req, res) => {
1885
+ try {
1886
+ const projectId = isScoped ? req.params?.projectId : void 0;
1887
+ const context = await this.resolveExecCtx(projectId, req);
1888
+ if (this.enforceAuth(req, res, context)) return;
1889
+ const svc = await resolveService(projectId);
1890
+ if (!svc) return respond501(res);
1891
+ await svc.revoke(req.params.shareId, context ?? {});
1892
+ res.status(204).end();
1893
+ } catch (error) {
1894
+ logError("[REST] Revoke share error:", error);
1895
+ res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1896
+ }
1897
+ },
1898
+ metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
1899
+ });
1900
+ }
1901
+ /**
1902
+ * Register sharing-rule endpoints (M10.17). Mirrors the existing
1903
+ * sharing endpoints but operates on `sys_sharing_rule` rows.
1904
+ *
1905
+ * GET {basePath}/sharing/rules?object=&activeOnly=
1906
+ * POST {basePath}/sharing/rules
1907
+ * GET {basePath}/sharing/rules/:idOrName
1908
+ * DELETE {basePath}/sharing/rules/:idOrName
1909
+ * POST {basePath}/sharing/rules/:idOrName/evaluate
1910
+ *
1911
+ * Returns 501 when no sharing-rule service is configured.
1912
+ */
1913
+ registerSharingRuleEndpoints(basePath) {
1914
+ const dataPath = basePath;
1915
+ const isScoped = basePath.includes("/projects/:projectId");
1916
+ const resolveService = async (projectId) => {
1917
+ if (!this.sharingRulesServiceProvider) return void 0;
1918
+ try {
1919
+ return await this.sharingRulesServiceProvider(projectId);
1920
+ } catch {
1921
+ return void 0;
1922
+ }
1923
+ };
1924
+ const respond501 = (res) => res.status(501).json({
1925
+ code: "NOT_IMPLEMENTED",
1926
+ message: "Sharing-rule service is not configured on this deployment"
1927
+ });
1928
+ const handleError = (err, res, defaultCode) => {
1929
+ const msg = String(err?.message ?? err ?? "");
1930
+ if (msg.startsWith("VALIDATION_FAILED")) {
1931
+ return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
1932
+ }
1933
+ if (msg.startsWith("RULE_NOT_FOUND")) {
1934
+ return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
1935
+ }
1936
+ logError(`[REST] sharing-rule ${defaultCode}:`, err);
1937
+ return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
1938
+ };
1939
+ this.routeManager.register({
1940
+ method: "GET",
1941
+ path: `${dataPath}/sharing/rules`,
1942
+ handler: async (req, res) => {
1943
+ try {
1944
+ const projectId = isScoped ? req.params?.projectId : void 0;
1945
+ const context = await this.resolveExecCtx(projectId, req);
1946
+ if (this.enforceAuth(req, res, context)) return;
1947
+ const svc = await resolveService(projectId);
1948
+ if (!svc) return respond501(res);
1949
+ const rows = await svc.listRules({
1950
+ object: req.query?.object,
1951
+ activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
1952
+ }, context ?? {});
1953
+ res.json({ data: rows });
1954
+ } catch (err) {
1955
+ handleError(err, res, "RULE_LIST_FAILED");
1956
+ }
1957
+ },
1958
+ metadata: { summary: "List sharing rules", tags: ["sharing"] }
1959
+ });
1960
+ this.routeManager.register({
1961
+ method: "POST",
1962
+ path: `${dataPath}/sharing/rules`,
1963
+ handler: async (req, res) => {
1964
+ try {
1965
+ const projectId = isScoped ? req.params?.projectId : void 0;
1966
+ const context = await this.resolveExecCtx(projectId, req);
1967
+ if (this.enforceAuth(req, res, context)) return;
1968
+ const svc = await resolveService(projectId);
1969
+ if (!svc) return respond501(res);
1970
+ const body = req.body ?? {};
1971
+ const input = {
1972
+ name: body.name,
1973
+ label: body.label,
1974
+ description: body.description,
1975
+ object: body.object ?? body.object_name,
1976
+ criteria: body.criteria,
1977
+ recipientType: body.recipientType ?? body.recipient_type,
1978
+ recipientId: body.recipientId ?? body.recipient_id,
1979
+ accessLevel: body.accessLevel ?? body.access_level,
1980
+ active: body.active
1981
+ };
1982
+ const row = await svc.defineRule(input, context ?? {});
1983
+ res.status(201).json(row);
1984
+ } catch (err) {
1985
+ handleError(err, res, "RULE_DEFINE_FAILED");
1986
+ }
1987
+ },
1988
+ metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
1989
+ });
1990
+ this.routeManager.register({
1991
+ method: "GET",
1992
+ path: `${dataPath}/sharing/rules/:idOrName`,
1993
+ handler: async (req, res) => {
1994
+ try {
1995
+ const projectId = isScoped ? req.params?.projectId : void 0;
1996
+ const context = await this.resolveExecCtx(projectId, req);
1997
+ if (this.enforceAuth(req, res, context)) return;
1998
+ const svc = await resolveService(projectId);
1999
+ if (!svc) return respond501(res);
2000
+ const row = await svc.getRule(req.params.idOrName, context ?? {});
2001
+ if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
2002
+ res.json(row);
2003
+ } catch (err) {
2004
+ handleError(err, res, "RULE_GET_FAILED");
2005
+ }
2006
+ },
2007
+ metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
2008
+ });
2009
+ this.routeManager.register({
2010
+ method: "DELETE",
2011
+ path: `${dataPath}/sharing/rules/:idOrName`,
2012
+ handler: async (req, res) => {
2013
+ try {
2014
+ const projectId = isScoped ? req.params?.projectId : void 0;
2015
+ const context = await this.resolveExecCtx(projectId, req);
2016
+ if (this.enforceAuth(req, res, context)) return;
2017
+ const svc = await resolveService(projectId);
2018
+ if (!svc) return respond501(res);
2019
+ await svc.deleteRule(req.params.idOrName, context ?? {});
2020
+ res.status(204).end();
2021
+ } catch (err) {
2022
+ handleError(err, res, "RULE_DELETE_FAILED");
2023
+ }
2024
+ },
2025
+ metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
2026
+ });
2027
+ this.routeManager.register({
2028
+ method: "POST",
2029
+ path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
2030
+ handler: async (req, res) => {
2031
+ try {
2032
+ const projectId = isScoped ? req.params?.projectId : void 0;
2033
+ const context = await this.resolveExecCtx(projectId, req);
2034
+ if (this.enforceAuth(req, res, context)) return;
2035
+ const svc = await resolveService(projectId);
2036
+ if (!svc) return respond501(res);
2037
+ const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
2038
+ res.json(result);
2039
+ } catch (err) {
2040
+ handleError(err, res, "RULE_EVALUATE_FAILED");
2041
+ }
2042
+ },
2043
+ metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
2044
+ });
2045
+ }
2046
+ /**
2047
+ * Register saved-report + scheduled-digest endpoints (M11.C16).
2048
+ *
2049
+ * Surfaces `IReportService` over HTTP so the UI can build,
2050
+ * run, and schedule reports without dropping to ObjectQL. Routes
2051
+ * live at the top of the API surface (alongside `/approvals` and
2052
+ * `/sharing`) — reports are a tenant-wide capability, not a record
2053
+ * on a specific CRUD object:
2054
+ *
2055
+ * GET {basePath}/reports?object=&ownerId=
2056
+ * POST {basePath}/reports
2057
+ * GET {basePath}/reports/:id
2058
+ * DELETE {basePath}/reports/:id
2059
+ * POST {basePath}/reports/:id/run
2060
+ * POST {basePath}/reports/:id/schedule
2061
+ * GET {basePath}/reports/:id/schedules
2062
+ * DELETE {basePath}/reports/schedules/:scheduleId
2063
+ *
2064
+ * All routes return 501 when `reportsServiceProvider` is unset so
2065
+ * a deployment without `@objectstack/plugin-reports` fails cleanly.
2066
+ */
2067
+ registerReportsEndpoints(basePath) {
2068
+ const dataPath = basePath;
2069
+ const isScoped = basePath.includes("/projects/:projectId");
2070
+ const resolveService = async (projectId) => {
2071
+ if (!this.reportsServiceProvider) return void 0;
2072
+ try {
2073
+ return await this.reportsServiceProvider(projectId);
2074
+ } catch {
2075
+ return void 0;
2076
+ }
2077
+ };
2078
+ const respond501 = (res) => res.status(501).json({
2079
+ code: "NOT_IMPLEMENTED",
2080
+ message: "Reports service is not configured on this deployment"
2081
+ });
2082
+ const handleValidation = (res, err) => {
2083
+ const msg = String(err?.message ?? err ?? "");
2084
+ if (msg.startsWith("VALIDATION_FAILED")) {
2085
+ res.status(400).json({
2086
+ code: "VALIDATION_FAILED",
2087
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2088
+ });
2089
+ return true;
2090
+ }
2091
+ if (msg.startsWith("REPORT_NOT_FOUND")) {
2092
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
2093
+ return true;
2094
+ }
2095
+ return false;
2096
+ };
2097
+ this.routeManager.register({
2098
+ method: "GET",
2099
+ path: `${dataPath}/reports`,
2100
+ handler: async (req, res) => {
2101
+ try {
2102
+ const projectId = isScoped ? req.params?.projectId : void 0;
2103
+ const context = await this.resolveExecCtx(projectId, req);
2104
+ if (this.enforceAuth(req, res, context)) return;
2105
+ const svc = await resolveService(projectId);
2106
+ if (!svc) return respond501(res);
2107
+ const q = req.query ?? {};
2108
+ const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
2109
+ res.json({ data: rows });
2110
+ } catch (error) {
2111
+ logError("[REST] List reports error:", error);
2112
+ res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2113
+ }
2114
+ },
2115
+ metadata: { summary: "List saved reports", tags: ["reports"] }
2116
+ });
2117
+ this.routeManager.register({
2118
+ method: "POST",
2119
+ path: `${dataPath}/reports`,
2120
+ handler: async (req, res) => {
2121
+ try {
2122
+ const projectId = isScoped ? req.params?.projectId : void 0;
2123
+ const context = await this.resolveExecCtx(projectId, req);
2124
+ if (this.enforceAuth(req, res, context)) return;
2125
+ const svc = await resolveService(projectId);
2126
+ if (!svc) return respond501(res);
2127
+ try {
2128
+ const row = await svc.saveReport(req.body ?? {}, context ?? {});
2129
+ res.status(201).json(row);
2130
+ } catch (err) {
2131
+ if (handleValidation(res, err)) return;
2132
+ throw err;
2133
+ }
2134
+ } catch (error) {
2135
+ logError("[REST] Save report error:", error);
2136
+ res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2137
+ }
2138
+ },
2139
+ metadata: { summary: "Create or update a saved report", tags: ["reports"] }
2140
+ });
2141
+ this.routeManager.register({
2142
+ method: "GET",
2143
+ path: `${dataPath}/reports/:id`,
2144
+ handler: async (req, res) => {
2145
+ try {
2146
+ const projectId = isScoped ? req.params?.projectId : void 0;
2147
+ const context = await this.resolveExecCtx(projectId, req);
2148
+ if (this.enforceAuth(req, res, context)) return;
2149
+ const svc = await resolveService(projectId);
2150
+ if (!svc) return respond501(res);
2151
+ const row = await svc.getReport(req.params.id, context ?? {});
2152
+ if (!row) {
2153
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
2154
+ return;
2155
+ }
2156
+ res.json(row);
2157
+ } catch (error) {
2158
+ logError("[REST] Get report error:", error);
2159
+ res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2160
+ }
2161
+ },
2162
+ metadata: { summary: "Get a saved report by id", tags: ["reports"] }
2163
+ });
2164
+ this.routeManager.register({
2165
+ method: "DELETE",
2166
+ path: `${dataPath}/reports/:id`,
2167
+ handler: async (req, res) => {
2168
+ try {
2169
+ const projectId = isScoped ? req.params?.projectId : void 0;
2170
+ const context = await this.resolveExecCtx(projectId, req);
2171
+ if (this.enforceAuth(req, res, context)) return;
2172
+ const svc = await resolveService(projectId);
2173
+ if (!svc) return respond501(res);
2174
+ await svc.deleteReport(req.params.id, context ?? {});
2175
+ res.status(204).end();
2176
+ } catch (error) {
2177
+ logError("[REST] Delete report error:", error);
2178
+ res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2179
+ }
2180
+ },
2181
+ metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
2182
+ });
2183
+ this.routeManager.register({
2184
+ method: "POST",
2185
+ path: `${dataPath}/reports/:id/run`,
2186
+ handler: async (req, res) => {
2187
+ try {
2188
+ const projectId = isScoped ? req.params?.projectId : void 0;
2189
+ const context = await this.resolveExecCtx(projectId, req);
2190
+ if (this.enforceAuth(req, res, context)) return;
2191
+ const svc = await resolveService(projectId);
2192
+ if (!svc) return respond501(res);
2193
+ try {
2194
+ const result = await svc.run(req.params.id, context ?? {});
2195
+ res.json(result);
2196
+ } catch (err) {
2197
+ if (handleValidation(res, err)) return;
2198
+ throw err;
2199
+ }
2200
+ } catch (error) {
2201
+ logError("[REST] Run report error:", error);
2202
+ res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2203
+ }
2204
+ },
2205
+ metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
2206
+ });
2207
+ this.routeManager.register({
2208
+ method: "POST",
2209
+ path: `${dataPath}/reports/:id/schedule`,
2210
+ handler: async (req, res) => {
2211
+ try {
2212
+ const projectId = isScoped ? req.params?.projectId : void 0;
2213
+ const context = await this.resolveExecCtx(projectId, req);
2214
+ if (this.enforceAuth(req, res, context)) return;
2215
+ const svc = await resolveService(projectId);
2216
+ if (!svc) return respond501(res);
2217
+ const body = req.body ?? {};
2218
+ try {
2219
+ const row = await svc.scheduleReport({
2220
+ reportId: req.params.id,
2221
+ recipients: body.recipients ?? [],
2222
+ name: body.name,
2223
+ intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
2224
+ cronExpression: body.cronExpression ?? body.cron_expression,
2225
+ timezone: body.timezone,
2226
+ format: body.format,
2227
+ subjectTemplate: body.subjectTemplate ?? body.subject_template,
2228
+ ownerId: body.ownerId ?? body.owner_id,
2229
+ active: body.active
2230
+ }, context ?? {});
2231
+ res.status(201).json(row);
2232
+ } catch (err) {
2233
+ if (handleValidation(res, err)) return;
2234
+ throw err;
2235
+ }
2236
+ } catch (error) {
2237
+ logError("[REST] Schedule report error:", error);
2238
+ res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2239
+ }
2240
+ },
2241
+ metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
2242
+ });
2243
+ this.routeManager.register({
2244
+ method: "GET",
2245
+ path: `${dataPath}/reports/:id/schedules`,
2246
+ handler: async (req, res) => {
2247
+ try {
2248
+ const projectId = isScoped ? req.params?.projectId : void 0;
2249
+ const context = await this.resolveExecCtx(projectId, req);
2250
+ if (this.enforceAuth(req, res, context)) return;
2251
+ const svc = await resolveService(projectId);
2252
+ if (!svc) return respond501(res);
2253
+ const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
2254
+ res.json({ data: rows });
2255
+ } catch (error) {
2256
+ logError("[REST] List schedules error:", error);
2257
+ res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2258
+ }
2259
+ },
2260
+ metadata: { summary: "List schedules for a report", tags: ["reports"] }
2261
+ });
2262
+ this.routeManager.register({
2263
+ method: "DELETE",
2264
+ path: `${dataPath}/reports/schedules/:scheduleId`,
2265
+ handler: async (req, res) => {
2266
+ try {
2267
+ const projectId = isScoped ? req.params?.projectId : void 0;
2268
+ const context = await this.resolveExecCtx(projectId, req);
2269
+ if (this.enforceAuth(req, res, context)) return;
2270
+ const svc = await resolveService(projectId);
2271
+ if (!svc) return respond501(res);
2272
+ await svc.unscheduleReport(req.params.scheduleId, context ?? {});
2273
+ res.status(204).end();
2274
+ } catch (error) {
2275
+ logError("[REST] Unschedule report error:", error);
2276
+ res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2277
+ }
2278
+ },
2279
+ metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
2280
+ });
2281
+ }
2282
+ /**
2283
+ * Register approval engine endpoints.
2284
+ *
2285
+ * Routes (all under {basePath}/approvals):
2286
+ * GET /processes — list approval processes
2287
+ * POST /processes — upsert (defineProcess)
2288
+ * GET /processes/:id — get by id or name
2289
+ * DELETE /processes/:id — delete process
2290
+ * POST /requests — submit
2291
+ * GET /requests — list (filters: status, object, recordId, approverId, submitterId)
2292
+ * GET /requests/:id — get request
2293
+ * POST /requests/:id/approve — approve current step
2294
+ * POST /requests/:id/reject — reject current step
2295
+ * POST /requests/:id/recall — recall (submitter only)
2296
+ * GET /requests/:id/actions — audit trail
2297
+ *
2298
+ * Returns 501 when `approvalsServiceProvider` is unset so deployments
2299
+ * without `@objectstack/plugin-approvals` fail cleanly.
2300
+ */
2301
+ registerApprovalsEndpoints(basePath) {
2302
+ const dataPath = basePath;
2303
+ const isScoped = basePath.includes("/projects/:projectId");
2304
+ const resolveService = async (projectId) => {
2305
+ if (!this.approvalsServiceProvider) return void 0;
2306
+ try {
2307
+ return await this.approvalsServiceProvider(projectId);
2308
+ } catch {
2309
+ return void 0;
2310
+ }
2311
+ };
2312
+ const respond501 = (res) => res.status(501).json({
2313
+ code: "NOT_IMPLEMENTED",
2314
+ message: "Approvals service is not configured on this deployment"
2315
+ });
2316
+ const handleApprovalError = (res, err) => {
2317
+ const msg = String(err?.message ?? err ?? "");
2318
+ const mapping = [
2319
+ [/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
2320
+ [/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
2321
+ [/^INVALID_STATE/, 409, "INVALID_STATE"],
2322
+ [/^FORBIDDEN/, 403, "FORBIDDEN"],
2323
+ [/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
2324
+ [/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
2325
+ [/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
2326
+ ];
2327
+ for (const [re, status, code] of mapping) {
2328
+ if (re.test(msg)) {
2329
+ res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
2330
+ return true;
2331
+ }
2332
+ }
2333
+ return false;
2334
+ };
2335
+ this.routeManager.register({
2336
+ method: "GET",
2337
+ path: `${dataPath}/approvals/processes`,
2338
+ handler: async (req, res) => {
2339
+ try {
2340
+ const projectId = isScoped ? req.params?.projectId : void 0;
2341
+ const context = await this.resolveExecCtx(projectId, req);
2342
+ if (this.enforceAuth(req, res, context)) return;
2343
+ const svc = await resolveService(projectId);
2344
+ if (!svc) return respond501(res);
2345
+ const q = req.query ?? {};
2346
+ const rows = await svc.listProcesses({
2347
+ object: q.object,
2348
+ activeOnly: q.activeOnly === "true" || q.activeOnly === true
2349
+ }, context ?? {});
2350
+ res.json({ data: rows });
2351
+ } catch (error) {
2352
+ logError("[REST] List approval processes error:", error);
2353
+ res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2354
+ }
2355
+ },
2356
+ metadata: { summary: "List approval processes", tags: ["approvals"] }
2357
+ });
2358
+ this.routeManager.register({
2359
+ method: "POST",
2360
+ path: `${dataPath}/approvals/processes`,
2361
+ handler: async (req, res) => {
2362
+ try {
2363
+ const projectId = isScoped ? req.params?.projectId : void 0;
2364
+ const context = await this.resolveExecCtx(projectId, req);
2365
+ if (this.enforceAuth(req, res, context)) return;
2366
+ const svc = await resolveService(projectId);
2367
+ if (!svc) return respond501(res);
2368
+ try {
2369
+ const row = await svc.defineProcess(req.body ?? {}, context ?? {});
2370
+ res.status(201).json(row);
2371
+ } catch (err) {
2372
+ if (handleApprovalError(res, err)) return;
2373
+ throw err;
2374
+ }
2375
+ } catch (error) {
2376
+ logError("[REST] Define approval process error:", error);
2377
+ res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2378
+ }
2379
+ },
2380
+ metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
2381
+ });
2382
+ this.routeManager.register({
2383
+ method: "GET",
2384
+ path: `${dataPath}/approvals/processes/:id`,
2385
+ handler: async (req, res) => {
2386
+ try {
2387
+ const projectId = isScoped ? req.params?.projectId : void 0;
2388
+ const context = await this.resolveExecCtx(projectId, req);
2389
+ if (this.enforceAuth(req, res, context)) return;
2390
+ const svc = await resolveService(projectId);
2391
+ if (!svc) return respond501(res);
2392
+ const row = await svc.getProcess(req.params.id, context ?? {});
2393
+ if (!row) {
2394
+ res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
2395
+ return;
2396
+ }
2397
+ res.json(row);
2398
+ } catch (error) {
2399
+ logError("[REST] Get approval process error:", error);
2400
+ res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2401
+ }
2402
+ },
2403
+ metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
2404
+ });
2405
+ this.routeManager.register({
2406
+ method: "DELETE",
2407
+ path: `${dataPath}/approvals/processes/:id`,
2408
+ handler: async (req, res) => {
2409
+ try {
2410
+ const projectId = isScoped ? req.params?.projectId : void 0;
2411
+ const context = await this.resolveExecCtx(projectId, req);
2412
+ if (this.enforceAuth(req, res, context)) return;
2413
+ const svc = await resolveService(projectId);
2414
+ if (!svc) return respond501(res);
2415
+ await svc.deleteProcess(req.params.id, context ?? {});
2416
+ res.status(204).end();
2417
+ } catch (error) {
2418
+ logError("[REST] Delete approval process error:", error);
2419
+ res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2420
+ }
2421
+ },
2422
+ metadata: { summary: "Delete an approval process", tags: ["approvals"] }
2423
+ });
2424
+ this.routeManager.register({
2425
+ method: "POST",
2426
+ path: `${dataPath}/approvals/requests`,
2427
+ handler: async (req, res) => {
2428
+ try {
2429
+ const projectId = isScoped ? req.params?.projectId : void 0;
2430
+ const context = await this.resolveExecCtx(projectId, req);
2431
+ if (this.enforceAuth(req, res, context)) return;
2432
+ const svc = await resolveService(projectId);
2433
+ if (!svc) return respond501(res);
2434
+ const body = req.body ?? {};
2435
+ try {
2436
+ const row = await svc.submit({
2437
+ object: body.object,
2438
+ recordId: body.recordId ?? body.record_id,
2439
+ processName: body.processName ?? body.process_name,
2440
+ submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
2441
+ comment: body.comment,
2442
+ payload: body.payload
2443
+ }, context ?? {});
2444
+ res.status(201).json(row);
2445
+ } catch (err) {
2446
+ if (handleApprovalError(res, err)) return;
2447
+ throw err;
2448
+ }
2449
+ } catch (error) {
2450
+ logError("[REST] Submit approval error:", error);
2451
+ res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2452
+ }
2453
+ },
2454
+ metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
2455
+ });
2456
+ this.routeManager.register({
2457
+ method: "GET",
2458
+ path: `${dataPath}/approvals/requests`,
2459
+ handler: async (req, res) => {
2460
+ try {
2461
+ const projectId = isScoped ? req.params?.projectId : void 0;
2462
+ const context = await this.resolveExecCtx(projectId, req);
2463
+ if (this.enforceAuth(req, res, context)) return;
2464
+ const svc = await resolveService(projectId);
2465
+ if (!svc) {
2466
+ res.json({ data: [] });
2467
+ return;
2468
+ }
2469
+ const q = req.query ?? {};
2470
+ const rows = await svc.listRequests({
2471
+ object: q.object,
2472
+ recordId: q.recordId ?? q.record_id,
2473
+ status: q.status,
2474
+ approverId: q.approverId ?? q.approver_id,
2475
+ submitterId: q.submitterId ?? q.submitter_id
2476
+ }, context ?? {});
2477
+ res.json({ data: rows });
2478
+ } catch (error) {
2479
+ logError("[REST] List approval requests error:", error);
2480
+ res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2481
+ }
2482
+ },
2483
+ metadata: { summary: "List approval requests", tags: ["approvals"] }
2484
+ });
2485
+ this.routeManager.register({
2486
+ method: "GET",
2487
+ path: `${dataPath}/approvals/requests/:id`,
2488
+ handler: async (req, res) => {
2489
+ try {
2490
+ const projectId = isScoped ? req.params?.projectId : void 0;
2491
+ const context = await this.resolveExecCtx(projectId, req);
2492
+ if (this.enforceAuth(req, res, context)) return;
2493
+ const svc = await resolveService(projectId);
2494
+ if (!svc) return respond501(res);
2495
+ const row = await svc.getRequest(req.params.id, context ?? {});
2496
+ if (!row) {
2497
+ res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
2498
+ return;
2499
+ }
2500
+ res.json(row);
2501
+ } catch (error) {
2502
+ logError("[REST] Get approval request error:", error);
2503
+ res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2504
+ }
2505
+ },
2506
+ metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
2507
+ });
2508
+ const decisionRoute = (suffix, method) => {
2509
+ this.routeManager.register({
2510
+ method: "POST",
2511
+ path: `${dataPath}/approvals/requests/:id/${suffix}`,
2512
+ handler: async (req, res) => {
2513
+ try {
2514
+ const projectId = isScoped ? req.params?.projectId : void 0;
2515
+ const context = await this.resolveExecCtx(projectId, req);
2516
+ if (this.enforceAuth(req, res, context)) return;
2517
+ const svc = await resolveService(projectId);
2518
+ if (!svc) return respond501(res);
2519
+ const body = req.body ?? {};
2520
+ try {
2521
+ const out = await svc[method](req.params.id, {
2522
+ actorId: body.actorId ?? body.actor_id ?? context?.userId,
2523
+ comment: body.comment
2524
+ }, context ?? {});
2525
+ res.json(out);
2526
+ } catch (err) {
2527
+ if (handleApprovalError(res, err)) return;
2528
+ throw err;
2529
+ }
2530
+ } catch (error) {
2531
+ logError(`[REST] ${suffix} approval error:`, error);
2532
+ res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
2533
+ }
2534
+ },
2535
+ metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
2536
+ });
2537
+ };
2538
+ decisionRoute("approve", "approve");
2539
+ decisionRoute("reject", "reject");
2540
+ decisionRoute("recall", "recall");
2541
+ this.routeManager.register({
2542
+ method: "GET",
2543
+ path: `${dataPath}/approvals/requests/:id/actions`,
2544
+ handler: async (req, res) => {
2545
+ try {
2546
+ const projectId = isScoped ? req.params?.projectId : void 0;
2547
+ const context = await this.resolveExecCtx(projectId, req);
2548
+ if (this.enforceAuth(req, res, context)) return;
2549
+ const svc = await resolveService(projectId);
2550
+ if (!svc) return respond501(res);
2551
+ const rows = await svc.listActions(req.params.id, context ?? {});
2552
+ res.json({ data: rows });
2553
+ } catch (error) {
2554
+ logError("[REST] List approval actions error:", error);
2555
+ res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2556
+ }
2557
+ },
2558
+ metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
2559
+ });
2560
+ }
2561
+ /**
2562
+ * Register batch operation endpoints
2563
+ */
2564
+ registerBatchEndpoints(basePath) {
2565
+ const { crud, batch } = this.config;
2566
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2567
+ const isScoped = basePath.includes("/projects/:projectId");
2568
+ const operations = batch.operations;
2569
+ if (batch.enableBatchEndpoint && this.protocol.batchData) {
2570
+ this.routeManager.register({
2571
+ method: "POST",
2572
+ path: `${dataPath}/:object/batch`,
2573
+ handler: async (req, res) => {
2574
+ try {
2575
+ const projectId = isScoped ? req.params?.projectId : void 0;
2576
+ const p = await this.resolveProtocol(projectId, req);
2577
+ const context = await this.resolveExecCtx(projectId, req);
2578
+ if (this.enforceAuth(req, res, context)) return;
2579
+ const result = await p.batchData({
2580
+ object: req.params.object,
2581
+ request: req.body,
2582
+ ...projectId ? { projectId } : {},
2583
+ ...context ? { context } : {}
2584
+ });
2585
+ res.json(result);
2586
+ } catch (error) {
2587
+ logError("[REST] Unhandled error:", error);
2588
+ sendError(res, error, req.params?.object);
2589
+ }
2590
+ },
2591
+ metadata: {
2592
+ summary: "Batch operations",
2593
+ tags: ["data", "batch"]
2594
+ }
2595
+ });
2596
+ }
2597
+ if (operations.createMany && this.protocol.createManyData) {
2598
+ this.routeManager.register({
2599
+ method: "POST",
2600
+ path: `${dataPath}/:object/createMany`,
2601
+ handler: async (req, res) => {
2602
+ try {
2603
+ const projectId = isScoped ? req.params?.projectId : void 0;
2604
+ const p = await this.resolveProtocol(projectId, req);
2605
+ const context = await this.resolveExecCtx(projectId, req);
2606
+ if (this.enforceAuth(req, res, context)) return;
2607
+ const result = await p.createManyData({
2608
+ object: req.params.object,
2609
+ records: req.body || [],
2610
+ ...projectId ? { projectId } : {},
2611
+ ...context ? { context } : {}
2612
+ });
2613
+ res.status(201).json(result);
2614
+ } catch (error) {
2615
+ logError("[REST] Unhandled error:", error);
2616
+ sendError(res, error, req.params?.object);
2617
+ }
2618
+ },
2619
+ metadata: {
2620
+ summary: "Create multiple records",
2621
+ tags: ["data", "batch"]
2622
+ }
2623
+ });
2624
+ }
2625
+ if (operations.updateMany && this.protocol.updateManyData) {
2626
+ this.routeManager.register({
2627
+ method: "POST",
2628
+ path: `${dataPath}/:object/updateMany`,
2629
+ handler: async (req, res) => {
2630
+ try {
2631
+ const projectId = isScoped ? req.params?.projectId : void 0;
2632
+ const p = await this.resolveProtocol(projectId, req);
2633
+ const context = await this.resolveExecCtx(projectId, req);
2634
+ if (this.enforceAuth(req, res, context)) return;
2635
+ const result = await p.updateManyData({
2636
+ object: req.params.object,
2637
+ ...req.body,
2638
+ ...projectId ? { projectId } : {},
2639
+ ...context ? { context } : {}
2640
+ });
2641
+ res.json(result);
2642
+ } catch (error) {
2643
+ logError("[REST] Unhandled error:", error);
2644
+ sendError(res, error, req.params?.object);
2645
+ }
2646
+ },
2647
+ metadata: {
2648
+ summary: "Update multiple records",
2649
+ tags: ["data", "batch"]
2650
+ }
2651
+ });
2652
+ }
1161
2653
  if (operations.deleteMany && this.protocol.deleteManyData) {
1162
2654
  this.routeManager.register({
1163
2655
  method: "POST",
@@ -1166,15 +2658,18 @@ var RestServer = class {
1166
2658
  try {
1167
2659
  const projectId = isScoped ? req.params?.projectId : void 0;
1168
2660
  const p = await this.resolveProtocol(projectId, req);
2661
+ const context = await this.resolveExecCtx(projectId, req);
2662
+ if (this.enforceAuth(req, res, context)) return;
1169
2663
  const result = await p.deleteManyData({
1170
2664
  object: req.params.object,
1171
2665
  ...req.body,
1172
- ...projectId ? { projectId } : {}
2666
+ ...projectId ? { projectId } : {},
2667
+ ...context ? { context } : {}
1173
2668
  });
1174
2669
  res.json(result);
1175
2670
  } catch (error) {
1176
2671
  logError("[REST] Unhandled error:", error);
1177
- res.status(400).json({ error: error.message });
2672
+ sendError(res, error, req.params?.object);
1178
2673
  }
1179
2674
  },
1180
2675
  metadata: {
@@ -1368,6 +2863,41 @@ function createRestApiPlugin(config = {}) {
1368
2863
  return void 0;
1369
2864
  }
1370
2865
  };
2866
+ const emailServiceProvider = async (_projectId) => {
2867
+ try {
2868
+ return ctx.getService("email");
2869
+ } catch {
2870
+ return void 0;
2871
+ }
2872
+ };
2873
+ const sharingServiceProvider = async (_projectId) => {
2874
+ try {
2875
+ return ctx.getService("sharing");
2876
+ } catch {
2877
+ return void 0;
2878
+ }
2879
+ };
2880
+ const reportsServiceProvider = async (_projectId) => {
2881
+ try {
2882
+ return ctx.getService("reports");
2883
+ } catch {
2884
+ return void 0;
2885
+ }
2886
+ };
2887
+ const approvalsServiceProvider = async (_projectId) => {
2888
+ try {
2889
+ return ctx.getService("approvals");
2890
+ } catch {
2891
+ return void 0;
2892
+ }
2893
+ };
2894
+ const sharingRulesServiceProvider = async (_projectId) => {
2895
+ try {
2896
+ return ctx.getService("sharingRules");
2897
+ } catch {
2898
+ return void 0;
2899
+ }
2900
+ };
1371
2901
  if (!server) {
1372
2902
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
1373
2903
  return;
@@ -1378,7 +2908,7 @@ function createRestApiPlugin(config = {}) {
1378
2908
  }
1379
2909
  ctx.logger.info("Hydrating REST API from Protocol...");
1380
2910
  try {
1381
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
2911
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
1382
2912
  restServer.registerRoutes();
1383
2913
  ctx.logger.info("REST API successfully registered");
1384
2914
  } catch (err) {