Source: middleware/idempotency.mjs

import { logDanger, logWarning, logInfo } from "../utilities/logger.mjs";
//import { Request, Response, next } from 'express';
import crypto from "crypto";
import { InMemoryIdempotencyStore } from "./InMemoryIdempotencyStore.mjs";

const defaultOptions = {
  headerName: "Idempotency-Key",
  requiredForMethods: ["POST"], //, 'PUT', 'PATCH'],
  ttlMs: 24 * 60 * 60 * 1000,
  cleanupIntervalM: 60,
};

// Hash the request body to detect key reuse with different payloads
function hashRequest(body) {
  const content = JSON.stringify(body) || "";
  return crypto.createHash("sha256").update(content).digest("hex");
}

/**
 * Create middleware that rejects duplicate requests with an idempotency key.
 * @param {object} [options] Middleware configuration overrides.
 * @param {string} [options.headerName="Idempotency-Key"] Header containing the idempotency key.
 * @param {string[]} [options.requiredForMethods=["POST"]] HTTP methods that require idempotency keys.
 * @param {number} [options.ttlMs=86400000] How long stored responses remain valid, in milliseconds.
 * @param {number} [options.cleanupIntervalM=60] Interval between cleanup runs, in minutes.
 * @returns {express.RequestHandler} Configured Express middleware.
 */
export function idempotencyMiddleware(options = {}) {
  const config = { ...defaultOptions, ...options };
  const idemStore = new InMemoryIdempotencyStore({
    ttlMs: config.ttlMs,
    cleanupIntervalM: config.cleanupIntervalM,
  });

  console.log(
    logInfo,
    `Idempotency Middleware: Activated
                - Expiry (mins): ${config.ttlMs / 1000 / 60}
                - Cleanup Interval (mins): ${config.cleanupIntervalM} 
                - Key Name: ${config.headerName} 
                - Methods: ${config.requiredForMethods}`,
  );

  return async (req, res, next) => {
    if (req.is("application/x-www-form-urlencoded")) {
      return next();
    }

    // Skip methods that do not need idempotency
    if (!config.requiredForMethods?.includes(req.method)) {
      return next();
    }

    const idempotencyKey = req.headers[config.headerName?.toLowerCase()];

    // Require idempotency key for protected methods
    if (!idempotencyKey) {
      if (process.env?.NODE_ENV === "development") {
        console.log(
          logWarning,
          `Idempotency Middleware: Rejected - ${config.headerName} header is required for ${req.method} requests`,
        );
      }
      return res.status(400).json({
        error: "Missing idempotency key",
        message: `The ${config.headerName} header is required for ${req.method} requests`,
      });
    }

    // Validate key format (UUID recommended)
    if (!/^[a-zA-Z0-9-_]{16,64}$/.test(idempotencyKey)) {
      if (process.env?.NODE_ENV === "development") {
        console.log(
          logWarning,
          `Idempotency Middleware: Rejected - The ${config.headerName} must be 16-64 alphanumeric characters, hyphens, or underscores`,
        );
      }
      return res.status(400).json({
        error: "Invalid idempotency key format",
        message:
          "Key must be 16-64 alphanumeric characters, hyphens, or underscores",
      });
    }

    // Create a composite key including the endpoint
    const compositeKey = `${req.method}:${req.path}:${idempotencyKey}`;
    const requestHash = hashRequest(req.body);

    // Check for existing record
    const existing = idemStore.get(compositeKey);

    if (existing) {
      // Verify request body matches (detect key reuse with different payload)
      if (existing.requestHash !== requestHash) {
        if (process.env?.NODE_ENV === "development") {
          console.log(
            logDanger,
            "Idempotency Middleware: Rejected - Key reused with different request",
          );
        }
        return res.status(422).json({
          error: "Idempotency key reused with different request",
          message: "Each unique request must use a unique idempotency key",
        });
      }

      // Request is still processing
      if (existing.status === "processing") {
        return res.status(409).json({
          error: "Request in progress",
          message:
            "A request with this idempotency key is currently being processed",
        });
      }

      // Return cached response
      if (existing.response) {
        // Set headers to indicate cached response
        res.set("Idempotent-Replayed", "true");
        if (process.env?.NODE_ENV === "development") {
          console.log(
            logWarning,
            "Idempotency Middleware: Request already served - Response replayed only - ",
            existing?.response?.statusCode,
            existing?.response?.body?.message,
          );
        }
        for (const [key, value] of Object.entries(existing.response.headers)) {
          res.set(key, value);
        }

        return res
          .status(existing.response.statusCode)
          .json(existing.response.body);
      }
    }

    // Mark request as processing
    idemStore.set(compositeKey, {
      status: "processing",
      requestHash,
      createdAt: new Date(),
    });

    // Store original response methods
    const originalJson = res.json.bind(res);
    const originalSend = res.send.bind(res);

    // Intercept response to cache it
    const captureResponse = (body) => {
      const record = idemStore.get(compositeKey);

      if (record && record.status === "processing") {
        // Determine if successful based on status code
        const isSuccess = res.statusCode >= 200 && res.statusCode < 300;
        // if not a success, delete the key
        if (!isSuccess) {
          if (process.env?.NODE_ENV === "development") {
            console.log(
              logWarning,
              `Idempotency Middleware: ${req.method} request failed - Key not saved`,
            );
          }
          idemStore.delete(compositeKey);
          return;
        } else {
          if (process.env?.NODE_ENV === "development") {
            try {
              const cutOff = Date.now() + config.ttlMs;
              console.log(
                logInfo,
                `Idempotency Middleware: Key added - ${compositeKey} - Expires: ${new Date(cutOff).toLocaleString()}`,
              );
            } catch (err) {
              console.log(logDanger, err);
            }
          }
        }

        idemStore.set(compositeKey, {
          ...record,
          status: isSuccess ? "completed" : "failed",
          response: {
            statusCode: res.statusCode,
            body,
            headers: {
              "Content-Type": res.get("Content-Type") || "application/json",
            },
          },
          completedAt: new Date(),
        });
      }

      return body;
    };

    // Override response methods
    res.json = (body) => {
      captureResponse(body);
      return originalJson(body);
    };

    res.send = (body) => {
      if (typeof body === "object") {
        captureResponse(body);
      }
      return originalSend(body);
    };

    // Handle errors - clear processing state
    res.on("error", () => {
      idemStore.delete(compositeKey);
    });

    next();
  };
}