import prisma from "@shieldai/db"; import { createHmac, timingSafeEqual } from "crypto"; import { DataSource, WebhookEventType } from "@shieldai/types"; export class WebhookHandler { private secret: string; constructor(secret?: string) { if (secret) { this.secret = secret; } else if (process.env.DARKWATCH_WEBHOOK_SECRET) { this.secret = process.env.DARKWATCH_WEBHOOK_SECRET; } else { console.warn("[Webhook] DARKWATCH_WEBHOOK_SECRET not set — signature verification will fail"); this.secret = ""; } } /** * Verify HMAC signature of incoming webhook payload. */ verifySignature(payload: string, signature: string | string[]): boolean { if (!this.secret) return false; if (!signature) return false; const sigArray = Array.isArray(signature) ? signature : [signature]; const expected = this.computeSignature(payload); for (const sig of sigArray) { if (timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { return true; } } return false; } /** * Process an incoming webhook event. * Validates, stores, and triggers appropriate action. */ async processEvent( eventType: string, payload: Record, source?: string, signature?: string ): Promise<{ eventId: string; scanTriggered: boolean }> { const payloadStr = JSON.stringify(payload); if (!signature || !this.verifySignature(payloadStr, signature)) { throw new Error("Webhook signature verification failed"); } const eventTypeNormalized = this.normalizeEventType(eventType); const event = await prisma.webhookEvent.create({ data: { eventType: eventTypeNormalized, payload: payloadStr, source, signature, }, }); let scanTriggered = false; if (eventTypeNormalized === WebhookEventType.SCAN_TRIGGER) { const userId = payload.userId as string | undefined; const source = (payload.dataSource as string) || undefined; if (userId) { scanTriggered = await this.triggerScanFromWebhook(event.id, userId, source); } } await prisma.webhookEvent.update({ where: { id: event.id }, data: { processed: true, processedAt: new Date(), }, }); return { eventId: event.id, scanTriggered }; } /** * Trigger a scan job from a webhook event. */ private async triggerScanFromWebhook( eventId: string, userId: string, dataSource?: string ): Promise { try { const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) { return false; } const job = await prisma.scanJob.create({ data: { userId, status: "PENDING", source: (dataSource as DataSource) || undefined, scheduledBy: "webhook", }, }); await prisma.webhookEvent.update({ where: { id: eventId }, data: { scanJobId: job.id }, }); return true; } catch (err) { console.error(`[Webhook] Scan trigger failed for event ${eventId}:`, err); return false; } } /** * Get webhook event history. */ async getEventHistory(limit = 50, offset = 0) { return prisma.webhookEvent.findMany({ orderBy: { createdAt: "desc" }, take: limit, skip: offset, include: { scanJob: true }, }); } /** * Get events for a specific user (via linked scan jobs). */ async getUserEvents(userId: string, limit = 50, offset = 0) { return prisma.webhookEvent.findMany({ where: { scanJob: { userId }, }, orderBy: { createdAt: "desc" }, take: limit, skip: offset, }); } /** * Process unprocessed webhook events (retry mechanism). */ async processPendingEvents(): Promise { const pending = await prisma.webhookEvent.findMany({ where: { processed: false, eventType: WebhookEventType.SCAN_TRIGGER, }, orderBy: { createdAt: "asc" }, take: 50, }); let processed = 0; for (const event of pending) { try { const payload = JSON.parse(event.payload) as Record; const userId = payload.userId as string | undefined; if (userId) { const success = await this.triggerScanFromWebhook( event.id, userId, payload.dataSource as string | undefined ); if (success) { await prisma.webhookEvent.update({ where: { id: event.id }, data: { processed: true, processedAt: new Date() }, }); processed++; } } } catch (err) { console.error(`[Webhook] Retry failed for event ${event.id}:`, err); } } return processed; } private computeSignature(payload: string): string { return createHmac("sha256", this.secret).update(payload).digest("hex"); } private normalizeEventType(eventType: string): WebhookEventType { const upper = eventType.toUpperCase().replace(/\s+/g, "_"); const validTypes: WebhookEventType[] = [WebhookEventType.SCAN_TRIGGER, WebhookEventType.BREACH_DETECTED, WebhookEventType.SUBSCRIPTION_CHANGE]; if (!validTypes.includes(upper as WebhookEventType)) { throw new Error(`Unknown event type: ${eventType}`); } return upper as WebhookEventType; } }