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:
2026-10-08 15:36:19 +02:00
parent 046d77c813
commit d43fc620e9
37 changed files with 3660 additions and 28 deletions

View File

@@ -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 } },
);
}
}

View File

@@ -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 },

View File

@@ -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 }),
]);
}

View File

@@ -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 },

View File

@@ -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);
});
});

View File

@@ -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 };
}
}