import crypto from "crypto"; import { db } from "@epicure/db"; import { webhooks, webhookDeliveries } from "@epicure/db"; import { eq, and } from "@epicure/db"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; 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", hook.secret).update(body).digest("hex"); let statusCode = 0; let success = false; try { // Re-validate at dispatch time to defeat DNS rebinding between create and // dispatch. A small window remains between this lookup and fetch's own // resolution of the hostname — accepted for now. const ssrfError = await validateWebhookUrl(hook.url); if (ssrfError) throw new Error(ssrfError); const res = await fetch(hook.url, { method: "POST", headers: { "Content-Type": "application/json", "X-Epicure-Signature": `sha256=${sig}`, "X-Epicure-Event": event, }, body, // Redirects could point at internal services, so treat 3xx as failure. redirect: "manual", 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(), }); }) ); }