@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.cjs CHANGED
@@ -250,6 +250,17 @@ var RouteGroupBuilder = class {
250
250
  // src/rest-server.ts
251
251
  var logError = (...args) => globalThis.console?.error(...args);
252
252
  function mapDataError(error, object) {
253
+ if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
254
+ return {
255
+ status: 400,
256
+ body: {
257
+ error: error?.message ?? "Validation failed",
258
+ code: "VALIDATION_FAILED",
259
+ fields: Array.isArray(error?.fields) ? error.fields : [],
260
+ ...object ? { object } : {}
261
+ }
262
+ };
263
+ }
253
264
  if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
254
265
  return {
255
266
  status: 403,
@@ -273,6 +284,16 @@ function mapDataError(error, object) {
273
284
  }
274
285
  };
275
286
  }
287
+ if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
288
+ return {
289
+ status: 404,
290
+ body: {
291
+ error: raw,
292
+ code: "RECORD_NOT_FOUND",
293
+ ...object ? { object } : {}
294
+ }
295
+ };
296
+ }
276
297
  const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
277
298
  if (looksLikeUnknownObject) {
278
299
  return {
@@ -284,13 +305,132 @@ function mapDataError(error, object) {
284
305
  }
285
306
  };
286
307
  }
308
+ const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
309
+ if (looksLikeSqlLeak) {
310
+ if (lower.includes("unique constraint") || lower.includes("unique violation")) {
311
+ return {
312
+ status: 409,
313
+ body: {
314
+ error: "A record with this value already exists",
315
+ code: "UNIQUE_VIOLATION",
316
+ ...object ? { object } : {}
317
+ }
318
+ };
319
+ }
320
+ return {
321
+ status: 500,
322
+ body: { error: "Internal data error", code: "DATABASE_ERROR" }
323
+ };
324
+ }
287
325
  return { status: 400, body: { error: raw || "Bad request" } };
288
326
  }
327
+ function sendError(res, error, object) {
328
+ if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
329
+ const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
330
+ res.status(error.status).json({
331
+ error: safeMsg,
332
+ ...error.code ? { code: error.code } : {}
333
+ });
334
+ return;
335
+ }
336
+ const mapped = mapDataError(error, object);
337
+ res.status(mapped.status).json(mapped.body);
338
+ }
289
339
  function isExpectedDataStatus(status) {
290
- return status === 403 || status === 404 || status === 502 || status === 503;
340
+ return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
341
+ }
342
+ function parseCsvToRows(csv, mapping = {}) {
343
+ const text = csv.replace(/^\uFEFF/, "");
344
+ const cells = [];
345
+ let cur = "";
346
+ let row = [];
347
+ let inQuotes = false;
348
+ for (let i = 0; i < text.length; i++) {
349
+ const ch = text[i];
350
+ if (inQuotes) {
351
+ if (ch === '"') {
352
+ if (text[i + 1] === '"') {
353
+ cur += '"';
354
+ i++;
355
+ } else {
356
+ inQuotes = false;
357
+ }
358
+ } else {
359
+ cur += ch;
360
+ }
361
+ continue;
362
+ }
363
+ if (ch === '"') {
364
+ inQuotes = true;
365
+ continue;
366
+ }
367
+ if (ch === ",") {
368
+ row.push(cur);
369
+ cur = "";
370
+ continue;
371
+ }
372
+ if (ch === "\r") {
373
+ continue;
374
+ }
375
+ if (ch === "\n") {
376
+ row.push(cur);
377
+ cur = "";
378
+ cells.push(row);
379
+ row = [];
380
+ continue;
381
+ }
382
+ cur += ch;
383
+ }
384
+ if (cur.length > 0 || row.length > 0) {
385
+ row.push(cur);
386
+ cells.push(row);
387
+ }
388
+ while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
389
+ if (cells.length < 2) return [];
390
+ const header = cells[0].map((h) => h.trim());
391
+ const fields = header.map((h) => mapping[h] ?? h);
392
+ const out = [];
393
+ for (let r = 1; r < cells.length; r++) {
394
+ const row2 = cells[r];
395
+ const obj = {};
396
+ for (let c = 0; c < fields.length; c++) {
397
+ const key = fields[c];
398
+ if (!key) continue;
399
+ const raw = row2[c] ?? "";
400
+ obj[key] = raw;
401
+ }
402
+ out.push(obj);
403
+ }
404
+ return out;
405
+ }
406
+ function formatCsvCell(value) {
407
+ if (value === null || value === void 0) return "";
408
+ let s;
409
+ if (typeof value === "string") s = value;
410
+ else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") s = String(value);
411
+ else if (value instanceof Date) s = value.toISOString();
412
+ else {
413
+ try {
414
+ s = JSON.stringify(value);
415
+ } catch {
416
+ s = String(value);
417
+ }
418
+ }
419
+ if (/[",\r\n]/.test(s)) {
420
+ return `"${s.replace(/"/g, '""')}"`;
421
+ }
422
+ return s;
423
+ }
424
+ function rowsToCsv(fields, rows, includeHeader) {
425
+ const lines = [];
426
+ if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
427
+ for (const row of rows) {
428
+ lines.push(fields.map((f) => formatCsvCell(row?.[f])).join(","));
429
+ }
430
+ return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
291
431
  }
292
432
  var RestServer = class {
293
- constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
433
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
294
434
  this.protocol = protocol;
295
435
  this.config = this.normalizeConfig(config);
296
436
  this.routeManager = new RouteManager(server);
@@ -299,6 +439,12 @@ var RestServer = class {
299
439
  this.defaultProjectIdProvider = defaultProjectIdProvider;
300
440
  this.authServiceProvider = authServiceProvider;
301
441
  this.objectQLProvider = objectQLProvider;
442
+ this.emailServiceProvider = emailServiceProvider;
443
+ this.sharingServiceProvider = sharingServiceProvider;
444
+ this.reportsServiceProvider = reportsServiceProvider;
445
+ this.approvalsServiceProvider = approvalsServiceProvider;
446
+ this.sharingRulesServiceProvider = sharingRulesServiceProvider;
447
+ this.i18nServiceProvider = i18nServiceProvider;
302
448
  }
303
449
  /**
304
450
  * Resolve the protocol for a given request. When `projectId` is present
@@ -366,14 +512,70 @@ var RestServer = class {
366
512
  * requests intentionally return `undefined` because the platform kernel
367
513
  * does not own per-app translation bundles.
368
514
  */
369
- async resolveI18nService(projectId) {
370
- if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
371
- try {
372
- const kernel = await this.kernelManager.getOrCreate(projectId);
373
- return await kernel.getServiceAsync("i18n");
374
- } catch {
375
- return void 0;
515
+ async resolveI18nService(projectId, req) {
516
+ if (projectId === "platform") return void 0;
517
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
518
+ const host = this.extractHostname(req);
519
+ if (host) {
520
+ try {
521
+ const result = await this.envRegistry.resolveByHostname(host);
522
+ if (result?.projectId) projectId = result.projectId;
523
+ } catch {
524
+ }
525
+ }
526
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
527
+ const headerVal = this.extractProjectIdHeader(req);
528
+ if (headerVal) {
529
+ try {
530
+ const driver = await this.envRegistry.resolveById(headerVal);
531
+ if (driver) projectId = headerVal;
532
+ } catch {
533
+ }
534
+ }
535
+ }
536
+ }
537
+ if (!projectId && this.defaultProjectIdProvider) {
538
+ try {
539
+ const def = this.defaultProjectIdProvider();
540
+ if (def) projectId = def;
541
+ } catch {
542
+ }
543
+ }
544
+ if (projectId && this.kernelManager) {
545
+ try {
546
+ const kernel = await this.kernelManager.getOrCreate(projectId);
547
+ const svc = await kernel.getServiceAsync("i18n");
548
+ if (svc) return svc;
549
+ } catch {
550
+ }
551
+ }
552
+ if (this.i18nServiceProvider) {
553
+ try {
554
+ return await this.i18nServiceProvider(projectId);
555
+ } catch {
556
+ return void 0;
557
+ }
376
558
  }
559
+ return void 0;
560
+ }
561
+ /**
562
+ * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
563
+ * Returns `true` if the response was sent and the caller should stop
564
+ * processing. Returns `false` to continue.
565
+ *
566
+ * The check is intentionally narrow: only `context?.userId` counts as
567
+ * "authenticated". `isSystem` flags are never set on inbound HTTP
568
+ * requests (they're internal-only), so they cannot bypass this gate.
569
+ */
570
+ enforceAuth(req, res, context) {
571
+ if (!this.config.api.requireAuth) return false;
572
+ if (context?.userId) return false;
573
+ if (req?.method === "OPTIONS") return false;
574
+ res.status(401).json({
575
+ error: "unauthenticated",
576
+ message: "Authentication is required to access this endpoint."
577
+ });
578
+ return true;
377
579
  }
378
580
  /**
379
581
  * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
@@ -383,6 +585,26 @@ var RestServer = class {
383
585
  */
384
586
  async resolveExecCtx(projectId, req) {
385
587
  try {
588
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
589
+ const host = this.extractHostname(req);
590
+ if (host) {
591
+ try {
592
+ const result = await this.envRegistry.resolveByHostname(host);
593
+ if (result?.projectId) projectId = result.projectId;
594
+ } catch {
595
+ }
596
+ }
597
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
598
+ const headerVal = this.extractProjectIdHeader(req);
599
+ if (headerVal) {
600
+ try {
601
+ const driver = await this.envRegistry.resolveById(headerVal);
602
+ if (driver) projectId = headerVal;
603
+ } catch {
604
+ }
605
+ }
606
+ }
607
+ }
386
608
  let authService;
387
609
  let kernel;
388
610
  if (projectId && projectId !== "platform" && this.kernelManager) {
@@ -536,8 +758,8 @@ var RestServer = class {
536
758
  */
537
759
  async translateMetaItem(req, type, projectId, item) {
538
760
  if (!item || typeof item !== "object") return item;
539
- if (type !== "view" && type !== "action") return item;
540
- const i18n = await this.resolveI18nService(projectId);
761
+ if (type !== "view" && type !== "action" && type !== "object") return item;
762
+ const i18n = await this.resolveI18nService(projectId, req);
541
763
  const bundle = this.buildTranslationBundle(i18n);
542
764
  if (!bundle) return item;
543
765
  const locale = this.extractLocale(req, i18n);
@@ -550,8 +772,8 @@ var RestServer = class {
550
772
  */
551
773
  async translateMetaItems(req, type, projectId, items) {
552
774
  if (!Array.isArray(items)) return items;
553
- if (type !== "view" && type !== "action") return items;
554
- const i18n = await this.resolveI18nService(projectId);
775
+ if (type !== "view" && type !== "action" && type !== "object") return items;
776
+ const i18n = await this.resolveI18nService(projectId, req);
555
777
  const bundle = this.buildTranslationBundle(i18n);
556
778
  if (!bundle) return items;
557
779
  const locale = this.extractLocale(req, i18n);
@@ -622,8 +844,10 @@ var RestServer = class {
622
844
  enableUi: api.enableUi ?? true,
623
845
  enableBatch: api.enableBatch ?? true,
624
846
  enableDiscovery: api.enableDiscovery ?? true,
847
+ enableSearch: api.enableSearch ?? true,
625
848
  enableProjectScoping: api.enableProjectScoping ?? false,
626
849
  projectResolution: api.projectResolution ?? "auto",
850
+ requireAuth: api.requireAuth ?? false,
627
851
  documentation: api.documentation,
628
852
  responseFormat: api.responseFormat
629
853
  },
@@ -705,9 +929,19 @@ var RestServer = class {
705
929
  if (this.config.api.enableUi) {
706
930
  this.registerUiEndpoints(bp);
707
931
  }
932
+ if (this.config.api.enableSearch ?? true) {
933
+ this.registerSearchEndpoints(bp);
934
+ }
935
+ this.registerEmailEndpoints(bp);
936
+ this.registerFormEndpoints(bp);
937
+ this.registerSharingEndpoints(bp);
938
+ this.registerSharingRuleEndpoints(bp);
939
+ this.registerReportsEndpoints(bp);
940
+ this.registerApprovalsEndpoints(bp);
708
941
  if (this.config.api.enableCrud) {
709
942
  this.registerCrudEndpoints(bp);
710
943
  }
944
+ this.registerDataActionEndpoints(bp);
711
945
  if (this.config.api.enableBatch) {
712
946
  this.registerBatchEndpoints(bp);
713
947
  }
@@ -758,7 +992,7 @@ var RestServer = class {
758
992
  res.json(discovery);
759
993
  } catch (error) {
760
994
  logError("[REST] Unhandled error:", error);
761
- res.status(500).json({ error: error.message });
995
+ sendError(res, error);
762
996
  }
763
997
  };
764
998
  this.routeManager.register({
@@ -799,7 +1033,7 @@ var RestServer = class {
799
1033
  res.json(types);
800
1034
  } catch (error) {
801
1035
  logError("[REST] Unhandled error:", error);
802
- res.status(500).json({ error: error.message });
1036
+ sendError(res, error);
803
1037
  }
804
1038
  },
805
1039
  metadata: {
@@ -827,7 +1061,7 @@ var RestServer = class {
827
1061
  res.json(translated);
828
1062
  } catch (error) {
829
1063
  logError("[REST] Unhandled error:", error);
830
- res.status(404).json({ error: error.message });
1064
+ sendError(res, error);
831
1065
  }
832
1066
  },
833
1067
  metadata: {
@@ -885,7 +1119,7 @@ var RestServer = class {
885
1119
  }
886
1120
  } catch (error) {
887
1121
  logError("[REST] Unhandled error:", error);
888
- res.status(404).json({ error: error.message });
1122
+ sendError(res, error);
889
1123
  }
890
1124
  },
891
1125
  metadata: {
@@ -905,16 +1139,18 @@ var RestServer = class {
905
1139
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
906
1140
  return;
907
1141
  }
1142
+ const body = req.body ?? {};
1143
+ const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
908
1144
  const result = await p.saveMetaItem({
909
1145
  type: req.params.type,
910
1146
  name: req.params.name,
911
- item: req.body,
1147
+ item,
912
1148
  ...projectId ? { projectId } : {}
913
1149
  });
914
1150
  res.json(result);
915
1151
  } catch (error) {
916
1152
  logError("[REST] Unhandled error:", error);
917
- res.status(400).json({ error: error.message });
1153
+ sendError(res, error);
918
1154
  }
919
1155
  },
920
1156
  metadata: {
@@ -922,6 +1158,92 @@ var RestServer = class {
922
1158
  tags: ["metadata"]
923
1159
  }
924
1160
  });
1161
+ this.routeManager.register({
1162
+ method: "DELETE",
1163
+ path: `${metaPath}/:type/:name`,
1164
+ handler: async (req, res) => {
1165
+ try {
1166
+ const projectId = isScoped ? req.params?.projectId : void 0;
1167
+ const p = await this.resolveProtocol(projectId, req);
1168
+ if (!p.deleteMetaItem) {
1169
+ res.status(501).json({
1170
+ error: "Reset operation not supported by protocol implementation"
1171
+ });
1172
+ return;
1173
+ }
1174
+ const result = await p.deleteMetaItem({
1175
+ type: req.params.type,
1176
+ name: req.params.name,
1177
+ ...projectId ? { projectId } : {}
1178
+ });
1179
+ res.json(result);
1180
+ } catch (error) {
1181
+ logError("[REST] Unhandled error:", error);
1182
+ sendError(res, error);
1183
+ }
1184
+ },
1185
+ metadata: {
1186
+ summary: "Reset metadata item to artifact default (deletes customization overlay)",
1187
+ tags: ["metadata"]
1188
+ }
1189
+ });
1190
+ if (metadata.endpoints.item !== false) {
1191
+ this.routeManager.register({
1192
+ method: "GET",
1193
+ path: `${metaPath}/:type/:section/:name`,
1194
+ handler: async (req, res) => {
1195
+ try {
1196
+ const projectId = isScoped ? req.params?.projectId : void 0;
1197
+ const p = await this.resolveProtocol(projectId, req);
1198
+ const compoundName = `${req.params.section}/${req.params.name}`;
1199
+ const packageId = req.query?.package || void 0;
1200
+ const item = await p.getMetaItem({
1201
+ type: req.params.type,
1202
+ name: compoundName,
1203
+ packageId
1204
+ });
1205
+ res.header("Vary", "Accept-Language");
1206
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
1207
+ } catch (error) {
1208
+ logError("[REST] Unhandled error:", error);
1209
+ sendError(res, error);
1210
+ }
1211
+ },
1212
+ metadata: {
1213
+ summary: "Get specific metadata item by compound name",
1214
+ tags: ["metadata"]
1215
+ }
1216
+ });
1217
+ }
1218
+ this.routeManager.register({
1219
+ method: "PUT",
1220
+ path: `${metaPath}/:type/:section/:name`,
1221
+ handler: async (req, res) => {
1222
+ try {
1223
+ const projectId = isScoped ? req.params?.projectId : void 0;
1224
+ const p = await this.resolveProtocol(projectId, req);
1225
+ if (!p.saveMetaItem) {
1226
+ res.status(501).json({ error: "Save operation not supported by protocol implementation" });
1227
+ return;
1228
+ }
1229
+ const compoundName = `${req.params.section}/${req.params.name}`;
1230
+ const result = await p.saveMetaItem({
1231
+ type: req.params.type,
1232
+ name: compoundName,
1233
+ item: req.body,
1234
+ ...projectId ? { projectId } : {}
1235
+ });
1236
+ res.json(result);
1237
+ } catch (error) {
1238
+ logError("[REST] Unhandled error:", error);
1239
+ sendError(res, error);
1240
+ }
1241
+ },
1242
+ metadata: {
1243
+ summary: "Save specific metadata item by compound name",
1244
+ tags: ["metadata"]
1245
+ }
1246
+ });
925
1247
  }
926
1248
  /**
927
1249
  * Register UI endpoints
@@ -948,7 +1270,7 @@ var RestServer = class {
948
1270
  }
949
1271
  } catch (error) {
950
1272
  logError("[REST] Unhandled error:", error);
951
- res.status(404).json({ error: error.message });
1273
+ sendError(res, error, req.params?.object);
952
1274
  }
953
1275
  },
954
1276
  metadata: {
@@ -974,6 +1296,7 @@ var RestServer = class {
974
1296
  const projectId = isScoped ? req.params?.projectId : void 0;
975
1297
  const p = await this.resolveProtocol(projectId, req);
976
1298
  const context = await this.resolveExecCtx(projectId, req);
1299
+ if (this.enforceAuth(req, res, context)) return;
977
1300
  const result = await p.findData({
978
1301
  object: req.params.object,
979
1302
  query: req.query,
@@ -1007,6 +1330,7 @@ var RestServer = class {
1007
1330
  const p = await this.resolveProtocol(projectId, req);
1008
1331
  const { select, expand } = req.query || {};
1009
1332
  const context = await this.resolveExecCtx(projectId, req);
1333
+ if (this.enforceAuth(req, res, context)) return;
1010
1334
  const result = await p.getData({
1011
1335
  object: req.params.object,
1012
1336
  id: req.params.id,
@@ -1018,7 +1342,7 @@ var RestServer = class {
1018
1342
  res.json(result);
1019
1343
  } catch (error) {
1020
1344
  const mapped = mapDataError(error, req.params?.object);
1021
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1345
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1022
1346
  res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
1023
1347
  }
1024
1348
  },
@@ -1037,6 +1361,7 @@ var RestServer = class {
1037
1361
  const projectId = isScoped ? req.params?.projectId : void 0;
1038
1362
  const p = await this.resolveProtocol(projectId, req);
1039
1363
  const context = await this.resolveExecCtx(projectId, req);
1364
+ if (this.enforceAuth(req, res, context)) return;
1040
1365
  const result = await p.createData({
1041
1366
  object: req.params.object,
1042
1367
  data: req.body,
@@ -1046,7 +1371,7 @@ var RestServer = class {
1046
1371
  res.status(201).json(result);
1047
1372
  } catch (error) {
1048
1373
  const mapped = mapDataError(error, req.params?.object);
1049
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1374
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1050
1375
  res.status(mapped.status).json(mapped.body);
1051
1376
  }
1052
1377
  },
@@ -1056,19 +1381,19 @@ var RestServer = class {
1056
1381
  }
1057
1382
  });
1058
1383
  }
1059
- if (operations.update) {
1384
+ if (operations.list) {
1060
1385
  this.routeManager.register({
1061
- method: "PATCH",
1062
- path: `${dataPath}/:object/:id`,
1386
+ method: "POST",
1387
+ path: `${dataPath}/:object/query`,
1063
1388
  handler: async (req, res) => {
1064
1389
  try {
1065
1390
  const projectId = isScoped ? req.params?.projectId : void 0;
1066
1391
  const p = await this.resolveProtocol(projectId, req);
1067
1392
  const context = await this.resolveExecCtx(projectId, req);
1068
- const result = await p.updateData({
1393
+ if (this.enforceAuth(req, res, context)) return;
1394
+ const result = await p.findData({
1069
1395
  object: req.params.object,
1070
- id: req.params.id,
1071
- data: req.body,
1396
+ query: req.body || {},
1072
1397
  ...projectId ? { projectId } : {},
1073
1398
  ...context ? { context } : {}
1074
1399
  });
@@ -1080,95 +1405,1640 @@ var RestServer = class {
1080
1405
  }
1081
1406
  },
1082
1407
  metadata: {
1083
- summary: "Update record",
1408
+ summary: "Advanced query (QueryAST in body)",
1084
1409
  tags: ["data", "crud"]
1085
1410
  }
1086
1411
  });
1087
1412
  }
1088
- if (operations.delete) {
1413
+ if (operations.update) {
1089
1414
  this.routeManager.register({
1090
- method: "DELETE",
1415
+ method: "PATCH",
1091
1416
  path: `${dataPath}/:object/:id`,
1092
1417
  handler: async (req, res) => {
1093
1418
  try {
1094
1419
  const projectId = isScoped ? req.params?.projectId : void 0;
1095
1420
  const p = await this.resolveProtocol(projectId, req);
1096
1421
  const context = await this.resolveExecCtx(projectId, req);
1097
- const result = await p.deleteData({
1422
+ if (this.enforceAuth(req, res, context)) return;
1423
+ const result = await p.updateData({
1098
1424
  object: req.params.object,
1099
1425
  id: req.params.id,
1426
+ data: req.body,
1100
1427
  ...projectId ? { projectId } : {},
1101
1428
  ...context ? { context } : {}
1102
1429
  });
1103
1430
  res.json(result);
1104
1431
  } catch (error) {
1105
1432
  const mapped = mapDataError(error, req.params?.object);
1106
- if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1433
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1107
1434
  res.status(mapped.status).json(mapped.body);
1108
1435
  }
1109
1436
  },
1110
1437
  metadata: {
1111
- summary: "Delete record",
1438
+ summary: "Update record",
1112
1439
  tags: ["data", "crud"]
1113
1440
  }
1114
1441
  });
1115
1442
  }
1116
- }
1117
- /**
1118
- * Register batch operation endpoints
1119
- */
1120
- registerBatchEndpoints(basePath) {
1121
- const { crud, batch } = this.config;
1122
- const dataPath = `${basePath}${crud.dataPrefix}`;
1123
- const isScoped = basePath.includes("/projects/:projectId");
1124
- const operations = batch.operations;
1125
- if (batch.enableBatchEndpoint && this.protocol.batchData) {
1443
+ if (operations.delete) {
1126
1444
  this.routeManager.register({
1127
- method: "POST",
1128
- path: `${dataPath}/:object/batch`,
1445
+ method: "DELETE",
1446
+ path: `${dataPath}/:object/:id`,
1129
1447
  handler: async (req, res) => {
1130
1448
  try {
1131
1449
  const projectId = isScoped ? req.params?.projectId : void 0;
1132
1450
  const p = await this.resolveProtocol(projectId, req);
1133
- const result = await p.batchData({
1451
+ const context = await this.resolveExecCtx(projectId, req);
1452
+ if (this.enforceAuth(req, res, context)) return;
1453
+ const result = await p.deleteData({
1134
1454
  object: req.params.object,
1135
- request: req.body,
1136
- ...projectId ? { projectId } : {}
1455
+ id: req.params.id,
1456
+ ...projectId ? { projectId } : {},
1457
+ ...context ? { context } : {}
1137
1458
  });
1138
1459
  res.json(result);
1139
1460
  } catch (error) {
1140
- logError("[REST] Unhandled error:", error);
1141
- res.status(400).json({ error: error.message });
1461
+ const mapped = mapDataError(error, req.params?.object);
1462
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1463
+ res.status(mapped.status).json(mapped.body);
1142
1464
  }
1143
1465
  },
1144
1466
  metadata: {
1145
- summary: "Batch operations",
1146
- tags: ["data", "batch"]
1467
+ summary: "Delete record",
1468
+ tags: ["data", "crud"]
1147
1469
  }
1148
1470
  });
1149
1471
  }
1150
- if (operations.createMany && this.protocol.createManyData) {
1151
- this.routeManager.register({
1152
- method: "POST",
1153
- path: `${dataPath}/:object/createMany`,
1154
- handler: async (req, res) => {
1155
- try {
1156
- const projectId = isScoped ? req.params?.projectId : void 0;
1157
- const p = await this.resolveProtocol(projectId, req);
1158
- const result = await p.createManyData({
1159
- object: req.params.object,
1160
- records: req.body || [],
1161
- ...projectId ? { projectId } : {}
1162
- });
1163
- res.status(201).json(result);
1164
- } catch (error) {
1165
- logError("[REST] Unhandled error:", error);
1166
- res.status(400).json({ error: error.message });
1472
+ }
1473
+ /**
1474
+ * Register object-specific action endpoints that don't fit the
1475
+ * generic CRUD shape. These are domain operations (Salesforce
1476
+ * convertLead, etc.) where the protocol implementation does its own
1477
+ * multi-record orchestration and we just need a thin HTTP route.
1478
+ *
1479
+ * POST {basePath}/data/lead/:id/convert M10.6 lead conversion.
1480
+ */
1481
+ registerDataActionEndpoints(basePath) {
1482
+ const isScoped = basePath.includes("/projects/:projectId");
1483
+ const { crud } = this.config;
1484
+ const dataPath = `${basePath}${crud.dataPrefix}`;
1485
+ this.routeManager.register({
1486
+ method: "POST",
1487
+ path: `${dataPath}/lead/:id/convert`,
1488
+ handler: async (req, res) => {
1489
+ try {
1490
+ const projectId = isScoped ? req.params?.projectId : void 0;
1491
+ const p = await this.resolveProtocol(projectId, req);
1492
+ const context = await this.resolveExecCtx(projectId, req);
1493
+ if (this.enforceAuth(req, res, context)) return;
1494
+ const convertLead = p.convertLead;
1495
+ if (typeof convertLead !== "function") {
1496
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
1497
+ return;
1167
1498
  }
1168
- },
1169
- metadata: {
1170
- summary: "Create multiple records",
1171
- tags: ["data", "batch"]
1499
+ const body = req.body ?? {};
1500
+ const result = await convertLead.call(p, {
1501
+ leadId: req.params.id,
1502
+ accountId: body.accountId,
1503
+ contactId: body.contactId,
1504
+ createOpportunity: body.createOpportunity,
1505
+ opportunity: body.opportunity,
1506
+ convertedStatus: body.convertedStatus,
1507
+ ...context ? { context } : {}
1508
+ });
1509
+ res.json(result);
1510
+ } catch (error) {
1511
+ logError("[REST] Unhandled error:", error);
1512
+ sendError(res, error, "lead");
1513
+ }
1514
+ },
1515
+ metadata: {
1516
+ summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
1517
+ tags: ["data", "lead"]
1518
+ }
1519
+ });
1520
+ this.routeManager.register({
1521
+ method: "POST",
1522
+ path: `${dataPath}/:object/import`,
1523
+ handler: async (req, res) => {
1524
+ try {
1525
+ const projectId = isScoped ? req.params?.projectId : void 0;
1526
+ const p = await this.resolveProtocol(projectId, req);
1527
+ const context = await this.resolveExecCtx(projectId, req);
1528
+ if (this.enforceAuth(req, res, context)) return;
1529
+ const objectName = String(req.params.object || "");
1530
+ if (!objectName) {
1531
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1532
+ return;
1533
+ }
1534
+ const body = req.body ?? {};
1535
+ const dryRun = body.dryRun === true;
1536
+ const mapping = body.mapping ?? {};
1537
+ let rows = [];
1538
+ if (body.format === "json" && Array.isArray(body.rows)) {
1539
+ rows = body.rows;
1540
+ } else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
1541
+ rows = parseCsvToRows(body.csv, mapping);
1542
+ } else if (Array.isArray(body)) {
1543
+ rows = body;
1544
+ } else {
1545
+ res.status(400).json({
1546
+ code: "INVALID_REQUEST",
1547
+ error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
1548
+ });
1549
+ return;
1550
+ }
1551
+ const max = 5e3;
1552
+ if (rows.length > max) {
1553
+ res.status(413).json({
1554
+ code: "PAYLOAD_TOO_LARGE",
1555
+ error: `Import limit is ${max} rows per request (got ${rows.length})`
1556
+ });
1557
+ return;
1558
+ }
1559
+ const results = [];
1560
+ let okCount = 0;
1561
+ let errCount = 0;
1562
+ for (let i = 0; i < rows.length; i++) {
1563
+ const data = rows[i];
1564
+ try {
1565
+ if (dryRun) {
1566
+ const validate = p.validate;
1567
+ if (typeof validate === "function") {
1568
+ await validate.call(p, { object: objectName, data, context });
1569
+ }
1570
+ results.push({ row: i + 1, ok: true });
1571
+ okCount++;
1572
+ } else {
1573
+ const created = await p.createData({ object: objectName, data, context });
1574
+ const id = created?.id ?? created?.record?.id;
1575
+ results.push({ row: i + 1, ok: true, id });
1576
+ okCount++;
1577
+ }
1578
+ } catch (err) {
1579
+ errCount++;
1580
+ const code = err?.code ?? "IMPORT_ROW_FAILED";
1581
+ const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
1582
+ results.push({ row: i + 1, ok: false, error: message, code });
1583
+ }
1584
+ }
1585
+ res.json({
1586
+ object: objectName,
1587
+ dryRun,
1588
+ total: rows.length,
1589
+ ok: okCount,
1590
+ errors: errCount,
1591
+ results
1592
+ });
1593
+ } catch (error) {
1594
+ logError("[REST] Unhandled error:", error);
1595
+ sendError(res, error, String(req.params?.object || ""));
1596
+ }
1597
+ },
1598
+ metadata: {
1599
+ summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
1600
+ tags: ["data", "import"]
1601
+ }
1602
+ });
1603
+ this.routeManager.register({
1604
+ method: "GET",
1605
+ path: `${dataPath}/:object/export`,
1606
+ handler: async (req, res) => {
1607
+ try {
1608
+ const projectId = isScoped ? req.params?.projectId : void 0;
1609
+ const p = await this.resolveProtocol(projectId, req);
1610
+ const context = await this.resolveExecCtx(projectId, req);
1611
+ if (this.enforceAuth(req, res, context)) return;
1612
+ const objectName = String(req.params.object || "");
1613
+ if (!objectName) {
1614
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1615
+ return;
1616
+ }
1617
+ const q = req.query ?? {};
1618
+ const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
1619
+ const HARD_CAP = 5e4;
1620
+ const MAX_CHUNK = 5e3;
1621
+ const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
1622
+ const limit = Math.min(requestedLimit, HARD_CAP);
1623
+ const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
1624
+ let filter = void 0;
1625
+ if (typeof q.filter === "string" && q.filter.length > 0) {
1626
+ try {
1627
+ filter = JSON.parse(q.filter);
1628
+ } catch {
1629
+ res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
1630
+ return;
1631
+ }
1632
+ } else if (q.filter && typeof q.filter === "object") {
1633
+ filter = q.filter;
1634
+ }
1635
+ let orderby = void 0;
1636
+ if (typeof q.orderby === "string" && q.orderby.length > 0) {
1637
+ if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
1638
+ try {
1639
+ orderby = JSON.parse(q.orderby);
1640
+ } catch {
1641
+ }
1642
+ } else {
1643
+ const obj = {};
1644
+ for (const part of q.orderby.split(",")) {
1645
+ const [field, dir] = part.split(":").map((s) => s.trim());
1646
+ if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
1647
+ }
1648
+ if (Object.keys(obj).length > 0) orderby = obj;
1649
+ }
1650
+ }
1651
+ let fields;
1652
+ if (typeof q.fields === "string" && q.fields.length > 0) {
1653
+ fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
1654
+ } else if (Array.isArray(q.fields)) {
1655
+ fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
1656
+ }
1657
+ if (!fields || fields.length === 0) {
1658
+ try {
1659
+ const schema = await p.getObjectSchema?.(objectName, projectId);
1660
+ const schemaFields = schema?.fields;
1661
+ if (Array.isArray(schemaFields)) {
1662
+ fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
1663
+ }
1664
+ } catch {
1665
+ }
1666
+ }
1667
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1668
+ const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
1669
+ if (format === "csv") {
1670
+ res.header("Content-Type", "text/csv; charset=utf-8");
1671
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
1672
+ } else {
1673
+ res.header("Content-Type", "application/json; charset=utf-8");
1674
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
1675
+ }
1676
+ res.header("X-Export-Format", format);
1677
+ res.header("X-Export-Limit", String(limit));
1678
+ res.header("Cache-Control", "no-store");
1679
+ let exported = 0;
1680
+ let firstChunk = true;
1681
+ let skip = 0;
1682
+ if (format === "json") res.write("[");
1683
+ while (exported < limit) {
1684
+ const take = Math.min(chunkSize, limit - exported);
1685
+ const findArgs = {
1686
+ object: objectName,
1687
+ query: {
1688
+ ...filter ? { $filter: filter } : {},
1689
+ ...orderby ? { $orderby: orderby } : {},
1690
+ $top: take,
1691
+ $skip: skip
1692
+ },
1693
+ ...projectId ? { projectId } : {},
1694
+ ...context ? { context } : {}
1695
+ };
1696
+ const result = await p.findData(findArgs);
1697
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
1698
+ if (rows.length === 0) break;
1699
+ if (format === "csv") {
1700
+ if ((!fields || fields.length === 0) && firstChunk) {
1701
+ fields = Object.keys(rows[0] ?? {});
1702
+ }
1703
+ const text = rowsToCsv(fields ?? [], rows, firstChunk);
1704
+ res.write(text);
1705
+ } else {
1706
+ for (let i = 0; i < rows.length; i++) {
1707
+ const prefix = firstChunk && i === 0 ? "" : ",";
1708
+ res.write(prefix + JSON.stringify(rows[i]));
1709
+ }
1710
+ }
1711
+ firstChunk = false;
1712
+ exported += rows.length;
1713
+ skip += rows.length;
1714
+ if (rows.length < take) break;
1715
+ }
1716
+ if (format === "json") res.write("]");
1717
+ res.end();
1718
+ } catch (error) {
1719
+ logError("[REST] Unhandled error:", error);
1720
+ try {
1721
+ sendError(res, error, String(req.params?.object || ""));
1722
+ } catch {
1723
+ try {
1724
+ res.end();
1725
+ } catch {
1726
+ }
1727
+ }
1728
+ }
1729
+ },
1730
+ metadata: {
1731
+ summary: "Streaming export of object rows (CSV or JSON)",
1732
+ tags: ["data", "export"]
1733
+ }
1734
+ });
1735
+ }
1736
+ /**
1737
+ * Register global cross-object search endpoint (M10.5).
1738
+ * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
1739
+ */
1740
+ registerSearchEndpoints(basePath) {
1741
+ const isScoped = basePath.includes("/projects/:projectId");
1742
+ this.routeManager.register({
1743
+ method: "GET",
1744
+ path: `${basePath}/search`,
1745
+ handler: async (req, res) => {
1746
+ try {
1747
+ const projectId = isScoped ? req.params?.projectId : void 0;
1748
+ const p = await this.resolveProtocol(projectId, req);
1749
+ const context = await this.resolveExecCtx(projectId, req);
1750
+ if (this.enforceAuth(req, res, context)) return;
1751
+ const searchAll = p.searchAll;
1752
+ if (typeof searchAll !== "function") {
1753
+ res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
1754
+ return;
1755
+ }
1756
+ const q = String(req.query?.q ?? req.query?.query ?? "");
1757
+ const objectsParam = req.query?.objects;
1758
+ const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
1759
+ const result = await searchAll.call(p, {
1760
+ q,
1761
+ objects,
1762
+ limit: req.query?.limit ? Number(req.query.limit) : void 0,
1763
+ perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
1764
+ ...context ? { context } : {}
1765
+ });
1766
+ res.json(result);
1767
+ } catch (error) {
1768
+ const mapped = mapDataError(error);
1769
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
1770
+ logError("[REST] Unhandled error:", error);
1771
+ }
1772
+ res.status(mapped.status).json(mapped.body);
1773
+ }
1774
+ },
1775
+ metadata: {
1776
+ summary: "Global cross-object search",
1777
+ tags: ["search"]
1778
+ }
1779
+ });
1780
+ }
1781
+ /**
1782
+ * Register email endpoints (M11.B1 / M10.7).
1783
+ *
1784
+ * POST {basePath}/email/send — send a transactional email via the
1785
+ * `IEmailService` provider registered by EmailServicePlugin. Returns
1786
+ * 501 when no provider is wired so deployments without email
1787
+ * configured fail cleanly.
1788
+ *
1789
+ * Request body:
1790
+ * {
1791
+ * to: "a@b.com" | ["a@b.com", { name, address }],
1792
+ * from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
1793
+ * subject: string,
1794
+ * text?: string, html?: string, // at least one required
1795
+ * attachments?: [{ filename, content, contentType?, cid? }],
1796
+ * headers?: { [name]: value },
1797
+ * relatedObject?: string, relatedId?: string,
1798
+ * }
1799
+ */
1800
+ registerEmailEndpoints(basePath) {
1801
+ const isScoped = basePath.includes("/projects/:projectId");
1802
+ this.routeManager.register({
1803
+ method: "POST",
1804
+ path: `${basePath}/email/send`,
1805
+ handler: async (req, res) => {
1806
+ try {
1807
+ const projectId = isScoped ? req.params?.projectId : void 0;
1808
+ const context = await this.resolveExecCtx(projectId, req);
1809
+ if (this.enforceAuth(req, res, context)) return;
1810
+ if (!this.emailServiceProvider) {
1811
+ res.status(501).json({
1812
+ code: "NOT_IMPLEMENTED",
1813
+ message: "Email service is not configured on this deployment"
1814
+ });
1815
+ return;
1816
+ }
1817
+ const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
1818
+ if (!emailService || typeof emailService.send !== "function") {
1819
+ res.status(501).json({
1820
+ code: "NOT_IMPLEMENTED",
1821
+ message: "Email service is not configured on this deployment"
1822
+ });
1823
+ return;
1824
+ }
1825
+ const body = req.body ?? {};
1826
+ if (!body || typeof body !== "object") {
1827
+ res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
1828
+ return;
1829
+ }
1830
+ const input = {
1831
+ ...body,
1832
+ ...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
1833
+ };
1834
+ try {
1835
+ const result = await emailService.send(input);
1836
+ if (result?.status === "sent") {
1837
+ res.status(200).json(result);
1838
+ } else {
1839
+ res.status(200).json(result);
1840
+ }
1841
+ } catch (err) {
1842
+ const message = String(err?.message ?? err ?? "send failed");
1843
+ if (message.startsWith("VALIDATION_FAILED")) {
1844
+ res.status(400).json({
1845
+ code: "VALIDATION_FAILED",
1846
+ error: message.replace(/^VALIDATION_FAILED:\s*/, "")
1847
+ });
1848
+ return;
1849
+ }
1850
+ throw err;
1851
+ }
1852
+ } catch (error) {
1853
+ logError("[REST] Email send unhandled error:", error);
1854
+ res.status(500).json({
1855
+ code: "EMAIL_SEND_FAILED",
1856
+ error: String(error?.message ?? error ?? "send failed").slice(0, 500)
1857
+ });
1858
+ }
1859
+ },
1860
+ metadata: {
1861
+ summary: "Send a transactional email via the configured EmailService",
1862
+ tags: ["email"]
1863
+ }
1864
+ });
1865
+ }
1866
+ /**
1867
+ * Register public (anonymous) form endpoints.
1868
+ *
1869
+ * Public forms are opt-in: a `FormView` becomes accessible to anonymous
1870
+ * visitors only when `sharing.allowAnonymous === true` AND a
1871
+ * `sharing.publicLink` slug is configured. Two routes are registered:
1872
+ *
1873
+ * GET {basePath}/forms/:slug → resolved form spec
1874
+ * POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
1875
+ *
1876
+ * Both routes bypass `enforceAuth` even when `requireAuth=true` on the
1877
+ * deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
1878
+ * `guest_portal` permission set carried on the execution context — the
1879
+ * SecurityPlugin enforces INSERT-only access to the target object. If
1880
+ * the deployment hasn't registered a `guest_portal` profile, the
1881
+ * security middleware falls open with `permissions: []` (no userId),
1882
+ * matching the existing anonymous-access semantics; deployers must
1883
+ * keep `requireAuth=true` deployments paired with a `guest_portal`
1884
+ * profile (the CRM example does this) to enforce the INSERT-only
1885
+ * contract.
1886
+ *
1887
+ * The matched FormView's parent ViewSchema is found by scanning
1888
+ * `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
1889
+ * `form.sharing` and every entry in `formViews`; the first FormView
1890
+ * whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
1891
+ * wins. The response carries the matched form view under `form` and
1892
+ * the inferred target object, matching what the frontend's
1893
+ * `mapViewSpecToEmbeddableConfig` expects.
1894
+ */
1895
+ registerFormEndpoints(basePath) {
1896
+ const isScoped = basePath.includes("/projects/:projectId");
1897
+ const slugMatchesPublicLink = (publicLink, slug) => {
1898
+ if (!publicLink || typeof publicLink !== "string") return false;
1899
+ const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
1900
+ return normalized === slug;
1901
+ };
1902
+ const findPublicFormView = (views, slug) => {
1903
+ for (const view of views ?? []) {
1904
+ if (!view || typeof view !== "object") continue;
1905
+ const candidates = [];
1906
+ if (view.form && view.form.sharing) candidates.push({ form: view.form });
1907
+ const formViews = view.formViews;
1908
+ if (formViews && typeof formViews === "object") {
1909
+ for (const [key, fv] of Object.entries(formViews)) {
1910
+ if (fv && typeof fv === "object" && fv.sharing) {
1911
+ candidates.push({ form: fv, key });
1912
+ }
1913
+ }
1914
+ }
1915
+ for (const c of candidates) {
1916
+ const sharing = c.form?.sharing;
1917
+ if (!sharing || sharing.allowAnonymous !== true) continue;
1918
+ if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
1919
+ const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
1920
+ if (!objectName) continue;
1921
+ return { view, form: c.form, object: objectName };
1922
+ }
1923
+ }
1924
+ return null;
1925
+ };
1926
+ const resolveFormBySlug = async (projectId, req, slug) => {
1927
+ const p = await this.resolveProtocol(projectId, req);
1928
+ if (typeof p.getMetaItems !== "function") return null;
1929
+ const result = await p.getMetaItems({
1930
+ type: "view",
1931
+ ...projectId ? { projectId } : {}
1932
+ });
1933
+ const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
1934
+ return findPublicFormView(items, slug);
1935
+ };
1936
+ this.routeManager.register({
1937
+ method: "GET",
1938
+ path: `${basePath}/forms/:slug`,
1939
+ handler: async (req, res) => {
1940
+ try {
1941
+ const projectId = isScoped ? req.params?.projectId : void 0;
1942
+ const slug = String(req.params?.slug ?? "").trim();
1943
+ if (!slug) {
1944
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
1945
+ return;
1946
+ }
1947
+ const match = await resolveFormBySlug(projectId, req, slug);
1948
+ if (!match) {
1949
+ res.status(404).json({
1950
+ code: "FORM_NOT_FOUND",
1951
+ error: `No public form configured at /forms/${slug}`
1952
+ });
1953
+ return;
1954
+ }
1955
+ let objectSchema = null;
1956
+ try {
1957
+ const p = await this.resolveProtocol(projectId, req);
1958
+ if (typeof p.getMetaItems === "function") {
1959
+ const r = await p.getMetaItems({
1960
+ type: "object",
1961
+ ...projectId ? { projectId } : {}
1962
+ });
1963
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1964
+ const obj = items.find((o) => o?.name === match.object);
1965
+ if (obj && obj.fields && typeof obj.fields === "object") {
1966
+ const allowed = /* @__PURE__ */ new Set();
1967
+ for (const sec of match.form?.sections ?? []) {
1968
+ for (const f of sec?.fields ?? []) {
1969
+ if (typeof f === "string") allowed.add(f);
1970
+ else if (f?.field) allowed.add(f.field);
1971
+ }
1972
+ }
1973
+ const fields = {};
1974
+ for (const [name, def] of Object.entries(obj.fields)) {
1975
+ if (allowed.size === 0 || allowed.has(name)) {
1976
+ fields[name] = def;
1977
+ }
1978
+ }
1979
+ objectSchema = { name: obj.name, label: obj.label, fields };
1980
+ try {
1981
+ const i18n = await this.resolveI18nService(projectId, req);
1982
+ const bundle = this.buildTranslationBundle(i18n);
1983
+ const locale = this.extractLocale(req, i18n);
1984
+ if (bundle && locale) {
1985
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
1986
+ objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
1987
+ }
1988
+ } catch (e) {
1989
+ logError("[REST] Public form schema translation failed:", e);
1990
+ }
1991
+ }
1992
+ }
1993
+ } catch (e) {
1994
+ logError("[REST] Public form schema load failed:", e);
1995
+ }
1996
+ const safeForm = (() => {
1997
+ if (!match.form || !Array.isArray(match.form.sections)) return match.form;
1998
+ const allow = (name, cfg) => {
1999
+ const def = objectSchema?.fields?.[name];
2000
+ const t = def?.type;
2001
+ if (t !== "lookup" && t !== "master_detail") return true;
2002
+ return !!cfg?.publicPicker;
2003
+ };
2004
+ const sections = match.form.sections.map((sec) => {
2005
+ const fields = (sec?.fields ?? []).filter((f) => {
2006
+ const name = typeof f === "string" ? f : f?.field;
2007
+ if (!name) return false;
2008
+ const cfg = typeof f === "string" ? {} : f;
2009
+ return allow(name, cfg);
2010
+ });
2011
+ return { ...sec, fields };
2012
+ });
2013
+ return { ...match.form, sections };
2014
+ })();
2015
+ res.header("Vary", "Accept-Language");
2016
+ res.json({
2017
+ slug,
2018
+ object: match.object,
2019
+ label: match.view?.label ?? match.form?.label,
2020
+ form: safeForm,
2021
+ objectSchema
2022
+ });
2023
+ } catch (error) {
2024
+ logError("[REST] Public form resolve error:", error);
2025
+ res.status(500).json({
2026
+ code: "FORM_RESOLVE_FAILED",
2027
+ error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
2028
+ });
2029
+ }
2030
+ },
2031
+ metadata: {
2032
+ summary: "Resolve a public form spec by slug (anonymous)",
2033
+ tags: ["forms", "public"]
2034
+ }
2035
+ });
2036
+ this.routeManager.register({
2037
+ method: "POST",
2038
+ path: `${basePath}/forms/:slug/submit`,
2039
+ handler: async (req, res) => {
2040
+ try {
2041
+ const projectId = isScoped ? req.params?.projectId : void 0;
2042
+ const slug = String(req.params?.slug ?? "").trim();
2043
+ if (!slug) {
2044
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
2045
+ return;
2046
+ }
2047
+ const match = await resolveFormBySlug(projectId, req, slug);
2048
+ if (!match) {
2049
+ res.status(404).json({
2050
+ code: "FORM_NOT_FOUND",
2051
+ error: `No public form configured at /forms/${slug}`
2052
+ });
2053
+ return;
2054
+ }
2055
+ const allowedFields = /* @__PURE__ */ new Set();
2056
+ for (const section of match.form?.sections ?? []) {
2057
+ for (const f of section?.fields ?? []) {
2058
+ if (typeof f === "string") allowedFields.add(f);
2059
+ else if (f?.field) allowedFields.add(f.field);
2060
+ }
2061
+ }
2062
+ const rawBody = req.body && typeof req.body === "object" ? req.body : {};
2063
+ const filteredData = {};
2064
+ if (allowedFields.size > 0) {
2065
+ for (const [k, v] of Object.entries(rawBody)) {
2066
+ if (allowedFields.has(k)) filteredData[k] = v;
2067
+ }
2068
+ } else {
2069
+ Object.assign(filteredData, rawBody);
2070
+ }
2071
+ const context = {
2072
+ permissions: ["guest_portal"],
2073
+ anonymous: true
2074
+ };
2075
+ const p = await this.resolveProtocol(projectId, req);
2076
+ const result = await p.createData({
2077
+ object: match.object,
2078
+ data: filteredData,
2079
+ ...projectId ? { projectId } : {},
2080
+ context
2081
+ });
2082
+ res.status(201).json(result);
2083
+ } catch (error) {
2084
+ const mapped = mapDataError(error);
2085
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
2086
+ logError("[REST] Public form submit error:", error);
2087
+ }
2088
+ res.status(mapped.status).json(mapped.body);
2089
+ }
2090
+ },
2091
+ metadata: {
2092
+ summary: "Submit an anonymous public form",
2093
+ tags: ["forms", "public"]
2094
+ }
2095
+ });
2096
+ this.routeManager.register({
2097
+ method: "GET",
2098
+ path: `${basePath}/forms/:slug/lookup/:field`,
2099
+ handler: async (req, res) => {
2100
+ try {
2101
+ const projectId = isScoped ? req.params?.projectId : void 0;
2102
+ const slug = String(req.params?.slug ?? "").trim();
2103
+ const fieldName = String(req.params?.field ?? "").trim();
2104
+ if (!slug || !fieldName) {
2105
+ res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
2106
+ return;
2107
+ }
2108
+ const match = await resolveFormBySlug(projectId, req, slug);
2109
+ if (!match) {
2110
+ res.status(404).json({
2111
+ code: "FORM_NOT_FOUND",
2112
+ error: `No public form configured at /forms/${slug}`
2113
+ });
2114
+ return;
2115
+ }
2116
+ let fieldCfg = null;
2117
+ for (const sec of match.form?.sections ?? []) {
2118
+ for (const f of sec?.fields ?? []) {
2119
+ const name = typeof f === "string" ? f : f?.field;
2120
+ if (name === fieldName) {
2121
+ fieldCfg = typeof f === "string" ? {} : f;
2122
+ break;
2123
+ }
2124
+ }
2125
+ if (fieldCfg) break;
2126
+ }
2127
+ const picker = fieldCfg?.publicPicker;
2128
+ if (!picker) {
2129
+ res.status(403).json({
2130
+ code: "LOOKUP_NOT_PUBLIC",
2131
+ error: `Field "${fieldName}" is not enabled for public lookup on this form`
2132
+ });
2133
+ return;
2134
+ }
2135
+ const p = await this.resolveProtocol(projectId, req);
2136
+ let referenceTo = picker.object;
2137
+ if (!referenceTo && typeof p.getMetaItems === "function") {
2138
+ try {
2139
+ const r = await p.getMetaItems({
2140
+ type: "object",
2141
+ ...projectId ? { projectId } : {}
2142
+ });
2143
+ const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
2144
+ const obj = items.find((o) => o?.name === match.object);
2145
+ const def = obj?.fields?.[fieldName];
2146
+ referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
2147
+ } catch {
2148
+ }
2149
+ }
2150
+ if (!referenceTo) {
2151
+ res.status(500).json({
2152
+ code: "LOOKUP_TARGET_MISSING",
2153
+ error: `Could not resolve referenced object for "${fieldName}"`
2154
+ });
2155
+ return;
2156
+ }
2157
+ const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
2158
+ const hardCap = 50;
2159
+ const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
2160
+ const q = String(req.query?.q ?? "").trim().slice(0, 100);
2161
+ const filters = [];
2162
+ if (Array.isArray(picker.filter)) filters.push(...picker.filter);
2163
+ if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
2164
+ const context = {
2165
+ permissions: ["guest_portal"],
2166
+ anonymous: true
2167
+ };
2168
+ const result = await p.findData({
2169
+ object: referenceTo,
2170
+ query: {
2171
+ limit: maxResults,
2172
+ offset: 0,
2173
+ filters,
2174
+ select: ["id", ...displayFields],
2175
+ sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
2176
+ },
2177
+ ...projectId ? { projectId } : {},
2178
+ context
2179
+ });
2180
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
2181
+ const projected = rows.slice(0, maxResults).map((row) => {
2182
+ const out = { id: row?.id };
2183
+ for (const f of displayFields) {
2184
+ if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
2185
+ }
2186
+ return out;
2187
+ });
2188
+ res.json({
2189
+ data: projected,
2190
+ total: projected.length,
2191
+ truncated: rows.length >= maxResults,
2192
+ displayFields
2193
+ });
2194
+ } catch (error) {
2195
+ const mapped = mapDataError(error);
2196
+ if (!isExpectedDataStatus(mapped.status)) {
2197
+ logError("[REST] Public form lookup error:", error);
2198
+ }
2199
+ res.status(mapped.status).json(mapped.body);
2200
+ }
2201
+ },
2202
+ metadata: {
2203
+ summary: "Scoped lookup picker for a public form field (anonymous)",
2204
+ tags: ["forms", "public"]
2205
+ }
2206
+ });
2207
+ }
2208
+ /**
2209
+ * Register record-level sharing endpoints (M11.C17).
2210
+ *
2211
+ * Surfaces `ISharingService` over HTTP so the UI can list, create
2212
+ * and revoke per-record grants without going through ObjectQL. The
2213
+ * three routes mirror the share-management drawer in Salesforce /
2214
+ * ServiceNow:
2215
+ *
2216
+ * GET {basePath}/data/:object/:id/shares
2217
+ * POST {basePath}/data/:object/:id/shares
2218
+ * DELETE {basePath}/data/:object/:id/shares/:shareId
2219
+ *
2220
+ * All three resolve via `sharingServiceProvider`; routes return 501
2221
+ * when no sharing service is configured so a deployment without the
2222
+ * `@objectstack/plugin-sharing` plugin fails cleanly.
2223
+ */
2224
+ registerSharingEndpoints(basePath) {
2225
+ const { crud } = this.config;
2226
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2227
+ const isScoped = basePath.includes("/projects/:projectId");
2228
+ const resolveService = async (projectId) => {
2229
+ if (!this.sharingServiceProvider) return void 0;
2230
+ try {
2231
+ return await this.sharingServiceProvider(projectId);
2232
+ } catch {
2233
+ return void 0;
2234
+ }
2235
+ };
2236
+ const respond501 = (res) => res.status(501).json({
2237
+ code: "NOT_IMPLEMENTED",
2238
+ message: "Sharing service is not configured on this deployment"
2239
+ });
2240
+ this.routeManager.register({
2241
+ method: "GET",
2242
+ path: `${dataPath}/:object/:id/shares`,
2243
+ handler: async (req, res) => {
2244
+ try {
2245
+ const projectId = isScoped ? req.params?.projectId : void 0;
2246
+ const context = await this.resolveExecCtx(projectId, req);
2247
+ if (this.enforceAuth(req, res, context)) return;
2248
+ const svc = await resolveService(projectId);
2249
+ if (!svc) return respond501(res);
2250
+ const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
2251
+ res.json({ data: rows });
2252
+ } catch (error) {
2253
+ logError("[REST] List shares error:", error);
2254
+ res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2255
+ }
2256
+ },
2257
+ metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
2258
+ });
2259
+ this.routeManager.register({
2260
+ method: "POST",
2261
+ path: `${dataPath}/:object/:id/shares`,
2262
+ handler: async (req, res) => {
2263
+ try {
2264
+ const projectId = isScoped ? req.params?.projectId : void 0;
2265
+ const context = await this.resolveExecCtx(projectId, req);
2266
+ if (this.enforceAuth(req, res, context)) return;
2267
+ const svc = await resolveService(projectId);
2268
+ if (!svc) return respond501(res);
2269
+ const body = req.body ?? {};
2270
+ const input = {
2271
+ object: req.params.object,
2272
+ recordId: req.params.id,
2273
+ recipientType: body.recipientType ?? body.recipient_type,
2274
+ recipientId: body.recipientId ?? body.recipient_id,
2275
+ accessLevel: body.accessLevel ?? body.access_level,
2276
+ source: body.source,
2277
+ sourceId: body.sourceId ?? body.source_id,
2278
+ reason: body.reason
2279
+ };
2280
+ try {
2281
+ const row = await svc.grant(input, context ?? {});
2282
+ res.status(201).json(row);
2283
+ } catch (err) {
2284
+ const msg = String(err?.message ?? err ?? "");
2285
+ if (msg.startsWith("VALIDATION_FAILED")) {
2286
+ res.status(400).json({
2287
+ code: "VALIDATION_FAILED",
2288
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2289
+ });
2290
+ return;
2291
+ }
2292
+ throw err;
2293
+ }
2294
+ } catch (error) {
2295
+ logError("[REST] Grant share error:", error);
2296
+ res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2297
+ }
2298
+ },
2299
+ metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
2300
+ });
2301
+ this.routeManager.register({
2302
+ method: "DELETE",
2303
+ path: `${dataPath}/:object/:id/shares/:shareId`,
2304
+ handler: async (req, res) => {
2305
+ try {
2306
+ const projectId = isScoped ? req.params?.projectId : void 0;
2307
+ const context = await this.resolveExecCtx(projectId, req);
2308
+ if (this.enforceAuth(req, res, context)) return;
2309
+ const svc = await resolveService(projectId);
2310
+ if (!svc) return respond501(res);
2311
+ await svc.revoke(req.params.shareId, context ?? {});
2312
+ res.status(204).end();
2313
+ } catch (error) {
2314
+ logError("[REST] Revoke share error:", error);
2315
+ res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2316
+ }
2317
+ },
2318
+ metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
2319
+ });
2320
+ }
2321
+ /**
2322
+ * Register sharing-rule endpoints (M10.17). Mirrors the existing
2323
+ * sharing endpoints but operates on `sys_sharing_rule` rows.
2324
+ *
2325
+ * GET {basePath}/sharing/rules?object=&activeOnly=
2326
+ * POST {basePath}/sharing/rules
2327
+ * GET {basePath}/sharing/rules/:idOrName
2328
+ * DELETE {basePath}/sharing/rules/:idOrName
2329
+ * POST {basePath}/sharing/rules/:idOrName/evaluate
2330
+ *
2331
+ * Returns 501 when no sharing-rule service is configured.
2332
+ */
2333
+ registerSharingRuleEndpoints(basePath) {
2334
+ const dataPath = basePath;
2335
+ const isScoped = basePath.includes("/projects/:projectId");
2336
+ const resolveService = async (projectId) => {
2337
+ if (!this.sharingRulesServiceProvider) return void 0;
2338
+ try {
2339
+ return await this.sharingRulesServiceProvider(projectId);
2340
+ } catch {
2341
+ return void 0;
2342
+ }
2343
+ };
2344
+ const respond501 = (res) => res.status(501).json({
2345
+ code: "NOT_IMPLEMENTED",
2346
+ message: "Sharing-rule service is not configured on this deployment"
2347
+ });
2348
+ const handleError = (err, res, defaultCode) => {
2349
+ const msg = String(err?.message ?? err ?? "");
2350
+ if (msg.startsWith("VALIDATION_FAILED")) {
2351
+ return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
2352
+ }
2353
+ if (msg.startsWith("RULE_NOT_FOUND")) {
2354
+ return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
2355
+ }
2356
+ logError(`[REST] sharing-rule ${defaultCode}:`, err);
2357
+ return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
2358
+ };
2359
+ this.routeManager.register({
2360
+ method: "GET",
2361
+ path: `${dataPath}/sharing/rules`,
2362
+ handler: async (req, res) => {
2363
+ try {
2364
+ const projectId = isScoped ? req.params?.projectId : void 0;
2365
+ const context = await this.resolveExecCtx(projectId, req);
2366
+ if (this.enforceAuth(req, res, context)) return;
2367
+ const svc = await resolveService(projectId);
2368
+ if (!svc) return respond501(res);
2369
+ const rows = await svc.listRules({
2370
+ object: req.query?.object,
2371
+ activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
2372
+ }, context ?? {});
2373
+ res.json({ data: rows });
2374
+ } catch (err) {
2375
+ handleError(err, res, "RULE_LIST_FAILED");
2376
+ }
2377
+ },
2378
+ metadata: { summary: "List sharing rules", tags: ["sharing"] }
2379
+ });
2380
+ this.routeManager.register({
2381
+ method: "POST",
2382
+ path: `${dataPath}/sharing/rules`,
2383
+ handler: async (req, res) => {
2384
+ try {
2385
+ const projectId = isScoped ? req.params?.projectId : void 0;
2386
+ const context = await this.resolveExecCtx(projectId, req);
2387
+ if (this.enforceAuth(req, res, context)) return;
2388
+ const svc = await resolveService(projectId);
2389
+ if (!svc) return respond501(res);
2390
+ const body = req.body ?? {};
2391
+ const input = {
2392
+ name: body.name,
2393
+ label: body.label,
2394
+ description: body.description,
2395
+ object: body.object ?? body.object_name,
2396
+ criteria: body.criteria,
2397
+ recipientType: body.recipientType ?? body.recipient_type,
2398
+ recipientId: body.recipientId ?? body.recipient_id,
2399
+ accessLevel: body.accessLevel ?? body.access_level,
2400
+ active: body.active
2401
+ };
2402
+ const row = await svc.defineRule(input, context ?? {});
2403
+ res.status(201).json(row);
2404
+ } catch (err) {
2405
+ handleError(err, res, "RULE_DEFINE_FAILED");
2406
+ }
2407
+ },
2408
+ metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
2409
+ });
2410
+ this.routeManager.register({
2411
+ method: "GET",
2412
+ path: `${dataPath}/sharing/rules/:idOrName`,
2413
+ handler: async (req, res) => {
2414
+ try {
2415
+ const projectId = isScoped ? req.params?.projectId : void 0;
2416
+ const context = await this.resolveExecCtx(projectId, req);
2417
+ if (this.enforceAuth(req, res, context)) return;
2418
+ const svc = await resolveService(projectId);
2419
+ if (!svc) return respond501(res);
2420
+ const row = await svc.getRule(req.params.idOrName, context ?? {});
2421
+ if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
2422
+ res.json(row);
2423
+ } catch (err) {
2424
+ handleError(err, res, "RULE_GET_FAILED");
2425
+ }
2426
+ },
2427
+ metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
2428
+ });
2429
+ this.routeManager.register({
2430
+ method: "DELETE",
2431
+ path: `${dataPath}/sharing/rules/:idOrName`,
2432
+ handler: async (req, res) => {
2433
+ try {
2434
+ const projectId = isScoped ? req.params?.projectId : void 0;
2435
+ const context = await this.resolveExecCtx(projectId, req);
2436
+ if (this.enforceAuth(req, res, context)) return;
2437
+ const svc = await resolveService(projectId);
2438
+ if (!svc) return respond501(res);
2439
+ await svc.deleteRule(req.params.idOrName, context ?? {});
2440
+ res.status(204).end();
2441
+ } catch (err) {
2442
+ handleError(err, res, "RULE_DELETE_FAILED");
2443
+ }
2444
+ },
2445
+ metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
2446
+ });
2447
+ this.routeManager.register({
2448
+ method: "POST",
2449
+ path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
2450
+ handler: async (req, res) => {
2451
+ try {
2452
+ const projectId = isScoped ? req.params?.projectId : void 0;
2453
+ const context = await this.resolveExecCtx(projectId, req);
2454
+ if (this.enforceAuth(req, res, context)) return;
2455
+ const svc = await resolveService(projectId);
2456
+ if (!svc) return respond501(res);
2457
+ const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
2458
+ res.json(result);
2459
+ } catch (err) {
2460
+ handleError(err, res, "RULE_EVALUATE_FAILED");
2461
+ }
2462
+ },
2463
+ metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
2464
+ });
2465
+ }
2466
+ /**
2467
+ * Register saved-report + scheduled-digest endpoints (M11.C16).
2468
+ *
2469
+ * Surfaces `IReportService` over HTTP so the UI can build,
2470
+ * run, and schedule reports without dropping to ObjectQL. Routes
2471
+ * live at the top of the API surface (alongside `/approvals` and
2472
+ * `/sharing`) — reports are a tenant-wide capability, not a record
2473
+ * on a specific CRUD object:
2474
+ *
2475
+ * GET {basePath}/reports?object=&ownerId=
2476
+ * POST {basePath}/reports
2477
+ * GET {basePath}/reports/:id
2478
+ * DELETE {basePath}/reports/:id
2479
+ * POST {basePath}/reports/:id/run
2480
+ * POST {basePath}/reports/:id/schedule
2481
+ * GET {basePath}/reports/:id/schedules
2482
+ * DELETE {basePath}/reports/schedules/:scheduleId
2483
+ *
2484
+ * All routes return 501 when `reportsServiceProvider` is unset so
2485
+ * a deployment without `@objectstack/plugin-reports` fails cleanly.
2486
+ */
2487
+ registerReportsEndpoints(basePath) {
2488
+ const dataPath = basePath;
2489
+ const isScoped = basePath.includes("/projects/:projectId");
2490
+ const resolveService = async (projectId) => {
2491
+ if (!this.reportsServiceProvider) return void 0;
2492
+ try {
2493
+ return await this.reportsServiceProvider(projectId);
2494
+ } catch {
2495
+ return void 0;
2496
+ }
2497
+ };
2498
+ const respond501 = (res) => res.status(501).json({
2499
+ code: "NOT_IMPLEMENTED",
2500
+ message: "Reports service is not configured on this deployment"
2501
+ });
2502
+ const handleValidation = (res, err) => {
2503
+ const msg = String(err?.message ?? err ?? "");
2504
+ if (msg.startsWith("VALIDATION_FAILED")) {
2505
+ res.status(400).json({
2506
+ code: "VALIDATION_FAILED",
2507
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2508
+ });
2509
+ return true;
2510
+ }
2511
+ if (msg.startsWith("REPORT_NOT_FOUND")) {
2512
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
2513
+ return true;
2514
+ }
2515
+ return false;
2516
+ };
2517
+ this.routeManager.register({
2518
+ method: "GET",
2519
+ path: `${dataPath}/reports`,
2520
+ handler: async (req, res) => {
2521
+ try {
2522
+ const projectId = isScoped ? req.params?.projectId : void 0;
2523
+ const context = await this.resolveExecCtx(projectId, req);
2524
+ if (this.enforceAuth(req, res, context)) return;
2525
+ const svc = await resolveService(projectId);
2526
+ if (!svc) return respond501(res);
2527
+ const q = req.query ?? {};
2528
+ const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
2529
+ res.json({ data: rows });
2530
+ } catch (error) {
2531
+ logError("[REST] List reports error:", error);
2532
+ res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2533
+ }
2534
+ },
2535
+ metadata: { summary: "List saved reports", tags: ["reports"] }
2536
+ });
2537
+ this.routeManager.register({
2538
+ method: "POST",
2539
+ path: `${dataPath}/reports`,
2540
+ handler: async (req, res) => {
2541
+ try {
2542
+ const projectId = isScoped ? req.params?.projectId : void 0;
2543
+ const context = await this.resolveExecCtx(projectId, req);
2544
+ if (this.enforceAuth(req, res, context)) return;
2545
+ const svc = await resolveService(projectId);
2546
+ if (!svc) return respond501(res);
2547
+ try {
2548
+ const row = await svc.saveReport(req.body ?? {}, context ?? {});
2549
+ res.status(201).json(row);
2550
+ } catch (err) {
2551
+ if (handleValidation(res, err)) return;
2552
+ throw err;
2553
+ }
2554
+ } catch (error) {
2555
+ logError("[REST] Save report error:", error);
2556
+ res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2557
+ }
2558
+ },
2559
+ metadata: { summary: "Create or update a saved report", tags: ["reports"] }
2560
+ });
2561
+ this.routeManager.register({
2562
+ method: "GET",
2563
+ path: `${dataPath}/reports/:id`,
2564
+ handler: async (req, res) => {
2565
+ try {
2566
+ const projectId = isScoped ? req.params?.projectId : void 0;
2567
+ const context = await this.resolveExecCtx(projectId, req);
2568
+ if (this.enforceAuth(req, res, context)) return;
2569
+ const svc = await resolveService(projectId);
2570
+ if (!svc) return respond501(res);
2571
+ const row = await svc.getReport(req.params.id, context ?? {});
2572
+ if (!row) {
2573
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
2574
+ return;
2575
+ }
2576
+ res.json(row);
2577
+ } catch (error) {
2578
+ logError("[REST] Get report error:", error);
2579
+ res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2580
+ }
2581
+ },
2582
+ metadata: { summary: "Get a saved report by id", tags: ["reports"] }
2583
+ });
2584
+ this.routeManager.register({
2585
+ method: "DELETE",
2586
+ path: `${dataPath}/reports/:id`,
2587
+ handler: async (req, res) => {
2588
+ try {
2589
+ const projectId = isScoped ? req.params?.projectId : void 0;
2590
+ const context = await this.resolveExecCtx(projectId, req);
2591
+ if (this.enforceAuth(req, res, context)) return;
2592
+ const svc = await resolveService(projectId);
2593
+ if (!svc) return respond501(res);
2594
+ await svc.deleteReport(req.params.id, context ?? {});
2595
+ res.status(204).end();
2596
+ } catch (error) {
2597
+ logError("[REST] Delete report error:", error);
2598
+ res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2599
+ }
2600
+ },
2601
+ metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
2602
+ });
2603
+ this.routeManager.register({
2604
+ method: "POST",
2605
+ path: `${dataPath}/reports/:id/run`,
2606
+ handler: async (req, res) => {
2607
+ try {
2608
+ const projectId = isScoped ? req.params?.projectId : void 0;
2609
+ const context = await this.resolveExecCtx(projectId, req);
2610
+ if (this.enforceAuth(req, res, context)) return;
2611
+ const svc = await resolveService(projectId);
2612
+ if (!svc) return respond501(res);
2613
+ try {
2614
+ const result = await svc.run(req.params.id, context ?? {});
2615
+ res.json(result);
2616
+ } catch (err) {
2617
+ if (handleValidation(res, err)) return;
2618
+ throw err;
2619
+ }
2620
+ } catch (error) {
2621
+ logError("[REST] Run report error:", error);
2622
+ res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2623
+ }
2624
+ },
2625
+ metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
2626
+ });
2627
+ this.routeManager.register({
2628
+ method: "POST",
2629
+ path: `${dataPath}/reports/:id/schedule`,
2630
+ handler: async (req, res) => {
2631
+ try {
2632
+ const projectId = isScoped ? req.params?.projectId : void 0;
2633
+ const context = await this.resolveExecCtx(projectId, req);
2634
+ if (this.enforceAuth(req, res, context)) return;
2635
+ const svc = await resolveService(projectId);
2636
+ if (!svc) return respond501(res);
2637
+ const body = req.body ?? {};
2638
+ try {
2639
+ const row = await svc.scheduleReport({
2640
+ reportId: req.params.id,
2641
+ recipients: body.recipients ?? [],
2642
+ name: body.name,
2643
+ intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
2644
+ cronExpression: body.cronExpression ?? body.cron_expression,
2645
+ timezone: body.timezone,
2646
+ format: body.format,
2647
+ subjectTemplate: body.subjectTemplate ?? body.subject_template,
2648
+ ownerId: body.ownerId ?? body.owner_id,
2649
+ active: body.active
2650
+ }, context ?? {});
2651
+ res.status(201).json(row);
2652
+ } catch (err) {
2653
+ if (handleValidation(res, err)) return;
2654
+ throw err;
2655
+ }
2656
+ } catch (error) {
2657
+ logError("[REST] Schedule report error:", error);
2658
+ res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2659
+ }
2660
+ },
2661
+ metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
2662
+ });
2663
+ this.routeManager.register({
2664
+ method: "GET",
2665
+ path: `${dataPath}/reports/:id/schedules`,
2666
+ handler: async (req, res) => {
2667
+ try {
2668
+ const projectId = isScoped ? req.params?.projectId : void 0;
2669
+ const context = await this.resolveExecCtx(projectId, req);
2670
+ if (this.enforceAuth(req, res, context)) return;
2671
+ const svc = await resolveService(projectId);
2672
+ if (!svc) return respond501(res);
2673
+ const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
2674
+ res.json({ data: rows });
2675
+ } catch (error) {
2676
+ logError("[REST] List schedules error:", error);
2677
+ res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2678
+ }
2679
+ },
2680
+ metadata: { summary: "List schedules for a report", tags: ["reports"] }
2681
+ });
2682
+ this.routeManager.register({
2683
+ method: "DELETE",
2684
+ path: `${dataPath}/reports/schedules/:scheduleId`,
2685
+ handler: async (req, res) => {
2686
+ try {
2687
+ const projectId = isScoped ? req.params?.projectId : void 0;
2688
+ const context = await this.resolveExecCtx(projectId, req);
2689
+ if (this.enforceAuth(req, res, context)) return;
2690
+ const svc = await resolveService(projectId);
2691
+ if (!svc) return respond501(res);
2692
+ await svc.unscheduleReport(req.params.scheduleId, context ?? {});
2693
+ res.status(204).end();
2694
+ } catch (error) {
2695
+ logError("[REST] Unschedule report error:", error);
2696
+ res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2697
+ }
2698
+ },
2699
+ metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
2700
+ });
2701
+ }
2702
+ /**
2703
+ * Register approval engine endpoints.
2704
+ *
2705
+ * Routes (all under {basePath}/approvals):
2706
+ * GET /processes — list approval processes
2707
+ * POST /processes — upsert (defineProcess)
2708
+ * GET /processes/:id — get by id or name
2709
+ * DELETE /processes/:id — delete process
2710
+ * POST /requests — submit
2711
+ * GET /requests — list (filters: status, object, recordId, approverId, submitterId)
2712
+ * GET /requests/:id — get request
2713
+ * POST /requests/:id/approve — approve current step
2714
+ * POST /requests/:id/reject — reject current step
2715
+ * POST /requests/:id/recall — recall (submitter only)
2716
+ * GET /requests/:id/actions — audit trail
2717
+ *
2718
+ * Returns 501 when `approvalsServiceProvider` is unset so deployments
2719
+ * without `@objectstack/plugin-approvals` fail cleanly.
2720
+ */
2721
+ registerApprovalsEndpoints(basePath) {
2722
+ const dataPath = basePath;
2723
+ const isScoped = basePath.includes("/projects/:projectId");
2724
+ const resolveService = async (projectId) => {
2725
+ if (!this.approvalsServiceProvider) return void 0;
2726
+ try {
2727
+ return await this.approvalsServiceProvider(projectId);
2728
+ } catch {
2729
+ return void 0;
2730
+ }
2731
+ };
2732
+ const respond501 = (res) => res.status(501).json({
2733
+ code: "NOT_IMPLEMENTED",
2734
+ message: "Approvals service is not configured on this deployment"
2735
+ });
2736
+ const handleApprovalError = (res, err) => {
2737
+ const msg = String(err?.message ?? err ?? "");
2738
+ const mapping = [
2739
+ [/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
2740
+ [/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
2741
+ [/^INVALID_STATE/, 409, "INVALID_STATE"],
2742
+ [/^FORBIDDEN/, 403, "FORBIDDEN"],
2743
+ [/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
2744
+ [/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
2745
+ [/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
2746
+ ];
2747
+ for (const [re, status, code] of mapping) {
2748
+ if (re.test(msg)) {
2749
+ res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
2750
+ return true;
2751
+ }
2752
+ }
2753
+ return false;
2754
+ };
2755
+ this.routeManager.register({
2756
+ method: "GET",
2757
+ path: `${dataPath}/approvals/processes`,
2758
+ handler: async (req, res) => {
2759
+ try {
2760
+ const projectId = isScoped ? req.params?.projectId : void 0;
2761
+ const context = await this.resolveExecCtx(projectId, req);
2762
+ if (this.enforceAuth(req, res, context)) return;
2763
+ const svc = await resolveService(projectId);
2764
+ if (!svc) return respond501(res);
2765
+ const q = req.query ?? {};
2766
+ const rows = await svc.listProcesses({
2767
+ object: q.object,
2768
+ activeOnly: q.activeOnly === "true" || q.activeOnly === true
2769
+ }, context ?? {});
2770
+ res.json({ data: rows });
2771
+ } catch (error) {
2772
+ logError("[REST] List approval processes error:", error);
2773
+ res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2774
+ }
2775
+ },
2776
+ metadata: { summary: "List approval processes", tags: ["approvals"] }
2777
+ });
2778
+ this.routeManager.register({
2779
+ method: "POST",
2780
+ path: `${dataPath}/approvals/processes`,
2781
+ handler: async (req, res) => {
2782
+ try {
2783
+ const projectId = isScoped ? req.params?.projectId : void 0;
2784
+ const context = await this.resolveExecCtx(projectId, req);
2785
+ if (this.enforceAuth(req, res, context)) return;
2786
+ const svc = await resolveService(projectId);
2787
+ if (!svc) return respond501(res);
2788
+ try {
2789
+ const row = await svc.defineProcess(req.body ?? {}, context ?? {});
2790
+ res.status(201).json(row);
2791
+ } catch (err) {
2792
+ if (handleApprovalError(res, err)) return;
2793
+ throw err;
2794
+ }
2795
+ } catch (error) {
2796
+ logError("[REST] Define approval process error:", error);
2797
+ res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2798
+ }
2799
+ },
2800
+ metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
2801
+ });
2802
+ this.routeManager.register({
2803
+ method: "GET",
2804
+ path: `${dataPath}/approvals/processes/:id`,
2805
+ handler: async (req, res) => {
2806
+ try {
2807
+ const projectId = isScoped ? req.params?.projectId : void 0;
2808
+ const context = await this.resolveExecCtx(projectId, req);
2809
+ if (this.enforceAuth(req, res, context)) return;
2810
+ const svc = await resolveService(projectId);
2811
+ if (!svc) return respond501(res);
2812
+ const row = await svc.getProcess(req.params.id, context ?? {});
2813
+ if (!row) {
2814
+ res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
2815
+ return;
2816
+ }
2817
+ res.json(row);
2818
+ } catch (error) {
2819
+ logError("[REST] Get approval process error:", error);
2820
+ res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2821
+ }
2822
+ },
2823
+ metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
2824
+ });
2825
+ this.routeManager.register({
2826
+ method: "DELETE",
2827
+ path: `${dataPath}/approvals/processes/:id`,
2828
+ handler: async (req, res) => {
2829
+ try {
2830
+ const projectId = isScoped ? req.params?.projectId : void 0;
2831
+ const context = await this.resolveExecCtx(projectId, req);
2832
+ if (this.enforceAuth(req, res, context)) return;
2833
+ const svc = await resolveService(projectId);
2834
+ if (!svc) return respond501(res);
2835
+ await svc.deleteProcess(req.params.id, context ?? {});
2836
+ res.status(204).end();
2837
+ } catch (error) {
2838
+ logError("[REST] Delete approval process error:", error);
2839
+ res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2840
+ }
2841
+ },
2842
+ metadata: { summary: "Delete an approval process", tags: ["approvals"] }
2843
+ });
2844
+ this.routeManager.register({
2845
+ method: "POST",
2846
+ path: `${dataPath}/approvals/requests`,
2847
+ handler: async (req, res) => {
2848
+ try {
2849
+ const projectId = isScoped ? req.params?.projectId : void 0;
2850
+ const context = await this.resolveExecCtx(projectId, req);
2851
+ if (this.enforceAuth(req, res, context)) return;
2852
+ const svc = await resolveService(projectId);
2853
+ if (!svc) return respond501(res);
2854
+ const body = req.body ?? {};
2855
+ try {
2856
+ const row = await svc.submit({
2857
+ object: body.object,
2858
+ recordId: body.recordId ?? body.record_id,
2859
+ processName: body.processName ?? body.process_name,
2860
+ submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
2861
+ comment: body.comment,
2862
+ payload: body.payload
2863
+ }, context ?? {});
2864
+ res.status(201).json(row);
2865
+ } catch (err) {
2866
+ if (handleApprovalError(res, err)) return;
2867
+ throw err;
2868
+ }
2869
+ } catch (error) {
2870
+ logError("[REST] Submit approval error:", error);
2871
+ res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2872
+ }
2873
+ },
2874
+ metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
2875
+ });
2876
+ this.routeManager.register({
2877
+ method: "GET",
2878
+ path: `${dataPath}/approvals/requests`,
2879
+ handler: async (req, res) => {
2880
+ try {
2881
+ const projectId = isScoped ? req.params?.projectId : void 0;
2882
+ const context = await this.resolveExecCtx(projectId, req);
2883
+ if (this.enforceAuth(req, res, context)) return;
2884
+ const svc = await resolveService(projectId);
2885
+ if (!svc) {
2886
+ res.json({ data: [] });
2887
+ return;
2888
+ }
2889
+ const q = req.query ?? {};
2890
+ const rows = await svc.listRequests({
2891
+ object: q.object,
2892
+ recordId: q.recordId ?? q.record_id,
2893
+ status: q.status,
2894
+ approverId: q.approverId ?? q.approver_id,
2895
+ submitterId: q.submitterId ?? q.submitter_id
2896
+ }, context ?? {});
2897
+ res.json({ data: rows });
2898
+ } catch (error) {
2899
+ logError("[REST] List approval requests error:", error);
2900
+ res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2901
+ }
2902
+ },
2903
+ metadata: { summary: "List approval requests", tags: ["approvals"] }
2904
+ });
2905
+ this.routeManager.register({
2906
+ method: "GET",
2907
+ path: `${dataPath}/approvals/requests/:id`,
2908
+ handler: async (req, res) => {
2909
+ try {
2910
+ const projectId = isScoped ? req.params?.projectId : void 0;
2911
+ const context = await this.resolveExecCtx(projectId, req);
2912
+ if (this.enforceAuth(req, res, context)) return;
2913
+ const svc = await resolveService(projectId);
2914
+ if (!svc) return respond501(res);
2915
+ const row = await svc.getRequest(req.params.id, context ?? {});
2916
+ if (!row) {
2917
+ res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
2918
+ return;
2919
+ }
2920
+ res.json(row);
2921
+ } catch (error) {
2922
+ logError("[REST] Get approval request error:", error);
2923
+ res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2924
+ }
2925
+ },
2926
+ metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
2927
+ });
2928
+ const decisionRoute = (suffix, method) => {
2929
+ this.routeManager.register({
2930
+ method: "POST",
2931
+ path: `${dataPath}/approvals/requests/:id/${suffix}`,
2932
+ handler: async (req, res) => {
2933
+ try {
2934
+ const projectId = isScoped ? req.params?.projectId : void 0;
2935
+ const context = await this.resolveExecCtx(projectId, req);
2936
+ if (this.enforceAuth(req, res, context)) return;
2937
+ const svc = await resolveService(projectId);
2938
+ if (!svc) return respond501(res);
2939
+ const body = req.body ?? {};
2940
+ try {
2941
+ const out = await svc[method](req.params.id, {
2942
+ actorId: body.actorId ?? body.actor_id ?? context?.userId,
2943
+ comment: body.comment
2944
+ }, context ?? {});
2945
+ res.json(out);
2946
+ } catch (err) {
2947
+ if (handleApprovalError(res, err)) return;
2948
+ throw err;
2949
+ }
2950
+ } catch (error) {
2951
+ logError(`[REST] ${suffix} approval error:`, error);
2952
+ res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
2953
+ }
2954
+ },
2955
+ metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
2956
+ });
2957
+ };
2958
+ decisionRoute("approve", "approve");
2959
+ decisionRoute("reject", "reject");
2960
+ decisionRoute("recall", "recall");
2961
+ this.routeManager.register({
2962
+ method: "GET",
2963
+ path: `${dataPath}/approvals/requests/:id/actions`,
2964
+ handler: async (req, res) => {
2965
+ try {
2966
+ const projectId = isScoped ? req.params?.projectId : void 0;
2967
+ const context = await this.resolveExecCtx(projectId, req);
2968
+ if (this.enforceAuth(req, res, context)) return;
2969
+ const svc = await resolveService(projectId);
2970
+ if (!svc) return respond501(res);
2971
+ const rows = await svc.listActions(req.params.id, context ?? {});
2972
+ res.json({ data: rows });
2973
+ } catch (error) {
2974
+ logError("[REST] List approval actions error:", error);
2975
+ res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2976
+ }
2977
+ },
2978
+ metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
2979
+ });
2980
+ }
2981
+ /**
2982
+ * Register batch operation endpoints
2983
+ */
2984
+ registerBatchEndpoints(basePath) {
2985
+ const { crud, batch } = this.config;
2986
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2987
+ const isScoped = basePath.includes("/projects/:projectId");
2988
+ const operations = batch.operations;
2989
+ if (batch.enableBatchEndpoint && this.protocol.batchData) {
2990
+ this.routeManager.register({
2991
+ method: "POST",
2992
+ path: `${dataPath}/:object/batch`,
2993
+ handler: async (req, res) => {
2994
+ try {
2995
+ const projectId = isScoped ? req.params?.projectId : void 0;
2996
+ const p = await this.resolveProtocol(projectId, req);
2997
+ const context = await this.resolveExecCtx(projectId, req);
2998
+ if (this.enforceAuth(req, res, context)) return;
2999
+ const result = await p.batchData({
3000
+ object: req.params.object,
3001
+ request: req.body,
3002
+ ...projectId ? { projectId } : {},
3003
+ ...context ? { context } : {}
3004
+ });
3005
+ res.json(result);
3006
+ } catch (error) {
3007
+ logError("[REST] Unhandled error:", error);
3008
+ sendError(res, error, req.params?.object);
3009
+ }
3010
+ },
3011
+ metadata: {
3012
+ summary: "Batch operations",
3013
+ tags: ["data", "batch"]
3014
+ }
3015
+ });
3016
+ }
3017
+ if (operations.createMany && this.protocol.createManyData) {
3018
+ this.routeManager.register({
3019
+ method: "POST",
3020
+ path: `${dataPath}/:object/createMany`,
3021
+ handler: async (req, res) => {
3022
+ try {
3023
+ const projectId = isScoped ? req.params?.projectId : void 0;
3024
+ const p = await this.resolveProtocol(projectId, req);
3025
+ const context = await this.resolveExecCtx(projectId, req);
3026
+ if (this.enforceAuth(req, res, context)) return;
3027
+ const result = await p.createManyData({
3028
+ object: req.params.object,
3029
+ records: req.body || [],
3030
+ ...projectId ? { projectId } : {},
3031
+ ...context ? { context } : {}
3032
+ });
3033
+ res.status(201).json(result);
3034
+ } catch (error) {
3035
+ logError("[REST] Unhandled error:", error);
3036
+ sendError(res, error, req.params?.object);
3037
+ }
3038
+ },
3039
+ metadata: {
3040
+ summary: "Create multiple records",
3041
+ tags: ["data", "batch"]
1172
3042
  }
1173
3043
  });
1174
3044
  }
@@ -1180,15 +3050,18 @@ var RestServer = class {
1180
3050
  try {
1181
3051
  const projectId = isScoped ? req.params?.projectId : void 0;
1182
3052
  const p = await this.resolveProtocol(projectId, req);
3053
+ const context = await this.resolveExecCtx(projectId, req);
3054
+ if (this.enforceAuth(req, res, context)) return;
1183
3055
  const result = await p.updateManyData({
1184
3056
  object: req.params.object,
1185
3057
  ...req.body,
1186
- ...projectId ? { projectId } : {}
3058
+ ...projectId ? { projectId } : {},
3059
+ ...context ? { context } : {}
1187
3060
  });
1188
3061
  res.json(result);
1189
3062
  } catch (error) {
1190
3063
  logError("[REST] Unhandled error:", error);
1191
- res.status(400).json({ error: error.message });
3064
+ sendError(res, error, req.params?.object);
1192
3065
  }
1193
3066
  },
1194
3067
  metadata: {
@@ -1205,15 +3078,18 @@ var RestServer = class {
1205
3078
  try {
1206
3079
  const projectId = isScoped ? req.params?.projectId : void 0;
1207
3080
  const p = await this.resolveProtocol(projectId, req);
3081
+ const context = await this.resolveExecCtx(projectId, req);
3082
+ if (this.enforceAuth(req, res, context)) return;
1208
3083
  const result = await p.deleteManyData({
1209
3084
  object: req.params.object,
1210
3085
  ...req.body,
1211
- ...projectId ? { projectId } : {}
3086
+ ...projectId ? { projectId } : {},
3087
+ ...context ? { context } : {}
1212
3088
  });
1213
3089
  res.json(result);
1214
3090
  } catch (error) {
1215
3091
  logError("[REST] Unhandled error:", error);
1216
- res.status(400).json({ error: error.message });
3092
+ sendError(res, error, req.params?.object);
1217
3093
  }
1218
3094
  },
1219
3095
  metadata: {
@@ -1407,6 +3283,48 @@ function createRestApiPlugin(config = {}) {
1407
3283
  return void 0;
1408
3284
  }
1409
3285
  };
3286
+ const emailServiceProvider = async (_projectId) => {
3287
+ try {
3288
+ return ctx.getService("email");
3289
+ } catch {
3290
+ return void 0;
3291
+ }
3292
+ };
3293
+ const sharingServiceProvider = async (_projectId) => {
3294
+ try {
3295
+ return ctx.getService("sharing");
3296
+ } catch {
3297
+ return void 0;
3298
+ }
3299
+ };
3300
+ const reportsServiceProvider = async (_projectId) => {
3301
+ try {
3302
+ return ctx.getService("reports");
3303
+ } catch {
3304
+ return void 0;
3305
+ }
3306
+ };
3307
+ const approvalsServiceProvider = async (_projectId) => {
3308
+ try {
3309
+ return ctx.getService("approvals");
3310
+ } catch {
3311
+ return void 0;
3312
+ }
3313
+ };
3314
+ const sharingRulesServiceProvider = async (_projectId) => {
3315
+ try {
3316
+ return ctx.getService("sharingRules");
3317
+ } catch {
3318
+ return void 0;
3319
+ }
3320
+ };
3321
+ const i18nServiceProvider = async (_projectId) => {
3322
+ try {
3323
+ return ctx.getService("i18n");
3324
+ } catch {
3325
+ return void 0;
3326
+ }
3327
+ };
1410
3328
  if (!server) {
1411
3329
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
1412
3330
  return;
@@ -1417,7 +3335,7 @@ function createRestApiPlugin(config = {}) {
1417
3335
  }
1418
3336
  ctx.logger.info("Hydrating REST API from Protocol...");
1419
3337
  try {
1420
- const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
3338
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
1421
3339
  restServer.registerRoutes();
1422
3340
  ctx.logger.info("REST API successfully registered");
1423
3341
  } catch (err) {