import { type Middleware } from "@nifrajs/core/server"; /** * Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient * tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect * running twice (double-charge, double-publish). Runs in `onRequest` (before the handler), so a replay * or an in-flight collision short-circuits before any mutation. * * Pair it with a DB uniqueness constraint - this stops the *retry*, the constraint is the source of * truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore} * (Redis, etc.) so the guarantee holds across instances; {@link MemoryIdempotencyStore} is dev-only. */ /** A captured response, replayed verbatim on a retry. Body is base64 (binary-safe + JSON-serializable). */ export interface IdempotencyRecord { readonly status: number; /** Response headers, **excluding `Set-Cookie`** (cookies are session-specific - see {@link idempotency}). */ readonly headers: ReadonlyArray; /** Response body, base64-encoded. */ readonly body: string; } export type IdempotencyClaim = { readonly state: "new"; } | { readonly state: "in_flight"; } | { readonly state: "replay"; readonly record: IdempotencyRecord; }; /** * Store backing the idempotency guarantee. Production deploys MUST use a shared store so the key holds * across instances; `begin` MUST be **atomic** (e.g. Redis `SET key NX PX lockTtlMs`) or two concurrent * retries can both see `"new"`. {@link MemoryIdempotencyStore} is for dev / single-instance only. */ export interface IdempotencyStore { /** * Atomically claim `key`: `"replay"` if a completed response is stored, `"in_flight"` if another * request holds the lock, else `"new"` (the caller now owns the lock and must `complete`/`release`). * The in-flight lock expires after `lockTtlMs` so a crashed handler can't wedge the key forever. */ begin(key: string, lockTtlMs: number): Promise; /** Store the completed response and release the lock (kept for `ttlMs`). */ complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise; /** Release the lock without storing (handler errored / response not cacheable). */ release(key: string): Promise; } export interface MemoryIdempotencyStoreOptions { /** Allow the in-memory store in production. Off by default - a per-instance store can't dedupe across instances. */ readonly allowInProduction?: boolean; /** Maximum retained locks + records. Default `10_000`. */ readonly maxEntries?: number; /** Maximum key length in UTF-8 bytes. Default `1024`. */ readonly maxKeyBytes?: number; } /** * Thrown by a store that is full of entries none of which may be discarded. The middleware turns it * into a `503` with `retry-after`; a store that can grow (Redis) never raises it. */ export declare class IdempotencyCapacityError extends Error { constructor(); } /** In-process store. Refuses to run in production unless explicitly allowed (per-instance ⇒ no cross-instance dedupe). */ export declare class MemoryIdempotencyStore implements IdempotencyStore { private readonly entries; private readonly maxEntries; private readonly maxKeyBytes; constructor(options?: MemoryIdempotencyStoreOptions); private validateKey; private maintain; private reserve; begin(key: string, lockTtlMs: number): Promise; complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise; release(key: string): Promise; } export interface IdempotencyOptions { /** Where claims + cached responses live. `MemoryIdempotencyStore` for dev; a shared store in production. */ readonly store: IdempotencyStore; /** Header carrying the key. Default `"idempotency-key"`. */ readonly header?: string; /** Methods the guard applies to. Default `["POST", "PUT", "PATCH", "DELETE"]` (unsafe methods). */ readonly methods?: readonly string[]; /** How long a completed response is replayable, in ms. Default 24h. */ readonly ttlMs?: number; /** How long the in-flight lock survives a crashed handler, in ms. Default 60s. */ readonly lockTtlMs?: number; /** Max response bytes to cache. A larger response is returned but **not** stored. Default 1 MiB. */ readonly maxBytes?: number; /** Whether a response should be cached for replay. Default: status `< 500` (don't replay transient 5xx). */ readonly shouldCache?: (response: Response) => boolean; /** * Derive the store key from the request. Default: the `header` value scoped by method + path **and * by a digest of the caller's credentials** (see `principalHeaders`), so neither a different * endpoint nor a different caller can collide on one key. Return `null`/`""` to skip dedupe for * this request. * * Supplying your own replaces that scoping entirely - a custom key MUST fold in the principal * itself (e.g. `` `${userId}:${req.headers.get("idempotency-key")}` ``), or one user can replay * another's stored response by guessing their key. */ readonly key?: (req: Request, header: string) => string | null | Promise; /** * Headers whose values identify the caller. Their digest scopes the default key, so the same * `Idempotency-Key` from two callers addresses two entries and neither can read the other's cached * response. Defaults to Authorization, Cookie, and x-api-key. Only a digest is stored - a raw * credential must never become a store key, which would put it in front of every Redis `KEYS` dump * and slow-log line. Ignored when `key` is supplied. * * The digest covers the header value verbatim, so anything that varies between two requests from the * same caller - a rotated bearer token, an analytics cookie appended mid-session - lands on a fresh * key and the retry executes again. Narrow the list (or supply `key`) when the app has a stable * principal id to scope by; that is strictly better than digesting a whole `Cookie` header. */ readonly principalHeaders?: readonly string[]; } /** * Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`. * * The store key is scoped by method + path **and** by a digest of the caller's credential headers, so * a key one caller chose addresses only that caller's entry - presenting someone else's key replays * nothing. See `principalHeaders`; a custom `key` replaces that scoping and must do it itself. * * **`Set-Cookie` is intentionally not cached or replayed** - a cookie set on the first request is * session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a * session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request. * * Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses. */ export declare function idempotency(options: IdempotencyOptions): Middleware; //# sourceMappingURL=idempotency.d.ts.map