import { createHmac, randomBytes } from "crypto"; import { prisma } from "./prisma"; const PRIVATE_IP_RE = /^(localhost|127\.|0\.0\.0\.0|::1|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i; function isSafeWebhookUrl(raw: string): boolean { try { const { protocol, hostname } = new URL(raw); if (protocol !== "https:" && protocol !== "http:") return false; if (PRIVATE_IP_RE.test(hostname)) return false; return true; } catch { return false; } } export type WebhookEventType = | "event.created" | "growth.created" | "doctor_note.created" | "milestone.created" | "medication.due"; export function generateWebhookSecret(): string { return randomBytes(32).toString("hex"); } export async function dispatchWebhook( familyId: string, eventType: WebhookEventType, payload: Record ): Promise { const hooks = await prisma.webhook.findMany({ where: { familyId, active: true, events: { has: eventType } }, }); if (hooks.length === 0) return; const body = JSON.stringify({ event: eventType, ...payload }); await Promise.allSettled( hooks.map(async (hook) => { if (!isSafeWebhookUrl(hook.url)) return; const sig = createHmac("sha256", hook.secret).update(body).digest("hex"); await fetch(hook.url, { method: "POST", headers: { "Content-Type": "application/json", "X-Grow-Signature": `sha256=${sig}`, "X-Grow-Event": eventType, }, body, signal: AbortSignal.timeout(10_000), }); }) ); }