import crypto from "crypto"; import { db } from "@epicure/db"; import { webhooks, webhookDeliveries } from "@epicure/db"; import { eq, and } from "@epicure/db"; import { safeFetch } from "@/lib/validate-webhook-url"; import { decrypt } from "@/lib/encrypt"; // Secrets created before encryption-at-rest was added are a bare 64-char hex // string (crypto.randomBytes(32).toString("hex")) with no ":" separators — // encrypt()'s output is always "iv:authTag:ciphertext". Fall back to treating // the value as already-plaintext rather than a hard migration, since this is // only reached with a value this app itself generated (never user input). function decryptWebhookSecret(stored: string): string { return stored.split(":").length === 3 ? decrypt(stored) : stored; } export const WEBHOOK_EVENTS = [ "recipe.created", "recipe.updated", "recipe.published", "recipe.deleted", "meal_plan.updated", "shopping_list.completed", "comment.added", ] as const; export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; export async function dispatchWebhook(userId: string, event: WebhookEvent, payload: object) { const hooks = await db .select() .from(webhooks) .where(and(eq(webhooks.userId, userId), eq(webhooks.active, true))); const filtered = hooks.filter((h) => h.events.length === 0 || h.events.includes(event)); await Promise.allSettled( filtered.map(async (hook) => { const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() }); const sig = crypto.createHmac("sha256", decryptWebhookSecret(hook.secret)).update(body).digest("hex"); let statusCode = 0; let success = false; try { // safeFetch resolves and pins the connection to a single validated IP // (and re-validates + re-pins every redirect hop), so there's no // separate re-resolution for a rebinding attack to exploit. const res = await safeFetch(hook.url, { method: "POST", headers: { "Content-Type": "application/json", "X-Epicure-Signature": `sha256=${sig}`, "X-Epicure-Event": event, }, body, signal: AbortSignal.timeout(10000), }); statusCode = res.status; success = res.ok; } catch { // delivery failed; statusCode stays 0, success stays false } await db.insert(webhookDeliveries).values({ id: crypto.randomUUID(), webhookId: hook.id, event, payload: payload as Record, statusCode, success, attempts: 1, createdAt: new Date(), }); }) ); }