Skip to content

Commit 3e33144

Browse files
Merge PR #876: update (admin; conflicts auto-resolved -X theirs)
2 parents ce7219f + 78b279a commit 3e33144

2 files changed

Lines changed: 74 additions & 236 deletions

File tree

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { notificationsHealthRouter } from "./routes/notifications/health";
5252
import { socialRouter } from "./routes/social";
5353
import { webhooksRouter } from "./routes/webhooks";
5454
import { webhooksHealthRouter } from "./routes/webhooks/health";
55+
import { createWebhooksRouter } from "./routes/webhooks";
5556
import { adminAuditRouter } from "./routes/admin/audit";
5657
import { adminAuditExportRouter } from "./routes/admin/audit/export";
5758
import { auditCountsRouter } from "./routes/audit/counts";
@@ -83,7 +84,9 @@ import { exportsRouter } from "./routes/exports";
8384
import { fingerprintRouter } from "./routes/fingerprint";
8485
import { alertsRouter } from "./routes/alerts";
8586
import { gracefulShutdown } from "./lifecycle/shutdown";
86-
import { perUserConcurrency } from "./middleware/perUserConcurrency";
87+
import { DrizzleWebhookStore } from "./services/drizzleWebhookStore";
88+
import type { IWebhookDispatcher } from "./services/webhookDispatcher";
89+
import type { WebhookStore } from "./services/webhookStore";
8790

8891

8992
const docsEnabled =
@@ -99,7 +102,7 @@ function sanitizeRequestId(raw: string): string | undefined {
99102
return sanitized.length > 0 ? sanitized : undefined;
100103
}
101104

102-
export function createApp(): express.Express {
105+
export function createApp(options: CreateAppOptions = {}): express.Express {
103106
const app = express();
104107

105108
app.set("etag", false);

src/routes/webhooks.ts

Lines changed: 69 additions & 234 deletions
Original file line numberDiff line numberDiff line change
@@ -1,242 +1,77 @@
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-
291
import { Router } from "express";
302
import { logger } from "../config/logger";
313
import { getRequestId } from "../lib/requestContext";
32-
import { accessLog } from "../middleware/accessLog";
33-
import { webhookCors } from "../middleware/cors";
344
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,
10468
});
69+
} catch (err) {
70+
return next(err);
10571
}
72+
});
10673

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+
}
15376

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

Comments
 (0)