Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
114 changes: 114 additions & 0 deletions apps/api/src/webhook-support/1700000000000-CreateWebhookTables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';

export class CreateWebhookTables1700000000000 implements MigrationInterface {
name = 'CreateWebhookTables1700000000000';

// ──────────────────────────────────────────────────────────────────────────
// UP
// ──────────────────────────────────────────────────────────────────────────
async up(queryRunner: QueryRunner): Promise<void> {
// ── webhooks ────────────────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'webhooks',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'name', type: 'varchar', length: '255' },
{ name: 'url', type: 'text' },
{ name: 'secret', type: 'text' },
{
name: 'events',
type: 'text',
// TypeORM simple-array uses comma-separated text
default: "''",
},
{ name: 'isActive', type: 'boolean', default: true },
{ name: 'maxRetries', type: 'int', default: 5 },
{ name: 'description', type: 'text', isNullable: true },
{
name: 'createdAt',
type: 'timestamptz',
default: 'now()',
},
{
name: 'updatedAt',
type: 'timestamptz',
default: 'now()',
},
],
}),
true,
);

// ── webhook_deliveries ──────────────────────────────────────────────────
await queryRunner.createTable(
new Table({
name: 'webhook_deliveries',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'gen_random_uuid()',
},
{ name: 'webhookId', type: 'uuid' },
{ name: 'event', type: 'varchar' },
{ name: 'payload', type: 'jsonb' },
{ name: 'status', type: 'varchar', default: "'pending'" },
{ name: 'responseStatus', type: 'int', isNullable: true },
{ name: 'responseBody', type: 'text', isNullable: true },
{ name: 'errorMessage', type: 'text', isNullable: true },
{ name: 'attempt', type: 'int', default: 0 },
{ name: 'nextRetryAt', type: 'timestamptz', isNullable: true },
{ name: 'deliveredAt', type: 'timestamptz', isNullable: true },
{
name: 'createdAt',
type: 'timestamptz',
default: 'now()',
},
],
foreignKeys: [
{
columnNames: ['webhookId'],
referencedTableName: 'webhooks',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);

// ── indexes ─────────────────────────────────────────────────────────────
await queryRunner.createIndex(
'webhook_deliveries',
new TableIndex({
name: 'IDX_webhook_deliveries_webhookId_createdAt',
columnNames: ['webhookId', 'createdAt'],
}),
);

await queryRunner.createIndex(
'webhook_deliveries',
new TableIndex({
name: 'IDX_webhook_deliveries_status',
columnNames: ['status'],
}),
);
}

// ──────────────────────────────────────────────────────────────────────────
// DOWN
// ──────────────────────────────────────────────────────────────────────────
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('webhook_deliveries', true);
await queryRunner.dropTable('webhooks', true);
}
}
70 changes: 70 additions & 0 deletions apps/api/src/webhook-support/dispatch-usage.example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Usage Example — Dispatching webhook events from other BridgeWise services
*
* Drop this pattern into any service that wants to emit webhook events.
* No circular dependency: WebhookModule exports WebhookService so any
* feature module can import it freely.
*/

import { Injectable } from '@nestjs/common';
import { WebhookEvent, WebhookService } from '../index'; // adjust path

// ── Example: Gas alert service emitting webhook events ────────────────────────

@Injectable()
export class GasAlertService {
constructor(
// ... your other deps
private readonly webhooks: WebhookService,
) {}

async handleSpikeDetected(chainId: string, gweiValue: number): Promise<void> {
// ... your existing logic

// Fire & forget — webhook delivery is async via BullMQ
await this.webhooks.dispatch({
event: WebhookEvent.GAS_SPIKE_DETECTED,
data: {
chainId,
gweiValue,
detectedAt: new Date().toISOString(),
},
});
}

async handleGasNormalized(chainId: string, gweiValue: number): Promise<void> {
// ... your existing logic

await this.webhooks.dispatch({
event: WebhookEvent.GAS_NORMALIZED,
data: { chainId, gweiValue },
});
}
}

// ── AppModule wiring ──────────────────────────────────────────────────────────
//
// In your AppModule (or the feature module):
//
// @Module({
// imports: [
// WebhookModule, // <-- add this
// GasAlertModule,
// BullModule.forRootAsync({
// useFactory: (config: ConfigService) => ({
// connection: {
// host: config.get('REDIS_HOST', 'localhost'),
// port: config.get<number>('REDIS_PORT', 6379),
// },
// }),
// inject: [ConfigService],
// }),
// ],
// })
// export class AppModule {}

// ── Required environment variables ───────────────────────────────────────────
//
// REDIS_HOST=localhost
// REDIS_PORT=6379
// WEBHOOK_ADMIN_SECRET=<min 32 random chars> # guards POST /webhooks/dispatch
56 changes: 56 additions & 0 deletions apps/api/src/webhook-support/webhook-admin.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { WebhookAdminGuard } from '../guards/webhook-admin.guard';

const makeContext = (authHeader?: string): ExecutionContext =>
({
switchToHttp: () => ({
getRequest: () => ({
headers: authHeader ? { authorization: authHeader } : {},
}),
}),
} as unknown as ExecutionContext);

const makeGuard = (secret = 'admin-secret-key') => {
const config = { get: jest.fn().mockReturnValue(secret) } as unknown as ConfigService;
return new WebhookAdminGuard(config);
};

describe('WebhookAdminGuard', () => {
it('allows a request with the correct Bearer token', () => {
const guard = makeGuard('my-admin-secret');
expect(guard.canActivate(makeContext('Bearer my-admin-secret'))).toBe(true);
});

it('throws when the Authorization header is missing', () => {
expect(() => makeGuard().canActivate(makeContext())).toThrow(UnauthorizedException);
});

it('throws when the scheme is not Bearer', () => {
expect(() => makeGuard().canActivate(makeContext('Basic dXNlcjpwYXNz'))).toThrow(
UnauthorizedException,
);
});

it('throws when the token does not match', () => {
const guard = makeGuard('correct-secret');
expect(() => guard.canActivate(makeContext('Bearer wrong-token'))).toThrow(
UnauthorizedException,
);
});

it('throws when WEBHOOK_ADMIN_SECRET is not set', () => {
const config = { get: jest.fn().mockReturnValue('') } as unknown as ConfigService;
const guard = new WebhookAdminGuard(config);
expect(() => guard.canActivate(makeContext('Bearer anything'))).toThrow(
UnauthorizedException,
);
});

it('handles tokens of different lengths without throwing (timing-safe)', () => {
const guard = makeGuard('short');
expect(() =>
guard.canActivate(makeContext('Bearer this-is-a-much-longer-token-than-expected')),
).toThrow(UnauthorizedException);
});
});
59 changes: 59 additions & 0 deletions apps/api/src/webhook-support/webhook-admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { timingSafeEqual } from 'crypto';

/**
* WebhookAdminGuard
* ─────────────────
* Protects the internal /webhooks/dispatch endpoint (and any other admin
* webhook routes) behind a pre-shared API key.
*
* The key is read from the environment variable WEBHOOK_ADMIN_SECRET.
* Consumers must supply it as:
*
* Authorization: Bearer <WEBHOOK_ADMIN_SECRET>
*
* Usage — apply per route:
* @UseGuards(WebhookAdminGuard)
* @Post('dispatch')
* dispatch(@Body() dto: DispatchEventDto) { ... }
*
* Or globally in AppModule / a dedicated admin module guard.
*/
@Injectable()
export class WebhookAdminGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}

canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
const authHeader = request.headers['authorization'] ?? '';

if (!authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing admin Bearer token');
}

const token = authHeader.slice(7);
const expected = this.config.get<string>('WEBHOOK_ADMIN_SECRET', '');

if (!expected) {
throw new UnauthorizedException('WEBHOOK_ADMIN_SECRET is not configured');
}

let match = false;
try {
match = timingSafeEqual(Buffer.from(token), Buffer.from(expected));
} catch {
// Buffers differ in length
match = false;
}

if (!match) throw new UnauthorizedException('Invalid admin token');
return true;
}
}
Loading
Loading