From 8193ac80bf0cd664b664bcb1eb207659ed4402fd Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 Date: Sun, 28 Jun 2026 02:05:00 +0100 Subject: [PATCH] feat: webhook delivery monitor endpoint --- docs/openapi.json | 93 +++-- package-lock.json | 53 ++- src/errors/codes.ts | 5 +- src/repositories/apiRepository.drizzle.ts | 4 +- src/routes/admin.ts | 11 +- src/routes/admin/webhooks.test.ts | 449 ++++++++++++++++++++++ src/routes/admin/webhooks.ts | 121 ++++++ src/services/webhookMonitor.ts | 49 +++ src/webhooks/webhook.dispatcher.ts | 16 + src/webhooks/webhook.store.ts | 72 ++++ 10 files changed, 833 insertions(+), 40 deletions(-) create mode 100644 src/routes/admin/webhooks.test.ts create mode 100644 src/routes/admin/webhooks.ts create mode 100644 src/services/webhookMonitor.ts diff --git a/docs/openapi.json b/docs/openapi.json index c24d389..c130e7a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -109,18 +109,28 @@ } } }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "responses": { "200": { - ... + "description": "Success" }, "400": { - ... + "description": "Bad Request" }, "401": { - ... + "description": "Unauthorized" }, "402": { - ... + "description": "Payment Required" }, "409": { "description": "Idempotency conflict", @@ -143,16 +153,6 @@ } }, "500": {} - }, - "500": { - "description": "Internal server error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } } } } @@ -358,14 +358,35 @@ "items": { "type": "object", "properties": { - "apiId": { "type": "string" }, - "day": { "type": "string", "example": "2026-03-21" }, - "type": { "type": "string", "enum": ["spike", "drop"] }, - "calls": { "type": "integer" }, - "revenue": { "type": "string" }, - "baselineMean": { "type": "number" }, - "stdDev": { "type": "number" }, - "zScore": { "type": "number" } + "apiId": { + "type": "string" + }, + "day": { + "type": "string", + "example": "2026-03-21" + }, + "type": { + "type": "string", + "enum": [ + "spike", + "drop" + ] + }, + "calls": { + "type": "integer" + }, + "revenue": { + "type": "string" + }, + "baselineMean": { + "type": "number" + }, + "stdDev": { + "type": "number" + }, + "zScore": { + "type": "number" + } } } }, @@ -375,14 +396,28 @@ "window": { "type": "object", "properties": { - "from": { "type": "string", "format": "date-time" }, - "to": { "type": "string", "format": "date-time" } + "from": { + "type": "string", + "format": "date-time" + }, + "to": { + "type": "string", + "format": "date-time" + } } }, - "threshold": { "type": "number" }, - "minDataPoints": { "type": "integer" }, - "seriesAnalyzed": { "type": "integer" }, - "anomalyCount": { "type": "integer" } + "threshold": { + "type": "number" + }, + "minDataPoints": { + "type": "integer" + }, + "seriesAnalyzed": { + "type": "integer" + }, + "anomalyCount": { + "type": "integer" + } } } } @@ -1812,4 +1847,4 @@ } } } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index c3767fc..62504e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4275,7 +4275,7 @@ "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -4476,7 +4476,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -4498,6 +4498,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -5892,6 +5902,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, "node_modules/d": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", @@ -13037,6 +13054,29 @@ "destr": "^2.0.3" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -13224,6 +13264,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -14629,7 +14676,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/src/errors/codes.ts b/src/errors/codes.ts index 7a84f1d..fbbeb6d 100644 --- a/src/errors/codes.ts +++ b/src/errors/codes.ts @@ -244,7 +244,10 @@ export const ErrorCode = { UNSUPPORTED_MEDIA_TYPE: "UNSUPPORTED_MEDIA_TYPE", /** Request is syntactically correct but semantically invalid */ - UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY" + UNPROCESSABLE_ENTITY: "UNPROCESSABLE_ENTITY", + + /** Usage aggregate not found for the given developer */ + USAGE_AGGREGATE_NOT_FOUND: "USAGE_AGGREGATE_NOT_FOUND" } as const; diff --git a/src/repositories/apiRepository.drizzle.ts b/src/repositories/apiRepository.drizzle.ts index 21993af..7d6a9df 100644 --- a/src/repositories/apiRepository.drizzle.ts +++ b/src/repositories/apiRepository.drizzle.ts @@ -117,7 +117,7 @@ export class DrizzleApiRepository implements ApiRepository { // better-sqlite3's RunResult exposes the affected row count on `changes`. // The database FK with ON DELETE CASCADE will automatically clean up endpoints. - return deleted.changes > 0; + return result.changes > 0; } async listByDeveloper( @@ -232,7 +232,7 @@ export class DrizzleApiRepository implements ApiRepository { .from(schema.apiEndpoints) .where(eq(schema.apiEndpoints.api_id, apiId)); - return rows.map((r) => ({ + return rows.map((r: ApiEndpointInfo) => ({ path: r.path, method: r.method, price_per_call_usdc: r.price_per_call_usdc, diff --git a/src/routes/admin.ts b/src/routes/admin.ts index e65604d..a692bb9 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,4 +1,4 @@ -import { Router } from 'express'; +import { Router, type Response } from 'express'; import { adminAuth } from '../middleware/adminAuth.js'; import { createAdminIpAllowlist } from '../middleware/ipAllowlist.js'; import { findUsers } from '../repositories/userRepository.js'; @@ -13,7 +13,7 @@ import { approveQuotaRequest, rejectQuotaRequest, } from '../services/quotaService.js'; -import webhookKeysRouter from './admin/webhookKeys.js'; +import { createAdminWebhooksRouter } from './admin/webhooks.js'; import { createAdminApisRouter } from './admin/apis.js'; const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; @@ -60,7 +60,7 @@ router.get('/users', async (req, res, next) => { } }); -router.get('/usage/:developerId', async (req, res, next) => { +router.get('/usage/:developerId', async (req, res: Response, next) => { try { const snapshot = await usageStore.getDeveloperUsageSnapshot(req.params.developerId); if (!snapshot) { @@ -197,11 +197,12 @@ router.post('/quota/requests/:id/reject', async (req, res, next) => { }); // --------------------------------------------------------------------------- -// Webhook signing-key rotation +// Webhook signing-key rotation + delivery monitoring // Mounts: POST /api/admin/webhooks/rotate-key // GET /api/admin/webhooks/grace-window +// GET /api/admin/webhooks/monitor // --------------------------------------------------------------------------- -router.use('/webhooks', webhookKeysRouter); +router.use('/webhooks', createAdminWebhooksRouter()); // --------------------------------------------------------------------------- // API soft-delete and restore diff --git a/src/routes/admin/webhooks.test.ts b/src/routes/admin/webhooks.test.ts new file mode 100644 index 0000000..8a42bc3 --- /dev/null +++ b/src/routes/admin/webhooks.test.ts @@ -0,0 +1,449 @@ +/** + * Tests for GET /api/admin/webhooks/monitor + * + * Coverage: + * - Successful admin access (API key + JWT) + * - Unauthorized access (no credentials, wrong credentials) + * - Last-100 failure limit is enforced + * - Accurate DLQ depth is returned + * - Per-subscription statistics are correct + * - Empty dataset (no failures, no subscriptions, DLQ depth 0) + * - Standardized error response shape + * - Secrets are never exposed in the response + */ + +jest.mock('better-sqlite3', () => { + return class MockDatabase { + prepare() { return { get: () => null }; } + exec() {} + close() {} + }; +}); + +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { errorHandler } from '../../middleware/errorHandler.js'; +import { WebhookStore } from '../../webhooks/webhook.store.js'; +import { createAdminWebhooksRouter } from './webhooks.js'; +import type { FailedDeliveryEntry } from '../../webhooks/webhook.store.js'; +import type { DeadLetterEntry } from '../../webhooks/webhook.types.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const ADMIN_KEY = 'test-monitor-admin-key'; +const JWT_SECRET = 'test-monitor-jwt-secret'; + +// --------------------------------------------------------------------------- +// App factory +// --------------------------------------------------------------------------- + +function buildApp() { + const app = express(); + app.use(express.json()); + + // Simulate the two adminAuth paths used by the real middleware: + // 1. x-admin-api-key header + // 2. Bearer JWT with role=admin + // Unauthorised requests fall through to a 401 without setting adminActor. + app.use((req, res, next) => { + const apiKey = req.headers['x-admin-api-key']; + if (apiKey === ADMIN_KEY) { + res.locals.adminActor = 'admin-api-key'; + return next(); + } + + const auth = req.headers['authorization']; + if (auth?.startsWith('Bearer ')) { + try { + const payload = jwt.verify(auth.slice(7), JWT_SECRET) as { role?: string }; + if (payload.role === 'admin') { + res.locals.adminActor = 'admin-jwt'; + return next(); + } + } catch { + // fall through + } + } + + res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'Unauthorized: admin access required', + requestId: 'test', + }); + }); + + app.use('/api/admin/webhooks', createAdminWebhooksRouter()); + app.use(errorHandler); + return app; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFailure(overrides: Partial = {}): FailedDeliveryEntry { + return { + deliveryId: `d-${Math.random().toString(36).slice(2)}`, + developerId: 'dev-1', + event: 'new_api_call', + url: 'https://example.com/hook', + failedAt: new Date().toISOString(), + lastError: 'HTTP 503 Service Unavailable', + attempts: 5, + ...overrides, + }; +} + +function makeDlqEntry(overrides: Partial = {}): DeadLetterEntry { + return { + deliveryId: `dlq-${Math.random().toString(36).slice(2)}`, + config: { + developerId: 'dev-1', + url: 'https://example.com/hook', + events: ['new_api_call'], + createdAt: new Date(), + }, + payload: { + event: 'new_api_call', + timestamp: new Date().toISOString(), + developerId: 'dev-1', + data: {}, + }, + failedAt: new Date().toISOString(), + lastError: 'timeout', + attempts: 5, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Test setup / teardown +// --------------------------------------------------------------------------- + +let app: ReturnType; + +beforeEach(() => { + process.env.ADMIN_API_KEY = ADMIN_KEY; + process.env.JWT_SECRET = JWT_SECRET; + WebhookStore.clear(); + WebhookStore.clearDlq(); + WebhookStore.clearFailedDeliveries(); + app = buildApp(); +}); + +afterEach(() => { + delete process.env.ADMIN_API_KEY; + delete process.env.JWT_SECRET; + jest.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Authorization +// --------------------------------------------------------------------------- + +describe('GET /api/admin/webhooks/monitor — authorization', () => { + it('returns 200 with a valid admin API key', async () => { + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + }); + + it('returns 200 with a valid admin JWT', async () => { + const token = jwt.sign({ role: 'admin', sub: 'admin-user' }, JWT_SECRET, { expiresIn: '1h' }); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.data).toBeDefined(); + }); + + it('returns 401 with no credentials', async () => { + const res = await request(app).get('/api/admin/webhooks/monitor'); + + expect(res.status).toBe(401); + expect(res.body.code).toBe('UNAUTHORIZED'); + }); + + it('returns 401 with a wrong API key', async () => { + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', 'definitely-wrong'); + + expect(res.status).toBe(401); + }); + + it('returns 401 with a non-admin JWT role', async () => { + const token = jwt.sign({ role: 'developer', sub: 'user-1' }, JWT_SECRET, { expiresIn: '1h' }); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(401); + }); +}); + +// --------------------------------------------------------------------------- +// Empty dataset +// --------------------------------------------------------------------------- + +describe('GET /api/admin/webhooks/monitor — empty dataset', () => { + it('returns well-formed empty snapshot when nothing is registered', async () => { + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + const { data } = res.body; + + expect(data.failedDeliveries).toEqual([]); + expect(data.dlqDepth).toBe(0); + expect(data.subscriptions).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Failed deliveries +// --------------------------------------------------------------------------- + +describe('GET /api/admin/webhooks/monitor — failed deliveries', () => { + it('returns recorded failures, most-recent first', async () => { + const older = makeFailure({ developerId: 'dev-1', failedAt: '2026-06-01T10:00:00.000Z' }); + const newer = makeFailure({ developerId: 'dev-2', failedAt: '2026-06-01T11:00:00.000Z' }); + WebhookStore.recordFailedDelivery(older); + WebhookStore.recordFailedDelivery(newer); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + const { failedDeliveries } = res.body.data; + + // Newest first + expect(failedDeliveries[0].developerId).toBe('dev-2'); + expect(failedDeliveries[1].developerId).toBe('dev-1'); + }); + + it('caps the returned list at 100 even when more failures exist', async () => { + // Record 150 failures + for (let i = 0; i < 150; i++) { + WebhookStore.recordFailedDelivery(makeFailure({ developerId: `dev-${i}` })); + } + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.data.failedDeliveries.length).toBeLessThanOrEqual(100); + }); + + it('includes the expected operational fields on each failure entry', async () => { + const failure = makeFailure({ + deliveryId: 'uuid-001', + developerId: 'dev-abc', + event: 'settlement_completed', + url: 'https://hooks.example.com/recv', + failedAt: '2026-06-28T00:00:00.000Z', + lastError: 'HTTP 500 Internal Server Error', + attempts: 5, + }); + WebhookStore.recordFailedDelivery(failure); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + const entry = res.body.data.failedDeliveries[0]; + expect(entry.deliveryId).toBe('uuid-001'); + expect(entry.developerId).toBe('dev-abc'); + expect(entry.event).toBe('settlement_completed'); + expect(entry.url).toBe('https://hooks.example.com/recv'); + expect(entry.failedAt).toBe('2026-06-28T00:00:00.000Z'); + expect(entry.lastError).toBe('HTTP 500 Internal Server Error'); + expect(entry.attempts).toBe(5); + }); + + it('does not expose raw payload data in failure entries', async () => { + WebhookStore.recordFailedDelivery(makeFailure()); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + const entry = res.body.data.failedDeliveries[0]; + // FailedDeliveryEntry must not contain a 'payload' or 'config' field + expect(entry).not.toHaveProperty('payload'); + expect(entry).not.toHaveProperty('config'); + expect(entry).not.toHaveProperty('secret'); + }); +}); + +// --------------------------------------------------------------------------- +// DLQ depth +// --------------------------------------------------------------------------- + +describe('GET /api/admin/webhooks/monitor — DLQ depth', () => { + it('reports dlqDepth 0 when the DLQ is empty', async () => { + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.body.data.dlqDepth).toBe(0); + }); + + it('reflects the accurate DLQ depth at request time', async () => { + WebhookStore.addToDlq(makeDlqEntry({ deliveryId: 'dlq-1' })); + WebhookStore.addToDlq(makeDlqEntry({ deliveryId: 'dlq-2' })); + WebhookStore.addToDlq(makeDlqEntry({ deliveryId: 'dlq-3' })); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.body.data.dlqDepth).toBe(3); + }); + + it('updates immediately after DLQ changes without background recomputation', async () => { + WebhookStore.addToDlq(makeDlqEntry({ deliveryId: 'live-1' })); + + const first = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(first.body.data.dlqDepth).toBe(1); + + WebhookStore.addToDlq(makeDlqEntry({ deliveryId: 'live-2' })); + + const second = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(second.body.data.dlqDepth).toBe(2); + }); +}); + +// --------------------------------------------------------------------------- +// Per-subscription statistics +// --------------------------------------------------------------------------- + +describe('GET /api/admin/webhooks/monitor — subscriptions', () => { + it('returns an empty subscriptions array when no webhooks are registered', async () => { + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.body.data.subscriptions).toEqual([]); + }); + + it('returns one entry per registered subscription with operational fields', async () => { + const createdAt = new Date('2026-06-01T00:00:00.000Z'); + WebhookStore.register({ + developerId: 'sub-dev-1', + url: 'https://recv.example.com/a', + events: ['new_api_call', 'settlement_completed'], + createdAt, + }); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + const { subscriptions } = res.body.data; + expect(subscriptions).toHaveLength(1); + + const sub = subscriptions[0]; + expect(sub.developerId).toBe('sub-dev-1'); + expect(sub.url).toBe('https://recv.example.com/a'); + expect(sub.events).toEqual(['new_api_call', 'settlement_completed']); + expect(sub.registeredAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('returns one entry per developer when multiple subscriptions are registered', async () => { + WebhookStore.register({ + developerId: 'sub-dev-a', + url: 'https://a.example.com/hook', + events: ['new_api_call'], + createdAt: new Date(), + }); + WebhookStore.register({ + developerId: 'sub-dev-b', + url: 'https://b.example.com/hook', + events: ['settlement_completed'], + createdAt: new Date(), + }); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + const devIds = res.body.data.subscriptions.map((s: { developerId: string }) => s.developerId); + expect(devIds).toContain('sub-dev-a'); + expect(devIds).toContain('sub-dev-b'); + }); + + it('does not expose signing secrets in subscription stats', async () => { + WebhookStore.register({ + developerId: 'secret-dev', + url: 'https://example.com/hook', + events: ['new_api_call'], + secret_current: 'super-secret-value', + createdAt: new Date(), + }); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + const sub = res.body.data.subscriptions[0]; + expect(sub).not.toHaveProperty('secret'); + expect(sub).not.toHaveProperty('secret_current'); + expect(sub).not.toHaveProperty('secret_previous'); + // Values should not appear anywhere in the response body + expect(JSON.stringify(res.body)).not.toContain('super-secret-value'); + }); +}); + +// --------------------------------------------------------------------------- +// Response shape (standardized envelope) +// --------------------------------------------------------------------------- + +describe('GET /api/admin/webhooks/monitor — response shape', () => { + it('wraps the snapshot in a { data } envelope', async () => { + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('data'); + expect(res.body.data).toHaveProperty('failedDeliveries'); + expect(res.body.data).toHaveProperty('dlqDepth'); + expect(res.body.data).toHaveProperty('subscriptions'); + }); + + it('returns a standardized error envelope on internal error', async () => { + // Force an error by making getWebhookMonitorSnapshot throw + jest.spyOn( + await import('../../services/webhookMonitor.js'), + 'getWebhookMonitorSnapshot', + ).mockImplementation(() => { throw new Error('boom'); }); + + const res = await request(app) + .get('/api/admin/webhooks/monitor') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(500); + expect(res.body).toHaveProperty('code'); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('requestId'); + }); +}); diff --git a/src/routes/admin/webhooks.ts b/src/routes/admin/webhooks.ts new file mode 100644 index 0000000..b73aab3 --- /dev/null +++ b/src/routes/admin/webhooks.ts @@ -0,0 +1,121 @@ +/** + * src/routes/admin/webhooks.ts + * + * Composes all admin webhook routes under one router: + * POST /api/admin/webhooks/rotate-key (from webhookKeys) + * GET /api/admin/webhooks/grace-window (from webhookKeys) + * GET /api/admin/webhooks/monitor ← new + * + * Authentication: adminAuth middleware applied at the parent admin router. + * IP allowlist: createAdminIpAllowlist() applied at the parent admin router. + * + * Mount in admin.ts: + * adminRouter.use('/webhooks', createAdminWebhooksRouter()); + */ + +import { Router, type Response } from 'express'; +import { getClientIp } from '../../lib/clientIp.js'; +import { AppError, InternalServerError } from '../../errors/index.js'; +import { logger } from '../../logger.js'; +import { getWebhookMonitorSnapshot } from '../../services/webhookMonitor.js'; +import { createWebhookKeysRouter } from './webhookKeys.js'; + +export { createWebhookKeysRouter } from './webhookKeys.js'; + +const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; + +/** + * Factory that returns the composite admin webhook router. + * + * Accepts optional deps forwarded to the key-rotation sub-router so tests can + * inject stubs without touching the module-level singleton. + */ +export function createAdminWebhooksRouter( + keysRouterDeps?: Parameters[0], +): Router { + const router = Router(); + + // Mount the existing key-rotation routes unchanged (rotate-key, grace-window). + router.use('/', createWebhookKeysRouter(keysRouterDeps)); + + // ── GET /monitor ────────────────────────────────────────────────────────── + /** + * @openapi + * /api/admin/webhooks/monitor: + * get: + * summary: Webhook delivery monitoring snapshot + * description: | + * Returns the last 100 failed webhook deliveries (most-recent first), + * the current Dead-Letter Queue depth, and per-subscription statistics. + * + * Only operational metadata is returned; raw payloads and signing + * secrets are never exposed. + * security: + * - AdminApiKey: [] + * - AdminJWT: [] + * responses: + * '200': + * description: Monitoring snapshot. + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * properties: + * failedDeliveries: + * type: array + * maxItems: 100 + * items: + * type: object + * properties: + * deliveryId: { type: string, format: uuid } + * developerId: { type: string } + * event: { type: string } + * url: { type: string, format: uri } + * failedAt: { type: string, format: date-time } + * lastError: { type: string } + * attempts: { type: integer } + * dlqDepth: + * type: integer + * description: Current number of entries in the Dead-Letter Queue. + * subscriptions: + * type: array + * items: + * type: object + * properties: + * developerId: { type: string } + * url: { type: string, format: uri } + * events: { type: array, items: { type: string } } + * registeredAt: { type: string, format: date-time } + * '401': { $ref: '#/components/responses/Unauthorized' } + * '500': { $ref: '#/components/responses/InternalServerError' } + */ + router.get('/monitor', (req, res: Response, next) => { + try { + const snapshot = getWebhookMonitorSnapshot(); + + logger.info('[admin] webhook monitor snapshot requested', { + actor: res.locals.adminActor, + clientIp: getClientIp(req, TRUST_PROXY), + failedDeliveryCount: snapshot.failedDeliveries.length, + dlqDepth: snapshot.dlqDepth, + subscriptionCount: snapshot.subscriptions.length, + }); + + return res.status(200).json({ data: snapshot }); + } catch (error) { + if (error instanceof AppError) { + next(error); + return; + } + logger.error('[admin] webhook monitor failed', error); + next(new InternalServerError('Failed to retrieve webhook monitor data')); + } + }); + + return router; +} + +export default createAdminWebhooksRouter(); diff --git a/src/services/webhookMonitor.ts b/src/services/webhookMonitor.ts new file mode 100644 index 0000000..9243028 --- /dev/null +++ b/src/services/webhookMonitor.ts @@ -0,0 +1,49 @@ +/** + * src/services/webhookMonitor.ts + * + * Aggregates operational state from the in-memory WebhookStore for the + * GET /api/admin/webhooks/monitor endpoint. + * + * All data is derived from existing store state — no new storage is introduced. + * Secrets and raw payloads are never included in the output. + */ + +import { WebhookStore, type FailedDeliveryEntry } from '../webhooks/webhook.store.js'; + +/** Operational stats for a single subscription. */ +export interface SubscriptionStats { + developerId: string; + url: string; + events: string[]; + registeredAt: string; // ISO-8601 +} + +export interface WebhookMonitorSnapshot { + /** Last 100 failed deliveries, most-recent first. */ + failedDeliveries: FailedDeliveryEntry[]; + /** Current depth of the Dead-Letter Queue. Accurate at request time. */ + dlqDepth: number; + /** Operational metadata per registered subscription. */ + subscriptions: SubscriptionStats[]; +} + +/** + * Collect a point-in-time monitoring snapshot. + * + * All three data points are read synchronously from in-memory state, so the + * snapshot is self-consistent within a single call. + */ +export function getWebhookMonitorSnapshot(): WebhookMonitorSnapshot { + const failedDeliveries = WebhookStore.getRecentFailures(100); + const dlqDepth = WebhookStore.dlqDepth(); + + // Build per-subscription stats; strip secrets before returning. + const subscriptions: SubscriptionStats[] = WebhookStore.list().map((cfg) => ({ + developerId: cfg.developerId, + url: cfg.url, + events: cfg.events, + registeredAt: cfg.createdAt.toISOString(), + })); + + return { failedDeliveries, dlqDepth, subscriptions }; +} diff --git a/src/webhooks/webhook.dispatcher.ts b/src/webhooks/webhook.dispatcher.ts index 8d73359..2b6816c 100644 --- a/src/webhooks/webhook.dispatcher.ts +++ b/src/webhooks/webhook.dispatcher.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { WebhookConfig, WebhookPayload } from './webhook.types.js'; +import { WebhookStore } from './webhook.store.js'; import { logger } from '../logger.js'; import { getRequestId } from '../utils/asyncContext.js'; @@ -116,10 +117,25 @@ export async function dispatchWebhook( } } + const failedAt = new Date().toISOString(); + const lastErrorMessage = + lastError instanceof Error ? lastError.message : String(lastError); + logger.error( `[webhook] ✗ Failed to deliver ${payload.event} to ${config.url} after ${MAX_RETRIES} attempts.`, lastError ); + + // Persist operational failure metadata (no payload or secrets). + WebhookStore.recordFailedDelivery({ + deliveryId, + developerId: config.developerId, + event: payload.event, + url: config.url, + failedAt, + lastError: lastErrorMessage, + attempts: MAX_RETRIES, + }); })()); } diff --git a/src/webhooks/webhook.store.ts b/src/webhooks/webhook.store.ts index aed2b6e..c967e1b 100644 --- a/src/webhooks/webhook.store.ts +++ b/src/webhooks/webhook.store.ts @@ -3,6 +3,34 @@ import { WebhookConfig, WebhookEventType, DeadLetterEntry } from './webhook.type const store = new Map(); const deadLetterStore = new Map(); +/** + * Lightweight record written by the dispatcher when a delivery exhausts all + * retry attempts. Intentionally omits raw payload/secrets — only operational + * metadata is stored. + */ +export interface FailedDeliveryEntry { + /** Unique ID generated per dispatch call (X-Callora-Delivery header value). */ + deliveryId: string; + /** Subscription owner. */ + developerId: string; + /** Event type that was being delivered. */ + event: string; + /** Target URL. */ + url: string; + /** ISO-8601 timestamp of the final failure. */ + failedAt: string; + /** Last error message (non-sensitive). */ + lastError: string; + /** Total delivery attempts made (always equal to MAX_RETRIES). */ + attempts: number; +} + +/** Ordered list of failed deliveries (most-recent last; reversed on read). */ +const failedDeliveryLog: FailedDeliveryEntry[] = []; + +/** Maximum failed-delivery entries retained in memory. */ +const MAX_FAILED_LOG = 200; // keep 2× the read limit for ring-buffer headroom + function normalizeConfig(config: WebhookConfig): WebhookConfig { const secret_current = config.secret_current ?? config.secret; @@ -78,4 +106,48 @@ export const WebhookStore = { clear(): void { store.clear(); }, + + // ── Dead-Letter Queue (DLQ) ───────────────────────────────────────────── + + /** Add an entry to the DLQ (keyed by deliveryId). */ + addToDlq(entry: DeadLetterEntry): void { + deadLetterStore.set(entry.deliveryId, entry); + }, + + /** Current number of entries in the DLQ. Accurate at call time. */ + dlqDepth(): number { + return deadLetterStore.size; + }, + + /** Clear the DLQ — for testing only. */ + clearDlq(): void { + deadLetterStore.clear(); + }, + + // ── Failed-delivery log ───────────────────────────────────────────────── + + /** + * Record a final delivery failure. Keeps at most MAX_FAILED_LOG entries + * by evicting the oldest entry when the buffer is full. + */ + recordFailedDelivery(entry: FailedDeliveryEntry): void { + if (failedDeliveryLog.length >= MAX_FAILED_LOG) { + failedDeliveryLog.shift(); // drop oldest + } + failedDeliveryLog.push(entry); + }, + + /** + * Return the most-recent `limit` failed-delivery entries, newest first. + * Defaults to 100; hard-capped at 100. + */ + getRecentFailures(limit: number = 100): FailedDeliveryEntry[] { + const cap = Math.min(limit, 100); + return failedDeliveryLog.slice(-cap).reverse(); + }, + + /** Clear the failed-delivery log — for testing only. */ + clearFailedDeliveries(): void { + failedDeliveryLog.splice(0, failedDeliveryLog.length); + }, };