/** * @factory/shared — outbound webhook DELIVERY core (TASK-0130). * * The single implementation of the delivery contract (docs/api/webhooks.md * §4) used by BOTH the worker's `webhooks` processor (fan-out / retries / * resubmits) and the API's synchronous test-send endpoint (docs §2): * * - envelope §3 `{ id: "evt_…", type, workspaceId, occurredAt, * actor?, workspace?, data, dataDelta? }` * - signature §4.2 `X-Factory-Signature: t=,v1=` where * v1 = HMAC-SHA256(secret, ".") — the * SAME scheme as the incoming-webhook verification in * `automation.ts` (buildWebhookSignature); * - transport §4.1 POST, 10 s timeout, ≤3 redirects (each hop * SSRF re-checked), User-Agent factory-webhooks/1.0; * - response split §4.1 2xx=delivered · 4xx=REJECTED (terminal, no * retry) · 5xx/timeout/transport=FAILED (retried); * - backoff task §Requirements: min(30s · 2^attempt, 15min) + * jitter ±20% (C-13.8d — the published §4.3 table is * superseded; base/cap env-configurable for tests). * * Pure + side-effect-free except the fetch + the DNS lookups (SSRF). All * injectable (fetchImpl, jitter, clock) so the contract is unit-tested * without a network. */ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { generateId } from "./events.js"; import { aes256gcmDecrypt } from "./crypto.js"; import { assertPublicUrl, assertPublicUrlString } from "./net.js"; // Re-exported so callers (the API's `err.name === "SsrfError"` branch and the // delivery unit test) can import the SSRF error class from the delivery module. export { SsrfError } from "./net.js"; export const WEBHOOK_DELIVERY_TIMEOUT_MS = 10_000; export const WEBHOOK_MAX_REDIRECTS = 3; export const WEBHOOK_USER_AGENT = "factory-webhooks/1.0"; /** Task default backoff: 30 s base, 15 min cap (C-13.8d). */ export const WEBHOOK_BACKOFF_BASE_MS = 30_000; export const WEBHOOK_BACKOFF_MAX_MS = 15 * 60_000; // --------------------------------------------------------------------------- // Signature (outbound == inbound scheme, doc §4.2) // --------------------------------------------------------------------------- /** v1 = HMAC-SHA256_hex(secret, `.` + rawBody). */ export function signWebhookBody(secret: string, rawBody: string, ts: number): string { return createHmac("sha256", secret).update(`${ts}.` + rawBody).digest("hex"); } /** `X-Factory-Signature` value: `t=,v1=` (doc §4.2 canonical). */ export function webhookSignatureHeader(secret: string, rawBody: string, ts: number): string { return `t=${ts},v1=${signWebhookBody(secret, rawBody, ts)}`; } /** * Parse a received `X-Factory-Signature`. Canonical form `t=,v1=`; * tolerant of `;` / `; ` / `, ` separators (the acceptance-criteria shorthand * and any legacy emission still verify). */ export function parseWebhookSignatureHeader( header: string | undefined ): { t: number; v1: string } | null { if (!header) return 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] }; } /** Constant-time verification of a received signature. */ export function verifyWebhookSignatureValue( secret: string, rawBody: string, ts: number, claimedV1: string ): boolean { const expected = signWebhookBody(secret, rawBody, ts); if (claimedV1.length !== expected.length) return false; return timingSafeEqual(Buffer.from(expected), Buffer.from(claimedV1)); } // --------------------------------------------------------------------------- // Secret resolution (C-13.8a — the API WRITES these columns, the worker READS) // --------------------------------------------------------------------------- export type SigningSecretSource = "encrypted" | "legacy-ciphertext" | "legacy-hash"; /** * Resolve the RAW signing secret for a webhook row: * 1. `secretEncrypted` (base64 iv‖tag‖ct) → decrypt; * 2. legacy Phase-11 rows stored the CIPHERTEXT in `secretHash` → decrypt it; * 3. a bare 64-hex `secretHash` → sign with the hash itself (documented * legacy behavior — the pre-encryption rows never kept the raw secret); * 4. otherwise null (unrecoverable — the dispatcher dead-letters). */ export function resolveSigningSecret( masterKey: string, secretEncrypted: Uint8Array | string | null | undefined, secretHash: string ): { secret: string; source: SigningSecretSource } | null { if (!masterKey) return null; const b64 = typeof secretEncrypted === "string" && secretEncrypted.length > 0 ? secretEncrypted : secretEncrypted != null && secretEncrypted.length > 0 ? Buffer.from(secretEncrypted).toString("base64") : null; if (b64) { try { return { secret: aes256gcmDecrypt(b64, masterKey), source: "encrypted" }; } catch { /* corrupt / rotated key — fall through */ } } try { return { secret: aes256gcmDecrypt(secretHash, masterKey), source: "legacy-ciphertext" }; } catch { /* not a ciphertext */ } if (/^[0-9a-f]{64}$/.test(secretHash)) { return { secret: secretHash, source: "legacy-hash" }; } return null; } // --------------------------------------------------------------------------- // Envelope (doc §3) // --------------------------------------------------------------------------- export interface DeliveryWorkspace { id: string; name: string; slug: string; } export interface DeliveryEnvelopeInput { id?: string; // stable event id (evt_…) — minted once, reused across retries type: string; workspaceId: string; workspace?: DeliveryWorkspace; occurredAt?: string; // ISO — default now data: Record; dataDelta?: Record; // updated events: changed fields only actor?: { type: "user" | "api_key" | "integration" | "system"; id?: string }; /** Test-send marker (docs §2 — the synthetic webhook.test payload). */ test?: boolean; } /** Build the delivery envelope. The `id` is the dedupe key (X-Factory-Event-Id). */ export function buildDeliveryEnvelope(input: DeliveryEnvelopeInput): Record { const env: Record = { id: input.id ?? generateId("evt"), type: input.type, workspaceId: input.workspaceId, occurredAt: input.occurredAt ?? new Date().toISOString(), data: input.data }; if (input.workspace) env.workspace = input.workspace; if (input.dataDelta) env.dataDelta = input.dataDelta; if (input.actor) env.actor = input.actor; if (input.test) env.test = true; return env; } // --------------------------------------------------------------------------- // Backoff (task §Requirements — min(base·2^attempt, cap) + jitter ±20%) // --------------------------------------------------------------------------- export interface BackoffOptions { /** Base delay in ms for attempt 1 (default 30 000 — task default). */ baseMs?: number; /** Cap in ms (default 900 000 = 15 min). */ maxMs?: number; /** Jitter multiplier factory, expected range [0.8, 1.2]; injectable for tests. */ jitter?: () => number; /** Random source (injectable for deterministic tests). */ rand?: () => number; } /** * `min(baseMs · 2^(attempt-1), maxMs)` × jitter (±20%). attempt is 1-based. */ export function webhookBackoffMs(attempt: number, opts: BackoffOptions = {}): number { const baseMs = opts.baseMs ?? WEBHOOK_BACKOFF_BASE_MS; const maxMs = opts.maxMs ?? WEBHOOK_BACKOFF_MAX_MS; const rand = opts.rand ?? Math.random; const jitter = opts.jitter ?? (() => 1 + (rand() * 0.4 - 0.2)); const raw = baseMs * 2 ** Math.max(0, attempt - 1); const capped = Math.min(raw, maxMs); return Math.max(1, Math.round(capped * jitter())); } // --------------------------------------------------------------------------- // SSRF guard (delivery URL — docs §1.1: no private/link-local/loopback) // --------------------------------------------------------------------------- /** * Validate a delivery URL before any bytes go out: * - scheme + private-IP-literal + internal-hostname checks (no network); * - DNS-resolved re-check for non-literal hosts (rebinding defense). * `allowPrivate` (dev override, WEBHOOK_ALLOW_PRIVATE) skips the checks. * Throws {@link SsrfError} on any violation. */ export async function assertSafeDeliveryUrl(url: string, allowPrivate: boolean): Promise { if (allowPrivate) return; const u = new URL(url); assertPublicUrlString(u.toString()); const host = u.hostname; // Pure IP literals were already checked by assertPublicUrlString; only // hostnames need the DNS re-check. if (!/^[0-9a-fA-F:.]+$/.test(host)) { await assertPublicUrl(u.toString()); } } // --------------------------------------------------------------------------- // Transport (doc §4.1) // --------------------------------------------------------------------------- export interface DeliveryTarget { url: string; secret: string; event: string; envelope: Record; deliveryId: string; /** 1-based attempt number (X-Factory-Attempt). */ attempt: number; } export interface DeliveryResult { status: number; latencyMs: number; /** Set when the request could not complete (timeout / transport / SSRF). */ transportError?: string; } /** * POST one delivery attempt. Manual redirects (≤3, each re-SSRF-checked). * Never throws for 4xx/5xx — those are the response contract; transport * failures + timeouts are reported via `transportError` so the caller can * classify FAILED-vs-terminal deterministically. */ export async function postWebhookDelivery( target: DeliveryTarget, opts: { allowPrivate?: boolean; timeoutMs?: number; fetchImpl?: typeof fetch; } = {} ): Promise { const doFetch = opts.fetchImpl ?? fetch; const timeoutMs = opts.timeoutMs ?? WEBHOOK_DELIVERY_TIMEOUT_MS; const allowPrivate = opts.allowPrivate ?? false; const rawBody = JSON.stringify(target.envelope); const ts = Math.floor(Date.now() / 1000); const started = Date.now(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { let current = target.url; let last: Response; for (let hop = 0; hop <= WEBHOOK_MAX_REDIRECTS; hop++) { await assertSafeDeliveryUrl(current, allowPrivate); last = await doFetch(current, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": WEBHOOK_USER_AGENT, "X-Factory-Event": target.event, "X-Factory-Event-Id": String(target.envelope.id ?? ""), "X-Factory-Delivery-Id": target.deliveryId, "X-Factory-Attempt": String(target.attempt), "X-Factory-Signature": webhookSignatureHeader(target.secret, rawBody, ts) }, body: rawBody, redirect: "manual", signal: controller.signal }); if ([301, 302, 303, 307, 308].includes(last.status)) { const loc = last.headers.get("location"); if (!loc) { return { status: 502, latencyMs: Date.now() - started, transportError: "redirect without Location header" }; } current = new URL(loc, current).toString(); continue; } return { status: last.status, latencyMs: Date.now() - started }; } return { status: 502, latencyMs: Date.now() - started, transportError: `too many redirects (>${WEBHOOK_MAX_REDIRECTS})` }; } catch (err) { const aborted = (err as Error).name === "AbortError"; return { status: 0, latencyMs: Date.now() - started, transportError: aborted ? `timeout after ${timeoutMs}ms` : (err as Error).message }; } finally { clearTimeout(timer); } } /** Classify a delivery result per docs §4.1 (the single source of the split). */ export function classifyDeliveryResult( result: DeliveryResult ): "DELIVERED" | "REJECTED" | "FAILED" { if (result.transportError) return "FAILED"; if (result.status >= 200 && result.status < 300) return "DELIVERED"; if (result.status >= 400 && result.status < 500) return "REJECTED"; return "FAILED"; } /** Fresh delivery id (`dlv_…`-shaped uuid). */ export function newDeliveryId(): string { return randomUUID(); }