import { createHmac, randomBytes, randomUUID } from 'node:crypto'; import { Logger } from '@nestjs/common'; import { Command, CommandHandler, EventsHandler, ICommandHandler, IEventHandler, IQueryHandler, Query, QueryHandler, } from '@nestjs/cqrs'; import { BusinessRuleError, NotFoundError, assertThat, } from '@task-stream/api/shared/domain'; import { TrackerEvent } from '@task-stream/api/tracker/domain'; import { WriteAccess } from '../commands/write-access'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { OutgoingWebhook, OutgoingWebhooksRepository, WebhookDelivery, WebhookSender, } from '../ports/outgoing-webhooks.repository'; import { UserDirectory } from '../ports/user-directory'; import { WEBHOOK_EVENTS, normalizeWebhookEvents, subscribes, webhookEventOf, } from './webhook-events'; /** A project keeps at most this many webhooks. */ export const MAX_WEBHOOKS = 20; /** Waits between attempts: right away, after 2 s, after 10 s. */ export const RETRY_DELAYS_MS = [0, 2_000, 10_000]; /** The webhook as listed: the secret is only ever shown when created. */ export type WebhookView = Omit; const view = ({ secret: _secret, ...rest }: OutgoingWebhook): WebhookView => rest; function assertUrl(url: string): string { const next = url.trim(); let parsed: URL | null = null; try { parsed = new URL(next); } catch { parsed = null; } assertThat( !!parsed && ['http:', 'https:'].includes(parsed.protocol), 'A webhook needs an http(s) URL', ); return next; } export class CreateWebhookCommand extends Command<{ id: string; secret: string; }> { constructor( public readonly actorId: string, public readonly projectId: string, public readonly url: string, public readonly events: string[], ) { super(); } } export class UpdateWebhookCommand extends Command { constructor( public readonly actorId: string, public readonly webhookId: string, public readonly changes: { url?: string; events?: string[]; active?: boolean; }, ) { super(); } } export class DeleteWebhookCommand extends Command { constructor( public readonly actorId: string, public readonly webhookId: string, ) { super(); } } /** Sends a `ping` now and tells how it went. */ export class TestWebhookCommand extends Command { constructor( public readonly actorId: string, public readonly webhookId: string, ) { super(); } } export class GetWebhooksQuery extends Query { constructor( public readonly viewerId: string, public readonly projectId: string, ) { super(); } } /** Signs and sends deliveries, retrying a couple of times, and records the outcome. */ export class WebhookDispatcher { private readonly logger = new Logger(WebhookDispatcher.name); constructor( private readonly webhooks: OutgoingWebhooksRepository, private readonly sender: WebhookSender, private readonly delays: number[] = RETRY_DELAYS_MS, ) {} async deliver( webhook: OutgoingWebhook, event: string, payload: Record, ): Promise { const deliveryId = randomUUID(); const body = JSON.stringify({ event, deliveryId, ...payload }); const signature = createHmac('sha256', webhook.secret) .update(body) .digest('hex'); let delivery: WebhookDelivery = { at: new Date().toISOString(), event, status: null, ok: false, error: null, }; for (const delay of this.delays) { if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); try { const { status } = await this.sender.send({ url: webhook.url, body, headers: { 'content-type': 'application/json', 'user-agent': 'TaskStream-Webhooks', 'x-taskstream-event': event, 'x-taskstream-delivery': deliveryId, 'x-taskstream-signature': `sha256=${signature}`, }, }); delivery = { ...delivery, at: new Date().toISOString(), status, ok: status < 300, error: status < 300 ? null : `HTTP ${status}`, }; // A 4xx will not get better by retrying. if (delivery.ok || (status >= 400 && status < 500)) break; } catch (error) { delivery = { ...delivery, at: new Date().toISOString(), status: null, ok: false, error: error instanceof Error ? error.message : String(error), }; } } if (!delivery.ok) this.logger.warn( `Webhook ${webhook.id} (${event}) failed: ${delivery.error}`, ); await this.webhooks.recordDelivery(webhook.id, delivery); return delivery; } } /** Whoever manages the project of a webhook. */ async function managedWebhook( webhooks: OutgoingWebhooksRepository, access: WriteAccess, webhookId: string, actorId: string, ): Promise { const webhook = await webhooks.findById(webhookId); if (!webhook) throw new NotFoundError('Webhook', webhookId); (await access.project(webhook.projectId)).assertCanManage(actorId); return webhook; } @CommandHandler(CreateWebhookCommand) export class CreateWebhookCommandHandler implements ICommandHandler { constructor( private readonly webhooks: OutgoingWebhooksRepository, private readonly access: WriteAccess, private readonly users: UserDirectory, ) {} async execute(command: CreateWebhookCommand) { (await this.access.project(command.projectId)).assertCanManage( command.actorId, ); const events = normalizeWebhookEvents(command.events); assertThat(events.length > 0, 'Pick at least one event'); if ( (await this.webhooks.findByProject(command.projectId)).length >= MAX_WEBHOOKS ) throw new BusinessRuleError( `A project keeps at most ${MAX_WEBHOOKS} webhooks`, ); const users = await this.users.refs([command.actorId]); const webhook: OutgoingWebhook = { id: randomUUID(), projectId: command.projectId, url: assertUrl(command.url), secret: randomBytes(24).toString('hex'), events, active: true, createdBy: users.of(command.actorId), createdAt: new Date().toISOString(), lastDelivery: null, }; await this.webhooks.insert(webhook); return { id: webhook.id, secret: webhook.secret }; } } @CommandHandler(UpdateWebhookCommand) export class UpdateWebhookCommandHandler implements ICommandHandler { constructor( private readonly webhooks: OutgoingWebhooksRepository, private readonly access: WriteAccess, ) {} async execute(command: UpdateWebhookCommand): Promise { const webhook = await managedWebhook( this.webhooks, this.access, command.webhookId, command.actorId, ); const changes: { url?: string; events?: string[]; active?: boolean } = {}; if (command.changes.url !== undefined) changes.url = assertUrl(command.changes.url); if (command.changes.events !== undefined) { changes.events = normalizeWebhookEvents(command.changes.events); assertThat(changes.events.length > 0, 'Pick at least one event'); } if (command.changes.active !== undefined) changes.active = command.changes.active; await this.webhooks.update(webhook.id, changes); return view({ ...webhook, ...changes }); } } @CommandHandler(DeleteWebhookCommand) export class DeleteWebhookCommandHandler implements ICommandHandler { constructor( private readonly webhooks: OutgoingWebhooksRepository, private readonly access: WriteAccess, ) {} async execute(command: DeleteWebhookCommand): Promise { const webhook = await managedWebhook( this.webhooks, this.access, command.webhookId, command.actorId, ); await this.webhooks.delete(webhook.id); } } @CommandHandler(TestWebhookCommand) export class TestWebhookCommandHandler implements ICommandHandler { constructor( private readonly webhooks: OutgoingWebhooksRepository, private readonly access: WriteAccess, private readonly sender: WebhookSender, ) {} async execute(command: TestWebhookCommand): Promise { const webhook = await managedWebhook( this.webhooks, this.access, command.webhookId, command.actorId, ); // One attempt: someone is waiting for the answer. return new WebhookDispatcher(this.webhooks, this.sender, [0]).deliver( webhook, 'ping', { occurredAt: new Date().toISOString(), projectId: webhook.projectId, actorId: command.actorId, data: { message: 'Hola desde TaskStream' }, }, ); } } @QueryHandler(GetWebhooksQuery) export class GetWebhooksQueryHandler implements IQueryHandler { constructor( private readonly webhooks: OutgoingWebhooksRepository, private readonly access: WriteAccess, ) {} async execute(query: GetWebhooksQuery): Promise { (await this.access.project(query.projectId)).assertCanManage( query.viewerId, ); return (await this.webhooks.findByProject(query.projectId)).map(view); } } /** The event's own fields: what happened, without the envelope. */ function dataOf(event: TrackerEvent): Record { const { actorId: _actor, aggregateId: _aggregate, projectId: _project, occurredAt: _at, aggregateVersion: _version, ...data } = event as unknown as Record; return data; } /** * Tells the project's webhooks about what happened. Deliveries go out in the * background: a slow receiver never holds the events bridge. */ @EventsHandler(...WEBHOOK_EVENTS.map(([type]) => type)) export class OutgoingWebhooksEventHandler implements IEventHandler { private readonly dispatcher: WebhookDispatcher; constructor( private readonly webhooks: OutgoingWebhooksRepository, sender: WebhookSender, private readonly findProjects: FindProjectsRepository, private readonly findIssues: FindIssuesRepository, ) { this.dispatcher = new WebhookDispatcher(webhooks, sender); } async handle(event: TrackerEvent) { const name = webhookEventOf(event); if (!name) return; const targets = (await this.webhooks.findActive(event.projectId)).filter( (w) => subscribes(w.events, name), ); if (!targets.length) return; const project = await this.findProjects.findById(event.projectId); const issueId = event.constructor.name.startsWith('Issue') ? event.aggregateId : ((event as { issueId?: string; comment?: { issueId: string } }) .issueId ?? (event as { comment?: { issueId: string } }).comment?.issueId ?? null); const issue = issueId ? await this.findIssues.findById(issueId) : null; const payload = { occurredAt: event.occurredAt, actorId: event.actorId, project: project ? { id: project.id, key: project.key } : { id: event.projectId }, aggregateId: event.aggregateId, version: event.aggregateVersion ?? null, ...(issue ? { issue: { id: issue.id, key: issue.key, title: issue.title } } : {}), data: dataOf(event), }; for (const webhook of targets) void this.dispatcher .deliver(webhook, name, payload) .catch(() => undefined); } } export const WEBHOOK_HANDLERS = [ CreateWebhookCommandHandler, UpdateWebhookCommandHandler, DeleteWebhookCommandHandler, TestWebhookCommandHandler, GetWebhooksQueryHandler, OutgoingWebhooksEventHandler, ];