From 73a36c0f2f6eb508b2e81f0e90ced9bc16470261 Mon Sep 17 00:00:00 2001 From: Truphile Date: Wed, 29 Jul 2026 15:02:04 -0400 Subject: [PATCH] feat: idempotency-key middleware for POST/PATCH on /api/reports --- PR_DESCRIPTION.md | 11 ++++++----- src/middleware/idempotency.ts | 31 +++++-------------------------- src/routes/reports.ts | 6 ++++++ 3 files changed, 17 insertions(+), 31 deletions(-) diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 0c2bc63d..08a60f80 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -1,11 +1,12 @@ ## Description -This PR persists audit rows for state-changing calls on the `/api/impersonate` endpoint, capturing the actor, action, and before/after states. +This PR implements the Idempotency-Key middleware for POST/PATCH endpoints on `/api/reports`, ensuring safe retries. -- Enriched `createAuditLog` calls in `src/routes/admin/users/impersonate.ts` to log `beforeState: null` and `afterState: { targetAddress, role: "user" }`. -- Restored missing `getCircuitBreaker` utility function in `src/lib/circuitBreaker.ts` to correctly mock/track circuit breakers. -- Updated `tests/adminImpersonate.test.ts` and `tests/impersonateCircuitBreaker.test.ts` to strictly assert the new payload format. +- Fixed a bug where `persisted` flag was missing in the global idempotency middleware, throwing a ReferenceError. +- Fixed a bug where `res.json` manually inserted records causing duplicate database inserts. +- Addressed undefined variables `TTL_MS` and `correlationId` in `idempotency.ts`. +- Explicitly mounted the `idempotency` middleware inside `createReportsRouter` (in `src/routes/reports.ts`) for all `POST` and `PATCH` methods. ## API / Visible Changes - Global `audit_logs` will now contain detailed states outlining what token roles were issued when impersonating. -Closes # +Closes #123 diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts index 60c4f474..3e0e9fb7 100644 --- a/src/middleware/idempotency.ts +++ b/src/middleware/idempotency.ts @@ -159,6 +159,8 @@ export async function idempotency( const originalJson = res.json.bind(res); const originalSend = res.send.bind(res); + let persisted = false; + function saveIdempotency(bodyToSave: unknown) { if (persisted) return; persisted = true; @@ -169,7 +171,7 @@ export async function idempotency( const v = res.getHeader(h); if (v !== undefined) headers[h] = String(v); } - const expiresAt = new Date(Date.now() + TTL_MS); + const expiresAt = new Date(Date.now() + IDEMPOTENCY_TTL_MS); const valBody = typeof bodyToSave === "string" ? { content: bodyToSave } : bodyToSave; db.insert(idempotencyRecords) .values({ @@ -180,35 +182,12 @@ export async function idempotency( responseHeaders: headers, expiresAt, }) - .catch((err) => logger.error({ err, key, correlationId }, "idempotency_persist_failed")); + .catch((err) => logger.error({ err, key, correlationId: reqId }, "idempotency_persist_failed")); } } res.json = function (responseBody: unknown) { - const status = res.statusCode; - const headers: Record = {}; - for (const h of REPLAY_HEADERS) { - const v = res.getHeader(h); - if (v) headers[h] = String(v); - } - - // Only cache successful mutations; client / server errors are not stored. - if (status >= 200 && status < 300) { - const expiresAt = new Date(Date.now() + IDEMPOTENCY_TTL_MS); - db.insert(idempotencyRecords) - .values({ - key, - fingerprint, - responseStatus: status, - responseBody, - responseHeaders: headers, - expiresAt, - }) - .catch((err) => - logger.error({ err, reqId, key }, "idempotency_persist_failed"), - ); - } - + saveIdempotency(responseBody); return originalJson(responseBody); }; diff --git a/src/routes/reports.ts b/src/routes/reports.ts index 765cfe5a..becd2fa5 100644 --- a/src/routes/reports.ts +++ b/src/routes/reports.ts @@ -15,6 +15,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/requireAuth"; import { createPerUserTokenBucketLimiter } from "../middleware/rateLimit"; import { scheduledReportsRouter } from "./reports/scheduled"; +import { idempotency } from "../middleware/idempotency"; export interface ReportsRouterOptions { rateLimit?: { @@ -34,6 +35,11 @@ export function createReportsRouter(options: ReportsRouterOptions = {}): Router }), ); + const mutationMethods = ["POST", "PATCH"]; + router.use((req, res, next) => + mutationMethods.includes(req.method) ? idempotency(req, res, next) : next() + ); + router.use("/scheduled", scheduledReportsRouter); return router;