mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
833 lines
24 KiB
TypeScript
833 lines
24 KiB
TypeScript
import crypto from "node:crypto";
|
|
import express, { type Request, type Response } from "express";
|
|
import type { AuthenticatedRequest } from "../../../src/types/index.js";
|
|
import type {
|
|
AutomationDefinition,
|
|
Step,
|
|
Trigger,
|
|
} from "../../../src/types/automations.js";
|
|
import { AUTOMATION_DEFINITION_VERSION } from "../../../src/types/automations.js";
|
|
import { PermissionManager } from "../../../src/backend/utils/permission-manager.js";
|
|
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
|
|
import { databaseLogger } from "../../../src/backend/utils/logger.js";
|
|
import {
|
|
getAuditUsername,
|
|
getRequestMeta,
|
|
logAudit,
|
|
} from "../../../src/backend/utils/audit-logger.js";
|
|
import { createCurrentAutomationRepository } from "../../../src/backend/database/repositories/factory.js";
|
|
import type { AutomationRow } from "../../../src/backend/database/repositories/automation-repository.js";
|
|
import { AutomationEngine } from "./engine.js";
|
|
import { computeNextDueAt, isValidCron, isValidTimezone } from "./cron.js";
|
|
import {
|
|
registerAutomationsRouter,
|
|
unregisterAutomationsRouter,
|
|
} from "../../../src/backend/database/routes/automation-dispatch.js";
|
|
|
|
export 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);
|
|
}
|
|
|
|
/** Called from activate(). Mounts this router at /automations via the shared
|
|
* dispatcher. */
|
|
export function startAutomationsService(): void {
|
|
registerAutomationsRouter(router);
|
|
}
|
|
|
|
/** Called from deactivate(). /automations/* falls back to 404 until
|
|
* reactivated. */
|
|
export function stopAutomationsService(): void {
|
|
unregisterAutomationsRouter();
|
|
}
|