mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move automations into a first-party plugin
This commit is contained in:
1 parent
a6f6a0439c
commit
e0f14f2ecf
47 files changed
+384
-125
No files matched your search
@@ -33,7 +33,7 @@ import vaultRoutes from "./routes/vault.js";
|
||||
import secretSourceRoutes from "./routes/secret-sources.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import aiDispatch from "./routes/ai-dispatch.js";
|
||||
import automationsRoutes from "./routes/automations.js";
|
||||
import automationsDispatch from "./routes/automation-dispatch.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import pluginApiRoutes from "./routes/plugin-api-routes.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
@@ -1776,7 +1776,7 @@ app.use("/vault", vaultRoutes);
|
||||
app.use("/secret-sources", secretSourceRoutes);
|
||||
// Before the alert routes, which are mounted at the root and would otherwise
|
||||
// have first claim on the path.
|
||||
app.use("/automations", automationsRoutes);
|
||||
app.use("/automations", automationsDispatch);
|
||||
app.use("/ai", aiDispatch);
|
||||
app.use("/", alertRulesRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Mounted at /automations. Automations ships as a first-party in-process
|
||||
// plugin (plugins/automations) rather than a route file imported directly
|
||||
// here, so this module exists to keep /automations/* working at its original
|
||||
// path without handing the plugin a reference to the shared app instance. The
|
||||
// plugin runtime registers its router via registerAutomationsRouter when it
|
||||
// activates, and clears it on deactivate, so a disabled plugin falls back to
|
||||
// the 404 below.
|
||||
//
|
||||
// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards
|
||||
// to exactly one first-party router (the Automations plugin's own), and every
|
||||
// route in that router already applies authenticateJWT/requireDataAccess
|
||||
// itself (including the intentionally unauthenticated webhook route, which
|
||||
// gates on its own token instead), the same as every other router mounted
|
||||
// alongside it here (/users, /host, /fleets, ...). /plugin-api gates at the
|
||||
// mount point because it forwards to arbitrary third-party plugin code that
|
||||
// cannot be assumed to gate itself; that reasoning does not apply to a single
|
||||
// first-party router that already does.
|
||||
|
||||
import type { Request, Response, NextFunction, Router } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
let activeRouter: Router | null = null;
|
||||
|
||||
export function registerAutomationsRouter(router: Router): void {
|
||||
activeRouter = router;
|
||||
databaseLogger.info("Registered Automations plugin router", {
|
||||
operation: "automations_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterAutomationsRouter(): void {
|
||||
if (!activeRouter) return;
|
||||
activeRouter = null;
|
||||
databaseLogger.info("Unregistered Automations plugin router", {
|
||||
operation: "automations_dispatch_register",
|
||||
});
|
||||
}
|
||||
|
||||
export default function automationsDispatch(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (!activeRouter) {
|
||||
res.status(404).json({ error: "Automations is not available" });
|
||||
return;
|
||||
}
|
||||
activeRouter(req, res, next);
|
||||
}
|
||||
@@ -1,822 +0,0 @@
|
||||
import crypto from "node:crypto";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type {
|
||||
AutomationDefinition,
|
||||
Step,
|
||||
Trigger,
|
||||
} from "../../../types/automations.js";
|
||||
import { AUTOMATION_DEFINITION_VERSION } from "../../../types/automations.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { createCurrentAutomationRepository } from "../repositories/factory.js";
|
||||
import type { AutomationRow } from "../repositories/automation-repository.js";
|
||||
import { AutomationEngine } from "../../automations/engine.js";
|
||||
import {
|
||||
computeNextDueAt,
|
||||
isValidCron,
|
||||
isValidTimezone,
|
||||
} from "../../automations/cron.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
const TRIGGER_KINDS = new Set([
|
||||
"metric_threshold",
|
||||
"host_status",
|
||||
"health_check",
|
||||
"schedule",
|
||||
"docker_event",
|
||||
"internal_event",
|
||||
"webhook",
|
||||
]);
|
||||
|
||||
const STEP_TYPES = new Set([
|
||||
"notify",
|
||||
"http",
|
||||
"run_snippet",
|
||||
"run_command",
|
||||
"docker",
|
||||
"tunnel",
|
||||
"wol",
|
||||
"wait",
|
||||
"set_var",
|
||||
"if",
|
||||
"run_automation",
|
||||
"stop",
|
||||
]);
|
||||
|
||||
const OPERATORS = new Set([
|
||||
">",
|
||||
"<",
|
||||
">=",
|
||||
"<=",
|
||||
"==",
|
||||
"!=",
|
||||
"contains",
|
||||
"not_contains",
|
||||
"changed",
|
||||
]);
|
||||
|
||||
function parseId(raw: unknown): number | null {
|
||||
const id = typeof raw === "string" ? parseInt(raw, 10) : NaN;
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a definition before it is stored. The engine treats the stored
|
||||
* blob as trusted, so everything it relies on is checked once here.
|
||||
*/
|
||||
export function validateDefinition(value: unknown): {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
definition?: AutomationDefinition;
|
||||
} {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return { ok: false, error: "Definition must be an object" };
|
||||
}
|
||||
|
||||
const candidate = value as Partial<AutomationDefinition>;
|
||||
const trigger = candidate.trigger as Trigger | undefined;
|
||||
|
||||
if (!trigger || !TRIGGER_KINDS.has(trigger.kind)) {
|
||||
return { ok: false, error: "Unknown or missing trigger kind" };
|
||||
}
|
||||
|
||||
if (trigger.kind === "metric_threshold") {
|
||||
if (!OPERATORS.has(trigger.operator)) {
|
||||
return { ok: false, error: "Unknown comparison operator" };
|
||||
}
|
||||
if (typeof trigger.value !== "number" || !Number.isFinite(trigger.value)) {
|
||||
return { ok: false, error: "Threshold value must be a number" };
|
||||
}
|
||||
if (!trigger.metric?.path) {
|
||||
return { ok: false, error: "Trigger is missing a metric" };
|
||||
}
|
||||
}
|
||||
|
||||
if (trigger.kind === "schedule") {
|
||||
const hasInterval =
|
||||
typeof trigger.intervalSeconds === "number" &&
|
||||
trigger.intervalSeconds > 0;
|
||||
const hasCron = isNonEmptyString(trigger.cron);
|
||||
if (!hasInterval && !hasCron) {
|
||||
return { ok: false, error: "Schedule needs an interval or a cron" };
|
||||
}
|
||||
if (hasCron && !isValidCron(trigger.cron as string)) {
|
||||
return { ok: false, error: "Cron expression is not valid" };
|
||||
}
|
||||
if (hasInterval && (trigger.intervalSeconds as number) < 60) {
|
||||
return { ok: false, error: "Interval must be at least 60 seconds" };
|
||||
}
|
||||
if (
|
||||
isNonEmptyString(trigger.timezone) &&
|
||||
!isValidTimezone(trigger.timezone)
|
||||
) {
|
||||
return { ok: false, error: "Time zone is not valid" };
|
||||
}
|
||||
}
|
||||
|
||||
const steps = candidate.steps;
|
||||
if (!Array.isArray(steps)) {
|
||||
return { ok: false, error: "Definition must include a steps array" };
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stepError = validateSteps(steps as Step[], seen);
|
||||
if (stepError) return { ok: false, error: stepError };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
definition: {
|
||||
version: candidate.version ?? AUTOMATION_DEFINITION_VERSION,
|
||||
trigger,
|
||||
steps: steps as Step[],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function validateSteps(steps: Step[], seen: Set<string>): string | null {
|
||||
for (const step of steps) {
|
||||
if (!step || typeof step !== "object") return "Step must be an object";
|
||||
if (!isNonEmptyString(step.id)) return "Every step needs an id";
|
||||
if (seen.has(step.id)) return `Duplicate step id: ${step.id}`;
|
||||
seen.add(step.id);
|
||||
if (!STEP_TYPES.has(step.type)) {
|
||||
return `Unknown step type: ${step.type}`;
|
||||
}
|
||||
|
||||
if (step.type === "if") {
|
||||
if (!step.condition || !OPERATORS.has(step.condition.operator)) {
|
||||
return "Condition needs a valid operator";
|
||||
}
|
||||
const thenError = validateSteps(step.then ?? [], seen);
|
||||
if (thenError) return thenError;
|
||||
const elseError = validateSteps(step.else ?? [], seen);
|
||||
if (elseError) return elseError;
|
||||
}
|
||||
|
||||
if (step.type === "http" && !isNonEmptyString(step.url)) {
|
||||
return "HTTP steps need a URL";
|
||||
}
|
||||
if (step.type === "run_command" && !isNonEmptyString(step.command)) {
|
||||
return "Command steps need a command";
|
||||
}
|
||||
if (step.type === "wait" && typeof step.seconds !== "number") {
|
||||
return "Wait steps need a number of seconds";
|
||||
}
|
||||
if (step.type === "set_var" && !isNonEmptyString(step.name)) {
|
||||
return "Variable steps need a name";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Never leak a webhook token hash to the client. */
|
||||
function serialize(row: AutomationRow) {
|
||||
let definition: AutomationDefinition | null = null;
|
||||
try {
|
||||
definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
} catch {
|
||||
definition = null;
|
||||
}
|
||||
|
||||
if (definition?.trigger?.kind === "webhook") {
|
||||
definition = {
|
||||
...definition,
|
||||
trigger: { ...definition.trigger, tokenHash: "" },
|
||||
};
|
||||
}
|
||||
|
||||
return { ...row, definition };
|
||||
}
|
||||
|
||||
async function syncSchedule(
|
||||
automationId: number,
|
||||
definition: AutomationDefinition,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
if (definition.trigger?.kind !== "schedule") {
|
||||
await repository.deleteSchedule(automationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = definition.trigger;
|
||||
await repository.upsertSchedule({
|
||||
automationId,
|
||||
cron: trigger.cron ?? null,
|
||||
intervalSeconds: trigger.intervalSeconds ?? null,
|
||||
timezone: trigger.timezone ?? null,
|
||||
nextDueAt: computeNextDueAt({
|
||||
cron: trigger.cron,
|
||||
intervalSeconds: trigger.intervalSeconds,
|
||||
timezone: trigger.timezone,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations:
|
||||
* get:
|
||||
* summary: List the current user's automations
|
||||
* description: Returns every automation the caller owns, with its parsed definition and linked notification channels.
|
||||
* tags:
|
||||
* - Automations
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of automations.
|
||||
* 403:
|
||||
* description: Missing the automations.view permission.
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await createCurrentAutomationRepository().list(userId);
|
||||
res.json(rows.map(serialize));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list automations", error, {
|
||||
operation: "automation_list_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list automations" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* get:
|
||||
* summary: Fetch a single automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The automation.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const row = await createCurrentAutomationRepository().findForUser(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!row) return res.status(404).json({ error: "Automation not found" });
|
||||
res.json(serialize(row));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to fetch automation", error, {
|
||||
operation: "automation_get_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations:
|
||||
* post:
|
||||
* summary: Create an automation
|
||||
* description: Validates the trigger and every step before storing the definition. A schedule trigger also registers its next due time.
|
||||
* tags:
|
||||
* - Automations
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* definition:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: The created automation.
|
||||
* 400:
|
||||
* description: Validation failed.
|
||||
*/
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.create"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, description, enabled, definition, concurrencyPolicy } =
|
||||
req.body ?? {};
|
||||
|
||||
if (!isNonEmptyString(name)) {
|
||||
return res.status(400).json({ error: "Name is required" });
|
||||
}
|
||||
|
||||
const validated = validateDefinition(definition);
|
||||
if (!validated.ok || !validated.definition) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
// A webhook trigger's token is shown once here and only stored hashed.
|
||||
let webhookToken: string | undefined;
|
||||
if (validated.definition.trigger.kind === "webhook") {
|
||||
webhookToken = crypto.randomBytes(32).toString("hex");
|
||||
validated.definition = {
|
||||
...validated.definition,
|
||||
trigger: {
|
||||
kind: "webhook",
|
||||
tokenHash: hashToken(webhookToken),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const created = await repository.create({
|
||||
userId,
|
||||
name: name.trim(),
|
||||
description: description ?? null,
|
||||
enabled: enabled !== false,
|
||||
definition: JSON.stringify(validated.definition),
|
||||
concurrencyPolicy,
|
||||
channels: Array.isArray(req.body?.channels) ? req.body.channels : [],
|
||||
});
|
||||
|
||||
await syncSchedule(created.id, validated.definition);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "create_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(created.id),
|
||||
resourceName: created.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.status(201).json({ ...serialize(created), webhookToken });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create automation", error, {
|
||||
operation: "automation_create_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* put:
|
||||
* summary: Update an automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The updated automation.
|
||||
* 400:
|
||||
* description: Validation failed.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.edit"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const existing = await repository.findForUser(id, userId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
if (req.body?.name !== undefined) {
|
||||
if (!isNonEmptyString(req.body.name)) {
|
||||
return res.status(400).json({ error: "Name cannot be empty" });
|
||||
}
|
||||
update.name = req.body.name.trim();
|
||||
}
|
||||
if (req.body?.description !== undefined) {
|
||||
update.description = req.body.description;
|
||||
}
|
||||
if (req.body?.enabled !== undefined) update.enabled = !!req.body.enabled;
|
||||
if (req.body?.concurrencyPolicy !== undefined) {
|
||||
update.concurrencyPolicy = req.body.concurrencyPolicy;
|
||||
}
|
||||
if (Array.isArray(req.body?.channels)) update.channels = req.body.channels;
|
||||
|
||||
let parsedDefinition: AutomationDefinition | null = null;
|
||||
if (req.body?.definition !== undefined) {
|
||||
const validated = validateDefinition(req.body.definition);
|
||||
if (!validated.ok || !validated.definition) {
|
||||
return res.status(400).json({ error: validated.error });
|
||||
}
|
||||
|
||||
// Keep the stored token hash: the raw token is only ever shown once.
|
||||
if (validated.definition.trigger.kind === "webhook") {
|
||||
const previous = JSON.parse(
|
||||
existing.definition,
|
||||
) as AutomationDefinition;
|
||||
const previousHash =
|
||||
previous.trigger?.kind === "webhook"
|
||||
? previous.trigger.tokenHash
|
||||
: "";
|
||||
validated.definition = {
|
||||
...validated.definition,
|
||||
trigger: { kind: "webhook", tokenHash: previousHash },
|
||||
};
|
||||
}
|
||||
|
||||
parsedDefinition = validated.definition;
|
||||
update.definition = JSON.stringify(validated.definition);
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await repository.update(id, userId, update);
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
if (parsedDefinition) await syncSchedule(id, parsedDefinition);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "update_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
resourceName: updated.name,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(serialize(updated));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update automation", error, {
|
||||
operation: "automation_update_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}:
|
||||
* delete:
|
||||
* summary: Delete an automation
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deleted.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.delete"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentAutomationRepository().delete(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "delete_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete automation", error, {
|
||||
operation: "automation_delete_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/{id}/run:
|
||||
* post:
|
||||
* summary: Run an automation now
|
||||
* description: Runs immediately, as the automation's owner. Pass dryRun to record what each step would do without touching anything outside Termix.
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* dryRun:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The run outcome.
|
||||
* 404:
|
||||
* description: Automation not found.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/run",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.run"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const id = parseId(req.params.id);
|
||||
if (id === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
const existing = await createCurrentAutomationRepository().findForUser(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Automation not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: id,
|
||||
triggerType: "manual",
|
||||
triggerContext: { manual: true, requestedBy: userId },
|
||||
dryRun: req.body?.dryRun === true,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "run_automation",
|
||||
resourceType: "automation",
|
||||
resourceId: String(id),
|
||||
resourceName: existing.name,
|
||||
details: JSON.stringify({
|
||||
status: outcome.status,
|
||||
dryRun: req.body?.dryRun === true,
|
||||
}),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: outcome.status === "success",
|
||||
});
|
||||
|
||||
res.json(outcome);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to run automation", error, {
|
||||
operation: "automation_run_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to run automation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/runs:
|
||||
* get:
|
||||
* summary: List automation runs
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: automationId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Recent runs, newest first.
|
||||
*/
|
||||
router.get(
|
||||
"/runs/history",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const runs = await createCurrentAutomationRepository().listRuns(userId, {
|
||||
automationId: parseId(req.query.automationId) ?? undefined,
|
||||
limit: Number(req.query.limit) || 50,
|
||||
offset: Number(req.query.offset) || 0,
|
||||
});
|
||||
res.json(runs);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list automation runs", error, {
|
||||
operation: "automation_runs_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list runs" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/runs/{runId}/steps:
|
||||
* get:
|
||||
* summary: Step-by-step results for a run
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: runId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The run's steps in order.
|
||||
* 404:
|
||||
* description: Run not found.
|
||||
*/
|
||||
router.get(
|
||||
"/runs/:runId/steps",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("automations.view"),
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const runId = parseId(req.params.runId);
|
||||
if (runId === null) return res.status(400).json({ error: "Invalid id" });
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const run = await repository.findRunForUser(runId, userId);
|
||||
if (!run) return res.status(404).json({ error: "Run not found" });
|
||||
|
||||
res.json(await repository.listRunSteps(runId));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list run steps", error, {
|
||||
operation: "automation_run_steps_error",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list run steps" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /automations/webhook/{token}:
|
||||
* post:
|
||||
* summary: Trigger an automation from an external system
|
||||
* description: Unauthenticated by design; the 32-byte token in the path is the credential and is compared against a stored hash in constant time.
|
||||
* tags:
|
||||
* - Automations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: token
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 202:
|
||||
* description: The run was accepted.
|
||||
* 404:
|
||||
* description: No automation matches that token.
|
||||
*/
|
||||
router.post("/webhook/:token", async (req: Request, res: Response) => {
|
||||
const token = req.params.token;
|
||||
if (!isNonEmptyString(token) || token.length < 32) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const repository = createCurrentAutomationRepository();
|
||||
const candidates = await repository.listAllEnabled();
|
||||
const wanted = hashToken(token);
|
||||
|
||||
const match = candidates.find((row) => {
|
||||
try {
|
||||
const definition = JSON.parse(row.definition) as AutomationDefinition;
|
||||
if (definition.trigger?.kind !== "webhook") return false;
|
||||
return timingSafeEqual(definition.trigger.tokenHash, wanted);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!match) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const outcome = await AutomationEngine.getInstance().run({
|
||||
automationId: match.id,
|
||||
triggerType: "webhook",
|
||||
triggerContext: {
|
||||
body: req.body ?? {},
|
||||
receivedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
res.status(202).json({ runId: outcome.runId, status: outcome.status });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Webhook automation failed", error, {
|
||||
operation: "automation_webhook_error",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to run automation" });
|
||||
}
|
||||
});
|
||||
|
||||
function hashToken(token: string): string {
|
||||
return crypto.createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function timingSafeEqual(a: string, b: string): boolean {
|
||||
const left = Buffer.from(a || "", "utf8");
|
||||
const right = Buffer.from(b || "", "utf8");
|
||||
if (left.length !== right.length) return false;
|
||||
return crypto.timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export default router;
|
||||
Reference in new issue
Block a user