/** * Outbound webhook dispatcher. * * Fetches active webhooks matching a given event, sends POST requests * with JSON payloads, and optionally signs them with HMAC-SHA256. * * Fire-and-forget — errors are logged, never thrown. */ import { createHmac } from "node:crypto"; import { logger } from "./logger.js"; import { sendSlackNotification } from "./slack-notify.js"; import { assertWebhookUrlAllowed } from "./ssrf-guard.js"; /** Available webhook event types. */ export const WEBHOOK_EVENTS = [ "allocation.created", "allocation.updated", "allocation.deleted", "project.created", "project.status_changed", "vacation.approved", "estimate.submitted", "estimate.approved", ] as const; export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; interface MinimalDb { webhook: { findMany: (args: { where: { isActive: boolean; events: { has: string } }; }) => Promise< Array<{ id: string; name: string; url: string; secret: string | null; events: string[]; }> >; }; } /** * Dispatch an event to all matching active webhooks. * This is fire-and-forget: errors are logged and swallowed. */ export function dispatchWebhooks( db: MinimalDb, event: string, payload: Record, ): void { void _dispatch(db, event, payload); } async function _dispatch( db: MinimalDb, event: string, payload: Record, ): Promise { try { const webhooks = await db.webhook.findMany({ where: { isActive: true, events: { has: event } }, }); if (webhooks.length === 0) return; const timestamp = new Date().toISOString(); const body = JSON.stringify({ event, timestamp, payload }); const promises = webhooks.map((wh) => _sendToWebhook(wh, event, body, timestamp, payload), ); await Promise.allSettled(promises); } catch (err) { logger.error({ err, event }, "Failed to dispatch webhooks"); } } async function _sendToWebhook( wh: { id: string; name: string; url: string; secret: string | null }, event: string, body: string, timestamp: string, payload: Record, ): Promise { try { await assertWebhookUrlAllowed(wh.url); // Slack-specific path: use the Slack notification helper. // Use strict hostname match to prevent bypass via "hooks.slack.com.attacker.example.com". const parsedUrl = new URL(wh.url); if (parsedUrl.hostname === "hooks.slack.com") { const message = formatSlackMessage(event, payload); await sendSlackNotification(wh.url, message); return; } const headers: Record = { "Content-Type": "application/json", "X-Webhook-Event": event, "X-Webhook-Timestamp": timestamp, }; if (wh.secret) { const signature = createHmac("sha256", wh.secret) .update(body) .digest("hex"); headers["X-Webhook-Signature"] = signature; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5_000); try { const response = await fetch(wh.url, { method: "POST", headers, body, signal: controller.signal, }); if (!response.ok) { throw new Error(`Webhook responded with HTTP ${response.status}`); } } finally { clearTimeout(timeout); } } catch (err) { logger.warn( { err, event, webhookId: wh.id, webhookName: wh.name, webhookUrl: wh.url }, "Webhook delivery failed", ); } } /** * Format a human-readable Slack message from a webhook event. */ function formatSlackMessage( event: string, payload: Record, ): string { const label = event.replace(/\./g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); const id = (payload["id"] as string) ?? (payload["projectId"] as string) ?? ""; const name = (payload["name"] as string) ?? ""; const parts = [`*${label}*`]; if (name) parts.push(`\u2022 ${name}`); if (id) parts.push(`ID: \`${id}\``); return parts.join("\n"); }