Skip to content
Closed
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
80 changes: 80 additions & 0 deletions src/routes/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,84 @@ describe('Webhook management routes — Authentication & Authorization (Issue #1
assert.equal(res.body.developerId, 'dev-bob');
});
});

describe('Multiple webhook endpoints per developer (Issue #1241)', () => {
it('registers two different URLs with independent secrets/retry policies, supports legacy GET, and deletes one without affecting the other', async () => {
const reg1 = await request(app)
.post('/api/webhooks')
.set(authHeader('dev-multi-route'))
.send({
developerId: 'dev-multi-route',
url: 'https://example.com/finance-hook',
events: ['settlement_completed', 'new_api_call'],
secret: 'finance-secret-key-at-least-32-chars-long',
retryPolicy: { maxRetries: 5, baseDelayMs: 2000 },
});
assert.equal(reg1.status, 201);
const id1 = reg1.body.id;
assert.ok(id1);

const reg2 = await request(app)
.post('/api/webhooks')
.set(authHeader('dev-multi-route'))
.send({
developerId: 'dev-multi-route',
url: 'https://example.com/monitoring-hook',
events: ['new_api_call', 'low_balance_alert'],
secret: 'monitoring-secret-key-at-least-32-chars',
retryPolicy: { maxRetries: 2, baseDelayMs: 500 },
});
assert.equal(reg2.status, 201);
const id2 = reg2.body.id;
assert.ok(id2);
assert.notEqual(id1, id2);

// Both registrations receive matching events via getByEvent
const matching = WebhookStore.getByEvent('new_api_call').filter(
(c) => c.developerId === 'dev-multi-route',
);
assert.equal(matching.length, 2);

// Legacy GET /api/webhooks/:developerId returns the first subscription
const legacyGet = await request(app)
.get('/api/webhooks/dev-multi-route')
.set(authHeader('dev-multi-route'));
assert.equal(legacyGet.status, 200);
assert.equal(legacyGet.body.id, id1);
assert.equal(legacyGet.body.url, 'https://example.com/finance-hook');

// Subscription-specific GET and PATCH /api/webhooks/:developerId/subscriptions/:id
const sub2Get = await request(app)
.get(`/api/webhooks/dev-multi-route/subscriptions/${id2}`)
.set(authHeader('dev-multi-route'));
assert.equal(sub2Get.status, 200);
assert.equal(sub2Get.body.url, 'https://example.com/monitoring-hook');
assert.deepEqual(sub2Get.body.retryPolicy, { maxRetries: 2, baseDelayMs: 500 });

const sub2Patch = await request(app)
.patch(`/api/webhooks/dev-multi-route/subscriptions/${id2}/retry-policy`)
.set(authHeader('dev-multi-route'))
.send({ retryPolicy: { maxRetries: 4, baseDelayMs: 1500 } });
assert.equal(sub2Patch.status, 200);
assert.deepEqual(sub2Patch.body.retryPolicy, { maxRetries: 4, baseDelayMs: 1500 });
// First subscription retryPolicy is untouched
assert.deepEqual(WebhookStore.get('dev-multi-route', id1)?.retryPolicy, {
maxRetries: 5,
baseDelayMs: 2000,
});

// Deleting one subscription leaves the other intact
const del1 = await request(app)
.delete(`/api/webhooks/dev-multi-route/subscriptions/${id1}`)
.set(authHeader('dev-multi-route'));
assert.equal(del1.status, 200);

const listRes = await request(app)
.get('/api/webhooks/dev-multi-route/subscriptions')
.set(authHeader('dev-multi-route'));
assert.equal(listRes.status, 200);
assert.equal(listRes.body.subscriptions.length, 1);
assert.equal(listRes.body.subscriptions[0].id, id2);
});
});
});
245 changes: 222 additions & 23 deletions src/routes/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,15 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res:
throw new AppError('URL validation failed.', 500, 'WEBHOOK_URL_VALIDATION_FAILED');
}

const before = WebhookStore.get(developerId) ? sanitizeConfig(WebhookStore.get(developerId) as unknown as Record<string, unknown>) : null;

WebhookStore.register({
const existingForUrl = req.body?.id
? WebhookStore.get(developerId, String(req.body.id))
: WebhookStore.getByUrl(developerId, url);
const before = existingForUrl
? sanitizeConfig(existingForUrl as unknown as Record<string, unknown>)
: null;

const registered = WebhookStore.register({
id: typeof req.body?.id === 'string' ? req.body.id : undefined,
developerId,
url,
events: events as WebhookEventType[],
Expand All @@ -230,11 +236,13 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res:
createdAt: new Date(),
});

const after = sanitizeConfig(WebhookStore.get(developerId) as unknown as Record<string, unknown>);
const after = sanitizeConfig(registered as unknown as Record<string, unknown>);

await auditStateChange(req, res, 'WEBHOOK_REGISTERED', before, after);

res.status(201).json({
id: registered.id,
subscriptionId: registered.id,
message: 'Webhook registered successfully.',
developerId,
url,
Expand All @@ -245,7 +253,205 @@ router.post('/', webhookMgmtRateLimit, express.json(), async (req: Request, res:
}
});

// GET /api/webhooks/:developerId — Get webhook config
function toSafeWebhookConfig(config: Record<string, unknown> & { secret?: unknown; secret_current?: unknown; secret_previous?: unknown }) {
const {
secret: _s,
secret_current: _sc,
secret_previous: _sp,
...safeConfig
} = config;
return safeConfig;
}

// GET /api/webhooks/:developerId/subscriptions — List all webhook subscriptions for a developer
router.get('/:developerId/subscriptions', webhookMgmtRateLimit, (req: Request, res: Response, next: NextFunction) => {
try {
const { developerId } = req.params;
if (!isAuthorizedForDeveloper(res, developerId)) {
throw new ForbiddenError(
'Cannot view webhook for another developer.',
'FORBIDDEN'
);
}

const subscriptions = WebhookStore.listByDeveloper(developerId).map((cfg) =>
toSafeWebhookConfig(cfg as unknown as Record<string, unknown>),
);
return res.json({ developerId, subscriptions });
} catch (error) {
next(error);
}
});

// GET /api/webhooks/:developerId/subscriptions/:id — Get a specific webhook subscription
router.get('/:developerId/subscriptions/:id', webhookMgmtRateLimit, (req: Request, res: Response, next: NextFunction) => {
try {
const { developerId, id } = req.params;
if (!isAuthorizedForDeveloper(res, developerId)) {
throw new ForbiddenError(
'Cannot view webhook for another developer.',
'FORBIDDEN'
);
}

const subscription = WebhookStore.get(developerId, id);
if (!subscription) {
throw new NotFoundError(
'No webhook subscription found with this id.',
'WEBHOOK_NOT_FOUND'
);
}

return res.json(toSafeWebhookConfig(subscription as unknown as Record<string, unknown>));
} catch (error) {
next(error);
}
});

// POST /api/webhooks/:developerId/subscriptions/:id/rotate-secret — Rotate secret for specific subscription
router.post('/:developerId/subscriptions/:id/rotate-secret', webhookMgmtRateLimit, (req: Request, res: Response, next: NextFunction) => {
try {
const { developerId, id } = req.params;
if (!isAuthorizedForDeveloper(res, developerId)) {
throw new ForbiddenError(
'Cannot rotate secret for another developer.',
'FORBIDDEN'
);
}

const existing = WebhookStore.get(developerId, id);
if (!existing) {
throw new NotFoundError(
'No webhook subscription found with this id.',
'WEBHOOK_NOT_FOUND'
);
}

const before = sanitizeConfig(existing as unknown as Record<string, unknown>);
const newSecret = generateWebhookSecret();
const previousExpiresAt = new Date(Date.now() + config.webhooks.secretRotationGraceMs);
const rotated = WebhookStore.rotateSecret(developerId, newSecret, previousExpiresAt, id);

if (!rotated) {
throw new NotFoundError(
'No webhook subscription found with this id.',
'WEBHOOK_NOT_FOUND'
);
}

const after = sanitizeConfig(rotated as unknown as Record<string, unknown>);
const actor = getAuthenticatedActor(res) ?? developerId;

logger.audit('WEBHOOK_SECRET_ROTATED', actor, {
developerId,
subscriptionId: id,
actor,
previousExpiresAt: rotated.previous_expires_at?.toISOString(),
hadPreviousSecret: Boolean(existing.secret_current ?? existing.secret),
});

void auditStateChange(req, res, 'WEBHOOK_SECRET_ROTATED', before, after);

return res.status(200).json({
message: 'Webhook secret rotated successfully.',
id: rotated.id,
developerId,
secret: newSecret,
previous_expires_at: rotated.previous_expires_at?.toISOString(),
});
} catch (error) {
next(error);
}
});

// PATCH /api/webhooks/:developerId/subscriptions/:id/retry-policy — Update retry policy for specific subscription
router.patch('/:developerId/subscriptions/:id/retry-policy', webhookMgmtRateLimit, express.json(), (req: Request, res: Response, next: NextFunction) => {
try {
const { developerId, id } = req.params;
if (!isAuthorizedForDeveloper(res, developerId)) {
throw new ForbiddenError(
'Cannot update retry policy for another developer.',
'FORBIDDEN'
);
}

const { retryPolicy } = req.body;
const validation = validateRetryPolicy(retryPolicy);
if (!validation.valid) {
throw new BadRequestError(
validation.error!,
'INVALID_RETRY_POLICY'
);
}

const existing = WebhookStore.get(developerId, id);
const before = existing ? { retryPolicy: (existing as unknown as Record<string, unknown>).retryPolicy } : null;

const updated = WebhookStore.updateRetryPolicy(
developerId,
retryPolicy as RetryPolicy | undefined,
id,
);

if (!updated) {
throw new NotFoundError(
'No webhook subscription found with this id.',
'WEBHOOK_NOT_FOUND'
);
}

const after = { retryPolicy: updated.retryPolicy };
const actor = getAuthenticatedActor(res) ?? developerId;

logger.audit('WEBHOOK_RETRY_POLICY_UPDATED', actor, {
developerId,
subscriptionId: id,
actor,
retryPolicy: updated.retryPolicy,
});

void auditStateChange(req, res, 'WEBHOOK_RETRY_POLICY_UPDATED', before, after);

return res.status(200).json({
message: 'Webhook retry policy updated successfully.',
...toSafeWebhookConfig(updated as unknown as Record<string, unknown>),
});
} catch (error) {
next(error);
}
});

// DELETE /api/webhooks/:developerId/subscriptions/:id — Remove a specific subscription
router.delete('/:developerId/subscriptions/:id', webhookMgmtRateLimit, async (req: Request, res: Response, next: NextFunction) => {
try {
const { developerId, id } = req.params;
if (!isAuthorizedForDeveloper(res, developerId)) {
throw new ForbiddenError(
'Cannot delete webhook for another developer.',
'FORBIDDEN'
);
}

const existing = WebhookStore.get(developerId, id);
if (!existing) {
throw new NotFoundError(
'No webhook subscription found with this id.',
'WEBHOOK_NOT_FOUND'
);
}

const before = sanitizeConfig(existing as unknown as Record<string, unknown>);
WebhookStore.delete(developerId, id);

await auditStateChange(req, res, 'WEBHOOK_DELETED', before, null);

return res.json({ message: 'Webhook subscription removed.', id });
} catch (error) {
next(error);
}
});

// GET /api/webhooks/:developerId — Legacy alias for the first webhook subscription
router.get('/:developerId', webhookMgmtRateLimit, (req: Request, res: Response, next: NextFunction) => {
try {
const { developerId } = req.params;
Expand All @@ -263,13 +469,7 @@ router.get('/:developerId', webhookMgmtRateLimit, (req: Request, res: Response,
'WEBHOOK_NOT_FOUND'
);
}
const {
secret: _s,
secret_current: _sc,
secret_previous: _sp,
...safeConfig
} = config;
return res.json(safeConfig);
return res.json(toSafeWebhookConfig(config as unknown as Record<string, unknown>));
} catch (error) {
next(error);
}
Expand Down Expand Up @@ -401,16 +601,9 @@ router.patch('/:developerId/retry-policy', webhookMgmtRateLimit, express.json(),

void auditStateChange(req, res, 'WEBHOOK_RETRY_POLICY_UPDATED', before, after);

const {
secret: _s,
secret_current: _sc,
secret_previous: _sp,
...safeConfig
} = updated;

return res.status(200).json({
message: 'Webhook retry policy updated successfully.',
...safeConfig,
...toSafeWebhookConfig(updated as unknown as Record<string, unknown>),
});
} catch (error) {
next(error);
Expand All @@ -421,15 +614,21 @@ router.post(
'/deliver/:developerId',
captureRawBody,
(req: Request & { webhookSecrets?: string[] }, res: Response, next) => {
const config = WebhookStore.get(req.params.developerId);
if (!config) {
const subscriptions = WebhookStore.listByDeveloper(req.params.developerId);
if (subscriptions.length === 0) {
next(new NotFoundError(
'No webhook registered for this developer.',
'WEBHOOK_NOT_FOUND'
));
return;
}
req.webhookSecrets = WebhookStore.getActiveSecrets(config);
const activeSecrets = new Set<string>();
for (const sub of subscriptions) {
for (const secret of WebhookStore.getActiveSecrets(sub)) {
activeSecrets.add(secret);
}
}
req.webhookSecrets = [...activeSecrets];
next();
},
verifyWebhookSignature,
Expand Down
Loading