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
1 change: 1 addition & 0 deletions listener/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ RECONNECT_DELAY_MS=5000
# Events API Configuration
EVENTS_API_PORT=8787
EVENTS_API_CORS_ORIGIN=http://localhost:5173
WEBHOOK_SECRETS=[{"id":"default","secret":"whsec_your_secret_here"}]

# Discord Webhook Configuration (optional)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
Expand Down
160 changes: 160 additions & 0 deletions listener/src/api/events-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import http from 'http';
import crypto from 'crypto';
import { createEventsServer, checkStellarRpc, checkDiscord } from './events-server';
import { eventRegistry } from '../store/event-registry';

const mockGetHealth = jest.fn();

jest.mock('@stellar/stellar-sdk', () => ({
rpc: {
Server: jest.fn().mockImplementation(() => ({
getHealth: mockGetHealth,
})),
},
}));
import { createEventsServer } from './events-server';
import { preferenceStore } from '../store/preference-store';

Expand Down Expand Up @@ -118,3 +131,150 @@ describe('Preference API endpoints', () => {
});
});
});

function computeSignature(payload: string, secret: string): string {
const sig = crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex');
return `sha256=${sig}`;
}

function makePostRequest(
server: http.Server,
path: string,
body: string,
headers: Record<string, string>
): Promise<{ status: number; body: unknown }> {
return new Promise((resolve, reject) => {
const addr = server.address() as { port: number };
const req = http.request(
{
host: '127.0.0.1',
port: addr.port,
path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers,
},
},
(res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve({ status: res.statusCode!, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode!, body: data });
}
});
}
);
req.on('error', reject);
req.write(body);
req.end();
});
}

describe('POST /api/webhooks', () => {
let server: http.Server;
const secrets = [
{ id: 'key-1', secret: 'whsec_test_secret' },
{ id: 'key-2', secret: 'whsec_other_secret' },
];

beforeEach(() => {
jest.clearAllMocks();
});

afterEach(async () => {
if (server) await closeServer(server);
});

it('accepts a webhook with a valid signature', async () => {
const payload = JSON.stringify({ event: 'test', data: { foo: 'bar' } });
const signature = computeSignature(payload, 'whsec_test_secret');

server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets });
const { status, body } = await makePostRequest(server, '/api/webhooks', payload, {
'X-Webhook-Signature': signature,
'X-Webhook-Key-Id': 'key-1',
});

expect(status).toBe(202);
expect((body as any).status).toBe('accepted');
});

it('rejects a webhook with an invalid signature', async () => {
const payload = JSON.stringify({ event: 'test' });

server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets });
const { status, body } = await makePostRequest(server, '/api/webhooks', payload, {
'X-Webhook-Signature': 'sha256=invalid',
'X-Webhook-Key-Id': 'key-1',
});

expect(status).toBe(401);
expect((body as any).error).toBe('Invalid signature');
});

it('rejects when signature header is missing', async () => {
const payload = JSON.stringify({ event: 'test' });

server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets });
const { status, body } = await makePostRequest(server, '/api/webhooks', payload, {
'X-Webhook-Key-Id': 'key-1',
});

expect(status).toBe(401);
expect((body as any).error).toBe('Missing signature header');
});

it('rejects when key-id header is missing', async () => {
const payload = JSON.stringify({ event: 'test' });
const signature = computeSignature(payload, 'whsec_test_secret');

server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets });
const { status, body } = await makePostRequest(server, '/api/webhooks', payload, {
'X-Webhook-Signature': signature,
});

expect(status).toBe(401);
expect((body as any).error).toBe('Missing key-id header');
});

it('rejects when key-id is unknown', async () => {
const payload = JSON.stringify({ event: 'test' });
const signature = computeSignature(payload, 'whsec_test_secret');

server = await startServer({ ...BASE_OPTIONS, webhookSecrets: secrets });
const { status, body } = await makePostRequest(server, '/api/webhooks', payload, {
'X-Webhook-Signature': signature,
'X-Webhook-Key-Id': 'unknown-key',
});

expect(status).toBe(401);
expect((body as any).error).toBe('Unknown key-id');
});

it('rejects when no webhook secrets are configured', async () => {
const payload = JSON.stringify({ event: 'test' });
const signature = computeSignature(payload, 'whsec_test_secret');

server = await startServer(BASE_OPTIONS);
const { status, body } = await makePostRequest(server, '/api/webhooks', payload, {
'X-Webhook-Signature': signature,
'X-Webhook-Key-Id': 'key-1',
});

expect(status).toBe(401);
expect((body as any).error).toBe('Unknown key-id');
});

it('returns 404 for POST to other paths', async () => {
const payload = JSON.stringify({ event: 'test' });

server = await startServer(BASE_OPTIONS);
const { status, body } = await makePostRequest(server, '/api/events', payload, {});

expect(status).toBe(404);
});
});
122 changes: 122 additions & 0 deletions listener/src/api/events-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { preferenceStore } from '../store/preference-store';
import { PreferencesUpdateInput } from '../types/preferences';
import logger from '../utils/logger';
import { generateRequestId } from '../utils/request-id';
import {
verifySignature,
extractSignature,
extractKeyId,
getSecretForKey,
collectRawBody,
} from '../services/webhook-verifier';
import { WebhookSecret } from '../types';
import { RateLimitConfig } from '../types';
import { RateLimiter } from './rate-limiter';

Expand All @@ -13,6 +21,7 @@ export interface EventsServerOptions {
corsOrigin?: string;
stellarRpcUrl: string;
discordWebhookUrl?: string;
webhookSecrets?: WebhookSecret[];
notificationAPI?: NotificationAPI | null;
rateLimit?: RateLimitConfig;
}
Expand Down Expand Up @@ -147,6 +156,119 @@ export function createEventsServer(options: EventsServerOptions): http.Server {
? eventRegistry.getEvents(limit)
: eventRegistry.getEvents();

logger.info('Handling GET /api/events', {
requestId,
limit: limit ?? 'all',
});

const events =
limit !== undefined && !Number.isNaN(limit)
? eventRegistry.getEvents(limit)
: eventRegistry.getEvents();

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
count: eventRegistry.count(),
events,
})
);

logger.info('GET /api/events complete', {
requestId,
returned: events.length,
durationMs: Date.now() - startTime,
});
return;
}

// Schedule notification endpoint
if (req.method === 'POST' && req.url === '/api/schedule') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
return;
}

let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});

if (req.method === 'POST' && req.url === '/api/webhooks') {
collectRawBody(req).then((rawBody) => {
const signatureHeader = extractSignature(req.headers);
const keyId = extractKeyId(req.headers);

if (!signatureHeader) {
logger.warn('Webhook missing signature header', { requestId });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing signature header' }));
return;
}

if (!keyId) {
logger.warn('Webhook missing key-id header', { requestId });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing key-id header' }));
return;
}

const secrets = options.webhookSecrets ?? [];
const secret = getSecretForKey(secrets, keyId);

if (!secret) {
logger.warn('Webhook unknown key-id', { requestId, keyId });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unknown key-id' }));
return;
}

const isValid = verifySignature(rawBody, signatureHeader, secret);

if (!isValid) {
logger.warn('Webhook invalid signature', { requestId, keyId });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid signature' }));
return;
}

logger.info('Webhook received and verified', { requestId, keyId });

res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'accepted' }));
}).catch((err) => {
logger.error('Failed to read webhook body', { requestId, error: err });
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read request body' }));
});
// Schedule notification endpoint
if (req.method === 'POST' && req.url === '/api/schedule') {
if (!options.notificationAPI) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Scheduler not enabled' }));
req.on('end', async () => {
try {
const data = JSON.parse(body);

// Validate required fields
if (!data.executeAt || !data.payload || !data.targetRecipient) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing required fields: executeAt, payload, targetRecipient' }));
return;
}

const notificationId = await options.notificationAPI!.scheduleNotification({
payload: data.payload,
notificationType: data.notificationType || NotificationType.DISCORD,
targetRecipient: data.targetRecipient,
executeAt: new Date(data.executeAt),
maxRetries: data.maxRetries,
priority: data.priority,
eventId: data.eventId,
contractAddress: data.contractAddress,
metadata: data.metadata,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ count: eventRegistry.count(), events }));
return;
Expand Down
45 changes: 45 additions & 0 deletions listener/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,49 @@ describe('Config validation', () => {
deduplicationMaxSize: 250,
});
});

describe('WEBHOOK_SECRETS', () => {
it('defaults to an empty array when not set', () => {
delete process.env.WEBHOOK_SECRETS;
const config = loadConfig();
expect(config.webhookSecrets).toEqual([]);
});

it('parses valid webhook secrets', () => {
process.env.WEBHOOK_SECRETS = JSON.stringify([
{ id: 'key-1', secret: 'whsec_abc' },
{ id: 'key-2', secret: 'whsec_def' },
]);

const config = loadConfig();
expect(config.webhookSecrets).toEqual([
{ id: 'key-1', secret: 'whsec_abc' },
{ id: 'key-2', secret: 'whsec_def' },
]);
});

it('throws ConfigError for invalid JSON', () => {
process.env.WEBHOOK_SECRETS = 'not-json';
expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS must be valid JSON');
});

it('throws ConfigError when item is missing id', () => {
process.env.WEBHOOK_SECRETS = JSON.stringify([{ secret: 'whsec_abc' }]);
expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS[0].id must be a non-empty string');
});

it('throws ConfigError when item is missing secret', () => {
process.env.WEBHOOK_SECRETS = JSON.stringify([{ id: 'key-1' }]);
expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS[0].secret must be a non-empty string');
});

it('throws ConfigError when value is not an array', () => {
process.env.WEBHOOK_SECRETS = '"string-value"';
expect(() => loadConfig()).toThrow(ConfigError);
expect(() => loadConfig()).toThrow('WEBHOOK_SECRETS must be a JSON array');
});
});
});
Loading