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