@nifrajs/middleware 4.0.2 → 5.1.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 (86) 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/admission.d.ts +4 -2
  7. package/dist/admission.d.ts.map +1 -1
  8. package/dist/admission.js +17 -5
  9. package/dist/admission.js.map +1 -1
  10. package/dist/basic-auth.d.ts.map +1 -1
  11. package/dist/basic-auth.js +8 -13
  12. package/dist/basic-auth.js.map +1 -1
  13. package/dist/cache-control.d.ts.map +1 -1
  14. package/dist/cache-control.js +4 -2
  15. package/dist/cache-control.js.map +1 -1
  16. package/dist/cache.d.ts +7 -4
  17. package/dist/cache.d.ts.map +1 -1
  18. package/dist/cache.js +58 -25
  19. package/dist/cache.js.map +1 -1
  20. package/dist/compression.d.ts.map +1 -1
  21. package/dist/compression.js +58 -1
  22. package/dist/compression.js.map +1 -1
  23. package/dist/conditional.d.ts.map +1 -1
  24. package/dist/conditional.js +1 -1
  25. package/dist/conditional.js.map +1 -1
  26. package/dist/content-negotiation.d.ts.map +1 -1
  27. package/dist/content-negotiation.js +3 -1
  28. package/dist/content-negotiation.js.map +1 -1
  29. package/dist/csrf.d.ts.map +1 -1
  30. package/dist/csrf.js +10 -5
  31. package/dist/csrf.js.map +1 -1
  32. package/dist/healthcheck.d.ts.map +1 -1
  33. package/dist/healthcheck.js +3 -1
  34. package/dist/healthcheck.js.map +1 -1
  35. package/dist/idempotency.d.ts +20 -4
  36. package/dist/idempotency.d.ts.map +1 -1
  37. package/dist/idempotency.js +44 -17
  38. package/dist/idempotency.js.map +1 -1
  39. package/dist/index.d.ts +1 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/jwt.d.ts +17 -2
  44. package/dist/jwt.d.ts.map +1 -1
  45. package/dist/jwt.js +70 -31
  46. package/dist/jwt.js.map +1 -1
  47. package/dist/language.d.ts.map +1 -1
  48. package/dist/language.js +12 -0
  49. package/dist/language.js.map +1 -1
  50. package/dist/openapi.d.ts +19 -4
  51. package/dist/openapi.d.ts.map +1 -1
  52. package/dist/openapi.js +24 -8
  53. package/dist/openapi.js.map +1 -1
  54. package/dist/rate-limit.d.ts +31 -0
  55. package/dist/rate-limit.d.ts.map +1 -1
  56. package/dist/rate-limit.js +160 -26
  57. package/dist/rate-limit.js.map +1 -1
  58. package/dist/request-id.d.ts.map +1 -1
  59. package/dist/request-id.js +1 -1
  60. package/dist/request-id.js.map +1 -1
  61. package/dist/timing.d.ts.map +1 -1
  62. package/dist/timing.js +1 -1
  63. package/dist/timing.js.map +1 -1
  64. package/dist/token-auth.d.ts.map +1 -1
  65. package/dist/token-auth.js +8 -13
  66. package/dist/token-auth.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/_utils.ts +61 -35
  69. package/src/admission.ts +15 -5
  70. package/src/basic-auth.ts +15 -13
  71. package/src/cache-control.ts +5 -2
  72. package/src/cache.ts +68 -28
  73. package/src/compression.ts +84 -4
  74. package/src/conditional.ts +8 -4
  75. package/src/content-negotiation.ts +3 -1
  76. package/src/csrf.ts +10 -6
  77. package/src/healthcheck.ts +4 -1
  78. package/src/idempotency.ts +77 -20
  79. package/src/index.ts +3 -0
  80. package/src/jwt.ts +106 -34
  81. package/src/language.ts +11 -0
  82. package/src/openapi.ts +52 -10
  83. package/src/rate-limit.ts +233 -41
  84. package/src/request-id.ts +7 -3
  85. package/src/timing.ts +8 -4
  86. package/src/token-auth.ts +18 -16
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,11 +1,18 @@
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
6
11
  * tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect
7
- * running twice (double-charge, double-publish). Runs in `onRequest` (before the handler), so a replay
8
- * or an in-flight collision short-circuits before any mutation.
12
+ * running twice (double-charge, double-publish). The key is claimed in `onRequest`, so an in-flight
13
+ * collision short-circuits before any mutation; a replay waits for `beforeHandle`, so a credential
14
+ * revoked since the first request gets the route's current auth answer, not the stored response.
15
+ * That needs the route declared after `.use(idempotency())` - `beforeHandle` is order-scoped.
9
16
  *
10
17
  * Pair it with a DB uniqueness constraint - this stops the *retry*, the constraint is the source of
11
18
  * truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore}
@@ -19,6 +26,12 @@ export interface IdempotencyRecord {
19
26
  readonly headers: ReadonlyArray<readonly [string, string]>
20
27
  /** Response body, base64-encoded. */
21
28
  readonly body: string
29
+ /**
30
+ * The response came from a route this middleware's `beforeHandle` covers, so a retry is replayed
31
+ * from there - after the route's authentication, validation and earlier guards accept it again. A
32
+ * record without it (a route declared before `.use(idempotency())`) is replayed in `onRequest`.
33
+ */
34
+ readonly recheck?: boolean
22
35
  }
23
36
 
24
37
  export type IdempotencyClaim =
@@ -192,8 +205,11 @@ export interface IdempotencyOptions {
192
205
  readonly maxBytes?: number
193
206
  /**
194
207
  * 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.
208
+ * 408, 409, 425, and 429, and the request rejections 400, 404, 405, 413, 414, 415, 422 and 431.
209
+ * Like a transient 5xx, those say the operation did not run: replaying one would refuse every
210
+ * retry under that key until it expired, and storing a rejection lets a caller fill the store
211
+ * with keys sent to a route that does not exist. A handler that answers 400 or 422 after a side
212
+ * effect should supply its own predicate.
197
213
  */
198
214
  readonly shouldCache?: (response: Response) => boolean
199
215
  /**
@@ -218,12 +234,19 @@ export interface IdempotencyOptions {
218
234
  * same caller - a rotated bearer token, an analytics cookie appended mid-session - lands on a fresh
219
235
  * key and the retry executes again. Narrow the list (or supply `key`) when the app has a stable
220
236
  * principal id to scope by; that is strictly better than digesting a whole `Cookie` header.
237
+ *
238
+ * A request that carries none of these headers is keyed as anonymous. If an authentication stage
239
+ * then accepts it (a credential in another header), its response is not stored: under the
240
+ * anonymous key any caller presenting the same `Idempotency-Key` would be replayed it. Add that
241
+ * header here to dedupe such requests.
221
242
  */
222
243
  readonly principalHeaders?: readonly string[]
223
244
  }
224
245
 
225
246
  const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const
226
- const NOT_THE_OPERATIONS_ANSWER = new Set([401, 403, 408, 409, 425, 429])
247
+ const NOT_THE_OPERATIONS_ANSWER = new Set([
248
+ 400, 401, 403, 404, 405, 408, 409, 413, 414, 415, 422, 425, 429, 431,
249
+ ])
227
250
  const DAY_MS = 24 * 60 * 60 * 1000
228
251
 
229
252
  function assertPositiveTtl(value: number, name: string): void {
@@ -364,6 +387,20 @@ function replay(record: IdempotencyRecord): Response {
364
387
  return new Response(body === null ? null : body.slice(), { status: record.status, headers })
365
388
  }
366
389
 
390
+ /** The request `onRequest` recorded in `map`, walking back past a later hook's replacement of it
391
+ * (methodOverride, a transport codec). */
392
+ function recordedRequest<T>(
393
+ map: WeakMap<Request, T>,
394
+ req: Request,
395
+ ): readonly [Request, T] | undefined {
396
+ for (let current: Request | undefined = req; current !== undefined; ) {
397
+ const found = map.get(current)
398
+ if (found !== undefined) return [current, found]
399
+ current = replacedRequestOf(current)
400
+ }
401
+ return undefined
402
+ }
403
+
367
404
  const DEFAULT_PRINCIPAL_HEADERS = ["authorization", "cookie", "x-api-key"] as const
368
405
 
369
406
  /** SHA-256 of the caller's credential headers, base64url. Only the digest ever becomes part of a store
@@ -440,7 +477,13 @@ export function idempotency(options: IdempotencyOptions): Middleware {
440
477
  ? (req: Request): Promise<string | null> | null =>
441
478
  defaultIdempotencyKey(req, header, principalHeaders)
442
479
  : (req: Request): string | null | Promise<string | null> => custom(req, header)
443
- const claimed = new WeakMap<Request, { readonly key: string; readonly reservation: string }>()
480
+ const claimed = new WeakMap<
481
+ Request,
482
+ { readonly key: string; readonly reservation: string; readonly anonymous: boolean }
483
+ >()
484
+ // Replays held for `beforeHandle`, and fresh claims whose route `beforeHandle` reached.
485
+ const deferred = new WeakMap<Request, IdempotencyRecord>()
486
+ const rechecked = new WeakSet<Request>()
444
487
 
445
488
  const middleware: Middleware = {
446
489
  name: "idempotency",
@@ -461,7 +504,11 @@ export function idempotency(options: IdempotencyOptions): Middleware {
461
504
  headers: { "content-type": "application/json", "retry-after": "1" },
462
505
  })
463
506
  }
464
- if (claim.state === "replay") return replay(claim.record)
507
+ if (claim.state === "replay") {
508
+ if (claim.record.recheck !== true) return replay(claim.record)
509
+ deferred.set(req, claim.record)
510
+ return undefined
511
+ }
465
512
  if (claim.state === "in_flight") {
466
513
  return new Response(JSON.stringify({ ok: false, error: "idempotency_in_progress" }), {
467
514
  status: 409,
@@ -472,21 +519,31 @@ export function idempotency(options: IdempotencyOptions): Middleware {
472
519
  })
473
520
  }
474
521
  if (claim.state !== "new") return undefined
475
- claimed.set(req, { key, reservation: claim.reservation })
522
+ const anonymous =
523
+ custom === undefined && principalHeaders.every((name) => req.headers.get(name) === null)
524
+ claimed.set(req, { key, reservation: claim.reservation, anonymous })
476
525
  return undefined
477
526
  },
478
- async onResponse(res, 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)
527
+ beforeHandle(context) {
528
+ // Authentication and validation have accepted this retry; only now is the stored answer its own.
529
+ const held = recordedRequest(deferred, context.req)
530
+ if (held !== undefined) {
531
+ deferred.delete(held[0])
532
+ return replay(held[1])
486
533
  }
487
- if (claim === undefined || claimedReq === undefined) return res // not a claimed request
534
+ const fresh = recordedRequest(claimed, context.req)
535
+ if (fresh !== undefined) rechecked.add(fresh[0])
536
+ return undefined
537
+ },
538
+ async onResponse(res, req) {
539
+ const found = recordedRequest(claimed, req)
540
+ if (found === undefined) return res // not a claimed request
541
+ const [claimedReq, claim] = found
488
542
  claimed.delete(claimedReq)
489
- if (!shouldCache(res)) {
543
+ if (
544
+ !shouldCache(res) ||
545
+ (claim.anonymous && (isAuthenticatedRequest(req) || isAuthenticatedRequest(claimedReq)))
546
+ ) {
490
547
  await store.release(claim.key, claim.reservation)
491
548
  return res
492
549
  }
@@ -507,7 +564,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
507
564
  await store.complete(
508
565
  claim.key,
509
566
  claim.reservation,
510
- { status: res.status, headers, body: toBase64(bytes) },
567
+ { status: res.status, headers, body: toBase64(bytes), recheck: rechecked.has(claimedReq) },
511
568
  ttlMs,
512
569
  )
513
570
  return captured.response
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"
package/src/jwt.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  base64UrlDecode,
5
5
  bearerToken,
6
6
  guardName,
7
+ type HeaderGet,
8
+ installAuthGuard,
7
9
  jsonError,
8
10
  type MaybePromise,
9
11
  parseCookies,
@@ -88,12 +90,27 @@ export interface JwksOptions {
88
90
  readonly fetch?: typeof fetch
89
91
  readonly cacheMs?: number
90
92
  /**
91
- * Extra time to keep using the last successful key set when refresh fails.
92
- * Defaults to one cache window; set `0` to fail closed immediately on refresh errors.
93
+ * Extra time to keep using the last successful key set past `cacheMs`. Inside it a request is
94
+ * answered from that set at once while one refresh runs behind it, so a slow or failing identity
95
+ * provider holds no request. Defaults to one cache window; set `0` to wait for every refresh and
96
+ * fail closed on refresh errors.
93
97
  */
94
98
  readonly staleMs?: number
95
99
  readonly timeoutMs?: number
96
100
  readonly maxBytes?: number
101
+ /**
102
+ * Least time (ms) between the refreshes a token with a `kid` the cached set lacks may start, so a
103
+ * newly rotated key is picked up without made-up `kid`s turning every request into a fetch.
104
+ * Default 30 s.
105
+ */
106
+ readonly cooldownMs?: number
107
+ /**
108
+ * Least time (ms) between fetches after one that failed or returned no keys. Inside it a request
109
+ * that needs the set gets that outcome again at once (the same error, or no key) instead of
110
+ * waiting up to `timeoutMs` on a fetch of its own, so an identity provider outage does not hold
111
+ * every request. Default 5 s; `0` fetches again for every such request.
112
+ */
113
+ readonly failureCooldownMs?: number
97
114
  }
98
115
 
99
116
  const HASH: Record<JwtAlgorithm, "SHA-256" | "SHA-384" | "SHA-512"> = {
@@ -300,15 +317,19 @@ export async function tryVerifyJwt<C extends JwtClaims = JwtClaims>(
300
317
  }
301
318
  }
302
319
 
303
- function tokenFromRequest(req: Request, header: string, cookie: string | undefined): string | null {
304
- const headerValue = req.headers.get(header)
320
+ function tokenFromHeaders(
321
+ get: HeaderGet,
322
+ header: string,
323
+ cookie: string | undefined,
324
+ ): string | null {
325
+ const headerValue = get(header)
305
326
  if (headerValue !== null) {
306
327
  if (header === "authorization") {
307
328
  return bearerToken(headerValue)
308
329
  }
309
330
  return headerValue.trim() || null
310
331
  }
311
- return cookie === undefined ? null : (parseCookies(req.headers.get("cookie"))[cookie] ?? null)
332
+ return cookie === undefined ? null : (parseCookies(get("cookie"))[cookie] ?? null)
312
333
  }
313
334
 
314
335
  function challenge(realm: string): string {
@@ -327,17 +348,23 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
327
348
  const store = new WeakMap<Request, C>()
328
349
 
329
350
  const plugin = defineIdentityPlugin(guardName("jwt"), (app) =>
330
- app.beforeHandle(async (c: { readonly req: Request }) => {
331
- const token = tokenFromRequest(c.req, header, options.cookie)
332
- if (token === null) return optional ? undefined : reject(realm)
333
- try {
334
- const verified = await verifyJwt<C>(token, options)
335
- store.set(c.req, verified.claims)
336
- return undefined
337
- } catch {
338
- return optional ? undefined : reject(realm)
339
- }
340
- }),
351
+ installAuthGuard(
352
+ app,
353
+ "jwt",
354
+ optional,
355
+ store,
356
+ async (get) => {
357
+ const token = tokenFromHeaders(get, header, options.cookie)
358
+ if (token === null) return null
359
+ try {
360
+ return (await verifyJwt<C>(token, options)).claims
361
+ } catch {
362
+ // A token that fails verification is no credential: a 401, never a 503.
363
+ return null
364
+ }
365
+ },
366
+ () => reject(realm),
367
+ ),
341
368
  )
342
369
  const instrumented = optional
343
370
  ? plugin
@@ -429,6 +456,12 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
429
456
  const staleMs = options.staleMs ?? cacheMs
430
457
  const timeoutMs = options.timeoutMs ?? 5_000
431
458
  const maxBytes = options.maxBytes ?? 65_536
459
+ const cooldownMs = options.cooldownMs ?? 30_000
460
+ const failureCooldownMs = options.failureCooldownMs ?? 5_000
461
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0)
462
+ throw new Error("jwks: cooldownMs must be non-negative")
463
+ if (!Number.isFinite(failureCooldownMs) || failureCooldownMs < 0)
464
+ throw new Error("jwks: failureCooldownMs must be non-negative")
432
465
  if (!Number.isFinite(cacheMs) || cacheMs < 0)
433
466
  throw new Error("jwks: cacheMs must be non-negative")
434
467
  if (!Number.isFinite(staleMs) || staleMs < 0)
@@ -440,30 +473,69 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
440
473
  }
441
474
  let cached: readonly JwkKey[] = []
442
475
  let cachedAt = 0
476
+ let refreshedAt = Number.NEGATIVE_INFINITY
443
477
  let pending: Promise<readonly JwkKey[]> | undefined
478
+ // When the last fetch failed or returned no keys, and its error (none for an empty set).
479
+ let failedAt = Number.NEGATIVE_INFINITY
480
+ let failure: { readonly error: unknown } | undefined
481
+
482
+ const refresh = (): Promise<readonly JwkKey[]> => {
483
+ if (pending === undefined) {
484
+ refreshedAt = Date.now()
485
+ pending = fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes })
486
+ .then(
487
+ (keys) => {
488
+ cached = keys
489
+ cachedAt = Date.now()
490
+ failedAt = keys.length === 0 ? cachedAt : Number.NEGATIVE_INFINITY
491
+ failure = undefined
492
+ return keys
493
+ },
494
+ (error: unknown) => {
495
+ failedAt = Date.now()
496
+ failure = { error }
497
+ throw error
498
+ },
499
+ )
500
+ .finally(() => {
501
+ pending = undefined
502
+ })
503
+ }
504
+ return pending
505
+ }
444
506
 
445
- const load = async (): Promise<readonly JwkKey[]> => {
446
- const now = Date.now()
447
- if (cached.length > 0 && now - cachedAt < cacheMs) return cached
448
- pending ??= fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes }).finally(() => {
449
- pending = undefined
450
- })
451
- try {
452
- cached = await pending
453
- cachedAt = Date.now()
454
- return cached
455
- } catch (error) {
456
- if (cached.length > 0 && staleMs > 0 && Date.now() - cachedAt < cacheMs + staleMs) {
457
- return cached
458
- }
459
- throw error
507
+ const coolingDown = (): boolean =>
508
+ pending === undefined && Date.now() - failedAt < failureCooldownMs
509
+
510
+ const load = (): Promise<readonly JwkKey[]> => {
511
+ const age = Date.now() - cachedAt
512
+ if (cached.length > 0 && age < cacheMs) return Promise.resolve(cached)
513
+ if (cached.length > 0 && age < cacheMs + staleMs) {
514
+ if (!coolingDown()) refresh().catch(() => {})
515
+ return Promise.resolve(cached)
516
+ }
517
+ if (coolingDown()) {
518
+ return failure === undefined ? Promise.resolve(cached) : Promise.reject(failure.error)
460
519
  }
520
+ return refresh()
461
521
  }
462
522
 
463
523
  return async (header) => {
464
- if (typeof header.kid !== "string" || header.kid === "") return null
465
- const keys = await load()
524
+ const kid = header.kid
525
+ if (typeof kid !== "string" || kid === "") return null
466
526
  const alg = header.alg as JwtAlgorithm
467
- return keys.find((key) => key.kid === header.kid && validateJwkForAlg(key, alg, header.kid))
527
+ const find = (keys: readonly JwkKey[]): JwkKey | undefined =>
528
+ keys.find((key) => key.kid === kid && validateJwkForAlg(key, alg, kid))
529
+ const found = find(await load())
530
+ if (found !== undefined) return found
531
+ // A kid the set lacks may be a key the provider just rotated in.
532
+ if (coolingDown() || (pending === undefined && Date.now() - refreshedAt < cooldownMs)) {
533
+ return undefined
534
+ }
535
+ try {
536
+ return find(await refresh())
537
+ } catch {
538
+ return undefined
539
+ }
468
540
  }
469
541
  }
package/src/language.ts CHANGED
@@ -81,6 +81,15 @@ export function pickLanguage<const L extends readonly string[]>(
81
81
  return { language: defaultLanguage, matched: "default" }
82
82
  }
83
83
 
84
+ /** `value` naming each of `names` once; `Vary: *` already covers every request header. */
85
+ function varyOn(value: string | null, names: readonly string[]): string {
86
+ if (value === null || value.trim() === "") return names.join(", ")
87
+ const fields = value.split(",").map((field) => field.trim().toLowerCase())
88
+ if (fields.includes("*")) return value
89
+ const missing = names.filter((name) => !fields.includes(name.toLowerCase()))
90
+ return missing.length === 0 ? value : `${value}, ${missing.join(", ")}`
91
+ }
92
+
84
93
  /**
85
94
  * Derives `c.language` from `Accept-Language` and emits `Content-Language` by default.
86
95
  *
@@ -114,6 +123,8 @@ export function language<const L extends readonly string[]>(
114
123
  .use(
115
124
  withResponseObserver<Middleware>({
116
125
  onResponseHeaders(headers, req) {
126
+ // The language is chosen from Accept-Language, so a shared cache must key on it.
127
+ headers.set("vary", varyOn(headers.get("vary"), ["Accept-Language"]))
117
128
  if (!emitHeader) return
118
129
  if (headers.has("content-language")) return
119
130
  const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
package/src/openapi.ts CHANGED
@@ -11,7 +11,12 @@ export interface RouteLike {
11
11
  // `| undefined` (not just `?`) so a core `RouteDescriptor` - whose `schema` is a *required*
12
12
  // `RouteSchema | undefined` - is assignable under `exactOptionalPropertyTypes`.
13
13
  readonly schema?:
14
- | { readonly body?: unknown; readonly query?: unknown; readonly response?: unknown }
14
+ | {
15
+ readonly body?: unknown
16
+ readonly query?: unknown
17
+ readonly response?: unknown
18
+ readonly sse?: unknown
19
+ }
15
20
  | undefined
16
21
  }
17
22
 
@@ -40,11 +45,23 @@ export interface OpenApiUiOptions {
40
45
  readonly path?: string
41
46
  /** Page title. Defaults to `info.title`. */
42
47
  readonly title?: string
43
- /** The Scalar script URL (loaded from a CDN). Default jsDelivr's `@scalar/api-reference`. */
48
+ /**
49
+ * The Scalar script URL. Default: `@scalar/api-reference` 1.72.1's standalone build on jsDelivr,
50
+ * pinned by {@link integrity}. Point it at a copy you serve yourself (no third-party origin to
51
+ * allow in a `script-src` policy, nothing to trust but your own deploy).
52
+ */
44
53
  readonly cdn?: string
54
+ /**
55
+ * The script's Subresource Integrity hash (`sha384-...`), which makes the browser refuse a script
56
+ * whose bytes differ. Default: the default build's hash when `cdn` is unset, none when it is set.
57
+ * Pass the hash of your own copy, or `false` to send none.
58
+ */
59
+ readonly integrity?: string | false
45
60
  }
46
61
 
47
62
  export interface OpenApiOptions {
63
+ /** The OpenAPI version to declare. `"3.2.0"` describes each SSE event with the standard `itemSchema`. */
64
+ readonly openapiVersion?: "3.1.0" | "3.2.0"
48
65
  readonly info?: OpenApiInfo
49
66
  readonly servers?: readonly OpenApiServer[]
50
67
  /** Tag definitions (top-level `tags`). Reference them from an operation via `operations`. */
@@ -91,6 +108,7 @@ export function buildOpenApiDocument(
91
108
  // and the shim only satisfies it structurally.
92
109
  const appShim = { routes: () => included } as unknown as Parameters<typeof toOpenAPI>[0]
93
110
  return toOpenAPI(appShim, {
111
+ ...(options.openapiVersion !== undefined ? { openapiVersion: options.openapiVersion } : {}),
94
112
  title: options.info?.title ?? "nifra API",
95
113
  version: options.info?.version ?? "0.0.0",
96
114
  ...(options.info?.description !== undefined ? { description: options.info.description } : {}),
@@ -102,16 +120,34 @@ export function buildOpenApiDocument(
102
120
  }) as unknown as Record<string, unknown>
103
121
  }
104
122
 
105
- const SCALAR_CDN = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
123
+ // An exact version, so the page always loads the bytes SCALAR_INTEGRITY names: a bare package URL
124
+ // follows `latest`, and anyone who can publish to it could run script on the API's own origin.
125
+ const SCALAR_CDN =
126
+ "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.72.1/dist/browser/standalone.js"
127
+ // SHA-384 of that file, the same from the npm tarball (checked against its registry integrity) and
128
+ // from jsDelivr. Bump both together.
129
+ const SCALAR_INTEGRITY = "sha384-U11tb2XnKvmwt8RlTvnwUnYgrN+ur4Xyh9htLhjajWNR/Oyl5AX5DEz00qRmlrmK"
130
+
131
+ // Named per call, so a second, differently configured instance applies instead of being deduped.
132
+ let instances = 0
106
133
 
107
134
  /**
108
135
  * Minimal HTML that mounts the Scalar API reference against a spec URL. The interpolated values are
109
136
  * dev-provided config (not request input), but they're still escaped so a stray quote or `<` can't
110
137
  * break the markup.
111
138
  */
112
- function scalarPage(specUrl: string, title: string, cdn: string): string {
139
+ function scalarPage(
140
+ specUrl: string,
141
+ title: string,
142
+ cdn: string,
143
+ integrity: string | undefined,
144
+ ): string {
113
145
  const attr = (s: string): string => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;")
114
146
  const text = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;")
147
+ // SRI on a cross-origin script needs a CORS fetch; without a hash, a plain fetch keeps a copy
148
+ // served without CORS headers loading.
149
+ const pinned =
150
+ integrity === undefined ? "" : ` integrity="${attr(integrity)}" crossorigin="anonymous"`
115
151
  return `<!doctype html>
116
152
  <html>
117
153
  <head>
@@ -121,7 +157,7 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
121
157
  </head>
122
158
  <body>
123
159
  <script id="api-reference" data-url="${attr(specUrl)}"></script>
124
- <script src="${attr(cdn)}"></script>
160
+ <script src="${attr(cdn)}"${pinned}></script>
125
161
  </body>
126
162
  </html>`
127
163
  }
@@ -132,9 +168,11 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
132
168
  * **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
133
169
  * registered - so the plugin's own position in the chain doesn't matter.
134
170
  *
135
- * Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page (default
136
- * `/reference`) that renders the spec. The page loads Scalar from a CDN; pin/self-host via `ui.cdn`,
137
- * and remember to allow that origin if you ship a `script-src` Content-Security-Policy.
171
+ * Pass `ui: true` (or `ui: { path, title, cdn, integrity }`) to also serve a Scalar API-reference page
172
+ * (default `/reference`) that renders the spec. By default the page loads an exact Scalar version from
173
+ * jsDelivr with a Subresource Integrity hash, so a changed file is refused rather than run on your
174
+ * origin. Self-host it via `ui.cdn` (with your copy's `ui.integrity`), and allow the script's origin if
175
+ * you ship a `script-src` Content-Security-Policy.
138
176
  *
139
177
  * ```ts
140
178
  * app.use(openapi({ info: { title: "My API", version: "1.0.0" }, ui: true }))
@@ -149,7 +187,7 @@ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
149
187
  let uiCached: string | undefined
150
188
  // A router plugin: it serves the spec (and optionally the UI page) as a side effect and returns the
151
189
  // app type-unchanged, so every route declared after `.use(openapi())` keeps its types.
152
- return defineRouterPlugin("openapi", (app) => {
190
+ return defineRouterPlugin(`openapi#${++instances}`, (app) => {
153
191
  // Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
154
192
  // routes lazily, at request time - so all routes exist regardless of where the plugin sits.
155
193
  app.register("GET", docPath, undefined, () => {
@@ -166,8 +204,12 @@ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
166
204
  if (ui !== undefined && uiPath !== undefined) {
167
205
  const title = ui.title ?? options.info?.title ?? "nifra API"
168
206
  const cdn = ui.cdn ?? SCALAR_CDN
207
+ const integrity =
208
+ ui.integrity === false
209
+ ? undefined
210
+ : (ui.integrity ?? (ui.cdn === undefined ? SCALAR_INTEGRITY : undefined))
169
211
  app.register("GET", uiPath, undefined, () => {
170
- uiCached ??= scalarPage(docPath, title, cdn)
212
+ uiCached ??= scalarPage(docPath, title, cdn, integrity)
171
213
  return new Response(uiCached, { headers: { "content-type": "text/html; charset=utf-8" } })
172
214
  })
173
215
  }