feat: outgoing webhooks
Owners and admins can point a project at other systems: a signed JSON POST (HMAC-SHA256 of the body) when tickets, comments, releases or sprints change, filtered by event or family. Deliveries are retried a couple of times, the last one is shown, and a ping can be sent from the settings. Private addresses are refused unless WEBHOOKS_ALLOW_PRIVATE is set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
411
libs/api/tracker/application/src/lib/webhooks/webhooks.ts
Normal file
411
libs/api/tracker/application/src/lib/webhooks/webhooks.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
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<OutgoingWebhook, 'secret'>;
|
||||
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<WebhookView> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly webhookId: string,
|
||||
public readonly changes: {
|
||||
url?: string;
|
||||
events?: string[];
|
||||
active?: boolean;
|
||||
},
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteWebhookCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly webhookId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a `ping` now and tells how it went. */
|
||||
export class TestWebhookCommand extends Command<WebhookDelivery> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly webhookId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetWebhooksQuery extends Query<WebhookView[]> {
|
||||
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<string, unknown>,
|
||||
): Promise<WebhookDelivery> {
|
||||
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<OutgoingWebhook> {
|
||||
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<CreateWebhookCommand>
|
||||
{
|
||||
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<UpdateWebhookCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly webhooks: OutgoingWebhooksRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateWebhookCommand): Promise<WebhookView> {
|
||||
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<DeleteWebhookCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly webhooks: OutgoingWebhooksRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteWebhookCommand): Promise<void> {
|
||||
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<TestWebhookCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly webhooks: OutgoingWebhooksRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly sender: WebhookSender,
|
||||
) {}
|
||||
|
||||
async execute(command: TestWebhookCommand): Promise<WebhookDelivery> {
|
||||
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<GetWebhooksQuery, WebhookView[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly webhooks: OutgoingWebhooksRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetWebhooksQuery): Promise<WebhookView[]> {
|
||||
(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<string, unknown> {
|
||||
const {
|
||||
actorId: _actor,
|
||||
aggregateId: _aggregate,
|
||||
projectId: _project,
|
||||
occurredAt: _at,
|
||||
aggregateVersion: _version,
|
||||
...data
|
||||
} = event as unknown as Record<string, unknown>;
|
||||
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<TrackerEvent>
|
||||
{
|
||||
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,
|
||||
];
|
||||
Reference in New Issue
Block a user