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:
@@ -12,6 +12,9 @@ export * from './lib/notifications';
|
||||
export * from './lib/saved-filters.service';
|
||||
export * from './lib/sprints.service';
|
||||
export * from './lib/git.service';
|
||||
export * from './lib/webhooks.service';
|
||||
export * from './lib/webhooks/webhooks';
|
||||
export * from './lib/webhooks/webhook-events';
|
||||
export * from './lib/git/git';
|
||||
export * from './lib/git/git-payloads';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
|
||||
@@ -24,3 +24,4 @@ export * from './saved-filters.repository';
|
||||
export * from './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
export * from './git-integrations.repository';
|
||||
export * from './outgoing-webhooks.repository';
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export interface WebhookDelivery {
|
||||
at: string;
|
||||
event: string;
|
||||
/** HTTP status, null when it never answered. */
|
||||
status: number | null;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Where a project tells other systems what happened in it. */
|
||||
export interface OutgoingWebhook {
|
||||
id: string;
|
||||
projectId: string;
|
||||
url: string;
|
||||
/** Signs every delivery (HMAC-SHA256); shown once, when created. */
|
||||
secret: string;
|
||||
/** Event names, `issue.*` or `*`. */
|
||||
events: string[];
|
||||
active: boolean;
|
||||
createdBy: UserRefReadModel;
|
||||
createdAt: string;
|
||||
lastDelivery: WebhookDelivery | null;
|
||||
}
|
||||
|
||||
/** Configuration, not history: kept outside the event store. */
|
||||
export abstract class OutgoingWebhooksRepository {
|
||||
abstract findByProject(projectId: string): Promise<OutgoingWebhook[]>;
|
||||
abstract findActive(projectId: string): Promise<OutgoingWebhook[]>;
|
||||
abstract findById(id: string): Promise<OutgoingWebhook | null>;
|
||||
abstract insert(webhook: OutgoingWebhook): Promise<void>;
|
||||
abstract update(
|
||||
id: string,
|
||||
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||
): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
abstract recordDelivery(id: string, delivery: WebhookDelivery): Promise<void>;
|
||||
}
|
||||
|
||||
/** Sends one delivery. Throws when the receiver cannot be reached. */
|
||||
export abstract class WebhookSender {
|
||||
abstract send(request: {
|
||||
url: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<{ status: number }>;
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import { SavedFiltersService } from './saved-filters.service';
|
||||
import { SprintsService } from './sprints.service';
|
||||
import { GitService } from './git.service';
|
||||
import { GIT_HANDLERS } from './git/git';
|
||||
import { WebhooksService } from './webhooks.service';
|
||||
import { WEBHOOK_HANDLERS } from './webhooks/webhooks';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
const NOTIFICATIONS = [
|
||||
@@ -56,6 +58,7 @@ const SERVICES = [
|
||||
SavedFiltersService,
|
||||
SprintsService,
|
||||
GitService,
|
||||
WebhooksService,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -79,6 +82,7 @@ const SERVICES = [
|
||||
...NOTIFICATIONS,
|
||||
...SAVED_FILTERS,
|
||||
...GIT_HANDLERS,
|
||||
...WEBHOOK_HANDLERS,
|
||||
],
|
||||
exports: SERVICES,
|
||||
})
|
||||
|
||||
62
libs/api/tracker/application/src/lib/webhooks.service.ts
Normal file
62
libs/api/tracker/application/src/lib/webhooks.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { WebhookDelivery } from './ports/outgoing-webhooks.repository';
|
||||
import { WEBHOOK_EVENT_NAMES } from './webhooks/webhook-events';
|
||||
import {
|
||||
CreateWebhookCommand,
|
||||
DeleteWebhookCommand,
|
||||
GetWebhooksQuery,
|
||||
TestWebhookCommand,
|
||||
UpdateWebhookCommand,
|
||||
WebhookView,
|
||||
} from './webhooks/webhooks';
|
||||
|
||||
/** The project's outgoing webhooks (owners and admins). */
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
/** Names other systems can subscribe to (plus `family.*` and `*`). */
|
||||
events(): string[] {
|
||||
return WEBHOOK_EVENT_NAMES;
|
||||
}
|
||||
|
||||
list(viewerId: string, projectId: string): Promise<WebhookView[]> {
|
||||
return this.queryBus.execute(new GetWebhooksQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
/** The secret comes back only here. */
|
||||
create(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
url: string,
|
||||
events: string[],
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
return this.commandBus.execute(
|
||||
new CreateWebhookCommand(actorId, projectId, url, events),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
webhookId: string,
|
||||
changes: { url?: string; events?: string[]; active?: boolean },
|
||||
): Promise<WebhookView> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateWebhookCommand(actorId, webhookId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, webhookId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteWebhookCommand(actorId, webhookId),
|
||||
);
|
||||
}
|
||||
|
||||
test(actorId: string, webhookId: string): Promise<WebhookDelivery> {
|
||||
return this.commandBus.execute(new TestWebhookCommand(actorId, webhookId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
CommentDeletedEvent,
|
||||
CommentEditedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDeletedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueUpdatedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintStartedEvent,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Type } from '@nestjs/common';
|
||||
|
||||
/** What other systems can subscribe to, by the event that causes it. */
|
||||
export const WEBHOOK_EVENTS: [Type<TrackerEvent>, string][] = [
|
||||
[IssueCreatedEvent, 'issue.created'],
|
||||
[IssueUpdatedEvent, 'issue.updated'],
|
||||
[IssueStatusChangedEvent, 'issue.status_changed'],
|
||||
[IssueAssignedEvent, 'issue.assigned'],
|
||||
[IssueSprintChangedEvent, 'issue.sprint_changed'],
|
||||
[IssueReleaseChangedEvent, 'issue.release_changed'],
|
||||
[IssueDevLinkRecordedEvent, 'issue.dev_link'],
|
||||
[IssueDeletedEvent, 'issue.deleted'],
|
||||
[CommentAddedEvent, 'comment.added'],
|
||||
[CommentEditedEvent, 'comment.edited'],
|
||||
[CommentDeletedEvent, 'comment.deleted'],
|
||||
[ReleaseCreatedEvent, 'release.created'],
|
||||
[ReleaseShippedEvent, 'release.shipped'],
|
||||
[ReleaseArchivedEvent, 'release.archived'],
|
||||
[SprintStartedEvent, 'sprint.started'],
|
||||
[SprintClosedEvent, 'sprint.closed'],
|
||||
];
|
||||
|
||||
export const WEBHOOK_EVENT_NAMES = WEBHOOK_EVENTS.map(([, name]) => name);
|
||||
|
||||
export function webhookEventOf(event: TrackerEvent): string | null {
|
||||
return WEBHOOK_EVENTS.find(([type]) => event instanceof type)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** `*`, an exact name, or a family (`issue.*`). */
|
||||
export function subscribes(events: string[], name: string): boolean {
|
||||
return events.some(
|
||||
(e) =>
|
||||
e === '*' ||
|
||||
e === name ||
|
||||
(e.endsWith('.*') && name.startsWith(e.slice(0, -1))),
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps known names and families, once each. */
|
||||
export function normalizeWebhookEvents(events: string[]): string[] {
|
||||
const families = new Set(
|
||||
WEBHOOK_EVENT_NAMES.map((n) => `${n.split('.')[0]}.*`),
|
||||
);
|
||||
return [
|
||||
...new Set(
|
||||
events
|
||||
.map((e) => e.trim())
|
||||
.filter(
|
||||
(e) =>
|
||||
e === '*' || families.has(e) || WEBHOOK_EVENT_NAMES.includes(e),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import {
|
||||
OutgoingWebhook,
|
||||
OutgoingWebhooksRepository,
|
||||
WebhookDelivery,
|
||||
WebhookSender,
|
||||
} from '../ports/outgoing-webhooks.repository';
|
||||
import { normalizeWebhookEvents, subscribes } from './webhook-events';
|
||||
import { WebhookDispatcher } from './webhooks';
|
||||
|
||||
const webhook: OutgoingWebhook = {
|
||||
id: 'w1',
|
||||
projectId: 'p1',
|
||||
url: 'https://example.org/hook',
|
||||
secret: 's3cret',
|
||||
events: ['issue.*'],
|
||||
active: true,
|
||||
createdBy: { id: 'u', name: 'U' },
|
||||
createdAt: '',
|
||||
lastDelivery: null,
|
||||
};
|
||||
|
||||
function doubles(statuses: (number | Error)[]) {
|
||||
const recorded: WebhookDelivery[] = [];
|
||||
const sent: { body: string; headers: Record<string, string> }[] = [];
|
||||
const repo = {
|
||||
recordDelivery: async (_id: string, d: WebhookDelivery) => {
|
||||
recorded.push(d);
|
||||
},
|
||||
} as unknown as OutgoingWebhooksRepository;
|
||||
const sender: WebhookSender = {
|
||||
send: async (request) => {
|
||||
sent.push(request);
|
||||
const next = statuses.shift() ?? 200;
|
||||
if (next instanceof Error) throw next;
|
||||
return { status: next };
|
||||
},
|
||||
};
|
||||
return { repo, sender, recorded, sent };
|
||||
}
|
||||
|
||||
describe('outgoing webhooks', () => {
|
||||
it('subscribes to names, families and everything', () => {
|
||||
expect(subscribes(['issue.*'], 'issue.created')).toBe(true);
|
||||
expect(subscribes(['issue.*'], 'comment.added')).toBe(false);
|
||||
expect(subscribes(['*'], 'sprint.closed')).toBe(true);
|
||||
expect(
|
||||
normalizeWebhookEvents(['issue.*', 'nope', ' sprint.closed ', 'issue.*']),
|
||||
).toEqual(['issue.*', 'sprint.closed']);
|
||||
});
|
||||
|
||||
it('signs the body and retries until it gets through', async () => {
|
||||
const { repo, sender, recorded, sent } = doubles([
|
||||
new Error('ECONNRESET'),
|
||||
503,
|
||||
204,
|
||||
]);
|
||||
const delivery = await new WebhookDispatcher(
|
||||
repo,
|
||||
sender,
|
||||
[0, 0, 0],
|
||||
).deliver(webhook, 'issue.created', { data: { title: 'x' } });
|
||||
expect(sent).toHaveLength(3);
|
||||
const { body, headers } = sent[0];
|
||||
expect(headers['x-taskstream-signature']).toBe(
|
||||
`sha256=${createHmac('sha256', 's3cret').update(body).digest('hex')}`,
|
||||
);
|
||||
expect(JSON.parse(body)).toMatchObject({
|
||||
event: 'issue.created',
|
||||
data: { title: 'x' },
|
||||
});
|
||||
expect(delivery).toMatchObject({ ok: true, status: 204 });
|
||||
expect(recorded).toEqual([delivery]);
|
||||
});
|
||||
|
||||
it('gives up at once on a 4xx', async () => {
|
||||
const { repo, sender, sent } = doubles([410]);
|
||||
const delivery = await new WebhookDispatcher(
|
||||
repo,
|
||||
sender,
|
||||
[0, 0, 0],
|
||||
).deliver(webhook, 'issue.created', {});
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(delivery).toMatchObject({
|
||||
ok: false,
|
||||
status: 410,
|
||||
error: 'HTTP 410',
|
||||
});
|
||||
});
|
||||
});
|
||||
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,
|
||||
];
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
OutgoingWebhook,
|
||||
OutgoingWebhooksRepository,
|
||||
WebhookDelivery,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { Model } from 'mongoose';
|
||||
import { OutgoingWebhookDoc, ReadModels, fromDoc } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoOutgoingWebhooksRepository extends OutgoingWebhooksRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.OutgoingWebhook)
|
||||
private readonly webhooks: Model<OutgoingWebhookDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<OutgoingWebhook[]> {
|
||||
const docs = await this.webhooks
|
||||
.find({ projectId })
|
||||
.sort({ createdAt: 1 })
|
||||
.lean<OutgoingWebhookDoc[]>();
|
||||
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
|
||||
}
|
||||
|
||||
async findActive(projectId: string): Promise<OutgoingWebhook[]> {
|
||||
const docs = await this.webhooks
|
||||
.find({ projectId, active: true })
|
||||
.lean<OutgoingWebhookDoc[]>();
|
||||
return docs.map((doc) => fromDoc(doc) as OutgoingWebhook);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<OutgoingWebhook | null> {
|
||||
const doc = await this.webhooks.findById(id).lean<OutgoingWebhookDoc>();
|
||||
return doc ? (fromDoc(doc) as OutgoingWebhook) : null;
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: OutgoingWebhook): Promise<void> {
|
||||
await this.webhooks.create({ _id: id, ...rest });
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||
): Promise<void> {
|
||||
if (Object.keys(changes).length)
|
||||
await this.webhooks.updateOne({ _id: id }, { $set: changes });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.webhooks.deleteOne({ _id: id });
|
||||
}
|
||||
|
||||
async recordDelivery(id: string, delivery: WebhookDelivery): Promise<void> {
|
||||
await this.webhooks.updateOne(
|
||||
{ _id: id },
|
||||
{ $set: { lastDelivery: delivery } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OutgoingWebhook } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
@@ -30,6 +31,7 @@ export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export type OutgoingWebhookDoc = Omit<OutgoingWebhook, 'id'> & { _id: string };
|
||||
export interface GitIntegrationDoc {
|
||||
_id: string;
|
||||
secret: string;
|
||||
@@ -52,6 +54,7 @@ export const ReadModels = {
|
||||
SavedFilter: 'SavedFilter',
|
||||
Sprint: 'SprintReadModel',
|
||||
GitIntegration: 'GitIntegration',
|
||||
OutgoingWebhook: 'OutgoingWebhook',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -203,6 +206,18 @@ export const GitIntegrationSchema = new Schema<GitIntegrationDoc>(
|
||||
{ ...loose, collection: 'git_integrations' },
|
||||
);
|
||||
|
||||
export const OutgoingWebhookSchema = new Schema<OutgoingWebhookDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: { type: String, index: true },
|
||||
active: Boolean,
|
||||
events: [String],
|
||||
createdBy: userRef,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'outgoing_webhooks' },
|
||||
);
|
||||
|
||||
export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
@@ -210,6 +225,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||
{ name: ReadModels.Sprint, schema: SprintSchema },
|
||||
{ name: ReadModels.GitIntegration, schema: GitIntegrationSchema },
|
||||
{ name: ReadModels.OutgoingWebhook, schema: OutgoingWebhookSchema },
|
||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
ReleaseDoc,
|
||||
SavedFilterDoc,
|
||||
GitIntegrationDoc,
|
||||
OutgoingWebhookDoc,
|
||||
TimelineDoc,
|
||||
} from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
@@ -52,6 +53,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||
@InjectModel(ReadModels.GitIntegration)
|
||||
private readonly gitIntegrations: Model<GitIntegrationDoc>,
|
||||
@InjectModel(ReadModels.OutgoingWebhook)
|
||||
private readonly outgoingWebhooks: Model<OutgoingWebhookDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -64,6 +67,7 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
// Views of the board of a project that is gone.
|
||||
this.savedFilters.deleteMany({ projectId }),
|
||||
this.gitIntegrations.deleteOne({ _id: projectId }),
|
||||
this.outgoingWebhooks.deleteMany({ projectId }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
NotificationsRepository,
|
||||
SavedFiltersRepository,
|
||||
GitIntegrationsRepository,
|
||||
OutgoingWebhooksRepository,
|
||||
WebhookSender,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
FindIssuesRepository,
|
||||
@@ -62,6 +64,8 @@ import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||
import {
|
||||
MongoFindSprintsRepository,
|
||||
MongoUpsertMaterializedSprintRepository,
|
||||
@@ -127,6 +131,11 @@ const PORTS = [
|
||||
provide: GitIntegrationsRepository,
|
||||
useClass: MongoGitIntegrationsRepository,
|
||||
},
|
||||
{
|
||||
provide: OutgoingWebhooksRepository,
|
||||
useClass: MongoOutgoingWebhooksRepository,
|
||||
},
|
||||
{ provide: WebhookSender, useClass: HttpWebhookSender },
|
||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { isPrivateAddress } from './http-webhook.sender';
|
||||
|
||||
describe('webhook sender', () => {
|
||||
it('knows private and loopback addresses', () => {
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'10.1.2.3',
|
||||
'172.20.0.5',
|
||||
'192.168.1.9',
|
||||
'169.254.169.254',
|
||||
'::1',
|
||||
'fd00::1',
|
||||
'::ffff:127.0.0.1',
|
||||
])
|
||||
expect(isPrivateAddress(address)).toBe(true);
|
||||
for (const address of ['8.8.8.8', '172.32.0.1', '2a00:1450::1'])
|
||||
expect(isPrivateAddress(address)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { WebhookSender } from '@task-stream/api/tracker/application';
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Loopback, private, link-local and unique-local addresses. */
|
||||
export function isPrivateAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [a, b] = address.split('.').map(Number);
|
||||
return (
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a === 0 ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 100 && b >= 64 && b <= 127)
|
||||
);
|
||||
}
|
||||
const v6 = address.toLowerCase();
|
||||
return (
|
||||
v6 === '::1' ||
|
||||
v6 === '::' ||
|
||||
v6.startsWith('fe80:') ||
|
||||
v6.startsWith('fc') ||
|
||||
v6.startsWith('fd') ||
|
||||
(v6.startsWith('::ffff:') && isPrivateAddress(v6.slice(7)))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts deliveries with fetch, 5 s at most. Unless WEBHOOKS_ALLOW_PRIVATE is
|
||||
* true, refuses hosts that resolve to this machine or its private network:
|
||||
* a webhook must not become a way to reach internal services.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpWebhookSender extends WebhookSender {
|
||||
private readonly allowPrivate: boolean;
|
||||
|
||||
constructor(config: ConfigService) {
|
||||
super();
|
||||
this.allowPrivate =
|
||||
String(config.get('WEBHOOKS_ALLOW_PRIVATE') ?? '').toLowerCase() ===
|
||||
'true';
|
||||
}
|
||||
|
||||
async send(request: {
|
||||
url: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<{ status: number }> {
|
||||
const url = new URL(request.url);
|
||||
if (!this.allowPrivate) {
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
const addresses = isIP(host)
|
||||
? [host]
|
||||
: (await lookup(host, { all: true })).map((a) => a.address);
|
||||
if (addresses.some(isPrivateAddress))
|
||||
throw new Error(`${url.hostname} is a private address`);
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
return { status: response.status };
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,33 @@ export class SaveFilterInput {
|
||||
criteria!: SavedFilterCriteriaInput;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class CreateWebhookInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field() @IsUrl({ require_tld: false, require_protocol: true }) url!: string;
|
||||
@Field(() => [String], { description: 'Event names, family.* or *' })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
events!: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateWebhookInput {
|
||||
@Field(() => ID) @IsUUID() webhookId!: string;
|
||||
@Field({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
url?: string;
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
events?: string[];
|
||||
@Field({ nullable: true }) @IsOptional() @IsBoolean() active?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class RankIssueInput {
|
||||
@Field(() => ID) @IsUUID() issueId!: string;
|
||||
|
||||
@@ -170,6 +170,42 @@ export class GitIntegrationGql {
|
||||
webhookPath!: string;
|
||||
}
|
||||
|
||||
@ObjectType('WebhookDelivery')
|
||||
export class WebhookDeliveryGql {
|
||||
@Field() at!: string;
|
||||
@Field() event!: string;
|
||||
@Field(() => Int, { nullable: true, description: 'HTTP status' })
|
||||
status!: number | null;
|
||||
@Field() ok!: boolean;
|
||||
@Field(() => String, { nullable: true }) error!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('Webhook', {
|
||||
description:
|
||||
'Where the project tells another system what happened (signed POST, HMAC-SHA256)',
|
||||
})
|
||||
export class WebhookGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field() url!: string;
|
||||
@Field(() => [String], { description: 'Event names, family.* or *' })
|
||||
events!: string[];
|
||||
@Field() active!: boolean;
|
||||
@Field(() => UserRefGql) createdBy!: UserRefGql;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => WebhookDeliveryGql, { nullable: true })
|
||||
lastDelivery!: WebhookDeliveryGql | null;
|
||||
}
|
||||
|
||||
@ObjectType('WebhookCreated')
|
||||
export class WebhookCreatedGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field({
|
||||
description: 'Signs every delivery. Shown only now: keep it.',
|
||||
})
|
||||
secret!: string;
|
||||
}
|
||||
|
||||
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
||||
export class IssueParentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { CurrentUser } from '@task-stream/api/identity/presenters';
|
||||
import { WebhooksService } from '@task-stream/api/tracker/application';
|
||||
import { CreateWebhookInput, UpdateWebhookInput } from './inputs';
|
||||
import { WebhookCreatedGql, WebhookDeliveryGql, WebhookGql } from './types';
|
||||
|
||||
/** Outgoing webhooks of a project (owners and admins). */
|
||||
@Resolver(() => WebhookGql)
|
||||
export class WebhookResolver {
|
||||
constructor(private readonly webhooksService: WebhooksService) {}
|
||||
|
||||
@Query(() => [WebhookGql])
|
||||
webhooks(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.webhooksService.list(me.id, projectId);
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
description: 'Event names a webhook can subscribe to (also family.* and *)',
|
||||
})
|
||||
webhookEvents() {
|
||||
return this.webhooksService.events();
|
||||
}
|
||||
|
||||
@Mutation(() => WebhookCreatedGql)
|
||||
createWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: CreateWebhookInput,
|
||||
) {
|
||||
return this.webhooksService.create(
|
||||
me.id,
|
||||
input.projectId,
|
||||
input.url,
|
||||
input.events,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => WebhookGql)
|
||||
updateWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateWebhookInput,
|
||||
) {
|
||||
const { webhookId, url, events, active } = input;
|
||||
return this.webhooksService.update(me.id, webhookId, {
|
||||
...(url != null && { url }),
|
||||
...(events != null && { events }),
|
||||
...(active != null && { active }),
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async deleteWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('webhookId', { type: () => ID }) webhookId: string,
|
||||
) {
|
||||
await this.webhooksService.delete(me.id, webhookId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Mutation(() => WebhookDeliveryGql, {
|
||||
description: 'Send a ping now and tell how it went',
|
||||
})
|
||||
testWebhook(
|
||||
@CurrentUser() me: User,
|
||||
@Args('webhookId', { type: () => ID }) webhookId: string,
|
||||
) {
|
||||
return this.webhooksService.test(me.id, webhookId);
|
||||
}
|
||||
}
|
||||
@@ -731,6 +731,29 @@ export class SaveFilterDto {
|
||||
criteria!: SavedFilterCriteriaDto;
|
||||
}
|
||||
|
||||
export class CreateWebhookDto {
|
||||
@ApiProperty({ example: 'https://chat.example.org/hooks/taskstream' })
|
||||
@IsUrl({ require_tld: false, require_protocol: true })
|
||||
url!: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['issue.status_changed', 'release.*'],
|
||||
description: 'Event names (GET /api/webhook-events), family.* or *',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
events!: string[];
|
||||
}
|
||||
|
||||
export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
|
||||
@ApiPropertyOptional({ description: 'Paused webhooks get nothing' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export class RankIssueDto {
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
|
||||
@@ -167,6 +167,33 @@ export class GitIntegrationDto {
|
||||
@ApiProperty({ example: '/api/webhooks/git/3f2c…' }) webhookPath!: string;
|
||||
}
|
||||
|
||||
export class WebhookDeliveryDto {
|
||||
@ApiProperty({ format: 'date-time' }) at!: string;
|
||||
@ApiProperty({ example: 'issue.created' }) event!: string;
|
||||
@ApiProperty({ type: Number, nullable: true, description: 'HTTP status' })
|
||||
status!: number | null;
|
||||
@ApiProperty() ok!: boolean;
|
||||
@ApiProperty({ type: String, nullable: true }) error!: string | null;
|
||||
}
|
||||
|
||||
export class WebhookDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||
@ApiProperty() url!: string;
|
||||
@ApiProperty({ type: [String] }) events!: string[];
|
||||
@ApiProperty() active!: boolean;
|
||||
@ApiProperty({ type: UserRefDto }) createdBy!: UserRefDto;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: WebhookDeliveryDto, nullable: true })
|
||||
lastDelivery!: WebhookDeliveryDto | null;
|
||||
}
|
||||
|
||||
export class WebhookCreatedDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ description: 'Signs every delivery. Shown only now.' })
|
||||
secret!: string;
|
||||
}
|
||||
|
||||
export class GitDeliveryResultDto {
|
||||
@ApiProperty({ description: 'Tickets that got something new' })
|
||||
recorded!: number;
|
||||
|
||||
136
libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts
Normal file
136
libs/api/tracker/presenters/src/lib/http/webhooks.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import { WebhooksService } from '@task-stream/api/tracker/application';
|
||||
import { CreateWebhookDto, UpdateWebhookDto } from './dto/requests.dto';
|
||||
import {
|
||||
WebhookCreatedDto,
|
||||
WebhookDeliveryDto,
|
||||
WebhookDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const WEBHOOK = ApiParam({ name: 'webhook', format: 'uuid' });
|
||||
|
||||
/**
|
||||
* Outgoing webhooks: a signed POST to another system when something happens
|
||||
* in the project. Header `X-TaskStream-Signature: sha256=<HMAC of the body>`.
|
||||
*/
|
||||
@ApiTags('Webhooks')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class WebhooksController {
|
||||
constructor(
|
||||
private readonly webhooks: WebhooksService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('webhook-events')
|
||||
@ApiOperation({ summary: 'Event names a webhook can subscribe to' })
|
||||
@ApiOkResponse({ type: [String] })
|
||||
events() {
|
||||
return this.webhooks.events();
|
||||
}
|
||||
|
||||
@Get('projects/:project/webhooks')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({ summary: "The project's webhooks (owners and admins)" })
|
||||
@ApiOkResponse({ type: [WebhookDto] })
|
||||
@ApiReadErrors()
|
||||
async list(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
return this.webhooks.list(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Post('projects/:project/webhooks')
|
||||
@ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: 'Add a webhook',
|
||||
description: 'The answer carries the secret, the only time it is shown.',
|
||||
})
|
||||
@ApiCreatedResponse({ type: WebhookCreatedDto })
|
||||
@ApiCommandErrors()
|
||||
async create(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateWebhookDto,
|
||||
) {
|
||||
return this.webhooks.create(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
body.url,
|
||||
body.events,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('outgoing-webhooks/:webhook')
|
||||
@WEBHOOK
|
||||
@ApiOperation({ summary: 'Change the URL or the events, pause or resume' })
|
||||
@ApiOkResponse({ type: WebhookDto })
|
||||
@ApiCommandErrors()
|
||||
update(
|
||||
@CurrentUser() me: User,
|
||||
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||
@Body() body: UpdateWebhookDto,
|
||||
) {
|
||||
return this.webhooks.update(me.id, webhook, body);
|
||||
}
|
||||
|
||||
@Delete('outgoing-webhooks/:webhook')
|
||||
@WEBHOOK
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Delete a webhook' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async delete(
|
||||
@CurrentUser() me: User,
|
||||
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||
) {
|
||||
await this.webhooks.delete(me.id, webhook);
|
||||
}
|
||||
|
||||
@Post('outgoing-webhooks/:webhook/test')
|
||||
@WEBHOOK
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Send a ping now and tell how it went' })
|
||||
@ApiOkResponse({ type: WebhookDeliveryDto })
|
||||
@ApiCommandErrors()
|
||||
test(
|
||||
@CurrentUser() me: User,
|
||||
@Param('webhook', ParseUUIDPipe) webhook: string,
|
||||
) {
|
||||
return this.webhooks.test(me.id, webhook);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import { SprintResolver } from './graphql/sprint.resolver';
|
||||
import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||
import { WebhooksController } from './http/webhooks.controller';
|
||||
|
||||
/**
|
||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||
@@ -54,6 +56,7 @@ import { GitResolver } from './graphql/git.resolver';
|
||||
SavedFiltersController,
|
||||
SprintsController,
|
||||
GitController,
|
||||
WebhooksController,
|
||||
],
|
||||
providers: [
|
||||
Refs,
|
||||
@@ -66,6 +69,7 @@ import { GitResolver } from './graphql/git.resolver';
|
||||
SavedFilterResolver,
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
WebhookResolver,
|
||||
],
|
||||
})
|
||||
export class TrackerPresentersModule {
|
||||
|
||||
Reference in New Issue
Block a user