|
1 | | -/** |
2 | | - * User-facing webhook subscription management. |
3 | | - * |
4 | | - * Mounted at `/api/webhooks`. All endpoints require authentication and |
5 | | - * are subject to a **per-user** rate limit configured via: |
6 | | - * |
7 | | - * - WEBHOOKS_RATE_LIMIT_WINDOW_MS (default 15 min) |
8 | | - * - WEBHOOKS_RATE_LIMIT_MAX (default 100 requests) |
9 | | - * |
10 | | - * Rate-limit keying is based on the authenticated stellar address populated by |
11 | | - * `requireAuth`, so quota is tracked independently per user, not per IP. |
12 | | - * |
13 | | - * All routes are wrapped by `accessLog` which: |
14 | | - * - Resolves a correlation ID via the priority chain (header → req.id → UUID) |
15 | | - * - Echoes it back in the `X-Correlation-Id` response header |
16 | | - * - Emits a structured `webhooks_access_log` entry on every response finish. |
17 | | - * |
18 | | - * Endpoints: |
19 | | - * GET / — list webhook subscriptions |
20 | | - * POST / — create a new webhook subscription |
21 | | - * GET /:id — fetch a single subscription (by UUID) |
22 | | - * PATCH /:id — update a subscription (URL, events, active flag) |
23 | | - * DELETE /:id — deactivate / remove a subscription |
24 | | - * |
25 | | - * All mutations are protected by the idempotency layer that runs before |
26 | | - * any `/api/*` POST / PATCH handler in `src/index.ts`. |
27 | | - */ |
28 | | - |
29 | 1 | import { Router } from "express"; |
30 | 2 | import { logger } from "../config/logger"; |
31 | 3 | import { getRequestId } from "../lib/requestContext"; |
32 | | -import { accessLog } from "../middleware/accessLog"; |
33 | | -import { webhookCors } from "../middleware/cors"; |
34 | 4 | import { requireAdmin } from "../middleware/requireAdmin"; |
35 | | -import { webhooksRateLimiter } from "../middleware/rateLimit"; |
36 | | -import { correlationMiddleware, getCorrelationId } from "../middleware/correlation"; |
37 | | - |
38 | | -// --------------------------------------------------------------------------- |
39 | | -// Zod schemas — boundary validation |
40 | | -// --------------------------------------------------------------------------- |
41 | | - |
42 | | -// --------------------------------------------------------------------------- |
43 | | -// Router |
44 | | -// --------------------------------------------------------------------------- |
45 | | - |
46 | | -export const webhooksRouter = Router(); |
47 | | - |
48 | | -// Structured access log — resolves correlation ID, echoes it back, and |
49 | | -// emits a webhooks_access_log entry on every response finish. |
50 | | -// Mounted first so the correlation ID is available to all downstream handlers. |
51 | | -webhooksRouter.use(accessLog); |
52 | | - |
53 | | -// Enforce CORS allowlist before admin auth so unapproved origins are |
54 | | -// rejected early without leaking auth challenge details. |
55 | | -webhooksRouter.use(webhookCors()); |
56 | | -webhooksRouter.use(correlationMiddleware); |
57 | | -webhooksRouter.use(webhooksRateLimiter); |
58 | | -webhooksRouter.use(requireAdmin); |
59 | | - |
60 | | -webhooksRouter.get("/", async (req, res, next) => { |
61 | | - const reqId = getRequestId(); |
62 | | - const correlationId = getCorrelationId(); |
63 | | - const userId = req.user!.id; |
64 | | - |
65 | | - try { |
66 | | - const rows = await db |
67 | | - .select() |
68 | | - .from(webhookSubscriptions) |
69 | | - .orderBy(webhookSubscriptions.createdAt); |
70 | | - |
71 | | - logger.debug( |
72 | | - { reqId, correlationId, userId, count: rows.length }, |
73 | | - "webhooks_listed", |
74 | | - ); |
75 | | - |
76 | | - return res.json({ data: rows.map(serializeSub) }); |
77 | | - } catch (err) { |
78 | | - return next(err); |
79 | | - } |
80 | | -}); |
81 | | - |
82 | | -// ── Create ──────────────────────────────────────────────────────────────── |
83 | | - |
84 | | -webhooksRouter.post("/", async (req, res, next) => { |
85 | | - const reqId = getRequestId(); |
86 | | - const correlationId = getCorrelationId(); |
87 | | - const userId = req.user!.id; |
88 | | - |
89 | | - try { |
90 | | - const parsed = createSchema.safeParse(req.body); |
91 | | - if (!parsed.success) { |
92 | | - const issue = parsed.error.issues[0]!; |
93 | | - logger.warn( |
94 | | - { reqId, correlationId, userId, issues: parsed.error.issues }, |
95 | | - "webhooks_create_validation_failed", |
96 | | - ); |
97 | | - return res.status(400).json({ |
98 | | - error: { |
99 | | - code: "validation_error", |
100 | | - message: issue.message, |
101 | | - requestId: reqId, |
102 | | - correlationId: correlationId, |
103 | | - }, |
| 5 | +import { webhooksMetricsMiddleware } from "../metrics/webhooksMetrics"; |
| 6 | +import type { WebhookStore, WebhookDelivery } from "../services/webhookStore"; |
| 7 | +import { listWebhooksQuerySchema } from "../validators/webhooks"; |
| 8 | + |
| 9 | +export interface WebhooksRouterDeps { |
| 10 | + store: WebhookStore; |
| 11 | +} |
| 12 | + |
| 13 | +function serializeDelivery(row: WebhookDelivery) { |
| 14 | + return { |
| 15 | + id: row.id, |
| 16 | + eventId: row.eventId, |
| 17 | + eventType: row.eventType, |
| 18 | + targetUrl: row.targetUrl, |
| 19 | + payloadBase64: row.payload.toString("base64"), |
| 20 | + signature: row.signature, |
| 21 | + headers: row.headers, |
| 22 | + status: row.status, |
| 23 | + attempts: row.attempts, |
| 24 | + maxAttempts: row.maxAttempts, |
| 25 | + lastError: row.lastError, |
| 26 | + nextAttemptAt: row.nextAttemptAt?.toISOString() ?? null, |
| 27 | + createdAt: row.createdAt.toISOString(), |
| 28 | + updatedAt: row.updatedAt.toISOString(), |
| 29 | + }; |
| 30 | +} |
| 31 | + |
| 32 | +export function createWebhooksRouter(deps: WebhooksRouterDeps): Router { |
| 33 | + const router = Router(); |
| 34 | + |
| 35 | + router.use(webhooksMetricsMiddleware); |
| 36 | + router.use(requireAdmin); |
| 37 | + |
| 38 | + router.get("/", async (req, res, next) => { |
| 39 | + const requestId = getRequestId(); |
| 40 | + |
| 41 | + try { |
| 42 | + const parseResult = listWebhooksQuerySchema.safeParse(req.query); |
| 43 | + if (!parseResult.success) { |
| 44 | + const issue = parseResult.error.issues[0]; |
| 45 | + logger.warn( |
| 46 | + { |
| 47 | + event: "webhooks_list_validation_failed", |
| 48 | + requestId, |
| 49 | + adminAddress: req.adminAddress, |
| 50 | + issues: parseResult.error.issues, |
| 51 | + }, |
| 52 | + "Webhook list: invalid query parameters", |
| 53 | + ); |
| 54 | + return res.status(400).json({ |
| 55 | + error: { |
| 56 | + code: "validation_error", |
| 57 | + message: issue?.message ?? "invalid query parameters", |
| 58 | + requestId, |
| 59 | + }, |
| 60 | + }); |
| 61 | + } |
| 62 | + |
| 63 | + const { cursor, limit } = parseResult.data; |
| 64 | + const page = await deps.store.listDeliveries(cursor, limit); |
| 65 | + return res.json({ |
| 66 | + data: page.data.map(serializeDelivery), |
| 67 | + nextCursor: page.nextCursor, |
104 | 68 | }); |
| 69 | + } catch (err) { |
| 70 | + return next(err); |
105 | 71 | } |
| 72 | + }); |
106 | 73 |
|
107 | | - const { url, events } = parsed.data; |
108 | | - const secret = uuidv4(); |
109 | | - |
110 | | - const [row] = await db |
111 | | - .insert(webhookSubscriptions) |
112 | | - .values({ url, events, secret }) |
113 | | - .returning(); |
114 | | - |
115 | | - logger.info( |
116 | | - { reqId, correlationId, userId, subscriptionId: row.id }, |
117 | | - "webhooks_subscription_created", |
118 | | - ); |
119 | | - |
120 | | - return res.status(201).json({ |
121 | | - data: { ...serializeSub(row), secret }, |
122 | | - }); |
123 | | - } catch (err) { |
124 | | - return next(err); |
125 | | - } |
126 | | -}); |
127 | | - |
128 | | -// ── Get by id ───────────────────────────────────────────────────────────── |
129 | | - |
130 | | -webhooksRouter.get("/:id", async (req, res, next) => { |
131 | | - const reqId = getRequestId(); |
132 | | - const correlationId = getCorrelationId(); |
133 | | - const userId = req.user!.id; |
134 | | - |
135 | | - try { |
136 | | - const idParse = idParamSchema.safeParse(req.params.id); |
137 | | - if (!idParse.success) { |
138 | | - throw RouteErrorFactory.validation(idParse.error.issues[0]?.message ?? "invalid id"); |
139 | | - } |
140 | | - |
141 | | - const [row] = await db |
142 | | - .select() |
143 | | - .from(webhookSubscriptions) |
144 | | - .where(eq(webhookSubscriptions.id, idParse.data)); |
145 | | - |
146 | | - if (!row) { |
147 | | - logger.debug( |
148 | | - { reqId, correlationId, userId, subscriptionId: idParse.data }, |
149 | | - "webhooks_subscription_not_found", |
150 | | - ); |
151 | | - throw RouteErrorFactory.notFound("Subscription not found"); |
152 | | - } |
| 74 | + return router; |
| 75 | +} |
153 | 76 |
|
154 | | - return res.json({ data: serializeSub(row) }); |
155 | | - } catch (err) { |
156 | | - return next(err); |
157 | | - } |
158 | | -}); |
159 | | - |
160 | | -// ── Update ──────────────────────────────────────────────────────────────── |
161 | | - |
162 | | -webhooksRouter.patch("/:id", async (req, res, next) => { |
163 | | - const reqId = getRequestId(); |
164 | | - const correlationId = getCorrelationId(); |
165 | | - const userId = req.user!.id; |
166 | | - |
167 | | - try { |
168 | | - const idParse = idParamSchema.safeParse(req.params.id); |
169 | | - if (!idParse.success) { |
170 | | - throw RouteErrorFactory.validation(idParse.error.issues[0]?.message ?? "invalid id"); |
171 | | - } |
172 | | - |
173 | | - const bodyParse = updateSchema.safeParse(req.body); |
174 | | - if (!bodyParse.success) { |
175 | | - const issue = bodyParse.error.issues[0]!; |
176 | | - return res.status(400).json({ |
177 | | - error: { |
178 | | - code: "validation_error", |
179 | | - message: issue.message, |
180 | | - requestId: reqId, |
181 | | - correlationId: correlationId, |
182 | | - }, |
183 | | - }); |
184 | | - } |
185 | | - |
186 | | - const [existing] = await db |
187 | | - .select() |
188 | | - .from(webhookSubscriptions) |
189 | | - .where(eq(webhookSubscriptions.id, idParse.data)); |
190 | | - |
191 | | - if (!existing) { |
192 | | - throw RouteErrorFactory.notFound("Subscription not found"); |
193 | | - } |
194 | | - |
195 | | - const [updated] = await db |
196 | | - .update(webhookSubscriptions) |
197 | | - .set({ ...bodyParse.data, updatedAt: new Date() }) |
198 | | - .where(eq(webhookSubscriptions.id, idParse.data)) |
199 | | - .returning(); |
200 | | - |
201 | | - logger.info( |
202 | | - { reqId, correlationId, userId, subscriptionId: updated.id }, |
203 | | - "webhooks_subscription_updated", |
204 | | - ); |
205 | | - |
206 | | - return res.json({ data: serializeSub(updated) }); |
207 | | - } catch (err) { |
208 | | - return next(err); |
209 | | - } |
210 | | -}); |
211 | | - |
212 | | -// ── Delete / deactivate ─────────────────────────────────────────────────── |
213 | | - |
214 | | -webhooksRouter.delete("/:id", async (req, res, next) => { |
215 | | - const reqId = getRequestId(); |
216 | | - const correlationId = getCorrelationId(); |
217 | | - const userId = req.user!.id; |
218 | | - |
219 | | - try { |
220 | | - const idParse = idParamSchema.safeParse(req.params.id); |
221 | | - if (!idParse.success) { |
222 | | - throw RouteErrorFactory.validation(idParse.error.issues[0]?.message ?? "invalid id"); |
223 | | - } |
224 | | - |
225 | | - const result = await db |
226 | | - .delete(webhookSubscriptions) |
227 | | - .where(eq(webhookSubscriptions.id, idParse.data)); |
228 | | - |
229 | | - if (result.rowCount === 0) { |
230 | | - throw RouteErrorFactory.notFound("Subscription not found"); |
231 | | - } |
232 | | - |
233 | | - logger.info( |
234 | | - { reqId, correlationId, userId, subscriptionId: idParse.data }, |
235 | | - "webhooks_subscription_deleted", |
236 | | - ); |
237 | | - |
238 | | - return res.status(204).send(); |
239 | | - } catch (err) { |
240 | | - return next(err); |
241 | | - } |
242 | | -}); |
| 77 | +export const webhooksRouter = Router(); |
0 commit comments