/** * @factory/shared/automation — Phase-11 automation engine contracts. * * Single source of truth shared by the API (save-time validation), the worker * (runtime execution) and the UI (form metadata). Contents: * * - enum value lists (trigger / action / status / run-status), * - trigger config zod schemas (one per trigger type), * - the condition expression schema (AND/OR/NOT tree, 14 operators) + * `validateConditionTree` (depth ≤ 5, nodes ≤ 100), * - the 9 action config zod schemas + `AUTOMATION_ACTIONS` / * `AUTOMATION_TRIGGERS` display metadata (the UI renders from these), * - cron validation (cron-parser; rejects intervals < 60 s), * - the status machine (valid transitions table), * - loop-prevention helpers (depth cap 5, self-trigger block), * - incoming-webhook signature build/verify (HMAC-SHA256 + timestamp, * constant-time compare), * - interpolation (the 7 key families), * - JSON truncation (64 KB / 128 KB run-storage caps), * - run-state helpers (terminal set, backoff schedule). * * The worker re-declares no automation constants: everything runtime * decisions need lives here (TASK-0110/0111/0112/0113 contract). */ import { createHmac, timingSafeEqual, randomBytes } from "node:crypto"; import { z } from "zod"; // NOTE: cron-parser is CJS (`module.exports = CronParser` — a whole-object // export), so Node's ESM named-import interop CANNOT see its named exports // (cjs-module-lexer fails on `module.exports = X`). The default import // (= module.exports) works under both CJS (the API) and Node ESM (the // worker) runtimes. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment import cronParser from "cron-parser"; // --------------------------------------------------------------------------- // Enums (mirror the Prisma enums — keep in sync with prisma/schema.prisma) // --------------------------------------------------------------------------- export const AUTOMATION_TRIGGER_TYPES = [ "RECORD_CREATED", "RECORD_UPDATED", "PROPERTY_CHANGED", "PAGE_CREATED", "FORM_SUBMITTED", "SCHEDULE", "WEBHOOK", "MANUAL" ] as const; export type AutomationTriggerType = (typeof AUTOMATION_TRIGGER_TYPES)[number]; export const AUTOMATION_ACTION_TYPES = [ "UPDATE_RECORD", "CREATE_PAGE", "SEND_EMAIL", "SEND_WEBHOOK", "CREATE_NOTIFICATION", "ASSIGN_USER", "CHANGE_STATUS", "CALL_INTEGRATION", "RUN_AI_ACTION" ] as const; export type AutomationActionType = (typeof AUTOMATION_ACTION_TYPES)[number]; export const AUTOMATION_STATUSES = ["DRAFT", "ACTIVE", "PAUSED", "ARCHIVED"] as const; export type AutomationStatus = (typeof AUTOMATION_STATUSES)[number]; export const AUTOMATION_RUN_STATUSES = [ "PENDING", "RUNNING", "SUCCEEDED", "FAILED", "DEAD_LETTER" ] as const; export type AutomationRunStatus = (typeof AUTOMATION_RUN_STATUSES)[number]; /** Terminal run states (SUCCEEDED | FAILED | DEAD_LETTER). */ export const TERMINAL_RUN_STATUSES: readonly AutomationRunStatus[] = [ "SUCCEEDED", "FAILED", "DEAD_LETTER" ]; // --------------------------------------------------------------------------- // Engine limits (frozen by the phase spec) // --------------------------------------------------------------------------- /** Max actions stored on an automation (save-time cap, 422 TOO_MANY_ACTIONS). */ export const AUTOMATION_MAX_ACTIONS = 50; /** Max triggers per automation (OR-semantics). */ export const AUTOMATION_MAX_TRIGGERS = 3; /** Run-level retry cap: exponential backoff, then dead-letter. */ export const AUTOMATION_MAX_RETRIES = 8; /** Loop-prevention depth cap (events at depth >= 5 are dropped). */ export const AUTOMATION_MAX_DEPTH = 5; /** Condition tree: max nesting depth. */ export const CONDITION_MAX_DEPTH = 5; /** Condition tree: max total nodes. */ export const CONDITION_MAX_NODES = 100; /** Max condition groups stored on an automation row set (0..1 tree). */ export const AUTOMATION_MAX_CONDITIONS = 1; /** Max automation name length. */ export const AUTOMATION_NAME_MAX = 100; /** Max description length. */ export const AUTOMATION_DESCRIPTION_MAX = 500; /** Action retry count bounds (per action). */ export const AUTOMATION_ACTION_RETRIES = { min: 1, max: 8, default: 3 } as const; /** Action timeout bounds (ms, per action). */ export const AUTOMATION_ACTION_TIMEOUT_MS = { min: 1_000, max: 300_000, default: 30_000 } as const; /** Run storage truncation caps (bytes, UTF-8). */ export const RUN_INPUT_MAX_BYTES = 64 * 1024; export const RUN_OUTPUT_MAX_BYTES = 128 * 1024; /** Incoming-webhook body cap (bytes) + stored payload cap. */ export const WEBHOOK_BODY_MAX_BYTES = 256 * 1024; export const WEBHOOK_PAYLOAD_MAX_BYTES = 64 * 1024; /** Webhook signature timestamp tolerance (±5 min). */ export const WEBHOOK_TS_TOLERANCE_MS = 5 * 60 * 1000; /** Webhook per-endpoint rate limit (requests / minute). */ export const WEBHOOK_RATE_PER_MINUTE = 60; /** Manual-run input cap (bytes). */ export const MANUAL_RUN_INPUT_MAX_BYTES = 64 * 1024; /** Cron minimum interval: reject expressions firing more than once a minute. */ export const CRON_MIN_INTERVAL_MS = 60_000; /** Automation emails per workspace per day (SEND_EMAIL cap). */ export const AUTOMATION_EMAIL_DAILY_CAP = 500; /** * Run-level retry backoff schedule (seconds): attempt n (1-based, the FIRST * retry is attempt 1) waits 2^(n-1) s capped at 600 s. TASK-0113 unit-test * pins this; the worker multiplies by AUTO_BACKOFF_BASE_MS in test profiles. */ export function runBackoffSeconds(attempt: number): number { if (!Number.isInteger(attempt) || attempt < 1) return 0; return Math.min(2 ** (attempt - 1), 600); } // --------------------------------------------------------------------------- // Status machine // --------------------------------------------------------------------------- /** * Valid status transitions (TASK-0110 §2). Anything else → 409: * DRAFT → ACTIVE * ACTIVE ↔ PAUSED * ACTIVE/PAUSED → ARCHIVED * ARCHIVED → ACTIVE * (DRAFT/PAUSED → ARCHIVED allowed too? No — the spec allows * ACTIVE/PAUSED → ARCHIVED and ARCHIVED → ACTIVE; DRAFT → ARCHIVED is not in * the spec's valid set. The 5 invalid pairs the tests assert include * DRAFT → PAUSED.) */ /** * Status machine (TASK-0110 §2) — exactly 6 valid transitions: * DRAFT→ACTIVE, ACTIVE↔PAUSED, ACTIVE/PAUSED→ARCHIVED, ARCHIVED→ACTIVE. * DRAFT→ARCHIVED is NOT a transition (a DRAFT row is removed via the DELETE * endpoint, which archives it as a separate operation — not a status change). */ export const AUTOMATION_STATUS_TRANSITIONS: Record = { DRAFT: ["ACTIVE"], ACTIVE: ["PAUSED", "ARCHIVED"], PAUSED: ["ACTIVE", "ARCHIVED"], ARCHIVED: ["ACTIVE"] }; export function isValidAutomationTransition(from: AutomationStatus, to: AutomationStatus): boolean { if (from === to) return false; // no-op transition is invalid (client bug) return (AUTOMATION_STATUS_TRANSITIONS[from] ?? []).includes(to); } // --------------------------------------------------------------------------- // Trigger schemas // --------------------------------------------------------------------------- const cuid = z .string() .min(1) .max(64) .refine((s) => !s.includes(" ") && s.length >= 8, "expected an id"); export const TriggerConfigSchemas = { RECORD_CREATED: z.object({ databaseId: cuid.optional() }).strict(), RECORD_UPDATED: z .object({ databaseId: cuid.optional(), /** Optional narrowing: only these property names (empty/absent = all). */ properties: z.array(z.string().min(1).max(100)).max(20).optional() }) .strict(), PROPERTY_CHANGED: z .object({ databaseId: cuid.optional(), /** Required: fires when any of these properties changed. */ properties: z.array(z.string().min(1).max(100)).min(1).max(20) }) .strict(), PAGE_CREATED: z.object({}).strict(), FORM_SUBMITTED: z.object({ databaseId: cuid.optional() }).strict(), SCHEDULE: z .object({ cron: z.string().min(2).max(120), timeZone: z.string().min(1).max(64).optional() }) .strict() .refine((c) => cronCheck(c.cron).ok, { message: "invalid cron" }), WEBHOOK: z .object({ /** Server-generated public id (cuid) — returned once with the secret. */ webhookId: cuid.optional(), /** sha256(secret) hex — for display/regeneration bookkeeping. */ secretHash: z.string().regex(/^[0-9a-f]{64}$/).optional(), /** KMS-wrapped secret (see webhook-secret.ts) — server-managed. */ secretEncrypted: z.string().min(8).optional() }) .strict(), MANUAL: z.object({}).strict() } as const; export const AUTOMATION_TRIGGER_TYPES_Z = z.enum(AUTOMATION_TRIGGER_TYPES); export const AutomationTriggerDto = z.object({ type: AUTOMATION_TRIGGER_TYPES_Z, config: z .object({}) .passthrough() .default({}) }); /** * Validate a trigger config against its type-specific schema. Used by the * API (save-time, 422 with path-located errors) — the worker trusts stored * rows but re-checks SCHEDULE cron at reconcile time (corrupt-row defense). */ export function validateTriggerConfig( type: AutomationTriggerType, config: unknown ): { ok: true } | { ok: false; errors: { path: string; message: string }[] } { const schema = TriggerConfigSchemas[type]; const parsed = schema.safeParse(config ?? {}); if (parsed.success) return { ok: true }; return { ok: false, errors: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) }; } // --------------------------------------------------------------------------- // Cron // --------------------------------------------------------------------------- export interface CronCheckResult { ok: boolean; /** "CRON_INVALID" | "CRON_TOO_FREQUENT" | null. */ code: "CRON_INVALID" | "CRON_TOO_FREQUENT" | null; message: string | null; /** Next fire time (UTC) when valid. */ nextFire?: Date; } /** * Validate a 5/6-field cron expression (cron-parser; seconds field optional — * the spec's "6-field, seconds optional" = 5-field minute-precision or * 6-field second-precision; 4-field expressions are rejected even though * cron-parser itself accepts them). Rejects unparseable expressions and any * interval < 60 s between two consecutive fires (CRON_TOO_FREQUENT). */ export function cronCheck(expr: string): CronCheckResult { const fields = expr.trim().split(/\s+/).filter(Boolean); if (fields.length !== 5 && fields.length !== 6) { return { ok: false, code: "CRON_INVALID", message: `cron must have 5 or 6 fields (got ${fields.length})` }; } let intervalMs = Infinity; try { const it = cronParser.parseExpression(expr); const next1 = it.next().toDate(); const next2 = it.next().toDate(); intervalMs = next2.getTime() - next1.getTime(); return { ok: intervalMs >= CRON_MIN_INTERVAL_MS, code: intervalMs >= CRON_MIN_INTERVAL_MS ? null : "CRON_TOO_FREQUENT", message: intervalMs >= CRON_MIN_INTERVAL_MS ? null : `cron fires every ${Math.round(intervalMs / 1000)} s (minimum 60 s)`, nextFire: next1 }; } catch (err) { return { ok: false, code: "CRON_INVALID", message: `invalid cron expression: ${(err as Error).message}` }; } } // --------------------------------------------------------------------------- // Condition expression (TASK-0111) // --------------------------------------------------------------------------- export const CONDITION_OPERATORS = [ "eq", "neq", "contains", "not_contains", "starts_with", "ends_with", "gt", "gte", "lt", "lte", "in", "not_in", "is_empty", "is_not_empty" ] as const; export type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; const predicateSchema = z .object({ op: z.never().optional(), field: z .object({ ref: z.enum(["event", "property"]), name: z.string().min(1).max(200) }) .strict(), operator: z.enum(CONDITION_OPERATORS), value: z .union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number(), z.boolean()]))]) .optional() }) .strict(); /** * Condition expression tree (frozen shape — stored on * AutomationCondition.expression, evaluated worker-side). * * { op: "AND" | "OR", children: Expr[] } (AND/OR need ≥ 2 children) * { op: "NOT", child: Expr } (single child — spec TASK-0111) * { field: { ref, name }, operator, value? } predicate * * `value` is required for every operator except is_empty / is_not_empty. * The zod schema is a save-time shape check only — the authoritative * structural check is {@link validateConditionTree} (depth/node caps + * per-group child-count rules), which is what the API calls. */ export const CONDITION_EXPRESSION_SCHEMA: z.ZodType = z.lazy(() => z.union([ z .object({ op: z.enum(["AND", "OR"]), children: z.array(CONDITION_EXPRESSION_SCHEMA).min(1).max(CONDITION_MAX_NODES) }) .strict(), z.object({ op: z.literal("NOT"), child: CONDITION_EXPRESSION_SCHEMA }).strict(), predicateSchema ]) ); export interface ConditionTreeCheck { ok: boolean; code: "CONDITION_TOO_COMPLEX" | "CONDITION_INVALID" | null; message: string | null; depth: number; nodes: number; } /** * Structural validation of a stored condition tree: * - well-formed (groups have children arrays; AND/OR ≥ 2, NOT == 1), * - depth ≤ CONDITION_MAX_DEPTH (counting group levels; a bare predicate = 1), * - total nodes ≤ CONDITION_MAX_NODES (groups count as 1 each). * Never throws — returns a check result (the API maps failures to 422, * the worker to CONDITION_INVALID at evaluation time). */ export function validateConditionTree(tree: unknown): ConditionTreeCheck { if (tree === null || tree === undefined) return { ok: true, code: null, message: null, depth: 0, nodes: 0 }; let depth = 0; // max GROUP nesting level (a bare predicate = 0) let nodes = 0; let valid = true; let message: string | null = null; let code: "CONDITION_TOO_COMPLEX" | "CONDITION_INVALID" | null = null; /** `enclosing` = number of group ancestors of `node`. */ const walk = (node: unknown, enclosing: number): void => { if (!valid) return; nodes += 1; if (nodes > CONDITION_MAX_NODES) { valid = false; code = "CONDITION_TOO_COMPLEX"; message = `condition tree exceeds ${CONDITION_MAX_NODES} nodes`; return; } if (typeof node !== "object" || node === null) { valid = false; code = "CONDITION_INVALID"; message = "condition node must be an object"; return; } const n = node as Record; if (n.op === "AND" || n.op === "OR" || n.op === "NOT") { const level = enclosing + 1; if (level > CONDITION_MAX_DEPTH) { valid = false; code = "CONDITION_TOO_COMPLEX"; message = `condition tree exceeds depth ${CONDITION_MAX_DEPTH}`; return; } let children: unknown[]; if (n.op === "NOT") { // Spec shape: { op: "NOT", child: Expr }. if (n.child === undefined) { valid = false; code = "CONDITION_INVALID"; message = "NOT group needs a `child`"; return; } children = [n.child]; } else { if (!Array.isArray(n.children) || n.children.length === 0) { valid = false; code = "CONDITION_INVALID"; message = `${n.op} group needs children`; return; } if (n.children.length < 2) { valid = false; code = "CONDITION_INVALID"; message = `${n.op} group needs ≥ 2 children`; return; } children = n.children; } depth = Math.max(depth, level); for (const c of children) walk(c, level); return; } // Predicate leaf. const f = n.field; if ( typeof f !== "object" || f === null || !["event", "property"].includes((f as { ref?: unknown }).ref as string) || typeof (f as { name?: unknown }).name !== "string" ) { valid = false; code = "CONDITION_INVALID"; message = "predicate needs field { ref: event|property, name }"; return; } const op = n.operator; if (typeof op !== "string" || !(CONDITION_OPERATORS as readonly string[]).includes(op)) { valid = false; code = "CONDITION_INVALID"; message = `unknown operator: ${String(op)}`; return; } if (op !== "is_empty" && op !== "is_not_empty" && n.value === undefined) { valid = false; code = "CONDITION_INVALID"; message = `operator ${op} requires a value`; return; } }; walk(tree, 0); if (!valid) return { ok: false, code, message, depth, nodes }; return { ok: true, code: null, message: null, depth, nodes }; } /** * Normalize a stored property value for condition comparison (the spec's * value-shape table). SELECT/STATUS → option name (or null); * MULTI_SELECT → sorted option-name array; PERSON → user-id array; * DATE → ISO string; NUMBER → number; CHECKBOX → boolean; TITLE/TEXT → * string. Unknown shapes pass through (the operators fail closed on them). */ export function normalizeConditionValue( type: string, config: Record | null | undefined, value: unknown ): unknown { const options: { id: string; name: string }[] = Array.isArray(config?.options) ? (config!.options as { id: string; name: string }[]) : []; const nameOf = (id: unknown): string => typeof id === "string" ? (options.find((o) => o.id === id)?.name ?? id) : String(id); switch (type) { case "SELECT": case "STATUS": return typeof value === "string" ? nameOf(value) : value ?? null; case "MULTI_SELECT": return Array.isArray(value) ? [...value].map(nameOf).sort() : value ?? null; case "PERSON": return Array.isArray(value) ? value.map(String) : value ?? null; case "DATE": if (value && typeof value === "object" && "start" in value && "end" in value) { return `${(value as { start: unknown }).start}..${(value as { end: unknown }).end}`; } return typeof value === "string" || typeof value === "number" ? String(value) : value ?? null; case "NUMBER": return typeof value === "number" && Number.isFinite(value) ? value : value ?? null; case "CHECKBOX": return typeof value === "boolean" ? value : value ?? null; case "TITLE": case "TEXT": return typeof value === "string" ? value : value ?? null; default: return value ?? null; } } // --------------------------------------------------------------------------- // Property value coercion (TASK-0112 executors write Phase-5 envelopes) // --------------------------------------------------------------------------- /** * Coerce an automation action's raw property value into the Phase-5 stored * envelope shape (the single source of truth the API's record write path uses * — mirror of `query.service.coerceValue`, shared so executors and the API * never drift): * TEXT/TITLE/RICH_TEXT → { text, marks: [] } NUMBER/EMAIL/PHONE → { value } * SELECT/STATUS → { optionId } MULTI_SELECT → { optionIds } * PERSON → { userIds } DATE/DATE_RANGE → { start, end } * CHECKBOX → { value } URL → { value, title: null } * The input may already be an envelope (passed through) or a bare value * (SELECT: option NAME is resolved by the CALLER — the shared layer sees the * optionId string). Returns null for a null input. * * @throws {PropertyCoercionError} (code `PROPERTY_NOT_WRITABLE` / * `PROPERTY_VALUE_INVALID`) on read-only types or uncoercible values. */ export class PropertyCoercionError extends Error { constructor( public readonly code: "PROPERTY_NOT_WRITABLE" | "PROPERTY_VALUE_INVALID", message: string ) { super(message); this.name = "PropertyCoercionError"; } } const READ_ONLY_PROPERTY_TYPES = new Set([ "FORMULA", "ROLLUP", "CREATED_TIME", "CREATED_BY", "LAST_EDITED_TIME", "LAST_EDITED_BY", "RELATION", "BUTTON", "UNIQUE_ID" ]); export function coercePropertyWriteValue( type: string, raw: unknown ): Record | null { if (raw === null || raw === undefined) return null; if (READ_ONLY_PROPERTY_TYPES.has(type)) { throw new PropertyCoercionError("PROPERTY_NOT_WRITABLE", `${type} is read-only`); } const asStringArray = (v: unknown): string[] => { const arr = Array.isArray(v) ? v : typeof v === "string" ? [v] : []; if (!arr.every((x) => typeof x === "string")) { throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "string array required"); } return arr; }; switch (type) { case "TEXT": case "RICH_TEXT": case "TITLE": { if (typeof raw === "string") return { text: raw, marks: [] }; if (typeof raw === "object" && raw !== null && "text" in (raw as object)) return raw as Record; throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "text value required"); } case "NUMBER": case "EMAIL": case "PHONE": case "CHECKBOX": { if (typeof raw === "number" || typeof raw === "string" || typeof raw === "boolean") return { value: raw }; if (typeof raw === "object" && raw !== null && "value" in (raw as object)) return raw as Record; throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", `${type} value required`); } case "SELECT": case "STATUS": { if (typeof raw === "string") return { optionId: raw }; if (typeof raw === "object" && raw !== null && "optionId" in (raw as object)) return raw as Record; throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "optionId value required"); } case "MULTI_SELECT": return { optionIds: asStringArray(raw) }; case "PERSON": return { userIds: asStringArray(raw) }; case "FILES": return { fileIds: asStringArray(raw) }; case "DATE": case "DATE_RANGE": { if (typeof raw === "string") return { start: raw, end: null }; if (typeof raw === "object" && raw !== null && "start" in (raw as object)) return raw as Record; throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "date value required"); } case "URL": { if (typeof raw === "string") return { value: raw, title: null }; if (typeof raw === "object" && raw !== null && "value" in (raw as object)) return raw as Record; throw new PropertyCoercionError("PROPERTY_VALUE_INVALID", "url value required"); } default: throw new PropertyCoercionError("PROPERTY_NOT_WRITABLE", `type ${type} does not accept direct writes`); } } // --------------------------------------------------------------------------- // Action schemas (TASK-0112) // --------------------------------------------------------------------------- /** Interpolable string field: max length + `{{…}}` interpolation allowed. */ const interpString = (max: number) => z .string() .min(1) .max(max) .refine((s) => { // Reject interpolation paths deeper than 3 segments (spec §9). const paths = [...s.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((m) => (m[1] as string).split(".")); return paths.every((p) => p.length <= 3); }, "interpolation path too deep (max 3 segments)"); const fromTrigger = z .literal("from_trigger") .or(cuid) .or(z.literal("trigger_user")) .or(z.literal("automation_creator")); export const ActionConfigSchemas: Record = { UPDATE_RECORD: z .object({ recordId: z .union([z.literal("from_trigger"), cuid]) .default("from_trigger"), /** property NAME → raw value (coerced at runtime via the record service). */ propertyValues: z .record(z.string().min(1).max(100), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))])) .refine((o) => Object.keys(o).length >= 1, "propertyValues must have ≥ 1 entry") .refine((o) => Object.keys(o).length <= 20, "propertyValues allows ≤ 20 entries") }) .strict(), CREATE_PAGE: z .object({ title: interpString(200), parentId: z.union([z.literal("from_trigger_page"), cuid]).optional(), databaseId: cuid.optional(), /** Phase-19 templates — optional, validated if set (spec §2). */ templateId: cuid.optional(), icon: z .string() .min(1) .max(8) .optional() .refine((s) => [...(s ?? "")].length <= 2, "icon must be an emoji (≤ 2 codepoints)"), /** Optional property values for a record page (databaseId set). */ propertyValues: z .record( z.string().min(1).max(100), z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.union([z.string(), z.number()]))]) ) .optional() }) .strict(), SEND_EMAIL: z .object({ to: z .union([ z.string().email(), z.literal("trigger_user"), z.literal("automation_creator") ]) .default("automation_creator"), subject: interpString(200), body: z.string().min(1).max(20_000), cc: z .array(z.string().email()) .max(5) .optional() }) .strict(), SEND_WEBHOOK: z .object({ url: z .string() .url() .max(2048) .refine((u) => u.startsWith("https://"), "webhook URL must be https (SSRF guard)"), headers: z .record(z.string().min(1).max(128), z.string().max(512)) .refine((h) => Object.keys(h).length <= 20, "≤ 20 headers") .optional(), payload: z .record(z.string(), z.unknown()) .optional() .refine((p) => JSON.stringify(p ?? {}).length <= WEBHOOK_PAYLOAD_MAX_BYTES, "payload ≤ 64 KB") }) .strict(), CREATE_NOTIFICATION: z .object({ to: z .union([cuid, z.literal("trigger_user"), z.literal("automation_creator")]) .default("automation_creator"), type: z.enum([ "MENTION", "FORM_SUBMITTED", "COMMENT", "ASSIGNMENT", "PAGE_SHARED", "TASK_ASSIGNED", "DATABASE_CHANGE", "AUTOMATION", "INTEGRATION" ]), body: interpString(500), /** Internal path only ("/…") — no host. */ link: z .string() .max(500) .refine((l) => l.startsWith("/") && !l.startsWith("//"), "link must be an internal path (starts with /)") .optional() }) .strict(), ASSIGN_USER: z .object({ recordId: z.union([z.literal("from_trigger"), cuid]).default("from_trigger"), /** * PERSON property id (spec TASK-0112). The API verifies at save that the * property exists, belongs to the automation's database, and is of * type PERSON (422 otherwise); the worker re-checks at runtime. */ propertyId: cuid, userId: fromTrigger }) .strict(), CHANGE_STATUS: z .object({ recordId: z.union([z.literal("from_trigger"), cuid]).default("from_trigger"), /** STATUS or SELECT property id (verified at save; 422 otherwise). */ propertyId: cuid, /** Option NAME (verified against the property's options at save). */ value: z.string().min(1).max(100) }) .strict(), CALL_INTEGRATION: z .object({ integrationId: cuid, operation: z.string().min(1).max(100), params: z.record(z.string(), z.unknown()).optional() }) .strict(), RUN_AI_ACTION: z .object({ prompt: interpString(4_000), task: z.enum(["summarize", "classify", "extract"]) }) .strict() }; export interface AutomationActionDtoInput { type: AutomationActionType; config: Record; retries?: number; timeoutMs?: number; } /** * Validate one action entry (type + config + per-action knobs). The API uses * this for `PUT .../actions` / create/update; the worker re-validates at * runtime (corrupt-row defense → fatal UNKNOWN/INVALID config errors). */ export function validateActionDto( action: AutomationActionDtoInput ): { ok: true; normalized: { type: AutomationActionType; config: Record; retries: number; timeoutMs: number } } | { ok: false; errors: { path: string; message: string }[]; } { if (!(AUTOMATION_ACTION_TYPES as readonly string[]).includes(action.type)) { return { ok: false, errors: [{ path: "type", message: `unknown action type: ${action.type}` }] }; } const schema = ActionConfigSchemas[action.type]; const parsed = schema.safeParse(action.config ?? {}); if (!parsed.success) { return { ok: false, errors: parsed.error.issues.map((i) => ({ path: `config.${i.path.join(".")}`, message: i.message })) }; } const retries = action.retries ?? AUTOMATION_ACTION_RETRIES.default; const timeoutMs = action.timeoutMs ?? AUTOMATION_ACTION_TIMEOUT_MS.default; if (!Number.isInteger(retries) || retries < AUTOMATION_ACTION_RETRIES.min || retries > AUTOMATION_ACTION_RETRIES.max) { return { ok: false, errors: [{ path: "retries", message: `retries must be ${AUTOMATION_ACTION_RETRIES.min}..${AUTOMATION_ACTION_RETRIES.max}` }] }; } if (!Number.isInteger(timeoutMs) || timeoutMs < AUTOMATION_ACTION_TIMEOUT_MS.min || timeoutMs > AUTOMATION_ACTION_TIMEOUT_MS.max) { return { ok: false, errors: [{ path: "timeoutMs", message: `timeoutMs must be ${AUTOMATION_ACTION_TIMEOUT_MS.min}..${AUTOMATION_ACTION_TIMEOUT_MS.max}` }] }; } return { ok: true, normalized: { type: action.type, config: parsed.data as Record, retries, timeoutMs } }; } // --------------------------------------------------------------------------- // UI metadata (TASK-0114 renders FROM these — no duplicated type lists) // --------------------------------------------------------------------------- export interface AutomationTriggerMeta { type: AutomationTriggerType; label: string; description: string; /** Domain events this trigger type can be sourced from (info for the UI). */ events: string[]; /** Whether the UI needs a database scope pick for this trigger. */ needsDatabase: boolean; } export const AUTOMATION_TRIGGERS: readonly AutomationTriggerMeta[] = [ { type: "RECORD_CREATED", label: "When a record is created", description: "Fires when a new record appears in a database.", events: ["record.created"], needsDatabase: true }, { type: "RECORD_UPDATED", label: "When a record is updated", description: "Fires on any record update (optionally narrowed to properties).", events: ["record.updated"], needsDatabase: true }, { type: "PROPERTY_CHANGED", label: "When a property changes", description: "Fires when one of the listed properties changes.", events: ["record.updated"], needsDatabase: true }, { type: "PAGE_CREATED", label: "When a page is created", description: "Fires when a page is created in the workspace.", events: ["page.created"], needsDatabase: false }, { type: "FORM_SUBMITTED", label: "When a form is submitted", description: "Fires on a public form submission (creates a record).", events: ["form.submitted"], needsDatabase: true }, { type: "SCHEDULE", label: "On a schedule", description: "Runs on a cron schedule (minimum 60 s).", events: ["schedule"], needsDatabase: false }, { type: "WEBHOOK", label: "When a webhook is received", description: "Fires on a signed POST to the automation's public URL.", events: ["webhook"], needsDatabase: false }, { type: "MANUAL", label: "Manually", description: "Can be run from the Run now button or the API.", events: ["manual"], needsDatabase: false } ]; export interface AutomationActionMeta { type: AutomationActionType; label: string; description: string; icon: string; /** Stub handoff badge (Phase 12 AI / Phase 13 integration) — savable as DRAFT. */ stub: "phase-12" | "phase-13" | null; } export const AUTOMATION_ACTIONS: readonly AutomationActionMeta[] = [ { type: "UPDATE_RECORD", label: "Update record", description: "Set property values on a record.", icon: "pencil", stub: null }, { type: "CREATE_PAGE", label: "Create page", description: "Create a page (or a record page).", icon: "file-plus", stub: null }, { type: "SEND_EMAIL", label: "Send email", description: "Email a user or role.", icon: "mail", stub: null }, { type: "SEND_WEBHOOK", label: "Send webhook", description: "POST a JSON payload to a public URL.", icon: "webhook", stub: null }, { type: "CREATE_NOTIFICATION", label: "Notify user", description: "Create an in-app notification.", icon: "bell", stub: null }, { type: "ASSIGN_USER", label: "Assign user", description: "Set a person property to a user.", icon: "user-plus", stub: null }, { type: "CHANGE_STATUS", label: "Change status", description: "Set a status/select property to an option.", icon: "refresh-cw", stub: null }, { type: "CALL_INTEGRATION", label: "Call integration", description: "Call a connected integration (Phase 13).", icon: "plug", stub: "phase-13" }, { type: "RUN_AI_ACTION", label: "Run AI action", description: "Summarize / classify / extract with AI (Phase 12).", icon: "sparkles", stub: "phase-12" } ]; /** Action types that fail at runtime until their phase lands (stubs). */ export const STUB_ACTION_TYPES: readonly AutomationActionType[] = [ "CALL_INTEGRATION", "RUN_AI_ACTION" ]; // --------------------------------------------------------------------------- // Loop prevention (TASK-0113 contract — enforced in the worker) // --------------------------------------------------------------------------- /** The automationContext carried on events emitted BY automation actions. */ export interface AutomationContext { runId: string; automationId: string; depth: number; } export type LoopGuardDecision = | { blocked: false } | { blocked: true; reason: "self_trigger" | "depth_limit"; depth: number }; /** * Decide whether an event should be allowed to trigger `automationId`: * - self-trigger: the event was emitted by the SAME automation → always * blocked (its own record.updated cannot re-fire it); * - depth limit: events at depth ≥ AUTOMATION_MAX_DEPTH are dropped * (a chain A→B→C→D→E stops at E's side effects). * No context (a human/API-originated event) → allowed at depth 0. */ export function loopGuard( incoming: { automationId?: string; depth?: number } | null | undefined, automationId: string ): LoopGuardDecision { const depth = typeof incoming?.depth === "number" ? incoming.depth : 0; if (incoming && typeof incoming.automationId === "string" && incoming.automationId === automationId) { return { blocked: true, reason: "self_trigger", depth }; } if (depth >= AUTOMATION_MAX_DEPTH) { return { blocked: true, reason: "depth_limit", depth }; } return { blocked: false }; } /** Depth stamp for an event emitted by an action of a run at `depth`. */ export function nextAutomationContext( runId: string, automationId: string, depth: number ): AutomationContext { return { runId, automationId, depth: depth + 1 }; } // --------------------------------------------------------------------------- // Interpolation (7 key families — single implementation, TASK-0112 §9) // --------------------------------------------------------------------------- export interface InterpolationVars { record?: Record | null; event?: Record | null; input?: Record | null; automation?: { id?: string; name?: string } | null; run?: { id?: string } | null; [key: string]: unknown; } /** * Replace `{{dotted.path}}` tokens from the vars object. * - `record.`, `record.id`, `event.type`, `event.payload.`, * `input.`, `automation.id`/`automation.name`, `run.id`, * - dot paths capped at 3 segments (save-time rejected), * - unknown key → "" + a warning entry in the returned warnings list, * - object values are JSON-stringified. */ export function interpolate( template: string, vars: InterpolationVars ): { value: string; warnings: string[] } { const warnings: string[] = []; const value = template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_m, path: string) => { const parts = path.split(".").slice(0, 3); let cur: unknown = vars; for (const p of parts) { if (cur === null || cur === undefined || typeof cur !== "object") { cur = undefined; break; } cur = (cur as Record)[p]; } if (cur === undefined || cur === null) { if (!/^(record|event|input|automation|run)\./.test(path)) { warnings.push(`unknown interpolation key: {{${path}}}`); } return ""; } if (typeof cur === "object") return JSON.stringify(cur); return String(cur); }); return { value, warnings }; } // --------------------------------------------------------------------------- // Truncation (run storage) // --------------------------------------------------------------------------- /** * Truncate a JSON-serializable value to a UTF-8 byte budget. When truncated, * the result is an object { [key]: …, truncated: true, truncatedBytes: n } * (top-level objects) or the string form with a "…" tail (non-objects). */ export function truncateJson(value: unknown, maxBytes: number): { value: unknown; truncated: boolean } { const enc = new TextEncoder(); const raw = JSON.stringify(value ?? null); if (raw === undefined) return { value: null, truncated: false }; if (enc.encode(raw).length <= maxBytes) return { value, truncated: false }; if (value !== null && typeof value === "object" && !Array.isArray(value)) { // Drop the largest leaf values first until it fits. const obj = { ...(value as Record) }; const entries = Object.entries(obj).sort((a, b) => JSON.stringify(b[1]).length - JSON.stringify(a[1]).length); for (const [k, v] of entries) { if (enc.encode(JSON.stringify(obj)).length <= maxBytes) break; obj[k] = `[truncated]`; } obj.truncated = true; return { value: obj, truncated: true }; } // Non-object: cut the serialized string to fit, preserving valid JSON. let s = raw; while (enc.encode(s).length > maxBytes - 24) { s = s.slice(0, Math.max(0, s.length - 1024)); } return { value: s + ` …[truncated]`, truncated: true }; } // --------------------------------------------------------------------------- // Incoming webhook signature (TASK-0110 §5) // --------------------------------------------------------------------------- export interface WebhookSignatureParts { t: number; // unix seconds v1: string; // hex hmac } /** Build `X-FS-Signature: t=,v1=`. */ export function buildWebhookSignature(secret: string, unixTsSeconds: number, rawBody: string): string { const mac = createHmac("sha256", secret).update(`${unixTsSeconds}.${rawBody}`).digest("hex"); return `t=${unixTsSeconds},v1=${mac}`; } export function parseWebhookSignature(header: string): WebhookSignatureParts | null { const m = header.match(/t=(\d+),v1=([0-9a-f]{64})/); if (!m || m[1] === undefined || m[2] === undefined) return null; return { t: Number(m[1]), v1: m[2] }; } /** * Verify an incoming-webhook signature in constant time. * `nowMs` is injectable for tests. Returns the failure reason code: * null = valid; "WEBHOOK_SIGNATURE_INVALID" | "WEBHOOK_TIMESTAMP_STALE". */ export function verifyWebhookSignature( secret: string, header: string | undefined, rawBody: string, nowMs: number = Date.now() ): null | "WEBHOOK_SIGNATURE_INVALID" | "WEBHOOK_TIMESTAMP_STALE" { const parts = header ? parseWebhookSignature(header) : null; if (!parts) return "WEBHOOK_SIGNATURE_INVALID"; const tsMs = parts.t * 1000; if (Math.abs(nowMs - tsMs) > WEBHOOK_TS_TOLERANCE_MS) return "WEBHOOK_TIMESTAMP_STALE"; const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"); const a = Buffer.from(expected, "hex"); const b = Buffer.from(parts.v1, "hex"); if (a.length !== b.length || !timingSafeEqual(a, b)) return "WEBHOOK_SIGNATURE_INVALID"; return null; } /** Generate a fresh webhook signing secret: "whsec_" + 32 random bytes hex. */ export function generateWebhookSecret(): string { return "whsec_" + randomBytes(32).toString("hex"); } /** sha256(secret) hex — stored for bookkeeping; the wrapped secret is what verifies. */ export function webhookSecretHash(secret: string): string { return createHmac("sha256", "hash").update(secret).digest("hex"); } // --------------------------------------------------------------------------- // Redis key helpers (dedupe / rate limit / email cap) // --------------------------------------------------------------------------- /** Matcher dedupe key: one run per (automation, event). TTL 10 min. */ export const DEDUPE_TTL_SECONDS = 600; export function dedupeKey(automationId: string, eventId: string): string { return `auto:dedupe:${automationId}:${eventId}`; } /** Per-webhookId rate-limit key (sliding 60 s window). */ export function webhookRateKey(webhookId: string): string { return `auto:webhook-rate:${webhookId}`; } /** Per-workspace daily automation-email cap key (UTC date). */ export function emailCapKey(workspaceId: string, date: Date = new Date()): string { return `auto:email:${workspaceId}:${date.toISOString().slice(0, 10)}`; }