@objectstack/rest 4.0.5 → 4.1.1

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, i18nServiceProvider) {
255
395
  this.protocol = protocol;
256
396
  this.config = this.normalizeConfig(config);
257
397
  this.routeManager = new RouteManager(server);
@@ -260,6 +400,12 @@ 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;
408
+ this.i18nServiceProvider = i18nServiceProvider;
263
409
  }
264
410
  /**
265
411
  * Resolve the protocol for a given request. When `projectId` is present
@@ -327,14 +473,70 @@ var RestServer = class {
327
473
  * requests intentionally return `undefined` because the platform kernel
328
474
  * does not own per-app translation bundles.
329
475
  */
330
- async resolveI18nService(projectId) {
331
- if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
332
- try {
333
- const kernel = await this.kernelManager.getOrCreate(projectId);
334
- return await kernel.getServiceAsync("i18n");
335
- } catch {
336
- return void 0;
476
+ async resolveI18nService(projectId, req) {
477
+ if (projectId === "platform") return void 0;
478
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
479
+ const host = this.extractHostname(req);
480
+ if (host) {
481
+ try {
482
+ const result = await this.envRegistry.resolveByHostname(host);
483
+ if (result?.projectId) projectId = result.projectId;
484
+ } catch {
485
+ }
486
+ }
487
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
488
+ const headerVal = this.extractProjectIdHeader(req);
489
+ if (headerVal) {
490
+ try {
491
+ const driver = await this.envRegistry.resolveById(headerVal);
492
+ if (driver) projectId = headerVal;
493
+ } catch {
494
+ }
495
+ }
496
+ }
497
+ }
498
+ if (!projectId && this.defaultProjectIdProvider) {
499
+ try {
500
+ const def = this.defaultProjectIdProvider();
501
+ if (def) projectId = def;
502
+ } catch {
503
+ }
504
+ }
505
+ if (projectId && this.kernelManager) {
506
+ try {
507
+ const kernel = await this.kernelManager.getOrCreate(projectId);
508
+ const svc = await kernel.getServiceAsync("i18n");
509
+ if (svc) return svc;
510
+ } catch {
511
+ }
512
+ }
513
+ if (this.i18nServiceProvider) {
514
+ try {
515
+ return await this.i18nServiceProvider(projectId);
516
+ } catch {
517
+ return void 0;
518
+ }
337
519
  }
520
+ return void 0;
521
+ }
522
+ /**
523
+ * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
524
+ * Returns `true` if the response was sent and the caller should stop
525
+ * processing. Returns `false` to continue.
526
+ *
527
+ * The check is intentionally narrow: only `context?.userId` counts as
528
+ * "authenticated". `isSystem` flags are never set on inbound HTTP
529
+ * requests (they're internal-only), so they cannot bypass this gate.
530
+ */
531
+ enforceAuth(req, res, context) {
532
+ if (!this.config.api.requireAuth) return false;
533
+ if (context?.userId) return false;
534
+ if (req?.method === "OPTIONS") return false;
535
+ res.status(401).json({
536
+ error: "unauthenticated",
537
+ message: "Authentication is required to access this endpoint."
538
+ });
539
+ return true;
338
540
  }
339
541
  /**
340
542
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
@@ -344,6 +546,26 @@ var RestServer = class {
344
546
  */
345
547
  async resolveExecCtx(projectId, req) {
346
548
  try {
549
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
550
+ const host = this.extractHostname(req);
551
+ if (host) {
552
+ try {
553
+ const result = await this.envRegistry.resolveByHostname(host);
554
+ if (result?.projectId) projectId = result.projectId;
555
+ } catch {
556
+ }
557
+ }
558
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
559
+ const headerVal = this.extractProjectIdHeader(req);
560
+ if (headerVal) {
561
+ try {
562
+ const driver = await this.envRegistry.resolveById(headerVal);
563
+ if (driver) projectId = headerVal;
564
+ } catch {
565
+ }
566
+ }
567
+ }
568
+ }
347
569
  let authService;
348
570
  let kernel;
349
571
  if (projectId && projectId !== "platform" && this.kernelManager) {
@@ -497,8 +719,8 @@ var RestServer = class {
497
719
  */
498
720
  async translateMetaItem(req, type, projectId, item) {
499
721
  if (!item || typeof item !== "object") return item;
500
- if (type !== "view" && type !== "action") return item;
501
- const i18n = await this.resolveI18nService(projectId);
722
+ if (type !== "view" && type !== "action" && type !== "object") return item;
723
+ const i18n = await this.resolveI18nService(projectId, req);
502
724
  const bundle = this.buildTranslationBundle(i18n);
503
725
  if (!bundle) return item;
504
726
  const locale = this.extractLocale(req, i18n);
@@ -511,8 +733,8 @@ var RestServer = class {
511
733
  */
512
734
  async translateMetaItems(req, type, projectId, items) {
513
735
  if (!Array.isArray(items)) return items;
514
- if (type !== "view" && type !== "action") return items;
515
- const i18n = await this.resolveI18nService(projectId);
736
+ if (type !== "view" && type !== "action" && type !== "object") return items;
737
+ const i18n = await this.resolveI18nService(projectId, req);
516
738
  const bundle = this.buildTranslationBundle(i18n);
517
739
  if (!bundle) return items;
518
740
  const locale = this.extractLocale(req, i18n);
@@ -583,8 +805,10 @@ var RestServer = class {
583
805
  enableUi: api.enableUi ?? true,
584
806
  enableBatch: api.enableBatch ?? true,
585
807
  enableDiscovery: api.enableDiscovery ?? true,
808
+ enableSearch: api.enableSearch ?? true,
586
809
  enableProjectScoping: api.enableProjectScoping ?? false,
587
810
  projectResolution: api.projectResolution ?? "auto",
811
+ requireAuth: api.requireAuth ?? false,
588
812
  documentation: api.documentation,
589
813
  responseFormat: api.responseFormat
590
814
  },
@@ -666,9 +890,19 @@ var RestServer = class {
666
890
  if (this.config.api.enableUi) {
667
891
  this.registerUiEndpoints(bp);
668
892
  }
893
+ if (this.config.api.enableSearch ?? true) {
894
+ this.registerSearchEndpoints(bp);
895
+ }
896
+ this.registerEmailEndpoints(bp);
897
+ this.registerFormEndpoints(bp);
898
+ this.registerSharingEndpoints(bp);
899
+ this.registerSharingRuleEndpoints(bp);
900
+ this.registerReportsEndpoints(bp);
901
+ this.registerApprovalsEndpoints(bp);
669
902
  if (this.config.api.enableCrud) {
670
903
  this.registerCrudEndpoints(bp);
671
904
  }
905
+ this.registerDataActionEndpoints(bp);
672
906
  if (this.config.api.enableBatch) {
673
907
  this.registerBatchEndpoints(bp);
674
908
  }
@@ -719,7 +953,7 @@ var RestServer = class {
719
953
  res.json(discovery);
720
954
  } catch (error) {
721
955
  logError("[REST] Unhandled error:", error);
722
- res.status(500).json({ error: error.message });
956
+ sendError(res, error);
723
957
  }
724
958
  };
725
959
  this.routeManager.register({
@@ -760,7 +994,7 @@ var RestServer = class {
760
994
  res.json(types);
761
995
  } catch (error) {
762
996
  logError("[REST] Unhandled error:", error);
763
- res.status(500).json({ error: error.message });
997
+ sendError(res, error);
764
998
  }
765
999
  },
766
1000
  metadata: {
@@ -788,7 +1022,7 @@ var RestServer = class {
788
1022
  res.json(translated);
789
1023
  } catch (error) {
790
1024
  logError("[REST] Unhandled error:", error);
791
- res.status(404).json({ error: error.message });
1025
+ sendError(res, error);
792
1026
  }
793
1027
  },
794
1028
  metadata: {
@@ -846,7 +1080,7 @@ var RestServer = class {
846
1080
  }
847
1081
  } catch (error) {
848
1082
  logError("[REST] Unhandled error:", error);
849
- res.status(404).json({ error: error.message });
1083
+ sendError(res, error);
850
1084
  }
851
1085
  },
852
1086
  metadata: {
@@ -866,16 +1100,18 @@ var RestServer = class {
866
1100
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
867
1101
  return;
868
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;
869
1105
  const result = await p.saveMetaItem({
870
1106
  type: req.params.type,
871
1107
  name: req.params.name,
872
- item: req.body,
1108
+ item,
873
1109
  ...projectId ? { projectId } : {}
874
1110
  });
875
1111
  res.json(result);
876
1112
  } catch (error) {
877
1113
  logError("[REST] Unhandled error:", error);
878
- res.status(400).json({ error: error.message });
1114
+ sendError(res, error);
879
1115
  }
880
1116
  },
881
1117
  metadata: {
@@ -883,6 +1119,92 @@ var RestServer = class {
883
1119
  tags: ["metadata"]
884
1120
  }
885
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
+ });
886
1208
  }
887
1209
  /**
888
1210
  * Register UI endpoints
@@ -909,7 +1231,7 @@ var RestServer = class {
909
1231
  }
910
1232
  } catch (error) {
911
1233
  logError("[REST] Unhandled error:", error);
912
- res.status(404).json({ error: error.message });
1234
+ sendError(res, error, req.params?.object);
913
1235
  }
914
1236
  },
915
1237
  metadata: {
@@ -935,6 +1257,7 @@ var RestServer = class {
935
1257
  const projectId = isScoped ? req.params?.projectId : void 0;
936
1258
  const p = await this.resolveProtocol(projectId, req);
937
1259
  const context = await this.resolveExecCtx(projectId, req);
1260
+ if (this.enforceAuth(req, res, context)) return;
938
1261
  const result = await p.findData({
939
1262
  object: req.params.object,
940
1263
  query: req.query,
@@ -968,6 +1291,7 @@ var RestServer = class {
968
1291
  const p = await this.resolveProtocol(projectId, req);
969
1292
  const { select, expand } = req.query || {};
970
1293
  const context = await this.resolveExecCtx(projectId, req);
1294
+ if (this.enforceAuth(req, res, context)) return;
971
1295
  const result = await p.getData({
972
1296
  object: req.params.object,
973
1297
  id: req.params.id,
@@ -979,7 +1303,7 @@ var RestServer = class {
979
1303
  res.json(result);
980
1304
  } catch (error) {
981
1305
  const mapped = mapDataError(error, req.params?.object);
982
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1306
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
983
1307
  res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
984
1308
  }
985
1309
  },
@@ -998,6 +1322,7 @@ var RestServer = class {
998
1322
  const projectId = isScoped ? req.params?.projectId : void 0;
999
1323
  const p = await this.resolveProtocol(projectId, req);
1000
1324
  const context = await this.resolveExecCtx(projectId, req);
1325
+ if (this.enforceAuth(req, res, context)) return;
1001
1326
  const result = await p.createData({
1002
1327
  object: req.params.object,
1003
1328
  data: req.body,
@@ -1007,7 +1332,7 @@ var RestServer = class {
1007
1332
  res.status(201).json(result);
1008
1333
  } catch (error) {
1009
1334
  const mapped = mapDataError(error, req.params?.object);
1010
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1335
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1011
1336
  res.status(mapped.status).json(mapped.body);
1012
1337
  }
1013
1338
  },
@@ -1017,19 +1342,19 @@ var RestServer = class {
1017
1342
  }
1018
1343
  });
1019
1344
  }
1020
- if (operations.update) {
1345
+ if (operations.list) {
1021
1346
  this.routeManager.register({
1022
- method: "PATCH",
1023
- path: `${dataPath}/:object/:id`,
1347
+ method: "POST",
1348
+ path: `${dataPath}/:object/query`,
1024
1349
  handler: async (req, res) => {
1025
1350
  try {
1026
1351
  const projectId = isScoped ? req.params?.projectId : void 0;
1027
1352
  const p = await this.resolveProtocol(projectId, req);
1028
1353
  const context = await this.resolveExecCtx(projectId, req);
1029
- const result = await p.updateData({
1354
+ if (this.enforceAuth(req, res, context)) return;
1355
+ const result = await p.findData({
1030
1356
  object: req.params.object,
1031
- id: req.params.id,
1032
- data: req.body,
1357
+ query: req.body || {},
1033
1358
  ...projectId ? { projectId } : {},
1034
1359
  ...context ? { context } : {}
1035
1360
  });
@@ -1041,95 +1366,1640 @@ var RestServer = class {
1041
1366
  }
1042
1367
  },
1043
1368
  metadata: {
1044
- summary: "Update record",
1369
+ summary: "Advanced query (QueryAST in body)",
1045
1370
  tags: ["data", "crud"]
1046
1371
  }
1047
1372
  });
1048
1373
  }
1049
- if (operations.delete) {
1374
+ if (operations.update) {
1050
1375
  this.routeManager.register({
1051
- method: "DELETE",
1376
+ method: "PATCH",
1052
1377
  path: `${dataPath}/:object/:id`,
1053
1378
  handler: async (req, res) => {
1054
1379
  try {
1055
1380
  const projectId = isScoped ? req.params?.projectId : void 0;
1056
1381
  const p = await this.resolveProtocol(projectId, req);
1057
1382
  const context = await this.resolveExecCtx(projectId, req);
1058
- const result = await p.deleteData({
1383
+ if (this.enforceAuth(req, res, context)) return;
1384
+ const result = await p.updateData({
1059
1385
  object: req.params.object,
1060
1386
  id: req.params.id,
1387
+ data: req.body,
1061
1388
  ...projectId ? { projectId } : {},
1062
1389
  ...context ? { context } : {}
1063
1390
  });
1064
1391
  res.json(result);
1065
1392
  } catch (error) {
1066
1393
  const mapped = mapDataError(error, req.params?.object);
1067
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1394
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1068
1395
  res.status(mapped.status).json(mapped.body);
1069
1396
  }
1070
1397
  },
1071
1398
  metadata: {
1072
- summary: "Delete record",
1399
+ summary: "Update record",
1073
1400
  tags: ["data", "crud"]
1074
1401
  }
1075
1402
  });
1076
1403
  }
1077
- }
1078
- /**
1079
- * Register batch operation endpoints
1080
- */
1081
- registerBatchEndpoints(basePath) {
1082
- const { crud, batch } = this.config;
1083
- const dataPath = `${basePath}${crud.dataPrefix}`;
1084
- const isScoped = basePath.includes("/projects/:projectId");
1085
- const operations = batch.operations;
1086
- if (batch.enableBatchEndpoint && this.protocol.batchData) {
1404
+ if (operations.delete) {
1087
1405
  this.routeManager.register({
1088
- method: "POST",
1089
- path: `${dataPath}/:object/batch`,
1406
+ method: "DELETE",
1407
+ path: `${dataPath}/:object/:id`,
1090
1408
  handler: async (req, res) => {
1091
1409
  try {
1092
1410
  const projectId = isScoped ? req.params?.projectId : void 0;
1093
1411
  const p = await this.resolveProtocol(projectId, req);
1094
- const result = await p.batchData({
1412
+ const context = await this.resolveExecCtx(projectId, req);
1413
+ if (this.enforceAuth(req, res, context)) return;
1414
+ const result = await p.deleteData({
1095
1415
  object: req.params.object,
1096
- request: req.body,
1097
- ...projectId ? { projectId } : {}
1416
+ id: req.params.id,
1417
+ ...projectId ? { projectId } : {},
1418
+ ...context ? { context } : {}
1098
1419
  });
1099
1420
  res.json(result);
1100
1421
  } catch (error) {
1101
- logError("[REST] Unhandled error:", error);
1102
- res.status(400).json({ error: error.message });
1422
+ const mapped = mapDataError(error, req.params?.object);
1423
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1424
+ res.status(mapped.status).json(mapped.body);
1103
1425
  }
1104
1426
  },
1105
1427
  metadata: {
1106
- summary: "Batch operations",
1107
- tags: ["data", "batch"]
1428
+ summary: "Delete record",
1429
+ tags: ["data", "crud"]
1108
1430
  }
1109
1431
  });
1110
1432
  }
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 } : {}
1123
- });
1124
- res.status(201).json(result);
1125
- } catch (error) {
1126
- logError("[REST] Unhandled error:", error);
1127
- res.status(400).json({ error: error.message });
1433
+ }
1434
+ /**
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.
1441
+ */
1442
+ registerDataActionEndpoints(basePath) {
1443
+ const isScoped = basePath.includes("/projects/:projectId");
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;
1128
1459
  }
1129
- },
1130
- metadata: {
1131
- summary: "Create multiple records",
1132
- 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");
1474
+ }
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[]'
1509
+ });
1510
+ return;
1511
+ }
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})`
1517
+ });
1518
+ return;
1519
+ }
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 public (anonymous) form endpoints.
1829
+ *
1830
+ * Public forms are opt-in: a `FormView` becomes accessible to anonymous
1831
+ * visitors only when `sharing.allowAnonymous === true` AND a
1832
+ * `sharing.publicLink` slug is configured. Two routes are registered:
1833
+ *
1834
+ * GET {basePath}/forms/:slug → resolved form spec
1835
+ * POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
1836
+ *
1837
+ * Both routes bypass `enforceAuth` even when `requireAuth=true` on the
1838
+ * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
1839
+ * `guest_portal` permission set carried on the execution context — the
1840
+ * SecurityPlugin enforces INSERT-only access to the target object. If
1841
+ * the deployment hasn't registered a `guest_portal` profile, the
1842
+ * security middleware falls open with `permissions: []` (no userId),
1843
+ * matching the existing anonymous-access semantics; deployers must
1844
+ * keep `requireAuth=true` deployments paired with a `guest_portal`
1845
+ * profile (the CRM example does this) to enforce the INSERT-only
1846
+ * contract.
1847
+ *
1848
+ * The matched FormView's parent ViewSchema is found by scanning
1849
+ * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
1850
+ * `form.sharing` and every entry in `formViews`; the first FormView
1851
+ * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
1852
+ * wins. The response carries the matched form view under `form` and
1853
+ * the inferred target object, matching what the frontend's
1854
+ * `mapViewSpecToEmbeddableConfig` expects.
1855
+ */
1856
+ registerFormEndpoints(basePath) {
1857
+ const isScoped = basePath.includes("/projects/:projectId");
1858
+ const slugMatchesPublicLink = (publicLink, slug) => {
1859
+ if (!publicLink || typeof publicLink !== "string") return false;
1860
+ const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
1861
+ return normalized === slug;
1862
+ };
1863
+ const findPublicFormView = (views, slug) => {
1864
+ for (const view of views ?? []) {
1865
+ if (!view || typeof view !== "object") continue;
1866
+ const candidates = [];
1867
+ if (view.form && view.form.sharing) candidates.push({ form: view.form });
1868
+ const formViews = view.formViews;
1869
+ if (formViews && typeof formViews === "object") {
1870
+ for (const [key, fv] of Object.entries(formViews)) {
1871
+ if (fv && typeof fv === "object" && fv.sharing) {
1872
+ candidates.push({ form: fv, key });
1873
+ }
1874
+ }
1875
+ }
1876
+ for (const c of candidates) {
1877
+ const sharing = c.form?.sharing;
1878
+ if (!sharing || sharing.allowAnonymous !== true) continue;
1879
+ if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
1880
+ const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
1881
+ if (!objectName) continue;
1882
+ return { view, form: c.form, object: objectName };
1883
+ }
1884
+ }
1885
+ return null;
1886
+ };
1887
+ const resolveFormBySlug = async (projectId, req, slug) => {
1888
+ const p = await this.resolveProtocol(projectId, req);
1889
+ if (typeof p.getMetaItems !== "function") return null;
1890
+ const result = await p.getMetaItems({
1891
+ type: "view",
1892
+ ...projectId ? { projectId } : {}
1893
+ });
1894
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
1895
+ return findPublicFormView(items, slug);
1896
+ };
1897
+ this.routeManager.register({
1898
+ method: "GET",
1899
+ path: `${basePath}/forms/:slug`,
1900
+ handler: async (req, res) => {
1901
+ try {
1902
+ const projectId = isScoped ? req.params?.projectId : void 0;
1903
+ const slug = String(req.params?.slug ?? "").trim();
1904
+ if (!slug) {
1905
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
1906
+ return;
1907
+ }
1908
+ const match = await resolveFormBySlug(projectId, req, slug);
1909
+ if (!match) {
1910
+ res.status(404).json({
1911
+ code: "FORM_NOT_FOUND",
1912
+ error: `No public form configured at /forms/${slug}`
1913
+ });
1914
+ return;
1915
+ }
1916
+ let objectSchema = null;
1917
+ try {
1918
+ const p = await this.resolveProtocol(projectId, req);
1919
+ if (typeof p.getMetaItems === "function") {
1920
+ const r = await p.getMetaItems({
1921
+ type: "object",
1922
+ ...projectId ? { projectId } : {}
1923
+ });
1924
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1925
+ const obj = items.find((o) => o?.name === match.object);
1926
+ if (obj && obj.fields && typeof obj.fields === "object") {
1927
+ const allowed = /* @__PURE__ */ new Set();
1928
+ for (const sec of match.form?.sections ?? []) {
1929
+ for (const f of sec?.fields ?? []) {
1930
+ if (typeof f === "string") allowed.add(f);
1931
+ else if (f?.field) allowed.add(f.field);
1932
+ }
1933
+ }
1934
+ const fields = {};
1935
+ for (const [name, def] of Object.entries(obj.fields)) {
1936
+ if (allowed.size === 0 || allowed.has(name)) {
1937
+ fields[name] = def;
1938
+ }
1939
+ }
1940
+ objectSchema = { name: obj.name, label: obj.label, fields };
1941
+ try {
1942
+ const i18n = await this.resolveI18nService(projectId, req);
1943
+ const bundle = this.buildTranslationBundle(i18n);
1944
+ const locale = this.extractLocale(req, i18n);
1945
+ if (bundle && locale) {
1946
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
1947
+ objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
1948
+ }
1949
+ } catch (e) {
1950
+ logError("[REST] Public form schema translation failed:", e);
1951
+ }
1952
+ }
1953
+ }
1954
+ } catch (e) {
1955
+ logError("[REST] Public form schema load failed:", e);
1956
+ }
1957
+ const safeForm = (() => {
1958
+ if (!match.form || !Array.isArray(match.form.sections)) return match.form;
1959
+ const allow = (name, cfg) => {
1960
+ const def = objectSchema?.fields?.[name];
1961
+ const t = def?.type;
1962
+ if (t !== "lookup" && t !== "master_detail") return true;
1963
+ return !!cfg?.publicPicker;
1964
+ };
1965
+ const sections = match.form.sections.map((sec) => {
1966
+ const fields = (sec?.fields ?? []).filter((f) => {
1967
+ const name = typeof f === "string" ? f : f?.field;
1968
+ if (!name) return false;
1969
+ const cfg = typeof f === "string" ? {} : f;
1970
+ return allow(name, cfg);
1971
+ });
1972
+ return { ...sec, fields };
1973
+ });
1974
+ return { ...match.form, sections };
1975
+ })();
1976
+ res.header("Vary", "Accept-Language");
1977
+ res.json({
1978
+ slug,
1979
+ object: match.object,
1980
+ label: match.view?.label ?? match.form?.label,
1981
+ form: safeForm,
1982
+ objectSchema
1983
+ });
1984
+ } catch (error) {
1985
+ logError("[REST] Public form resolve error:", error);
1986
+ res.status(500).json({
1987
+ code: "FORM_RESOLVE_FAILED",
1988
+ error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
1989
+ });
1990
+ }
1991
+ },
1992
+ metadata: {
1993
+ summary: "Resolve a public form spec by slug (anonymous)",
1994
+ tags: ["forms", "public"]
1995
+ }
1996
+ });
1997
+ this.routeManager.register({
1998
+ method: "POST",
1999
+ path: `${basePath}/forms/:slug/submit`,
2000
+ handler: async (req, res) => {
2001
+ try {
2002
+ const projectId = isScoped ? req.params?.projectId : void 0;
2003
+ const slug = String(req.params?.slug ?? "").trim();
2004
+ if (!slug) {
2005
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
2006
+ return;
2007
+ }
2008
+ const match = await resolveFormBySlug(projectId, req, slug);
2009
+ if (!match) {
2010
+ res.status(404).json({
2011
+ code: "FORM_NOT_FOUND",
2012
+ error: `No public form configured at /forms/${slug}`
2013
+ });
2014
+ return;
2015
+ }
2016
+ const allowedFields = /* @__PURE__ */ new Set();
2017
+ for (const section of match.form?.sections ?? []) {
2018
+ for (const f of section?.fields ?? []) {
2019
+ if (typeof f === "string") allowedFields.add(f);
2020
+ else if (f?.field) allowedFields.add(f.field);
2021
+ }
2022
+ }
2023
+ const rawBody = req.body && typeof req.body === "object" ? req.body : {};
2024
+ const filteredData = {};
2025
+ if (allowedFields.size > 0) {
2026
+ for (const [k, v] of Object.entries(rawBody)) {
2027
+ if (allowedFields.has(k)) filteredData[k] = v;
2028
+ }
2029
+ } else {
2030
+ Object.assign(filteredData, rawBody);
2031
+ }
2032
+ const context = {
2033
+ permissions: ["guest_portal"],
2034
+ anonymous: true
2035
+ };
2036
+ const p = await this.resolveProtocol(projectId, req);
2037
+ const result = await p.createData({
2038
+ object: match.object,
2039
+ data: filteredData,
2040
+ ...projectId ? { projectId } : {},
2041
+ context
2042
+ });
2043
+ res.status(201).json(result);
2044
+ } catch (error) {
2045
+ const mapped = mapDataError(error);
2046
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
2047
+ logError("[REST] Public form submit error:", error);
2048
+ }
2049
+ res.status(mapped.status).json(mapped.body);
2050
+ }
2051
+ },
2052
+ metadata: {
2053
+ summary: "Submit an anonymous public form",
2054
+ tags: ["forms", "public"]
2055
+ }
2056
+ });
2057
+ this.routeManager.register({
2058
+ method: "GET",
2059
+ path: `${basePath}/forms/:slug/lookup/:field`,
2060
+ handler: async (req, res) => {
2061
+ try {
2062
+ const projectId = isScoped ? req.params?.projectId : void 0;
2063
+ const slug = String(req.params?.slug ?? "").trim();
2064
+ const fieldName = String(req.params?.field ?? "").trim();
2065
+ if (!slug || !fieldName) {
2066
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
2067
+ return;
2068
+ }
2069
+ const match = await resolveFormBySlug(projectId, req, slug);
2070
+ if (!match) {
2071
+ res.status(404).json({
2072
+ code: "FORM_NOT_FOUND",
2073
+ error: `No public form configured at /forms/${slug}`
2074
+ });
2075
+ return;
2076
+ }
2077
+ let fieldCfg = null;
2078
+ for (const sec of match.form?.sections ?? []) {
2079
+ for (const f of sec?.fields ?? []) {
2080
+ const name = typeof f === "string" ? f : f?.field;
2081
+ if (name === fieldName) {
2082
+ fieldCfg = typeof f === "string" ? {} : f;
2083
+ break;
2084
+ }
2085
+ }
2086
+ if (fieldCfg) break;
2087
+ }
2088
+ const picker = fieldCfg?.publicPicker;
2089
+ if (!picker) {
2090
+ res.status(403).json({
2091
+ code: "LOOKUP_NOT_PUBLIC",
2092
+ error: `Field "${fieldName}" is not enabled for public lookup on this form`
2093
+ });
2094
+ return;
2095
+ }
2096
+ const p = await this.resolveProtocol(projectId, req);
2097
+ let referenceTo = picker.object;
2098
+ if (!referenceTo && typeof p.getMetaItems === "function") {
2099
+ try {
2100
+ const r = await p.getMetaItems({
2101
+ type: "object",
2102
+ ...projectId ? { projectId } : {}
2103
+ });
2104
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
2105
+ const obj = items.find((o) => o?.name === match.object);
2106
+ const def = obj?.fields?.[fieldName];
2107
+ referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
2108
+ } catch {
2109
+ }
2110
+ }
2111
+ if (!referenceTo) {
2112
+ res.status(500).json({
2113
+ code: "LOOKUP_TARGET_MISSING",
2114
+ error: `Could not resolve referenced object for "${fieldName}"`
2115
+ });
2116
+ return;
2117
+ }
2118
+ const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
2119
+ const hardCap = 50;
2120
+ const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
2121
+ const q = String(req.query?.q ?? "").trim().slice(0, 100);
2122
+ const filters = [];
2123
+ if (Array.isArray(picker.filter)) filters.push(...picker.filter);
2124
+ if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
2125
+ const context = {
2126
+ permissions: ["guest_portal"],
2127
+ anonymous: true
2128
+ };
2129
+ const result = await p.findData({
2130
+ object: referenceTo,
2131
+ query: {
2132
+ limit: maxResults,
2133
+ offset: 0,
2134
+ filters,
2135
+ select: ["id", ...displayFields],
2136
+ sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
2137
+ },
2138
+ ...projectId ? { projectId } : {},
2139
+ context
2140
+ });
2141
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
2142
+ const projected = rows.slice(0, maxResults).map((row) => {
2143
+ const out = { id: row?.id };
2144
+ for (const f of displayFields) {
2145
+ if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
2146
+ }
2147
+ return out;
2148
+ });
2149
+ res.json({
2150
+ data: projected,
2151
+ total: projected.length,
2152
+ truncated: rows.length >= maxResults,
2153
+ displayFields
2154
+ });
2155
+ } catch (error) {
2156
+ const mapped = mapDataError(error);
2157
+ if (!isExpectedDataStatus(mapped.status)) {
2158
+ logError("[REST] Public form lookup error:", error);
2159
+ }
2160
+ res.status(mapped.status).json(mapped.body);
2161
+ }
2162
+ },
2163
+ metadata: {
2164
+ summary: "Scoped lookup picker for a public form field (anonymous)",
2165
+ tags: ["forms", "public"]
2166
+ }
2167
+ });
2168
+ }
2169
+ /**
2170
+ * Register record-level sharing endpoints (M11.C17).
2171
+ *
2172
+ * Surfaces `ISharingService` over HTTP so the UI can list, create
2173
+ * and revoke per-record grants without going through ObjectQL. The
2174
+ * three routes mirror the share-management drawer in Salesforce /
2175
+ * ServiceNow:
2176
+ *
2177
+ * GET {basePath}/data/:object/:id/shares
2178
+ * POST {basePath}/data/:object/:id/shares
2179
+ * DELETE {basePath}/data/:object/:id/shares/:shareId
2180
+ *
2181
+ * All three resolve via `sharingServiceProvider`; routes return 501
2182
+ * when no sharing service is configured so a deployment without the
2183
+ * `@objectstack/plugin-sharing` plugin fails cleanly.
2184
+ */
2185
+ registerSharingEndpoints(basePath) {
2186
+ const { crud } = this.config;
2187
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2188
+ const isScoped = basePath.includes("/projects/:projectId");
2189
+ const resolveService = async (projectId) => {
2190
+ if (!this.sharingServiceProvider) return void 0;
2191
+ try {
2192
+ return await this.sharingServiceProvider(projectId);
2193
+ } catch {
2194
+ return void 0;
2195
+ }
2196
+ };
2197
+ const respond501 = (res) => res.status(501).json({
2198
+ code: "NOT_IMPLEMENTED",
2199
+ message: "Sharing service is not configured on this deployment"
2200
+ });
2201
+ this.routeManager.register({
2202
+ method: "GET",
2203
+ path: `${dataPath}/:object/:id/shares`,
2204
+ handler: async (req, res) => {
2205
+ try {
2206
+ const projectId = isScoped ? req.params?.projectId : void 0;
2207
+ const context = await this.resolveExecCtx(projectId, req);
2208
+ if (this.enforceAuth(req, res, context)) return;
2209
+ const svc = await resolveService(projectId);
2210
+ if (!svc) return respond501(res);
2211
+ const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
2212
+ res.json({ data: rows });
2213
+ } catch (error) {
2214
+ logError("[REST] List shares error:", error);
2215
+ res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2216
+ }
2217
+ },
2218
+ metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
2219
+ });
2220
+ this.routeManager.register({
2221
+ method: "POST",
2222
+ path: `${dataPath}/:object/:id/shares`,
2223
+ handler: async (req, res) => {
2224
+ try {
2225
+ const projectId = isScoped ? req.params?.projectId : void 0;
2226
+ const context = await this.resolveExecCtx(projectId, req);
2227
+ if (this.enforceAuth(req, res, context)) return;
2228
+ const svc = await resolveService(projectId);
2229
+ if (!svc) return respond501(res);
2230
+ const body = req.body ?? {};
2231
+ const input = {
2232
+ object: req.params.object,
2233
+ recordId: req.params.id,
2234
+ recipientType: body.recipientType ?? body.recipient_type,
2235
+ recipientId: body.recipientId ?? body.recipient_id,
2236
+ accessLevel: body.accessLevel ?? body.access_level,
2237
+ source: body.source,
2238
+ sourceId: body.sourceId ?? body.source_id,
2239
+ reason: body.reason
2240
+ };
2241
+ try {
2242
+ const row = await svc.grant(input, context ?? {});
2243
+ res.status(201).json(row);
2244
+ } catch (err) {
2245
+ const msg = String(err?.message ?? err ?? "");
2246
+ if (msg.startsWith("VALIDATION_FAILED")) {
2247
+ res.status(400).json({
2248
+ code: "VALIDATION_FAILED",
2249
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2250
+ });
2251
+ return;
2252
+ }
2253
+ throw err;
2254
+ }
2255
+ } catch (error) {
2256
+ logError("[REST] Grant share error:", error);
2257
+ res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2258
+ }
2259
+ },
2260
+ metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
2261
+ });
2262
+ this.routeManager.register({
2263
+ method: "DELETE",
2264
+ path: `${dataPath}/:object/:id/shares/:shareId`,
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.revoke(req.params.shareId, context ?? {});
2273
+ res.status(204).end();
2274
+ } catch (error) {
2275
+ logError("[REST] Revoke share error:", error);
2276
+ res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2277
+ }
2278
+ },
2279
+ metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
2280
+ });
2281
+ }
2282
+ /**
2283
+ * Register sharing-rule endpoints (M10.17). Mirrors the existing
2284
+ * sharing endpoints but operates on `sys_sharing_rule` rows.
2285
+ *
2286
+ * GET {basePath}/sharing/rules?object=&activeOnly=
2287
+ * POST {basePath}/sharing/rules
2288
+ * GET {basePath}/sharing/rules/:idOrName
2289
+ * DELETE {basePath}/sharing/rules/:idOrName
2290
+ * POST {basePath}/sharing/rules/:idOrName/evaluate
2291
+ *
2292
+ * Returns 501 when no sharing-rule service is configured.
2293
+ */
2294
+ registerSharingRuleEndpoints(basePath) {
2295
+ const dataPath = basePath;
2296
+ const isScoped = basePath.includes("/projects/:projectId");
2297
+ const resolveService = async (projectId) => {
2298
+ if (!this.sharingRulesServiceProvider) return void 0;
2299
+ try {
2300
+ return await this.sharingRulesServiceProvider(projectId);
2301
+ } catch {
2302
+ return void 0;
2303
+ }
2304
+ };
2305
+ const respond501 = (res) => res.status(501).json({
2306
+ code: "NOT_IMPLEMENTED",
2307
+ message: "Sharing-rule service is not configured on this deployment"
2308
+ });
2309
+ const handleError = (err, res, defaultCode) => {
2310
+ const msg = String(err?.message ?? err ?? "");
2311
+ if (msg.startsWith("VALIDATION_FAILED")) {
2312
+ return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
2313
+ }
2314
+ if (msg.startsWith("RULE_NOT_FOUND")) {
2315
+ return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
2316
+ }
2317
+ logError(`[REST] sharing-rule ${defaultCode}:`, err);
2318
+ return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
2319
+ };
2320
+ this.routeManager.register({
2321
+ method: "GET",
2322
+ path: `${dataPath}/sharing/rules`,
2323
+ handler: async (req, res) => {
2324
+ try {
2325
+ const projectId = isScoped ? req.params?.projectId : void 0;
2326
+ const context = await this.resolveExecCtx(projectId, req);
2327
+ if (this.enforceAuth(req, res, context)) return;
2328
+ const svc = await resolveService(projectId);
2329
+ if (!svc) return respond501(res);
2330
+ const rows = await svc.listRules({
2331
+ object: req.query?.object,
2332
+ activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
2333
+ }, context ?? {});
2334
+ res.json({ data: rows });
2335
+ } catch (err) {
2336
+ handleError(err, res, "RULE_LIST_FAILED");
2337
+ }
2338
+ },
2339
+ metadata: { summary: "List sharing rules", tags: ["sharing"] }
2340
+ });
2341
+ this.routeManager.register({
2342
+ method: "POST",
2343
+ path: `${dataPath}/sharing/rules`,
2344
+ handler: async (req, res) => {
2345
+ try {
2346
+ const projectId = isScoped ? req.params?.projectId : void 0;
2347
+ const context = await this.resolveExecCtx(projectId, req);
2348
+ if (this.enforceAuth(req, res, context)) return;
2349
+ const svc = await resolveService(projectId);
2350
+ if (!svc) return respond501(res);
2351
+ const body = req.body ?? {};
2352
+ const input = {
2353
+ name: body.name,
2354
+ label: body.label,
2355
+ description: body.description,
2356
+ object: body.object ?? body.object_name,
2357
+ criteria: body.criteria,
2358
+ recipientType: body.recipientType ?? body.recipient_type,
2359
+ recipientId: body.recipientId ?? body.recipient_id,
2360
+ accessLevel: body.accessLevel ?? body.access_level,
2361
+ active: body.active
2362
+ };
2363
+ const row = await svc.defineRule(input, context ?? {});
2364
+ res.status(201).json(row);
2365
+ } catch (err) {
2366
+ handleError(err, res, "RULE_DEFINE_FAILED");
2367
+ }
2368
+ },
2369
+ metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
2370
+ });
2371
+ this.routeManager.register({
2372
+ method: "GET",
2373
+ path: `${dataPath}/sharing/rules/:idOrName`,
2374
+ handler: async (req, res) => {
2375
+ try {
2376
+ const projectId = isScoped ? req.params?.projectId : void 0;
2377
+ const context = await this.resolveExecCtx(projectId, req);
2378
+ if (this.enforceAuth(req, res, context)) return;
2379
+ const svc = await resolveService(projectId);
2380
+ if (!svc) return respond501(res);
2381
+ const row = await svc.getRule(req.params.idOrName, context ?? {});
2382
+ if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
2383
+ res.json(row);
2384
+ } catch (err) {
2385
+ handleError(err, res, "RULE_GET_FAILED");
2386
+ }
2387
+ },
2388
+ metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
2389
+ });
2390
+ this.routeManager.register({
2391
+ method: "DELETE",
2392
+ path: `${dataPath}/sharing/rules/:idOrName`,
2393
+ handler: async (req, res) => {
2394
+ try {
2395
+ const projectId = isScoped ? req.params?.projectId : void 0;
2396
+ const context = await this.resolveExecCtx(projectId, req);
2397
+ if (this.enforceAuth(req, res, context)) return;
2398
+ const svc = await resolveService(projectId);
2399
+ if (!svc) return respond501(res);
2400
+ await svc.deleteRule(req.params.idOrName, context ?? {});
2401
+ res.status(204).end();
2402
+ } catch (err) {
2403
+ handleError(err, res, "RULE_DELETE_FAILED");
2404
+ }
2405
+ },
2406
+ metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
2407
+ });
2408
+ this.routeManager.register({
2409
+ method: "POST",
2410
+ path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
2411
+ handler: async (req, res) => {
2412
+ try {
2413
+ const projectId = isScoped ? req.params?.projectId : void 0;
2414
+ const context = await this.resolveExecCtx(projectId, req);
2415
+ if (this.enforceAuth(req, res, context)) return;
2416
+ const svc = await resolveService(projectId);
2417
+ if (!svc) return respond501(res);
2418
+ const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
2419
+ res.json(result);
2420
+ } catch (err) {
2421
+ handleError(err, res, "RULE_EVALUATE_FAILED");
2422
+ }
2423
+ },
2424
+ metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
2425
+ });
2426
+ }
2427
+ /**
2428
+ * Register saved-report + scheduled-digest endpoints (M11.C16).
2429
+ *
2430
+ * Surfaces `IReportService` over HTTP so the UI can build,
2431
+ * run, and schedule reports without dropping to ObjectQL. Routes
2432
+ * live at the top of the API surface (alongside `/approvals` and
2433
+ * `/sharing`) — reports are a tenant-wide capability, not a record
2434
+ * on a specific CRUD object:
2435
+ *
2436
+ * GET {basePath}/reports?object=&ownerId=
2437
+ * POST {basePath}/reports
2438
+ * GET {basePath}/reports/:id
2439
+ * DELETE {basePath}/reports/:id
2440
+ * POST {basePath}/reports/:id/run
2441
+ * POST {basePath}/reports/:id/schedule
2442
+ * GET {basePath}/reports/:id/schedules
2443
+ * DELETE {basePath}/reports/schedules/:scheduleId
2444
+ *
2445
+ * All routes return 501 when `reportsServiceProvider` is unset so
2446
+ * a deployment without `@objectstack/plugin-reports` fails cleanly.
2447
+ */
2448
+ registerReportsEndpoints(basePath) {
2449
+ const dataPath = basePath;
2450
+ const isScoped = basePath.includes("/projects/:projectId");
2451
+ const resolveService = async (projectId) => {
2452
+ if (!this.reportsServiceProvider) return void 0;
2453
+ try {
2454
+ return await this.reportsServiceProvider(projectId);
2455
+ } catch {
2456
+ return void 0;
2457
+ }
2458
+ };
2459
+ const respond501 = (res) => res.status(501).json({
2460
+ code: "NOT_IMPLEMENTED",
2461
+ message: "Reports service is not configured on this deployment"
2462
+ });
2463
+ const handleValidation = (res, err) => {
2464
+ const msg = String(err?.message ?? err ?? "");
2465
+ if (msg.startsWith("VALIDATION_FAILED")) {
2466
+ res.status(400).json({
2467
+ code: "VALIDATION_FAILED",
2468
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2469
+ });
2470
+ return true;
2471
+ }
2472
+ if (msg.startsWith("REPORT_NOT_FOUND")) {
2473
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
2474
+ return true;
2475
+ }
2476
+ return false;
2477
+ };
2478
+ this.routeManager.register({
2479
+ method: "GET",
2480
+ path: `${dataPath}/reports`,
2481
+ handler: async (req, res) => {
2482
+ try {
2483
+ const projectId = isScoped ? req.params?.projectId : void 0;
2484
+ const context = await this.resolveExecCtx(projectId, req);
2485
+ if (this.enforceAuth(req, res, context)) return;
2486
+ const svc = await resolveService(projectId);
2487
+ if (!svc) return respond501(res);
2488
+ const q = req.query ?? {};
2489
+ const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
2490
+ res.json({ data: rows });
2491
+ } catch (error) {
2492
+ logError("[REST] List reports error:", error);
2493
+ res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2494
+ }
2495
+ },
2496
+ metadata: { summary: "List saved reports", tags: ["reports"] }
2497
+ });
2498
+ this.routeManager.register({
2499
+ method: "POST",
2500
+ path: `${dataPath}/reports`,
2501
+ handler: async (req, res) => {
2502
+ try {
2503
+ const projectId = isScoped ? req.params?.projectId : void 0;
2504
+ const context = await this.resolveExecCtx(projectId, req);
2505
+ if (this.enforceAuth(req, res, context)) return;
2506
+ const svc = await resolveService(projectId);
2507
+ if (!svc) return respond501(res);
2508
+ try {
2509
+ const row = await svc.saveReport(req.body ?? {}, context ?? {});
2510
+ res.status(201).json(row);
2511
+ } catch (err) {
2512
+ if (handleValidation(res, err)) return;
2513
+ throw err;
2514
+ }
2515
+ } catch (error) {
2516
+ logError("[REST] Save report error:", error);
2517
+ res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2518
+ }
2519
+ },
2520
+ metadata: { summary: "Create or update a saved report", tags: ["reports"] }
2521
+ });
2522
+ this.routeManager.register({
2523
+ method: "GET",
2524
+ path: `${dataPath}/reports/:id`,
2525
+ handler: async (req, res) => {
2526
+ try {
2527
+ const projectId = isScoped ? req.params?.projectId : void 0;
2528
+ const context = await this.resolveExecCtx(projectId, req);
2529
+ if (this.enforceAuth(req, res, context)) return;
2530
+ const svc = await resolveService(projectId);
2531
+ if (!svc) return respond501(res);
2532
+ const row = await svc.getReport(req.params.id, context ?? {});
2533
+ if (!row) {
2534
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
2535
+ return;
2536
+ }
2537
+ res.json(row);
2538
+ } catch (error) {
2539
+ logError("[REST] Get report error:", error);
2540
+ res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2541
+ }
2542
+ },
2543
+ metadata: { summary: "Get a saved report by id", tags: ["reports"] }
2544
+ });
2545
+ this.routeManager.register({
2546
+ method: "DELETE",
2547
+ path: `${dataPath}/reports/:id`,
2548
+ handler: async (req, res) => {
2549
+ try {
2550
+ const projectId = isScoped ? req.params?.projectId : void 0;
2551
+ const context = await this.resolveExecCtx(projectId, req);
2552
+ if (this.enforceAuth(req, res, context)) return;
2553
+ const svc = await resolveService(projectId);
2554
+ if (!svc) return respond501(res);
2555
+ await svc.deleteReport(req.params.id, context ?? {});
2556
+ res.status(204).end();
2557
+ } catch (error) {
2558
+ logError("[REST] Delete report error:", error);
2559
+ res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2560
+ }
2561
+ },
2562
+ metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
2563
+ });
2564
+ this.routeManager.register({
2565
+ method: "POST",
2566
+ path: `${dataPath}/reports/:id/run`,
2567
+ handler: async (req, res) => {
2568
+ try {
2569
+ const projectId = isScoped ? req.params?.projectId : void 0;
2570
+ const context = await this.resolveExecCtx(projectId, req);
2571
+ if (this.enforceAuth(req, res, context)) return;
2572
+ const svc = await resolveService(projectId);
2573
+ if (!svc) return respond501(res);
2574
+ try {
2575
+ const result = await svc.run(req.params.id, context ?? {});
2576
+ res.json(result);
2577
+ } catch (err) {
2578
+ if (handleValidation(res, err)) return;
2579
+ throw err;
2580
+ }
2581
+ } catch (error) {
2582
+ logError("[REST] Run report error:", error);
2583
+ res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2584
+ }
2585
+ },
2586
+ metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
2587
+ });
2588
+ this.routeManager.register({
2589
+ method: "POST",
2590
+ path: `${dataPath}/reports/:id/schedule`,
2591
+ handler: async (req, res) => {
2592
+ try {
2593
+ const projectId = isScoped ? req.params?.projectId : void 0;
2594
+ const context = await this.resolveExecCtx(projectId, req);
2595
+ if (this.enforceAuth(req, res, context)) return;
2596
+ const svc = await resolveService(projectId);
2597
+ if (!svc) return respond501(res);
2598
+ const body = req.body ?? {};
2599
+ try {
2600
+ const row = await svc.scheduleReport({
2601
+ reportId: req.params.id,
2602
+ recipients: body.recipients ?? [],
2603
+ name: body.name,
2604
+ intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
2605
+ cronExpression: body.cronExpression ?? body.cron_expression,
2606
+ timezone: body.timezone,
2607
+ format: body.format,
2608
+ subjectTemplate: body.subjectTemplate ?? body.subject_template,
2609
+ ownerId: body.ownerId ?? body.owner_id,
2610
+ active: body.active
2611
+ }, context ?? {});
2612
+ res.status(201).json(row);
2613
+ } catch (err) {
2614
+ if (handleValidation(res, err)) return;
2615
+ throw err;
2616
+ }
2617
+ } catch (error) {
2618
+ logError("[REST] Schedule report error:", error);
2619
+ res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2620
+ }
2621
+ },
2622
+ metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
2623
+ });
2624
+ this.routeManager.register({
2625
+ method: "GET",
2626
+ path: `${dataPath}/reports/:id/schedules`,
2627
+ handler: async (req, res) => {
2628
+ try {
2629
+ const projectId = isScoped ? req.params?.projectId : void 0;
2630
+ const context = await this.resolveExecCtx(projectId, req);
2631
+ if (this.enforceAuth(req, res, context)) return;
2632
+ const svc = await resolveService(projectId);
2633
+ if (!svc) return respond501(res);
2634
+ const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
2635
+ res.json({ data: rows });
2636
+ } catch (error) {
2637
+ logError("[REST] List schedules error:", error);
2638
+ res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2639
+ }
2640
+ },
2641
+ metadata: { summary: "List schedules for a report", tags: ["reports"] }
2642
+ });
2643
+ this.routeManager.register({
2644
+ method: "DELETE",
2645
+ path: `${dataPath}/reports/schedules/:scheduleId`,
2646
+ handler: async (req, res) => {
2647
+ try {
2648
+ const projectId = isScoped ? req.params?.projectId : void 0;
2649
+ const context = await this.resolveExecCtx(projectId, req);
2650
+ if (this.enforceAuth(req, res, context)) return;
2651
+ const svc = await resolveService(projectId);
2652
+ if (!svc) return respond501(res);
2653
+ await svc.unscheduleReport(req.params.scheduleId, context ?? {});
2654
+ res.status(204).end();
2655
+ } catch (error) {
2656
+ logError("[REST] Unschedule report error:", error);
2657
+ res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2658
+ }
2659
+ },
2660
+ metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
2661
+ });
2662
+ }
2663
+ /**
2664
+ * Register approval engine endpoints.
2665
+ *
2666
+ * Routes (all under {basePath}/approvals):
2667
+ * GET /processes — list approval processes
2668
+ * POST /processes — upsert (defineProcess)
2669
+ * GET /processes/:id — get by id or name
2670
+ * DELETE /processes/:id — delete process
2671
+ * POST /requests — submit
2672
+ * GET /requests — list (filters: status, object, recordId, approverId, submitterId)
2673
+ * GET /requests/:id — get request
2674
+ * POST /requests/:id/approve — approve current step
2675
+ * POST /requests/:id/reject — reject current step
2676
+ * POST /requests/:id/recall — recall (submitter only)
2677
+ * GET /requests/:id/actions — audit trail
2678
+ *
2679
+ * Returns 501 when `approvalsServiceProvider` is unset so deployments
2680
+ * without `@objectstack/plugin-approvals` fail cleanly.
2681
+ */
2682
+ registerApprovalsEndpoints(basePath) {
2683
+ const dataPath = basePath;
2684
+ const isScoped = basePath.includes("/projects/:projectId");
2685
+ const resolveService = async (projectId) => {
2686
+ if (!this.approvalsServiceProvider) return void 0;
2687
+ try {
2688
+ return await this.approvalsServiceProvider(projectId);
2689
+ } catch {
2690
+ return void 0;
2691
+ }
2692
+ };
2693
+ const respond501 = (res) => res.status(501).json({
2694
+ code: "NOT_IMPLEMENTED",
2695
+ message: "Approvals service is not configured on this deployment"
2696
+ });
2697
+ const handleApprovalError = (res, err) => {
2698
+ const msg = String(err?.message ?? err ?? "");
2699
+ const mapping = [
2700
+ [/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
2701
+ [/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
2702
+ [/^INVALID_STATE/, 409, "INVALID_STATE"],
2703
+ [/^FORBIDDEN/, 403, "FORBIDDEN"],
2704
+ [/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
2705
+ [/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
2706
+ [/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
2707
+ ];
2708
+ for (const [re, status, code] of mapping) {
2709
+ if (re.test(msg)) {
2710
+ res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
2711
+ return true;
2712
+ }
2713
+ }
2714
+ return false;
2715
+ };
2716
+ this.routeManager.register({
2717
+ method: "GET",
2718
+ path: `${dataPath}/approvals/processes`,
2719
+ handler: async (req, res) => {
2720
+ try {
2721
+ const projectId = isScoped ? req.params?.projectId : void 0;
2722
+ const context = await this.resolveExecCtx(projectId, req);
2723
+ if (this.enforceAuth(req, res, context)) return;
2724
+ const svc = await resolveService(projectId);
2725
+ if (!svc) return respond501(res);
2726
+ const q = req.query ?? {};
2727
+ const rows = await svc.listProcesses({
2728
+ object: q.object,
2729
+ activeOnly: q.activeOnly === "true" || q.activeOnly === true
2730
+ }, context ?? {});
2731
+ res.json({ data: rows });
2732
+ } catch (error) {
2733
+ logError("[REST] List approval processes error:", error);
2734
+ res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2735
+ }
2736
+ },
2737
+ metadata: { summary: "List approval processes", tags: ["approvals"] }
2738
+ });
2739
+ this.routeManager.register({
2740
+ method: "POST",
2741
+ path: `${dataPath}/approvals/processes`,
2742
+ handler: async (req, res) => {
2743
+ try {
2744
+ const projectId = isScoped ? req.params?.projectId : void 0;
2745
+ const context = await this.resolveExecCtx(projectId, req);
2746
+ if (this.enforceAuth(req, res, context)) return;
2747
+ const svc = await resolveService(projectId);
2748
+ if (!svc) return respond501(res);
2749
+ try {
2750
+ const row = await svc.defineProcess(req.body ?? {}, context ?? {});
2751
+ res.status(201).json(row);
2752
+ } catch (err) {
2753
+ if (handleApprovalError(res, err)) return;
2754
+ throw err;
2755
+ }
2756
+ } catch (error) {
2757
+ logError("[REST] Define approval process error:", error);
2758
+ res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2759
+ }
2760
+ },
2761
+ metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
2762
+ });
2763
+ this.routeManager.register({
2764
+ method: "GET",
2765
+ path: `${dataPath}/approvals/processes/:id`,
2766
+ handler: async (req, res) => {
2767
+ try {
2768
+ const projectId = isScoped ? req.params?.projectId : void 0;
2769
+ const context = await this.resolveExecCtx(projectId, req);
2770
+ if (this.enforceAuth(req, res, context)) return;
2771
+ const svc = await resolveService(projectId);
2772
+ if (!svc) return respond501(res);
2773
+ const row = await svc.getProcess(req.params.id, context ?? {});
2774
+ if (!row) {
2775
+ res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
2776
+ return;
2777
+ }
2778
+ res.json(row);
2779
+ } catch (error) {
2780
+ logError("[REST] Get approval process error:", error);
2781
+ res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2782
+ }
2783
+ },
2784
+ metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
2785
+ });
2786
+ this.routeManager.register({
2787
+ method: "DELETE",
2788
+ path: `${dataPath}/approvals/processes/:id`,
2789
+ handler: async (req, res) => {
2790
+ try {
2791
+ const projectId = isScoped ? req.params?.projectId : void 0;
2792
+ const context = await this.resolveExecCtx(projectId, req);
2793
+ if (this.enforceAuth(req, res, context)) return;
2794
+ const svc = await resolveService(projectId);
2795
+ if (!svc) return respond501(res);
2796
+ await svc.deleteProcess(req.params.id, context ?? {});
2797
+ res.status(204).end();
2798
+ } catch (error) {
2799
+ logError("[REST] Delete approval process error:", error);
2800
+ res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2801
+ }
2802
+ },
2803
+ metadata: { summary: "Delete an approval process", tags: ["approvals"] }
2804
+ });
2805
+ this.routeManager.register({
2806
+ method: "POST",
2807
+ path: `${dataPath}/approvals/requests`,
2808
+ handler: async (req, res) => {
2809
+ try {
2810
+ const projectId = isScoped ? req.params?.projectId : void 0;
2811
+ const context = await this.resolveExecCtx(projectId, req);
2812
+ if (this.enforceAuth(req, res, context)) return;
2813
+ const svc = await resolveService(projectId);
2814
+ if (!svc) return respond501(res);
2815
+ const body = req.body ?? {};
2816
+ try {
2817
+ const row = await svc.submit({
2818
+ object: body.object,
2819
+ recordId: body.recordId ?? body.record_id,
2820
+ processName: body.processName ?? body.process_name,
2821
+ submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
2822
+ comment: body.comment,
2823
+ payload: body.payload
2824
+ }, context ?? {});
2825
+ res.status(201).json(row);
2826
+ } catch (err) {
2827
+ if (handleApprovalError(res, err)) return;
2828
+ throw err;
2829
+ }
2830
+ } catch (error) {
2831
+ logError("[REST] Submit approval error:", error);
2832
+ res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2833
+ }
2834
+ },
2835
+ metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
2836
+ });
2837
+ this.routeManager.register({
2838
+ method: "GET",
2839
+ path: `${dataPath}/approvals/requests`,
2840
+ handler: async (req, res) => {
2841
+ try {
2842
+ const projectId = isScoped ? req.params?.projectId : void 0;
2843
+ const context = await this.resolveExecCtx(projectId, req);
2844
+ if (this.enforceAuth(req, res, context)) return;
2845
+ const svc = await resolveService(projectId);
2846
+ if (!svc) {
2847
+ res.json({ data: [] });
2848
+ return;
2849
+ }
2850
+ const q = req.query ?? {};
2851
+ const rows = await svc.listRequests({
2852
+ object: q.object,
2853
+ recordId: q.recordId ?? q.record_id,
2854
+ status: q.status,
2855
+ approverId: q.approverId ?? q.approver_id,
2856
+ submitterId: q.submitterId ?? q.submitter_id
2857
+ }, context ?? {});
2858
+ res.json({ data: rows });
2859
+ } catch (error) {
2860
+ logError("[REST] List approval requests error:", error);
2861
+ res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2862
+ }
2863
+ },
2864
+ metadata: { summary: "List approval requests", tags: ["approvals"] }
2865
+ });
2866
+ this.routeManager.register({
2867
+ method: "GET",
2868
+ path: `${dataPath}/approvals/requests/:id`,
2869
+ handler: async (req, res) => {
2870
+ try {
2871
+ const projectId = isScoped ? req.params?.projectId : void 0;
2872
+ const context = await this.resolveExecCtx(projectId, req);
2873
+ if (this.enforceAuth(req, res, context)) return;
2874
+ const svc = await resolveService(projectId);
2875
+ if (!svc) return respond501(res);
2876
+ const row = await svc.getRequest(req.params.id, context ?? {});
2877
+ if (!row) {
2878
+ res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
2879
+ return;
2880
+ }
2881
+ res.json(row);
2882
+ } catch (error) {
2883
+ logError("[REST] Get approval request error:", error);
2884
+ res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2885
+ }
2886
+ },
2887
+ metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
2888
+ });
2889
+ const decisionRoute = (suffix, method) => {
2890
+ this.routeManager.register({
2891
+ method: "POST",
2892
+ path: `${dataPath}/approvals/requests/:id/${suffix}`,
2893
+ handler: async (req, res) => {
2894
+ try {
2895
+ const projectId = isScoped ? req.params?.projectId : void 0;
2896
+ const context = await this.resolveExecCtx(projectId, req);
2897
+ if (this.enforceAuth(req, res, context)) return;
2898
+ const svc = await resolveService(projectId);
2899
+ if (!svc) return respond501(res);
2900
+ const body = req.body ?? {};
2901
+ try {
2902
+ const out = await svc[method](req.params.id, {
2903
+ actorId: body.actorId ?? body.actor_id ?? context?.userId,
2904
+ comment: body.comment
2905
+ }, context ?? {});
2906
+ res.json(out);
2907
+ } catch (err) {
2908
+ if (handleApprovalError(res, err)) return;
2909
+ throw err;
2910
+ }
2911
+ } catch (error) {
2912
+ logError(`[REST] ${suffix} approval error:`, error);
2913
+ res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
2914
+ }
2915
+ },
2916
+ metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
2917
+ });
2918
+ };
2919
+ decisionRoute("approve", "approve");
2920
+ decisionRoute("reject", "reject");
2921
+ decisionRoute("recall", "recall");
2922
+ this.routeManager.register({
2923
+ method: "GET",
2924
+ path: `${dataPath}/approvals/requests/:id/actions`,
2925
+ handler: async (req, res) => {
2926
+ try {
2927
+ const projectId = isScoped ? req.params?.projectId : void 0;
2928
+ const context = await this.resolveExecCtx(projectId, req);
2929
+ if (this.enforceAuth(req, res, context)) return;
2930
+ const svc = await resolveService(projectId);
2931
+ if (!svc) return respond501(res);
2932
+ const rows = await svc.listActions(req.params.id, context ?? {});
2933
+ res.json({ data: rows });
2934
+ } catch (error) {
2935
+ logError("[REST] List approval actions error:", error);
2936
+ res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2937
+ }
2938
+ },
2939
+ metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
2940
+ });
2941
+ }
2942
+ /**
2943
+ * Register batch operation endpoints
2944
+ */
2945
+ registerBatchEndpoints(basePath) {
2946
+ const { crud, batch } = this.config;
2947
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2948
+ const isScoped = basePath.includes("/projects/:projectId");
2949
+ const operations = batch.operations;
2950
+ if (batch.enableBatchEndpoint && this.protocol.batchData) {
2951
+ this.routeManager.register({
2952
+ method: "POST",
2953
+ path: `${dataPath}/:object/batch`,
2954
+ handler: async (req, res) => {
2955
+ try {
2956
+ const projectId = isScoped ? req.params?.projectId : void 0;
2957
+ const p = await this.resolveProtocol(projectId, req);
2958
+ const context = await this.resolveExecCtx(projectId, req);
2959
+ if (this.enforceAuth(req, res, context)) return;
2960
+ const result = await p.batchData({
2961
+ object: req.params.object,
2962
+ request: req.body,
2963
+ ...projectId ? { projectId } : {},
2964
+ ...context ? { context } : {}
2965
+ });
2966
+ res.json(result);
2967
+ } catch (error) {
2968
+ logError("[REST] Unhandled error:", error);
2969
+ sendError(res, error, req.params?.object);
2970
+ }
2971
+ },
2972
+ metadata: {
2973
+ summary: "Batch operations",
2974
+ tags: ["data", "batch"]
2975
+ }
2976
+ });
2977
+ }
2978
+ if (operations.createMany && this.protocol.createManyData) {
2979
+ this.routeManager.register({
2980
+ method: "POST",
2981
+ path: `${dataPath}/:object/createMany`,
2982
+ handler: async (req, res) => {
2983
+ try {
2984
+ const projectId = isScoped ? req.params?.projectId : void 0;
2985
+ const p = await this.resolveProtocol(projectId, req);
2986
+ const context = await this.resolveExecCtx(projectId, req);
2987
+ if (this.enforceAuth(req, res, context)) return;
2988
+ const result = await p.createManyData({
2989
+ object: req.params.object,
2990
+ records: req.body || [],
2991
+ ...projectId ? { projectId } : {},
2992
+ ...context ? { context } : {}
2993
+ });
2994
+ res.status(201).json(result);
2995
+ } catch (error) {
2996
+ logError("[REST] Unhandled error:", error);
2997
+ sendError(res, error, req.params?.object);
2998
+ }
2999
+ },
3000
+ metadata: {
3001
+ summary: "Create multiple records",
3002
+ tags: ["data", "batch"]
1133
3003
  }
1134
3004
  });
1135
3005
  }
@@ -1141,15 +3011,18 @@ var RestServer = class {
1141
3011
  try {
1142
3012
  const projectId = isScoped ? req.params?.projectId : void 0;
1143
3013
  const p = await this.resolveProtocol(projectId, req);
3014
+ const context = await this.resolveExecCtx(projectId, req);
3015
+ if (this.enforceAuth(req, res, context)) return;
1144
3016
  const result = await p.updateManyData({
1145
3017
  object: req.params.object,
1146
3018
  ...req.body,
1147
- ...projectId ? { projectId } : {}
3019
+ ...projectId ? { projectId } : {},
3020
+ ...context ? { context } : {}
1148
3021
  });
1149
3022
  res.json(result);
1150
3023
  } catch (error) {
1151
3024
  logError("[REST] Unhandled error:", error);
1152
- res.status(400).json({ error: error.message });
3025
+ sendError(res, error, req.params?.object);
1153
3026
  }
1154
3027
  },
1155
3028
  metadata: {
@@ -1166,15 +3039,18 @@ var RestServer = class {
1166
3039
  try {
1167
3040
  const projectId = isScoped ? req.params?.projectId : void 0;
1168
3041
  const p = await this.resolveProtocol(projectId, req);
3042
+ const context = await this.resolveExecCtx(projectId, req);
3043
+ if (this.enforceAuth(req, res, context)) return;
1169
3044
  const result = await p.deleteManyData({
1170
3045
  object: req.params.object,
1171
3046
  ...req.body,
1172
- ...projectId ? { projectId } : {}
3047
+ ...projectId ? { projectId } : {},
3048
+ ...context ? { context } : {}
1173
3049
  });
1174
3050
  res.json(result);
1175
3051
  } catch (error) {
1176
3052
  logError("[REST] Unhandled error:", error);
1177
- res.status(400).json({ error: error.message });
3053
+ sendError(res, error, req.params?.object);
1178
3054
  }
1179
3055
  },
1180
3056
  metadata: {
@@ -1368,6 +3244,48 @@ function createRestApiPlugin(config = {}) {
1368
3244
  return void 0;
1369
3245
  }
1370
3246
  };
3247
+ const emailServiceProvider = async (_projectId) => {
3248
+ try {
3249
+ return ctx.getService("email");
3250
+ } catch {
3251
+ return void 0;
3252
+ }
3253
+ };
3254
+ const sharingServiceProvider = async (_projectId) => {
3255
+ try {
3256
+ return ctx.getService("sharing");
3257
+ } catch {
3258
+ return void 0;
3259
+ }
3260
+ };
3261
+ const reportsServiceProvider = async (_projectId) => {
3262
+ try {
3263
+ return ctx.getService("reports");
3264
+ } catch {
3265
+ return void 0;
3266
+ }
3267
+ };
3268
+ const approvalsServiceProvider = async (_projectId) => {
3269
+ try {
3270
+ return ctx.getService("approvals");
3271
+ } catch {
3272
+ return void 0;
3273
+ }
3274
+ };
3275
+ const sharingRulesServiceProvider = async (_projectId) => {
3276
+ try {
3277
+ return ctx.getService("sharingRules");
3278
+ } catch {
3279
+ return void 0;
3280
+ }
3281
+ };
3282
+ const i18nServiceProvider = async (_projectId) => {
3283
+ try {
3284
+ return ctx.getService("i18n");
3285
+ } catch {
3286
+ return void 0;
3287
+ }
3288
+ };
1371
3289
  if (!server) {
1372
3290
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
1373
3291
  return;
@@ -1378,7 +3296,7 @@ function createRestApiPlugin(config = {}) {
1378
3296
  }
1379
3297
  ctx.logger.info("Hydrating REST API from Protocol...");
1380
3298
  try {
1381
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
3299
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
1382
3300
  restServer.registerRoutes();
1383
3301
  ctx.logger.info("REST API successfully registered");
1384
3302
  } catch (err) {