@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.cjs CHANGED
@@ -250,6 +250,17 @@ var RouteGroupBuilder = class {
250
250
  // src/rest-server.ts
251
251
  var logError = (...args) => globalThis.console?.error(...args);
252
252
  function mapDataError(error, object) {
253
+ if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
254
+ return {
255
+ status: 400,
256
+ body: {
257
+ error: error?.message ?? "Validation failed",
258
+ code: "VALIDATION_FAILED",
259
+ fields: Array.isArray(error?.fields) ? error.fields : [],
260
+ ...object ? { object } : {}
261
+ }
262
+ };
263
+ }
253
264
  if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
254
265
  return {
255
266
  status: 403,
@@ -273,6 +284,16 @@ function mapDataError(error, object) {
273
284
  }
274
285
  };
275
286
  }
287
+ if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
288
+ return {
289
+ status: 404,
290
+ body: {
291
+ error: raw,
292
+ code: "RECORD_NOT_FOUND",
293
+ ...object ? { object } : {}
294
+ }
295
+ };
296
+ }
276
297
  const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
277
298
  if (looksLikeUnknownObject) {
278
299
  return {
@@ -284,13 +305,132 @@ function mapDataError(error, object) {
284
305
  }
285
306
  };
286
307
  }
308
+ 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");
309
+ if (looksLikeSqlLeak) {
310
+ if (lower.includes("unique constraint") || lower.includes("unique violation")) {
311
+ return {
312
+ status: 409,
313
+ body: {
314
+ error: "A record with this value already exists",
315
+ code: "UNIQUE_VIOLATION",
316
+ ...object ? { object } : {}
317
+ }
318
+ };
319
+ }
320
+ return {
321
+ status: 500,
322
+ body: { error: "Internal data error", code: "DATABASE_ERROR" }
323
+ };
324
+ }
287
325
  return { status: 400, body: { error: raw || "Bad request" } };
288
326
  }
327
+ function sendError(res, error, object) {
328
+ if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
329
+ const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
330
+ res.status(error.status).json({
331
+ error: safeMsg,
332
+ ...error.code ? { code: error.code } : {}
333
+ });
334
+ return;
335
+ }
336
+ const mapped = mapDataError(error, object);
337
+ res.status(mapped.status).json(mapped.body);
338
+ }
289
339
  function isExpectedDataStatus(status) {
290
- return status === 403 || status === 404 || status === 502 || status === 503;
340
+ return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
341
+ }
342
+ function parseCsvToRows(csv, mapping = {}) {
343
+ const text = csv.replace(/^\uFEFF/, "");
344
+ const cells = [];
345
+ let cur = "";
346
+ let row = [];
347
+ let inQuotes = false;
348
+ for (let i = 0; i < text.length; i++) {
349
+ const ch = text[i];
350
+ if (inQuotes) {
351
+ if (ch === '"') {
352
+ if (text[i + 1] === '"') {
353
+ cur += '"';
354
+ i++;
355
+ } else {
356
+ inQuotes = false;
357
+ }
358
+ } else {
359
+ cur += ch;
360
+ }
361
+ continue;
362
+ }
363
+ if (ch === '"') {
364
+ inQuotes = true;
365
+ continue;
366
+ }
367
+ if (ch === ",") {
368
+ row.push(cur);
369
+ cur = "";
370
+ continue;
371
+ }
372
+ if (ch === "\r") {
373
+ continue;
374
+ }
375
+ if (ch === "\n") {
376
+ row.push(cur);
377
+ cur = "";
378
+ cells.push(row);
379
+ row = [];
380
+ continue;
381
+ }
382
+ cur += ch;
383
+ }
384
+ if (cur.length > 0 || row.length > 0) {
385
+ row.push(cur);
386
+ cells.push(row);
387
+ }
388
+ while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
389
+ if (cells.length < 2) return [];
390
+ const header = cells[0].map((h) => h.trim());
391
+ const fields = header.map((h) => mapping[h] ?? h);
392
+ const out = [];
393
+ for (let r = 1; r < cells.length; r++) {
394
+ const row2 = cells[r];
395
+ const obj = {};
396
+ for (let c = 0; c < fields.length; c++) {
397
+ const key = fields[c];
398
+ if (!key) continue;
399
+ const raw = row2[c] ?? "";
400
+ obj[key] = raw;
401
+ }
402
+ out.push(obj);
403
+ }
404
+ return out;
405
+ }
406
+ function formatCsvCell(value) {
407
+ if (value === null || value === void 0) return "";
408
+ let s;
409
+ if (typeof value === "string") s = value;
410
+ else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") s = String(value);
411
+ else if (value instanceof Date) s = value.toISOString();
412
+ else {
413
+ try {
414
+ s = JSON.stringify(value);
415
+ } catch {
416
+ s = String(value);
417
+ }
418
+ }
419
+ if (/[",\r\n]/.test(s)) {
420
+ return `"${s.replace(/"/g, '""')}"`;
421
+ }
422
+ return s;
423
+ }
424
+ function rowsToCsv(fields, rows, includeHeader) {
425
+ const lines = [];
426
+ if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
427
+ for (const row of rows) {
428
+ lines.push(fields.map((f) => formatCsvCell(row?.[f])).join(","));
429
+ }
430
+ return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
291
431
  }
292
432
  var RestServer = class {
293
- constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
433
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
294
434
  this.protocol = protocol;
295
435
  this.config = this.normalizeConfig(config);
296
436
  this.routeManager = new RouteManager(server);
@@ -299,6 +439,11 @@ var RestServer = class {
299
439
  this.defaultProjectIdProvider = defaultProjectIdProvider;
300
440
  this.authServiceProvider = authServiceProvider;
301
441
  this.objectQLProvider = objectQLProvider;
442
+ this.emailServiceProvider = emailServiceProvider;
443
+ this.sharingServiceProvider = sharingServiceProvider;
444
+ this.reportsServiceProvider = reportsServiceProvider;
445
+ this.approvalsServiceProvider = approvalsServiceProvider;
446
+ this.sharingRulesServiceProvider = sharingRulesServiceProvider;
302
447
  }
303
448
  /**
304
449
  * Resolve the protocol for a given request. When `projectId` is present
@@ -375,6 +520,25 @@ var RestServer = class {
375
520
  return void 0;
376
521
  }
377
522
  }
523
+ /**
524
+ * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
525
+ * Returns `true` if the response was sent and the caller should stop
526
+ * processing. Returns `false` to continue.
527
+ *
528
+ * The check is intentionally narrow: only `context?.userId` counts as
529
+ * "authenticated". `isSystem` flags are never set on inbound HTTP
530
+ * requests (they're internal-only), so they cannot bypass this gate.
531
+ */
532
+ enforceAuth(req, res, context) {
533
+ if (!this.config.api.requireAuth) return false;
534
+ if (context?.userId) return false;
535
+ if (req?.method === "OPTIONS") return false;
536
+ res.status(401).json({
537
+ error: "unauthenticated",
538
+ message: "Authentication is required to access this endpoint."
539
+ });
540
+ return true;
541
+ }
378
542
  /**
379
543
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
380
544
  * the better-auth session via the project's `auth` service. Returns
@@ -383,6 +547,26 @@ var RestServer = class {
383
547
  */
384
548
  async resolveExecCtx(projectId, req) {
385
549
  try {
550
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
551
+ const host = this.extractHostname(req);
552
+ if (host) {
553
+ try {
554
+ const result = await this.envRegistry.resolveByHostname(host);
555
+ if (result?.projectId) projectId = result.projectId;
556
+ } catch {
557
+ }
558
+ }
559
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
560
+ const headerVal = this.extractProjectIdHeader(req);
561
+ if (headerVal) {
562
+ try {
563
+ const driver = await this.envRegistry.resolveById(headerVal);
564
+ if (driver) projectId = headerVal;
565
+ } catch {
566
+ }
567
+ }
568
+ }
569
+ }
386
570
  let authService;
387
571
  let kernel;
388
572
  if (projectId && projectId !== "platform" && this.kernelManager) {
@@ -622,8 +806,10 @@ var RestServer = class {
622
806
  enableUi: api.enableUi ?? true,
623
807
  enableBatch: api.enableBatch ?? true,
624
808
  enableDiscovery: api.enableDiscovery ?? true,
809
+ enableSearch: api.enableSearch ?? true,
625
810
  enableProjectScoping: api.enableProjectScoping ?? false,
626
811
  projectResolution: api.projectResolution ?? "auto",
812
+ requireAuth: api.requireAuth ?? false,
627
813
  documentation: api.documentation,
628
814
  responseFormat: api.responseFormat
629
815
  },
@@ -705,9 +891,18 @@ var RestServer = class {
705
891
  if (this.config.api.enableUi) {
706
892
  this.registerUiEndpoints(bp);
707
893
  }
894
+ if (this.config.api.enableSearch ?? true) {
895
+ this.registerSearchEndpoints(bp);
896
+ }
897
+ this.registerEmailEndpoints(bp);
898
+ this.registerSharingEndpoints(bp);
899
+ this.registerSharingRuleEndpoints(bp);
900
+ this.registerReportsEndpoints(bp);
901
+ this.registerApprovalsEndpoints(bp);
708
902
  if (this.config.api.enableCrud) {
709
903
  this.registerCrudEndpoints(bp);
710
904
  }
905
+ this.registerDataActionEndpoints(bp);
711
906
  if (this.config.api.enableBatch) {
712
907
  this.registerBatchEndpoints(bp);
713
908
  }
@@ -758,7 +953,7 @@ var RestServer = class {
758
953
  res.json(discovery);
759
954
  } catch (error) {
760
955
  logError("[REST] Unhandled error:", error);
761
- res.status(500).json({ error: error.message });
956
+ sendError(res, error);
762
957
  }
763
958
  };
764
959
  this.routeManager.register({
@@ -799,7 +994,7 @@ var RestServer = class {
799
994
  res.json(types);
800
995
  } catch (error) {
801
996
  logError("[REST] Unhandled error:", error);
802
- res.status(500).json({ error: error.message });
997
+ sendError(res, error);
803
998
  }
804
999
  },
805
1000
  metadata: {
@@ -827,7 +1022,7 @@ var RestServer = class {
827
1022
  res.json(translated);
828
1023
  } catch (error) {
829
1024
  logError("[REST] Unhandled error:", error);
830
- res.status(404).json({ error: error.message });
1025
+ sendError(res, error);
831
1026
  }
832
1027
  },
833
1028
  metadata: {
@@ -885,7 +1080,7 @@ var RestServer = class {
885
1080
  }
886
1081
  } catch (error) {
887
1082
  logError("[REST] Unhandled error:", error);
888
- res.status(404).json({ error: error.message });
1083
+ sendError(res, error);
889
1084
  }
890
1085
  },
891
1086
  metadata: {
@@ -905,16 +1100,18 @@ var RestServer = class {
905
1100
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
906
1101
  return;
907
1102
  }
1103
+ const body = req.body ?? {};
1104
+ const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
908
1105
  const result = await p.saveMetaItem({
909
1106
  type: req.params.type,
910
1107
  name: req.params.name,
911
- item: req.body,
1108
+ item,
912
1109
  ...projectId ? { projectId } : {}
913
1110
  });
914
1111
  res.json(result);
915
1112
  } catch (error) {
916
1113
  logError("[REST] Unhandled error:", error);
917
- res.status(400).json({ error: error.message });
1114
+ sendError(res, error);
918
1115
  }
919
1116
  },
920
1117
  metadata: {
@@ -922,6 +1119,92 @@ var RestServer = class {
922
1119
  tags: ["metadata"]
923
1120
  }
924
1121
  });
1122
+ this.routeManager.register({
1123
+ method: "DELETE",
1124
+ path: `${metaPath}/:type/:name`,
1125
+ handler: async (req, res) => {
1126
+ try {
1127
+ const projectId = isScoped ? req.params?.projectId : void 0;
1128
+ const p = await this.resolveProtocol(projectId, req);
1129
+ if (!p.deleteMetaItem) {
1130
+ res.status(501).json({
1131
+ error: "Reset operation not supported by protocol implementation"
1132
+ });
1133
+ return;
1134
+ }
1135
+ const result = await p.deleteMetaItem({
1136
+ type: req.params.type,
1137
+ name: req.params.name,
1138
+ ...projectId ? { projectId } : {}
1139
+ });
1140
+ res.json(result);
1141
+ } catch (error) {
1142
+ logError("[REST] Unhandled error:", error);
1143
+ sendError(res, error);
1144
+ }
1145
+ },
1146
+ metadata: {
1147
+ summary: "Reset metadata item to artifact default (deletes customization overlay)",
1148
+ tags: ["metadata"]
1149
+ }
1150
+ });
1151
+ if (metadata.endpoints.item !== false) {
1152
+ this.routeManager.register({
1153
+ method: "GET",
1154
+ path: `${metaPath}/:type/:section/:name`,
1155
+ handler: async (req, res) => {
1156
+ try {
1157
+ const projectId = isScoped ? req.params?.projectId : void 0;
1158
+ const p = await this.resolveProtocol(projectId, req);
1159
+ const compoundName = `${req.params.section}/${req.params.name}`;
1160
+ const packageId = req.query?.package || void 0;
1161
+ const item = await p.getMetaItem({
1162
+ type: req.params.type,
1163
+ name: compoundName,
1164
+ packageId
1165
+ });
1166
+ res.header("Vary", "Accept-Language");
1167
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
1168
+ } catch (error) {
1169
+ logError("[REST] Unhandled error:", error);
1170
+ sendError(res, error);
1171
+ }
1172
+ },
1173
+ metadata: {
1174
+ summary: "Get specific metadata item by compound name",
1175
+ tags: ["metadata"]
1176
+ }
1177
+ });
1178
+ }
1179
+ this.routeManager.register({
1180
+ method: "PUT",
1181
+ path: `${metaPath}/:type/:section/:name`,
1182
+ handler: async (req, res) => {
1183
+ try {
1184
+ const projectId = isScoped ? req.params?.projectId : void 0;
1185
+ const p = await this.resolveProtocol(projectId, req);
1186
+ if (!p.saveMetaItem) {
1187
+ res.status(501).json({ error: "Save operation not supported by protocol implementation" });
1188
+ return;
1189
+ }
1190
+ const compoundName = `${req.params.section}/${req.params.name}`;
1191
+ const result = await p.saveMetaItem({
1192
+ type: req.params.type,
1193
+ name: compoundName,
1194
+ item: req.body,
1195
+ ...projectId ? { projectId } : {}
1196
+ });
1197
+ res.json(result);
1198
+ } catch (error) {
1199
+ logError("[REST] Unhandled error:", error);
1200
+ sendError(res, error);
1201
+ }
1202
+ },
1203
+ metadata: {
1204
+ summary: "Save specific metadata item by compound name",
1205
+ tags: ["metadata"]
1206
+ }
1207
+ });
925
1208
  }
926
1209
  /**
927
1210
  * Register UI endpoints
@@ -948,7 +1231,7 @@ var RestServer = class {
948
1231
  }
949
1232
  } catch (error) {
950
1233
  logError("[REST] Unhandled error:", error);
951
- res.status(404).json({ error: error.message });
1234
+ sendError(res, error, req.params?.object);
952
1235
  }
953
1236
  },
954
1237
  metadata: {
@@ -974,6 +1257,7 @@ var RestServer = class {
974
1257
  const projectId = isScoped ? req.params?.projectId : void 0;
975
1258
  const p = await this.resolveProtocol(projectId, req);
976
1259
  const context = await this.resolveExecCtx(projectId, req);
1260
+ if (this.enforceAuth(req, res, context)) return;
977
1261
  const result = await p.findData({
978
1262
  object: req.params.object,
979
1263
  query: req.query,
@@ -1007,6 +1291,7 @@ var RestServer = class {
1007
1291
  const p = await this.resolveProtocol(projectId, req);
1008
1292
  const { select, expand } = req.query || {};
1009
1293
  const context = await this.resolveExecCtx(projectId, req);
1294
+ if (this.enforceAuth(req, res, context)) return;
1010
1295
  const result = await p.getData({
1011
1296
  object: req.params.object,
1012
1297
  id: req.params.id,
@@ -1018,7 +1303,7 @@ var RestServer = class {
1018
1303
  res.json(result);
1019
1304
  } catch (error) {
1020
1305
  const mapped = mapDataError(error, req.params?.object);
1021
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1306
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1022
1307
  res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
1023
1308
  }
1024
1309
  },
@@ -1037,6 +1322,7 @@ var RestServer = class {
1037
1322
  const projectId = isScoped ? req.params?.projectId : void 0;
1038
1323
  const p = await this.resolveProtocol(projectId, req);
1039
1324
  const context = await this.resolveExecCtx(projectId, req);
1325
+ if (this.enforceAuth(req, res, context)) return;
1040
1326
  const result = await p.createData({
1041
1327
  object: req.params.object,
1042
1328
  data: req.body,
@@ -1046,7 +1332,7 @@ var RestServer = class {
1046
1332
  res.status(201).json(result);
1047
1333
  } catch (error) {
1048
1334
  const mapped = mapDataError(error, req.params?.object);
1049
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1335
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1050
1336
  res.status(mapped.status).json(mapped.body);
1051
1337
  }
1052
1338
  },
@@ -1056,6 +1342,35 @@ var RestServer = class {
1056
1342
  }
1057
1343
  });
1058
1344
  }
1345
+ if (operations.list) {
1346
+ this.routeManager.register({
1347
+ method: "POST",
1348
+ path: `${dataPath}/:object/query`,
1349
+ handler: async (req, res) => {
1350
+ try {
1351
+ const projectId = isScoped ? req.params?.projectId : void 0;
1352
+ const p = await this.resolveProtocol(projectId, req);
1353
+ const context = await this.resolveExecCtx(projectId, req);
1354
+ if (this.enforceAuth(req, res, context)) return;
1355
+ const result = await p.findData({
1356
+ object: req.params.object,
1357
+ query: req.body || {},
1358
+ ...projectId ? { projectId } : {},
1359
+ ...context ? { context } : {}
1360
+ });
1361
+ res.json(result);
1362
+ } catch (error) {
1363
+ const mapped = mapDataError(error, req.params?.object);
1364
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1365
+ res.status(mapped.status).json(mapped.body);
1366
+ }
1367
+ },
1368
+ metadata: {
1369
+ summary: "Advanced query (QueryAST in body)",
1370
+ tags: ["data", "crud"]
1371
+ }
1372
+ });
1373
+ }
1059
1374
  if (operations.update) {
1060
1375
  this.routeManager.register({
1061
1376
  method: "PATCH",
@@ -1065,6 +1380,7 @@ var RestServer = class {
1065
1380
  const projectId = isScoped ? req.params?.projectId : void 0;
1066
1381
  const p = await this.resolveProtocol(projectId, req);
1067
1382
  const context = await this.resolveExecCtx(projectId, req);
1383
+ if (this.enforceAuth(req, res, context)) return;
1068
1384
  const result = await p.updateData({
1069
1385
  object: req.params.object,
1070
1386
  id: req.params.id,
@@ -1075,7 +1391,7 @@ var RestServer = class {
1075
1391
  res.json(result);
1076
1392
  } catch (error) {
1077
1393
  const mapped = mapDataError(error, req.params?.object);
1078
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1394
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1079
1395
  res.status(mapped.status).json(mapped.body);
1080
1396
  }
1081
1397
  },
@@ -1094,6 +1410,7 @@ var RestServer = class {
1094
1410
  const projectId = isScoped ? req.params?.projectId : void 0;
1095
1411
  const p = await this.resolveProtocol(projectId, req);
1096
1412
  const context = await this.resolveExecCtx(projectId, req);
1413
+ if (this.enforceAuth(req, res, context)) return;
1097
1414
  const result = await p.deleteData({
1098
1415
  object: req.params.object,
1099
1416
  id: req.params.id,
@@ -1103,7 +1420,7 @@ var RestServer = class {
1103
1420
  res.json(result);
1104
1421
  } catch (error) {
1105
1422
  const mapped = mapDataError(error, req.params?.object);
1106
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1423
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1107
1424
  res.status(mapped.status).json(mapped.body);
1108
1425
  }
1109
1426
  },
@@ -1115,88 +1432,1263 @@ var RestServer = class {
1115
1432
  }
1116
1433
  }
1117
1434
  /**
1118
- * Register batch operation endpoints
1435
+ * Register object-specific action endpoints that don't fit the
1436
+ * generic CRUD shape. These are domain operations (Salesforce
1437
+ * convertLead, etc.) where the protocol implementation does its own
1438
+ * multi-record orchestration and we just need a thin HTTP route.
1439
+ *
1440
+ * POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
1119
1441
  */
1120
- registerBatchEndpoints(basePath) {
1121
- const { crud, batch } = this.config;
1122
- const dataPath = `${basePath}${crud.dataPrefix}`;
1442
+ registerDataActionEndpoints(basePath) {
1123
1443
  const isScoped = basePath.includes("/projects/:projectId");
1124
- const operations = batch.operations;
1125
- if (batch.enableBatchEndpoint && this.protocol.batchData) {
1126
- this.routeManager.register({
1127
- method: "POST",
1128
- path: `${dataPath}/:object/batch`,
1129
- handler: async (req, res) => {
1130
- try {
1131
- const projectId = isScoped ? req.params?.projectId : void 0;
1132
- const p = await this.resolveProtocol(projectId, req);
1133
- const result = await p.batchData({
1134
- object: req.params.object,
1135
- request: req.body,
1136
- ...projectId ? { projectId } : {}
1137
- });
1138
- res.json(result);
1139
- } catch (error) {
1140
- logError("[REST] Unhandled error:", error);
1141
- res.status(400).json({ error: error.message });
1444
+ const { crud } = this.config;
1445
+ const dataPath = `${basePath}${crud.dataPrefix}`;
1446
+ this.routeManager.register({
1447
+ method: "POST",
1448
+ path: `${dataPath}/lead/:id/convert`,
1449
+ handler: async (req, res) => {
1450
+ try {
1451
+ const projectId = isScoped ? req.params?.projectId : void 0;
1452
+ const p = await this.resolveProtocol(projectId, req);
1453
+ const context = await this.resolveExecCtx(projectId, req);
1454
+ if (this.enforceAuth(req, res, context)) return;
1455
+ const convertLead = p.convertLead;
1456
+ if (typeof convertLead !== "function") {
1457
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
1458
+ return;
1142
1459
  }
1143
- },
1144
- metadata: {
1145
- summary: "Batch operations",
1146
- tags: ["data", "batch"]
1460
+ const body = req.body ?? {};
1461
+ const result = await convertLead.call(p, {
1462
+ leadId: req.params.id,
1463
+ accountId: body.accountId,
1464
+ contactId: body.contactId,
1465
+ createOpportunity: body.createOpportunity,
1466
+ opportunity: body.opportunity,
1467
+ convertedStatus: body.convertedStatus,
1468
+ ...context ? { context } : {}
1469
+ });
1470
+ res.json(result);
1471
+ } catch (error) {
1472
+ logError("[REST] Unhandled error:", error);
1473
+ sendError(res, error, "lead");
1147
1474
  }
1148
- });
1149
- }
1150
- if (operations.createMany && this.protocol.createManyData) {
1151
- this.routeManager.register({
1152
- method: "POST",
1153
- path: `${dataPath}/:object/createMany`,
1154
- handler: async (req, res) => {
1155
- try {
1156
- const projectId = isScoped ? req.params?.projectId : void 0;
1157
- const p = await this.resolveProtocol(projectId, req);
1158
- const result = await p.createManyData({
1159
- object: req.params.object,
1160
- records: req.body || [],
1161
- ...projectId ? { projectId } : {}
1475
+ },
1476
+ metadata: {
1477
+ summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
1478
+ tags: ["data", "lead"]
1479
+ }
1480
+ });
1481
+ this.routeManager.register({
1482
+ method: "POST",
1483
+ path: `${dataPath}/:object/import`,
1484
+ handler: async (req, res) => {
1485
+ try {
1486
+ const projectId = isScoped ? req.params?.projectId : void 0;
1487
+ const p = await this.resolveProtocol(projectId, req);
1488
+ const context = await this.resolveExecCtx(projectId, req);
1489
+ if (this.enforceAuth(req, res, context)) return;
1490
+ const objectName = String(req.params.object || "");
1491
+ if (!objectName) {
1492
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1493
+ return;
1494
+ }
1495
+ const body = req.body ?? {};
1496
+ const dryRun = body.dryRun === true;
1497
+ const mapping = body.mapping ?? {};
1498
+ let rows = [];
1499
+ if (body.format === "json" && Array.isArray(body.rows)) {
1500
+ rows = body.rows;
1501
+ } else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
1502
+ rows = parseCsvToRows(body.csv, mapping);
1503
+ } else if (Array.isArray(body)) {
1504
+ rows = body;
1505
+ } else {
1506
+ res.status(400).json({
1507
+ code: "INVALID_REQUEST",
1508
+ error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
1162
1509
  });
1163
- res.status(201).json(result);
1164
- } catch (error) {
1165
- logError("[REST] Unhandled error:", error);
1166
- res.status(400).json({ error: error.message });
1510
+ return;
1167
1511
  }
1168
- },
1169
- metadata: {
1170
- summary: "Create multiple records",
1171
- tags: ["data", "batch"]
1172
- }
1173
- });
1174
- }
1175
- if (operations.updateMany && this.protocol.updateManyData) {
1176
- this.routeManager.register({
1177
- method: "POST",
1178
- path: `${dataPath}/:object/updateMany`,
1179
- handler: async (req, res) => {
1180
- try {
1181
- const projectId = isScoped ? req.params?.projectId : void 0;
1182
- const p = await this.resolveProtocol(projectId, req);
1183
- const result = await p.updateManyData({
1184
- object: req.params.object,
1185
- ...req.body,
1186
- ...projectId ? { projectId } : {}
1512
+ const max = 5e3;
1513
+ if (rows.length > max) {
1514
+ res.status(413).json({
1515
+ code: "PAYLOAD_TOO_LARGE",
1516
+ error: `Import limit is ${max} rows per request (got ${rows.length})`
1187
1517
  });
1188
- res.json(result);
1189
- } catch (error) {
1190
- logError("[REST] Unhandled error:", error);
1191
- res.status(400).json({ error: error.message });
1518
+ return;
1192
1519
  }
1193
- },
1194
- metadata: {
1195
- summary: "Update multiple records",
1196
- tags: ["data", "batch"]
1197
- }
1198
- });
1199
- }
1520
+ const results = [];
1521
+ let okCount = 0;
1522
+ let errCount = 0;
1523
+ for (let i = 0; i < rows.length; i++) {
1524
+ const data = rows[i];
1525
+ try {
1526
+ if (dryRun) {
1527
+ const validate = p.validate;
1528
+ if (typeof validate === "function") {
1529
+ await validate.call(p, { object: objectName, data, context });
1530
+ }
1531
+ results.push({ row: i + 1, ok: true });
1532
+ okCount++;
1533
+ } else {
1534
+ const created = await p.createData({ object: objectName, data, context });
1535
+ const id = created?.id ?? created?.record?.id;
1536
+ results.push({ row: i + 1, ok: true, id });
1537
+ okCount++;
1538
+ }
1539
+ } catch (err) {
1540
+ errCount++;
1541
+ const code = err?.code ?? "IMPORT_ROW_FAILED";
1542
+ const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
1543
+ results.push({ row: i + 1, ok: false, error: message, code });
1544
+ }
1545
+ }
1546
+ res.json({
1547
+ object: objectName,
1548
+ dryRun,
1549
+ total: rows.length,
1550
+ ok: okCount,
1551
+ errors: errCount,
1552
+ results
1553
+ });
1554
+ } catch (error) {
1555
+ logError("[REST] Unhandled error:", error);
1556
+ sendError(res, error, String(req.params?.object || ""));
1557
+ }
1558
+ },
1559
+ metadata: {
1560
+ summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
1561
+ tags: ["data", "import"]
1562
+ }
1563
+ });
1564
+ this.routeManager.register({
1565
+ method: "GET",
1566
+ path: `${dataPath}/:object/export`,
1567
+ handler: async (req, res) => {
1568
+ try {
1569
+ const projectId = isScoped ? req.params?.projectId : void 0;
1570
+ const p = await this.resolveProtocol(projectId, req);
1571
+ const context = await this.resolveExecCtx(projectId, req);
1572
+ if (this.enforceAuth(req, res, context)) return;
1573
+ const objectName = String(req.params.object || "");
1574
+ if (!objectName) {
1575
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1576
+ return;
1577
+ }
1578
+ const q = req.query ?? {};
1579
+ const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
1580
+ const HARD_CAP = 5e4;
1581
+ const MAX_CHUNK = 5e3;
1582
+ const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
1583
+ const limit = Math.min(requestedLimit, HARD_CAP);
1584
+ const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
1585
+ let filter = void 0;
1586
+ if (typeof q.filter === "string" && q.filter.length > 0) {
1587
+ try {
1588
+ filter = JSON.parse(q.filter);
1589
+ } catch {
1590
+ res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
1591
+ return;
1592
+ }
1593
+ } else if (q.filter && typeof q.filter === "object") {
1594
+ filter = q.filter;
1595
+ }
1596
+ let orderby = void 0;
1597
+ if (typeof q.orderby === "string" && q.orderby.length > 0) {
1598
+ if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
1599
+ try {
1600
+ orderby = JSON.parse(q.orderby);
1601
+ } catch {
1602
+ }
1603
+ } else {
1604
+ const obj = {};
1605
+ for (const part of q.orderby.split(",")) {
1606
+ const [field, dir] = part.split(":").map((s) => s.trim());
1607
+ if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
1608
+ }
1609
+ if (Object.keys(obj).length > 0) orderby = obj;
1610
+ }
1611
+ }
1612
+ let fields;
1613
+ if (typeof q.fields === "string" && q.fields.length > 0) {
1614
+ fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
1615
+ } else if (Array.isArray(q.fields)) {
1616
+ fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
1617
+ }
1618
+ if (!fields || fields.length === 0) {
1619
+ try {
1620
+ const schema = await p.getObjectSchema?.(objectName, projectId);
1621
+ const schemaFields = schema?.fields;
1622
+ if (Array.isArray(schemaFields)) {
1623
+ fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
1624
+ }
1625
+ } catch {
1626
+ }
1627
+ }
1628
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1629
+ const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
1630
+ if (format === "csv") {
1631
+ res.header("Content-Type", "text/csv; charset=utf-8");
1632
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
1633
+ } else {
1634
+ res.header("Content-Type", "application/json; charset=utf-8");
1635
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
1636
+ }
1637
+ res.header("X-Export-Format", format);
1638
+ res.header("X-Export-Limit", String(limit));
1639
+ res.header("Cache-Control", "no-store");
1640
+ let exported = 0;
1641
+ let firstChunk = true;
1642
+ let skip = 0;
1643
+ if (format === "json") res.write("[");
1644
+ while (exported < limit) {
1645
+ const take = Math.min(chunkSize, limit - exported);
1646
+ const findArgs = {
1647
+ object: objectName,
1648
+ query: {
1649
+ ...filter ? { $filter: filter } : {},
1650
+ ...orderby ? { $orderby: orderby } : {},
1651
+ $top: take,
1652
+ $skip: skip
1653
+ },
1654
+ ...projectId ? { projectId } : {},
1655
+ ...context ? { context } : {}
1656
+ };
1657
+ const result = await p.findData(findArgs);
1658
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
1659
+ if (rows.length === 0) break;
1660
+ if (format === "csv") {
1661
+ if ((!fields || fields.length === 0) && firstChunk) {
1662
+ fields = Object.keys(rows[0] ?? {});
1663
+ }
1664
+ const text = rowsToCsv(fields ?? [], rows, firstChunk);
1665
+ res.write(text);
1666
+ } else {
1667
+ for (let i = 0; i < rows.length; i++) {
1668
+ const prefix = firstChunk && i === 0 ? "" : ",";
1669
+ res.write(prefix + JSON.stringify(rows[i]));
1670
+ }
1671
+ }
1672
+ firstChunk = false;
1673
+ exported += rows.length;
1674
+ skip += rows.length;
1675
+ if (rows.length < take) break;
1676
+ }
1677
+ if (format === "json") res.write("]");
1678
+ res.end();
1679
+ } catch (error) {
1680
+ logError("[REST] Unhandled error:", error);
1681
+ try {
1682
+ sendError(res, error, String(req.params?.object || ""));
1683
+ } catch {
1684
+ try {
1685
+ res.end();
1686
+ } catch {
1687
+ }
1688
+ }
1689
+ }
1690
+ },
1691
+ metadata: {
1692
+ summary: "Streaming export of object rows (CSV or JSON)",
1693
+ tags: ["data", "export"]
1694
+ }
1695
+ });
1696
+ }
1697
+ /**
1698
+ * Register global cross-object search endpoint (M10.5).
1699
+ * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
1700
+ */
1701
+ registerSearchEndpoints(basePath) {
1702
+ const isScoped = basePath.includes("/projects/:projectId");
1703
+ this.routeManager.register({
1704
+ method: "GET",
1705
+ path: `${basePath}/search`,
1706
+ handler: async (req, res) => {
1707
+ try {
1708
+ const projectId = isScoped ? req.params?.projectId : void 0;
1709
+ const p = await this.resolveProtocol(projectId, req);
1710
+ const context = await this.resolveExecCtx(projectId, req);
1711
+ if (this.enforceAuth(req, res, context)) return;
1712
+ const searchAll = p.searchAll;
1713
+ if (typeof searchAll !== "function") {
1714
+ res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
1715
+ return;
1716
+ }
1717
+ const q = String(req.query?.q ?? req.query?.query ?? "");
1718
+ const objectsParam = req.query?.objects;
1719
+ const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
1720
+ const result = await searchAll.call(p, {
1721
+ q,
1722
+ objects,
1723
+ limit: req.query?.limit ? Number(req.query.limit) : void 0,
1724
+ perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
1725
+ ...context ? { context } : {}
1726
+ });
1727
+ res.json(result);
1728
+ } catch (error) {
1729
+ const mapped = mapDataError(error);
1730
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
1731
+ logError("[REST] Unhandled error:", error);
1732
+ }
1733
+ res.status(mapped.status).json(mapped.body);
1734
+ }
1735
+ },
1736
+ metadata: {
1737
+ summary: "Global cross-object search",
1738
+ tags: ["search"]
1739
+ }
1740
+ });
1741
+ }
1742
+ /**
1743
+ * Register email endpoints (M11.B1 / M10.7).
1744
+ *
1745
+ * POST {basePath}/email/send — send a transactional email via the
1746
+ * `IEmailService` provider registered by EmailServicePlugin. Returns
1747
+ * 501 when no provider is wired so deployments without email
1748
+ * configured fail cleanly.
1749
+ *
1750
+ * Request body:
1751
+ * {
1752
+ * to: "a@b.com" | ["a@b.com", { name, address }],
1753
+ * from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
1754
+ * subject: string,
1755
+ * text?: string, html?: string, // at least one required
1756
+ * attachments?: [{ filename, content, contentType?, cid? }],
1757
+ * headers?: { [name]: value },
1758
+ * relatedObject?: string, relatedId?: string,
1759
+ * }
1760
+ */
1761
+ registerEmailEndpoints(basePath) {
1762
+ const isScoped = basePath.includes("/projects/:projectId");
1763
+ this.routeManager.register({
1764
+ method: "POST",
1765
+ path: `${basePath}/email/send`,
1766
+ handler: async (req, res) => {
1767
+ try {
1768
+ const projectId = isScoped ? req.params?.projectId : void 0;
1769
+ const context = await this.resolveExecCtx(projectId, req);
1770
+ if (this.enforceAuth(req, res, context)) return;
1771
+ if (!this.emailServiceProvider) {
1772
+ res.status(501).json({
1773
+ code: "NOT_IMPLEMENTED",
1774
+ message: "Email service is not configured on this deployment"
1775
+ });
1776
+ return;
1777
+ }
1778
+ const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
1779
+ if (!emailService || typeof emailService.send !== "function") {
1780
+ res.status(501).json({
1781
+ code: "NOT_IMPLEMENTED",
1782
+ message: "Email service is not configured on this deployment"
1783
+ });
1784
+ return;
1785
+ }
1786
+ const body = req.body ?? {};
1787
+ if (!body || typeof body !== "object") {
1788
+ res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
1789
+ return;
1790
+ }
1791
+ const input = {
1792
+ ...body,
1793
+ ...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
1794
+ };
1795
+ try {
1796
+ const result = await emailService.send(input);
1797
+ if (result?.status === "sent") {
1798
+ res.status(200).json(result);
1799
+ } else {
1800
+ res.status(200).json(result);
1801
+ }
1802
+ } catch (err) {
1803
+ const message = String(err?.message ?? err ?? "send failed");
1804
+ if (message.startsWith("VALIDATION_FAILED")) {
1805
+ res.status(400).json({
1806
+ code: "VALIDATION_FAILED",
1807
+ error: message.replace(/^VALIDATION_FAILED:\s*/, "")
1808
+ });
1809
+ return;
1810
+ }
1811
+ throw err;
1812
+ }
1813
+ } catch (error) {
1814
+ logError("[REST] Email send unhandled error:", error);
1815
+ res.status(500).json({
1816
+ code: "EMAIL_SEND_FAILED",
1817
+ error: String(error?.message ?? error ?? "send failed").slice(0, 500)
1818
+ });
1819
+ }
1820
+ },
1821
+ metadata: {
1822
+ summary: "Send a transactional email via the configured EmailService",
1823
+ tags: ["email"]
1824
+ }
1825
+ });
1826
+ }
1827
+ /**
1828
+ * Register record-level sharing endpoints (M11.C17).
1829
+ *
1830
+ * Surfaces `ISharingService` over HTTP so the UI can list, create
1831
+ * and revoke per-record grants without going through ObjectQL. The
1832
+ * three routes mirror the share-management drawer in Salesforce /
1833
+ * ServiceNow:
1834
+ *
1835
+ * GET {basePath}/data/:object/:id/shares
1836
+ * POST {basePath}/data/:object/:id/shares
1837
+ * DELETE {basePath}/data/:object/:id/shares/:shareId
1838
+ *
1839
+ * All three resolve via `sharingServiceProvider`; routes return 501
1840
+ * when no sharing service is configured so a deployment without the
1841
+ * `@objectstack/plugin-sharing` plugin fails cleanly.
1842
+ */
1843
+ registerSharingEndpoints(basePath) {
1844
+ const { crud } = this.config;
1845
+ const dataPath = `${basePath}${crud.dataPrefix}`;
1846
+ const isScoped = basePath.includes("/projects/:projectId");
1847
+ const resolveService = async (projectId) => {
1848
+ if (!this.sharingServiceProvider) return void 0;
1849
+ try {
1850
+ return await this.sharingServiceProvider(projectId);
1851
+ } catch {
1852
+ return void 0;
1853
+ }
1854
+ };
1855
+ const respond501 = (res) => res.status(501).json({
1856
+ code: "NOT_IMPLEMENTED",
1857
+ message: "Sharing service is not configured on this deployment"
1858
+ });
1859
+ this.routeManager.register({
1860
+ method: "GET",
1861
+ path: `${dataPath}/:object/:id/shares`,
1862
+ handler: async (req, res) => {
1863
+ try {
1864
+ const projectId = isScoped ? req.params?.projectId : void 0;
1865
+ const context = await this.resolveExecCtx(projectId, req);
1866
+ if (this.enforceAuth(req, res, context)) return;
1867
+ const svc = await resolveService(projectId);
1868
+ if (!svc) return respond501(res);
1869
+ const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
1870
+ res.json({ data: rows });
1871
+ } catch (error) {
1872
+ logError("[REST] List shares error:", error);
1873
+ res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1874
+ }
1875
+ },
1876
+ metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
1877
+ });
1878
+ this.routeManager.register({
1879
+ method: "POST",
1880
+ path: `${dataPath}/:object/:id/shares`,
1881
+ handler: async (req, res) => {
1882
+ try {
1883
+ const projectId = isScoped ? req.params?.projectId : void 0;
1884
+ const context = await this.resolveExecCtx(projectId, req);
1885
+ if (this.enforceAuth(req, res, context)) return;
1886
+ const svc = await resolveService(projectId);
1887
+ if (!svc) return respond501(res);
1888
+ const body = req.body ?? {};
1889
+ const input = {
1890
+ object: req.params.object,
1891
+ recordId: req.params.id,
1892
+ recipientType: body.recipientType ?? body.recipient_type,
1893
+ recipientId: body.recipientId ?? body.recipient_id,
1894
+ accessLevel: body.accessLevel ?? body.access_level,
1895
+ source: body.source,
1896
+ sourceId: body.sourceId ?? body.source_id,
1897
+ reason: body.reason
1898
+ };
1899
+ try {
1900
+ const row = await svc.grant(input, context ?? {});
1901
+ res.status(201).json(row);
1902
+ } catch (err) {
1903
+ const msg = String(err?.message ?? err ?? "");
1904
+ if (msg.startsWith("VALIDATION_FAILED")) {
1905
+ res.status(400).json({
1906
+ code: "VALIDATION_FAILED",
1907
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
1908
+ });
1909
+ return;
1910
+ }
1911
+ throw err;
1912
+ }
1913
+ } catch (error) {
1914
+ logError("[REST] Grant share error:", error);
1915
+ res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1916
+ }
1917
+ },
1918
+ metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
1919
+ });
1920
+ this.routeManager.register({
1921
+ method: "DELETE",
1922
+ path: `${dataPath}/:object/:id/shares/:shareId`,
1923
+ handler: async (req, res) => {
1924
+ try {
1925
+ const projectId = isScoped ? req.params?.projectId : void 0;
1926
+ const context = await this.resolveExecCtx(projectId, req);
1927
+ if (this.enforceAuth(req, res, context)) return;
1928
+ const svc = await resolveService(projectId);
1929
+ if (!svc) return respond501(res);
1930
+ await svc.revoke(req.params.shareId, context ?? {});
1931
+ res.status(204).end();
1932
+ } catch (error) {
1933
+ logError("[REST] Revoke share error:", error);
1934
+ res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1935
+ }
1936
+ },
1937
+ metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
1938
+ });
1939
+ }
1940
+ /**
1941
+ * Register sharing-rule endpoints (M10.17). Mirrors the existing
1942
+ * sharing endpoints but operates on `sys_sharing_rule` rows.
1943
+ *
1944
+ * GET {basePath}/sharing/rules?object=&activeOnly=
1945
+ * POST {basePath}/sharing/rules
1946
+ * GET {basePath}/sharing/rules/:idOrName
1947
+ * DELETE {basePath}/sharing/rules/:idOrName
1948
+ * POST {basePath}/sharing/rules/:idOrName/evaluate
1949
+ *
1950
+ * Returns 501 when no sharing-rule service is configured.
1951
+ */
1952
+ registerSharingRuleEndpoints(basePath) {
1953
+ const dataPath = basePath;
1954
+ const isScoped = basePath.includes("/projects/:projectId");
1955
+ const resolveService = async (projectId) => {
1956
+ if (!this.sharingRulesServiceProvider) return void 0;
1957
+ try {
1958
+ return await this.sharingRulesServiceProvider(projectId);
1959
+ } catch {
1960
+ return void 0;
1961
+ }
1962
+ };
1963
+ const respond501 = (res) => res.status(501).json({
1964
+ code: "NOT_IMPLEMENTED",
1965
+ message: "Sharing-rule service is not configured on this deployment"
1966
+ });
1967
+ const handleError = (err, res, defaultCode) => {
1968
+ const msg = String(err?.message ?? err ?? "");
1969
+ if (msg.startsWith("VALIDATION_FAILED")) {
1970
+ return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
1971
+ }
1972
+ if (msg.startsWith("RULE_NOT_FOUND")) {
1973
+ return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
1974
+ }
1975
+ logError(`[REST] sharing-rule ${defaultCode}:`, err);
1976
+ return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
1977
+ };
1978
+ this.routeManager.register({
1979
+ method: "GET",
1980
+ path: `${dataPath}/sharing/rules`,
1981
+ handler: async (req, res) => {
1982
+ try {
1983
+ const projectId = isScoped ? req.params?.projectId : void 0;
1984
+ const context = await this.resolveExecCtx(projectId, req);
1985
+ if (this.enforceAuth(req, res, context)) return;
1986
+ const svc = await resolveService(projectId);
1987
+ if (!svc) return respond501(res);
1988
+ const rows = await svc.listRules({
1989
+ object: req.query?.object,
1990
+ activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
1991
+ }, context ?? {});
1992
+ res.json({ data: rows });
1993
+ } catch (err) {
1994
+ handleError(err, res, "RULE_LIST_FAILED");
1995
+ }
1996
+ },
1997
+ metadata: { summary: "List sharing rules", tags: ["sharing"] }
1998
+ });
1999
+ this.routeManager.register({
2000
+ method: "POST",
2001
+ path: `${dataPath}/sharing/rules`,
2002
+ handler: async (req, res) => {
2003
+ try {
2004
+ const projectId = isScoped ? req.params?.projectId : void 0;
2005
+ const context = await this.resolveExecCtx(projectId, req);
2006
+ if (this.enforceAuth(req, res, context)) return;
2007
+ const svc = await resolveService(projectId);
2008
+ if (!svc) return respond501(res);
2009
+ const body = req.body ?? {};
2010
+ const input = {
2011
+ name: body.name,
2012
+ label: body.label,
2013
+ description: body.description,
2014
+ object: body.object ?? body.object_name,
2015
+ criteria: body.criteria,
2016
+ recipientType: body.recipientType ?? body.recipient_type,
2017
+ recipientId: body.recipientId ?? body.recipient_id,
2018
+ accessLevel: body.accessLevel ?? body.access_level,
2019
+ active: body.active
2020
+ };
2021
+ const row = await svc.defineRule(input, context ?? {});
2022
+ res.status(201).json(row);
2023
+ } catch (err) {
2024
+ handleError(err, res, "RULE_DEFINE_FAILED");
2025
+ }
2026
+ },
2027
+ metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
2028
+ });
2029
+ this.routeManager.register({
2030
+ method: "GET",
2031
+ path: `${dataPath}/sharing/rules/:idOrName`,
2032
+ handler: async (req, res) => {
2033
+ try {
2034
+ const projectId = isScoped ? req.params?.projectId : void 0;
2035
+ const context = await this.resolveExecCtx(projectId, req);
2036
+ if (this.enforceAuth(req, res, context)) return;
2037
+ const svc = await resolveService(projectId);
2038
+ if (!svc) return respond501(res);
2039
+ const row = await svc.getRule(req.params.idOrName, context ?? {});
2040
+ if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
2041
+ res.json(row);
2042
+ } catch (err) {
2043
+ handleError(err, res, "RULE_GET_FAILED");
2044
+ }
2045
+ },
2046
+ metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
2047
+ });
2048
+ this.routeManager.register({
2049
+ method: "DELETE",
2050
+ path: `${dataPath}/sharing/rules/:idOrName`,
2051
+ handler: async (req, res) => {
2052
+ try {
2053
+ const projectId = isScoped ? req.params?.projectId : void 0;
2054
+ const context = await this.resolveExecCtx(projectId, req);
2055
+ if (this.enforceAuth(req, res, context)) return;
2056
+ const svc = await resolveService(projectId);
2057
+ if (!svc) return respond501(res);
2058
+ await svc.deleteRule(req.params.idOrName, context ?? {});
2059
+ res.status(204).end();
2060
+ } catch (err) {
2061
+ handleError(err, res, "RULE_DELETE_FAILED");
2062
+ }
2063
+ },
2064
+ metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
2065
+ });
2066
+ this.routeManager.register({
2067
+ method: "POST",
2068
+ path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
2069
+ handler: async (req, res) => {
2070
+ try {
2071
+ const projectId = isScoped ? req.params?.projectId : void 0;
2072
+ const context = await this.resolveExecCtx(projectId, req);
2073
+ if (this.enforceAuth(req, res, context)) return;
2074
+ const svc = await resolveService(projectId);
2075
+ if (!svc) return respond501(res);
2076
+ const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
2077
+ res.json(result);
2078
+ } catch (err) {
2079
+ handleError(err, res, "RULE_EVALUATE_FAILED");
2080
+ }
2081
+ },
2082
+ metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
2083
+ });
2084
+ }
2085
+ /**
2086
+ * Register saved-report + scheduled-digest endpoints (M11.C16).
2087
+ *
2088
+ * Surfaces `IReportService` over HTTP so the UI can build,
2089
+ * run, and schedule reports without dropping to ObjectQL. Routes
2090
+ * live at the top of the API surface (alongside `/approvals` and
2091
+ * `/sharing`) — reports are a tenant-wide capability, not a record
2092
+ * on a specific CRUD object:
2093
+ *
2094
+ * GET {basePath}/reports?object=&ownerId=
2095
+ * POST {basePath}/reports
2096
+ * GET {basePath}/reports/:id
2097
+ * DELETE {basePath}/reports/:id
2098
+ * POST {basePath}/reports/:id/run
2099
+ * POST {basePath}/reports/:id/schedule
2100
+ * GET {basePath}/reports/:id/schedules
2101
+ * DELETE {basePath}/reports/schedules/:scheduleId
2102
+ *
2103
+ * All routes return 501 when `reportsServiceProvider` is unset so
2104
+ * a deployment without `@objectstack/plugin-reports` fails cleanly.
2105
+ */
2106
+ registerReportsEndpoints(basePath) {
2107
+ const dataPath = basePath;
2108
+ const isScoped = basePath.includes("/projects/:projectId");
2109
+ const resolveService = async (projectId) => {
2110
+ if (!this.reportsServiceProvider) return void 0;
2111
+ try {
2112
+ return await this.reportsServiceProvider(projectId);
2113
+ } catch {
2114
+ return void 0;
2115
+ }
2116
+ };
2117
+ const respond501 = (res) => res.status(501).json({
2118
+ code: "NOT_IMPLEMENTED",
2119
+ message: "Reports service is not configured on this deployment"
2120
+ });
2121
+ const handleValidation = (res, err) => {
2122
+ const msg = String(err?.message ?? err ?? "");
2123
+ if (msg.startsWith("VALIDATION_FAILED")) {
2124
+ res.status(400).json({
2125
+ code: "VALIDATION_FAILED",
2126
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2127
+ });
2128
+ return true;
2129
+ }
2130
+ if (msg.startsWith("REPORT_NOT_FOUND")) {
2131
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
2132
+ return true;
2133
+ }
2134
+ return false;
2135
+ };
2136
+ this.routeManager.register({
2137
+ method: "GET",
2138
+ path: `${dataPath}/reports`,
2139
+ handler: async (req, res) => {
2140
+ try {
2141
+ const projectId = isScoped ? req.params?.projectId : void 0;
2142
+ const context = await this.resolveExecCtx(projectId, req);
2143
+ if (this.enforceAuth(req, res, context)) return;
2144
+ const svc = await resolveService(projectId);
2145
+ if (!svc) return respond501(res);
2146
+ const q = req.query ?? {};
2147
+ const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
2148
+ res.json({ data: rows });
2149
+ } catch (error) {
2150
+ logError("[REST] List reports error:", error);
2151
+ res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2152
+ }
2153
+ },
2154
+ metadata: { summary: "List saved reports", tags: ["reports"] }
2155
+ });
2156
+ this.routeManager.register({
2157
+ method: "POST",
2158
+ path: `${dataPath}/reports`,
2159
+ handler: async (req, res) => {
2160
+ try {
2161
+ const projectId = isScoped ? req.params?.projectId : void 0;
2162
+ const context = await this.resolveExecCtx(projectId, req);
2163
+ if (this.enforceAuth(req, res, context)) return;
2164
+ const svc = await resolveService(projectId);
2165
+ if (!svc) return respond501(res);
2166
+ try {
2167
+ const row = await svc.saveReport(req.body ?? {}, context ?? {});
2168
+ res.status(201).json(row);
2169
+ } catch (err) {
2170
+ if (handleValidation(res, err)) return;
2171
+ throw err;
2172
+ }
2173
+ } catch (error) {
2174
+ logError("[REST] Save report error:", error);
2175
+ res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2176
+ }
2177
+ },
2178
+ metadata: { summary: "Create or update a saved report", tags: ["reports"] }
2179
+ });
2180
+ this.routeManager.register({
2181
+ method: "GET",
2182
+ path: `${dataPath}/reports/:id`,
2183
+ handler: async (req, res) => {
2184
+ try {
2185
+ const projectId = isScoped ? req.params?.projectId : void 0;
2186
+ const context = await this.resolveExecCtx(projectId, req);
2187
+ if (this.enforceAuth(req, res, context)) return;
2188
+ const svc = await resolveService(projectId);
2189
+ if (!svc) return respond501(res);
2190
+ const row = await svc.getReport(req.params.id, context ?? {});
2191
+ if (!row) {
2192
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
2193
+ return;
2194
+ }
2195
+ res.json(row);
2196
+ } catch (error) {
2197
+ logError("[REST] Get report error:", error);
2198
+ res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2199
+ }
2200
+ },
2201
+ metadata: { summary: "Get a saved report by id", tags: ["reports"] }
2202
+ });
2203
+ this.routeManager.register({
2204
+ method: "DELETE",
2205
+ path: `${dataPath}/reports/:id`,
2206
+ handler: async (req, res) => {
2207
+ try {
2208
+ const projectId = isScoped ? req.params?.projectId : void 0;
2209
+ const context = await this.resolveExecCtx(projectId, req);
2210
+ if (this.enforceAuth(req, res, context)) return;
2211
+ const svc = await resolveService(projectId);
2212
+ if (!svc) return respond501(res);
2213
+ await svc.deleteReport(req.params.id, context ?? {});
2214
+ res.status(204).end();
2215
+ } catch (error) {
2216
+ logError("[REST] Delete report error:", error);
2217
+ res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2218
+ }
2219
+ },
2220
+ metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
2221
+ });
2222
+ this.routeManager.register({
2223
+ method: "POST",
2224
+ path: `${dataPath}/reports/:id/run`,
2225
+ handler: async (req, res) => {
2226
+ try {
2227
+ const projectId = isScoped ? req.params?.projectId : void 0;
2228
+ const context = await this.resolveExecCtx(projectId, req);
2229
+ if (this.enforceAuth(req, res, context)) return;
2230
+ const svc = await resolveService(projectId);
2231
+ if (!svc) return respond501(res);
2232
+ try {
2233
+ const result = await svc.run(req.params.id, context ?? {});
2234
+ res.json(result);
2235
+ } catch (err) {
2236
+ if (handleValidation(res, err)) return;
2237
+ throw err;
2238
+ }
2239
+ } catch (error) {
2240
+ logError("[REST] Run report error:", error);
2241
+ res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2242
+ }
2243
+ },
2244
+ metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
2245
+ });
2246
+ this.routeManager.register({
2247
+ method: "POST",
2248
+ path: `${dataPath}/reports/:id/schedule`,
2249
+ handler: async (req, res) => {
2250
+ try {
2251
+ const projectId = isScoped ? req.params?.projectId : void 0;
2252
+ const context = await this.resolveExecCtx(projectId, req);
2253
+ if (this.enforceAuth(req, res, context)) return;
2254
+ const svc = await resolveService(projectId);
2255
+ if (!svc) return respond501(res);
2256
+ const body = req.body ?? {};
2257
+ try {
2258
+ const row = await svc.scheduleReport({
2259
+ reportId: req.params.id,
2260
+ recipients: body.recipients ?? [],
2261
+ name: body.name,
2262
+ intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
2263
+ cronExpression: body.cronExpression ?? body.cron_expression,
2264
+ timezone: body.timezone,
2265
+ format: body.format,
2266
+ subjectTemplate: body.subjectTemplate ?? body.subject_template,
2267
+ ownerId: body.ownerId ?? body.owner_id,
2268
+ active: body.active
2269
+ }, context ?? {});
2270
+ res.status(201).json(row);
2271
+ } catch (err) {
2272
+ if (handleValidation(res, err)) return;
2273
+ throw err;
2274
+ }
2275
+ } catch (error) {
2276
+ logError("[REST] Schedule report error:", error);
2277
+ res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2278
+ }
2279
+ },
2280
+ metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
2281
+ });
2282
+ this.routeManager.register({
2283
+ method: "GET",
2284
+ path: `${dataPath}/reports/:id/schedules`,
2285
+ handler: async (req, res) => {
2286
+ try {
2287
+ const projectId = isScoped ? req.params?.projectId : void 0;
2288
+ const context = await this.resolveExecCtx(projectId, req);
2289
+ if (this.enforceAuth(req, res, context)) return;
2290
+ const svc = await resolveService(projectId);
2291
+ if (!svc) return respond501(res);
2292
+ const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
2293
+ res.json({ data: rows });
2294
+ } catch (error) {
2295
+ logError("[REST] List schedules error:", error);
2296
+ res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2297
+ }
2298
+ },
2299
+ metadata: { summary: "List schedules for a report", tags: ["reports"] }
2300
+ });
2301
+ this.routeManager.register({
2302
+ method: "DELETE",
2303
+ path: `${dataPath}/reports/schedules/:scheduleId`,
2304
+ handler: async (req, res) => {
2305
+ try {
2306
+ const projectId = isScoped ? req.params?.projectId : void 0;
2307
+ const context = await this.resolveExecCtx(projectId, req);
2308
+ if (this.enforceAuth(req, res, context)) return;
2309
+ const svc = await resolveService(projectId);
2310
+ if (!svc) return respond501(res);
2311
+ await svc.unscheduleReport(req.params.scheduleId, context ?? {});
2312
+ res.status(204).end();
2313
+ } catch (error) {
2314
+ logError("[REST] Unschedule report error:", error);
2315
+ res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2316
+ }
2317
+ },
2318
+ metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
2319
+ });
2320
+ }
2321
+ /**
2322
+ * Register approval engine endpoints.
2323
+ *
2324
+ * Routes (all under {basePath}/approvals):
2325
+ * GET /processes — list approval processes
2326
+ * POST /processes — upsert (defineProcess)
2327
+ * GET /processes/:id — get by id or name
2328
+ * DELETE /processes/:id — delete process
2329
+ * POST /requests — submit
2330
+ * GET /requests — list (filters: status, object, recordId, approverId, submitterId)
2331
+ * GET /requests/:id — get request
2332
+ * POST /requests/:id/approve — approve current step
2333
+ * POST /requests/:id/reject — reject current step
2334
+ * POST /requests/:id/recall — recall (submitter only)
2335
+ * GET /requests/:id/actions — audit trail
2336
+ *
2337
+ * Returns 501 when `approvalsServiceProvider` is unset so deployments
2338
+ * without `@objectstack/plugin-approvals` fail cleanly.
2339
+ */
2340
+ registerApprovalsEndpoints(basePath) {
2341
+ const dataPath = basePath;
2342
+ const isScoped = basePath.includes("/projects/:projectId");
2343
+ const resolveService = async (projectId) => {
2344
+ if (!this.approvalsServiceProvider) return void 0;
2345
+ try {
2346
+ return await this.approvalsServiceProvider(projectId);
2347
+ } catch {
2348
+ return void 0;
2349
+ }
2350
+ };
2351
+ const respond501 = (res) => res.status(501).json({
2352
+ code: "NOT_IMPLEMENTED",
2353
+ message: "Approvals service is not configured on this deployment"
2354
+ });
2355
+ const handleApprovalError = (res, err) => {
2356
+ const msg = String(err?.message ?? err ?? "");
2357
+ const mapping = [
2358
+ [/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
2359
+ [/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
2360
+ [/^INVALID_STATE/, 409, "INVALID_STATE"],
2361
+ [/^FORBIDDEN/, 403, "FORBIDDEN"],
2362
+ [/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
2363
+ [/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
2364
+ [/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
2365
+ ];
2366
+ for (const [re, status, code] of mapping) {
2367
+ if (re.test(msg)) {
2368
+ res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
2369
+ return true;
2370
+ }
2371
+ }
2372
+ return false;
2373
+ };
2374
+ this.routeManager.register({
2375
+ method: "GET",
2376
+ path: `${dataPath}/approvals/processes`,
2377
+ handler: async (req, res) => {
2378
+ try {
2379
+ const projectId = isScoped ? req.params?.projectId : void 0;
2380
+ const context = await this.resolveExecCtx(projectId, req);
2381
+ if (this.enforceAuth(req, res, context)) return;
2382
+ const svc = await resolveService(projectId);
2383
+ if (!svc) return respond501(res);
2384
+ const q = req.query ?? {};
2385
+ const rows = await svc.listProcesses({
2386
+ object: q.object,
2387
+ activeOnly: q.activeOnly === "true" || q.activeOnly === true
2388
+ }, context ?? {});
2389
+ res.json({ data: rows });
2390
+ } catch (error) {
2391
+ logError("[REST] List approval processes error:", error);
2392
+ res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2393
+ }
2394
+ },
2395
+ metadata: { summary: "List approval processes", tags: ["approvals"] }
2396
+ });
2397
+ this.routeManager.register({
2398
+ method: "POST",
2399
+ path: `${dataPath}/approvals/processes`,
2400
+ handler: async (req, res) => {
2401
+ try {
2402
+ const projectId = isScoped ? req.params?.projectId : void 0;
2403
+ const context = await this.resolveExecCtx(projectId, req);
2404
+ if (this.enforceAuth(req, res, context)) return;
2405
+ const svc = await resolveService(projectId);
2406
+ if (!svc) return respond501(res);
2407
+ try {
2408
+ const row = await svc.defineProcess(req.body ?? {}, context ?? {});
2409
+ res.status(201).json(row);
2410
+ } catch (err) {
2411
+ if (handleApprovalError(res, err)) return;
2412
+ throw err;
2413
+ }
2414
+ } catch (error) {
2415
+ logError("[REST] Define approval process error:", error);
2416
+ res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2417
+ }
2418
+ },
2419
+ metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
2420
+ });
2421
+ this.routeManager.register({
2422
+ method: "GET",
2423
+ path: `${dataPath}/approvals/processes/:id`,
2424
+ handler: async (req, res) => {
2425
+ try {
2426
+ const projectId = isScoped ? req.params?.projectId : void 0;
2427
+ const context = await this.resolveExecCtx(projectId, req);
2428
+ if (this.enforceAuth(req, res, context)) return;
2429
+ const svc = await resolveService(projectId);
2430
+ if (!svc) return respond501(res);
2431
+ const row = await svc.getProcess(req.params.id, context ?? {});
2432
+ if (!row) {
2433
+ res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
2434
+ return;
2435
+ }
2436
+ res.json(row);
2437
+ } catch (error) {
2438
+ logError("[REST] Get approval process error:", error);
2439
+ res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2440
+ }
2441
+ },
2442
+ metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
2443
+ });
2444
+ this.routeManager.register({
2445
+ method: "DELETE",
2446
+ path: `${dataPath}/approvals/processes/:id`,
2447
+ handler: async (req, res) => {
2448
+ try {
2449
+ const projectId = isScoped ? req.params?.projectId : void 0;
2450
+ const context = await this.resolveExecCtx(projectId, req);
2451
+ if (this.enforceAuth(req, res, context)) return;
2452
+ const svc = await resolveService(projectId);
2453
+ if (!svc) return respond501(res);
2454
+ await svc.deleteProcess(req.params.id, context ?? {});
2455
+ res.status(204).end();
2456
+ } catch (error) {
2457
+ logError("[REST] Delete approval process error:", error);
2458
+ res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2459
+ }
2460
+ },
2461
+ metadata: { summary: "Delete an approval process", tags: ["approvals"] }
2462
+ });
2463
+ this.routeManager.register({
2464
+ method: "POST",
2465
+ path: `${dataPath}/approvals/requests`,
2466
+ handler: async (req, res) => {
2467
+ try {
2468
+ const projectId = isScoped ? req.params?.projectId : void 0;
2469
+ const context = await this.resolveExecCtx(projectId, req);
2470
+ if (this.enforceAuth(req, res, context)) return;
2471
+ const svc = await resolveService(projectId);
2472
+ if (!svc) return respond501(res);
2473
+ const body = req.body ?? {};
2474
+ try {
2475
+ const row = await svc.submit({
2476
+ object: body.object,
2477
+ recordId: body.recordId ?? body.record_id,
2478
+ processName: body.processName ?? body.process_name,
2479
+ submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
2480
+ comment: body.comment,
2481
+ payload: body.payload
2482
+ }, context ?? {});
2483
+ res.status(201).json(row);
2484
+ } catch (err) {
2485
+ if (handleApprovalError(res, err)) return;
2486
+ throw err;
2487
+ }
2488
+ } catch (error) {
2489
+ logError("[REST] Submit approval error:", error);
2490
+ res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2491
+ }
2492
+ },
2493
+ metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
2494
+ });
2495
+ this.routeManager.register({
2496
+ method: "GET",
2497
+ path: `${dataPath}/approvals/requests`,
2498
+ handler: async (req, res) => {
2499
+ try {
2500
+ const projectId = isScoped ? req.params?.projectId : void 0;
2501
+ const context = await this.resolveExecCtx(projectId, req);
2502
+ if (this.enforceAuth(req, res, context)) return;
2503
+ const svc = await resolveService(projectId);
2504
+ if (!svc) {
2505
+ res.json({ data: [] });
2506
+ return;
2507
+ }
2508
+ const q = req.query ?? {};
2509
+ const rows = await svc.listRequests({
2510
+ object: q.object,
2511
+ recordId: q.recordId ?? q.record_id,
2512
+ status: q.status,
2513
+ approverId: q.approverId ?? q.approver_id,
2514
+ submitterId: q.submitterId ?? q.submitter_id
2515
+ }, context ?? {});
2516
+ res.json({ data: rows });
2517
+ } catch (error) {
2518
+ logError("[REST] List approval requests error:", error);
2519
+ res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2520
+ }
2521
+ },
2522
+ metadata: { summary: "List approval requests", tags: ["approvals"] }
2523
+ });
2524
+ this.routeManager.register({
2525
+ method: "GET",
2526
+ path: `${dataPath}/approvals/requests/:id`,
2527
+ handler: async (req, res) => {
2528
+ try {
2529
+ const projectId = isScoped ? req.params?.projectId : void 0;
2530
+ const context = await this.resolveExecCtx(projectId, req);
2531
+ if (this.enforceAuth(req, res, context)) return;
2532
+ const svc = await resolveService(projectId);
2533
+ if (!svc) return respond501(res);
2534
+ const row = await svc.getRequest(req.params.id, context ?? {});
2535
+ if (!row) {
2536
+ res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
2537
+ return;
2538
+ }
2539
+ res.json(row);
2540
+ } catch (error) {
2541
+ logError("[REST] Get approval request error:", error);
2542
+ res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2543
+ }
2544
+ },
2545
+ metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
2546
+ });
2547
+ const decisionRoute = (suffix, method) => {
2548
+ this.routeManager.register({
2549
+ method: "POST",
2550
+ path: `${dataPath}/approvals/requests/:id/${suffix}`,
2551
+ handler: async (req, res) => {
2552
+ try {
2553
+ const projectId = isScoped ? req.params?.projectId : void 0;
2554
+ const context = await this.resolveExecCtx(projectId, req);
2555
+ if (this.enforceAuth(req, res, context)) return;
2556
+ const svc = await resolveService(projectId);
2557
+ if (!svc) return respond501(res);
2558
+ const body = req.body ?? {};
2559
+ try {
2560
+ const out = await svc[method](req.params.id, {
2561
+ actorId: body.actorId ?? body.actor_id ?? context?.userId,
2562
+ comment: body.comment
2563
+ }, context ?? {});
2564
+ res.json(out);
2565
+ } catch (err) {
2566
+ if (handleApprovalError(res, err)) return;
2567
+ throw err;
2568
+ }
2569
+ } catch (error) {
2570
+ logError(`[REST] ${suffix} approval error:`, error);
2571
+ res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
2572
+ }
2573
+ },
2574
+ metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
2575
+ });
2576
+ };
2577
+ decisionRoute("approve", "approve");
2578
+ decisionRoute("reject", "reject");
2579
+ decisionRoute("recall", "recall");
2580
+ this.routeManager.register({
2581
+ method: "GET",
2582
+ path: `${dataPath}/approvals/requests/:id/actions`,
2583
+ handler: async (req, res) => {
2584
+ try {
2585
+ const projectId = isScoped ? req.params?.projectId : void 0;
2586
+ const context = await this.resolveExecCtx(projectId, req);
2587
+ if (this.enforceAuth(req, res, context)) return;
2588
+ const svc = await resolveService(projectId);
2589
+ if (!svc) return respond501(res);
2590
+ const rows = await svc.listActions(req.params.id, context ?? {});
2591
+ res.json({ data: rows });
2592
+ } catch (error) {
2593
+ logError("[REST] List approval actions error:", error);
2594
+ res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2595
+ }
2596
+ },
2597
+ metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
2598
+ });
2599
+ }
2600
+ /**
2601
+ * Register batch operation endpoints
2602
+ */
2603
+ registerBatchEndpoints(basePath) {
2604
+ const { crud, batch } = this.config;
2605
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2606
+ const isScoped = basePath.includes("/projects/:projectId");
2607
+ const operations = batch.operations;
2608
+ if (batch.enableBatchEndpoint && this.protocol.batchData) {
2609
+ this.routeManager.register({
2610
+ method: "POST",
2611
+ path: `${dataPath}/:object/batch`,
2612
+ handler: async (req, res) => {
2613
+ try {
2614
+ const projectId = isScoped ? req.params?.projectId : void 0;
2615
+ const p = await this.resolveProtocol(projectId, req);
2616
+ const context = await this.resolveExecCtx(projectId, req);
2617
+ if (this.enforceAuth(req, res, context)) return;
2618
+ const result = await p.batchData({
2619
+ object: req.params.object,
2620
+ request: req.body,
2621
+ ...projectId ? { projectId } : {},
2622
+ ...context ? { context } : {}
2623
+ });
2624
+ res.json(result);
2625
+ } catch (error) {
2626
+ logError("[REST] Unhandled error:", error);
2627
+ sendError(res, error, req.params?.object);
2628
+ }
2629
+ },
2630
+ metadata: {
2631
+ summary: "Batch operations",
2632
+ tags: ["data", "batch"]
2633
+ }
2634
+ });
2635
+ }
2636
+ if (operations.createMany && this.protocol.createManyData) {
2637
+ this.routeManager.register({
2638
+ method: "POST",
2639
+ path: `${dataPath}/:object/createMany`,
2640
+ handler: async (req, res) => {
2641
+ try {
2642
+ const projectId = isScoped ? req.params?.projectId : void 0;
2643
+ const p = await this.resolveProtocol(projectId, req);
2644
+ const context = await this.resolveExecCtx(projectId, req);
2645
+ if (this.enforceAuth(req, res, context)) return;
2646
+ const result = await p.createManyData({
2647
+ object: req.params.object,
2648
+ records: req.body || [],
2649
+ ...projectId ? { projectId } : {},
2650
+ ...context ? { context } : {}
2651
+ });
2652
+ res.status(201).json(result);
2653
+ } catch (error) {
2654
+ logError("[REST] Unhandled error:", error);
2655
+ sendError(res, error, req.params?.object);
2656
+ }
2657
+ },
2658
+ metadata: {
2659
+ summary: "Create multiple records",
2660
+ tags: ["data", "batch"]
2661
+ }
2662
+ });
2663
+ }
2664
+ if (operations.updateMany && this.protocol.updateManyData) {
2665
+ this.routeManager.register({
2666
+ method: "POST",
2667
+ path: `${dataPath}/:object/updateMany`,
2668
+ handler: async (req, res) => {
2669
+ try {
2670
+ const projectId = isScoped ? req.params?.projectId : void 0;
2671
+ const p = await this.resolveProtocol(projectId, req);
2672
+ const context = await this.resolveExecCtx(projectId, req);
2673
+ if (this.enforceAuth(req, res, context)) return;
2674
+ const result = await p.updateManyData({
2675
+ object: req.params.object,
2676
+ ...req.body,
2677
+ ...projectId ? { projectId } : {},
2678
+ ...context ? { context } : {}
2679
+ });
2680
+ res.json(result);
2681
+ } catch (error) {
2682
+ logError("[REST] Unhandled error:", error);
2683
+ sendError(res, error, req.params?.object);
2684
+ }
2685
+ },
2686
+ metadata: {
2687
+ summary: "Update multiple records",
2688
+ tags: ["data", "batch"]
2689
+ }
2690
+ });
2691
+ }
1200
2692
  if (operations.deleteMany && this.protocol.deleteManyData) {
1201
2693
  this.routeManager.register({
1202
2694
  method: "POST",
@@ -1205,15 +2697,18 @@ var RestServer = class {
1205
2697
  try {
1206
2698
  const projectId = isScoped ? req.params?.projectId : void 0;
1207
2699
  const p = await this.resolveProtocol(projectId, req);
2700
+ const context = await this.resolveExecCtx(projectId, req);
2701
+ if (this.enforceAuth(req, res, context)) return;
1208
2702
  const result = await p.deleteManyData({
1209
2703
  object: req.params.object,
1210
2704
  ...req.body,
1211
- ...projectId ? { projectId } : {}
2705
+ ...projectId ? { projectId } : {},
2706
+ ...context ? { context } : {}
1212
2707
  });
1213
2708
  res.json(result);
1214
2709
  } catch (error) {
1215
2710
  logError("[REST] Unhandled error:", error);
1216
- res.status(400).json({ error: error.message });
2711
+ sendError(res, error, req.params?.object);
1217
2712
  }
1218
2713
  },
1219
2714
  metadata: {
@@ -1407,6 +2902,41 @@ function createRestApiPlugin(config = {}) {
1407
2902
  return void 0;
1408
2903
  }
1409
2904
  };
2905
+ const emailServiceProvider = async (_projectId) => {
2906
+ try {
2907
+ return ctx.getService("email");
2908
+ } catch {
2909
+ return void 0;
2910
+ }
2911
+ };
2912
+ const sharingServiceProvider = async (_projectId) => {
2913
+ try {
2914
+ return ctx.getService("sharing");
2915
+ } catch {
2916
+ return void 0;
2917
+ }
2918
+ };
2919
+ const reportsServiceProvider = async (_projectId) => {
2920
+ try {
2921
+ return ctx.getService("reports");
2922
+ } catch {
2923
+ return void 0;
2924
+ }
2925
+ };
2926
+ const approvalsServiceProvider = async (_projectId) => {
2927
+ try {
2928
+ return ctx.getService("approvals");
2929
+ } catch {
2930
+ return void 0;
2931
+ }
2932
+ };
2933
+ const sharingRulesServiceProvider = async (_projectId) => {
2934
+ try {
2935
+ return ctx.getService("sharingRules");
2936
+ } catch {
2937
+ return void 0;
2938
+ }
2939
+ };
1410
2940
  if (!server) {
1411
2941
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
1412
2942
  return;
@@ -1417,7 +2947,7 @@ function createRestApiPlugin(config = {}) {
1417
2947
  }
1418
2948
  ctx.logger.info("Hydrating REST API from Protocol...");
1419
2949
  try {
1420
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
2950
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
1421
2951
  restServer.registerRoutes();
1422
2952
  ctx.logger.info("REST API successfully registered");
1423
2953
  } catch (err) {