@nifrajs/middleware 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +11 -6
  2. package/dist/_ip.d.ts +17 -0
  3. package/dist/_ip.d.ts.map +1 -0
  4. package/dist/_ip.js +91 -0
  5. package/dist/_ip.js.map +1 -0
  6. package/dist/_utils.d.ts +16 -0
  7. package/dist/_utils.d.ts.map +1 -1
  8. package/dist/_utils.js +33 -0
  9. package/dist/_utils.js.map +1 -1
  10. package/dist/basic-auth.d.ts.map +1 -1
  11. package/dist/basic-auth.js +2 -2
  12. package/dist/basic-auth.js.map +1 -1
  13. package/dist/body-limit.js +2 -2
  14. package/dist/body-limit.js.map +1 -1
  15. package/dist/cache.d.ts.map +1 -1
  16. package/dist/cache.js +110 -22
  17. package/dist/cache.js.map +1 -1
  18. package/dist/compression.d.ts.map +1 -1
  19. package/dist/compression.js +13 -2
  20. package/dist/compression.js.map +1 -1
  21. package/dist/cors.d.ts.map +1 -1
  22. package/dist/cors.js +50 -5
  23. package/dist/cors.js.map +1 -1
  24. package/dist/csrf.d.ts +21 -2
  25. package/dist/csrf.d.ts.map +1 -1
  26. package/dist/csrf.js +99 -10
  27. package/dist/csrf.js.map +1 -1
  28. package/dist/etag.d.ts.map +1 -1
  29. package/dist/etag.js +7 -1
  30. package/dist/etag.js.map +1 -1
  31. package/dist/idempotency.d.ts +16 -7
  32. package/dist/idempotency.d.ts.map +1 -1
  33. package/dist/idempotency.js +51 -22
  34. package/dist/idempotency.js.map +1 -1
  35. package/dist/ip-restriction.d.ts +12 -7
  36. package/dist/ip-restriction.d.ts.map +1 -1
  37. package/dist/ip-restriction.js +25 -72
  38. package/dist/ip-restriction.js.map +1 -1
  39. package/dist/jwt.d.ts.map +1 -1
  40. package/dist/jwt.js +3 -3
  41. package/dist/jwt.js.map +1 -1
  42. package/dist/language.d.ts.map +1 -1
  43. package/dist/language.js +4 -1
  44. package/dist/language.js.map +1 -1
  45. package/dist/multipart.d.ts.map +1 -1
  46. package/dist/multipart.js +6 -1
  47. package/dist/multipart.js.map +1 -1
  48. package/dist/pretty-json.d.ts.map +1 -1
  49. package/dist/pretty-json.js +63 -7
  50. package/dist/pretty-json.js.map +1 -1
  51. package/dist/problem-details.d.ts.map +1 -1
  52. package/dist/problem-details.js +8 -1
  53. package/dist/problem-details.js.map +1 -1
  54. package/dist/range.d.ts.map +1 -1
  55. package/dist/range.js +5 -2
  56. package/dist/range.js.map +1 -1
  57. package/dist/rate-limit.d.ts +11 -10
  58. package/dist/rate-limit.d.ts.map +1 -1
  59. package/dist/rate-limit.js +29 -19
  60. package/dist/rate-limit.js.map +1 -1
  61. package/dist/request-id.d.ts +8 -1
  62. package/dist/request-id.d.ts.map +1 -1
  63. package/dist/request-id.js +6 -3
  64. package/dist/request-id.js.map +1 -1
  65. package/dist/security-headers.d.ts +4 -0
  66. package/dist/security-headers.d.ts.map +1 -1
  67. package/dist/security-headers.js +7 -1
  68. package/dist/security-headers.js.map +1 -1
  69. package/dist/token-auth.d.ts.map +1 -1
  70. package/dist/token-auth.js +3 -5
  71. package/dist/token-auth.js.map +1 -1
  72. package/dist/trailing-slash.d.ts.map +1 -1
  73. package/dist/trailing-slash.js +12 -1
  74. package/dist/trailing-slash.js.map +1 -1
  75. package/package.json +6 -3
  76. package/src/_ip.ts +87 -0
  77. package/src/_utils.ts +37 -0
  78. package/src/basic-auth.ts +2 -1
  79. package/src/body-limit.ts +2 -2
  80. package/src/cache.ts +119 -21
  81. package/src/compression.ts +13 -2
  82. package/src/cors.ts +51 -7
  83. package/src/csrf.ts +115 -10
  84. package/src/etag.ts +11 -1
  85. package/src/idempotency.ts +76 -28
  86. package/src/ip-restriction.ts +38 -75
  87. package/src/jwt.ts +4 -2
  88. package/src/language.ts +5 -1
  89. package/src/multipart.ts +6 -1
  90. package/src/pretty-json.ts +56 -7
  91. package/src/problem-details.ts +8 -1
  92. package/src/range.ts +6 -3
  93. package/src/rate-limit.ts +48 -31
  94. package/src/request-id.ts +16 -4
  95. package/src/security-headers.ts +7 -1
  96. package/src/token-auth.ts +3 -5
  97. package/src/trailing-slash.ts +13 -1
package/src/cors.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { withResponseObserver } from "@nifrajs/core/response-observer"
2
- import type { Middleware } from "@nifrajs/core/server"
2
+ import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
3
+ import { setNodeHeader } from "./_utils.ts"
4
+
5
+ const BUN_NATIVE_REQUEST_SAFE = Symbol.for("@nifrajs/core/bun-native-request-safe")
3
6
 
4
7
  export interface CorsOptions {
5
8
  /** Allowed origin(s): `"*"`, an exact origin, a list, or a predicate. Default `"*"`. */
@@ -38,6 +41,31 @@ function resolveAllowOrigin(
38
41
  return option === requestOrigin ? requestOrigin : null
39
42
  }
40
43
 
44
+ function nodeHeader(
45
+ headers: Readonly<Record<string, string | readonly string[]>> | undefined,
46
+ name: string,
47
+ headersAreLowercase: boolean | undefined,
48
+ ): string | null {
49
+ if (headers === undefined) return null
50
+ const direct = headers[name]
51
+ if (direct !== undefined) return typeof direct === "string" ? direct : direct.join(", ")
52
+ if (headersAreLowercase) return null
53
+ for (const key of Object.keys(headers)) {
54
+ if (key.toLowerCase() !== name) continue
55
+ const value = headers[key]
56
+ return value === undefined ? null : typeof value === "string" ? value : value.join(", ")
57
+ }
58
+ return null
59
+ }
60
+
61
+ function varyOnOrigin(value: string | null): string {
62
+ if (value === null || value.trim() === "") return "Origin"
63
+ const fields = value.split(",").map((field) => field.trim())
64
+ // `Vary: *` is the complete field value, not one member of a list.
65
+ if (fields.includes("*")) return "*"
66
+ return fields.some((field) => field.toLowerCase() === "origin") ? value : `${value}, Origin`
67
+ }
68
+
41
69
  /**
42
70
  * CORS as a {@link Middleware}. Preflight (`OPTIONS` + `Access-Control-Request-Method`)
43
71
  * short-circuits to `204` via `onRequest`; the origin/credentials headers are added in
@@ -59,7 +87,7 @@ export function cors(options: CorsOptions = {}): Middleware {
59
87
  const exposedHeaders = options.exposedHeaders?.join(", ")
60
88
  const maxAge = options.maxAge
61
89
 
62
- return withResponseObserver({
90
+ const middleware = withResponseObserver({
63
91
  name: "cors",
64
92
  onRequest(req) {
65
93
  const isPreflight =
@@ -86,15 +114,31 @@ export function cors(options: CorsOptions = {}): Middleware {
86
114
  return new Response(null, { status: 204, headers })
87
115
  },
88
116
  onResponseHeaders(headers, req) {
117
+ // Any policy other than `*` makes the answer depend on the request's Origin - including the
118
+ // answer that omits Allow-Origin. Without `Vary` on that one, a shared cache stores the
119
+ // header-less response and replays it to an allowed origin, breaking CORS for everyone.
120
+ if (origin !== "*") {
121
+ headers.set("vary", varyOnOrigin(headers.get("vary")))
122
+ }
89
123
  const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
90
124
  if (allowOrigin === null) return
91
125
  headers.set("access-control-allow-origin", allowOrigin)
92
- if (allowOrigin !== "*") {
93
- const vary = headers.get("vary")
94
- headers.set("vary", vary === null || vary === "" ? "Origin" : `${vary}, Origin`)
95
- }
96
126
  if (credentials) headers.set("access-control-allow-credentials", "true")
97
127
  if (exposedHeaders !== undefined) headers.set("access-control-expose-headers", exposedHeaders)
98
128
  },
99
- })
129
+ onNodeResponseHeaders(response: NodeResponseContext, req: NodeRequestContext) {
130
+ if (origin !== "*") {
131
+ const vary = nodeHeader(response.headers, "vary", response.headersAreLowercase)
132
+ setNodeHeader(response, "vary", varyOnOrigin(vary))
133
+ }
134
+ const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
135
+ if (allowOrigin === null) return
136
+ setNodeHeader(response, "access-control-allow-origin", allowOrigin)
137
+ if (credentials) setNodeHeader(response, "access-control-allow-credentials", "true")
138
+ if (exposedHeaders !== undefined)
139
+ setNodeHeader(response, "access-control-expose-headers", exposedHeaders)
140
+ },
141
+ } satisfies Middleware)
142
+ Object.defineProperty(middleware, BUN_NATIVE_REQUEST_SAFE, { value: true })
143
+ return middleware
100
144
  }
package/src/csrf.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { METHODS, type Middleware } from "@nifrajs/core/server"
2
+ import { isSameOriginRequest, METHODS, type Middleware } from "@nifrajs/core/server"
3
3
  import {
4
4
  base64UrlEncode,
5
+ guardName,
5
6
  hmacSha256,
6
7
  jsonError,
7
8
  parseCookies,
@@ -35,9 +36,27 @@ export interface CsrfOptions {
35
36
  readonly cookie?: string
36
37
  /** Header carrying the same signed token. Default `"x-csrf-token"`. */
37
38
  readonly header?: string
39
+ /**
40
+ * Form field that may carry the token when the header is absent, so a plain HTML `<form>` (no
41
+ * script to set a header) can submit - e.g. `field: "_csrf"` with
42
+ * `<input type="hidden" name="_csrf" value="...">`. Read from `application/x-www-form-urlencoded`
43
+ * and `multipart/form-data` bodies only, through a clone, so the handler still reads the body.
44
+ * Unset (default), only the header is accepted.
45
+ *
46
+ * A cross-site form can set any field but never a custom header, so keep `checkOrigin` on with a
47
+ * field: the Origin check is what stops a site that can plant a cookie on your domain (a sibling
48
+ * subdomain) from pairing it with a matching field.
49
+ */
50
+ readonly field?: string
51
+ /**
52
+ * Largest body scanned for {@link field}, in bytes. Default 64 KiB. A bigger form (a file upload)
53
+ * fails closed - send the header, or raise this for that app.
54
+ */
55
+ readonly fieldMaxBytes?: number
38
56
  /** Unsafe methods to protect. Default: every method except GET/HEAD/OPTIONS/TRACE. */
39
57
  readonly methods?: readonly string[]
40
- /** Allowed request origins. Default: same-origin derived from the request URL. */
58
+ /** Allowed request origins. Default: same host as the request URL, where an `https:` Origin may
59
+ * reach an `http:` URL (what a TLS-terminating proxy looks like from the server). */
41
60
  readonly origins?: readonly string[]
42
61
  /** Check Origin/Referer on protected requests. Default true. */
43
62
  readonly checkOrigin?: boolean
@@ -49,20 +68,95 @@ function protectedMethod(method: string, configured: Set<string> | undefined): b
49
68
  return configured !== undefined ? configured.has(method) : !SAFE_METHODS.has(method)
50
69
  }
51
70
 
71
+ /**
72
+ * An explicit allowlist compares exact origins. The same-origin default goes through core's
73
+ * {@link isSameOriginRequest} instead of comparing against `new URL(req.url).origin`: behind a
74
+ * TLS-terminating proxy the request URL says `http:` while the browser reports `https:`, and an exact
75
+ * compare rejected every protected request there.
76
+ */
52
77
  function originAllowed(req: Request, origins: Set<string> | undefined): boolean {
53
- const allowed = origins ?? new Set([new URL(req.url).origin])
78
+ const allows = (candidate: string): boolean =>
79
+ origins !== undefined ? origins.has(candidate) : isSameOriginRequest(candidate, req)
54
80
  const origin = req.headers.get("origin")
55
- if (origin !== null) return allowed.has(origin)
81
+ if (origin !== null) return allows(origin)
56
82
 
57
83
  const referer = req.headers.get("referer")
58
84
  if (referer === null) return false
59
85
  try {
60
- return allowed.has(new URL(referer).origin)
86
+ return allows(new URL(referer).origin)
61
87
  } catch {
62
88
  return false
63
89
  }
64
90
  }
65
91
 
92
+ function mediaEssence(contentType: string): string {
93
+ const semi = contentType.indexOf(";")
94
+ return (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
95
+ }
96
+
97
+ /** Up to `maxBytes` of a request clone, or `null` when it is longer (or fails mid-read). */
98
+ async function readAtMost(req: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer> | null> {
99
+ const body = req.clone().body
100
+ if (body === null) return null
101
+ const reader = body.getReader()
102
+ const chunks: Uint8Array[] = []
103
+ let total = 0
104
+ try {
105
+ for (;;) {
106
+ const { done, value } = await reader.read()
107
+ if (done) break
108
+ total += value.byteLength
109
+ if (total > maxBytes) {
110
+ // A clone tees the request: cancel both branches on terminal rejection, without waiting
111
+ // for the unread branch or an upstream cleanup promise that may never settle.
112
+ void Promise.allSettled([reader.cancel(), req.body?.cancel()])
113
+ return null
114
+ }
115
+ chunks.push(value)
116
+ }
117
+ } catch {
118
+ return null
119
+ } finally {
120
+ reader.releaseLock()
121
+ }
122
+ const out = new Uint8Array(total)
123
+ let offset = 0
124
+ for (const chunk of chunks) {
125
+ out.set(chunk, offset)
126
+ offset += chunk.byteLength
127
+ }
128
+ return out
129
+ }
130
+
131
+ /**
132
+ * The token a form body carries in `field`, read from a clone so the handler keeps the original body.
133
+ * Anything unexpected - another media type, an oversized or unreadable body, a file part under the
134
+ * field's name - yields `null`, which the caller treats as a missing token.
135
+ */
136
+ async function formFieldToken(
137
+ req: Request,
138
+ field: string,
139
+ maxBytes: number,
140
+ ): Promise<string | null> {
141
+ if (req.body === null) return null
142
+ const contentType = req.headers.get("content-type") ?? ""
143
+ const essence = mediaEssence(contentType)
144
+ const urlencoded = essence === "application/x-www-form-urlencoded"
145
+ if (!urlencoded && essence !== "multipart/form-data") return null
146
+ const declared = req.headers.get("content-length")
147
+ if (declared !== null && !(Number(declared) <= maxBytes)) return null
148
+ const bytes = await readAtMost(req, maxBytes)
149
+ if (bytes === null) return null
150
+ try {
151
+ if (urlencoded) return new URLSearchParams(new TextDecoder().decode(bytes)).get(field)
152
+ const form = await new Response(bytes, { headers: { "content-type": contentType } }).formData()
153
+ const value = form.get(field)
154
+ return typeof value === "string" ? value : null
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
66
160
  export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promise<string> {
67
161
  const key = csrfKeys(secret)[0] as Uint8Array
68
162
  const tokenNonce = nonce ?? base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
@@ -93,27 +187,38 @@ export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promis
93
187
 
94
188
  /**
95
189
  * Signed double-submit CSRF protection. A protected request must carry the same signed token in a
96
- * cookie and a header, and must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
190
+ * cookie and in a header (or, for a plain HTML form, the {@link CsrfOptions.field} form field), and
191
+ * must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
97
192
  */
98
193
  export function csrf(options: CsrfOptions): Middleware {
99
194
  const keys = csrfKeys(options.secret)
100
195
  const cookie = options.cookie ?? "csrf-token"
101
196
  const header = (options.header ?? "x-csrf-token").toLowerCase()
197
+ const field = options.field
198
+ if (field !== undefined && field.trim() === "") throw new Error("csrf: field must not be empty")
199
+ const fieldMaxBytes = options.fieldMaxBytes ?? 64 * 1024
200
+ if (!Number.isInteger(fieldMaxBytes) || fieldMaxBytes < 1) {
201
+ throw new Error("csrf: fieldMaxBytes must be a positive integer")
202
+ }
102
203
  const methods =
103
204
  options.methods !== undefined ? new Set(options.methods.map((m) => m.toUpperCase())) : undefined
104
205
  const origins = options.origins !== undefined ? new Set(options.origins) : undefined
105
206
  const checkOrigin = options.checkOrigin !== false
106
207
 
107
208
  const middleware: Middleware = {
108
- name: "csrf",
209
+ name: guardName("csrf"),
109
210
  async onRequest(req) {
110
211
  if (!protectedMethod(req.method, methods)) return undefined
111
212
  if (checkOrigin && !originAllowed(req, origins)) return jsonError(403, "csrf_failed")
112
213
 
113
214
  const cookieToken = parseCookies(req.headers.get("cookie"))[cookie]
114
- const headerToken = req.headers.get(header)
115
- if (cookieToken === undefined || headerToken === null) return jsonError(403, "csrf_failed")
116
- if (!(await timingSafeEqualString(cookieToken, headerToken))) {
215
+ if (cookieToken === undefined) return jsonError(403, "csrf_failed")
216
+ // The header wins; the body is only read (from a clone) when a form had no way to set one.
217
+ const requestToken =
218
+ req.headers.get(header) ??
219
+ (field === undefined ? null : await formFieldToken(req, field, fieldMaxBytes))
220
+ if (requestToken === null) return jsonError(403, "csrf_failed")
221
+ if (!(await timingSafeEqualString(cookieToken, requestToken))) {
117
222
  return jsonError(403, "csrf_failed")
118
223
  }
119
224
  return (await verifyCsrfToken(cookieToken, keys)) ? undefined : jsonError(403, "csrf_failed")
package/src/etag.ts CHANGED
@@ -137,6 +137,16 @@ export function etag(options: ETagOptions = {}) {
137
137
  if (req.method !== "GET" || response.status !== 200 || response.body === null) {
138
138
  return response
139
139
  }
140
+ // Hashing reads the body before any of it is sent. A stream meant to arrive as it is
141
+ // produced - an event stream, or one marked no-store or no-transform - passes through.
142
+ if (
143
+ /^text\/event-stream\b/i.test(response.headers.get("content-type") ?? "") ||
144
+ /(?:^|,)\s*(?:no-store|no-transform)\s*(?:,|$)/i.test(
145
+ response.headers.get("cache-control") ?? "",
146
+ )
147
+ ) {
148
+ return response
149
+ }
140
150
  const declared = response.headers.get("content-length")
141
151
  if (
142
152
  declared !== null &&
@@ -173,7 +183,7 @@ async function etagRawResponse(
173
183
  // The body was consumed and rebuilt from bytes. Re-derive framing at the serving adapter
174
184
  // instead of trusting a stale upstream declaration.
175
185
  headers.delete("content-length")
176
- return new Response(read.bytes, {
186
+ return new Response(read.bytes.slice(), {
177
187
  status: response.status,
178
188
  statusText: response.statusText,
179
189
  headers,
@@ -1,5 +1,5 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { METHODS, type Middleware } from "@nifrajs/core/server"
2
+ import { METHODS, type Middleware, replacedRequestOf } from "@nifrajs/core/server"
3
3
 
4
4
  /**
5
5
  * Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
@@ -22,7 +22,7 @@ export interface IdempotencyRecord {
22
22
  }
23
23
 
24
24
  export type IdempotencyClaim =
25
- | { readonly state: "new" }
25
+ | { readonly state: "new"; readonly reservation: string }
26
26
  | { readonly state: "in_flight" }
27
27
  | { readonly state: "replay"; readonly record: IdempotencyRecord }
28
28
 
@@ -38,10 +38,15 @@ export interface IdempotencyStore {
38
38
  * The in-flight lock expires after `lockTtlMs` so a crashed handler can't wedge the key forever.
39
39
  */
40
40
  begin(key: string, lockTtlMs: number): Promise<IdempotencyClaim>
41
- /** Store the completed response and release the lock (kept for `ttlMs`). */
42
- complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void>
43
- /** Release the lock without storing (handler errored / response not cacheable). */
44
- release(key: string): Promise<void>
41
+ /** Store the completed response only when `reservation` still owns the lock (kept for `ttlMs`). */
42
+ complete(
43
+ key: string,
44
+ reservation: string,
45
+ record: IdempotencyRecord,
46
+ ttlMs: number,
47
+ ): Promise<boolean>
48
+ /** Release only the lock owned by `reservation` (handler errored / response not cacheable). */
49
+ release(key: string, reservation: string): Promise<boolean>
45
50
  }
46
51
 
47
52
  export interface MemoryIdempotencyStoreOptions {
@@ -54,7 +59,7 @@ export interface MemoryIdempotencyStoreOptions {
54
59
  }
55
60
 
56
61
  type Entry =
57
- | { readonly kind: "lock"; readonly expiresAt: number }
62
+ | { readonly kind: "lock"; readonly reservation: string; readonly expiresAt: number }
58
63
  | { readonly kind: "record"; readonly record: IdempotencyRecord; readonly expiresAt: number }
59
64
 
60
65
  const KEY_ENCODER = new TextEncoder()
@@ -137,21 +142,38 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
137
142
  )
138
143
  }
139
144
  // Free (or expired): take the lock. Synchronous Map write ⇒ atomic on a single instance.
140
- this.reserve(key, { kind: "lock", expiresAt: now + lockTtlMs })
141
- return Promise.resolve({ state: "new" })
145
+ const reservation = crypto.randomUUID()
146
+ this.reserve(key, { kind: "lock", reservation, expiresAt: now + lockTtlMs })
147
+ return Promise.resolve({ state: "new", reservation })
142
148
  }
143
149
 
144
- complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void> {
150
+ complete(
151
+ key: string,
152
+ reservation: string,
153
+ record: IdempotencyRecord,
154
+ ttlMs: number,
155
+ ): Promise<boolean> {
145
156
  this.validateKey(key)
146
157
  assertPositiveTtl(ttlMs, "ttlMs")
147
- this.reserve(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
148
- return Promise.resolve()
158
+ const entry = this.entries.get(key)
159
+ if (entry === undefined || entry.kind !== "lock" || entry.reservation !== reservation) {
160
+ return Promise.resolve(false)
161
+ }
162
+ if (entry.expiresAt <= Date.now()) {
163
+ this.entries.delete(key)
164
+ return Promise.resolve(false)
165
+ }
166
+ this.entries.set(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
167
+ return Promise.resolve(true)
149
168
  }
150
169
 
151
- release(key: string): Promise<void> {
170
+ release(key: string, reservation: string): Promise<boolean> {
152
171
  const entry = this.entries.get(key)
153
- if (entry !== undefined && entry.kind === "lock") this.entries.delete(key)
154
- return Promise.resolve()
172
+ if (entry === undefined || entry.kind !== "lock" || entry.reservation !== reservation) {
173
+ return Promise.resolve(false)
174
+ }
175
+ this.entries.delete(key)
176
+ return Promise.resolve(true)
155
177
  }
156
178
  }
157
179
 
@@ -168,7 +190,11 @@ export interface IdempotencyOptions {
168
190
  readonly lockTtlMs?: number
169
191
  /** Max response bytes to cache. A larger response is returned but **not** stored. Default 1 MiB. */
170
192
  readonly maxBytes?: number
171
- /** Whether a response should be cached for replay. Default: status `< 500` (don't replay transient 5xx). */
193
+ /**
194
+ * Whether a response should be cached for replay. Default: any status below 500 except 401, 403,
195
+ * 408, 409, 425, and 429. Those, like a transient 5xx, say the operation did not run; replaying one
196
+ * would refuse every retry under that key until it expired.
197
+ */
172
198
  readonly shouldCache?: (response: Response) => boolean
173
199
  /**
174
200
  * Derive the store key from the request. Default: the `header` value scoped by method + path **and
@@ -197,6 +223,7 @@ export interface IdempotencyOptions {
197
223
  }
198
224
 
199
225
  const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
226
+ const NOT_THE_OPERATIONS_ANSWER = new Set([401, 403, 408, 409, 425, 429])
200
227
  const DAY_MS = 24 * 60 * 60 * 1000
201
228
 
202
229
  function assertPositiveTtl(value: number, name: string): void {
@@ -250,7 +277,8 @@ function responseWithBody(
250
277
  res: Response,
251
278
  body: Uint8Array | ReadableStream<Uint8Array> | null,
252
279
  ): Response {
253
- return new Response(body, {
280
+ const safeBody = body instanceof Uint8Array ? body.slice() : body
281
+ return new Response(safeBody, {
254
282
  status: res.status,
255
283
  statusText: res.statusText,
256
284
  headers: res.headers,
@@ -332,7 +360,8 @@ async function captureBody(
332
360
  function replay(record: IdempotencyRecord): Response {
333
361
  const headers = new Headers(record.headers as Array<[string, string]>)
334
362
  headers.set("idempotent-replayed", "true")
335
- return new Response(bodyFor(fromBase64(record.body)), { status: record.status, headers })
363
+ const body = bodyFor(fromBase64(record.body))
364
+ return new Response(body === null ? null : body.slice(), { status: record.status, headers })
336
365
  }
337
366
 
338
367
  const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
@@ -381,6 +410,10 @@ function defaultIdempotencyKey(
381
410
  * session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
382
411
  * session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
383
412
  *
413
+ * The key is not bound to the request body: a key reused with a different body replays the first
414
+ * answer. A route that needs that check declares `schema.idempotency`, which fingerprints the request
415
+ * and refuses a mismatched reuse with 409.
416
+ *
384
417
  * Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses.
385
418
  */
386
419
  export function idempotency(options: IdempotencyOptions): Middleware {
@@ -395,7 +428,9 @@ export function idempotency(options: IdempotencyOptions): Middleware {
395
428
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
396
429
  throw new Error("idempotency: maxBytes must be a non-negative integer")
397
430
  }
398
- const shouldCache = options.shouldCache ?? ((res: Response) => res.status < 500)
431
+ const shouldCache =
432
+ options.shouldCache ??
433
+ ((res: Response) => res.status < 500 && !NOT_THE_OPERATIONS_ANSWER.has(res.status))
399
434
  const principalHeaders = (options.principalHeaders ?? DEFAULT_PRINCIPAL_HEADERS).map((name) =>
400
435
  name.toLowerCase(),
401
436
  )
@@ -405,7 +440,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
405
440
  ? (req: Request): Promise<string | null> | null =>
406
441
  defaultIdempotencyKey(req, header, principalHeaders)
407
442
  : (req: Request): string | null | Promise<string | null> => custom(req, header)
408
- const claimed = new WeakMap<Request, string>()
443
+ const claimed = new WeakMap<Request, { readonly key: string; readonly reservation: string }>()
409
444
 
410
445
  const middleware: Middleware = {
411
446
  name: "idempotency",
@@ -436,15 +471,23 @@ export function idempotency(options: IdempotencyOptions): Middleware {
436
471
  },
437
472
  })
438
473
  }
439
- claimed.set(req, key)
474
+ if (claim.state !== "new") return undefined
475
+ claimed.set(req, { key, reservation: claim.reservation })
440
476
  return undefined
441
477
  },
442
478
  async onResponse(res, req) {
443
- const key = claimed.get(req)
444
- if (key === undefined) return res // not a claimed request (safe method / no key / a replay)
445
- claimed.delete(req)
479
+ // A later onRequest hook (methodOverride, a transport codec) may have replaced the request
480
+ // this one claimed; walk back to it.
481
+ let claimedReq: Request | undefined = req
482
+ let claim = claimed.get(req)
483
+ while (claim === undefined && claimedReq !== undefined) {
484
+ claimedReq = replacedRequestOf(claimedReq)
485
+ if (claimedReq !== undefined) claim = claimed.get(claimedReq)
486
+ }
487
+ if (claim === undefined || claimedReq === undefined) return res // not a claimed request
488
+ claimed.delete(claimedReq)
446
489
  if (!shouldCache(res)) {
447
- await store.release(key)
490
+ await store.release(claim.key, claim.reservation)
448
491
  return res
449
492
  }
450
493
  // Buffer the body (consumes `res`), so a fresh Response is returned in its place.
@@ -452,16 +495,21 @@ export function idempotency(options: IdempotencyOptions): Middleware {
452
495
  try {
453
496
  captured = await captureBody(res, maxBytes)
454
497
  } catch (err) {
455
- await store.release(key)
498
+ await store.release(claim.key, claim.reservation)
456
499
  throw err
457
500
  }
458
501
  if (!("bytes" in captured)) {
459
- await store.release(key) // too large to store - return it, but don't cache
502
+ await store.release(claim.key, claim.reservation) // too large to store - return it, but don't cache
460
503
  return captured.response
461
504
  }
462
505
  const bytes = captured.bytes
463
506
  const headers = [...captured.response.headers].filter(([name]) => name !== "set-cookie")
464
- await store.complete(key, { status: res.status, headers, body: toBase64(bytes) }, ttlMs)
507
+ await store.complete(
508
+ claim.key,
509
+ claim.reservation,
510
+ { status: res.status, headers, body: toBase64(bytes) },
511
+ ttlMs,
512
+ )
465
513
  return captured.response
466
514
  },
467
515
  }