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