@nifrajs/middleware 2.10.0 → 2.12.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 (120) hide show
  1. package/dist/_utils.d.ts +14 -2
  2. package/dist/_utils.d.ts.map +1 -1
  3. package/dist/_utils.js +25 -10
  4. package/dist/_utils.js.map +1 -1
  5. package/dist/basic-auth.d.ts +2 -2
  6. package/dist/basic-auth.d.ts.map +1 -1
  7. package/dist/basic-auth.js +2 -2
  8. package/dist/basic-auth.js.map +1 -1
  9. package/dist/cache-control.d.ts +1 -1
  10. package/dist/cache-control.d.ts.map +1 -1
  11. package/dist/cache-control.js +2 -2
  12. package/dist/cache-control.js.map +1 -1
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +13 -1
  16. package/dist/cache.js.map +1 -1
  17. package/dist/combine.d.ts +11 -5
  18. package/dist/combine.d.ts.map +1 -1
  19. package/dist/combine.js +11 -4
  20. package/dist/combine.js.map +1 -1
  21. package/dist/compression.d.ts +8 -1
  22. package/dist/compression.d.ts.map +1 -1
  23. package/dist/compression.js +9 -2
  24. package/dist/compression.js.map +1 -1
  25. package/dist/csrf.d.ts +11 -4
  26. package/dist/csrf.d.ts.map +1 -1
  27. package/dist/csrf.js +19 -5
  28. package/dist/csrf.js.map +1 -1
  29. package/dist/durable-command.d.ts +2 -2
  30. package/dist/durable-command.d.ts.map +1 -1
  31. package/dist/durable-command.js +2 -2
  32. package/dist/durable-command.js.map +1 -1
  33. package/dist/etag.d.ts +2 -2
  34. package/dist/etag.d.ts.map +1 -1
  35. package/dist/etag.js +3 -3
  36. package/dist/etag.js.map +1 -1
  37. package/dist/healthcheck.d.ts +2 -1
  38. package/dist/healthcheck.d.ts.map +1 -1
  39. package/dist/healthcheck.js +5 -2
  40. package/dist/healthcheck.js.map +1 -1
  41. package/dist/idempotency.d.ts +51 -5
  42. package/dist/idempotency.d.ts.map +1 -1
  43. package/dist/idempotency.js +110 -13
  44. package/dist/idempotency.js.map +1 -1
  45. package/dist/jwt.d.ts +2 -2
  46. package/dist/jwt.d.ts.map +1 -1
  47. package/dist/jwt.js +14 -3
  48. package/dist/jwt.js.map +1 -1
  49. package/dist/language.d.ts +9 -1
  50. package/dist/language.d.ts.map +1 -1
  51. package/dist/language.js +8 -2
  52. package/dist/language.js.map +1 -1
  53. package/dist/logger.d.ts +9 -4
  54. package/dist/logger.d.ts.map +1 -1
  55. package/dist/logger.js +9 -5
  56. package/dist/logger.js.map +1 -1
  57. package/dist/method-override.d.ts +1 -1
  58. package/dist/method-override.d.ts.map +1 -1
  59. package/dist/method-override.js +2 -2
  60. package/dist/method-override.js.map +1 -1
  61. package/dist/openapi.d.ts +2 -1
  62. package/dist/openapi.d.ts.map +1 -1
  63. package/dist/openapi.js +4 -2
  64. package/dist/openapi.js.map +1 -1
  65. package/dist/pretty-json.d.ts +1 -1
  66. package/dist/pretty-json.d.ts.map +1 -1
  67. package/dist/pretty-json.js +2 -2
  68. package/dist/pretty-json.js.map +1 -1
  69. package/dist/problem-details.d.ts +1 -1
  70. package/dist/problem-details.d.ts.map +1 -1
  71. package/dist/problem-details.js +2 -2
  72. package/dist/problem-details.js.map +1 -1
  73. package/dist/rate-limit.d.ts.map +1 -1
  74. package/dist/rate-limit.js +6 -8
  75. package/dist/rate-limit.js.map +1 -1
  76. package/dist/request-id.d.ts +12 -4
  77. package/dist/request-id.d.ts.map +1 -1
  78. package/dist/request-id.js +62 -7
  79. package/dist/request-id.js.map +1 -1
  80. package/dist/security-headers.d.ts +11 -0
  81. package/dist/security-headers.d.ts.map +1 -1
  82. package/dist/security-headers.js +12 -0
  83. package/dist/security-headers.js.map +1 -1
  84. package/dist/timing.d.ts +4 -1
  85. package/dist/timing.d.ts.map +1 -1
  86. package/dist/timing.js +2 -2
  87. package/dist/timing.js.map +1 -1
  88. package/dist/token-auth.d.ts +2 -2
  89. package/dist/token-auth.d.ts.map +1 -1
  90. package/dist/token-auth.js +2 -2
  91. package/dist/token-auth.js.map +1 -1
  92. package/dist/trailing-slash.d.ts +2 -2
  93. package/dist/trailing-slash.d.ts.map +1 -1
  94. package/dist/trailing-slash.js +3 -3
  95. package/dist/trailing-slash.js.map +1 -1
  96. package/package.json +3 -3
  97. package/src/_utils.ts +27 -10
  98. package/src/basic-auth.ts +3 -3
  99. package/src/cache-control.ts +2 -2
  100. package/src/cache.ts +20 -1
  101. package/src/combine.ts +22 -10
  102. package/src/compression.ts +9 -2
  103. package/src/csrf.ts +31 -15
  104. package/src/durable-command.ts +3 -3
  105. package/src/etag.ts +3 -3
  106. package/src/healthcheck.ts +6 -3
  107. package/src/idempotency.ts +146 -18
  108. package/src/jwt.ts +22 -4
  109. package/src/language.ts +26 -16
  110. package/src/logger.ts +10 -6
  111. package/src/method-override.ts +2 -2
  112. package/src/openapi.ts +5 -3
  113. package/src/pretty-json.ts +2 -2
  114. package/src/problem-details.ts +2 -2
  115. package/src/rate-limit.ts +6 -8
  116. package/src/request-id.ts +63 -11
  117. package/src/security-headers.ts +23 -0
  118. package/src/timing.ts +3 -3
  119. package/src/token-auth.ts +3 -3
  120. package/src/trailing-slash.ts +3 -3
package/src/csrf.ts CHANGED
@@ -11,9 +11,26 @@ import {
11
11
  verifyHmacSha256,
12
12
  } from "./_utils.ts"
13
13
 
14
+ /**
15
+ * An HMAC secret, or a rotation list of them. With a list, the **first** secret signs new tokens
16
+ * and any listed secret verifies - rotate by prepending the new secret and dropping the old one
17
+ * once outstanding tokens have expired. Every entry must meet the 32-byte floor; an empty list
18
+ * throws.
19
+ */
20
+ export type CsrfSecret = string | Uint8Array | ReadonlyArray<string | Uint8Array>
21
+
22
+ /** Normalize to derived keys, validating every entry's 32-byte floor up front (fail loud at
23
+ * construction, not on the first request that happens to reach an old secret). */
24
+ function csrfKeys(secret: CsrfSecret): ReadonlyArray<Uint8Array> {
25
+ const list: ReadonlyArray<string | Uint8Array> =
26
+ typeof secret === "string" || secret instanceof Uint8Array ? [secret] : secret
27
+ if (list.length === 0) throw new Error("csrf: secret list cannot be empty")
28
+ return list.map((s) => secretBytes(s, "csrf"))
29
+ }
30
+
14
31
  export interface CsrfOptions {
15
- /** HMAC secret. Must be at least 32 bytes. */
16
- readonly secret: string | Uint8Array
32
+ /** HMAC secret (≥ 32 bytes), or a rotation list - see {@link CsrfSecret}. */
33
+ readonly secret: CsrfSecret
17
34
  /** Cookie carrying the signed token. Default `"csrf-token"`. */
18
35
  readonly cookie?: string
19
36
  /** Header carrying the same signed token. Default `"x-csrf-token"`. */
@@ -46,11 +63,8 @@ function originAllowed(req: Request, origins: Set<string> | undefined): boolean
46
63
  }
47
64
  }
48
65
 
49
- export async function createCsrfToken(
50
- secret: string | Uint8Array,
51
- nonce?: string,
52
- ): Promise<string> {
53
- const key = secretBytes(secret, "csrf")
66
+ export async function createCsrfToken(secret: CsrfSecret, nonce?: string): Promise<string> {
67
+ const key = csrfKeys(secret)[0] as Uint8Array
54
68
  const tokenNonce = nonce ?? base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
55
69
  if (!/^[A-Za-z0-9_-]{22,}$/.test(tokenNonce)) {
56
70
  throw new Error("csrf: nonce must be base64url-like and at least 22 characters")
@@ -59,17 +73,19 @@ export async function createCsrfToken(
59
73
  return `${payload}.${await hmacSha256(payload, key)}`
60
74
  }
61
75
 
62
- export async function verifyCsrfToken(
63
- token: string,
64
- secret: string | Uint8Array,
65
- ): Promise<boolean> {
66
- const key = secretBytes(secret, "csrf")
76
+ export async function verifyCsrfToken(token: string, secret: CsrfSecret): Promise<boolean> {
77
+ const keys = csrfKeys(secret)
67
78
  const parts = token.split(".")
68
79
  if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX || parts[1] === "" || parts[2] === "") {
69
80
  return false
70
81
  }
71
82
  const [prefix, nonce, signature] = parts as [string, string, string]
72
- return verifyHmacSha256(`${prefix}.${nonce}`, signature, key)
83
+ // Rotation: accept a token signed by any listed secret. Each comparison is constant-time; which
84
+ // generation matched is not attacker-meaningful, so the early exit between keys leaks nothing.
85
+ for (const key of keys) {
86
+ if (await verifyHmacSha256(`${prefix}.${nonce}`, signature, key)) return true
87
+ }
88
+ return false
73
89
  }
74
90
 
75
91
  /**
@@ -77,7 +93,7 @@ export async function verifyCsrfToken(
77
93
  * cookie and a header, and must come from an allowed Origin/Referer unless `checkOrigin:false` is set.
78
94
  */
79
95
  export function csrf(options: CsrfOptions): Middleware {
80
- const key = secretBytes(options.secret, "csrf")
96
+ const keys = csrfKeys(options.secret)
81
97
  const cookie = options.cookie ?? "csrf-token"
82
98
  const header = (options.header ?? "x-csrf-token").toLowerCase()
83
99
  const methods =
@@ -97,7 +113,7 @@ export function csrf(options: CsrfOptions): Middleware {
97
113
  if (!(await timingSafeEqualString(cookieToken, headerToken))) {
98
114
  return jsonError(403, "csrf_failed")
99
115
  }
100
- return (await verifyCsrfToken(cookieToken, key)) ? undefined : jsonError(403, "csrf_failed")
116
+ return (await verifyCsrfToken(cookieToken, keys)) ? undefined : jsonError(403, "csrf_failed")
101
117
  },
102
118
  }
103
119
  return withRouteAssurance(middleware, {
@@ -3,7 +3,7 @@ import {
3
3
  attachCapabilityJournal,
4
4
  type CapabilityExecutionJournal,
5
5
  } from "@nifrajs/core/capabilities"
6
- import { definePlugin, type NifraPlugin } from "@nifrajs/core/server"
6
+ import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
7
7
 
8
8
  export interface DurableCommandOptions {
9
9
  /** Where effect transitions are recorded. `createDurableEffectJournal` from
@@ -36,7 +36,7 @@ export interface DurableCommandOptions {
36
36
  * deliberately never proves this one: replaying a stored response after a crash mid-effect does not
37
37
  * make the effect exactly-once, and the journal is what survives the crash.
38
38
  */
39
- export function durableCommand(options: DurableCommandOptions): NifraPlugin {
39
+ export function durableCommand(options: DurableCommandOptions): IdentityPlugin {
40
40
  const journal = options.journal
41
41
  if (journal === null || typeof journal !== "object") {
42
42
  throw new TypeError("durableCommand: journal must be a CapabilityExecutionJournal")
@@ -48,7 +48,7 @@ export function durableCommand(options: DurableCommandOptions): NifraPlugin {
48
48
  throw new TypeError(`durableCommand: journal.${method} must be a function`)
49
49
  }
50
50
  }
51
- const plugin = definePlugin("nifra:durable-command", (app) =>
51
+ const plugin = defineIdentityPlugin("nifra:durable-command", (app) =>
52
52
  app.beforeHandle((c: object) => {
53
53
  attachCapabilityJournal(c, journal)
54
54
  return undefined
package/src/etag.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definePlugin, type ResponseBodyReplacement } from "@nifrajs/core/server"
1
+ import { defineRouterPlugin, type ResponseBodyReplacement } from "@nifrajs/core/server"
2
2
 
3
3
  /** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags - not crypto. */
4
4
  function fnv1a(bytes: Uint8Array): string {
@@ -92,7 +92,7 @@ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkR
92
92
  }
93
93
 
94
94
  /**
95
- * A {@link definePlugin} plugin that adds a content-hash `ETag` to `GET` `200` responses and returns
95
+ * A {@link defineRouterPlugin} plugin that adds a content-hash `ETag` to `GET` `200` responses and returns
96
96
  * **`304 Not Modified`** when the client's `If-None-Match` matches - saving bandwidth on unchanged
97
97
  * responses. Built on the portable `onResponseBody` tier: the hook receives the final
98
98
  * framework-serialized bytes on every runtime with nothing drained, so the middleware stays on
@@ -106,7 +106,7 @@ export function etag(options: ETagOptions = {}) {
106
106
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
107
107
  throw new Error("etag: maxBytes must be a non-negative safe integer")
108
108
  }
109
- return definePlugin("etag", (app) =>
109
+ return defineRouterPlugin("etag", (app) =>
110
110
  app.use({
111
111
  onResponseBody(body, headers, req, status) {
112
112
  if (req.method !== "GET" || status !== 200) return undefined
@@ -1,4 +1,4 @@
1
- import { definePlugin } from "@nifrajs/core/server"
1
+ import { defineRouterPlugin, type IdentityPlugin } from "@nifrajs/core/server"
2
2
 
3
3
  type MaybePromise<T> = T | Promise<T>
4
4
 
@@ -26,12 +26,15 @@ const noStore = { "cache-control": "no-store" } as const
26
26
  * app.use(healthcheck({ checks: { db: () => db.ping() } })).use(bearer({ verify }))
27
27
  * ```
28
28
  */
29
- export function healthcheck(options: HealthcheckOptions = {}) {
29
+ export function healthcheck(options: HealthcheckOptions = {}): IdentityPlugin {
30
30
  const path = options.path ?? "/health"
31
31
  const readyPath = options.readyPath ?? "/ready"
32
32
  const checks = options.checks ?? {}
33
33
  const checkNames = Object.keys(checks)
34
- return definePlugin("healthcheck", (app) => {
34
+ // A router plugin: the probes are registered as a side effect and the app comes back with its type
35
+ // unchanged, so routes declared after `.use(healthcheck())` keep theirs. The probes are deliberately
36
+ // absent from the caller's typed registry - they are operational endpoints, not part of the contract.
37
+ return defineRouterPlugin("healthcheck", (app) => {
35
38
  app.register("GET", path, undefined, () =>
36
39
  Response.json({ status: "ok" }, { headers: noStore }),
37
40
  )
@@ -47,15 +47,34 @@ export interface IdempotencyStore {
47
47
  export interface MemoryIdempotencyStoreOptions {
48
48
  /** Allow the in-memory store in production. Off by default - a per-instance store can't dedupe across instances. */
49
49
  readonly allowInProduction?: boolean
50
+ /** Maximum retained locks + records. Default `10_000`. */
51
+ readonly maxEntries?: number
52
+ /** Maximum key length in UTF-8 bytes. Default `1024`. */
53
+ readonly maxKeyBytes?: number
50
54
  }
51
55
 
52
56
  type Entry =
53
57
  | { readonly kind: "lock"; readonly expiresAt: number }
54
58
  | { readonly kind: "record"; readonly record: IdempotencyRecord; readonly expiresAt: number }
55
59
 
60
+ const KEY_ENCODER = new TextEncoder()
61
+
62
+ /**
63
+ * Thrown by a store that is full of entries none of which may be discarded. The middleware turns it
64
+ * into a `503` with `retry-after`; a store that can grow (Redis) never raises it.
65
+ */
66
+ export class IdempotencyCapacityError extends Error {
67
+ constructor() {
68
+ super("idempotency store is at capacity")
69
+ this.name = "IdempotencyCapacityError"
70
+ }
71
+ }
72
+
56
73
  /** In-process store. Refuses to run in production unless explicitly allowed (per-instance ⇒ no cross-instance dedupe). */
57
74
  export class MemoryIdempotencyStore implements IdempotencyStore {
58
75
  private readonly entries = new Map<string, Entry>()
76
+ private readonly maxEntries: number
77
+ private readonly maxKeyBytes: number
59
78
 
60
79
  constructor(options: MemoryIdempotencyStoreOptions = {}) {
61
80
  if (options.allowInProduction !== true && process.env.NODE_ENV === "production") {
@@ -64,11 +83,51 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
64
83
  "Use a shared store (e.g. Redis), or pass { allowInProduction: true } for a single-instance deploy.",
65
84
  )
66
85
  }
86
+ this.maxEntries = options.maxEntries ?? 10_000
87
+ this.maxKeyBytes = options.maxKeyBytes ?? 1024
88
+ if (!Number.isSafeInteger(this.maxEntries) || this.maxEntries < 1)
89
+ throw new RangeError("MemoryIdempotencyStore: maxEntries must be a positive safe integer")
90
+ if (!Number.isSafeInteger(this.maxKeyBytes) || this.maxKeyBytes < 1)
91
+ throw new RangeError("MemoryIdempotencyStore: maxKeyBytes must be a positive safe integer")
92
+ }
93
+
94
+ private validateKey(key: string): void {
95
+ if (KEY_ENCODER.encode(key).byteLength > this.maxKeyBytes)
96
+ throw new RangeError("MemoryIdempotencyStore: key is too large")
97
+ }
98
+
99
+ private maintain(now: number): void {
100
+ // Expiry cleanup is incremental so normal requests never scan the full map.
101
+ let checked = 0
102
+ for (const [key, entry] of this.entries) {
103
+ if (entry.expiresAt <= now) this.entries.delete(key)
104
+ if (++checked >= 16) break
105
+ }
106
+ }
107
+
108
+ private reserve(key: string, entry: Entry): void {
109
+ if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
110
+ // The incremental sweep only samples, so pay for a full one before declaring the store full.
111
+ const now = Date.now()
112
+ for (const [existing, held] of this.entries) {
113
+ if (held.expiresAt <= now) this.entries.delete(existing)
114
+ }
115
+ }
116
+ if (!this.entries.has(key) && this.entries.size >= this.maxEntries) {
117
+ // Refuse rather than evict. Every entry here is either a lock on a request still running or a
118
+ // response another request is entitled to replay, so dropping one to make room hands out
119
+ // exactly the duplicate execution this store exists to prevent - and does it silently, under
120
+ // the load where it is most likely to matter. A 503 is the honest answer; raise `maxEntries`.
121
+ throw new IdempotencyCapacityError()
122
+ }
123
+ this.entries.set(key, entry)
67
124
  }
68
125
 
69
126
  begin(key: string, lockTtlMs: number): Promise<IdempotencyClaim> {
127
+ this.validateKey(key)
70
128
  assertPositiveTtl(lockTtlMs, "lockTtlMs")
71
129
  const now = Date.now()
130
+ this.maintain(now)
72
131
  const entry = this.entries.get(key)
73
132
  if (entry !== undefined && entry.expiresAt > now) {
74
133
  return Promise.resolve(
@@ -78,13 +137,14 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
78
137
  )
79
138
  }
80
139
  // Free (or expired): take the lock. Synchronous Map write ⇒ atomic on a single instance.
81
- this.entries.set(key, { kind: "lock", expiresAt: now + lockTtlMs })
140
+ this.reserve(key, { kind: "lock", expiresAt: now + lockTtlMs })
82
141
  return Promise.resolve({ state: "new" })
83
142
  }
84
143
 
85
144
  complete(key: string, record: IdempotencyRecord, ttlMs: number): Promise<void> {
145
+ this.validateKey(key)
86
146
  assertPositiveTtl(ttlMs, "ttlMs")
87
- this.entries.set(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
147
+ this.reserve(key, { kind: "record", record, expiresAt: Date.now() + ttlMs })
88
148
  return Promise.resolve()
89
149
  }
90
150
 
@@ -111,12 +171,29 @@ export interface IdempotencyOptions {
111
171
  /** Whether a response should be cached for replay. Default: status `< 500` (don't replay transient 5xx). */
112
172
  readonly shouldCache?: (response: Response) => boolean
113
173
  /**
114
- * Derive the store key from the request. Default: the `header` value **scoped by method + path**, so the
115
- * same key on a different endpoint can't collide. Return a **principal-scoped** key (e.g.
116
- * `` `${userId}:${req.headers.get("idempotency-key")}` ``) so one user can't replay another's stored
117
- * response on a shared key. Return `null`/`""` to skip dedupe for this request.
174
+ * Derive the store key from the request. Default: the `header` value scoped by method + path **and
175
+ * by a digest of the caller's credentials** (see `principalHeaders`), so neither a different
176
+ * endpoint nor a different caller can collide on one key. Return `null`/`""` to skip dedupe for
177
+ * this request.
178
+ *
179
+ * Supplying your own replaces that scoping entirely - a custom key MUST fold in the principal
180
+ * itself (e.g. `` `${userId}:${req.headers.get("idempotency-key")}` ``), or one user can replay
181
+ * another's stored response by guessing their key.
182
+ */
183
+ readonly key?: (req: Request, header: string) => string | null | Promise<string | null>
184
+ /**
185
+ * Headers whose values identify the caller. Their digest scopes the default key, so the same
186
+ * `Idempotency-Key` from two callers addresses two entries and neither can read the other's cached
187
+ * response. Defaults to Authorization, Cookie, and x-api-key. Only a digest is stored - a raw
188
+ * credential must never become a store key, which would put it in front of every Redis `KEYS` dump
189
+ * and slow-log line. Ignored when `key` is supplied.
190
+ *
191
+ * The digest covers the header value verbatim, so anything that varies between two requests from the
192
+ * same caller - a rotated bearer token, an analytics cookie appended mid-session - lands on a fresh
193
+ * key and the retry executes again. Narrow the list (or supply `key`) when the app has a stable
194
+ * principal id to scope by; that is strictly better than digesting a whole `Cookie` header.
118
195
  */
119
- readonly key?: (req: Request, header: string) => string | null
196
+ readonly principalHeaders?: readonly string[]
120
197
  }
121
198
 
122
199
  const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
@@ -258,23 +335,54 @@ function replay(record: IdempotencyRecord): Response {
258
335
  return new Response(bodyFor(fromBase64(record.body)), { status: record.status, headers })
259
336
  }
260
337
 
338
+ const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
339
+
340
+ /** SHA-256 of the caller's credential headers, base64url. Only the digest ever becomes part of a store
341
+ * key: a raw `Authorization` value used as a key would be printed by every Redis `KEYS` dump, slow-log
342
+ * line, and store-side metric that treats keys as non-sensitive. */
343
+ async function principalDigest(req: Request, headers: readonly string[]): Promise<string | null> {
344
+ let material = ""
345
+ for (const name of headers) {
346
+ const value = req.headers.get(name)
347
+ // The header name is part of the material so `authorization: x` and `x-api-key: x` can't collide.
348
+ if (value !== null) material += `${name}:${value}\n`
349
+ }
350
+ if (material === "") return null // no credentials presented - an anonymous caller
351
+ const hash = await crypto.subtle.digest("SHA-256", KEY_ENCODER.encode(material))
352
+ return toBase64(new Uint8Array(hash)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
353
+ }
354
+
355
+ /**
356
+ * The default store key: the `header` value scoped by method + path (so the same key on a different
357
+ * endpoint can't collide) **and by a digest of the caller's credentials** (so one caller can't replay
358
+ * another's stored response by presenting their key). `null` when no key header is present - no dedupe.
359
+ */
360
+ function defaultIdempotencyKey(
361
+ req: Request,
362
+ header: string,
363
+ principalHeaders: readonly string[],
364
+ ): Promise<string | null> | null {
365
+ const raw = req.headers.get(header)
366
+ if (raw === null || raw === "") return null
367
+ const scope = `${req.method.toUpperCase()} ${new URL(req.url).pathname}`
368
+ return principalDigest(req, principalHeaders).then(
369
+ (principal) => `${scope}\n${principal ?? "anon"}\n${raw}`,
370
+ )
371
+ }
372
+
261
373
  /**
262
374
  * Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`.
263
375
  *
376
+ * The store key is scoped by method + path **and** by a digest of the caller's credential headers, so
377
+ * a key one caller chose addresses only that caller's entry - presenting someone else's key replays
378
+ * nothing. See `principalHeaders`; a custom `key` replaces that scoping and must do it itself.
379
+ *
264
380
  * **`Set-Cookie` is intentionally not cached or replayed** - a cookie set on the first request is
265
381
  * session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
266
382
  * session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
267
383
  *
268
384
  * Caching buffers the response body, so apply this to JSON/API routes, not streaming SSR responses.
269
385
  */
270
- /** The default store key: the `header` value scoped by method + path, so the same key on a different
271
- * endpoint can't collide (cross-resource replay). `null` when no key header is present (no dedupe). */
272
- function defaultIdempotencyKey(req: Request, header: string): string | null {
273
- const raw = req.headers.get(header)
274
- if (raw === null || raw === "") return null
275
- return `${req.method.toUpperCase()} ${new URL(req.url).pathname}\n${raw}`
276
- }
277
-
278
386
  export function idempotency(options: IdempotencyOptions): Middleware {
279
387
  const { store } = options
280
388
  const header = (options.header ?? "idempotency-key").toLowerCase()
@@ -288,16 +396,36 @@ export function idempotency(options: IdempotencyOptions): Middleware {
288
396
  throw new Error("idempotency: maxBytes must be a non-negative integer")
289
397
  }
290
398
  const shouldCache = options.shouldCache ?? ((res: Response) => res.status < 500)
291
- const keyOf = options.key ?? defaultIdempotencyKey
399
+ const principalHeaders = (options.principalHeaders ?? DEFAULT_PRINCIPAL_HEADERS).map((name) =>
400
+ name.toLowerCase(),
401
+ )
402
+ const custom = options.key
403
+ const keyOf =
404
+ custom === undefined
405
+ ? (req: Request): Promise<string | null> | null =>
406
+ defaultIdempotencyKey(req, header, principalHeaders)
407
+ : (req: Request): string | null | Promise<string | null> => custom(req, header)
292
408
  const claimed = new WeakMap<Request, string>()
293
409
 
294
410
  const middleware: Middleware = {
295
411
  name: "idempotency",
296
412
  async onRequest(req) {
297
413
  if (!methods.has(req.method.toUpperCase())) return undefined
298
- const key = keyOf(req, header)
414
+ const key = await keyOf(req)
299
415
  if (key === null || key === "") return undefined // opt-in per request - no key ⇒ no dedupe
300
- const claim = await store.begin(key, lockTtlMs)
416
+ let claim: IdempotencyClaim
417
+ try {
418
+ claim = await store.begin(key, lockTtlMs)
419
+ } catch (err) {
420
+ // A full store can't tell a first request from a retry, and guessing either way is wrong:
421
+ // serving the request risks a duplicate side effect, replaying nothing risks losing one. Say
422
+ // so with a 503 the client is expected to retry, rather than a 500 that reads as a bug.
423
+ if (!(err instanceof IdempotencyCapacityError)) throw err
424
+ return new Response(JSON.stringify({ ok: false, error: "idempotency_unavailable" }), {
425
+ status: 503,
426
+ headers: { "content-type": "application/json", "retry-after": "1" },
427
+ })
428
+ }
301
429
  if (claim.state === "replay") return replay(claim.record)
302
430
  if (claim.state === "in_flight") {
303
431
  return new Response(JSON.stringify({ ok: false, error: "idempotency_in_progress" }), {
package/src/jwt.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import { definePlugin, type NifraPlugin } from "@nifrajs/core/server"
2
+ import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
3
3
  import {
4
4
  base64UrlDecode,
5
5
  jsonError,
@@ -76,7 +76,7 @@ export interface JwtOptions extends VerifyJwtOptions {
76
76
  readonly cookie?: string
77
77
  }
78
78
 
79
- export type JwtPlugin<C extends JwtClaims = JwtClaims> = NifraPlugin & {
79
+ export type JwtPlugin<C extends JwtClaims = JwtClaims> = IdentityPlugin & {
80
80
  claims(request: Request): C | null
81
81
  requireClaims(request: Request): C
82
82
  }
@@ -152,7 +152,25 @@ async function importKey(
152
152
  kid: string | undefined,
153
153
  ): Promise<CryptoKey> {
154
154
  const hash = HASH[alg]
155
- if (key instanceof CryptoKey) return key
155
+ if (key instanceof CryptoKey) {
156
+ const algorithm = key.algorithm as {
157
+ readonly name: string
158
+ readonly hash?: { readonly name?: string }
159
+ }
160
+ const expectedName = HMAC_ALGS.has(alg) ? "HMAC" : "RSASSA-PKCS1-v1_5"
161
+ const expectedType = HMAC_ALGS.has(alg) ? "secret" : "public"
162
+ const actualHash =
163
+ "hash" in algorithm && typeof algorithm.hash === "object" ? algorithm.hash.name : undefined
164
+ if (
165
+ key.type !== expectedType ||
166
+ algorithm.name !== expectedName ||
167
+ actualHash !== hash ||
168
+ !key.usages.includes("verify")
169
+ ) {
170
+ throw jwtError("CryptoKey does not match the requested JWT algorithm")
171
+ }
172
+ return key
173
+ }
156
174
  if (typeof key === "string" || key instanceof Uint8Array) {
157
175
  if (!HMAC_ALGS.has(alg)) throw jwtError("symmetric key cannot verify an RSA token")
158
176
  return crypto.subtle.importKey("raw", secretBytes(key, "jwt"), { name: "HMAC", hash }, false, [
@@ -306,7 +324,7 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
306
324
  const header = (options.header ?? "authorization").toLowerCase()
307
325
  const store = new WeakMap<Request, C>()
308
326
 
309
- const plugin = definePlugin("jwt", (app) =>
327
+ const plugin = defineIdentityPlugin("jwt", (app) =>
310
328
  app.beforeHandle(async (c: { readonly req: Request }) => {
311
329
  const token = tokenFromRequest(c.req, header, options.cookie)
312
330
  if (token === null) return optional ? undefined : reject(realm)
package/src/language.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definePlugin } from "@nifrajs/core/server"
1
+ import { type ContextPlugin, defineContextPlugin } from "@nifrajs/core/server"
2
2
 
3
3
  export interface LanguageMatch {
4
4
  readonly language: string
@@ -78,8 +78,14 @@ export function pickLanguage<const L extends readonly string[]>(
78
78
 
79
79
  /**
80
80
  * Derives `c.language` from `Accept-Language` and emits `Content-Language` by default.
81
+ *
82
+ * Header-only by design. For the full detector - `?lang=` query parameter → cookie →
83
+ * `Accept-Language`, with optional cookie persistence - use `localeDetector()` from
84
+ * `@nifrajs/i18n/detector`; use one or the other, not both.
81
85
  */
82
- export function language<const L extends readonly string[]>(options: LanguageOptions<L>) {
86
+ export function language<const L extends readonly string[]>(
87
+ options: LanguageOptions<L>,
88
+ ): ContextPlugin<{ language: L[number]; languageMatch: LanguageMatch }> {
83
89
  const { supported, defaultLanguage } = options
84
90
  if (supported.length === 0) throw new Error("language: supported must not be empty")
85
91
  if (!supported.includes(defaultLanguage)) {
@@ -90,19 +96,23 @@ export function language<const L extends readonly string[]>(options: LanguageOpt
90
96
  // instead of pairing state through a WeakMap - that is what lets the Node twin exist at all (it
91
97
  // has no `Request` identity to key on), keeps both hooks trivially identical, and lets the derive
92
98
  // read the header without materializing a Web `Request` on the Node adapter.
93
- return definePlugin("language", (app) =>
94
- app
95
- .derive((c) => {
96
- const match = pickLanguage(c.header("accept-language"), supported, defaultLanguage)
97
- return { language: match.language, languageMatch: match }
98
- })
99
- .use({
100
- onResponseHeaders(headers, req) {
101
- if (!emitHeader) return
102
- if (headers.has("content-language")) return
103
- const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
104
- headers.set("content-language", match.language)
105
- },
106
- }),
99
+ return defineContextPlugin<{ language: L[number]; languageMatch: LanguageMatch }>(
100
+ "language",
101
+ (app) =>
102
+ app
103
+ .derive((c) => {
104
+ const match = pickLanguage(c.header("accept-language"), supported, defaultLanguage)
105
+ // `pickLanguage` only ever returns a member of `supported` (or `defaultLanguage`), so the
106
+ // narrower literal union is the honest type for handlers to switch on.
107
+ return { language: match.language as L[number], languageMatch: match }
108
+ })
109
+ .use({
110
+ onResponseHeaders(headers, req) {
111
+ if (!emitHeader) return
112
+ if (headers.has("content-language")) return
113
+ const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
114
+ headers.set("content-language", match.language)
115
+ },
116
+ }),
107
117
  )
108
118
  }
package/src/logger.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definePlugin, pathnameOf } from "@nifrajs/core/server"
1
+ import { defineIdentityPlugin, type IdentityPlugin, pathnameOf } from "@nifrajs/core/server"
2
2
 
3
3
  /** Structured fields logged per request. */
4
4
  export interface RequestLogFields {
@@ -15,18 +15,22 @@ export interface LoggerOptions {
15
15
  }
16
16
 
17
17
  /**
18
- * A {@link definePlugin} plugin that logs one structured line per request - method, path, status,
19
- * and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
20
- * paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
18
+ * A {@link defineIdentityPlugin} plugin that logs one structured line per request - method, path,
19
+ * status, and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start
20
+ * time is paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
21
+ *
22
+ * It adds no context and registers no routes, so it is type-identity: routes declared after
23
+ * `.use(logger())` keep their types. (Under `definePlugin` they would not - see that helper's
24
+ * FOOTGUN note.)
21
25
  */
22
- export function logger(options: LoggerOptions = {}) {
26
+ export function logger(options: LoggerOptions = {}): IdentityPlugin {
23
27
  // A request logger's whole job is to log; the default writes JSON to stdout, routable via `log`.
24
28
  const sink = options.log ?? ((fields: RequestLogFields) => console.log(JSON.stringify(fields)))
25
29
  const starts = new WeakMap<Request, number>()
26
30
  // Twin-side starts, keyed by the NodeRequestContext identity (the same object reaches the request
27
31
  // and response twins), so Node logging never has to materialize a Web `Request`.
28
32
  const nativeStarts = new WeakMap<object, number>()
29
- return definePlugin("logger", (app) =>
33
+ return defineIdentityPlugin("logger", (app) =>
30
34
  app.use({
31
35
  onRequest(req) {
32
36
  starts.set(req, performance.now())
@@ -1,4 +1,4 @@
1
- import { definePlugin, METHODS, type Method } from "@nifrajs/core/server"
1
+ import { defineRouterPlugin, METHODS, type Method } from "@nifrajs/core/server"
2
2
  import { jsonError } from "./_utils.ts"
3
3
 
4
4
  export interface MethodOverrideOptions {
@@ -54,7 +54,7 @@ export function methodOverride(options: MethodOverrideOptions = {}) {
54
54
  if (header !== false && header.trim() === "") throw new Error("methodOverride: header is empty")
55
55
  if (query !== false && query.trim() === "") throw new Error("methodOverride: query is empty")
56
56
 
57
- return definePlugin("methodOverride", (app) =>
57
+ return defineRouterPlugin("methodOverride", (app) =>
58
58
  app.onRequest((req) => {
59
59
  if (!sourceMethods.has(req.method.toUpperCase())) return undefined
60
60
 
package/src/openapi.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { definePlugin } from "@nifrajs/core/server"
1
+ import { defineRouterPlugin, type IdentityPlugin } from "@nifrajs/core/server"
2
2
  // The OpenAPI generator only - `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
3
3
  // plugin gets full `t`-schema introspection without pulling the `t` builder into every consumer.
4
4
  import { toOpenAPI } from "@nifrajs/schema/openapi"
@@ -140,14 +140,16 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
140
140
  * app.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))
141
141
  * ```
142
142
  */
143
- export function openapi(options: OpenApiOptions = {}) {
143
+ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
144
144
  const docPath = options.path ?? "/openapi.json"
145
145
  const ui: OpenApiUiOptions | undefined =
146
146
  options.ui === true ? {} : options.ui === false ? undefined : options.ui
147
147
  const uiPath = ui !== undefined ? (ui.path ?? "/reference") : undefined
148
148
  let cached: string | undefined
149
149
  let uiCached: string | undefined
150
- return definePlugin("openapi", (app) => {
150
+ // A router plugin: it serves the spec (and optionally the UI page) as a side effect and returns the
151
+ // app type-unchanged, so every route declared after `.use(openapi())` keeps its types.
152
+ return defineRouterPlugin("openapi", (app) => {
151
153
  // Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
152
154
  // routes lazily, at request time - so all routes exist regardless of where the plugin sits.
153
155
  app.register("GET", docPath, undefined, () => {
@@ -1,4 +1,4 @@
1
- import { definePlugin, type NodeRequestContext } from "@nifrajs/core/server"
1
+ import { defineRouterPlugin, type NodeRequestContext } from "@nifrajs/core/server"
2
2
 
3
3
  export interface PrettyJsonOptions {
4
4
  /** JSON indentation spaces. Default `2`. */
@@ -113,7 +113,7 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
113
113
 
114
114
  const decoder = new TextDecoder()
115
115
  const encoder = new TextEncoder()
116
- return definePlugin("prettyJson", (app) =>
116
+ return defineRouterPlugin("prettyJson", (app) =>
117
117
  app.use({
118
118
  onResponseBody(body, headers, req) {
119
119
  if (!isEnabled(req)) return undefined
@@ -1,4 +1,4 @@
1
- import { definePlugin, pathnameOf } from "@nifrajs/core/server"
1
+ import { defineRouterPlugin, pathnameOf } from "@nifrajs/core/server"
2
2
 
3
3
  const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i
4
4
  const DEFAULT_MAX_BYTES = 64 * 1024
@@ -144,7 +144,7 @@ export function problemDetails(options: ProblemDetailsOptions = {}) {
144
144
  const instance = options.includeInstance === true
145
145
  const typeBase = options.typeBase
146
146
 
147
- return definePlugin("problemDetails", (app) =>
147
+ return defineRouterPlugin("problemDetails", (app) =>
148
148
  app.use({
149
149
  onResponseBody(body, headers, req, status) {
150
150
  if (
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
3
- import { withHeaders, withNodeHeaders } from "./_utils.ts"
3
+ import { setNodeHeader, withHeaders } from "./_utils.ts"
4
4
 
5
5
  export interface RateLimitResult {
6
6
  /** Hits recorded in the current window, including this one. */
@@ -301,13 +301,11 @@ export function rateLimit(options: RateLimitOptions): Middleware {
301
301
  const info = nativeQuota.get(req)
302
302
  if (info === undefined) return
303
303
  nativeQuota.delete(req)
304
- withNodeHeaders(res, (headers) => {
305
- // Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so
306
- // this is byte-identical output.
307
- headers["ratelimit-limit"] = String(max)
308
- headers["ratelimit-remaining"] = String(info.remaining)
309
- headers["ratelimit-reset"] = String(info.resetSeconds)
310
- })
304
+ // Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so this
305
+ // is byte-identical output.
306
+ setNodeHeader(res, "ratelimit-limit", String(max))
307
+ setNodeHeader(res, "ratelimit-remaining", String(info.remaining))
308
+ setNodeHeader(res, "ratelimit-reset", String(info.resetSeconds))
311
309
  },
312
310
  }
313
311
  : {}),