Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const nodeGlobals = {
Buffer: "readonly",
setTimeout: "readonly",
clearTimeout: "readonly",
setInterval: "readonly",
clearInterval: "readonly",
};

const jestGlobals = {
Expand Down
7 changes: 7 additions & 0 deletions src/errors/ApiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions src/middleware/idempotency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
56 changes: 56 additions & 0 deletions src/middleware/idempotency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)
.sort()
.map(
(k) =>
JSON.stringify(k) +
":" +
stableStringify((value as Record<string, unknown>)[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 {
Expand All @@ -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;
}
Expand All @@ -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"];
Expand Down
16 changes: 16 additions & 0 deletions src/models/settlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions src/repositories/settlementRepository.anchorIndex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 47 additions & 1 deletion src/repositories/settlementRepository.test.ts
Original file line number Diff line number Diff line change
@@ -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<Settlement, "id"> {
return {
Expand Down Expand Up @@ -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<Settlement, "id">;

expect(() => repo.create(invalid)).toThrow(/Invalid settlement status/);
});
});
14 changes: 13 additions & 1 deletion src/repositories/settlementRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Settlement> {
Expand All @@ -15,6 +15,13 @@ export class SettlementRepository extends InMemoryRepository<number, Settlement>

/** Stores a settlement under a freshly allocated id. */
create(settlement: Omit<Settlement, "id">): 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
Expand All @@ -26,6 +33,11 @@ export class SettlementRepository extends InMemoryRepository<number, Settlement>

/** 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) {
Expand Down