diff --git a/eslint.config.mjs b/eslint.config.mjs index c1a1719..5f6266b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,8 @@ const nodeGlobals = { Buffer: "readonly", setTimeout: "readonly", clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", }; const jestGlobals = { diff --git a/src/errors/ApiError.ts b/src/errors/ApiError.ts index 0a49575..5440f28 100644 --- a/src/errors/ApiError.ts +++ b/src/errors/ApiError.ts @@ -35,6 +35,13 @@ export class ApiError extends Error { return new ApiError(429, code, message); } + static idempotencyKeyReuse( + message: string, + code = "IDEMPOTENCY_KEY_REUSE", + ): ApiError { + return new ApiError(422, code, message); + } + static serviceUnavailable( message: string, code = "SERVICE_UNAVAILABLE", diff --git a/src/middleware/idempotency.test.ts b/src/middleware/idempotency.test.ts index 5fe3378..ffad474 100644 --- a/src/middleware/idempotency.test.ts +++ b/src/middleware/idempotency.test.ts @@ -109,4 +109,93 @@ describe("idempotency", () => { expect(first.body.counter).toBe(1); expect(second.body.counter).toBe(2); }); + + it("replays cached response when same key and same body", async () => { + const app = makeApp(); + + const first = await request(app) + .post("/mutate") + .set("Idempotency-Key", "match-key") + .send({ value: "hello" }); + const second = await request(app) + .post("/mutate") + .set("Idempotency-Key", "match-key") + .send({ value: "hello" }); + + expect(first.status).toBe(201); + expect(first.body.counter).toBe(1); + expect(second.status).toBe(201); + expect(second.body.counter).toBe(1); + }); + + it("returns 422 IDEMPOTENCY_KEY_REUSE when same key and different body", async () => { + const app = makeApp(); + + const first = await request(app) + .post("/mutate") + .set("Idempotency-Key", "mismatch-key") + .send({ value: "hello" }); + const second = await request(app) + .post("/mutate") + .set("Idempotency-Key", "mismatch-key") + .send({ value: "world" }); + + expect(first.status).toBe(201); + expect(first.body.counter).toBe(1); + expect(second.status).toBe(422); + expect(second.body.error.code).toBe("IDEMPOTENCY_KEY_REUSE"); + }); + + it("replays cached response when same body with different key ordering", async () => { + const app = makeApp(); + + const first = await request(app) + .post("/mutate") + .set("Idempotency-Key", "order-key") + .send({ a: 1, b: 2 }); + const second = await request(app) + .post("/mutate") + .set("Idempotency-Key", "order-key") + .send({ b: 2, a: 1 }); + + expect(first.status).toBe(201); + expect(first.body.counter).toBe(1); + expect(second.status).toBe(201); + expect(second.body.counter).toBe(1); + }); + + it("returns 422 when same key used with body then without body", async () => { + const app = makeApp(); + + const first = await request(app) + .post("/mutate") + .set("Idempotency-Key", "body-vs-empty") + .send({ value: "data" }); + const second = await request(app) + .post("/mutate") + .set("Idempotency-Key", "body-vs-empty"); + + expect(first.status).toBe(201); + expect(first.body.counter).toBe(1); + expect(second.status).toBe(422); + expect(second.body.error.code).toBe("IDEMPOTENCY_KEY_REUSE"); + }); + + it("replays cached response when body has numeric-like keys in different orders", async () => { + const app = makeApp(); + + const first = await request(app) + .post("/mutate") + .set("Idempotency-Key", "numeric-key") + .send({ "1": "a", "2": "b" }); + const second = await request(app) + .post("/mutate") + .set("Idempotency-Key", "numeric-key") + .send({ "2": "b", "1": "a" }); + + expect(first.status).toBe(201); + expect(first.body.counter).toBe(1); + expect(second.status).toBe(201); + expect(second.body.counter).toBe(1); + }); }); diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts index 3dc18dd..2054085 100644 --- a/src/middleware/idempotency.ts +++ b/src/middleware/idempotency.ts @@ -10,19 +10,62 @@ * unaffected. State lives in a plain `Map` local to the returned middleware, * so like the existing rate limiter this is a per-process safeguard and not * suitable for multi-instance deployments without a shared store. + * + * A SHA-256 fingerprint of the canonical JSON request body is stored alongside + * the cached response. On key reuse, if the incoming body hash differs, a 422 + * `IDEMPOTENCY_KEY_REUSE` error is returned instead of replaying the cached + * response. Canonical serialization uses stable key ordering so that + * semantically identical bodies with different key orderings are treated as + * the same request. */ +import crypto from "crypto"; import { NextFunction, Request, Response } from "express"; +import { ApiError } from "../errors/ApiError"; const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** Default time a cached response remains eligible for replay. */ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; +/** + * Produce a deterministic JSON string for any value. Object keys are sorted + * recursively so that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` serialize to the + * same string. Array element order is preserved (order is semantically + * meaningful in arrays). Primitives and null pass through to JSON.stringify. + */ +function stableStringify(value: unknown): string { + if (value === null || value === undefined) return JSON.stringify(value); + if (typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) { + return "[" + value.map(stableStringify).join(",") + "]"; + } + const sorted = Object.keys(value as Record) + .sort() + .map( + (k) => + JSON.stringify(k) + + ":" + + stableStringify((value as Record)[k]), + ); + return "{" + sorted.join(",") + "}"; +} + +/** + * SHA-256 hex digest of the canonicalized request body. `undefined` (no body) + * hashes the empty string; an explicit `{}` hashes `"{}"`. This distinction + * is deliberate: the two are different wire representations. + */ +function hashBody(body: unknown): string { + const raw = body !== undefined ? stableStringify(body) : ""; + return crypto.createHash("sha256").update(raw).digest("hex"); +} + interface CachedResponse { status: number; body: unknown; expiresAt: number; + bodyHash: string; } export interface IdempotencyOptions { @@ -45,6 +88,18 @@ export function idempotency(options: IdempotencyOptions = {}) { const now = Date.now(); const cached = cache.get(cacheKey); if (cached && cached.expiresAt > now) { + // NOTE: concurrent requests with the same key may both pass this check + // before either writes to the cache (check-then-set race). This is a + // pre-existing limitation of the in-memory Map design, not introduced + // by this change. See README for per-process scope. + if (cached.bodyHash !== hashBody(req.body)) { + next( + ApiError.idempotencyKeyReuse( + "Idempotency key already used with a different request body", + ), + ); + return; + } res.status(cached.status).json(cached.body); return; } @@ -55,6 +110,7 @@ export function idempotency(options: IdempotencyOptions = {}) { status: res.statusCode, body, expiresAt: now + ttlMs, + bodyHash: hashBody(req.body), }); return originalJson(body); }) as Response["json"]; diff --git a/src/models/settlement.ts b/src/models/settlement.ts index 1491e9e..52c77d0 100644 --- a/src/models/settlement.ts +++ b/src/models/settlement.ts @@ -5,6 +5,22 @@ /** Lifecycle state of a settlement, mirroring the on-chain contract. */ export type SettlementStatus = "pending" | "executed" | "cancelled"; +const VALID_STATUSES: readonly SettlementStatus[] = [ + "pending", + "executed", + "cancelled", +]; + +/** Runtime type guard: returns `true` when `value` is a valid {@link SettlementStatus}. */ +export function isSettlementStatus( + value: unknown, +): value is SettlementStatus { + return ( + typeof value === "string" && + (VALID_STATUSES as readonly string[]).includes(value) + ); +} + /** A settlement that draws liquidity from a pool to settle a payment. */ export interface Settlement { /** Monotonic identifier assigned by the service. */ diff --git a/src/repositories/settlementRepository.anchorIndex.test.ts b/src/repositories/settlementRepository.anchorIndex.test.ts index 1c9434c..ed7550e 100644 --- a/src/repositories/settlementRepository.anchorIndex.test.ts +++ b/src/repositories/settlementRepository.anchorIndex.test.ts @@ -20,9 +20,9 @@ describe("SettlementRepository Anchor Index", () => { it("returns settlements for an anchor sorted most recent first", () => { const repo = new SettlementRepository(); - const s1 = repo.create(draft("anchorA", 100)); // id 1 - const s2 = repo.create(draft("anchorA", 200)); // id 2 - const s3 = repo.create(draft("anchorB", 300)); // id 3 + repo.create(draft("anchorA", 100)); // id 1 + repo.create(draft("anchorA", 200)); // id 2 + repo.create(draft("anchorB", 300)); // id 3 const result = repo.byAnchor("anchorA"); expect(result.map((s) => s.id)).toEqual([2, 1]); expect(result).toHaveLength(2); diff --git a/src/repositories/settlementRepository.test.ts b/src/repositories/settlementRepository.test.ts index b23648f..12131ae 100644 --- a/src/repositories/settlementRepository.test.ts +++ b/src/repositories/settlementRepository.test.ts @@ -1,5 +1,5 @@ import { SettlementRepository } from "./settlementRepository"; -import { Settlement } from "../models/settlement"; +import { Settlement, isSettlementStatus } from "../models/settlement"; function draft(anchor: string, amount: number): Omit { return { @@ -49,3 +49,49 @@ describe("SettlementRepository", () => { expect(repo.count()).toBe(3); }); }); + +describe("isSettlementStatus", () => { + it("accepts all valid statuses", () => { + expect(isSettlementStatus("pending")).toBe(true); + expect(isSettlementStatus("executed")).toBe(true); + expect(isSettlementStatus("cancelled")).toBe(true); + }); + + it("rejects invalid strings", () => { + expect(isSettlementStatus("unknown")).toBe(false); + expect(isSettlementStatus("")).toBe(false); + }); + + it("rejects near-miss strings (exact match only)", () => { + expect(isSettlementStatus("Pending")).toBe(false); + expect(isSettlementStatus("pending ")).toBe(false); + expect(isSettlementStatus("PENDING")).toBe(false); + }); + + it("rejects non-string values", () => { + expect(isSettlementStatus(123)).toBe(false); + expect(isSettlementStatus(null)).toBe(false); + expect(isSettlementStatus(undefined)).toBe(false); + expect(isSettlementStatus({})).toBe(false); + }); +}); + +describe("SettlementRepository rejects invalid status", () => { + it("save throws on invalid status", () => { + const repo = new SettlementRepository(); + const created = repo.create(draft("anchorA", 100)); + const invalid = { ...created, status: "bogus" } as unknown as Settlement; + + expect(() => repo.save(invalid)).toThrow(/Invalid settlement status/); + }); + + it("create throws on invalid status", () => { + const repo = new SettlementRepository(); + const invalid = { + ...draft("anchorA", 100), + status: "bogus", + } as unknown as Omit; + + expect(() => repo.create(invalid)).toThrow(/Invalid settlement status/); + }); +}); diff --git a/src/repositories/settlementRepository.ts b/src/repositories/settlementRepository.ts index 40326e1..5e178e4 100644 --- a/src/repositories/settlementRepository.ts +++ b/src/repositories/settlementRepository.ts @@ -2,7 +2,7 @@ * In-memory store for settlements with an auto-incrementing id. */ -import { Settlement } from "../models/settlement"; +import { Settlement, isSettlementStatus } from "../models/settlement"; import { InMemoryRepository } from "./inMemoryRepository"; export class SettlementRepository extends InMemoryRepository { @@ -15,6 +15,13 @@ export class SettlementRepository extends InMemoryRepository /** Stores a settlement under a freshly allocated id. */ create(settlement: Omit): Settlement { + if (!isSettlementStatus(settlement.status)) { + // Interpolate only the status, not the whole object, to avoid leaking + // untrusted input into logs. + throw new Error( + `Invalid settlement status: ${String(settlement.status)}`, + ); + } const id = this.generateId(); const created: Settlement = { ...settlement, id }; // Update anchor index @@ -26,6 +33,11 @@ export class SettlementRepository extends InMemoryRepository /** Replaces an existing settlement (e.g. after a status change). */ save(settlement: Settlement): Settlement { + if (!isSettlementStatus(settlement.status)) { + throw new Error( + `Invalid settlement status: ${String(settlement.status)}`, + ); + } // Ensure anchor index consistency (anchor is immutable) const existing = this.getByKey(settlement.id); if (existing && existing.anchor !== settlement.anchor) {