@objectstack/rest 4.0.4 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -209,11 +209,544 @@ var RouteGroupBuilder = class {
209
209
  };
210
210
 
211
211
  // src/rest-server.ts
212
+ var logError = (...args) => globalThis.console?.error(...args);
213
+ function mapDataError(error, object) {
214
+ if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
215
+ return {
216
+ status: 400,
217
+ body: {
218
+ error: error?.message ?? "Validation failed",
219
+ code: "VALIDATION_FAILED",
220
+ fields: Array.isArray(error?.fields) ? error.fields : [],
221
+ ...object ? { object } : {}
222
+ }
223
+ };
224
+ }
225
+ if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
226
+ return {
227
+ status: 403,
228
+ body: {
229
+ error: error?.message ?? "Permission denied",
230
+ code: "PERMISSION_DENIED",
231
+ ...object ? { object } : {}
232
+ }
233
+ };
234
+ }
235
+ const raw = String(error?.message ?? error ?? "");
236
+ const lower = raw.toLowerCase();
237
+ if (raw.includes("[ProjectKernelFactory]") && (lower.includes("missing database_url") || lower.includes("not found"))) {
238
+ const isProvisioning = lower.includes("status='provisioning'") || lower.includes("status='pending'");
239
+ const isFailed = lower.includes("status='failed'");
240
+ return {
241
+ status: isProvisioning ? 503 : isFailed ? 502 : 404,
242
+ body: {
243
+ error: raw,
244
+ code: isProvisioning ? "PROJECT_PROVISIONING" : isFailed ? "PROJECT_PROVISIONING_FAILED" : "PROJECT_NOT_FOUND"
245
+ }
246
+ };
247
+ }
248
+ if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
249
+ return {
250
+ status: 404,
251
+ body: {
252
+ error: raw,
253
+ code: "RECORD_NOT_FOUND",
254
+ ...object ? { object } : {}
255
+ }
256
+ };
257
+ }
258
+ const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
259
+ if (looksLikeUnknownObject) {
260
+ return {
261
+ status: 404,
262
+ body: {
263
+ error: object ? `Object '${object}' is not registered` : "Object not found",
264
+ code: "object_not_found",
265
+ object
266
+ }
267
+ };
268
+ }
269
+ const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
270
+ if (looksLikeSqlLeak) {
271
+ if (lower.includes("unique constraint") || lower.includes("unique violation")) {
272
+ return {
273
+ status: 409,
274
+ body: {
275
+ error: "A record with this value already exists",
276
+ code: "UNIQUE_VIOLATION",
277
+ ...object ? { object } : {}
278
+ }
279
+ };
280
+ }
281
+ return {
282
+ status: 500,
283
+ body: { error: "Internal data error", code: "DATABASE_ERROR" }
284
+ };
285
+ }
286
+ return { status: 400, body: { error: raw || "Bad request" } };
287
+ }
288
+ function sendError(res, error, object) {
289
+ if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
290
+ const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
291
+ res.status(error.status).json({
292
+ error: safeMsg,
293
+ ...error.code ? { code: error.code } : {}
294
+ });
295
+ return;
296
+ }
297
+ const mapped = mapDataError(error, object);
298
+ res.status(mapped.status).json(mapped.body);
299
+ }
300
+ function isExpectedDataStatus(status) {
301
+ return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
302
+ }
303
+ function parseCsvToRows(csv, mapping = {}) {
304
+ const text = csv.replace(/^\uFEFF/, "");
305
+ const cells = [];
306
+ let cur = "";
307
+ let row = [];
308
+ let inQuotes = false;
309
+ for (let i = 0; i < text.length; i++) {
310
+ const ch = text[i];
311
+ if (inQuotes) {
312
+ if (ch === '"') {
313
+ if (text[i + 1] === '"') {
314
+ cur += '"';
315
+ i++;
316
+ } else {
317
+ inQuotes = false;
318
+ }
319
+ } else {
320
+ cur += ch;
321
+ }
322
+ continue;
323
+ }
324
+ if (ch === '"') {
325
+ inQuotes = true;
326
+ continue;
327
+ }
328
+ if (ch === ",") {
329
+ row.push(cur);
330
+ cur = "";
331
+ continue;
332
+ }
333
+ if (ch === "\r") {
334
+ continue;
335
+ }
336
+ if (ch === "\n") {
337
+ row.push(cur);
338
+ cur = "";
339
+ cells.push(row);
340
+ row = [];
341
+ continue;
342
+ }
343
+ cur += ch;
344
+ }
345
+ if (cur.length > 0 || row.length > 0) {
346
+ row.push(cur);
347
+ cells.push(row);
348
+ }
349
+ while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
350
+ if (cells.length < 2) return [];
351
+ const header = cells[0].map((h) => h.trim());
352
+ const fields = header.map((h) => mapping[h] ?? h);
353
+ const out = [];
354
+ for (let r = 1; r < cells.length; r++) {
355
+ const row2 = cells[r];
356
+ const obj = {};
357
+ for (let c = 0; c < fields.length; c++) {
358
+ const key = fields[c];
359
+ if (!key) continue;
360
+ const raw = row2[c] ?? "";
361
+ obj[key] = raw;
362
+ }
363
+ out.push(obj);
364
+ }
365
+ return out;
366
+ }
367
+ function formatCsvCell(value) {
368
+ if (value === null || value === void 0) return "";
369
+ let s;
370
+ if (typeof value === "string") s = value;
371
+ else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") s = String(value);
372
+ else if (value instanceof Date) s = value.toISOString();
373
+ else {
374
+ try {
375
+ s = JSON.stringify(value);
376
+ } catch {
377
+ s = String(value);
378
+ }
379
+ }
380
+ if (/[",\r\n]/.test(s)) {
381
+ return `"${s.replace(/"/g, '""')}"`;
382
+ }
383
+ return s;
384
+ }
385
+ function rowsToCsv(fields, rows, includeHeader) {
386
+ const lines = [];
387
+ if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
388
+ for (const row of rows) {
389
+ lines.push(fields.map((f) => formatCsvCell(row?.[f])).join(","));
390
+ }
391
+ return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
392
+ }
212
393
  var RestServer = class {
213
- constructor(server, protocol, config = {}) {
394
+ constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
214
395
  this.protocol = protocol;
215
396
  this.config = this.normalizeConfig(config);
216
397
  this.routeManager = new RouteManager(server);
398
+ this.kernelManager = kernelManager;
399
+ this.envRegistry = envRegistry;
400
+ this.defaultProjectIdProvider = defaultProjectIdProvider;
401
+ this.authServiceProvider = authServiceProvider;
402
+ this.objectQLProvider = objectQLProvider;
403
+ this.emailServiceProvider = emailServiceProvider;
404
+ this.sharingServiceProvider = sharingServiceProvider;
405
+ this.reportsServiceProvider = reportsServiceProvider;
406
+ this.approvalsServiceProvider = approvalsServiceProvider;
407
+ this.sharingRulesServiceProvider = sharingRulesServiceProvider;
408
+ }
409
+ /**
410
+ * Resolve the protocol for a given request. When `projectId` is present
411
+ * and a KernelManager is wired, fetch the per-project kernel's
412
+ * `protocol` service so metadata / data / UI reads hit the project's
413
+ * own registry and datastore.
414
+ *
415
+ * When `projectId` is absent on an unscoped route and an `envRegistry`
416
+ * is wired (runtime mode), the resolution chain is:
417
+ * 1. Hostname → projectId (`envRegistry.resolveByHostname`)
418
+ * 2. `X-Project-Id` header → projectId (`envRegistry.resolveById`)
419
+ * 3. Default-project fallback (`defaultProjectIdProvider`, set by
420
+ * `createSingleProjectPlugin`)
421
+ * 4. Control-plane protocol captured at boot.
422
+ *
423
+ * Special case: `projectId === 'platform'` is a reserved virtual id used
424
+ * by Studio to address the control plane through the regular project
425
+ * URL shape (`/projects/platform/...`). It is NOT a row in the projects
426
+ * table, so we must never call `KernelManager.getOrCreate('platform')`.
427
+ * Instead, return the control-plane protocol directly. This lets Studio
428
+ * (and any other client) speak a single, uniform URL family without
429
+ * duplicating route logic for the platform surface.
430
+ */
431
+ async resolveProtocol(projectId, req) {
432
+ if (projectId === "platform") return this.protocol;
433
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
434
+ const host = this.extractHostname(req);
435
+ if (host) {
436
+ try {
437
+ const result = await this.envRegistry.resolveByHostname(host);
438
+ if (result?.projectId) projectId = result.projectId;
439
+ } catch {
440
+ }
441
+ }
442
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
443
+ const headerVal = this.extractProjectIdHeader(req);
444
+ if (headerVal) {
445
+ try {
446
+ const driver = await this.envRegistry.resolveById(headerVal);
447
+ if (driver) projectId = headerVal;
448
+ } catch {
449
+ }
450
+ }
451
+ }
452
+ }
453
+ if (!projectId && this.defaultProjectIdProvider) {
454
+ try {
455
+ const def = this.defaultProjectIdProvider();
456
+ if (def) projectId = def;
457
+ } catch {
458
+ }
459
+ }
460
+ if (!projectId || !this.kernelManager) return this.protocol;
461
+ const kernel = await this.kernelManager.getOrCreate(projectId);
462
+ return kernel.getServiceAsync("protocol");
463
+ }
464
+ /**
465
+ * Resolve the i18n service for the request's project (or control plane
466
+ * when no project id is in scope). Returns `undefined` when no service is
467
+ * registered, so callers can short-circuit and skip translation rather
468
+ * than failing.
469
+ *
470
+ * Mirrors `resolveProtocol`'s lookup chain: explicit `projectId` from the
471
+ * route → kernel-managed `i18n` service. Control-plane / unscoped
472
+ * requests intentionally return `undefined` because the platform kernel
473
+ * does not own per-app translation bundles.
474
+ */
475
+ async resolveI18nService(projectId) {
476
+ if (!projectId || projectId === "platform" || !this.kernelManager) return void 0;
477
+ try {
478
+ const kernel = await this.kernelManager.getOrCreate(projectId);
479
+ return await kernel.getServiceAsync("i18n");
480
+ } catch {
481
+ return void 0;
482
+ }
483
+ }
484
+ /**
485
+ * Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
486
+ * Returns `true` if the response was sent and the caller should stop
487
+ * processing. Returns `false` to continue.
488
+ *
489
+ * The check is intentionally narrow: only `context?.userId` counts as
490
+ * "authenticated". `isSystem` flags are never set on inbound HTTP
491
+ * requests (they're internal-only), so they cannot bypass this gate.
492
+ */
493
+ enforceAuth(req, res, context) {
494
+ if (!this.config.api.requireAuth) return false;
495
+ if (context?.userId) return false;
496
+ if (req?.method === "OPTIONS") return false;
497
+ res.status(401).json({
498
+ error: "unauthenticated",
499
+ message: "Authentication is required to access this endpoint."
500
+ });
501
+ return true;
502
+ }
503
+ /**
504
+ * Resolve the request's execution context (RBAC/RLS/FLS) by looking up
505
+ * the better-auth session via the project's `auth` service. Returns
506
+ * `undefined` for anonymous requests so callers can pass `context` as-is
507
+ * to the protocol layer (the SecurityPlugin treats undefined as anon).
508
+ */
509
+ async resolveExecCtx(projectId, req) {
510
+ try {
511
+ if (!projectId && req && this.envRegistry && this.kernelManager) {
512
+ const host = this.extractHostname(req);
513
+ if (host) {
514
+ try {
515
+ const result = await this.envRegistry.resolveByHostname(host);
516
+ if (result?.projectId) projectId = result.projectId;
517
+ } catch {
518
+ }
519
+ }
520
+ if (!projectId && typeof this.envRegistry.resolveById === "function") {
521
+ const headerVal = this.extractProjectIdHeader(req);
522
+ if (headerVal) {
523
+ try {
524
+ const driver = await this.envRegistry.resolveById(headerVal);
525
+ if (driver) projectId = headerVal;
526
+ } catch {
527
+ }
528
+ }
529
+ }
530
+ }
531
+ let authService;
532
+ let kernel;
533
+ if (projectId && projectId !== "platform" && this.kernelManager) {
534
+ kernel = await this.kernelManager.getOrCreate(projectId);
535
+ authService = await kernel.getServiceAsync("auth").catch(() => void 0);
536
+ }
537
+ if (!authService && this.defaultProjectIdProvider && this.kernelManager) {
538
+ try {
539
+ const def = this.defaultProjectIdProvider();
540
+ if (def) {
541
+ kernel = await this.kernelManager.getOrCreate(def);
542
+ authService = await kernel.getServiceAsync("auth").catch(() => void 0);
543
+ }
544
+ } catch {
545
+ }
546
+ }
547
+ if (!authService && this.authServiceProvider) {
548
+ authService = await this.authServiceProvider(projectId).catch(() => void 0);
549
+ }
550
+ if (!authService) return void 0;
551
+ let api = authService.api;
552
+ if (!api && typeof authService.getApi === "function") {
553
+ api = await authService.getApi();
554
+ }
555
+ if (!api?.getSession) return void 0;
556
+ const rawHeaders = req?.headers;
557
+ let headers;
558
+ if (rawHeaders && typeof rawHeaders.get === "function") {
559
+ headers = rawHeaders;
560
+ } else if (rawHeaders && typeof rawHeaders === "object") {
561
+ headers = new globalThis.Headers();
562
+ for (const [k, v] of Object.entries(rawHeaders)) {
563
+ if (Array.isArray(v)) v.forEach((x) => headers.append(k, String(x)));
564
+ else if (v != null) headers.set(k, String(v));
565
+ }
566
+ } else {
567
+ return void 0;
568
+ }
569
+ const session = await api.getSession({ headers });
570
+ if (!session?.user?.id) return void 0;
571
+ const userId = session.user.id;
572
+ const tenantId = session.session?.activeOrganizationId ?? void 0;
573
+ const permissions = [];
574
+ const roles = [];
575
+ try {
576
+ let ql;
577
+ if (kernel) {
578
+ ql = await kernel.getServiceAsync("objectql").catch(() => void 0);
579
+ }
580
+ if (!ql && this.objectQLProvider) {
581
+ ql = await this.objectQLProvider(projectId).catch(() => void 0);
582
+ }
583
+ if (ql && typeof ql.find === "function") {
584
+ const sysOpts = { context: { isSystem: true } };
585
+ const memberRows = await ql.find("sys_member", {
586
+ where: tenantId ? { user_id: userId, organization_id: tenantId } : { user_id: userId },
587
+ limit: 50,
588
+ ...sysOpts
589
+ }).catch(() => []);
590
+ for (const m of memberRows ?? []) {
591
+ if (typeof m.role === "string") {
592
+ for (const r of m.role.split(",").map((s) => s.trim()).filter(Boolean)) {
593
+ if (!roles.includes(r)) roles.push(r);
594
+ }
595
+ }
596
+ }
597
+ const upsRows = await ql.find("sys_user_permission_set", {
598
+ where: { user_id: userId },
599
+ limit: 100,
600
+ ...sysOpts
601
+ }).catch(() => []);
602
+ const psIds = /* @__PURE__ */ new Set();
603
+ for (const r of upsRows ?? []) {
604
+ const orgScope = r.organization_id ?? null;
605
+ if (!orgScope || tenantId && orgScope === tenantId) {
606
+ const pid = r.permission_set_id ?? r.permissionSetId;
607
+ if (pid) psIds.add(pid);
608
+ }
609
+ }
610
+ if (psIds.size > 0) {
611
+ const psRows = await ql.find("sys_permission_set", {
612
+ where: { id: { $in: Array.from(psIds) } },
613
+ limit: 500,
614
+ ...sysOpts
615
+ }).catch(() => []);
616
+ for (const ps of psRows ?? []) {
617
+ if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name);
618
+ }
619
+ }
620
+ }
621
+ } catch {
622
+ }
623
+ return {
624
+ userId,
625
+ tenantId,
626
+ roles,
627
+ permissions,
628
+ isSystem: false
629
+ };
630
+ } catch {
631
+ return void 0;
632
+ }
633
+ }
634
+ /**
635
+ * Build a `TranslationBundle` (`Record<locale, TranslationData>`) from an
636
+ * `II18nService` instance. Returns `undefined` when no locales are
637
+ * registered so callers can avoid translation work.
638
+ */
639
+ buildTranslationBundle(i18n) {
640
+ if (!i18n || typeof i18n.getLocales !== "function" || typeof i18n.getTranslations !== "function") {
641
+ return void 0;
642
+ }
643
+ const locales = i18n.getLocales();
644
+ if (!locales.length) return void 0;
645
+ const bundle = {};
646
+ for (const locale of locales) {
647
+ const data = i18n.getTranslations(locale);
648
+ if (data && typeof data === "object") bundle[locale] = data;
649
+ }
650
+ return Object.keys(bundle).length ? bundle : void 0;
651
+ }
652
+ /**
653
+ * Parse the highest-priority locale from an `Accept-Language` header.
654
+ * Falls back to a `?locale=` query parameter, then to the i18n service's
655
+ * default locale. Returns `undefined` when no preference is expressed
656
+ * (callers will then return untranslated metadata).
657
+ */
658
+ extractLocale(req, i18n) {
659
+ const headers = req?.headers;
660
+ let header;
661
+ if (headers) {
662
+ header = typeof headers.get === "function" ? headers.get("accept-language") ?? void 0 : headers["accept-language"] ?? headers["Accept-Language"];
663
+ }
664
+ if (typeof header === "string" && header.length > 0) {
665
+ const top = header.split(",")[0]?.split(";")[0]?.trim();
666
+ if (top) return top;
667
+ }
668
+ const queryLocale = req?.query?.locale;
669
+ if (typeof queryLocale === "string" && queryLocale.length > 0) return queryLocale;
670
+ if (i18n && typeof i18n.getDefaultLocale === "function") {
671
+ const def = i18n.getDefaultLocale();
672
+ if (typeof def === "string" && def.length > 0) return def;
673
+ }
674
+ return void 0;
675
+ }
676
+ /**
677
+ * Translate a single metadata document (view or action) when an i18n
678
+ * service is registered for the request's project and the requested
679
+ * locale yields a match. Falls through unchanged for unsupported types
680
+ * or missing translations.
681
+ */
682
+ async translateMetaItem(req, type, projectId, item) {
683
+ if (!item || typeof item !== "object") return item;
684
+ if (type !== "view" && type !== "action") return item;
685
+ const i18n = await this.resolveI18nService(projectId);
686
+ const bundle = this.buildTranslationBundle(i18n);
687
+ if (!bundle) return item;
688
+ const locale = this.extractLocale(req, i18n);
689
+ if (!locale) return item;
690
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
691
+ return translateMetadataDocument(type, item, bundle, { locale });
692
+ }
693
+ /**
694
+ * Translate a list of metadata documents using `translateMetaItem`.
695
+ */
696
+ async translateMetaItems(req, type, projectId, items) {
697
+ if (!Array.isArray(items)) return items;
698
+ if (type !== "view" && type !== "action") return items;
699
+ const i18n = await this.resolveI18nService(projectId);
700
+ const bundle = this.buildTranslationBundle(i18n);
701
+ if (!bundle) return items;
702
+ const locale = this.extractLocale(req, i18n);
703
+ if (!locale) return items;
704
+ const { translateMetadataDocument } = await import("@objectstack/spec/system");
705
+ return items.map((item) => translateMetadataDocument(type, item, bundle, { locale }));
706
+ }
707
+ /**
708
+ * Pull the request hostname (without port) from a Node-style `req` or
709
+ * a Fetch-style request wrapper. Returns undefined when no Host header
710
+ * is available.
711
+ */
712
+ extractHostname(req) {
713
+ const headers = req?.headers;
714
+ let host;
715
+ if (headers) {
716
+ if (typeof headers.get === "function") {
717
+ host = headers.get("host") ?? void 0;
718
+ } else {
719
+ host = headers.host ?? headers.Host;
720
+ }
721
+ }
722
+ if (!host && typeof req?.hostname === "string") host = req.hostname;
723
+ if (!host && typeof req?.url === "string") {
724
+ try {
725
+ host = new globalThis.URL(req.url).host;
726
+ } catch {
727
+ }
728
+ }
729
+ if (!host) return void 0;
730
+ return String(host).split(":")[0].toLowerCase();
731
+ }
732
+ /**
733
+ * Pull the `X-Project-Id` header from a Node- or Fetch-style request.
734
+ * Header names are case-insensitive; we probe both casings to cover
735
+ * adapters that don't normalize headers (e.g. raw Node http).
736
+ */
737
+ extractProjectIdHeader(req) {
738
+ const headers = req?.headers;
739
+ if (!headers) return void 0;
740
+ let val;
741
+ if (typeof headers.get === "function") {
742
+ val = headers.get("x-project-id") ?? headers.get("X-Project-Id");
743
+ } else {
744
+ val = headers["x-project-id"] ?? headers["X-Project-Id"];
745
+ }
746
+ if (Array.isArray(val)) val = val[0];
747
+ if (typeof val !== "string") return void 0;
748
+ const trimmed = val.trim();
749
+ return trimmed.length > 0 ? trimmed : void 0;
217
750
  }
218
751
  /**
219
752
  * Normalize configuration with defaults
@@ -234,6 +767,10 @@ var RestServer = class {
234
767
  enableUi: api.enableUi ?? true,
235
768
  enableBatch: api.enableBatch ?? true,
236
769
  enableDiscovery: api.enableDiscovery ?? true,
770
+ enableSearch: api.enableSearch ?? true,
771
+ enableProjectScoping: api.enableProjectScoping ?? false,
772
+ projectResolution: api.projectResolution ?? "auto",
773
+ requireAuth: api.requireAuth ?? false,
237
774
  documentation: api.documentation,
238
775
  responseFormat: api.responseFormat
239
776
  },
@@ -286,52 +823,98 @@ var RestServer = class {
286
823
  const { api } = this.config;
287
824
  return api.apiPath ?? `${api.basePath}/${api.version}`;
288
825
  }
826
+ /**
827
+ * Get the project-scoped base path for a given unscoped base.
828
+ * Example: `/api/v1` → `/api/v1/projects/:projectId`.
829
+ */
830
+ getScopedBasePath(basePath) {
831
+ return `${basePath}/projects/:projectId`;
832
+ }
289
833
  /**
290
834
  * Register all REST API routes
835
+ *
836
+ * When `enableProjectScoping` is true, routes are registered under
837
+ * `/api/v1/projects/:projectId/...`. The `projectResolution` strategy
838
+ * controls whether unscoped legacy routes remain available:
839
+ * - `required` → only scoped routes registered.
840
+ * - `optional` / `auto` → both scoped and unscoped routes registered.
291
841
  */
292
842
  registerRoutes() {
293
843
  const basePath = this.getApiBasePath();
294
- if (this.config.api.enableDiscovery) {
295
- this.registerDiscoveryEndpoints(basePath);
296
- }
297
- if (this.config.api.enableMetadata) {
298
- this.registerMetadataEndpoints(basePath);
299
- }
300
- if (this.config.api.enableUi) {
301
- this.registerUiEndpoints(basePath);
302
- }
303
- if (this.config.api.enableCrud) {
304
- this.registerCrudEndpoints(basePath);
305
- }
306
- if (this.config.api.enableBatch) {
307
- this.registerBatchEndpoints(basePath);
844
+ const { enableProjectScoping, projectResolution } = this.config.api;
845
+ const registerForBase = (bp) => {
846
+ if (this.config.api.enableDiscovery) {
847
+ this.registerDiscoveryEndpoints(bp);
848
+ }
849
+ if (this.config.api.enableMetadata) {
850
+ this.registerMetadataEndpoints(bp);
851
+ }
852
+ if (this.config.api.enableUi) {
853
+ this.registerUiEndpoints(bp);
854
+ }
855
+ if (this.config.api.enableSearch ?? true) {
856
+ this.registerSearchEndpoints(bp);
857
+ }
858
+ this.registerEmailEndpoints(bp);
859
+ this.registerSharingEndpoints(bp);
860
+ this.registerSharingRuleEndpoints(bp);
861
+ this.registerReportsEndpoints(bp);
862
+ this.registerApprovalsEndpoints(bp);
863
+ if (this.config.api.enableCrud) {
864
+ this.registerCrudEndpoints(bp);
865
+ }
866
+ this.registerDataActionEndpoints(bp);
867
+ if (this.config.api.enableBatch) {
868
+ this.registerBatchEndpoints(bp);
869
+ }
870
+ };
871
+ if (enableProjectScoping) {
872
+ const scopedBase = this.getScopedBasePath(basePath);
873
+ if (projectResolution === "required") {
874
+ registerForBase(scopedBase);
875
+ } else {
876
+ registerForBase(basePath);
877
+ registerForBase(scopedBase);
878
+ }
879
+ } else {
880
+ registerForBase(basePath);
308
881
  }
309
882
  }
310
883
  /**
311
884
  * Register discovery endpoints
312
885
  */
313
886
  registerDiscoveryEndpoints(basePath) {
314
- const discoveryHandler = async (_req, res) => {
887
+ const isScoped = basePath.includes("/projects/:projectId");
888
+ const discoveryHandler = async (req, res) => {
315
889
  try {
316
890
  const discovery = await this.protocol.getDiscovery();
317
891
  discovery.version = this.config.api.version;
892
+ const realBase = isScoped ? basePath.replace(":projectId", req.params?.projectId ?? ":projectId") : basePath;
318
893
  if (discovery.routes) {
319
894
  if (this.config.api.enableCrud) {
320
- discovery.routes.data = `${basePath}${this.config.crud.dataPrefix}`;
895
+ discovery.routes.data = `${realBase}${this.config.crud.dataPrefix}`;
321
896
  }
322
897
  if (this.config.api.enableMetadata) {
323
- discovery.routes.metadata = `${basePath}${this.config.metadata.prefix}`;
898
+ discovery.routes.metadata = `${realBase}${this.config.metadata.prefix}`;
324
899
  }
325
900
  if (this.config.api.enableUi) {
326
- discovery.routes.ui = `${basePath}/ui`;
901
+ discovery.routes.ui = `${realBase}/ui`;
327
902
  }
328
903
  if (discovery.routes.auth) {
329
- discovery.routes.auth = `${basePath}/auth`;
904
+ const unscopedBase = isScoped ? basePath.replace(/\/projects\/:projectId$/, "") : basePath;
905
+ discovery.routes.auth = `${unscopedBase}/auth`;
330
906
  }
331
907
  }
908
+ discovery.scoping = {
909
+ enabled: this.config.api.enableProjectScoping,
910
+ resolution: this.config.api.projectResolution,
911
+ scoped: isScoped,
912
+ projectId: isScoped ? req.params?.projectId : void 0
913
+ };
332
914
  res.json(discovery);
333
915
  } catch (error) {
334
- res.status(500).json({ error: error.message });
916
+ logError("[REST] Unhandled error:", error);
917
+ sendError(res, error);
335
918
  }
336
919
  };
337
920
  this.routeManager.register({
@@ -359,16 +942,20 @@ var RestServer = class {
359
942
  registerMetadataEndpoints(basePath) {
360
943
  const { metadata } = this.config;
361
944
  const metaPath = `${basePath}${metadata.prefix}`;
945
+ const isScoped = basePath.includes("/projects/:projectId");
362
946
  if (metadata.endpoints.types !== false) {
363
947
  this.routeManager.register({
364
948
  method: "GET",
365
949
  path: metaPath,
366
- handler: async (_req, res) => {
950
+ handler: async (req, res) => {
367
951
  try {
368
- const types = await this.protocol.getMetaTypes();
952
+ const projectId = isScoped ? req.params?.projectId : void 0;
953
+ const p = await this.resolveProtocol(projectId, req);
954
+ const types = await p.getMetaTypes();
369
955
  res.json(types);
370
956
  } catch (error) {
371
- res.status(500).json({ error: error.message });
957
+ logError("[REST] Unhandled error:", error);
958
+ sendError(res, error);
372
959
  }
373
960
  },
374
961
  metadata: {
@@ -384,10 +971,19 @@ var RestServer = class {
384
971
  handler: async (req, res) => {
385
972
  try {
386
973
  const packageId = req.query?.package || void 0;
387
- const items = await this.protocol.getMetaItems({ type: req.params.type, packageId });
388
- res.json(items);
974
+ const projectId = isScoped ? req.params?.projectId : void 0;
975
+ const p = await this.resolveProtocol(projectId, req);
976
+ const items = await p.getMetaItems({
977
+ type: req.params.type,
978
+ packageId,
979
+ ...projectId ? { projectId } : {}
980
+ });
981
+ const translated = await this.translateMetaItems(req, req.params.type, projectId, items);
982
+ res.header("Vary", "Accept-Language");
983
+ res.json(translated);
389
984
  } catch (error) {
390
- res.status(404).json({ error: error.message });
985
+ logError("[REST] Unhandled error:", error);
986
+ sendError(res, error);
391
987
  }
392
988
  },
393
989
  metadata: {
@@ -402,15 +998,18 @@ var RestServer = class {
402
998
  path: `${metaPath}/:type/:name`,
403
999
  handler: async (req, res) => {
404
1000
  try {
405
- if (metadata.enableCache && this.protocol.getMetaItemCached) {
1001
+ const projectId = isScoped ? req.params?.projectId : void 0;
1002
+ const p = await this.resolveProtocol(projectId, req);
1003
+ if (metadata.enableCache && p.getMetaItemCached) {
406
1004
  const cacheRequest = {
407
1005
  ifNoneMatch: req.headers["if-none-match"],
408
1006
  ifModifiedSince: req.headers["if-modified-since"]
409
1007
  };
410
- const result = await this.protocol.getMetaItemCached({
1008
+ const result = await p.getMetaItemCached({
411
1009
  type: req.params.type,
412
1010
  name: req.params.name,
413
- cacheRequest
1011
+ cacheRequest,
1012
+ ...projectId ? { projectId } : {}
414
1013
  });
415
1014
  if (result.notModified) {
416
1015
  res.status(304).send();
@@ -428,14 +1027,21 @@ var RestServer = class {
428
1027
  const maxAge = result.cacheControl.maxAge ? `, max-age=${result.cacheControl.maxAge}` : "";
429
1028
  res.header("Cache-Control", directives + maxAge);
430
1029
  }
431
- res.json(result.data);
1030
+ res.header("Vary", "Accept-Language");
1031
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, result.data));
432
1032
  } else {
433
1033
  const packageId = req.query?.package || void 0;
434
- const item = await this.protocol.getMetaItem({ type: req.params.type, name: req.params.name, packageId });
435
- res.json(item);
1034
+ const item = await p.getMetaItem({
1035
+ type: req.params.type,
1036
+ name: req.params.name,
1037
+ packageId
1038
+ });
1039
+ res.header("Vary", "Accept-Language");
1040
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
436
1041
  }
437
1042
  } catch (error) {
438
- res.status(404).json({ error: error.message });
1043
+ logError("[REST] Unhandled error:", error);
1044
+ sendError(res, error);
439
1045
  }
440
1046
  },
441
1047
  metadata: {
@@ -449,18 +1055,24 @@ var RestServer = class {
449
1055
  path: `${metaPath}/:type/:name`,
450
1056
  handler: async (req, res) => {
451
1057
  try {
452
- if (!this.protocol.saveMetaItem) {
1058
+ const projectId = isScoped ? req.params?.projectId : void 0;
1059
+ const p = await this.resolveProtocol(projectId, req);
1060
+ if (!p.saveMetaItem) {
453
1061
  res.status(501).json({ error: "Save operation not supported by protocol implementation" });
454
1062
  return;
455
1063
  }
456
- const result = await this.protocol.saveMetaItem({
1064
+ const body = req.body ?? {};
1065
+ const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
1066
+ const result = await p.saveMetaItem({
457
1067
  type: req.params.type,
458
1068
  name: req.params.name,
459
- item: req.body
1069
+ item,
1070
+ ...projectId ? { projectId } : {}
460
1071
  });
461
1072
  res.json(result);
462
1073
  } catch (error) {
463
- res.status(400).json({ error: error.message });
1074
+ logError("[REST] Unhandled error:", error);
1075
+ sendError(res, error);
464
1076
  }
465
1077
  },
466
1078
  metadata: {
@@ -468,28 +1080,119 @@ var RestServer = class {
468
1080
  tags: ["metadata"]
469
1081
  }
470
1082
  });
1083
+ this.routeManager.register({
1084
+ method: "DELETE",
1085
+ path: `${metaPath}/:type/:name`,
1086
+ handler: async (req, res) => {
1087
+ try {
1088
+ const projectId = isScoped ? req.params?.projectId : void 0;
1089
+ const p = await this.resolveProtocol(projectId, req);
1090
+ if (!p.deleteMetaItem) {
1091
+ res.status(501).json({
1092
+ error: "Reset operation not supported by protocol implementation"
1093
+ });
1094
+ return;
1095
+ }
1096
+ const result = await p.deleteMetaItem({
1097
+ type: req.params.type,
1098
+ name: req.params.name,
1099
+ ...projectId ? { projectId } : {}
1100
+ });
1101
+ res.json(result);
1102
+ } catch (error) {
1103
+ logError("[REST] Unhandled error:", error);
1104
+ sendError(res, error);
1105
+ }
1106
+ },
1107
+ metadata: {
1108
+ summary: "Reset metadata item to artifact default (deletes customization overlay)",
1109
+ tags: ["metadata"]
1110
+ }
1111
+ });
1112
+ if (metadata.endpoints.item !== false) {
1113
+ this.routeManager.register({
1114
+ method: "GET",
1115
+ path: `${metaPath}/:type/:section/:name`,
1116
+ handler: async (req, res) => {
1117
+ try {
1118
+ const projectId = isScoped ? req.params?.projectId : void 0;
1119
+ const p = await this.resolveProtocol(projectId, req);
1120
+ const compoundName = `${req.params.section}/${req.params.name}`;
1121
+ const packageId = req.query?.package || void 0;
1122
+ const item = await p.getMetaItem({
1123
+ type: req.params.type,
1124
+ name: compoundName,
1125
+ packageId
1126
+ });
1127
+ res.header("Vary", "Accept-Language");
1128
+ res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
1129
+ } catch (error) {
1130
+ logError("[REST] Unhandled error:", error);
1131
+ sendError(res, error);
1132
+ }
1133
+ },
1134
+ metadata: {
1135
+ summary: "Get specific metadata item by compound name",
1136
+ tags: ["metadata"]
1137
+ }
1138
+ });
1139
+ }
1140
+ this.routeManager.register({
1141
+ method: "PUT",
1142
+ path: `${metaPath}/:type/:section/:name`,
1143
+ handler: async (req, res) => {
1144
+ try {
1145
+ const projectId = isScoped ? req.params?.projectId : void 0;
1146
+ const p = await this.resolveProtocol(projectId, req);
1147
+ if (!p.saveMetaItem) {
1148
+ res.status(501).json({ error: "Save operation not supported by protocol implementation" });
1149
+ return;
1150
+ }
1151
+ const compoundName = `${req.params.section}/${req.params.name}`;
1152
+ const result = await p.saveMetaItem({
1153
+ type: req.params.type,
1154
+ name: compoundName,
1155
+ item: req.body,
1156
+ ...projectId ? { projectId } : {}
1157
+ });
1158
+ res.json(result);
1159
+ } catch (error) {
1160
+ logError("[REST] Unhandled error:", error);
1161
+ sendError(res, error);
1162
+ }
1163
+ },
1164
+ metadata: {
1165
+ summary: "Save specific metadata item by compound name",
1166
+ tags: ["metadata"]
1167
+ }
1168
+ });
471
1169
  }
472
1170
  /**
473
1171
  * Register UI endpoints
474
1172
  */
475
1173
  registerUiEndpoints(basePath) {
476
1174
  const uiPath = `${basePath}/ui`;
1175
+ const isScoped = basePath.includes("/projects/:projectId");
477
1176
  this.routeManager.register({
478
1177
  method: "GET",
479
1178
  path: `${uiPath}/view/:object/:type`,
480
1179
  handler: async (req, res) => {
481
1180
  try {
482
- if (this.protocol.getUiView) {
483
- const view = await this.protocol.getUiView({
1181
+ const projectId = isScoped ? req.params?.projectId : void 0;
1182
+ const p = await this.resolveProtocol(projectId, req);
1183
+ if (p.getUiView) {
1184
+ const view = await p.getUiView({
484
1185
  object: req.params.object,
485
- type: req.params.type
1186
+ type: req.params.type,
1187
+ ...projectId ? { projectId } : {}
486
1188
  });
487
1189
  res.json(view);
488
1190
  } else {
489
1191
  res.status(501).json({ error: "UI View resolution not supported by protocol implementation" });
490
1192
  }
491
1193
  } catch (error) {
492
- res.status(404).json({ error: error.message });
1194
+ logError("[REST] Unhandled error:", error);
1195
+ sendError(res, error, req.params?.object);
493
1196
  }
494
1197
  },
495
1198
  metadata: {
@@ -504,6 +1207,7 @@ var RestServer = class {
504
1207
  registerCrudEndpoints(basePath) {
505
1208
  const { crud } = this.config;
506
1209
  const dataPath = `${basePath}${crud.dataPrefix}`;
1210
+ const isScoped = basePath.includes("/projects/:projectId");
507
1211
  const operations = crud.operations;
508
1212
  if (operations.list) {
509
1213
  this.routeManager.register({
@@ -511,13 +1215,25 @@ var RestServer = class {
511
1215
  path: `${dataPath}/:object`,
512
1216
  handler: async (req, res) => {
513
1217
  try {
514
- const result = await this.protocol.findData({
1218
+ const projectId = isScoped ? req.params?.projectId : void 0;
1219
+ const p = await this.resolveProtocol(projectId, req);
1220
+ const context = await this.resolveExecCtx(projectId, req);
1221
+ if (this.enforceAuth(req, res, context)) return;
1222
+ const result = await p.findData({
515
1223
  object: req.params.object,
516
- query: req.query
1224
+ query: req.query,
1225
+ ...projectId ? { projectId } : {},
1226
+ ...context ? { context } : {}
517
1227
  });
518
1228
  res.json(result);
519
1229
  } catch (error) {
520
- res.status(400).json({ error: error.message });
1230
+ const mapped = mapDataError(error, req.params?.object);
1231
+ if (mapped.status === 404 || mapped.status === 503 || mapped.status === 502) {
1232
+ res.status(mapped.status).json(mapped.body);
1233
+ } else {
1234
+ logError("[REST] Unhandled error:", error);
1235
+ res.status(mapped.status).json(mapped.body);
1236
+ }
521
1237
  }
522
1238
  },
523
1239
  metadata: {
@@ -532,16 +1248,24 @@ var RestServer = class {
532
1248
  path: `${dataPath}/:object/:id`,
533
1249
  handler: async (req, res) => {
534
1250
  try {
1251
+ const projectId = isScoped ? req.params?.projectId : void 0;
1252
+ const p = await this.resolveProtocol(projectId, req);
535
1253
  const { select, expand } = req.query || {};
536
- const result = await this.protocol.getData({
1254
+ const context = await this.resolveExecCtx(projectId, req);
1255
+ if (this.enforceAuth(req, res, context)) return;
1256
+ const result = await p.getData({
537
1257
  object: req.params.object,
538
1258
  id: req.params.id,
539
1259
  ...select != null ? { select } : {},
540
- ...expand != null ? { expand } : {}
1260
+ ...expand != null ? { expand } : {},
1261
+ ...projectId ? { projectId } : {},
1262
+ ...context ? { context } : {}
541
1263
  });
542
1264
  res.json(result);
543
1265
  } catch (error) {
544
- res.status(404).json({ error: error.message });
1266
+ const mapped = mapDataError(error, req.params?.object);
1267
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1268
+ res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
545
1269
  }
546
1270
  },
547
1271
  metadata: {
@@ -556,13 +1280,21 @@ var RestServer = class {
556
1280
  path: `${dataPath}/:object`,
557
1281
  handler: async (req, res) => {
558
1282
  try {
559
- const result = await this.protocol.createData({
1283
+ const projectId = isScoped ? req.params?.projectId : void 0;
1284
+ const p = await this.resolveProtocol(projectId, req);
1285
+ const context = await this.resolveExecCtx(projectId, req);
1286
+ if (this.enforceAuth(req, res, context)) return;
1287
+ const result = await p.createData({
560
1288
  object: req.params.object,
561
- data: req.body
1289
+ data: req.body,
1290
+ ...projectId ? { projectId } : {},
1291
+ ...context ? { context } : {}
562
1292
  });
563
1293
  res.status(201).json(result);
564
1294
  } catch (error) {
565
- res.status(400).json({ error: error.message });
1295
+ const mapped = mapDataError(error, req.params?.object);
1296
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1297
+ res.status(mapped.status).json(mapped.body);
566
1298
  }
567
1299
  },
568
1300
  metadata: {
@@ -571,20 +1303,57 @@ var RestServer = class {
571
1303
  }
572
1304
  });
573
1305
  }
1306
+ if (operations.list) {
1307
+ this.routeManager.register({
1308
+ method: "POST",
1309
+ path: `${dataPath}/:object/query`,
1310
+ handler: async (req, res) => {
1311
+ try {
1312
+ const projectId = isScoped ? req.params?.projectId : void 0;
1313
+ const p = await this.resolveProtocol(projectId, req);
1314
+ const context = await this.resolveExecCtx(projectId, req);
1315
+ if (this.enforceAuth(req, res, context)) return;
1316
+ const result = await p.findData({
1317
+ object: req.params.object,
1318
+ query: req.body || {},
1319
+ ...projectId ? { projectId } : {},
1320
+ ...context ? { context } : {}
1321
+ });
1322
+ res.json(result);
1323
+ } catch (error) {
1324
+ const mapped = mapDataError(error, req.params?.object);
1325
+ if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
1326
+ res.status(mapped.status).json(mapped.body);
1327
+ }
1328
+ },
1329
+ metadata: {
1330
+ summary: "Advanced query (QueryAST in body)",
1331
+ tags: ["data", "crud"]
1332
+ }
1333
+ });
1334
+ }
574
1335
  if (operations.update) {
575
1336
  this.routeManager.register({
576
1337
  method: "PATCH",
577
1338
  path: `${dataPath}/:object/:id`,
578
1339
  handler: async (req, res) => {
579
1340
  try {
580
- const result = await this.protocol.updateData({
1341
+ const projectId = isScoped ? req.params?.projectId : void 0;
1342
+ const p = await this.resolveProtocol(projectId, req);
1343
+ const context = await this.resolveExecCtx(projectId, req);
1344
+ if (this.enforceAuth(req, res, context)) return;
1345
+ const result = await p.updateData({
581
1346
  object: req.params.object,
582
1347
  id: req.params.id,
583
- data: req.body
1348
+ data: req.body,
1349
+ ...projectId ? { projectId } : {},
1350
+ ...context ? { context } : {}
584
1351
  });
585
1352
  res.json(result);
586
1353
  } catch (error) {
587
- res.status(400).json({ error: error.message });
1354
+ const mapped = mapDataError(error, req.params?.object);
1355
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1356
+ res.status(mapped.status).json(mapped.body);
588
1357
  }
589
1358
  },
590
1359
  metadata: {
@@ -599,13 +1368,21 @@ var RestServer = class {
599
1368
  path: `${dataPath}/:object/:id`,
600
1369
  handler: async (req, res) => {
601
1370
  try {
602
- const result = await this.protocol.deleteData({
1371
+ const projectId = isScoped ? req.params?.projectId : void 0;
1372
+ const p = await this.resolveProtocol(projectId, req);
1373
+ const context = await this.resolveExecCtx(projectId, req);
1374
+ if (this.enforceAuth(req, res, context)) return;
1375
+ const result = await p.deleteData({
603
1376
  object: req.params.object,
604
- id: req.params.id
1377
+ id: req.params.id,
1378
+ ...projectId ? { projectId } : {},
1379
+ ...context ? { context } : {}
605
1380
  });
606
1381
  res.json(result);
607
1382
  } catch (error) {
608
- res.status(400).json({ error: error.message });
1383
+ const mapped = mapDataError(error, req.params?.object);
1384
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
1385
+ res.status(mapped.status).json(mapped.body);
609
1386
  }
610
1387
  },
611
1388
  metadata: {
@@ -616,46 +1393,1227 @@ var RestServer = class {
616
1393
  }
617
1394
  }
618
1395
  /**
619
- * Register batch operation endpoints
1396
+ * Register object-specific action endpoints that don't fit the
1397
+ * generic CRUD shape. These are domain operations (Salesforce
1398
+ * convertLead, etc.) where the protocol implementation does its own
1399
+ * multi-record orchestration and we just need a thin HTTP route.
1400
+ *
1401
+ * POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
620
1402
  */
621
- registerBatchEndpoints(basePath) {
622
- const { crud, batch } = this.config;
1403
+ registerDataActionEndpoints(basePath) {
1404
+ const isScoped = basePath.includes("/projects/:projectId");
1405
+ const { crud } = this.config;
623
1406
  const dataPath = `${basePath}${crud.dataPrefix}`;
624
- const operations = batch.operations;
625
- if (batch.enableBatchEndpoint && this.protocol.batchData) {
626
- this.routeManager.register({
627
- method: "POST",
628
- path: `${dataPath}/:object/batch`,
629
- handler: async (req, res) => {
630
- try {
631
- const result = await this.protocol.batchData({
632
- object: req.params.object,
633
- request: req.body
634
- });
635
- res.json(result);
636
- } catch (error) {
637
- res.status(400).json({ error: error.message });
1407
+ this.routeManager.register({
1408
+ method: "POST",
1409
+ path: `${dataPath}/lead/:id/convert`,
1410
+ handler: async (req, res) => {
1411
+ try {
1412
+ const projectId = isScoped ? req.params?.projectId : void 0;
1413
+ const p = await this.resolveProtocol(projectId, req);
1414
+ const context = await this.resolveExecCtx(projectId, req);
1415
+ if (this.enforceAuth(req, res, context)) return;
1416
+ const convertLead = p.convertLead;
1417
+ if (typeof convertLead !== "function") {
1418
+ res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
1419
+ return;
638
1420
  }
639
- },
640
- metadata: {
641
- summary: "Batch operations",
642
- tags: ["data", "batch"]
1421
+ const body = req.body ?? {};
1422
+ const result = await convertLead.call(p, {
1423
+ leadId: req.params.id,
1424
+ accountId: body.accountId,
1425
+ contactId: body.contactId,
1426
+ createOpportunity: body.createOpportunity,
1427
+ opportunity: body.opportunity,
1428
+ convertedStatus: body.convertedStatus,
1429
+ ...context ? { context } : {}
1430
+ });
1431
+ res.json(result);
1432
+ } catch (error) {
1433
+ logError("[REST] Unhandled error:", error);
1434
+ sendError(res, error, "lead");
643
1435
  }
644
- });
645
- }
646
- if (operations.createMany && this.protocol.createManyData) {
647
- this.routeManager.register({
648
- method: "POST",
649
- path: `${dataPath}/:object/createMany`,
650
- handler: async (req, res) => {
651
- try {
652
- const result = await this.protocol.createManyData({
1436
+ },
1437
+ metadata: {
1438
+ summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
1439
+ tags: ["data", "lead"]
1440
+ }
1441
+ });
1442
+ this.routeManager.register({
1443
+ method: "POST",
1444
+ path: `${dataPath}/:object/import`,
1445
+ handler: async (req, res) => {
1446
+ try {
1447
+ const projectId = isScoped ? req.params?.projectId : void 0;
1448
+ const p = await this.resolveProtocol(projectId, req);
1449
+ const context = await this.resolveExecCtx(projectId, req);
1450
+ if (this.enforceAuth(req, res, context)) return;
1451
+ const objectName = String(req.params.object || "");
1452
+ if (!objectName) {
1453
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1454
+ return;
1455
+ }
1456
+ const body = req.body ?? {};
1457
+ const dryRun = body.dryRun === true;
1458
+ const mapping = body.mapping ?? {};
1459
+ let rows = [];
1460
+ if (body.format === "json" && Array.isArray(body.rows)) {
1461
+ rows = body.rows;
1462
+ } else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
1463
+ rows = parseCsvToRows(body.csv, mapping);
1464
+ } else if (Array.isArray(body)) {
1465
+ rows = body;
1466
+ } else {
1467
+ res.status(400).json({
1468
+ code: "INVALID_REQUEST",
1469
+ error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
1470
+ });
1471
+ return;
1472
+ }
1473
+ const max = 5e3;
1474
+ if (rows.length > max) {
1475
+ res.status(413).json({
1476
+ code: "PAYLOAD_TOO_LARGE",
1477
+ error: `Import limit is ${max} rows per request (got ${rows.length})`
1478
+ });
1479
+ return;
1480
+ }
1481
+ const results = [];
1482
+ let okCount = 0;
1483
+ let errCount = 0;
1484
+ for (let i = 0; i < rows.length; i++) {
1485
+ const data = rows[i];
1486
+ try {
1487
+ if (dryRun) {
1488
+ const validate = p.validate;
1489
+ if (typeof validate === "function") {
1490
+ await validate.call(p, { object: objectName, data, context });
1491
+ }
1492
+ results.push({ row: i + 1, ok: true });
1493
+ okCount++;
1494
+ } else {
1495
+ const created = await p.createData({ object: objectName, data, context });
1496
+ const id = created?.id ?? created?.record?.id;
1497
+ results.push({ row: i + 1, ok: true, id });
1498
+ okCount++;
1499
+ }
1500
+ } catch (err) {
1501
+ errCount++;
1502
+ const code = err?.code ?? "IMPORT_ROW_FAILED";
1503
+ const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
1504
+ results.push({ row: i + 1, ok: false, error: message, code });
1505
+ }
1506
+ }
1507
+ res.json({
1508
+ object: objectName,
1509
+ dryRun,
1510
+ total: rows.length,
1511
+ ok: okCount,
1512
+ errors: errCount,
1513
+ results
1514
+ });
1515
+ } catch (error) {
1516
+ logError("[REST] Unhandled error:", error);
1517
+ sendError(res, error, String(req.params?.object || ""));
1518
+ }
1519
+ },
1520
+ metadata: {
1521
+ summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
1522
+ tags: ["data", "import"]
1523
+ }
1524
+ });
1525
+ this.routeManager.register({
1526
+ method: "GET",
1527
+ path: `${dataPath}/:object/export`,
1528
+ handler: async (req, res) => {
1529
+ try {
1530
+ const projectId = isScoped ? req.params?.projectId : void 0;
1531
+ const p = await this.resolveProtocol(projectId, req);
1532
+ const context = await this.resolveExecCtx(projectId, req);
1533
+ if (this.enforceAuth(req, res, context)) return;
1534
+ const objectName = String(req.params.object || "");
1535
+ if (!objectName) {
1536
+ res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
1537
+ return;
1538
+ }
1539
+ const q = req.query ?? {};
1540
+ const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
1541
+ const HARD_CAP = 5e4;
1542
+ const MAX_CHUNK = 5e3;
1543
+ const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
1544
+ const limit = Math.min(requestedLimit, HARD_CAP);
1545
+ const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
1546
+ let filter = void 0;
1547
+ if (typeof q.filter === "string" && q.filter.length > 0) {
1548
+ try {
1549
+ filter = JSON.parse(q.filter);
1550
+ } catch {
1551
+ res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
1552
+ return;
1553
+ }
1554
+ } else if (q.filter && typeof q.filter === "object") {
1555
+ filter = q.filter;
1556
+ }
1557
+ let orderby = void 0;
1558
+ if (typeof q.orderby === "string" && q.orderby.length > 0) {
1559
+ if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
1560
+ try {
1561
+ orderby = JSON.parse(q.orderby);
1562
+ } catch {
1563
+ }
1564
+ } else {
1565
+ const obj = {};
1566
+ for (const part of q.orderby.split(",")) {
1567
+ const [field, dir] = part.split(":").map((s) => s.trim());
1568
+ if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
1569
+ }
1570
+ if (Object.keys(obj).length > 0) orderby = obj;
1571
+ }
1572
+ }
1573
+ let fields;
1574
+ if (typeof q.fields === "string" && q.fields.length > 0) {
1575
+ fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
1576
+ } else if (Array.isArray(q.fields)) {
1577
+ fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
1578
+ }
1579
+ if (!fields || fields.length === 0) {
1580
+ try {
1581
+ const schema = await p.getObjectSchema?.(objectName, projectId);
1582
+ const schemaFields = schema?.fields;
1583
+ if (Array.isArray(schemaFields)) {
1584
+ fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
1585
+ }
1586
+ } catch {
1587
+ }
1588
+ }
1589
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1590
+ const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
1591
+ if (format === "csv") {
1592
+ res.header("Content-Type", "text/csv; charset=utf-8");
1593
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
1594
+ } else {
1595
+ res.header("Content-Type", "application/json; charset=utf-8");
1596
+ res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
1597
+ }
1598
+ res.header("X-Export-Format", format);
1599
+ res.header("X-Export-Limit", String(limit));
1600
+ res.header("Cache-Control", "no-store");
1601
+ let exported = 0;
1602
+ let firstChunk = true;
1603
+ let skip = 0;
1604
+ if (format === "json") res.write("[");
1605
+ while (exported < limit) {
1606
+ const take = Math.min(chunkSize, limit - exported);
1607
+ const findArgs = {
1608
+ object: objectName,
1609
+ query: {
1610
+ ...filter ? { $filter: filter } : {},
1611
+ ...orderby ? { $orderby: orderby } : {},
1612
+ $top: take,
1613
+ $skip: skip
1614
+ },
1615
+ ...projectId ? { projectId } : {},
1616
+ ...context ? { context } : {}
1617
+ };
1618
+ const result = await p.findData(findArgs);
1619
+ const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
1620
+ if (rows.length === 0) break;
1621
+ if (format === "csv") {
1622
+ if ((!fields || fields.length === 0) && firstChunk) {
1623
+ fields = Object.keys(rows[0] ?? {});
1624
+ }
1625
+ const text = rowsToCsv(fields ?? [], rows, firstChunk);
1626
+ res.write(text);
1627
+ } else {
1628
+ for (let i = 0; i < rows.length; i++) {
1629
+ const prefix = firstChunk && i === 0 ? "" : ",";
1630
+ res.write(prefix + JSON.stringify(rows[i]));
1631
+ }
1632
+ }
1633
+ firstChunk = false;
1634
+ exported += rows.length;
1635
+ skip += rows.length;
1636
+ if (rows.length < take) break;
1637
+ }
1638
+ if (format === "json") res.write("]");
1639
+ res.end();
1640
+ } catch (error) {
1641
+ logError("[REST] Unhandled error:", error);
1642
+ try {
1643
+ sendError(res, error, String(req.params?.object || ""));
1644
+ } catch {
1645
+ try {
1646
+ res.end();
1647
+ } catch {
1648
+ }
1649
+ }
1650
+ }
1651
+ },
1652
+ metadata: {
1653
+ summary: "Streaming export of object rows (CSV or JSON)",
1654
+ tags: ["data", "export"]
1655
+ }
1656
+ });
1657
+ }
1658
+ /**
1659
+ * Register global cross-object search endpoint (M10.5).
1660
+ * GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
1661
+ */
1662
+ registerSearchEndpoints(basePath) {
1663
+ const isScoped = basePath.includes("/projects/:projectId");
1664
+ this.routeManager.register({
1665
+ method: "GET",
1666
+ path: `${basePath}/search`,
1667
+ handler: async (req, res) => {
1668
+ try {
1669
+ const projectId = isScoped ? req.params?.projectId : void 0;
1670
+ const p = await this.resolveProtocol(projectId, req);
1671
+ const context = await this.resolveExecCtx(projectId, req);
1672
+ if (this.enforceAuth(req, res, context)) return;
1673
+ const searchAll = p.searchAll;
1674
+ if (typeof searchAll !== "function") {
1675
+ res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
1676
+ return;
1677
+ }
1678
+ const q = String(req.query?.q ?? req.query?.query ?? "");
1679
+ const objectsParam = req.query?.objects;
1680
+ const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
1681
+ const result = await searchAll.call(p, {
1682
+ q,
1683
+ objects,
1684
+ limit: req.query?.limit ? Number(req.query.limit) : void 0,
1685
+ perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
1686
+ ...context ? { context } : {}
1687
+ });
1688
+ res.json(result);
1689
+ } catch (error) {
1690
+ const mapped = mapDataError(error);
1691
+ if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
1692
+ logError("[REST] Unhandled error:", error);
1693
+ }
1694
+ res.status(mapped.status).json(mapped.body);
1695
+ }
1696
+ },
1697
+ metadata: {
1698
+ summary: "Global cross-object search",
1699
+ tags: ["search"]
1700
+ }
1701
+ });
1702
+ }
1703
+ /**
1704
+ * Register email endpoints (M11.B1 / M10.7).
1705
+ *
1706
+ * POST {basePath}/email/send — send a transactional email via the
1707
+ * `IEmailService` provider registered by EmailServicePlugin. Returns
1708
+ * 501 when no provider is wired so deployments without email
1709
+ * configured fail cleanly.
1710
+ *
1711
+ * Request body:
1712
+ * {
1713
+ * to: "a@b.com" | ["a@b.com", { name, address }],
1714
+ * from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
1715
+ * subject: string,
1716
+ * text?: string, html?: string, // at least one required
1717
+ * attachments?: [{ filename, content, contentType?, cid? }],
1718
+ * headers?: { [name]: value },
1719
+ * relatedObject?: string, relatedId?: string,
1720
+ * }
1721
+ */
1722
+ registerEmailEndpoints(basePath) {
1723
+ const isScoped = basePath.includes("/projects/:projectId");
1724
+ this.routeManager.register({
1725
+ method: "POST",
1726
+ path: `${basePath}/email/send`,
1727
+ handler: async (req, res) => {
1728
+ try {
1729
+ const projectId = isScoped ? req.params?.projectId : void 0;
1730
+ const context = await this.resolveExecCtx(projectId, req);
1731
+ if (this.enforceAuth(req, res, context)) return;
1732
+ if (!this.emailServiceProvider) {
1733
+ res.status(501).json({
1734
+ code: "NOT_IMPLEMENTED",
1735
+ message: "Email service is not configured on this deployment"
1736
+ });
1737
+ return;
1738
+ }
1739
+ const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
1740
+ if (!emailService || typeof emailService.send !== "function") {
1741
+ res.status(501).json({
1742
+ code: "NOT_IMPLEMENTED",
1743
+ message: "Email service is not configured on this deployment"
1744
+ });
1745
+ return;
1746
+ }
1747
+ const body = req.body ?? {};
1748
+ if (!body || typeof body !== "object") {
1749
+ res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
1750
+ return;
1751
+ }
1752
+ const input = {
1753
+ ...body,
1754
+ ...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
1755
+ };
1756
+ try {
1757
+ const result = await emailService.send(input);
1758
+ if (result?.status === "sent") {
1759
+ res.status(200).json(result);
1760
+ } else {
1761
+ res.status(200).json(result);
1762
+ }
1763
+ } catch (err) {
1764
+ const message = String(err?.message ?? err ?? "send failed");
1765
+ if (message.startsWith("VALIDATION_FAILED")) {
1766
+ res.status(400).json({
1767
+ code: "VALIDATION_FAILED",
1768
+ error: message.replace(/^VALIDATION_FAILED:\s*/, "")
1769
+ });
1770
+ return;
1771
+ }
1772
+ throw err;
1773
+ }
1774
+ } catch (error) {
1775
+ logError("[REST] Email send unhandled error:", error);
1776
+ res.status(500).json({
1777
+ code: "EMAIL_SEND_FAILED",
1778
+ error: String(error?.message ?? error ?? "send failed").slice(0, 500)
1779
+ });
1780
+ }
1781
+ },
1782
+ metadata: {
1783
+ summary: "Send a transactional email via the configured EmailService",
1784
+ tags: ["email"]
1785
+ }
1786
+ });
1787
+ }
1788
+ /**
1789
+ * Register record-level sharing endpoints (M11.C17).
1790
+ *
1791
+ * Surfaces `ISharingService` over HTTP so the UI can list, create
1792
+ * and revoke per-record grants without going through ObjectQL. The
1793
+ * three routes mirror the share-management drawer in Salesforce /
1794
+ * ServiceNow:
1795
+ *
1796
+ * GET {basePath}/data/:object/:id/shares
1797
+ * POST {basePath}/data/:object/:id/shares
1798
+ * DELETE {basePath}/data/:object/:id/shares/:shareId
1799
+ *
1800
+ * All three resolve via `sharingServiceProvider`; routes return 501
1801
+ * when no sharing service is configured so a deployment without the
1802
+ * `@objectstack/plugin-sharing` plugin fails cleanly.
1803
+ */
1804
+ registerSharingEndpoints(basePath) {
1805
+ const { crud } = this.config;
1806
+ const dataPath = `${basePath}${crud.dataPrefix}`;
1807
+ const isScoped = basePath.includes("/projects/:projectId");
1808
+ const resolveService = async (projectId) => {
1809
+ if (!this.sharingServiceProvider) return void 0;
1810
+ try {
1811
+ return await this.sharingServiceProvider(projectId);
1812
+ } catch {
1813
+ return void 0;
1814
+ }
1815
+ };
1816
+ const respond501 = (res) => res.status(501).json({
1817
+ code: "NOT_IMPLEMENTED",
1818
+ message: "Sharing service is not configured on this deployment"
1819
+ });
1820
+ this.routeManager.register({
1821
+ method: "GET",
1822
+ path: `${dataPath}/:object/:id/shares`,
1823
+ handler: async (req, res) => {
1824
+ try {
1825
+ const projectId = isScoped ? req.params?.projectId : void 0;
1826
+ const context = await this.resolveExecCtx(projectId, req);
1827
+ if (this.enforceAuth(req, res, context)) return;
1828
+ const svc = await resolveService(projectId);
1829
+ if (!svc) return respond501(res);
1830
+ const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
1831
+ res.json({ data: rows });
1832
+ } catch (error) {
1833
+ logError("[REST] List shares error:", error);
1834
+ res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1835
+ }
1836
+ },
1837
+ metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
1838
+ });
1839
+ this.routeManager.register({
1840
+ method: "POST",
1841
+ path: `${dataPath}/:object/:id/shares`,
1842
+ handler: async (req, res) => {
1843
+ try {
1844
+ const projectId = isScoped ? req.params?.projectId : void 0;
1845
+ const context = await this.resolveExecCtx(projectId, req);
1846
+ if (this.enforceAuth(req, res, context)) return;
1847
+ const svc = await resolveService(projectId);
1848
+ if (!svc) return respond501(res);
1849
+ const body = req.body ?? {};
1850
+ const input = {
1851
+ object: req.params.object,
1852
+ recordId: req.params.id,
1853
+ recipientType: body.recipientType ?? body.recipient_type,
1854
+ recipientId: body.recipientId ?? body.recipient_id,
1855
+ accessLevel: body.accessLevel ?? body.access_level,
1856
+ source: body.source,
1857
+ sourceId: body.sourceId ?? body.source_id,
1858
+ reason: body.reason
1859
+ };
1860
+ try {
1861
+ const row = await svc.grant(input, context ?? {});
1862
+ res.status(201).json(row);
1863
+ } catch (err) {
1864
+ const msg = String(err?.message ?? err ?? "");
1865
+ if (msg.startsWith("VALIDATION_FAILED")) {
1866
+ res.status(400).json({
1867
+ code: "VALIDATION_FAILED",
1868
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
1869
+ });
1870
+ return;
1871
+ }
1872
+ throw err;
1873
+ }
1874
+ } catch (error) {
1875
+ logError("[REST] Grant share error:", error);
1876
+ res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1877
+ }
1878
+ },
1879
+ metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
1880
+ });
1881
+ this.routeManager.register({
1882
+ method: "DELETE",
1883
+ path: `${dataPath}/:object/:id/shares/:shareId`,
1884
+ handler: async (req, res) => {
1885
+ try {
1886
+ const projectId = isScoped ? req.params?.projectId : void 0;
1887
+ const context = await this.resolveExecCtx(projectId, req);
1888
+ if (this.enforceAuth(req, res, context)) return;
1889
+ const svc = await resolveService(projectId);
1890
+ if (!svc) return respond501(res);
1891
+ await svc.revoke(req.params.shareId, context ?? {});
1892
+ res.status(204).end();
1893
+ } catch (error) {
1894
+ logError("[REST] Revoke share error:", error);
1895
+ res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
1896
+ }
1897
+ },
1898
+ metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
1899
+ });
1900
+ }
1901
+ /**
1902
+ * Register sharing-rule endpoints (M10.17). Mirrors the existing
1903
+ * sharing endpoints but operates on `sys_sharing_rule` rows.
1904
+ *
1905
+ * GET {basePath}/sharing/rules?object=&activeOnly=
1906
+ * POST {basePath}/sharing/rules
1907
+ * GET {basePath}/sharing/rules/:idOrName
1908
+ * DELETE {basePath}/sharing/rules/:idOrName
1909
+ * POST {basePath}/sharing/rules/:idOrName/evaluate
1910
+ *
1911
+ * Returns 501 when no sharing-rule service is configured.
1912
+ */
1913
+ registerSharingRuleEndpoints(basePath) {
1914
+ const dataPath = basePath;
1915
+ const isScoped = basePath.includes("/projects/:projectId");
1916
+ const resolveService = async (projectId) => {
1917
+ if (!this.sharingRulesServiceProvider) return void 0;
1918
+ try {
1919
+ return await this.sharingRulesServiceProvider(projectId);
1920
+ } catch {
1921
+ return void 0;
1922
+ }
1923
+ };
1924
+ const respond501 = (res) => res.status(501).json({
1925
+ code: "NOT_IMPLEMENTED",
1926
+ message: "Sharing-rule service is not configured on this deployment"
1927
+ });
1928
+ const handleError = (err, res, defaultCode) => {
1929
+ const msg = String(err?.message ?? err ?? "");
1930
+ if (msg.startsWith("VALIDATION_FAILED")) {
1931
+ return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
1932
+ }
1933
+ if (msg.startsWith("RULE_NOT_FOUND")) {
1934
+ return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
1935
+ }
1936
+ logError(`[REST] sharing-rule ${defaultCode}:`, err);
1937
+ return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
1938
+ };
1939
+ this.routeManager.register({
1940
+ method: "GET",
1941
+ path: `${dataPath}/sharing/rules`,
1942
+ handler: async (req, res) => {
1943
+ try {
1944
+ const projectId = isScoped ? req.params?.projectId : void 0;
1945
+ const context = await this.resolveExecCtx(projectId, req);
1946
+ if (this.enforceAuth(req, res, context)) return;
1947
+ const svc = await resolveService(projectId);
1948
+ if (!svc) return respond501(res);
1949
+ const rows = await svc.listRules({
1950
+ object: req.query?.object,
1951
+ activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
1952
+ }, context ?? {});
1953
+ res.json({ data: rows });
1954
+ } catch (err) {
1955
+ handleError(err, res, "RULE_LIST_FAILED");
1956
+ }
1957
+ },
1958
+ metadata: { summary: "List sharing rules", tags: ["sharing"] }
1959
+ });
1960
+ this.routeManager.register({
1961
+ method: "POST",
1962
+ path: `${dataPath}/sharing/rules`,
1963
+ handler: async (req, res) => {
1964
+ try {
1965
+ const projectId = isScoped ? req.params?.projectId : void 0;
1966
+ const context = await this.resolveExecCtx(projectId, req);
1967
+ if (this.enforceAuth(req, res, context)) return;
1968
+ const svc = await resolveService(projectId);
1969
+ if (!svc) return respond501(res);
1970
+ const body = req.body ?? {};
1971
+ const input = {
1972
+ name: body.name,
1973
+ label: body.label,
1974
+ description: body.description,
1975
+ object: body.object ?? body.object_name,
1976
+ criteria: body.criteria,
1977
+ recipientType: body.recipientType ?? body.recipient_type,
1978
+ recipientId: body.recipientId ?? body.recipient_id,
1979
+ accessLevel: body.accessLevel ?? body.access_level,
1980
+ active: body.active
1981
+ };
1982
+ const row = await svc.defineRule(input, context ?? {});
1983
+ res.status(201).json(row);
1984
+ } catch (err) {
1985
+ handleError(err, res, "RULE_DEFINE_FAILED");
1986
+ }
1987
+ },
1988
+ metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
1989
+ });
1990
+ this.routeManager.register({
1991
+ method: "GET",
1992
+ path: `${dataPath}/sharing/rules/:idOrName`,
1993
+ handler: async (req, res) => {
1994
+ try {
1995
+ const projectId = isScoped ? req.params?.projectId : void 0;
1996
+ const context = await this.resolveExecCtx(projectId, req);
1997
+ if (this.enforceAuth(req, res, context)) return;
1998
+ const svc = await resolveService(projectId);
1999
+ if (!svc) return respond501(res);
2000
+ const row = await svc.getRule(req.params.idOrName, context ?? {});
2001
+ if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
2002
+ res.json(row);
2003
+ } catch (err) {
2004
+ handleError(err, res, "RULE_GET_FAILED");
2005
+ }
2006
+ },
2007
+ metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
2008
+ });
2009
+ this.routeManager.register({
2010
+ method: "DELETE",
2011
+ path: `${dataPath}/sharing/rules/:idOrName`,
2012
+ handler: async (req, res) => {
2013
+ try {
2014
+ const projectId = isScoped ? req.params?.projectId : void 0;
2015
+ const context = await this.resolveExecCtx(projectId, req);
2016
+ if (this.enforceAuth(req, res, context)) return;
2017
+ const svc = await resolveService(projectId);
2018
+ if (!svc) return respond501(res);
2019
+ await svc.deleteRule(req.params.idOrName, context ?? {});
2020
+ res.status(204).end();
2021
+ } catch (err) {
2022
+ handleError(err, res, "RULE_DELETE_FAILED");
2023
+ }
2024
+ },
2025
+ metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
2026
+ });
2027
+ this.routeManager.register({
2028
+ method: "POST",
2029
+ path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
2030
+ handler: async (req, res) => {
2031
+ try {
2032
+ const projectId = isScoped ? req.params?.projectId : void 0;
2033
+ const context = await this.resolveExecCtx(projectId, req);
2034
+ if (this.enforceAuth(req, res, context)) return;
2035
+ const svc = await resolveService(projectId);
2036
+ if (!svc) return respond501(res);
2037
+ const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
2038
+ res.json(result);
2039
+ } catch (err) {
2040
+ handleError(err, res, "RULE_EVALUATE_FAILED");
2041
+ }
2042
+ },
2043
+ metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
2044
+ });
2045
+ }
2046
+ /**
2047
+ * Register saved-report + scheduled-digest endpoints (M11.C16).
2048
+ *
2049
+ * Surfaces `IReportService` over HTTP so the UI can build,
2050
+ * run, and schedule reports without dropping to ObjectQL. Routes
2051
+ * live at the top of the API surface (alongside `/approvals` and
2052
+ * `/sharing`) — reports are a tenant-wide capability, not a record
2053
+ * on a specific CRUD object:
2054
+ *
2055
+ * GET {basePath}/reports?object=&ownerId=
2056
+ * POST {basePath}/reports
2057
+ * GET {basePath}/reports/:id
2058
+ * DELETE {basePath}/reports/:id
2059
+ * POST {basePath}/reports/:id/run
2060
+ * POST {basePath}/reports/:id/schedule
2061
+ * GET {basePath}/reports/:id/schedules
2062
+ * DELETE {basePath}/reports/schedules/:scheduleId
2063
+ *
2064
+ * All routes return 501 when `reportsServiceProvider` is unset so
2065
+ * a deployment without `@objectstack/plugin-reports` fails cleanly.
2066
+ */
2067
+ registerReportsEndpoints(basePath) {
2068
+ const dataPath = basePath;
2069
+ const isScoped = basePath.includes("/projects/:projectId");
2070
+ const resolveService = async (projectId) => {
2071
+ if (!this.reportsServiceProvider) return void 0;
2072
+ try {
2073
+ return await this.reportsServiceProvider(projectId);
2074
+ } catch {
2075
+ return void 0;
2076
+ }
2077
+ };
2078
+ const respond501 = (res) => res.status(501).json({
2079
+ code: "NOT_IMPLEMENTED",
2080
+ message: "Reports service is not configured on this deployment"
2081
+ });
2082
+ const handleValidation = (res, err) => {
2083
+ const msg = String(err?.message ?? err ?? "");
2084
+ if (msg.startsWith("VALIDATION_FAILED")) {
2085
+ res.status(400).json({
2086
+ code: "VALIDATION_FAILED",
2087
+ error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
2088
+ });
2089
+ return true;
2090
+ }
2091
+ if (msg.startsWith("REPORT_NOT_FOUND")) {
2092
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
2093
+ return true;
2094
+ }
2095
+ return false;
2096
+ };
2097
+ this.routeManager.register({
2098
+ method: "GET",
2099
+ path: `${dataPath}/reports`,
2100
+ handler: async (req, res) => {
2101
+ try {
2102
+ const projectId = isScoped ? req.params?.projectId : void 0;
2103
+ const context = await this.resolveExecCtx(projectId, req);
2104
+ if (this.enforceAuth(req, res, context)) return;
2105
+ const svc = await resolveService(projectId);
2106
+ if (!svc) return respond501(res);
2107
+ const q = req.query ?? {};
2108
+ const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
2109
+ res.json({ data: rows });
2110
+ } catch (error) {
2111
+ logError("[REST] List reports error:", error);
2112
+ res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2113
+ }
2114
+ },
2115
+ metadata: { summary: "List saved reports", tags: ["reports"] }
2116
+ });
2117
+ this.routeManager.register({
2118
+ method: "POST",
2119
+ path: `${dataPath}/reports`,
2120
+ handler: async (req, res) => {
2121
+ try {
2122
+ const projectId = isScoped ? req.params?.projectId : void 0;
2123
+ const context = await this.resolveExecCtx(projectId, req);
2124
+ if (this.enforceAuth(req, res, context)) return;
2125
+ const svc = await resolveService(projectId);
2126
+ if (!svc) return respond501(res);
2127
+ try {
2128
+ const row = await svc.saveReport(req.body ?? {}, context ?? {});
2129
+ res.status(201).json(row);
2130
+ } catch (err) {
2131
+ if (handleValidation(res, err)) return;
2132
+ throw err;
2133
+ }
2134
+ } catch (error) {
2135
+ logError("[REST] Save report error:", error);
2136
+ res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2137
+ }
2138
+ },
2139
+ metadata: { summary: "Create or update a saved report", tags: ["reports"] }
2140
+ });
2141
+ this.routeManager.register({
2142
+ method: "GET",
2143
+ path: `${dataPath}/reports/:id`,
2144
+ handler: async (req, res) => {
2145
+ try {
2146
+ const projectId = isScoped ? req.params?.projectId : void 0;
2147
+ const context = await this.resolveExecCtx(projectId, req);
2148
+ if (this.enforceAuth(req, res, context)) return;
2149
+ const svc = await resolveService(projectId);
2150
+ if (!svc) return respond501(res);
2151
+ const row = await svc.getReport(req.params.id, context ?? {});
2152
+ if (!row) {
2153
+ res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
2154
+ return;
2155
+ }
2156
+ res.json(row);
2157
+ } catch (error) {
2158
+ logError("[REST] Get report error:", error);
2159
+ res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2160
+ }
2161
+ },
2162
+ metadata: { summary: "Get a saved report by id", tags: ["reports"] }
2163
+ });
2164
+ this.routeManager.register({
2165
+ method: "DELETE",
2166
+ path: `${dataPath}/reports/:id`,
2167
+ handler: async (req, res) => {
2168
+ try {
2169
+ const projectId = isScoped ? req.params?.projectId : void 0;
2170
+ const context = await this.resolveExecCtx(projectId, req);
2171
+ if (this.enforceAuth(req, res, context)) return;
2172
+ const svc = await resolveService(projectId);
2173
+ if (!svc) return respond501(res);
2174
+ await svc.deleteReport(req.params.id, context ?? {});
2175
+ res.status(204).end();
2176
+ } catch (error) {
2177
+ logError("[REST] Delete report error:", error);
2178
+ res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2179
+ }
2180
+ },
2181
+ metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
2182
+ });
2183
+ this.routeManager.register({
2184
+ method: "POST",
2185
+ path: `${dataPath}/reports/:id/run`,
2186
+ handler: async (req, res) => {
2187
+ try {
2188
+ const projectId = isScoped ? req.params?.projectId : void 0;
2189
+ const context = await this.resolveExecCtx(projectId, req);
2190
+ if (this.enforceAuth(req, res, context)) return;
2191
+ const svc = await resolveService(projectId);
2192
+ if (!svc) return respond501(res);
2193
+ try {
2194
+ const result = await svc.run(req.params.id, context ?? {});
2195
+ res.json(result);
2196
+ } catch (err) {
2197
+ if (handleValidation(res, err)) return;
2198
+ throw err;
2199
+ }
2200
+ } catch (error) {
2201
+ logError("[REST] Run report error:", error);
2202
+ res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2203
+ }
2204
+ },
2205
+ metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
2206
+ });
2207
+ this.routeManager.register({
2208
+ method: "POST",
2209
+ path: `${dataPath}/reports/:id/schedule`,
2210
+ handler: async (req, res) => {
2211
+ try {
2212
+ const projectId = isScoped ? req.params?.projectId : void 0;
2213
+ const context = await this.resolveExecCtx(projectId, req);
2214
+ if (this.enforceAuth(req, res, context)) return;
2215
+ const svc = await resolveService(projectId);
2216
+ if (!svc) return respond501(res);
2217
+ const body = req.body ?? {};
2218
+ try {
2219
+ const row = await svc.scheduleReport({
2220
+ reportId: req.params.id,
2221
+ recipients: body.recipients ?? [],
2222
+ name: body.name,
2223
+ intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
2224
+ cronExpression: body.cronExpression ?? body.cron_expression,
2225
+ timezone: body.timezone,
2226
+ format: body.format,
2227
+ subjectTemplate: body.subjectTemplate ?? body.subject_template,
2228
+ ownerId: body.ownerId ?? body.owner_id,
2229
+ active: body.active
2230
+ }, context ?? {});
2231
+ res.status(201).json(row);
2232
+ } catch (err) {
2233
+ if (handleValidation(res, err)) return;
2234
+ throw err;
2235
+ }
2236
+ } catch (error) {
2237
+ logError("[REST] Schedule report error:", error);
2238
+ res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2239
+ }
2240
+ },
2241
+ metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
2242
+ });
2243
+ this.routeManager.register({
2244
+ method: "GET",
2245
+ path: `${dataPath}/reports/:id/schedules`,
2246
+ handler: async (req, res) => {
2247
+ try {
2248
+ const projectId = isScoped ? req.params?.projectId : void 0;
2249
+ const context = await this.resolveExecCtx(projectId, req);
2250
+ if (this.enforceAuth(req, res, context)) return;
2251
+ const svc = await resolveService(projectId);
2252
+ if (!svc) return respond501(res);
2253
+ const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
2254
+ res.json({ data: rows });
2255
+ } catch (error) {
2256
+ logError("[REST] List schedules error:", error);
2257
+ res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2258
+ }
2259
+ },
2260
+ metadata: { summary: "List schedules for a report", tags: ["reports"] }
2261
+ });
2262
+ this.routeManager.register({
2263
+ method: "DELETE",
2264
+ path: `${dataPath}/reports/schedules/:scheduleId`,
2265
+ handler: async (req, res) => {
2266
+ try {
2267
+ const projectId = isScoped ? req.params?.projectId : void 0;
2268
+ const context = await this.resolveExecCtx(projectId, req);
2269
+ if (this.enforceAuth(req, res, context)) return;
2270
+ const svc = await resolveService(projectId);
2271
+ if (!svc) return respond501(res);
2272
+ await svc.unscheduleReport(req.params.scheduleId, context ?? {});
2273
+ res.status(204).end();
2274
+ } catch (error) {
2275
+ logError("[REST] Unschedule report error:", error);
2276
+ res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2277
+ }
2278
+ },
2279
+ metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
2280
+ });
2281
+ }
2282
+ /**
2283
+ * Register approval engine endpoints.
2284
+ *
2285
+ * Routes (all under {basePath}/approvals):
2286
+ * GET /processes — list approval processes
2287
+ * POST /processes — upsert (defineProcess)
2288
+ * GET /processes/:id — get by id or name
2289
+ * DELETE /processes/:id — delete process
2290
+ * POST /requests — submit
2291
+ * GET /requests — list (filters: status, object, recordId, approverId, submitterId)
2292
+ * GET /requests/:id — get request
2293
+ * POST /requests/:id/approve — approve current step
2294
+ * POST /requests/:id/reject — reject current step
2295
+ * POST /requests/:id/recall — recall (submitter only)
2296
+ * GET /requests/:id/actions — audit trail
2297
+ *
2298
+ * Returns 501 when `approvalsServiceProvider` is unset so deployments
2299
+ * without `@objectstack/plugin-approvals` fail cleanly.
2300
+ */
2301
+ registerApprovalsEndpoints(basePath) {
2302
+ const dataPath = basePath;
2303
+ const isScoped = basePath.includes("/projects/:projectId");
2304
+ const resolveService = async (projectId) => {
2305
+ if (!this.approvalsServiceProvider) return void 0;
2306
+ try {
2307
+ return await this.approvalsServiceProvider(projectId);
2308
+ } catch {
2309
+ return void 0;
2310
+ }
2311
+ };
2312
+ const respond501 = (res) => res.status(501).json({
2313
+ code: "NOT_IMPLEMENTED",
2314
+ message: "Approvals service is not configured on this deployment"
2315
+ });
2316
+ const handleApprovalError = (res, err) => {
2317
+ const msg = String(err?.message ?? err ?? "");
2318
+ const mapping = [
2319
+ [/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
2320
+ [/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
2321
+ [/^INVALID_STATE/, 409, "INVALID_STATE"],
2322
+ [/^FORBIDDEN/, 403, "FORBIDDEN"],
2323
+ [/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
2324
+ [/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
2325
+ [/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
2326
+ ];
2327
+ for (const [re, status, code] of mapping) {
2328
+ if (re.test(msg)) {
2329
+ res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
2330
+ return true;
2331
+ }
2332
+ }
2333
+ return false;
2334
+ };
2335
+ this.routeManager.register({
2336
+ method: "GET",
2337
+ path: `${dataPath}/approvals/processes`,
2338
+ handler: async (req, res) => {
2339
+ try {
2340
+ const projectId = isScoped ? req.params?.projectId : void 0;
2341
+ const context = await this.resolveExecCtx(projectId, req);
2342
+ if (this.enforceAuth(req, res, context)) return;
2343
+ const svc = await resolveService(projectId);
2344
+ if (!svc) return respond501(res);
2345
+ const q = req.query ?? {};
2346
+ const rows = await svc.listProcesses({
2347
+ object: q.object,
2348
+ activeOnly: q.activeOnly === "true" || q.activeOnly === true
2349
+ }, context ?? {});
2350
+ res.json({ data: rows });
2351
+ } catch (error) {
2352
+ logError("[REST] List approval processes error:", error);
2353
+ res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2354
+ }
2355
+ },
2356
+ metadata: { summary: "List approval processes", tags: ["approvals"] }
2357
+ });
2358
+ this.routeManager.register({
2359
+ method: "POST",
2360
+ path: `${dataPath}/approvals/processes`,
2361
+ handler: async (req, res) => {
2362
+ try {
2363
+ const projectId = isScoped ? req.params?.projectId : void 0;
2364
+ const context = await this.resolveExecCtx(projectId, req);
2365
+ if (this.enforceAuth(req, res, context)) return;
2366
+ const svc = await resolveService(projectId);
2367
+ if (!svc) return respond501(res);
2368
+ try {
2369
+ const row = await svc.defineProcess(req.body ?? {}, context ?? {});
2370
+ res.status(201).json(row);
2371
+ } catch (err) {
2372
+ if (handleApprovalError(res, err)) return;
2373
+ throw err;
2374
+ }
2375
+ } catch (error) {
2376
+ logError("[REST] Define approval process error:", error);
2377
+ res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2378
+ }
2379
+ },
2380
+ metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
2381
+ });
2382
+ this.routeManager.register({
2383
+ method: "GET",
2384
+ path: `${dataPath}/approvals/processes/:id`,
2385
+ handler: async (req, res) => {
2386
+ try {
2387
+ const projectId = isScoped ? req.params?.projectId : void 0;
2388
+ const context = await this.resolveExecCtx(projectId, req);
2389
+ if (this.enforceAuth(req, res, context)) return;
2390
+ const svc = await resolveService(projectId);
2391
+ if (!svc) return respond501(res);
2392
+ const row = await svc.getProcess(req.params.id, context ?? {});
2393
+ if (!row) {
2394
+ res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
2395
+ return;
2396
+ }
2397
+ res.json(row);
2398
+ } catch (error) {
2399
+ logError("[REST] Get approval process error:", error);
2400
+ res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2401
+ }
2402
+ },
2403
+ metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
2404
+ });
2405
+ this.routeManager.register({
2406
+ method: "DELETE",
2407
+ path: `${dataPath}/approvals/processes/:id`,
2408
+ handler: async (req, res) => {
2409
+ try {
2410
+ const projectId = isScoped ? req.params?.projectId : void 0;
2411
+ const context = await this.resolveExecCtx(projectId, req);
2412
+ if (this.enforceAuth(req, res, context)) return;
2413
+ const svc = await resolveService(projectId);
2414
+ if (!svc) return respond501(res);
2415
+ await svc.deleteProcess(req.params.id, context ?? {});
2416
+ res.status(204).end();
2417
+ } catch (error) {
2418
+ logError("[REST] Delete approval process error:", error);
2419
+ res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2420
+ }
2421
+ },
2422
+ metadata: { summary: "Delete an approval process", tags: ["approvals"] }
2423
+ });
2424
+ this.routeManager.register({
2425
+ method: "POST",
2426
+ path: `${dataPath}/approvals/requests`,
2427
+ handler: async (req, res) => {
2428
+ try {
2429
+ const projectId = isScoped ? req.params?.projectId : void 0;
2430
+ const context = await this.resolveExecCtx(projectId, req);
2431
+ if (this.enforceAuth(req, res, context)) return;
2432
+ const svc = await resolveService(projectId);
2433
+ if (!svc) return respond501(res);
2434
+ const body = req.body ?? {};
2435
+ try {
2436
+ const row = await svc.submit({
2437
+ object: body.object,
2438
+ recordId: body.recordId ?? body.record_id,
2439
+ processName: body.processName ?? body.process_name,
2440
+ submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
2441
+ comment: body.comment,
2442
+ payload: body.payload
2443
+ }, context ?? {});
2444
+ res.status(201).json(row);
2445
+ } catch (err) {
2446
+ if (handleApprovalError(res, err)) return;
2447
+ throw err;
2448
+ }
2449
+ } catch (error) {
2450
+ logError("[REST] Submit approval error:", error);
2451
+ res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2452
+ }
2453
+ },
2454
+ metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
2455
+ });
2456
+ this.routeManager.register({
2457
+ method: "GET",
2458
+ path: `${dataPath}/approvals/requests`,
2459
+ handler: async (req, res) => {
2460
+ try {
2461
+ const projectId = isScoped ? req.params?.projectId : void 0;
2462
+ const context = await this.resolveExecCtx(projectId, req);
2463
+ if (this.enforceAuth(req, res, context)) return;
2464
+ const svc = await resolveService(projectId);
2465
+ if (!svc) {
2466
+ res.json({ data: [] });
2467
+ return;
2468
+ }
2469
+ const q = req.query ?? {};
2470
+ const rows = await svc.listRequests({
2471
+ object: q.object,
2472
+ recordId: q.recordId ?? q.record_id,
2473
+ status: q.status,
2474
+ approverId: q.approverId ?? q.approver_id,
2475
+ submitterId: q.submitterId ?? q.submitter_id
2476
+ }, context ?? {});
2477
+ res.json({ data: rows });
2478
+ } catch (error) {
2479
+ logError("[REST] List approval requests error:", error);
2480
+ res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2481
+ }
2482
+ },
2483
+ metadata: { summary: "List approval requests", tags: ["approvals"] }
2484
+ });
2485
+ this.routeManager.register({
2486
+ method: "GET",
2487
+ path: `${dataPath}/approvals/requests/:id`,
2488
+ handler: async (req, res) => {
2489
+ try {
2490
+ const projectId = isScoped ? req.params?.projectId : void 0;
2491
+ const context = await this.resolveExecCtx(projectId, req);
2492
+ if (this.enforceAuth(req, res, context)) return;
2493
+ const svc = await resolveService(projectId);
2494
+ if (!svc) return respond501(res);
2495
+ const row = await svc.getRequest(req.params.id, context ?? {});
2496
+ if (!row) {
2497
+ res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
2498
+ return;
2499
+ }
2500
+ res.json(row);
2501
+ } catch (error) {
2502
+ logError("[REST] Get approval request error:", error);
2503
+ res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2504
+ }
2505
+ },
2506
+ metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
2507
+ });
2508
+ const decisionRoute = (suffix, method) => {
2509
+ this.routeManager.register({
2510
+ method: "POST",
2511
+ path: `${dataPath}/approvals/requests/:id/${suffix}`,
2512
+ handler: async (req, res) => {
2513
+ try {
2514
+ const projectId = isScoped ? req.params?.projectId : void 0;
2515
+ const context = await this.resolveExecCtx(projectId, req);
2516
+ if (this.enforceAuth(req, res, context)) return;
2517
+ const svc = await resolveService(projectId);
2518
+ if (!svc) return respond501(res);
2519
+ const body = req.body ?? {};
2520
+ try {
2521
+ const out = await svc[method](req.params.id, {
2522
+ actorId: body.actorId ?? body.actor_id ?? context?.userId,
2523
+ comment: body.comment
2524
+ }, context ?? {});
2525
+ res.json(out);
2526
+ } catch (err) {
2527
+ if (handleApprovalError(res, err)) return;
2528
+ throw err;
2529
+ }
2530
+ } catch (error) {
2531
+ logError(`[REST] ${suffix} approval error:`, error);
2532
+ res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
2533
+ }
2534
+ },
2535
+ metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
2536
+ });
2537
+ };
2538
+ decisionRoute("approve", "approve");
2539
+ decisionRoute("reject", "reject");
2540
+ decisionRoute("recall", "recall");
2541
+ this.routeManager.register({
2542
+ method: "GET",
2543
+ path: `${dataPath}/approvals/requests/:id/actions`,
2544
+ handler: async (req, res) => {
2545
+ try {
2546
+ const projectId = isScoped ? req.params?.projectId : void 0;
2547
+ const context = await this.resolveExecCtx(projectId, req);
2548
+ if (this.enforceAuth(req, res, context)) return;
2549
+ const svc = await resolveService(projectId);
2550
+ if (!svc) return respond501(res);
2551
+ const rows = await svc.listActions(req.params.id, context ?? {});
2552
+ res.json({ data: rows });
2553
+ } catch (error) {
2554
+ logError("[REST] List approval actions error:", error);
2555
+ res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
2556
+ }
2557
+ },
2558
+ metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
2559
+ });
2560
+ }
2561
+ /**
2562
+ * Register batch operation endpoints
2563
+ */
2564
+ registerBatchEndpoints(basePath) {
2565
+ const { crud, batch } = this.config;
2566
+ const dataPath = `${basePath}${crud.dataPrefix}`;
2567
+ const isScoped = basePath.includes("/projects/:projectId");
2568
+ const operations = batch.operations;
2569
+ if (batch.enableBatchEndpoint && this.protocol.batchData) {
2570
+ this.routeManager.register({
2571
+ method: "POST",
2572
+ path: `${dataPath}/:object/batch`,
2573
+ handler: async (req, res) => {
2574
+ try {
2575
+ const projectId = isScoped ? req.params?.projectId : void 0;
2576
+ const p = await this.resolveProtocol(projectId, req);
2577
+ const context = await this.resolveExecCtx(projectId, req);
2578
+ if (this.enforceAuth(req, res, context)) return;
2579
+ const result = await p.batchData({
653
2580
  object: req.params.object,
654
- records: req.body || []
2581
+ request: req.body,
2582
+ ...projectId ? { projectId } : {},
2583
+ ...context ? { context } : {}
2584
+ });
2585
+ res.json(result);
2586
+ } catch (error) {
2587
+ logError("[REST] Unhandled error:", error);
2588
+ sendError(res, error, req.params?.object);
2589
+ }
2590
+ },
2591
+ metadata: {
2592
+ summary: "Batch operations",
2593
+ tags: ["data", "batch"]
2594
+ }
2595
+ });
2596
+ }
2597
+ if (operations.createMany && this.protocol.createManyData) {
2598
+ this.routeManager.register({
2599
+ method: "POST",
2600
+ path: `${dataPath}/:object/createMany`,
2601
+ handler: async (req, res) => {
2602
+ try {
2603
+ const projectId = isScoped ? req.params?.projectId : void 0;
2604
+ const p = await this.resolveProtocol(projectId, req);
2605
+ const context = await this.resolveExecCtx(projectId, req);
2606
+ if (this.enforceAuth(req, res, context)) return;
2607
+ const result = await p.createManyData({
2608
+ object: req.params.object,
2609
+ records: req.body || [],
2610
+ ...projectId ? { projectId } : {},
2611
+ ...context ? { context } : {}
655
2612
  });
656
2613
  res.status(201).json(result);
657
2614
  } catch (error) {
658
- res.status(400).json({ error: error.message });
2615
+ logError("[REST] Unhandled error:", error);
2616
+ sendError(res, error, req.params?.object);
659
2617
  }
660
2618
  },
661
2619
  metadata: {
@@ -670,13 +2628,20 @@ var RestServer = class {
670
2628
  path: `${dataPath}/:object/updateMany`,
671
2629
  handler: async (req, res) => {
672
2630
  try {
673
- const result = await this.protocol.updateManyData({
2631
+ const projectId = isScoped ? req.params?.projectId : void 0;
2632
+ const p = await this.resolveProtocol(projectId, req);
2633
+ const context = await this.resolveExecCtx(projectId, req);
2634
+ if (this.enforceAuth(req, res, context)) return;
2635
+ const result = await p.updateManyData({
674
2636
  object: req.params.object,
675
- ...req.body
2637
+ ...req.body,
2638
+ ...projectId ? { projectId } : {},
2639
+ ...context ? { context } : {}
676
2640
  });
677
2641
  res.json(result);
678
2642
  } catch (error) {
679
- res.status(400).json({ error: error.message });
2643
+ logError("[REST] Unhandled error:", error);
2644
+ sendError(res, error, req.params?.object);
680
2645
  }
681
2646
  },
682
2647
  metadata: {
@@ -691,13 +2656,20 @@ var RestServer = class {
691
2656
  path: `${dataPath}/:object/deleteMany`,
692
2657
  handler: async (req, res) => {
693
2658
  try {
694
- const result = await this.protocol.deleteManyData({
2659
+ const projectId = isScoped ? req.params?.projectId : void 0;
2660
+ const p = await this.resolveProtocol(projectId, req);
2661
+ const context = await this.resolveExecCtx(projectId, req);
2662
+ if (this.enforceAuth(req, res, context)) return;
2663
+ const result = await p.deleteManyData({
695
2664
  object: req.params.object,
696
- ...req.body
2665
+ ...req.body,
2666
+ ...projectId ? { projectId } : {},
2667
+ ...context ? { context } : {}
697
2668
  });
698
2669
  res.json(result);
699
2670
  } catch (error) {
700
- res.status(400).json({ error: error.message });
2671
+ logError("[REST] Unhandled error:", error);
2672
+ sendError(res, error, req.params?.object);
701
2673
  }
702
2674
  },
703
2675
  metadata: {
@@ -721,6 +2693,123 @@ var RestServer = class {
721
2693
  }
722
2694
  };
723
2695
 
2696
+ // src/package-routes.ts
2697
+ function registerPackageRoutes(server, packageService, basePath = "/api/v1", options = {}) {
2698
+ const packagesPath = `${basePath}/packages`;
2699
+ server.post(packagesPath, async (req, res) => {
2700
+ try {
2701
+ const { manifest, metadata } = req.body || {};
2702
+ if (!manifest || !metadata) {
2703
+ res.status(400).json({ error: "Missing required fields: manifest, metadata" });
2704
+ return;
2705
+ }
2706
+ if (!manifest.id || !manifest.version) {
2707
+ res.status(400).json({ error: "Invalid manifest: id and version are required" });
2708
+ return;
2709
+ }
2710
+ const result = await packageService.publish({ manifest, metadata });
2711
+ if (result.success) {
2712
+ res.json({
2713
+ success: true,
2714
+ message: `Published ${manifest.id}@${manifest.version}`,
2715
+ package: {
2716
+ id: manifest.id,
2717
+ version: manifest.version
2718
+ }
2719
+ });
2720
+ return;
2721
+ }
2722
+ res.status(400).json({ success: false, error: result.error });
2723
+ } catch (error) {
2724
+ res.status(500).json({ error: error.message });
2725
+ }
2726
+ });
2727
+ server.get(packagesPath, async (_req, res) => {
2728
+ try {
2729
+ const packagesMap = /* @__PURE__ */ new Map();
2730
+ if (options.protocol && typeof options.protocol.getMetaItems === "function") {
2731
+ try {
2732
+ const result = await options.protocol.getMetaItems({ type: "package" });
2733
+ if (result?.items) {
2734
+ for (const item of result.items) {
2735
+ const id = item.manifest?.id || item.id;
2736
+ if (id) {
2737
+ packagesMap.set(id, {
2738
+ ...item,
2739
+ source: "registry"
2740
+ });
2741
+ }
2742
+ }
2743
+ }
2744
+ } catch {
2745
+ }
2746
+ }
2747
+ try {
2748
+ const dbPackages = await packageService.list();
2749
+ for (const pkg of dbPackages) {
2750
+ const id = pkg.manifest?.id || pkg.id;
2751
+ if (id) {
2752
+ packagesMap.set(id, {
2753
+ ...packagesMap.get(id),
2754
+ ...pkg,
2755
+ source: packagesMap.has(id) ? "both" : "database"
2756
+ });
2757
+ }
2758
+ }
2759
+ } catch {
2760
+ }
2761
+ const packages = Array.from(packagesMap.values());
2762
+ res.json({ packages, total: packages.length });
2763
+ } catch (error) {
2764
+ res.status(500).json({ error: error.message });
2765
+ }
2766
+ });
2767
+ server.get(`${packagesPath}/:id`, async (req, res) => {
2768
+ try {
2769
+ const packageId = req.params.id;
2770
+ const version = req.query?.version || "latest";
2771
+ const pkg = await packageService.get(packageId, version);
2772
+ if (pkg) {
2773
+ res.json({ package: { ...pkg, source: "database" } });
2774
+ return;
2775
+ }
2776
+ if (options.protocol && typeof options.protocol.getMetaItems === "function") {
2777
+ try {
2778
+ const result = await options.protocol.getMetaItems({ type: "package" });
2779
+ const match = result?.items?.find(
2780
+ (item) => (item.manifest?.id || item.id) === packageId
2781
+ );
2782
+ if (match) {
2783
+ res.json({ package: { ...match, source: "registry" } });
2784
+ return;
2785
+ }
2786
+ } catch {
2787
+ }
2788
+ }
2789
+ res.status(404).json({ error: "Package not found" });
2790
+ } catch (error) {
2791
+ res.status(500).json({ error: error.message });
2792
+ }
2793
+ });
2794
+ server.delete(`${packagesPath}/:id`, async (req, res) => {
2795
+ try {
2796
+ const packageId = req.params.id;
2797
+ const version = req.query?.version;
2798
+ const result = await packageService.delete(packageId, version);
2799
+ if (result.success) {
2800
+ res.json({
2801
+ success: true,
2802
+ message: `Deleted ${packageId}${version ? `@${version}` : ""}`
2803
+ });
2804
+ return;
2805
+ }
2806
+ res.status(400).json({ success: false });
2807
+ } catch (error) {
2808
+ res.status(500).json({ error: error.message });
2809
+ }
2810
+ });
2811
+ }
2812
+
724
2813
  // src/rest-api-plugin.ts
725
2814
  function createRestApiPlugin(config = {}) {
726
2815
  return {
@@ -741,6 +2830,74 @@ function createRestApiPlugin(config = {}) {
741
2830
  protocol = ctx.getService(protocolService);
742
2831
  } catch (e) {
743
2832
  }
2833
+ let kernelManager;
2834
+ const kernelManagerService = config.kernelManagerServiceName || "kernel-manager";
2835
+ try {
2836
+ kernelManager = ctx.getService(kernelManagerService);
2837
+ } catch (e) {
2838
+ }
2839
+ let envRegistry;
2840
+ try {
2841
+ envRegistry = ctx.getService("env-registry");
2842
+ } catch (e) {
2843
+ }
2844
+ const defaultProjectIdProvider = () => {
2845
+ try {
2846
+ const dp = ctx.getService("default-project");
2847
+ return dp?.projectId;
2848
+ } catch {
2849
+ return void 0;
2850
+ }
2851
+ };
2852
+ const authServiceProvider = async (_projectId) => {
2853
+ try {
2854
+ return ctx.getService("auth");
2855
+ } catch {
2856
+ return void 0;
2857
+ }
2858
+ };
2859
+ const objectQLProvider = async (_projectId) => {
2860
+ try {
2861
+ return ctx.getService("objectql");
2862
+ } catch {
2863
+ return void 0;
2864
+ }
2865
+ };
2866
+ const emailServiceProvider = async (_projectId) => {
2867
+ try {
2868
+ return ctx.getService("email");
2869
+ } catch {
2870
+ return void 0;
2871
+ }
2872
+ };
2873
+ const sharingServiceProvider = async (_projectId) => {
2874
+ try {
2875
+ return ctx.getService("sharing");
2876
+ } catch {
2877
+ return void 0;
2878
+ }
2879
+ };
2880
+ const reportsServiceProvider = async (_projectId) => {
2881
+ try {
2882
+ return ctx.getService("reports");
2883
+ } catch {
2884
+ return void 0;
2885
+ }
2886
+ };
2887
+ const approvalsServiceProvider = async (_projectId) => {
2888
+ try {
2889
+ return ctx.getService("approvals");
2890
+ } catch {
2891
+ return void 0;
2892
+ }
2893
+ };
2894
+ const sharingRulesServiceProvider = async (_projectId) => {
2895
+ try {
2896
+ return ctx.getService("sharingRules");
2897
+ } catch {
2898
+ return void 0;
2899
+ }
2900
+ };
744
2901
  if (!server) {
745
2902
  ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
746
2903
  return;
@@ -751,13 +2908,38 @@ function createRestApiPlugin(config = {}) {
751
2908
  }
752
2909
  ctx.logger.info("Hydrating REST API from Protocol...");
753
2910
  try {
754
- const restServer = new RestServer(server, protocol, config.api);
2911
+ const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
755
2912
  restServer.registerRoutes();
756
2913
  ctx.logger.info("REST API successfully registered");
757
2914
  } catch (err) {
758
2915
  ctx.logger.error("Failed to register REST API routes", { error: err.message });
759
2916
  throw err;
760
2917
  }
2918
+ try {
2919
+ const packageService = ctx.getService("package");
2920
+ if (packageService) {
2921
+ const basePath = config.api?.api?.basePath || "/api";
2922
+ const version = config.api?.api?.version || "v1";
2923
+ const versionedBase = `${basePath}/${version}`;
2924
+ const enableProjectScoping = config.api?.api?.enableProjectScoping ?? false;
2925
+ const projectResolution = config.api?.api?.projectResolution ?? "auto";
2926
+ if (enableProjectScoping && projectResolution === "required") {
2927
+ registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, {
2928
+ protocol
2929
+ });
2930
+ } else {
2931
+ registerPackageRoutes(server, packageService, versionedBase, { protocol });
2932
+ if (enableProjectScoping) {
2933
+ registerPackageRoutes(server, packageService, `${versionedBase}/projects/:projectId`, {
2934
+ protocol
2935
+ });
2936
+ }
2937
+ }
2938
+ ctx.logger.info("Package management routes registered");
2939
+ }
2940
+ } catch (e) {
2941
+ ctx.logger.debug("Package service not available, package routes skipped");
2942
+ }
761
2943
  }
762
2944
  };
763
2945
  }