Idempotency-Key
draft-ietf-httpapi-idempotency-key middleware — replay-safe POST / PUT / PATCH / DELETE handling for retry-capable clients. A client sends Idempotency-Key: on a mutating request; the middleware:
1. Looks up the key in the operator-supplied store. A hit replays the cached { statusCode, headers, body } without invoking the handler (idempotent replay). 2. Compares the inbound request fingerprint (method + path + body hash) against the cached fingerprint. A mismatch is a client-side mistake — same key, different request — and refuses with 422 + RFC 9457 Problem Details idempotency/key-reuse-mismatch per the draft §4.3. 3. On miss, attaches a capture wrapper to res.end so the handler's response is intercepted, persisted, and replayed on every subsequent retry within ttlMs.
Idempotency-Key is OPTIONAL — clients that don't send it skip the cache and the middleware is a no-op. Idempotency is a client-asserted contract; the server promises "if you send the same key + same body, you get the same answer." Operators wanting strict idempotency on a particular route compose with requireIdempotencyKey: true to refuse missing headers with 400 idempotency/missing-key.
Store interface is operator-supplied so cluster deployments can plug their distributed store (Redis, SQLite-cluster, etc.). The first-party memoryStore is included for single-instance testing — it accepts { ttlMs } and exposes _resetForTest().
b.middleware.idempotencyKey.memoryStore(opts?) #
{
maxEntries: number, // default 10000 — FIFO eviction on overflow
}
First-party in-memory store for idempotencyKey middleware. Single-instance only — cluster deployments compose against a distributed store (Redis / SQLite-cluster) matching the three-method interface: get(key) → record | null, set(key, value, ttlMs), delete(key). TTL is enforced lazily at read time; the store's resident size is operator-supplied via opts.maxEntries (default 10000) — when the cap is hit, the oldest entry is evicted (FIFO; the recorded request was idempotent anyway so re-running is correct, not just safe).
var store = b.middleware.idempotencyKey.memoryStore({ maxEntries: 5000 });
var mw = b.middleware.idempotencyKey({ store: store, ttlMs: C.TIME.hours(24) });
app.use(mw);
b.middleware.idempotencyKey.dbStore(opts) #
{
db: object, // required — sqlite-shaped: { prepare(sql) → { run, get, all } }
tableName?: string, // default "blamejs_idempotency_keys"; validated via b.safeSql.validateIdentifier
init?: boolean, // default true — run CREATE TABLE IF NOT EXISTS at construction
hashKeys?: boolean, // default true — store sha3-512 namespace-hash of the key, not the raw key
seal?: boolean, // default true — seal headers + body via b.cryptoField when vault is ready
aad?: boolean, // default true — AAD-bind seal to (table,k,column) so a DB-write attacker can't cross-row swap
fingerprintSeal?: boolean, // default true — HMAC fingerprint under a vault-derived secret instead of bare sha3-256
}
Persistent-backed store for idempotencyKey middleware. Implements the same three-method interface as memoryStore (get / set / delete) but stores records in a SQLite-shaped database — the framework's internal b.db, an operator-supplied better-sqlite3 instance, or any object exposing prepare(sql) → { run, get, all }.
Use dbStore instead of memoryStore when:
- multiple processes share the request-handling fleet (forks behind a load balancer, multi-instance K8s deployment) and a retry can land on a different process than the original; - the daemon may restart between the original request and the retry (graceful rolling deploy, OOM kill, planned reboot) — memoryStore is volatile, dbStore survives the restart; - audit / compliance review needs to walk historic idempotency cache decisions queryable with SELECT k, status_code, expires_at FROM — non-sealed columns are forensic-queryable without unsealing.
**Defense-in-depth defaults — every option below ships on by default:**
- hashKeys: true (since 0.9.15) — operator-supplied keys are sha3-512 namespace-hashed via b.crypto.namespaceHash("idempotency-key", key) before insert/lookup. The k column carries the hash, not the raw key. Operator keys often carry PII (order numbers, emails, vendor prefixes); the DB never sees them. - seal: true (since 0.9.15) — headers and body columns are sealed via b.cryptoField.sealRow (vault-managed key, AEAD envelope) so a DB dump leaks neither cached response bodies nor headers. Requires b.vault.init(...) to have run; falls back to plain-text with a one-shot audit warning when vault isn't ready, so test-fixture / boot-script callers still work. - aad: true (since 0.9.58) — sealed columns are bound via Additional Authenticated Data to (table, k, column, schemaVersion) so a DB-write attacker can't copy a sealed header/body cell from one row to another (which previously decrypted cleanly under plain vault.seal). Existing v0.9.15- v0.9.57 dbStore tables continue to read because unsealRow auto- detects the envelope shape; lazy re-seal on next set() upgrades each row to AAD form. Operators wanting a one-shot migration call b.middleware.idempotencyKey.resealMigrate(store). - fingerprintSeal: true (since 0.9.58) — the request fingerprint column carries an HMAC under a vault-derived secret instead of a bare SHA3-256 of method+path+body. The compare path is constant-time so the column doubles as a mismatch oracle without offline-brute-force exposure. - bodyFingerprintFallback: "deny" (since 0.9.58) — when neither bodyFingerprint nor req._rawBody/req.body is populated for a body-bearing method, the middleware previously silently degraded the fingerprint to method+path. Set to "deny" (the new default) and the middleware refuses the request with HTTP 400 idempotency/missing-body-fingerprint instead. Operators with a documented "no body" use case set bodyFingerprintFallback: "method-path-only" to restore the pre-0.9.58 behavior — the audit chain still emits idempotency.empty_body_fingerprint so the misorder is visible.
Lazily-expired: get(key) returns null for any row whose expires_at has passed. The cleanup is scoped by the observed expires_at so a concurrent upsert from a sibling process isn't clobbered.
**Schema (v0.9.15, split columns):**
k TEXT PRIMARY KEY -- hashed key when hashKeys=true
fingerprint TEXT NOT NULL -- request method+path+body digest
status_code INTEGER NOT NULL -- forensic-queryable
headers TEXT NOT NULL -- JSON, sealed when seal=true
body TEXT NOT NULL -- base64, sealed when seal=true
expires_at INTEGER NOT NULL
**Migration note**: v0.9.14 used a single v JSON envelope column. Operators with a v0.9.14 table must DROP TABLE (or pick a fresh tableName) before upgrading — CREATE TABLE IF NOT EXISTS won't migrate column layout. Pre-v1 the framework breaks across patch versions for security correctness.
// single-process daemon, framework's internal sqlite, both defaults on:
var b = require("blamejs");
await b.vault.init({ dataDir: "/var/lib/myapp" });
await b.db.init({ dataDir: "/var/lib/myapp", schema: [] });
var store = b.middleware.idempotencyKey.dbStore({ db: b.db });
var mw = b.middleware.idempotencyKey({
store: store,
ttlMs: b.constants.TIME.hours(24),
});
app.use(mw);
b.middleware.idempotencyKey(opts) #
{
store: object, // required — get/set/delete interface
ttlMs: number, // default: 24h
methods: string[], // default: ["POST","PUT","PATCH","DELETE"]
headerName: string, // default: "idempotency-key"
requireIdempotencyKey: boolean, // default: false — refuse missing-key
bodyFingerprint: function, // (req) => Buffer|string|object|null — operator-supplied body extractor
maxBodyBytes: number, // default: 1 MiB — replay-cache body cap
bodyFingerprintFallback: string, // default "deny" — when neither
// bodyFingerprint nor req._rawBody / req.body is
// available for POST/PUT/PATCH, refuse with HTTP 400
// idempotency/missing-body-fingerprint instead of
// silently degrading the fingerprint to method+path.
// Set to "method-path-only" to restore the pre-0.9.58
// behavior (the audit chain still logs
// idempotency.empty_body_fingerprint so the
// misorder is visible in operator review).
**Mount order — idempotency MUST run AFTER body-parser.** The hook
(and the default `req._rawBody||req.body` lookup) reads request
state at the moment the idempotency middleware runs; if it runs
before body-parser, `req.body` is still unset and the fingerprint
silently degrades to method+path only — which fails the §4.3
"same key, different body" guarantee. `b.middleware.composePipeline`
places bodyParser=20 / idempotency=30 by default so the canonical
order is correct; operators wiring middleware manually must mount
idempotency AFTER bodyParser. The runtime emits
`idempotency.empty_body_fingerprint` audit (warning) whenever a
body-bearing request reaches the middleware with no body data,
so the misordering is detectable from audit logs.
}
Build the Idempotency-Key middleware. Returns a connect-style (req, res, next) => void handler.
- When req.method is not in opts.methods (default POST / PUT / PATCH / DELETE), the middleware is a pass-through. - When the request lacks an Idempotency-Key header and opts.requireIdempotencyKey === true, refuses with HTTP 400 + application/problem+json body idempotency/missing-key. - When the key is present but malformed (control chars, length out of range), refuses with HTTP 400 + idempotency/bad-key. - When the store has a hit AND the cached fingerprint matches the inbound request fingerprint, replays the cached { statusCode, headers, body } and DOES NOT call next(). - When the store has a hit AND the fingerprint differs, refuses with HTTP 422 + idempotency/key-reuse-mismatch. - On a miss, wraps res.end to capture the handler's response and persist { fingerprint, statusCode, headers, body } to the store with ttlMs (default 24h) after the handler finishes. The wrapper does NOT capture 5xx server-error responses — replaying a transient infrastructure failure is not idempotent.
Per the draft §4.4, a concurrent-retry from the same client (two requests with the same key arriving in quick succession before the first has written to the store) is allowed to handler-execute twice and either response is acceptable; the framework does not lock the key. Operators wanting strict at-most-once execution implement a distributed-lock layer in their store's set() method (the interface is opaque to the middleware).
var store = b.middleware.idempotencyKey.memoryStore({ maxEntries: 10000 });
var mw = b.middleware.idempotencyKey({
store: store,
ttlMs: C.TIME.hours(24),
methods: ["POST", "PUT", "PATCH"],
// Optional: provide a body-fingerprint extractor that pulls
// from the parsed body shape. The extractor only runs against
// state populated by upstream middleware; mount idempotency
// AFTER bodyParser (composePipeline does this by default).
bodyFingerprint: function (req) { return req.body || null; },
});
app.use(mw);
b.middleware.idempotencyKey.resealMigrate(store) #
One-shot operator helper that walks a dbStore's table and reseals every row under the AAD-bound envelope shape introduced in v0.9.58. Existing v0.9.15-v0.9.57 rows continue to read on a per-row basis (unsealRow auto-detects shape) so a deploy without this call is correct, but operators who want to upgrade in bulk call this once after upgrading.
Returns { migrated, skipped, reason }. migrated counts rows rewritten with AAD-bound ciphertext; skipped counts rows already AAD-shaped or that failed unseal under the current key (those rows stay in place and surface via the standard idempotency.unseal_failed audit on next read). reason is null on success; populated when the store doesn't support migration (in-memory store, custom operator-supplied store, etc.).
var store = b.middleware.idempotencyKey.dbStore({ db: myDb });
var info = b.middleware.idempotencyKey.resealMigrate(store);
logger.info("idempotency migration", info);
Last updated 2026-08-08T16:39:49.652Z by seeder.