mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-04 17:45:24 +00:00
761 lines
21 KiB
TypeScript
761 lines
21 KiB
TypeScript
import express, { type Request, type Response } from "express";
|
|
import type {
|
|
ResourceListResult,
|
|
SignalResult,
|
|
} from "@termix-ssh/plugin-sdk/operations";
|
|
import { AuthManager } from "../../utils/auth-manager.js";
|
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
|
import { databaseLogger } from "../../utils/logger.js";
|
|
import { getErrorMessage } from "../../utils/error-message.js";
|
|
import { createCurrentOperationRepository } from "../repositories/factory.js";
|
|
import type { OperationRunRecord } from "../repositories/operation-repository.js";
|
|
import { buildCatalog } from "../../operations/catalog.js";
|
|
import {
|
|
cancelOperationRun,
|
|
decideOperationApproval,
|
|
OperationError,
|
|
runOperation,
|
|
} from "../../operations/gate.js";
|
|
import { getAction } from "../../operations/registry.js";
|
|
import {
|
|
flattenResourceRows,
|
|
readAcrossHosts,
|
|
} from "../../operations/resources.js";
|
|
import type { ActorOrigin } from "../../plugins/actor.js";
|
|
|
|
const router = express.Router();
|
|
const authenticateJWT = AuthManager.getInstance().createAuthMiddleware();
|
|
const permissions = PermissionManager.getInstance();
|
|
|
|
/** Sees every run on the server, not only their own. */
|
|
export const VIEW_ALL_RUNS = "admin.operations.view";
|
|
|
|
type OperationsRequest = Request & {
|
|
userId: string;
|
|
apiKeyId?: string;
|
|
apiKeyScopes?: string[] | null;
|
|
actingAdminUserId?: string;
|
|
};
|
|
|
|
function originOf(req: OperationsRequest): ActorOrigin {
|
|
return { kind: req.apiKeyId ? "api" : "ui" };
|
|
}
|
|
|
|
function sendError(res: Response, error: unknown, operation: string): void {
|
|
if (error instanceof OperationError) {
|
|
res.status(error.status).json({
|
|
error: error.message,
|
|
code: error.code,
|
|
details: error.details,
|
|
});
|
|
return;
|
|
}
|
|
databaseLogger.error("Operations route failed", error, { operation });
|
|
res.status(500).json({ error: getErrorMessage(error, "Request failed") });
|
|
}
|
|
|
|
function parseJson(text: string | null): unknown {
|
|
if (!text) return undefined;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/** Never includes the sealed input. */
|
|
function publicRun(run: OperationRunRecord) {
|
|
return {
|
|
id: run.id,
|
|
actionId: run.actionId,
|
|
pluginId: run.pluginId,
|
|
pluginVersion: run.pluginVersion,
|
|
userId: run.userId,
|
|
username: run.username,
|
|
origin: parseJson(run.origin),
|
|
hostId: run.hostId,
|
|
hostName: run.hostName,
|
|
batchId: run.batchId,
|
|
dryRun: run.dryRun,
|
|
risk: run.risk,
|
|
status: run.status,
|
|
summary: run.summary,
|
|
changed: run.changed,
|
|
input: parseJson(run.input),
|
|
output: parseJson(run.output),
|
|
undoAvailable: run.undoInput !== null,
|
|
error: run.error,
|
|
errorCode: run.errorCode,
|
|
undoOf: run.undoOf,
|
|
createdAt: run.createdAt,
|
|
startedAt: run.startedAt,
|
|
finishedAt: run.finishedAt,
|
|
};
|
|
}
|
|
|
|
async function canSeeRun(
|
|
run: OperationRunRecord,
|
|
userId: string,
|
|
): Promise<boolean> {
|
|
if (run.userId === userId) return true;
|
|
if (await permissions.hasPermission(userId, VIEW_ALL_RUNS)) return true;
|
|
if (run.hostId === null) return false;
|
|
const access = await permissions.canAccessHost(userId, run.hostId, "manage");
|
|
return access.hasAccess;
|
|
}
|
|
|
|
function hostIdsFrom(value: unknown): number[] | undefined {
|
|
if (typeof value !== "string" || value === "") return undefined;
|
|
const ids = value
|
|
.split(",")
|
|
.map(Number)
|
|
.filter((id) => Number.isInteger(id));
|
|
return ids.length > 0 ? ids : undefined;
|
|
}
|
|
|
|
function limitFrom(value: unknown): number | undefined {
|
|
const limit = Number(value);
|
|
return Number.isInteger(limit) && limit > 0 ? limit : undefined;
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/catalog:
|
|
* get:
|
|
* summary: List what this user can do
|
|
* description: Every action, event, resource and signal from running plugins that the caller has permission for, with titles in the requested language.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: query
|
|
* name: lang
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: surface
|
|
* schema:
|
|
* type: string
|
|
* enum: [assistant, mcp, palette, automations, mobile, cli]
|
|
* responses:
|
|
* 200:
|
|
* description: The catalog.
|
|
*/
|
|
router.get("/catalog", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
res.json(
|
|
await buildCatalog({
|
|
userId,
|
|
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
|
|
surface:
|
|
typeof req.query.surface === "string"
|
|
? (req.query.surface as never)
|
|
: undefined,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
sendError(res, error, "operations_catalog");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/catalog/{id}:
|
|
* get:
|
|
* summary: Describe one action
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The action.
|
|
* 404:
|
|
* description: No such action, or the caller may not use it.
|
|
*/
|
|
router.get("/catalog/:id", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const catalog = await buildCatalog({
|
|
userId,
|
|
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
|
|
});
|
|
const action = catalog.actions.find((entry) => entry.id === req.params.id);
|
|
if (!action) {
|
|
res.status(404).json({ error: "Action not found" });
|
|
return;
|
|
}
|
|
res.json(action);
|
|
} catch (error) {
|
|
sendError(res, error, "operations_catalog_action");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/runs:
|
|
* get:
|
|
* summary: List runs
|
|
* description: The caller's own runs, runs on hosts they manage, or every run with the view permission.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: query
|
|
* name: hostId
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: actionId
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: status
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: batchId
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: before
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: limit
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Runs, newest first.
|
|
*/
|
|
router.get("/runs", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
|
|
const hostId = limitFrom(req.query.hostId);
|
|
let managed = false;
|
|
if (!viewAll && hostId !== undefined) {
|
|
managed = (await permissions.canAccessHost(userId, hostId, "manage"))
|
|
.hasAccess;
|
|
}
|
|
const runs = await createCurrentOperationRepository().listRuns(
|
|
{
|
|
userId: viewAll || managed ? undefined : userId,
|
|
hostId,
|
|
actionId:
|
|
typeof req.query.actionId === "string"
|
|
? req.query.actionId
|
|
: undefined,
|
|
status:
|
|
typeof req.query.status === "string" ? req.query.status : undefined,
|
|
batchId:
|
|
typeof req.query.batchId === "string" ? req.query.batchId : undefined,
|
|
before:
|
|
typeof req.query.before === "string" ? req.query.before : undefined,
|
|
},
|
|
limitFrom(req.query.limit),
|
|
);
|
|
res.json({ runs: runs.map(publicRun) });
|
|
} catch (error) {
|
|
sendError(res, error, "operations_runs");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/runs/{id}:
|
|
* get:
|
|
* summary: Get a run
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The run.
|
|
* 404:
|
|
* description: Not found, or not visible to the caller.
|
|
*/
|
|
router.get("/runs/:id", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const run = await createCurrentOperationRepository().findRun(
|
|
String(req.params.id),
|
|
);
|
|
if (!run || !(await canSeeRun(run, userId))) {
|
|
res.status(404).json({ error: "Run not found" });
|
|
return;
|
|
}
|
|
res.json(publicRun(run));
|
|
} catch (error) {
|
|
sendError(res, error, "operations_run");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/runs/{id}/cancel:
|
|
* post:
|
|
* summary: Cancel a run
|
|
* description: Stops a running action, or withdraws one waiting for approval.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: Whether anything was cancelled.
|
|
* 404:
|
|
* description: Not found, or not visible to the caller.
|
|
*/
|
|
router.post("/runs/:id/cancel", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const run = await createCurrentOperationRepository().findRun(
|
|
String(req.params.id),
|
|
);
|
|
if (!run || !(await canSeeRun(run, userId))) {
|
|
res.status(404).json({ error: "Run not found" });
|
|
return;
|
|
}
|
|
res.json({ cancelled: await cancelOperationRun(run) });
|
|
} catch (error) {
|
|
sendError(res, error, "operations_cancel");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/runs/{id}/undo:
|
|
* post:
|
|
* summary: Undo a run
|
|
* description: Runs the action's undo with the input the run recorded. It goes through the same checks as any run.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The new run.
|
|
* 404:
|
|
* description: Not found, or nothing to undo.
|
|
*/
|
|
router.post("/runs/:id/undo", authenticateJWT, async (req, res) => {
|
|
const authReq = req as OperationsRequest;
|
|
try {
|
|
const run = await createCurrentOperationRepository().findRun(
|
|
String(req.params.id),
|
|
);
|
|
const undo = run ? getAction(run.actionId)?.action.undo : undefined;
|
|
if (
|
|
!run ||
|
|
!undo ||
|
|
!run.undoInput ||
|
|
!(await canSeeRun(run, authReq.userId))
|
|
) {
|
|
res.status(404).json({ error: "Nothing to undo" });
|
|
return;
|
|
}
|
|
res.json(
|
|
await runOperation({
|
|
actionId: undo,
|
|
hostId: run.hostId ?? undefined,
|
|
input: parseJson(run.undoInput),
|
|
confirmed: req.body?.confirmed === true,
|
|
undoOf: run.id,
|
|
userId: authReq.userId,
|
|
origin: originOf(authReq),
|
|
apiKeyScopes: authReq.apiKeyScopes,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
sendError(res, error, "operations_undo");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/approvals:
|
|
* get:
|
|
* summary: List waiting approvals
|
|
* description: Requests the caller could decide, with the exact input that would run.
|
|
* tags:
|
|
* - Operations
|
|
* responses:
|
|
* 200:
|
|
* description: Waiting approvals.
|
|
*/
|
|
router.get("/approvals", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const repository = createCurrentOperationRepository();
|
|
const approvals = [];
|
|
for (const approval of await repository.listPendingApprovals()) {
|
|
const run = await repository.findRun(approval.runId);
|
|
if (run && (await canSeeRun(run, userId))) {
|
|
approvals.push({ ...approval, run: publicRun(run) });
|
|
}
|
|
}
|
|
res.json({ approvals });
|
|
} catch (error) {
|
|
sendError(res, error, "operations_approvals");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/approvals/{id}:
|
|
* post:
|
|
* summary: Approve or reject a request
|
|
* description: One shot. What runs is the sealed request, never anything sent here.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [approve]
|
|
* properties:
|
|
* approve:
|
|
* type: boolean
|
|
* comment:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: The run's outcome.
|
|
* 403:
|
|
* description: The caller may not decide this.
|
|
* 409:
|
|
* description: Already decided, expired, or the host changed.
|
|
*/
|
|
router.post("/approvals/:id", authenticateJWT, async (req, res) => {
|
|
const authReq = req as OperationsRequest;
|
|
if (typeof req.body?.approve !== "boolean") {
|
|
res.status(400).json({ error: "approve must be true or false" });
|
|
return;
|
|
}
|
|
// A key could be handed to a script, so a person decides from a session.
|
|
if (authReq.apiKeyScopes) {
|
|
res.status(403).json({ error: "This API key may not approve" });
|
|
return;
|
|
}
|
|
try {
|
|
res.json(
|
|
await decideOperationApproval({
|
|
approvalId: String(req.params.id),
|
|
approverId: authReq.userId,
|
|
approve: req.body.approve,
|
|
comment:
|
|
typeof req.body.comment === "string" ? req.body.comment : undefined,
|
|
impersonated: !!authReq.actingAdminUserId,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
sendError(res, error, "operations_approve");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/events:
|
|
* get:
|
|
* summary: List declared events
|
|
* tags:
|
|
* - Operations
|
|
* responses:
|
|
* 200:
|
|
* description: Declared events from running plugins, plus core's run events.
|
|
*/
|
|
router.get("/events", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const catalog = await buildCatalog({
|
|
userId,
|
|
lang: typeof req.query.lang === "string" ? req.query.lang : undefined,
|
|
});
|
|
res.json({ events: catalog.events });
|
|
} catch (error) {
|
|
sendError(res, error, "operations_events");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/timeline:
|
|
* get:
|
|
* summary: Recent events
|
|
* description: Events the caller may see, newest first. Events tied to a host need access to it.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: query
|
|
* name: hostId
|
|
* schema:
|
|
* type: integer
|
|
* - in: query
|
|
* name: before
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: limit
|
|
* schema:
|
|
* type: integer
|
|
* responses:
|
|
* 200:
|
|
* description: Events.
|
|
*/
|
|
router.get("/timeline", authenticateJWT, async (req, res) => {
|
|
const { userId } = req as OperationsRequest;
|
|
try {
|
|
const rows = await createCurrentOperationRepository().listEvents(
|
|
{
|
|
hostId: limitFrom(req.query.hostId),
|
|
before:
|
|
typeof req.query.before === "string" ? req.query.before : undefined,
|
|
},
|
|
limitFrom(req.query.limit),
|
|
);
|
|
const viewAll = await permissions.hasPermission(userId, VIEW_ALL_RUNS);
|
|
const access = new Map<number, boolean>();
|
|
const events = [];
|
|
for (const row of rows) {
|
|
if (row.hostId === null) {
|
|
if (viewAll) events.push(row);
|
|
continue;
|
|
}
|
|
let allowed = access.get(row.hostId);
|
|
if (allowed === undefined) {
|
|
allowed = (
|
|
await permissions.canAccessHost(userId, row.hostId, "connect")
|
|
).hasAccess;
|
|
access.set(row.hostId, allowed);
|
|
}
|
|
if (allowed) events.push(row);
|
|
}
|
|
res.json({
|
|
events: events.map((event) => ({
|
|
...event,
|
|
payload: parseJson(event.payload),
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
sendError(res, error, "operations_timeline");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/signals:
|
|
* get:
|
|
* summary: Read status tiles
|
|
* description: Answers from memory right away and refreshes stale hosts behind it, so a page should poll. Reads hosts the caller can reach.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: query
|
|
* name: hostIds
|
|
* description: Comma separated. Leave out for every host.
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: ids
|
|
* description: Comma separated signal ids. Leave out for all.
|
|
* schema:
|
|
* type: string
|
|
* responses:
|
|
* 200:
|
|
* description: One entry per signal with per host results.
|
|
*/
|
|
router.get("/signals", authenticateJWT, async (req, res) => {
|
|
const authReq = req as OperationsRequest;
|
|
try {
|
|
const catalog = await buildCatalog({ userId: authReq.userId });
|
|
const wanted =
|
|
typeof req.query.ids === "string" && req.query.ids
|
|
? new Set(req.query.ids.split(","))
|
|
: null;
|
|
const hostIds = hostIdsFrom(req.query.hostIds);
|
|
const signals = [];
|
|
for (const signal of catalog.signals) {
|
|
if (wanted && !wanted.has(signal.id)) continue;
|
|
if (signal.scope === "server") {
|
|
const outcome = await runOperation({
|
|
actionId: signal.read,
|
|
internal: true,
|
|
userId: authReq.userId,
|
|
origin: originOf(authReq),
|
|
}).catch(() => null);
|
|
signals.push({
|
|
id: signal.id,
|
|
hosts: [],
|
|
result: outcome?.data as SignalResult | undefined,
|
|
});
|
|
continue;
|
|
}
|
|
signals.push({
|
|
id: signal.id,
|
|
hosts: await readAcrossHosts<SignalResult>({
|
|
userId: authReq.userId,
|
|
actionId: signal.read,
|
|
hostIds,
|
|
origin: originOf(authReq),
|
|
}),
|
|
});
|
|
}
|
|
res.json({ signals });
|
|
} catch (error) {
|
|
sendError(res, error, "operations_signals");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/resources/{id}:
|
|
* get:
|
|
* summary: Read one kind of thing across hosts
|
|
* description: Rows from cache plus each host's state (ok, loading, offline, error, no-access). Answers right away and refreshes stale hosts behind it, so a page should poll until no host is loading.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: hostIds
|
|
* description: Comma separated. Leave out for every host.
|
|
* schema:
|
|
* type: string
|
|
* - in: query
|
|
* name: refresh
|
|
* schema:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: Rows and per host state.
|
|
* 404:
|
|
* description: No such resource, or the caller may not read it.
|
|
*/
|
|
router.get("/resources/:id", authenticateJWT, async (req, res) => {
|
|
const authReq = req as OperationsRequest;
|
|
try {
|
|
const catalog = await buildCatalog({ userId: authReq.userId });
|
|
const resource = catalog.resources.find(
|
|
(entry) => entry.id === req.params.id,
|
|
);
|
|
if (!resource) {
|
|
res.status(404).json({ error: "Resource not found" });
|
|
return;
|
|
}
|
|
const reads = await readAcrossHosts<ResourceListResult>({
|
|
userId: authReq.userId,
|
|
actionId: resource.list,
|
|
hostIds: hostIdsFrom(req.query.hostIds),
|
|
refresh: req.query.refresh === "true",
|
|
origin: originOf(authReq),
|
|
});
|
|
res.json(flattenResourceRows(reads, resource.key));
|
|
} catch (error) {
|
|
sendError(res, error, "operations_resources");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /operations/{id}/run:
|
|
* post:
|
|
* summary: Run an action
|
|
* description: Goes through permission, host access, input and approval checks. A change returns as soon as it starts unless wait is true, then poll the run. A read always answers inline.
|
|
* tags:
|
|
* - Operations
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* hostId:
|
|
* type: integer
|
|
* resourceKey:
|
|
* type: string
|
|
* input:
|
|
* type: object
|
|
* dryRun:
|
|
* type: boolean
|
|
* confirmed:
|
|
* type: boolean
|
|
* idempotencyKey:
|
|
* type: string
|
|
* wait:
|
|
* type: boolean
|
|
* responses:
|
|
* 200:
|
|
* description: The outcome, or a run waiting for approval.
|
|
* 400:
|
|
* description: Invalid input.
|
|
* 403:
|
|
* description: Not allowed.
|
|
* 409:
|
|
* description: Needs a confirm, or safe mode is on.
|
|
*/
|
|
router.post("/:id/run", authenticateJWT, async (req, res) => {
|
|
const authReq = req as OperationsRequest;
|
|
const body = (req.body ?? {}) as Record<string, unknown>;
|
|
try {
|
|
res.json(
|
|
await runOperation({
|
|
actionId: String(req.params.id),
|
|
hostId: typeof body.hostId === "number" ? body.hostId : undefined,
|
|
resourceKey:
|
|
typeof body.resourceKey === "string" ? body.resourceKey : undefined,
|
|
input: body.input,
|
|
dryRun: body.dryRun === true,
|
|
confirmed: body.confirmed === true,
|
|
idempotencyKey:
|
|
typeof body.idempotencyKey === "string"
|
|
? body.idempotencyKey.slice(0, 128)
|
|
: undefined,
|
|
userId: authReq.userId,
|
|
origin: originOf(authReq),
|
|
apiKeyScopes: authReq.apiKeyScopes,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
sendError(res, error, "operations_run_action");
|
|
}
|
|
});
|
|
|
|
export default router;
|