diff --git a/services/platform-service/src/lib/api-key-auth.ts b/services/platform-service/src/lib/api-key-auth.ts index 4c832980..735b576c 100644 --- a/services/platform-service/src/lib/api-key-auth.ts +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -84,6 +84,8 @@ function loadApiKeyRateLimitConfig(): ApiKeyRateLimitConfig { 'maintenance:write': { maxRequests: 10, windowSeconds: 60 }, 'ip-rules:read': { maxRequests: 30, windowSeconds: 60 }, 'ip-rules:write': { maxRequests: 10, windowSeconds: 60 }, + 'webhooks:read': { maxRequests: 60, windowSeconds: 60 }, + 'webhooks:write': { maxRequests: 15, windowSeconds: 60 }, }; const raw = process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; diff --git a/services/platform-service/src/modules/webhooks/routes.ts b/services/platform-service/src/modules/webhooks/routes.ts index ddf722a2..053c7327 100644 --- a/services/platform-service/src/modules/webhooks/routes.ts +++ b/services/platform-service/src/modules/webhooks/routes.ts @@ -1,32 +1,56 @@ import { randomBytes } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; -import { extractAuth } from '../../lib/auth.js'; +import { requireJwtOrApiKey } from '../../lib/api-key-auth.js'; import { BadRequestError, NotFoundError } from '../../lib/errors.js'; import { CreateWebhookSubscriptionSchema, UpdateWebhookSubscriptionSchema } from './types.js'; import * as repo from './repository.js'; import { dispatchEvent } from './dispatcher.js'; -const DEFAULT_PRODUCT_ID = 'lysnrai'; - export async function webhookRoutes(app: FastifyInstance) { + function requireWebhooksRead(req: import('fastify').FastifyRequest): string { + const access = requireJwtOrApiKey(req, { + jwtRoles: ['super_admin', 'admin'], + apiKeyScopes: ['webhooks:read'], + rateLimitKey: 'webhooks:read', + }); + return access.productId; + } + + function requireWebhooksWrite(req: import('fastify').FastifyRequest): { + actorId: string; + productId: string; + } { + const access = requireJwtOrApiKey(req, { + jwtRoles: ['super_admin', 'admin'], + apiKeyScopes: ['webhooks:write'], + rateLimitKey: 'webhooks:write', + }); + return { + actorId: access.actorId, + productId: access.productId, + }; + } + // List webhook subscriptions app.get('/webhooks/subscriptions', async req => { - await extractAuth(req); - const query = req.query as Record; - const productId = query.productId || DEFAULT_PRODUCT_ID; + const productId = requireWebhooksRead(req); return repo.listSubscriptions(productId); }); // Create a webhook subscription app.post('/webhooks/subscriptions', async req => { - const auth = await extractAuth(req); + const access = requireWebhooksWrite(req); const parsed = CreateWebhookSubscriptionSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } const secret = randomBytes(32).toString('hex'); - const sub = await repo.createSubscription(parsed.data, secret, auth.sub); + const sub = await repo.createSubscription( + { ...parsed.data, productId: access.productId }, + secret, + access.actorId + ); // Return secret only on creation (shown once) return { ...sub, secret }; @@ -34,10 +58,8 @@ export async function webhookRoutes(app: FastifyInstance) { // Get a specific subscription app.get('/webhooks/subscriptions/:id', async req => { - await extractAuth(req); + const productId = requireWebhooksRead(req); const { id } = req.params as { id: string }; - const query = req.query as Record; - const productId = query.productId || DEFAULT_PRODUCT_ID; const sub = await repo.getSubscription(id, productId); if (!sub) throw new NotFoundError('Webhook subscription not found'); @@ -48,27 +70,23 @@ export async function webhookRoutes(app: FastifyInstance) { // Update a subscription app.patch('/webhooks/subscriptions/:id', async req => { - await extractAuth(req); + const access = requireWebhooksWrite(req); const { id } = req.params as { id: string }; - const query = req.query as Record; - const productId = query.productId || DEFAULT_PRODUCT_ID; const parsed = UpdateWebhookSubscriptionSchema.safeParse(req.body); if (!parsed.success) { throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); } - const updated = await repo.updateSubscription(id, productId, parsed.data); + const updated = await repo.updateSubscription(id, access.productId, parsed.data); if (!updated) throw new NotFoundError('Webhook subscription not found'); return { ...updated, secret: `${updated.secret.slice(0, 8)}...` }; }); // Delete a subscription app.delete('/webhooks/subscriptions/:id', async req => { - await extractAuth(req); + const productId = requireWebhooksWrite(req).productId; const { id } = req.params as { id: string }; - const query = req.query as Record; - const productId = query.productId || DEFAULT_PRODUCT_ID; const deleted = await repo.deleteSubscription(id, productId); if (!deleted) throw new NotFoundError('Webhook subscription not found'); @@ -77,7 +95,7 @@ export async function webhookRoutes(app: FastifyInstance) { // List deliveries for a subscription app.get('/webhooks/subscriptions/:id/deliveries', async req => { - await extractAuth(req); + requireWebhooksRead(req); const { id } = req.params as { id: string }; const query = req.query as Record; return repo.listDeliveries(id, { @@ -87,10 +105,8 @@ export async function webhookRoutes(app: FastifyInstance) { // Test delivery — send a test event to a specific subscription app.post('/webhooks/subscriptions/:id/test', async req => { - await extractAuth(req); + const productId = requireWebhooksWrite(req).productId; const { id } = req.params as { id: string }; - const query = req.query as Record; - const productId = query.productId || DEFAULT_PRODUCT_ID; const sub = await repo.getSubscription(id, productId); if (!sub) throw new NotFoundError('Webhook subscription not found'); @@ -107,10 +123,8 @@ export async function webhookRoutes(app: FastifyInstance) { // Rotate subscription secret app.post('/webhooks/subscriptions/:id/rotate-secret', async req => { - await extractAuth(req); + const productId = requireWebhooksWrite(req).productId; const { id } = req.params as { id: string }; - const query = req.query as Record; - const productId = query.productId || DEFAULT_PRODUCT_ID; const sub = await repo.getSubscription(id, productId); if (!sub) throw new NotFoundError('Webhook subscription not found'); diff --git a/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts b/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts new file mode 100644 index 00000000..c30361db --- /dev/null +++ b/services/platform-service/src/modules/webhooks/webhooks.api-key.test.ts @@ -0,0 +1,102 @@ +import Fastify from 'fastify'; +import bcrypt from 'bcryptjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { setProvider } from '../../lib/datastore.js'; +import { registerOptionalApiKeyContext } from '../../lib/api-key-auth.js'; + +const rawApiKey = `wai_${'c'.repeat(64)}`; + +const repoMock = { + listSubscriptions: vi.fn(), + createSubscription: vi.fn(), + getSubscription: vi.fn(), + updateSubscription: vi.fn(), + deleteSubscription: vi.fn(), + listDeliveries: vi.fn(), +}; + +const dispatcherMock = { + dispatchEvent: vi.fn(), +}; + +vi.mock('./repository.js', () => repoMock); +vi.mock('./dispatcher.js', () => dispatcherMock); + +async function seedApiKey(scopes: string[]) { + const provider = new MemoryDatastoreProvider(); + setProvider(provider); + const collection = provider.getCollection('api_tokens', '/id'); + await collection.create({ + id: 'tok_webhooks_1', + productId: 'lysnrai', + userId: 'svc_webhooks', + userName: 'Webhook Service', + prefix: rawApiKey.slice(0, 12), + tokenHash: await bcrypt.hash(rawApiKey, 10), + status: 'active', + scopes, + expiresAt: '2099-01-01T00:00:00.000Z', + lastUsed: null, + }); +} + +describe('webhookRoutes api key integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('allows subscription listing via scoped api key', async () => { + await seedApiKey(['webhooks:read']); + repoMock.listSubscriptions.mockResolvedValue([{ id: 'sub_1', productId: 'lysnrai' }]); + + const { webhookRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(webhookRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/subscriptions', + headers: { 'x-api-key': rawApiKey }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.listSubscriptions).toHaveBeenCalledWith('lysnrai'); + }); + + it('allows subscription creation via scoped api key', async () => { + await seedApiKey(['webhooks:write']); + repoMock.createSubscription.mockResolvedValue({ + id: 'sub_1', + productId: 'lysnrai', + url: 'https://example.com/hook', + secret: 'abc', + }); + + const { webhookRoutes } = await import('./routes.js'); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + await app.register(webhookRoutes, { prefix: '/api' }); + + const res = await app.inject({ + method: 'POST', + url: '/api/webhooks/subscriptions', + headers: { 'x-api-key': rawApiKey }, + payload: { + url: 'https://example.com/hook', + events: ['user.created'], + productId: 'other-product-ignored', + }, + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.createSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + productId: 'lysnrai', + }), + expect.any(String), + 'svc_webhooks' + ); + }); +});