diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2be2154e..29766b23 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -158,6 +158,18 @@ services: - ./:/usr/src/app - workers-deps:/usr/src/app/node_modules + hawk-worker-webhook: + build: + dockerfile: "dev.Dockerfile" + context: . + env_file: + - .env + restart: unless-stopped + entrypoint: yarn run-webhook + volumes: + - ./:/usr/src/app + - workers-deps:/usr/src/app/node_modules + volumes: workers-deps: diff --git a/package.json b/package.json index a053c5b8..175b7bc5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hawk.workers", "private": true, - "version": "0.1.2", + "version": "0.1.3", "description": "Hawk workers", "repository": "git@github.com:codex-team/hawk.workers.git", "license": "BUSL-1.1", @@ -34,6 +34,7 @@ "test:notifier": "jest workers/notifier", "test:js": "jest workers/javascript", "test:task-manager": "jest workers/task-manager", + "test:webhook": "jest workers/webhook", "test:clear": "jest --clearCache", "run-default": "yarn worker hawk-worker-default", "run-sentry": "yarn worker hawk-worker-sentry", @@ -49,13 +50,14 @@ "run-email": "yarn worker hawk-worker-email", "run-telegram": "yarn worker hawk-worker-telegram", "run-limiter": "yarn worker hawk-worker-limiter", - "run-task-manager": "yarn worker hawk-worker-task-manager" + "run-task-manager": "yarn worker hawk-worker-task-manager", + "run-webhook": "yarn worker hawk-worker-webhook" }, "dependencies": { "@babel/parser": "^7.26.9", "@babel/traverse": "7.26.9", "@hawk.so/nodejs": "^3.1.1", - "@hawk.so/types": "^0.5.7", + "@hawk.so/types": "^0.5.9", "@types/amqplib": "^0.8.2", "@types/jest": "^29.5.14", "@types/mongodb": "^3.5.15", diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts index 001a1f27..832d34d3 100644 --- a/workers/notifier/types/channel.ts +++ b/workers/notifier/types/channel.ts @@ -6,6 +6,7 @@ export enum ChannelType { Telegram = 'telegram', Slack = 'slack', Loop = 'loop', + Webhook = 'webhook', } /** diff --git a/workers/webhook/package.json b/workers/webhook/package.json new file mode 100644 index 00000000..167692d6 --- /dev/null +++ b/workers/webhook/package.json @@ -0,0 +1,11 @@ +{ + "name": "hawk-worker-webhook", + "version": "1.0.0", + "description": "Webhook sender worker — delivers event notifications as JSON POST requests", + "main": "src/index.ts", + "license": "MIT", + "workerType": "sender/webhook", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + } +} diff --git a/workers/webhook/src/deliverer.ts b/workers/webhook/src/deliverer.ts new file mode 100644 index 00000000..d2f3cbce --- /dev/null +++ b/workers/webhook/src/deliverer.ts @@ -0,0 +1,89 @@ +import https from 'https'; +import http from 'http'; +import { createLogger, format, Logger, transports } from 'winston'; +import { WebhookDelivery } from '../types/template'; + +/** + * Timeout for webhook delivery in milliseconds + */ +const DELIVERY_TIMEOUT_MS = 10000; + +/** + * HTTP status code threshold for error responses + */ +const HTTP_ERROR_STATUS = 400; + +/** + * Deliverer sends JSON POST requests to external webhook endpoints + */ +export default class WebhookDeliverer { + /** + * Logger module + * (default level='info') + */ + private logger: Logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + transports: [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.colorize(), + format.simple(), + format.printf((msg) => `${msg.timestamp} - ${msg.level}: ${msg.message}`) + ), + }), + ], + }); + + /** + * Sends webhook delivery to the endpoint via HTTP POST. + * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event). + * + * @param endpoint - URL to POST to + * @param delivery - webhook delivery { type, payload } + */ + public async deliver(endpoint: string, delivery: WebhookDelivery): Promise { + const body = JSON.stringify(delivery); + const url = new URL(endpoint); + const transport = url.protocol === 'https:' ? https : http; + + return new Promise((resolve) => { + const req = transport.request( + url, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Hawk-Webhook/1.0', + 'X-Hawk-Notification': delivery.type, + 'Content-Length': Buffer.byteLength(body), + }, + timeout: DELIVERY_TIMEOUT_MS, + }, + (res) => { + res.resume(); + + if (res.statusCode && res.statusCode >= HTTP_ERROR_STATUS) { + this.logger.log('error', `Webhook delivery failed: ${res.statusCode} ${res.statusMessage} for ${endpoint}`); + } + + resolve(); + } + ); + + req.on('error', (e) => { + this.logger.log('error', `Can't deliver webhook to ${endpoint}: ${e.message}`); + resolve(); + }); + + req.on('timeout', () => { + this.logger.log('error', `Webhook delivery timed out for ${endpoint}`); + req.destroy(); + resolve(); + }); + + req.write(body); + req.end(); + }); + } +} diff --git a/workers/webhook/src/index.ts b/workers/webhook/src/index.ts new file mode 100644 index 00000000..f826bb02 --- /dev/null +++ b/workers/webhook/src/index.ts @@ -0,0 +1,24 @@ +import * as pkg from './../package.json'; +import WebhookProvider from './provider'; +import SenderWorker from 'hawk-worker-sender/src'; +import { ChannelType } from 'hawk-worker-notifier/types/channel'; + +/** + * Worker to send webhook notifications + */ +export default class WebhookSenderWorker extends SenderWorker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Webhook channel type + */ + protected channelType = ChannelType.Webhook; + + /** + * Webhook provider + */ + protected provider = new WebhookProvider(); +} diff --git a/workers/webhook/src/provider.ts b/workers/webhook/src/provider.ts new file mode 100644 index 00000000..f894141e --- /dev/null +++ b/workers/webhook/src/provider.ts @@ -0,0 +1,34 @@ +import NotificationsProvider from 'hawk-worker-sender/src/provider'; +import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { toDelivery } from './templates'; +import WebhookDeliverer from './deliverer'; + +/** + * Webhook notification provider. + * Supports all notification types via a single generic serializer — + * type comes from notification.type, payload is sanitized automatically. + */ +export default class WebhookProvider extends NotificationsProvider { + /** + * Class with the 'deliver' method for sending HTTP POST requests + */ + private readonly deliverer: WebhookDeliverer; + + constructor() { + super(); + + this.deliverer = new WebhookDeliverer(); + } + + /** + * Send webhook notification to recipient + * + * @param to - recipient endpoint URL + * @param notification - notification with payload and type + */ + public async send(to: string, notification: Notification): Promise { + const delivery = toDelivery(notification); + + await this.deliverer.deliver(to, delivery); + } +} diff --git a/workers/webhook/src/templates/generic.ts b/workers/webhook/src/templates/generic.ts new file mode 100644 index 00000000..4255f8b6 --- /dev/null +++ b/workers/webhook/src/templates/generic.ts @@ -0,0 +1,55 @@ +import { Notification } from 'hawk-worker-sender/types/template-variables'; +import { WebhookDelivery } from '../../types/template'; + +/** + * List of internal fields that should not be exposed in webhook payload + */ +const INTERNAL_FIELDS = new Set(['host', 'hostOfStatic']); + +/** + * Recursively converts MongoDB ObjectIds and other non-JSON-safe values to strings + * + * @param value - any value to sanitize + */ +function sanitize(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === 'object' && '_bsontype' in (value as Record)) { + return String(value); + } + + if (Array.isArray(value)) { + return value.map(sanitize); + } + + if (typeof value === 'object') { + const result: Record = {}; + + for (const [k, v] of Object.entries(value as Record)) { + if (!INTERNAL_FIELDS.has(k)) { + result[k] = sanitize(v); + } + } + + return result; + } + + return value; +} + +/** + * Generic webhook template — handles any notification type + * by passing through the sanitized payload as-is. + * + * Used as a fallback when no curated template exists for the notification type. + * + * @param notification - notification with type and payload + */ +export default function render(notification: Notification): WebhookDelivery { + return { + type: notification.type, + payload: sanitize(notification.payload) as Record, + }; +} diff --git a/workers/webhook/src/templates/index.ts b/workers/webhook/src/templates/index.ts new file mode 100644 index 00000000..cce548d0 --- /dev/null +++ b/workers/webhook/src/templates/index.ts @@ -0,0 +1 @@ +export { default as toDelivery } from './generic'; diff --git a/workers/webhook/tests/__mocks__/assignee-notify.ts b/workers/webhook/tests/__mocks__/assignee-notify.ts new file mode 100644 index 00000000..086f557b --- /dev/null +++ b/workers/webhook/tests/__mocks__/assignee-notify.ts @@ -0,0 +1,33 @@ +import { AssigneeNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of assignee notify template variables + */ +export default { + type: 'assignee', + payload: { + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + event: { + totalCount: 5, + groupHash: 'abc123', + payload: { + title: 'TypeError: Cannot read property', + }, + }, + whoAssigned: { + name: 'John Doe', + email: 'john@example.com', + }, + daysRepeated: 3, + }, +} as AssigneeNotification; diff --git a/workers/webhook/tests/__mocks__/event-notify.ts b/workers/webhook/tests/__mocks__/event-notify.ts new file mode 100644 index 00000000..77a53b54 --- /dev/null +++ b/workers/webhook/tests/__mocks__/event-notify.ts @@ -0,0 +1,43 @@ +import { EventNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of new-events notify template variables + */ +export default { + type: 'event', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 1, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as EventNotification; diff --git a/workers/webhook/tests/__mocks__/several-events-notify.ts b/workers/webhook/tests/__mocks__/several-events-notify.ts new file mode 100644 index 00000000..24ed30e8 --- /dev/null +++ b/workers/webhook/tests/__mocks__/several-events-notify.ts @@ -0,0 +1,63 @@ +import { SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { GroupedEventDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Example of several-events notify template variables + */ +export default { + type: 'several-events', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as GroupedEventDBScheme, + daysRepeated: 1, + newCount: 1, + }, + { + event: { + totalCount: 5, + payload: { + title: 'New event 2', + timestamp: Date.now(), + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 100, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as SeveralEventsNotification; diff --git a/workers/webhook/tests/provider.test.ts b/workers/webhook/tests/provider.test.ts new file mode 100644 index 00000000..6d787032 --- /dev/null +++ b/workers/webhook/tests/provider.test.ts @@ -0,0 +1,123 @@ +import EventNotifyMock from './__mocks__/event-notify'; +import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import AssigneeNotifyMock from './__mocks__/assignee-notify'; +import WebhookProvider from '../src/provider'; + +/** + * The sample of a webhook endpoint + */ +const webhookEndpointSample = 'https://example.com/hawk-webhook'; + +/** + * Mock the 'deliver' method of WebhookDeliverer + */ +const deliver = jest.fn(); + +/** + * Webhook Deliverer mock + */ +jest.mock('./../src/deliverer.ts', () => { + return jest.fn().mockImplementation(() => { + return { + deliver: deliver, + }; + }); +}); + +/** + * Clear all records of mock calls between tests + */ +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('WebhookProvider', () => { + it('should deliver a message with { type, payload } structure', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith(webhookEndpointSample, expect.objectContaining({ + type: 'event', + payload: expect.any(Object), + })); + }); + + it('should preserve notification type in delivery', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + expect(deliver.mock.calls[0][1].type).toBe('event'); + + deliver.mockClear(); + + await provider.send(webhookEndpointSample, SeveralEventsNotifyMock); + expect(deliver.mock.calls[0][1].type).toBe('several-events'); + + deliver.mockClear(); + + await provider.send(webhookEndpointSample, AssigneeNotifyMock); + expect(deliver.mock.calls[0][1].type).toBe('assignee'); + }); + + it('should strip internal fields (host, hostOfStatic) from payload', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, { + type: 'payment-failed', + payload: { + host: 'https://garage.hawk.so', + hostOfStatic: 'https://api.hawk.so', + workspace: { name: 'Workspace' }, + reason: 'Insufficient funds', + }, + } as any); + + const delivery = deliver.mock.calls[0][1]; + + expect(delivery.payload).not.toHaveProperty('host'); + expect(delivery.payload).not.toHaveProperty('hostOfStatic'); + expect(delivery.payload).toHaveProperty('reason', 'Insufficient funds'); + }); + + it('should handle all known notification types without throwing', async () => { + const provider = new WebhookProvider(); + + const types = [ + 'event', + 'several-events', + 'assignee', + 'block-workspace', + 'blocked-workspace-reminder', + 'payment-failed', + 'payment-success', + 'days-limit-almost-reached', + 'events-limit-almost-reached', + 'sign-up', + 'password-reset', + 'workspace-invite', + ]; + + for (const type of types) { + await expect( + provider.send(webhookEndpointSample, { + type, + payload: { host: 'h', hostOfStatic: 's' }, + } as any) + ).resolves.toBeUndefined(); + } + + expect(deliver).toHaveBeenCalledTimes(types.length); + }); + + it('should only have { type, payload } keys at root level', async () => { + const provider = new WebhookProvider(); + + await provider.send(webhookEndpointSample, EventNotifyMock); + + const delivery = deliver.mock.calls[0][1]; + + expect(Object.keys(delivery).sort()).toEqual(['payload', 'type']); + }); +}); diff --git a/workers/webhook/types/template.d.ts b/workers/webhook/types/template.d.ts new file mode 100644 index 00000000..a2d06046 --- /dev/null +++ b/workers/webhook/types/template.d.ts @@ -0,0 +1,11 @@ +/** + * Unified root-level structure for all webhook deliveries. + * Every webhook POST body has the same shape: { type, payload }. + */ +export interface WebhookDelivery { + /** Notification type (e.g. 'event', 'several-events', 'assignee', 'payment-failed', ...) */ + type: string; + + /** Notification-specific payload — structure depends on the type */ + payload: Record; +} diff --git a/yarn.lock b/yarn.lock index 2232af0a..3172b2bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -402,10 +402,10 @@ dependencies: "@types/mongodb" "^3.5.34" -"@hawk.so/types@^0.5.7": - version "0.5.7" - resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.7.tgz#964af0ad780998820801708292dcb06c390fb9a3" - integrity sha512-ccKVQkFcgTpIe9VVQiz94mCXvGk0Zn6uDyHONlXpS3E0j037eISaw2wUsddzAzKntXYbnFQbxah+zmx35KkVtA== +"@hawk.so/types@^0.5.9": + version "0.5.9" + resolved "https://registry.yarnpkg.com/@hawk.so/types/-/types-0.5.9.tgz#817e8b26283d0367371125f055f2e37a274797bc" + integrity sha512-86aE0Bdzvy8C+Dqd1iZpnDho44zLGX/t92SGuAv2Q52gjSJ7SHQdpGDWtM91FXncfT5uzAizl9jYMuE6Qrtm0Q== dependencies: bson "^7.0.0"