/** * Webhooks (TASK-0130): workspace-level event subscriptions, production-grade. * * Contract: docs/api/webhooks.md (the published spec) + the task requirements. * - CRUD with catalog validation (unknown event → 400 listing the catalog), * SSRF-guarded delivery URLs (private/link-local/loopback rejected; * `WEBHOOK_ALLOW_PRIVATE=true` dev override), 20-per-workspace cap (409). * - Secret storage: `secretEncrypted` (AES-256-GCM, shared envelope) is the * source of truth for NEW rows; `secretHash` = sha256hex(secret) * bookkeeping. The worker's `resolveSigningSecret` keeps Phase-11 legacy * rows (ciphertext-in-secretHash / bare hash) working (C-13.8a). * - The raw secret is returned EXACTLY ONCE (create + rotate). List/get * expose only `secretPrefix`. * - Test send: SYNCHRONOUS dispatch through the shared delivery core * (docs §2 — the test delivery does not enter the retry queue). * - Deliveries: list (filters status/event/since/until, newest first, no * payload) + single (full payload) + per-delivery retry (doc §6) + * resubmit (task: new rows linked by originDeliveryId, attempts reset). * - DELETE is a soft delete (active=false; the row + delivery log are kept). * * Dispatch + retries + DLQ live in the worker (`webhooks.processor.ts`); the * delivery contract itself is the single shared implementation * (`@factory/shared` `webhook-delivery.ts`). */ import { Inject, Injectable } from "@nestjs/common"; import { Queue } from "bullmq"; import { jobTraceMetadata } from "../../common/trace-metadata.js"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { RedisService } from "../../common/redis.service.js"; import { ApiError } from "../../common/errors.js"; import { aes256gcmEncrypt, sha256hex } from "../../lib/crypto.js"; import { assertRole } from "../../lib/authorize.js"; import { loadEnv } from "../../config/env.js"; import { parsePagination } from "../../lib/pagination.js"; import { assertSafeDeliveryUrl, buildDeliveryEnvelope, classifyDeliveryResult, generateId, generateWebhookSecret, newDeliveryId, postWebhookDelivery, resolveSigningSecret, validateWebhookEvents, WEBHOOK_EVENT_NAMES } from "@factory/shared"; /** Max webhooks per workspace (docs §1.1: 409 beyond 20). */ const MAX_WEBHOOKS_PER_WORKSPACE = 20; /** The test-send payload is fixed (docs §2 — a synthetic webhook.test). */ const TEST_SEND_MESSAGE = "This is a test delivery from the Factory developer hub."; function secretPrefix(secretHash: string): string { // Bookkeeping-only identifier: the first 4 hash chars, whsec_-styled. // (The raw secret's own prefix is never derivable from the hash; this is // the doc §1.3 `secretPrefix` field and leaks nothing about the secret.) return `whsec_${secretHash.slice(0, 4)}`; } @Injectable() export class WebhooksService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(RedisService) private readonly redis: RedisService ) {} private masterKey(): string { const key = loadEnv().MASTER_ENCRYPTION_KEY; if (!key) { throw new ApiError( "INTERNAL_ERROR", 500, "MASTER_ENCRYPTION_KEY is not configured — webhook secrets cannot be stored." ); } return key; } private allowPrivate(): boolean { return loadEnv().WEBHOOK_ALLOW_PRIVATE === "true"; } private async requireAdmin(workspaceId: string, userId: string) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); } /** URL validation per docs §1.1: http(s), SSRF-guarded. */ private async validateUrl(url: string): Promise { let u: URL; try { u = new URL(url); } catch { throw ApiError.validation("Webhook URL must be a valid absolute URL.", [ { field: "url", issue: "invalid_url" } ]); } if (u.protocol !== "https:" && u.protocol !== "http:") { throw ApiError.validation("Webhook URL must be http(s).", [ { field: "url", issue: "invalid_scheme", received: u.protocol } ]); } if (u.search) { throw ApiError.validation("Webhook URL must not contain a query string.", [ { field: "url", issue: "query_string_not_allowed" } ]); } try { await assertSafeDeliveryUrl(u.toString(), this.allowPrivate()); } catch (err) { // Name check (not instanceof): the API (CJS) and shared (ESM) may load // different class instances of the same package. if (err instanceof Error && err.name === "SsrfError") { throw ApiError.validation("Webhook URL targets a private address.", [ { field: "url", issue: "private_address_not_allowed" } ]); } throw err; } return u.toString(); } /** Events must be catalog names (or `*`); unknown → 400 listing the catalog. */ private validateEvents(events: string[]): string[] { const { valid, unknown } = validateWebhookEvents(events); if (!valid) { throw ApiError.validation("Unknown webhook event name.", [ { field: "events", issue: "unknown_event", received: unknown, validEvents: WEBHOOK_EVENT_NAMES } ]); } return events; } async create( workspaceId: string, userId: string, input: { url: string; events?: string[]; secret?: string; active?: boolean }, req: any ) { await this.requireAdmin(workspaceId, userId); const url = await this.validateUrl(input.url); const events = this.validateEvents(input.events ?? ["*"]); const count = await this.prisma.webhook.count({ where: { workspaceId } }); if (count >= MAX_WEBHOOKS_PER_WORKSPACE) { throw ApiError.conflict("Workspace webhook limit reached (20).", [ { field: "url", issue: "webhook_limit", max: MAX_WEBHOOKS_PER_WORKSPACE } ]); } const secret = input.secret ?? generateWebhookSecret(); const webhook = await this.prisma.webhook.create({ data: { workspaceId, url, secretHash: sha256hex(secret), secretEncrypted: Buffer.from(aes256gcmEncrypt(secret, this.masterKey()), "base64"), events, active: input.active ?? true, createdById: userId } }); await this.audit.audit(req, req?.auth, { action: "webhook.created", resourceType: "webhook", resourceId: webhook.id, workspaceId, metadata: { url, events } }); return { ...this.shape(webhook), secret, // shown ONCE — never retrievable again warning: "Store this secret now — it cannot be retrieved again." }; } async list(workspaceId: string, userId: string, query: { cursor?: string; limit?: string }) { await this.requireAdmin(workspaceId, userId); const { limit } = parsePagination(query); const webhooks = await this.prisma.webhook.findMany({ where: { workspaceId }, orderBy: { createdAt: "asc" }, take: limit }); return { data: webhooks.map((w) => this.shape(w)) }; } async get(workspaceId: string, webhookId: string, userId: string) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const recent = await this.prisma.webhookDelivery.groupBy({ by: ["status"], where: { webhookId }, _count: { _all: true } }); const last = await this.prisma.webhookDelivery.findFirst({ where: { webhookId, status: "DELIVERED" }, orderBy: { deliveredAt: "desc" }, select: { deliveredAt: true } }); const total = recent.reduce((n, r) => n + (r._count._all ?? 0), 0); const failed = (recent.find((r) => r.status === "FAILED")?._count._all ?? 0) + (recent.find((r) => r.status === "DEAD_LETTER")?._count._all ?? 0) + (recent.find((r) => r.status === "REJECTED")?._count._all ?? 0); return { ...this.shape(webhook), recentDeliveries: { total, failed, lastDeliveredAt: last?.deliveredAt ?? null } }; } /** Merge-patch url / events / active (docs §1.4). */ async update( workspaceId: string, webhookId: string, userId: string, patch: { url?: string; events?: string[]; active?: boolean }, req: any ) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const data: Record = {}; if (patch.url) data.url = await this.validateUrl(patch.url); if (patch.events) data.events = this.validateEvents(patch.events); if (patch.active !== undefined) data.active = patch.active; const updated = await this.prisma.webhook.update({ where: { id: webhookId }, data }); await this.audit.audit(req, req?.auth, { action: "webhook.updated", resourceType: "webhook", resourceId: webhookId, workspaceId, metadata: { fields: Object.keys(patch) } }); return this.shape(updated); } /** Rotate the signing secret (docs §1.4 `rotateSecret`) — new secret shown once. */ async rotateSecret(workspaceId: string, webhookId: string, userId: string, req: any) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const secret = generateWebhookSecret(); const updated = await this.prisma.webhook.update({ where: { id: webhookId }, data: { secretHash: sha256hex(secret), secretEncrypted: Buffer.from(aes256gcmEncrypt(secret, this.masterKey()), "base64") } }); await this.audit.audit(req, req?.auth, { action: "webhook.secret_rotated", resourceType: "webhook", resourceId: webhookId, workspaceId // The raw secret is NEVER audited (task Security section). }); return { ...this.shape(updated), secret, warning: "Store this secret now — it cannot be retrieved again. In-flight deliveries signed with the old secret will fail verification." }; } /** Soft delete (docs §1.5): deliveries stop; the row + log are kept. */ async remove(workspaceId: string, webhookId: string, userId: string, req: any) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); await this.prisma.webhook.update({ where: { id: webhookId }, data: { active: false } }); // TASK-0133 audit catalog: the soft-delete is `webhook.revoked` (the // catalog's name for "a webhook was revoked/disabled"), with the // CATALOG-required `url` (the delivery target). await this.audit.audit(req, req?.auth, { action: "webhook.revoked", resourceType: "webhook", resourceId: webhookId, workspaceId, metadata: { url: webhook.url } }); return { deleted: true }; } /** * Test send (docs §2): SYNCHRONOUS delivery of a fixed webhook.test * payload through the shared transport. Does NOT enter the retry queue — * the result (delivered/rejected/failed) is recorded on the delivery row * and returned to the caller. */ async test(workspaceId: string, webhookId: string, userId: string, req: any) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const ws = await this.prisma.workspace.findUnique({ where: { id: webhook.workspaceId }, select: { id: true, name: true, slug: true } }); const eventId = generateId("evt"); const envelope = buildDeliveryEnvelope({ id: eventId, type: "webhook.test", workspaceId: webhook.workspaceId, workspace: ws ?? undefined, data: { message: TEST_SEND_MESSAGE, sentAt: new Date().toISOString() }, test: true }); const deliveryId = newDeliveryId(); await this.prisma.webhookDelivery.create({ data: { id: deliveryId, webhookId, event: "webhook.test", payload: envelope as object, status: "PENDING", attempts: 1 } }); const resolved = this.resolveSecretOrThrow(webhook); const result = await postWebhookDelivery( { url: webhook.url, secret: resolved, event: "webhook.test", envelope, deliveryId, attempt: 1 }, { allowPrivate: this.allowPrivate() } ); const outcome = classifyDeliveryResult(result); await this.prisma.webhookDelivery.update({ where: { id: deliveryId }, data: { status: outcome, statusCode: result.status || null, error: outcome === "DELIVERED" ? null : result.transportError ?? `non-2xx: HTTP ${result.status}`, deliveredAt: outcome === "DELIVERED" ? new Date() : null, latencyMs: result.latencyMs } }); await this.audit.audit(req, req?.auth, { action: "webhook.test_sent", resourceType: "webhook", resourceId: webhookId, workspaceId, metadata: { deliveryId, outcome, statusCode: result.status || null } }); return { deliveryId, status: outcome, statusCode: result.status || null, attempts: 1, latencyMs: result.latencyMs }; } /** Delivery log (docs §6): newest first, filters, no payload in the list. */ async deliveries( workspaceId: string, webhookId: string, userId: string, query: { cursor?: string; limit?: string; status?: string; event?: string; since?: string; until?: string; } ) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const { cursor, limit } = parsePagination(query); const since = query.since ? new Date(query.since) : undefined; const until = query.until ? new Date(query.until) : undefined; const rows = await this.prisma.webhookDelivery.findMany({ where: { webhookId, ...(query.status ? { status: query.status as never } : {}), ...(query.event ? { event: query.event } : {}), ...(since || until ? { createdAt: { ...(since ? { gte: since } : {}), ...(until ? { lte: until } : {}) } } : {}) }, ...(cursor ? { cursor: { id: cursor.id }, skip: 1 } : {}), orderBy: { createdAt: "desc" }, take: limit + 1 }); const page = rows.slice(0, limit); const next = rows.length > limit ? page[page.length - 1] : undefined; return { data: page.map((d) => this.shapeDelivery(d)), nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id })).toString("base64url") : null }; } /** Single delivery INCLUDING the full payload (docs §6 drawer). */ async delivery(workspaceId: string, webhookId: string, deliveryId: string, userId: string) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const d = await this.prisma.webhookDelivery.findFirst({ where: { id: deliveryId, webhookId } }); if (!d) throw ApiError.notFound("Delivery not found."); return { ...this.shapeDelivery(d), payload: d.payload }; } /** * Per-delivery manual retry (docs §6): FAILED/REJECTED → PENDING, attempt * counter reset, original payload re-signed with the CURRENT secret. * 409 when the delivery already succeeded. */ async retryDelivery( workspaceId: string, webhookId: string, deliveryId: string, userId: string, req: any ) { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const d = await this.prisma.webhookDelivery.findFirst({ where: { id: deliveryId, webhookId } }); if (!d) throw ApiError.notFound("Delivery not found."); if (d.status === "DELIVERED") { throw ApiError.conflict("Delivery already succeeded — nothing to retry.", [ { field: "deliveryId", issue: "already_delivered" } ]); } const envelope = d.payload as Record; await this.prisma.webhookDelivery.update({ where: { id: deliveryId }, data: { status: "PENDING", attempts: 0, error: null, nextRetryAt: null } }); await this.enqueueDeliveryJob(webhook, d, envelope, 1); await this.audit.audit(req, req?.auth, { action: "webhook.delivery_retried", resourceType: "webhook", resourceId: webhookId, workspaceId, metadata: { deliveryId } }); return { deliveryId, status: "PENDING", attempts: 0 }; } /** * Resubmit (task AC): re-queue FAILED / DEAD_LETTER / REJECTED deliveries * as NEW rows linked by `originDeliveryId` (the originals stay put for * the log), attempts reset. `scope: "all_dead"` = DEAD_LETTER (the AC * wording); "all_failed" adds FAILED; "all" adds REJECTED. */ /** * Resubmit (task L349 body): `{ deliveryId? }` (one specific row), * `{ all_dead: true }` (every DEAD_LETTER row — the AC wording), or * `{ window: { since, until } }` (failed/DLQ rows in a time window). * Each becomes a NEW row linked by `originDeliveryId`, attempt counter * reset (the originals stay put for the log). */ async resubmit( workspaceId: string, webhookId: string, userId: string, input: { deliveryId?: string; all_dead?: boolean; window?: { since?: string; until?: string }; }, req: any ): Promise<{ resubmitted: number; deliveryIds: string[]; originDeliveryIds: string[] }> { await this.requireAdmin(workspaceId, userId); const webhook = await this.prisma.webhook.findFirst({ where: { id: webhookId, workspaceId } }); if (!webhook) throw ApiError.notFound("Webhook not found."); const where: Record = { webhookId }; if (input.deliveryId) { const d = await this.prisma.webhookDelivery.findFirst({ where: { id: input.deliveryId, webhookId } }); if (!d) throw ApiError.notFound("Delivery not found."); if (d.status === "DELIVERED" || d.status === "PENDING") { throw ApiError.conflict("Only FAILED, REJECTED or DEAD_LETTER deliveries can be resubmitted.", [ { field: "deliveryId", issue: "not_retriable", status: d.status } ]); } where.id = input.deliveryId; } else if (input.window?.since || input.window?.until) { where.status = { in: ["DEAD_LETTER", "FAILED", "REJECTED"] as const }; where.createdAt = { ...(input.window.since ? { gte: new Date(input.window.since) } : {}), ...(input.window.until ? { lte: new Date(input.window.until) } : {}) }; } else { // default (the AC "resubmit all_dead") where.status = "DEAD_LETTER"; } const rows = await this.prisma.webhookDelivery.findMany({ where, orderBy: { createdAt: "desc" }, take: 200 // bound: resubmit is a manual operation, not a sweep }); const deliveryIds: string[] = []; const originDeliveryIds: string[] = []; for (const d of rows) { const envelope = d.payload as Record; const newId = newDeliveryId(); await this.prisma.webhookDelivery.create({ data: { id: newId, webhookId, event: d.event, payload: d.payload as object, status: "PENDING", attempts: 0, originDeliveryId: d.id } }); await this.enqueueDeliveryJob(webhook, d, envelope, 1, newId); deliveryIds.push(newId); originDeliveryIds.push(d.id); } await this.audit.audit(req, req?.auth, { action: "webhook.deliveries_resubmitted", resourceType: "webhook", resourceId: webhookId, workspaceId, metadata: { mode: input.deliveryId ? "single" : input.window ? "window" : "all_dead", count: deliveryIds.length } }); return { resubmitted: deliveryIds.length, deliveryIds, originDeliveryIds }; } // ---------------------------------------------------------------- helpers private resolveSecretOrThrow(webhook: { secretEncrypted: Uint8Array | null; secretHash: string; }): string { const resolved = resolveSigningSecret( this.masterKey(), webhook.secretEncrypted, webhook.secretHash ); if (!resolved) { throw new ApiError( "INTERNAL_ERROR", 500, "Webhook signing secret is unrecoverable — recreate the webhook." ); } return resolved.secret; } /** Enqueue a row dispatch job on the worker's `webhooks` queue. */ private async enqueueDeliveryJob( webhook: { id: string; workspaceId: string }, delivery: { id: string; event: string }, envelope: Record, attempt: number, deliveryId?: string ): Promise { const targetId = deliveryId ?? delivery.id; const eventId = typeof envelope.id === "string" ? envelope.id : generateId("evt"); const occurredAt = typeof envelope.occurredAt === "string" ? envelope.occurredAt : undefined; try { const q = new Queue("webhooks", { connection: this.redis.getRedis().options as never }); try { await q.add("webhooks", { kind: "row", webhookId: webhook.id, event: delivery.event, payload: envelope.data ?? {}, deliveryId: targetId, eventId, ...(occurredAt ? { occurredAt } : {}), attempt }, // TASK-0135: continue the enqueueing request's trace into the job. { ...jobTraceMetadata() } ); } finally { await q.close(); } } catch (err) { // Leave the row PENDING; the worker's watchdog/final-failure hook // dead-letters orphans (same degrade as the automation runs). console.warn(`webhook delivery ${targetId} enqueue failed: ${(err as Error).message}`); } } private shape(webhook: { id: string; url: string; events: string[]; active: boolean; createdById: string; workspaceId: string; createdAt: Date; updatedAt: Date; secretHash: string; }) { return { id: webhook.id, workspaceId: webhook.workspaceId, url: webhook.url, events: webhook.events, active: webhook.active, secretPrefix: secretPrefix(webhook.secretHash), createdById: webhook.createdById, createdAt: webhook.createdAt, updatedAt: webhook.updatedAt }; } private shapeDelivery(d: { id: string; webhookId: string; event: string; status: string; attempts: number; statusCode: number | null; error: string | null; deliveredAt: Date | null; nextRetryAt: Date | null; createdAt: Date; originDeliveryId: string | null; latencyMs?: number | null; payload?: unknown; }) { const env = (d.payload ?? {}) as Record; return { id: d.id, webhookId: d.webhookId, event: d.event, eventId: typeof env.id === "string" ? env.id : undefined, status: d.status, attempts: d.attempts, statusCode: d.statusCode, error: d.error, deliveredAt: d.deliveredAt, nextRetryAt: d.nextRetryAt, createdAt: d.createdAt, originDeliveryId: d.originDeliveryId, latencyMs: d.latencyMs ?? null }; } }