@nifrajs/middleware 4.0.2 → 5.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 (81) hide show
  1. package/README.md +3 -0
  2. package/dist/_utils.d.ts +15 -9
  3. package/dist/_utils.d.ts.map +1 -1
  4. package/dist/_utils.js +39 -40
  5. package/dist/_utils.js.map +1 -1
  6. package/dist/basic-auth.d.ts.map +1 -1
  7. package/dist/basic-auth.js +8 -13
  8. package/dist/basic-auth.js.map +1 -1
  9. package/dist/cache-control.d.ts.map +1 -1
  10. package/dist/cache-control.js +4 -2
  11. package/dist/cache-control.js.map +1 -1
  12. package/dist/cache.d.ts +7 -4
  13. package/dist/cache.d.ts.map +1 -1
  14. package/dist/cache.js +58 -25
  15. package/dist/cache.js.map +1 -1
  16. package/dist/compression.d.ts.map +1 -1
  17. package/dist/compression.js +58 -1
  18. package/dist/compression.js.map +1 -1
  19. package/dist/conditional.d.ts.map +1 -1
  20. package/dist/conditional.js +1 -1
  21. package/dist/conditional.js.map +1 -1
  22. package/dist/content-negotiation.d.ts.map +1 -1
  23. package/dist/content-negotiation.js +3 -1
  24. package/dist/content-negotiation.js.map +1 -1
  25. package/dist/csrf.d.ts.map +1 -1
  26. package/dist/csrf.js +10 -5
  27. package/dist/csrf.js.map +1 -1
  28. package/dist/healthcheck.d.ts.map +1 -1
  29. package/dist/healthcheck.js +3 -1
  30. package/dist/healthcheck.js.map +1 -1
  31. package/dist/idempotency.d.ts +10 -2
  32. package/dist/idempotency.d.ts.map +1 -1
  33. package/dist/idempotency.js +8 -4
  34. package/dist/idempotency.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/jwt.d.ts +10 -2
  40. package/dist/jwt.d.ts.map +1 -1
  41. package/dist/jwt.js +52 -31
  42. package/dist/jwt.js.map +1 -1
  43. package/dist/language.d.ts.map +1 -1
  44. package/dist/language.js +12 -0
  45. package/dist/language.js.map +1 -1
  46. package/dist/openapi.d.ts +3 -0
  47. package/dist/openapi.d.ts.map +1 -1
  48. package/dist/openapi.js +4 -1
  49. package/dist/openapi.js.map +1 -1
  50. package/dist/rate-limit.d.ts +31 -0
  51. package/dist/rate-limit.d.ts.map +1 -1
  52. package/dist/rate-limit.js +160 -26
  53. package/dist/rate-limit.js.map +1 -1
  54. package/dist/request-id.d.ts.map +1 -1
  55. package/dist/request-id.js +1 -1
  56. package/dist/request-id.js.map +1 -1
  57. package/dist/timing.d.ts.map +1 -1
  58. package/dist/timing.js +1 -1
  59. package/dist/timing.js.map +1 -1
  60. package/dist/token-auth.d.ts.map +1 -1
  61. package/dist/token-auth.js +8 -13
  62. package/dist/token-auth.js.map +1 -1
  63. package/package.json +3 -3
  64. package/src/_utils.ts +61 -35
  65. package/src/basic-auth.ts +15 -13
  66. package/src/cache-control.ts +5 -2
  67. package/src/cache.ts +68 -28
  68. package/src/compression.ts +84 -4
  69. package/src/conditional.ts +8 -4
  70. package/src/content-negotiation.ts +3 -1
  71. package/src/csrf.ts +10 -6
  72. package/src/healthcheck.ts +4 -1
  73. package/src/idempotency.ts +30 -7
  74. package/src/index.ts +3 -0
  75. package/src/jwt.ts +76 -34
  76. package/src/language.ts +11 -0
  77. package/src/openapi.ts +13 -2
  78. package/src/rate-limit.ts +233 -41
  79. package/src/request-id.ts +7 -3
  80. package/src/timing.ts +8 -4
  81. package/src/token-auth.ts +18 -16
package/src/basic-auth.ts CHANGED
@@ -3,6 +3,7 @@ import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
3
  import {
4
4
  decodeBase64,
5
5
  guardName,
6
+ installAuthGuard,
6
7
  jsonError,
7
8
  type MaybePromise,
8
9
  quotedHeaderValue,
@@ -39,9 +40,9 @@ function reject(realm: string): Response {
39
40
  return jsonError(401, "unauthorized", { "www-authenticate": challenge(realm) })
40
41
  }
41
42
 
42
- function credentials(request: Request): { username: string; password: string } | null {
43
- const header = request.headers.get("authorization")
44
- if (header?.startsWith("Basic ") !== true) return null
43
+ function credentials(header: string | null): { username: string; password: string } | null {
44
+ // The auth-scheme is case-insensitive (RFC 9110 section 11.1): `basic` and `BASIC` are Basic too.
45
+ if (header == null || header.slice(0, 6).toLowerCase() !== "basic ") return null
45
46
  const raw = decodeBase64(header.slice(6).trim())
46
47
  if (raw === null) return null
47
48
  let decoded: string
@@ -89,16 +90,17 @@ export function basicAuth<P>(
89
90
  : staticVerifier(options.username, options.password, options.principal ?? options.username)
90
91
 
91
92
  const plugin = defineIdentityPlugin(guardName("basicAuth"), (app) =>
92
- app.beforeHandle(async (c: { readonly req: Request }) => {
93
- const parsed = credentials(c.req)
94
- const principal =
95
- parsed === null ? null : await (await verifyPromise)(parsed.username, parsed.password)
96
- if (principal !== null && principal !== undefined) {
97
- store.set(c.req, principal)
98
- return undefined
99
- }
100
- return optional ? undefined : reject(realm)
101
- }),
93
+ installAuthGuard(
94
+ app,
95
+ "basicAuth",
96
+ optional,
97
+ store,
98
+ async (header) => {
99
+ const parsed = credentials(header("authorization"))
100
+ return parsed === null ? null : (await verifyPromise)(parsed.username, parsed.password)
101
+ },
102
+ () => reject(realm),
103
+ ),
102
104
  )
103
105
  const instrumented = optional
104
106
  ? plugin
@@ -11,6 +11,9 @@ export interface CacheControlOptions {
11
11
  readonly respectExisting?: boolean
12
12
  }
13
13
 
14
+ // Named per call, so a second, differently configured instance applies instead of being deduped.
15
+ let instances = 0
16
+
14
17
  /**
15
18
  * Set a `Cache-Control` header on matching responses. `value` is either a fixed directive string or a
16
19
  * function of the request (return `undefined` to leave a response untouched - e.g. cache by path).
@@ -31,7 +34,7 @@ export function cacheControl(
31
34
  const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()))
32
35
  const statusOk = options.status ?? ((status: number) => status >= 200 && status < 300)
33
36
  const respectExisting = options.respectExisting !== false
34
- return defineRouterPlugin("cacheControl", (app) => {
37
+ return defineRouterPlugin(`cacheControl#${++instances}`, (app) => {
35
38
  // A fixed directive needs nothing from the request beyond the method, so it ships as one
36
39
  // portable header hook. A dynamic directive's resolver takes a real `Request`, which only the
37
40
  // Web response walk carries - it keeps the full `onResponse` contract (and its cost).
@@ -51,7 +54,7 @@ export function cacheControl(
51
54
  if (respectExisting && res.headers.has("cache-control")) return res
52
55
  const directive = value(req)
53
56
  if (directive === undefined) return res
54
- return withHeaders(res, (headers) => headers.set("cache-control", directive))
57
+ return withHeaders(res, (headers) => headers.set("cache-control", directive), req)
55
58
  },
56
59
  }
57
60
  return app.use(middleware)
package/src/cache.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Middleware } from "@nifrajs/core/server"
1
+ import { isAuthenticatedRequest, type Middleware } from "@nifrajs/core/server"
2
2
  import { type MaybePromise, withHeaders } from "./_utils.ts"
3
3
 
4
4
  export interface CachedResponse {
@@ -66,7 +66,7 @@ export class MemoryResponseCache implements ResponseCacheStore {
66
66
 
67
67
  export interface CacheOptions {
68
68
  readonly store: ResponseCacheStore
69
- /** Time to live in milliseconds. */
69
+ /** Maximum time to live in milliseconds; response freshness can shorten it. */
70
70
  readonly ttlMs: number
71
71
  /** Methods cached. Default `["GET", "HEAD"]`. */
72
72
  readonly methods?: readonly string[]
@@ -80,7 +80,8 @@ export interface CacheOptions {
80
80
  readonly maxBytes?: number
81
81
  /** Respect request `Cache-Control: no-cache/no-store`. Default `true`. */
82
82
  readonly respectRequestCacheControl?: boolean
83
- /** Respect response `Cache-Control: private/no-store`. Default `true`. */
83
+ /** Respect response private/no-store/no-cache and max-age/s-maxage freshness. Default `true`.
84
+ * Responses requiring validation bypass this cache, which has no revalidation transport. */
84
85
  readonly respectResponseCacheControl?: boolean
85
86
  /** Cache responses with `Set-Cookie`. Default `false`. */
86
87
  readonly cacheSetCookie?: boolean
@@ -89,7 +90,9 @@ export interface CacheOptions {
89
90
  * personalized response (RFC 9111 §3.5), or it would be replayed to other users. Leave this off unless
90
91
  * the cached route is genuinely the same for every caller. */
91
92
  readonly cacheAuthenticated?: boolean
92
- /** Credential headers that make a request private. Defaults to Authorization, Cookie, and x-api-key. */
93
+ /** Credential headers that make a request private. Defaults to Authorization, Cookie, and x-api-key.
94
+ * A response to a request an authentication stage accepted (or an integration marked with
95
+ * `markAuthenticatedRequest`) is treated as private too, whichever header carried the credential. */
93
96
  readonly authenticatedHeaders?: readonly string[]
94
97
  /** Response header for cache status. Default `"x-nifra-cache"`; set `false` to disable. */
95
98
  readonly cacheStatusHeader?: string | false
@@ -107,10 +110,30 @@ function cacheControlHas(headers: Headers, names: readonly string[]): boolean {
107
110
  const parts = value
108
111
  .toLowerCase()
109
112
  .split(",")
110
- .map((part) => part.trim().split("=", 1)[0])
113
+ .map((part) => part.trim().split("=", 1)[0]?.trim())
111
114
  return names.some((name) => parts.includes(name))
112
115
  }
113
116
 
117
+ function responseLifetimeMs(headers: Headers, ttlMs: number): number {
118
+ if (cacheControlHas(headers, ["private", "no-store", "no-cache"])) return 0
119
+ const directives = headers.get("cache-control")?.split(",") ?? []
120
+ // This is a shared cache: s-maxage takes precedence over max-age. Ambiguous freshness is stale.
121
+ for (const name of ["s-maxage", "max-age"]) {
122
+ const matches = directives.filter(
123
+ (part) => part.trim().split("=", 1)[0]?.trim().toLowerCase() === name,
124
+ )
125
+ if (matches.length === 0) continue
126
+ if (matches.length !== 1) return 0
127
+ const raw = matches[0]?.slice(matches[0].indexOf("=") + 1).trim() ?? ""
128
+ const value = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
129
+ const seconds = parseLength(value)
130
+ if (seconds === undefined || !Number.isFinite(seconds)) return 0
131
+ const age = parseLength(headers.get("age")) ?? 0
132
+ return Math.min(ttlMs, Math.max(0, seconds - age) * 1000)
133
+ }
134
+ return ttlMs
135
+ }
136
+
114
137
  function appendVary(headers: Headers, vary: readonly string[]): void {
115
138
  if (vary.length === 0) return
116
139
  const existing = headers
@@ -257,9 +280,10 @@ function withStatusHeader(
257
280
  res: Response,
258
281
  header: string | false,
259
282
  status: "HIT" | "MISS" | "BYPASS",
283
+ request?: Request,
260
284
  ): Response {
261
285
  if (header === false) return res
262
- return withHeaders(res, (headers) => headers.set(header, status))
286
+ return withHeaders(res, (headers) => headers.set(header, status), request)
263
287
  }
264
288
 
265
289
  /**
@@ -308,25 +332,33 @@ export function cache(options: CacheOptions): Middleware {
308
332
  requestKeys.set(req, key)
309
333
  const entry = await store.get(key)
310
334
  if (entry === undefined) return undefined
335
+ const cachedHeaders = new Headers([...entry.headers])
311
336
  // Never replay a personalized entry before route-scoped authentication runs. Public entries
312
337
  // remain eligible (and are explicitly marked by their response Cache-Control metadata).
313
338
  if (
314
339
  !cacheAuthenticated &&
315
340
  hasAuthentication(req) &&
316
- !cacheControlHas(new Headers(entry.headers as Array<[string, string]>), [
317
- "public",
318
- "s-maxage",
319
- ])
341
+ !cacheControlHas(cachedHeaders, ["public", "s-maxage"])
320
342
  ) {
321
343
  return undefined
322
344
  }
323
- if (Date.now() >= entry.expiresAt) {
345
+ const lifetimeMs = respectResponseCacheControl
346
+ ? responseLifetimeMs(cachedHeaders, ttlMs)
347
+ : ttlMs
348
+ if (
349
+ lifetimeMs === 0 ||
350
+ Date.now() >= Math.min(entry.expiresAt, entry.storedAt + lifetimeMs)
351
+ ) {
324
352
  await store.delete?.(key)
325
353
  return undefined
326
354
  }
327
355
  hits.add(req)
328
- const headers = new Headers([...entry.headers])
329
- headers.set("age", String(Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1000))))
356
+ const headers = cachedHeaders
357
+ const age = parseLength(headers.get("age")) ?? 0
358
+ headers.set(
359
+ "age",
360
+ String(age + Math.max(0, Math.floor((Date.now() - entry.storedAt) / 1000))),
361
+ )
330
362
  if (cacheStatusHeader !== false) headers.set(cacheStatusHeader, "HIT")
331
363
  return new Response(req.method === "HEAD" ? null : entry.body.slice(), {
332
364
  status: entry.status,
@@ -343,46 +375,54 @@ export function cache(options: CacheOptions): Middleware {
343
375
  if (!methods.has(req.method.toUpperCase())) return res
344
376
  if (!statusOk(res.status)) return res
345
377
  if (respectRequestCacheControl && cacheControlHas(req.headers, ["no-store"])) {
346
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
378
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
347
379
  }
348
- if (respectResponseCacheControl && cacheControlHas(res.headers, ["private", "no-store"])) {
349
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
380
+ const lifetimeMs = respectResponseCacheControl
381
+ ? responseLifetimeMs(res.headers, ttlMs)
382
+ : ttlMs
383
+ if (lifetimeMs === 0) {
384
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
350
385
  }
351
386
  if (!cacheSetCookie && res.headers.has("set-cookie")) {
352
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
387
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
353
388
  }
354
389
  // A response-declared Vary must be represented in the lookup key. If the caller did not opt
355
390
  // those request headers into `vary`, bypass storage instead of replaying one representation to
356
391
  // another request. `Vary: *` is never cacheable by a shared cache.
357
392
  if (!responseVaryMatchesKey(res.headers, vary)) {
358
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
393
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
359
394
  }
360
395
  // Shared-cache safety (RFC 9111 §3.5): never store a response to an authenticated request
361
- // (Authorization or Cookie) unless it's explicitly public - otherwise a personalized 200 leaks
362
- // across users. Opt in with `cacheAuthenticated: true` for a route that's the same for everyone.
396
+ // unless it's explicitly public - otherwise a personalized 200 leaks across users. A credential
397
+ // header outside `authenticatedHeaders` is caught by the authentication stage that accepted it,
398
+ // because a hit is served before that stage runs. Opt in with `cacheAuthenticated: true` for a
399
+ // route that's the same for everyone.
363
400
  if (
364
401
  !cacheAuthenticated &&
365
- hasAuthentication(req) &&
402
+ (hasAuthentication(req) || isAuthenticatedRequest(req)) &&
366
403
  !cacheControlHas(res.headers, ["public", "s-maxage"])
367
404
  ) {
368
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
405
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
369
406
  }
370
407
  const declared = parseLength(res.headers.get("content-length"))
371
408
  if (declared !== undefined && declared > maxBytes) {
372
- return withStatusHeader(res, cacheStatusHeader, "BYPASS")
409
+ return withStatusHeader(res, cacheStatusHeader, "BYPASS", req)
373
410
  }
411
+ const storedAt = Date.now()
374
412
  const captured = await readBytesCapped(res, maxBytes)
375
413
  if (!("bytes" in captured)) {
376
- return withStatusHeader(captured.response, cacheStatusHeader, "BYPASS")
414
+ return withStatusHeader(captured.response, cacheStatusHeader, "BYPASS", req)
377
415
  }
378
416
  const body = captured.bytes
379
417
  const capturedResponse = captured.response
418
+ const expiresAt = storedAt + lifetimeMs
419
+ if (Date.now() >= expiresAt) {
420
+ return withStatusHeader(capturedResponse, cacheStatusHeader, "BYPASS", req)
421
+ }
380
422
 
381
423
  const headers = new Headers(capturedResponse.headers)
382
- headers.delete("age")
383
424
  if (cacheStatusHeader !== false) headers.delete(cacheStatusHeader)
384
425
  appendVary(headers, vary)
385
- const now = Date.now()
386
426
  const key = requestKeys.get(req) ?? keyOf(req)
387
427
  requestKeys.delete(req)
388
428
  await store.set(key, {
@@ -390,8 +430,8 @@ export function cache(options: CacheOptions): Middleware {
390
430
  statusText: res.statusText,
391
431
  headers: [...headers.entries()],
392
432
  body,
393
- expiresAt: now + ttlMs,
394
- storedAt: now,
433
+ expiresAt,
434
+ storedAt,
395
435
  })
396
436
  const outgoing = new Headers(capturedResponse.headers)
397
437
  appendVary(outgoing, vary)
@@ -179,10 +179,90 @@ async function compressRawBody(
179
179
  },
180
180
  cancel: (reason) => reader.cancel(reason),
181
181
  })
182
- return new Response(source.pipeThrough(new CompressionStream("gzip")), {
183
- status: response.status,
184
- statusText: response.statusText,
185
- headers: gzipHeaders(response.headers),
182
+ const zlib = zlibModule()
183
+ return new Response(
184
+ zlib === undefined
185
+ ? source.pipeThrough(new CompressionStream("gzip"))
186
+ : syncFlushedGzip(zlib, source),
187
+ {
188
+ status: response.status,
189
+ statusText: response.statusText,
190
+ headers: gzipHeaders(response.headers),
191
+ },
192
+ )
193
+ }
194
+
195
+ interface GzipStream {
196
+ write(chunk: Uint8Array): boolean
197
+ flush(kind: number, callback: () => void): void
198
+ end(): void
199
+ destroy(): void
200
+ on(event: "data", listener: (chunk: Uint8Array) => void): unknown
201
+ once(event: "end" | "error", listener: (error?: unknown) => void): unknown
202
+ }
203
+
204
+ interface Zlib {
205
+ createGzip(): GzipStream
206
+ readonly constants: { readonly Z_SYNC_FLUSH: number }
207
+ }
208
+
209
+ const isZlib = (value: unknown): value is Zlib =>
210
+ typeof value === "object" &&
211
+ value !== null &&
212
+ "createGzip" in value &&
213
+ typeof value.createGzip === "function" &&
214
+ "constants" in value &&
215
+ typeof value.constants === "object" &&
216
+ value.constants !== null &&
217
+ "Z_SYNC_FLUSH" in value.constants
218
+
219
+ let zlibLookup: { readonly zlib: Zlib | undefined } | undefined
220
+
221
+ function zlibModule(): Zlib | undefined {
222
+ if (zlibLookup !== undefined) return zlibLookup.zlib
223
+ let zlib: Zlib | undefined
224
+ try {
225
+ const proc: unknown = Reflect.get(globalThis, "process")
226
+ const load: unknown =
227
+ typeof proc === "object" && proc !== null ? Reflect.get(proc, "getBuiltinModule") : undefined
228
+ const module: unknown = typeof load === "function" ? load.call(proc, "node:zlib") : undefined
229
+ if (isZlib(module)) zlib = module
230
+ } catch {}
231
+ zlibLookup = { zlib }
232
+ return zlib
233
+ }
234
+
235
+ /** `CompressionStream` holds deflate output until its window fills or the input closes, so a
236
+ * streamed shell would not reach the client until the whole page did. A sync flush after each
237
+ * upstream chunk emits what that chunk compressed to, at the cost of a few bytes per flush. */
238
+ function syncFlushedGzip(
239
+ zlib: Zlib,
240
+ source: ReadableStream<Uint8Array>,
241
+ ): ReadableStream<Uint8Array> {
242
+ const reader = source.getReader()
243
+ const gzip = zlib.createGzip()
244
+ return new ReadableStream<Uint8Array>({
245
+ start(controller) {
246
+ gzip.on("data", (chunk) => controller.enqueue(chunk))
247
+ gzip.once("error", (error) => controller.error(error))
248
+ },
249
+ async pull(controller) {
250
+ const { done, value } = await reader.read()
251
+ if (done) {
252
+ await new Promise((resolve) => {
253
+ gzip.once("end", resolve)
254
+ gzip.end()
255
+ })
256
+ controller.close()
257
+ return
258
+ }
259
+ gzip.write(value)
260
+ await new Promise<void>((resolve) => gzip.flush(zlib.constants.Z_SYNC_FLUSH, resolve))
261
+ },
262
+ cancel(reason) {
263
+ gzip.destroy()
264
+ return reader.cancel(reason)
265
+ },
186
266
  })
187
267
  }
188
268
 
@@ -55,10 +55,14 @@ export function conditionalResponse(
55
55
  if (response.status < 200 || response.status >= 300) return response
56
56
  if (!isFresh(request, options)) {
57
57
  if (options.etag === undefined && modified === undefined) return response
58
- return withHeaders(response, (headers) => {
59
- if (options.etag !== undefined) headers.set("etag", options.etag)
60
- if (modified !== undefined) headers.set("last-modified", modified)
61
- })
58
+ return withHeaders(
59
+ response,
60
+ (headers) => {
61
+ if (options.etag !== undefined) headers.set("etag", options.etag)
62
+ if (modified !== undefined) headers.set("last-modified", modified)
63
+ },
64
+ request,
65
+ )
62
66
  }
63
67
  const headers = new Headers(response.headers)
64
68
  if (options.etag !== undefined) headers.set("etag", options.etag)
@@ -37,7 +37,9 @@ function parseMediaType(value: string, qDefault = 1): ParsedMediaType | undefine
37
37
  if (slash <= 0 || slash === media.length - 1) return undefined
38
38
  const type = media.slice(0, slash)
39
39
  const subtype = media.slice(slash + 1)
40
- if (!/^[\w!#$&^_.+-]+$/.test(type) || !/^[\w!#$&^_.+*-]+$/.test(subtype)) return undefined
40
+ // `*/*` is the one legal type wildcard; `*/json` names no media range.
41
+ const typeOk = type === "*" ? subtype === "*" : /^[\w!#$&^_.+-]+$/.test(type)
42
+ if (!typeOk || !/^[\w!#$&^_.+*-]+$/.test(subtype)) return undefined
41
43
 
42
44
  let q = qDefault
43
45
  const parameters = new Map<string, string>()
package/src/csrf.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { isSameOriginRequest, METHODS, type Middleware } from "@nifrajs/core/server"
2
+ import { isRegistrableMethod } from "@nifrajs/core/methods"
3
+ import { isSameOriginRequest, type Middleware } from "@nifrajs/core/server"
3
4
  import {
4
5
  base64UrlEncode,
5
6
  guardName,
@@ -78,6 +79,10 @@ function originAllowed(req: Request, origins: Set<string> | undefined): boolean
78
79
  const allows = (candidate: string): boolean =>
79
80
  origins !== undefined ? origins.has(candidate) : isSameOriginRequest(candidate, req)
80
81
  const origin = req.headers.get("origin")
82
+ // Under `Referrer-Policy: no-referrer` a browser sends a same-origin form POST with `Origin: null`;
83
+ // its own `Sec-Fetch-Site` still says same-origin, and page script can set neither header.
84
+ if (origin === "null" && origins === undefined)
85
+ return req.headers.get("sec-fetch-site") === "same-origin"
81
86
  if (origin !== null) return allows(origin)
82
87
 
83
88
  const referer = req.headers.get("referer")
@@ -170,7 +175,7 @@ export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promi
170
175
  export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promise<boolean> {
171
176
  const keys = csrfKeys(secret)
172
177
  const parts = token.split(".")
173
- // @nifra-gate-reviewed: TOKEN_PREFIX is a public, non-secret format tag; this equality is a shape
178
+ // nifra-expect NF-S002: TOKEN_PREFIX is a public, non-secret format tag; this equality is a shape
174
179
  // check that short-circuits malformed input. The secret comparison is the constant-time
175
180
  // verifyHmacSha256 over the signature below.
176
181
  if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX || parts[1] === "" || parts[2] === "") {
@@ -230,9 +235,8 @@ export function csrf(options: CsrfOptions): Middleware {
230
235
  scope: "global",
231
236
  // Middleware may intentionally mention methods the Nifra router does not expose (for example
232
237
  // TRACE). Keep runtime behavior unchanged, but publish evidence only for registerable routes.
233
- methods:
234
- options.methods === undefined
235
- ? ["POST", "PUT", "PATCH", "DELETE"]
236
- : METHODS.filter((method) => methods?.has(method)),
238
+ ...(methods === undefined
239
+ ? { exceptMethods: [...SAFE_METHODS].filter(isRegistrableMethod) }
240
+ : { methods: [...methods].filter(isRegistrableMethod) }),
237
241
  })
238
242
  }
@@ -14,6 +14,9 @@ export interface HealthcheckOptions {
14
14
 
15
15
  const noStore = { "cache-control": "no-store" } as const
16
16
 
17
+ // Named per call, so a second, differently configured instance applies instead of being deduped.
18
+ let instances = 0
19
+
17
20
  /**
18
21
  * Register **liveness** (`/health`) and **readiness** (`/ready`) endpoints. Liveness is a flat `200`
19
22
  * (the process is serving). Readiness runs each `check` and returns `200 { status: "ok", checks }`
@@ -34,7 +37,7 @@ export function healthcheck(options: HealthcheckOptions = {}): IdentityPlugin {
34
37
  // A router plugin: the probes are registered as a side effect and the app comes back with its type
35
38
  // unchanged, so routes declared after `.use(healthcheck())` keep theirs. The probes are deliberately
36
39
  // absent from the caller's typed registry - they are operational endpoints, not part of the contract.
37
- return defineRouterPlugin("healthcheck", (app) => {
40
+ return defineRouterPlugin(`healthcheck#${++instances}`, (app) => {
38
41
  app.register("GET", path, undefined, () =>
39
42
  Response.json({ status: "ok" }, { headers: noStore }),
40
43
  )
@@ -1,5 +1,10 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { METHODS, type Middleware, replacedRequestOf } from "@nifrajs/core/server"
2
+ import {
3
+ isAuthenticatedRequest,
4
+ METHODS,
5
+ type Middleware,
6
+ replacedRequestOf,
7
+ } from "@nifrajs/core/server"
3
8
 
4
9
  /**
5
10
  * Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
@@ -192,8 +197,11 @@ export interface IdempotencyOptions {
192
197
  readonly maxBytes?: number
193
198
  /**
194
199
  * 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.
200
+ * 408, 409, 425, and 429, and the request rejections 400, 404, 405, 413, 414, 415, 422 and 431.
201
+ * Like a transient 5xx, those say the operation did not run: replaying one would refuse every
202
+ * retry under that key until it expired, and storing a rejection lets a caller fill the store
203
+ * with keys sent to a route that does not exist. A handler that answers 400 or 422 after a side
204
+ * effect should supply its own predicate.
197
205
  */
198
206
  readonly shouldCache?: (response: Response) => boolean
199
207
  /**
@@ -218,12 +226,19 @@ export interface IdempotencyOptions {
218
226
  * same caller - a rotated bearer token, an analytics cookie appended mid-session - lands on a fresh
219
227
  * key and the retry executes again. Narrow the list (or supply `key`) when the app has a stable
220
228
  * principal id to scope by; that is strictly better than digesting a whole `Cookie` header.
229
+ *
230
+ * A request that carries none of these headers is keyed as anonymous. If an authentication stage
231
+ * then accepts it (a credential in another header), its response is not stored: under the
232
+ * anonymous key any caller presenting the same `Idempotency-Key` would be replayed it. Add that
233
+ * header here to dedupe such requests.
221
234
  */
222
235
  readonly principalHeaders?: readonly string[]
223
236
  }
224
237
 
225
238
  const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
226
- const NOT_THE_OPERATIONS_ANSWER = new Set([401, 403, 408, 409, 425, 429])
239
+ const NOT_THE_OPERATIONS_ANSWER = new Set([
240
+ 400, 401, 403, 404, 405, 408, 409, 413, 414, 415, 422, 425, 429, 431,
241
+ ])
227
242
  const DAY_MS = 24 * 60 * 60 * 1000
228
243
 
229
244
  function assertPositiveTtl(value: number, name: string): void {
@@ -440,7 +455,10 @@ export function idempotency(options: IdempotencyOptions): Middleware {
440
455
  ? (req: Request): Promise<string | null> | null =>
441
456
  defaultIdempotencyKey(req, header, principalHeaders)
442
457
  : (req: Request): string | null | Promise<string | null> => custom(req, header)
443
- const claimed = new WeakMap<Request, { readonly key: string; readonly reservation: string }>()
458
+ const claimed = new WeakMap<
459
+ Request,
460
+ { readonly key: string; readonly reservation: string; readonly anonymous: boolean }
461
+ >()
444
462
 
445
463
  const middleware: Middleware = {
446
464
  name: "idempotency",
@@ -472,7 +490,9 @@ export function idempotency(options: IdempotencyOptions): Middleware {
472
490
  })
473
491
  }
474
492
  if (claim.state !== "new") return undefined
475
- claimed.set(req, { key, reservation: claim.reservation })
493
+ const anonymous =
494
+ custom === undefined && principalHeaders.every((name) => req.headers.get(name) === null)
495
+ claimed.set(req, { key, reservation: claim.reservation, anonymous })
476
496
  return undefined
477
497
  },
478
498
  async onResponse(res, req) {
@@ -486,7 +506,10 @@ export function idempotency(options: IdempotencyOptions): Middleware {
486
506
  }
487
507
  if (claim === undefined || claimedReq === undefined) return res // not a claimed request
488
508
  claimed.delete(claimedReq)
489
- if (!shouldCache(res)) {
509
+ if (
510
+ !shouldCache(res) ||
511
+ (claim.anonymous && (isAuthenticatedRequest(req) || isAuthenticatedRequest(claimedReq)))
512
+ ) {
490
513
  await store.release(claim.key, claim.reservation)
491
514
  return res
492
515
  }
package/src/index.ts CHANGED
@@ -127,7 +127,10 @@ export {
127
127
  type RateLimitOptions,
128
128
  type RateLimitResult,
129
129
  type RateLimitStore,
130
+ type RateLimitTier,
131
+ type RateLimitTiersOptions,
130
132
  rateLimit,
133
+ rateLimitTiers,
131
134
  } from "./rate-limit.ts"
132
135
  export { type RequestIdOptions, requestId } from "./request-id.ts"
133
136
  export { type SecurityHeadersOptions, securityHeaders } from "./security-headers.ts"