Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/consumers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,62 @@ Consumes notifications from `consumer-queue`:
"🗳️ New governance proposal in UNI: 'Uniswap Protocol Governance'"
```

## 🔔 Webhook Notifications

Subscribers can receive notifications over HTTP instead of Telegram by registering a webhook URL.

### Register

`POST /webhooks` with `{ "url": "https://..." }` (HTTPS required).

The response on first registration includes a `secret` field — store it immediately, it is shown
exactly once and never returned again. Re-registering the same URL returns success without a
`secret`, and the stored secret does not change.

### Unregister

`DELETE /webhooks` with the same `{ "url": "https://..." }` body.

### Verifying a delivery

Every delivery includes two headers:
- `X-Webhook-Timestamp` — unix seconds
- `X-Webhook-Signature-V2` — raw HMAC-SHA256 hex digest

To verify:
1. Recompute `HMAC-SHA256(secret, "{timestamp}.{raw_request_body}")`. Use the exact raw body bytes
received — not a re-serialized/re-parsed version of it.
2. Compare the result to the signature using a timing-safe comparison (e.g. Node's
`crypto.timingSafeEqual`, or your language's constant-time equivalent) — never `===`/`==`.
3. Reject the request if the timestamp is more than 5 minutes old (replay protection).

Minimal Node.js example (mirrors the signing logic in
`src/services/webhook/webhook.service.ts`). Note that `crypto.timingSafeEqual` throws if the two
buffers differ in length, so guard for that — the snippet below returns `false` in that case rather
than letting it throw:

```js
const crypto = require('crypto');

function isValidSignature(secret, timestamp, rawBody, signatureHeader) {
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) return false; // reject stale requests (> 5 min)

const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');

const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;

return crypto.timingSafeEqual(a, b);
}
```

See `/docs` for the full OpenAPI spec, including request/response schemas for both endpoints.

## 🧪 Testing

### Running Tests
Expand Down
2 changes: 2 additions & 0 deletions apps/consumers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
"dependencies": {
"@anticapture/observability": "^1.0.0",
"@fastify/cors": "^11.0.1",
"@fastify/swagger": "^9.5.0",
"@fastify/swagger-ui": "^5.2.2",
"@notification-system/anticapture-client": "workspace:*",
"@notification-system/messages": "workspace:*",
"@notification-system/rabbitmq-client": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions apps/consumers/src/interfaces/subscription.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface UserSubscriptionResponse {
is_active: boolean;
created_at?: string;
updated_at?: string;
secret?: string;
}

/**
Expand Down
36 changes: 36 additions & 0 deletions apps/consumers/src/services/webhook/webhook-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebhookServer } from './webhook-server';
import { WebhookController } from './webhook.controller';
import { WebhookService } from './webhook.service';

describe('WebhookServer docs', () => {
let webhookServer: WebhookServer;
let server: any;

beforeEach(() => {
const webhookController = new WebhookController({} as WebhookService);
webhookServer = new WebhookServer(webhookController);
server = (webhookServer as any).server;
});

afterEach(async () => {
await webhookServer.stop();
});

it('serves an OpenAPI document listing the /webhooks path', async () => {
const response = await server.inject({ method: 'GET', url: '/docs/json' });

expect(response.statusCode).toBe(200);
const body = JSON.parse(response.body);
expect(body.paths).toHaveProperty('/webhooks');
expect(body.paths['/webhooks']).toHaveProperty('post');
expect(body.paths['/webhooks']).toHaveProperty('delete');
});

it('serves the Swagger UI', async () => {
const response = await server.inject({ method: 'GET', url: '/docs' });

expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toMatch(/text\/html/);
});
});
19 changes: 18 additions & 1 deletion apps/consumers/src/services/webhook/webhook-server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import fastify, { FastifyInstance } from 'fastify';
import { validatorCompiler, serializerCompiler, ZodTypeProvider } from 'fastify-type-provider-zod';
import { validatorCompiler, serializerCompiler, jsonSchemaTransform, ZodTypeProvider } from 'fastify-type-provider-zod';
import fastifyCors from '@fastify/cors';
import fastifySwagger from '@fastify/swagger';
import fastifySwaggerUi from '@fastify/swagger-ui';
import { z } from 'zod';
import { WebhookController } from './webhook.controller';
import { createLogger, type Logger } from '@anticapture/observability';
Expand All @@ -19,6 +21,21 @@ export class WebhookServer {
this.server.setSerializerCompiler(serializerCompiler);
this.server.register(fastifyCors, { origin: '*' });

this.server.register(fastifySwagger, {
openapi: {
info: {
title: 'Webhook Notification API',
description: 'API for registering and managing webhook notification subscriptions',
version: '1.0.0',
}
},
transform: jsonSchemaTransform
});

this.server.register(fastifySwaggerUi, {
routePrefix: '/docs',
});

this.server.register((app) => this.webhookController.register(app));
this.server.withTypeProvider<ZodTypeProvider>().get('/health', {
schema: {
Expand Down
44 changes: 41 additions & 3 deletions apps/consumers/src/services/webhook/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,59 @@ const webhookBodySchema = z.object({
}),
});

const VERIFICATION_RECIPE = `Deliveries are signed with HMAC-SHA256: HMAC-SHA256(\`\${timestamp}.\${rawBody}\`, secret), ` +
'sent as the `X-Webhook-Timestamp` header (unix seconds) and the `X-Webhook-Signature-V2` header ' +
'(raw hex digest). Receivers should recompute the signature and compare it using a timing-safe ' +
'comparison (`crypto.timingSafeEqual`), and reject requests where the timestamp is more than 5 ' +
'minutes old to prevent replay attacks.';

export class WebhookController {
constructor(private webhookService: WebhookService) {}

async register(app: FastifyInstance): Promise<void> {
const typedApp = app.withTypeProvider<ZodTypeProvider>();
typedApp.post('/webhooks', {
schema: { body: webhookBodySchema },
schema: {
tags: ['webhooks'],
description: 'Registers a webhook URL to receive notifications for all DAOs. On first ' +
'registration, returns a one-time HMAC secret used to verify delivery signatures — it is ' +
'never shown again, so store it immediately. Re-registering an already-active webhook ' +
`returns success without a secret.\n\n${VERIFICATION_RECIPE}`,
body: webhookBodySchema,
response: {
201: z.union([
z.object({
success: z.literal(true),
secret: z.string(),
note: z.string(),
}),
z.object({ success: z.literal(true) }),
]),
},
},
}, async (request, reply) => {
const { url } = request.body;
await this.webhookService.registerWebhook(url);
const { created, secret } = await this.webhookService.registerWebhook(url);
if (created) {
return reply.code(201).send({
success: true,
secret,
note: 'Store this secret now — it will not be shown again.',
});
}
return reply.code(201).send({ success: true });
});

typedApp.delete('/webhooks', {
schema: { body: webhookBodySchema },
schema: {
tags: ['webhooks'],
description: 'Deactivates a previously registered webhook URL, stopping further deliveries.',
body: webhookBodySchema,
response: {
200: z.object({ success: z.literal(true) }),
404: z.object({ error: z.string() }),
},
},
}, async (request, reply) => {
const { url } = request.body;
const found = await this.webhookService.deactivateWebhook(url);
Expand Down
129 changes: 129 additions & 0 deletions apps/consumers/src/services/webhook/webhook.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from 'vitest';
import * as crypto from 'crypto';
import { AxiosInstance } from 'axios';
import { WebhookService } from './webhook.service';
import { ISubscriptionAPI } from '../subscription-api.service';
import { UserSubscriptionResponse } from '../../interfaces/subscription.interface';
import { NotificationPayload } from '../../interfaces/notification.interface';
import { makeAnticaptureClient } from '@notification-system/anticapture-client';

class SimpleHttpClient {
public posts: Array<{ url: string; data: any; config: any }> = [];

post = async (url: string, data: any, config?: any) => {
this.posts.push({ url, data, config });
return { data: { id: 'delivered-1' } };
};
}

const anticaptureClient = makeAnticaptureClient({
getDAOs: async () => [
{ id: 'UNI', chainId: 1, blockTime: 12, votingDelay: '0', supportsCalldataReview: false, supportsOffchainData: false },
{ id: 'ENS', chainId: 1, blockTime: 12, votingDelay: '0', supportsCalldataReview: false, supportsOffchainData: false },
],
});

class SimpleSubscriptionAPI implements ISubscriptionAPI {
constructor(private readonly responses: UserSubscriptionResponse[]) {}

private callIndex = 0;

async saveUserPreference(): Promise<UserSubscriptionResponse> {
const response = this.responses[this.callIndex] ?? {};
this.callIndex += 1;
return response as UserSubscriptionResponse;
}

async getUserPreferences(): Promise<string[]> {
return [];
}
}

describe('WebhookService', () => {
describe('registerWebhook', () => {
it('returns created: true with the secret when one call carries a secret', async () => {
const subscriptionApi = new SimpleSubscriptionAPI([
{ user_id: '1', dao_id: 'UNI', is_active: true, secret: 'super-secret' },
{ user_id: '1', dao_id: 'ENS', is_active: true },
]);
const webhookService = new WebhookService(anticaptureClient, subscriptionApi);

const result = await webhookService.registerWebhook('https://example.com/webhook');

expect(result).toEqual({ created: true, secret: 'super-secret' });
});

it('returns created: false when no call carries a secret', async () => {
const subscriptionApi = new SimpleSubscriptionAPI([
{ user_id: '1', dao_id: 'UNI', is_active: true },
{ user_id: '1', dao_id: 'ENS', is_active: true },
]);
const webhookService = new WebhookService(anticaptureClient, subscriptionApi);

const result = await webhookService.registerWebhook('https://example.com/webhook');

expect(result).toEqual({ created: false });
});
});

describe('sendNotification', () => {
const subscriptionApi = new SimpleSubscriptionAPI([]);

const basePayload: NotificationPayload = {
userId: 'user123',
channel: 'webhook',
channelUserId: 'https://example.com/webhook',
message: 'Test notification message',
bot_token: 'shared-webhook-secret',
};

it('signs the delivery with an HMAC computed from the subscriber secret', async () => {
const httpClient = new SimpleHttpClient();
const webhookService = new WebhookService(
anticaptureClient,
subscriptionApi,
undefined,
httpClient as unknown as AxiosInstance,
);

const before = Math.floor(Date.now() / 1000);
await webhookService.sendNotification(basePayload);
const after = Math.floor(Date.now() / 1000);

expect(httpClient.posts).toHaveLength(1);
const [{ url, data: rawBody, config }] = httpClient.posts;

expect(url).toBe('https://example.com/webhook');
expect(typeof rawBody).toBe('string');

const timestamp = Number(config.headers['X-Webhook-Timestamp']);
expect(timestamp).toBeGreaterThanOrEqual(before);
expect(timestamp).toBeLessThanOrEqual(after);

const expectedSignature = crypto
.createHmac('sha256', basePayload.bot_token!)
.update(`${timestamp}.${rawBody}`)
.digest('hex');

expect(config.headers['X-Webhook-Signature-V2']).toBe(expectedSignature);
});

it('skips delivery and does not POST when the subscriber has no bot_token/secret', async () => {
const httpClient = new SimpleHttpClient();
const webhookService = new WebhookService(
anticaptureClient,
subscriptionApi,
undefined,
httpClient as unknown as AxiosInstance,
);

const result = await webhookService.sendNotification({
...basePayload,
bot_token: undefined,
});

expect(httpClient.posts).toHaveLength(0);
expect(result).toBe('');
});
});
});
Loading
Loading