import crypto from 'node:crypto'; import { Prisma } from '@vector/db'; import type { CreateWebhookSubscriptionRequest, UpdateWebhookSubscriptionRequest, WebhookEventName, WebhookSubscriptionListQuery, } from '@vector/shared'; import { errors } from '../lib/http-error.js'; import type { Tx } from './types.js'; // The DB stores `events` as a JSON string (pending Postgres cutover to String[]). // Parse on the way out, stringify on the way in. Keep this boundary in the service. interface StoredSubscription { id: string; url: string; secret: string; events: string; active: boolean; createdAt: Date; updatedAt: Date; } export interface WebhookSubscriptionDto { id: string; url: string; events: WebhookEventName[]; active: boolean; createdAt: string; updatedAt: string; // `secret` is returned only on create so operators can copy it into their receiver config. secret?: string; } function toDto(sub: StoredSubscription, includeSecret = false): WebhookSubscriptionDto { let events: WebhookEventName[] = []; try { const parsed = JSON.parse(sub.events); if (Array.isArray(parsed)) events = parsed as WebhookEventName[]; } catch { events = []; } return { id: sub.id, url: sub.url, events, active: sub.active, createdAt: sub.createdAt.toISOString(), updatedAt: sub.updatedAt.toISOString(), ...(includeSecret ? { secret: sub.secret } : {}), }; } export async function list(tx: Tx, q: WebhookSubscriptionListQuery) { const { page, pageSize, active } = q; const where: Prisma.WebhookSubscriptionWhereInput = {}; if (active !== undefined) where.active = active; const [rows, total] = await Promise.all([ tx.webhookSubscription.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), tx.webhookSubscription.count({ where }), ]); return { data: rows.map((r) => toDto(r)), page, pageSize, total }; } export async function create(tx: Tx, input: CreateWebhookSubscriptionRequest) { const secret = crypto.randomBytes(24).toString('base64url'); const row = await tx.webhookSubscription.create({ data: { url: input.url, secret, events: JSON.stringify(input.events), active: input.active ?? true, }, }); return toDto(row, true); } export async function update(tx: Tx, id: string, input: UpdateWebhookSubscriptionRequest) { const data: Prisma.WebhookSubscriptionUpdateInput = {}; if (input.url !== undefined) data.url = input.url; if (input.events !== undefined) data.events = JSON.stringify(input.events); if (input.active !== undefined) data.active = input.active; try { const row = await tx.webhookSubscription.update({ where: { id }, data }); return toDto(row); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { throw errors.notFound('WebhookSubscription'); } throw err; } } export async function remove(tx: Tx, id: string) { try { await tx.webhookSubscription.delete({ where: { id } }); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { throw errors.notFound('WebhookSubscription'); } throw err; } } export async function rotateSecret(tx: Tx, id: string) { const secret = crypto.randomBytes(24).toString('base64url'); try { const row = await tx.webhookSubscription.update({ where: { id }, data: { secret }, }); return toDto(row, true); } catch (err) { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { throw errors.notFound('WebhookSubscription'); } throw err; } } export async function listActiveForEvent(tx: Tx, event: WebhookEventName) { const rows = await tx.webhookSubscription.findMany({ where: { active: true } }); return rows .map((r) => toDto(r, true)) .filter((s) => s.events.includes(event)); } export function signBody(secret: string, body: string, timestamp: number): string { return crypto .createHmac('sha256', secret) .update(`${timestamp}.${body}`) .digest('hex'); }