@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.
- package/README.md +3 -0
- package/dist/_utils.d.ts +15 -9
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +39 -40
- package/dist/_utils.js.map +1 -1
- package/dist/basic-auth.d.ts.map +1 -1
- package/dist/basic-auth.js +8 -13
- package/dist/basic-auth.js.map +1 -1
- package/dist/cache-control.d.ts.map +1 -1
- package/dist/cache-control.js +4 -2
- package/dist/cache-control.js.map +1 -1
- package/dist/cache.d.ts +7 -4
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +58 -25
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +58 -1
- package/dist/compression.js.map +1 -1
- package/dist/conditional.d.ts.map +1 -1
- package/dist/conditional.js +1 -1
- package/dist/conditional.js.map +1 -1
- package/dist/content-negotiation.d.ts.map +1 -1
- package/dist/content-negotiation.js +3 -1
- package/dist/content-negotiation.js.map +1 -1
- package/dist/csrf.d.ts.map +1 -1
- package/dist/csrf.js +10 -5
- package/dist/csrf.js.map +1 -1
- package/dist/healthcheck.d.ts.map +1 -1
- package/dist/healthcheck.js +3 -1
- package/dist/healthcheck.js.map +1 -1
- package/dist/idempotency.d.ts +10 -2
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +8 -4
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +10 -2
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +52 -31
- package/dist/jwt.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +12 -0
- package/dist/language.js.map +1 -1
- package/dist/openapi.d.ts +3 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +4 -1
- package/dist/openapi.js.map +1 -1
- package/dist/rate-limit.d.ts +31 -0
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +160 -26
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.d.ts.map +1 -1
- package/dist/request-id.js +1 -1
- package/dist/request-id.js.map +1 -1
- package/dist/timing.d.ts.map +1 -1
- package/dist/timing.js +1 -1
- package/dist/timing.js.map +1 -1
- package/dist/token-auth.d.ts.map +1 -1
- package/dist/token-auth.js +8 -13
- package/dist/token-auth.js.map +1 -1
- package/package.json +3 -3
- package/src/_utils.ts +61 -35
- package/src/basic-auth.ts +15 -13
- package/src/cache-control.ts +5 -2
- package/src/cache.ts +68 -28
- package/src/compression.ts +84 -4
- package/src/conditional.ts +8 -4
- package/src/content-negotiation.ts +3 -1
- package/src/csrf.ts +10 -6
- package/src/healthcheck.ts +4 -1
- package/src/idempotency.ts +30 -7
- package/src/index.ts +3 -0
- package/src/jwt.ts +76 -34
- package/src/language.ts +11 -0
- package/src/openapi.ts +13 -2
- package/src/rate-limit.ts +233 -41
- package/src/request-id.ts +7 -3
- package/src/timing.ts +8 -4
- package/src/token-auth.ts +18 -16
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,20 @@ 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
|
|
92
|
-
*
|
|
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
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
const HASH: Record<JwtAlgorithm, "SHA-256" | "SHA-384" | "SHA-512"> = {
|
|
@@ -300,15 +310,19 @@ export async function tryVerifyJwt<C extends JwtClaims = JwtClaims>(
|
|
|
300
310
|
}
|
|
301
311
|
}
|
|
302
312
|
|
|
303
|
-
function
|
|
304
|
-
|
|
313
|
+
function tokenFromHeaders(
|
|
314
|
+
get: HeaderGet,
|
|
315
|
+
header: string,
|
|
316
|
+
cookie: string | undefined,
|
|
317
|
+
): string | null {
|
|
318
|
+
const headerValue = get(header)
|
|
305
319
|
if (headerValue !== null) {
|
|
306
320
|
if (header === "authorization") {
|
|
307
321
|
return bearerToken(headerValue)
|
|
308
322
|
}
|
|
309
323
|
return headerValue.trim() || null
|
|
310
324
|
}
|
|
311
|
-
return cookie === undefined ? null : (parseCookies(
|
|
325
|
+
return cookie === undefined ? null : (parseCookies(get("cookie"))[cookie] ?? null)
|
|
312
326
|
}
|
|
313
327
|
|
|
314
328
|
function challenge(realm: string): string {
|
|
@@ -327,17 +341,23 @@ export function jwt<C extends JwtClaims = JwtClaims>(options: JwtOptions): JwtPl
|
|
|
327
341
|
const store = new WeakMap<Request, C>()
|
|
328
342
|
|
|
329
343
|
const plugin = defineIdentityPlugin(guardName("jwt"), (app) =>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
344
|
+
installAuthGuard(
|
|
345
|
+
app,
|
|
346
|
+
"jwt",
|
|
347
|
+
optional,
|
|
348
|
+
store,
|
|
349
|
+
async (get) => {
|
|
350
|
+
const token = tokenFromHeaders(get, header, options.cookie)
|
|
351
|
+
if (token === null) return null
|
|
352
|
+
try {
|
|
353
|
+
return (await verifyJwt<C>(token, options)).claims
|
|
354
|
+
} catch {
|
|
355
|
+
// A token that fails verification is no credential: a 401, never a 503.
|
|
356
|
+
return null
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
() => reject(realm),
|
|
360
|
+
),
|
|
341
361
|
)
|
|
342
362
|
const instrumented = optional
|
|
343
363
|
? plugin
|
|
@@ -429,6 +449,9 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
|
|
|
429
449
|
const staleMs = options.staleMs ?? cacheMs
|
|
430
450
|
const timeoutMs = options.timeoutMs ?? 5_000
|
|
431
451
|
const maxBytes = options.maxBytes ?? 65_536
|
|
452
|
+
const cooldownMs = options.cooldownMs ?? 30_000
|
|
453
|
+
if (!Number.isFinite(cooldownMs) || cooldownMs < 0)
|
|
454
|
+
throw new Error("jwks: cooldownMs must be non-negative")
|
|
432
455
|
if (!Number.isFinite(cacheMs) || cacheMs < 0)
|
|
433
456
|
throw new Error("jwks: cacheMs must be non-negative")
|
|
434
457
|
if (!Number.isFinite(staleMs) || staleMs < 0)
|
|
@@ -440,30 +463,49 @@ export function jwks(options: JwksOptions): JwtKeyResolver {
|
|
|
440
463
|
}
|
|
441
464
|
let cached: readonly JwkKey[] = []
|
|
442
465
|
let cachedAt = 0
|
|
466
|
+
let refreshedAt = Number.NEGATIVE_INFINITY
|
|
443
467
|
let pending: Promise<readonly JwkKey[]> | undefined
|
|
444
468
|
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
return cached
|
|
458
|
-
}
|
|
459
|
-
throw error
|
|
469
|
+
const refresh = (): Promise<readonly JwkKey[]> => {
|
|
470
|
+
if (pending === undefined) {
|
|
471
|
+
refreshedAt = Date.now()
|
|
472
|
+
pending = fetchJwks({ url, fetch: fetcher, timeoutMs, maxBytes })
|
|
473
|
+
.then((keys) => {
|
|
474
|
+
cached = keys
|
|
475
|
+
cachedAt = Date.now()
|
|
476
|
+
return keys
|
|
477
|
+
})
|
|
478
|
+
.finally(() => {
|
|
479
|
+
pending = undefined
|
|
480
|
+
})
|
|
460
481
|
}
|
|
482
|
+
return pending
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const load = (): Promise<readonly JwkKey[]> => {
|
|
486
|
+
const age = Date.now() - cachedAt
|
|
487
|
+
if (cached.length > 0 && age < cacheMs) return Promise.resolve(cached)
|
|
488
|
+
if (cached.length > 0 && age < cacheMs + staleMs) {
|
|
489
|
+
refresh().catch(() => {})
|
|
490
|
+
return Promise.resolve(cached)
|
|
491
|
+
}
|
|
492
|
+
return refresh()
|
|
461
493
|
}
|
|
462
494
|
|
|
463
495
|
return async (header) => {
|
|
464
|
-
|
|
465
|
-
|
|
496
|
+
const kid = header.kid
|
|
497
|
+
if (typeof kid !== "string" || kid === "") return null
|
|
466
498
|
const alg = header.alg as JwtAlgorithm
|
|
467
|
-
|
|
499
|
+
const find = (keys: readonly JwkKey[]): JwkKey | undefined =>
|
|
500
|
+
keys.find((key) => key.kid === kid && validateJwkForAlg(key, alg, kid))
|
|
501
|
+
const found = find(await load())
|
|
502
|
+
if (found !== undefined) return found
|
|
503
|
+
// A kid the set lacks may be a key the provider just rotated in.
|
|
504
|
+
if (pending === undefined && Date.now() - refreshedAt < cooldownMs) return undefined
|
|
505
|
+
try {
|
|
506
|
+
return find(await refresh())
|
|
507
|
+
} catch {
|
|
508
|
+
return undefined
|
|
509
|
+
}
|
|
468
510
|
}
|
|
469
511
|
}
|
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
|
-
| {
|
|
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
|
|
|
@@ -45,6 +50,8 @@ export interface OpenApiUiOptions {
|
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface OpenApiOptions {
|
|
53
|
+
/** The OpenAPI version to declare. `"3.2.0"` describes each SSE event with the standard `itemSchema`. */
|
|
54
|
+
readonly openapiVersion?: "3.1.0" | "3.2.0"
|
|
48
55
|
readonly info?: OpenApiInfo
|
|
49
56
|
readonly servers?: readonly OpenApiServer[]
|
|
50
57
|
/** Tag definitions (top-level `tags`). Reference them from an operation via `operations`. */
|
|
@@ -91,6 +98,7 @@ export function buildOpenApiDocument(
|
|
|
91
98
|
// and the shim only satisfies it structurally.
|
|
92
99
|
const appShim = { routes: () => included } as unknown as Parameters<typeof toOpenAPI>[0]
|
|
93
100
|
return toOpenAPI(appShim, {
|
|
101
|
+
...(options.openapiVersion !== undefined ? { openapiVersion: options.openapiVersion } : {}),
|
|
94
102
|
title: options.info?.title ?? "nifra API",
|
|
95
103
|
version: options.info?.version ?? "0.0.0",
|
|
96
104
|
...(options.info?.description !== undefined ? { description: options.info.description } : {}),
|
|
@@ -104,6 +112,9 @@ export function buildOpenApiDocument(
|
|
|
104
112
|
|
|
105
113
|
const SCALAR_CDN = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"
|
|
106
114
|
|
|
115
|
+
// Named per call, so a second, differently configured instance applies instead of being deduped.
|
|
116
|
+
let instances = 0
|
|
117
|
+
|
|
107
118
|
/**
|
|
108
119
|
* Minimal HTML that mounts the Scalar API reference against a spec URL. The interpolated values are
|
|
109
120
|
* dev-provided config (not request input), but they're still escaped so a stray quote or `<` can't
|
|
@@ -149,7 +160,7 @@ export function openapi(options: OpenApiOptions = {}): IdentityPlugin {
|
|
|
149
160
|
let uiCached: string | undefined
|
|
150
161
|
// A router plugin: it serves the spec (and optionally the UI page) as a side effect and returns the
|
|
151
162
|
// app type-unchanged, so every route declared after `.use(openapi())` keeps its types.
|
|
152
|
-
return defineRouterPlugin(
|
|
163
|
+
return defineRouterPlugin(`openapi#${++instances}`, (app) => {
|
|
153
164
|
// Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
|
|
154
165
|
// routes lazily, at request time - so all routes exist regardless of where the plugin sits.
|
|
155
166
|
app.register("GET", docPath, undefined, () => {
|
package/src/rate-limit.ts
CHANGED
|
@@ -164,6 +164,27 @@ export interface RateLimitOptions {
|
|
|
164
164
|
* when none is declared).
|
|
165
165
|
*/
|
|
166
166
|
readonly key?: (req: Request, platform?: Platform) => string
|
|
167
|
+
/**
|
|
168
|
+
* Leave a request uncharged: it is not counted and carries no `RateLimit-*` headers. Runs before
|
|
169
|
+
* the key is derived, so a health check or an internal probe never touches the store.
|
|
170
|
+
*/
|
|
171
|
+
readonly skip?: (req: Request, platform?: Platform) => boolean
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One bucket of a {@link rateLimitTiers} limiter: the requests at `prefix` or below it. */
|
|
175
|
+
export interface RateLimitTier {
|
|
176
|
+
/** Static path prefix such as `/api/public`: no params, wildcards, dot segments or trailing slash. */
|
|
177
|
+
readonly prefix: string
|
|
178
|
+
readonly max: number
|
|
179
|
+
readonly windowMs: number
|
|
180
|
+
/** Bucket key for this tier only. Default: the limiter's `key`, or the derived client IP. */
|
|
181
|
+
readonly key?: (req: Request, platform?: Platform) => string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface RateLimitTiersOptions extends Omit<RateLimitOptions, "max" | "windowMs"> {
|
|
185
|
+
/** The bucket for every request no tier claims. */
|
|
186
|
+
readonly floor: { readonly max: number; readonly windowMs: number }
|
|
187
|
+
readonly tiers: readonly RateLimitTier[]
|
|
167
188
|
}
|
|
168
189
|
|
|
169
190
|
/**
|
|
@@ -230,49 +251,76 @@ function nativeKey(
|
|
|
230
251
|
return allowGlobalKey ? "global" : null
|
|
231
252
|
}
|
|
232
253
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
254
|
+
interface QuotaInfo {
|
|
255
|
+
readonly limit: number
|
|
256
|
+
readonly remaining: number
|
|
257
|
+
readonly resetSeconds: number
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A counter a request can be charged to. `scope` prefixes its store keys so buckets never share one. */
|
|
261
|
+
interface Bucket {
|
|
262
|
+
readonly max: number
|
|
263
|
+
readonly windowMs: number
|
|
264
|
+
readonly scope: string
|
|
265
|
+
readonly key: (req: Request, platform?: Platform) => string | null
|
|
266
|
+
readonly nativeKey: ((req: NodeRequestContext, platform?: Platform) => string | null) | undefined
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function assertWindow(max: number, windowMs: number, label: string): void {
|
|
270
|
+
if (!Number.isInteger(max) || max < 1) throw new Error(`${label}: max must be a positive integer`)
|
|
242
271
|
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
243
|
-
throw new Error(
|
|
272
|
+
throw new Error(`${label}: windowMs must be a positive number`)
|
|
244
273
|
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The shared keying options, validated once, as the default key pair every bucket starts from. */
|
|
277
|
+
function defaultKeys(
|
|
278
|
+
options: RateLimitOptions | RateLimitTiersOptions,
|
|
279
|
+
label: string,
|
|
280
|
+
): Pick<Bucket, "key" | "nativeKey"> {
|
|
245
281
|
const trustedProxies = options.trustedProxies ?? 0
|
|
246
282
|
if (!Number.isInteger(trustedProxies) || trustedProxies < 0) {
|
|
247
|
-
throw new Error(
|
|
283
|
+
throw new Error(`${label}: trustedProxies must be a non-negative integer`)
|
|
248
284
|
}
|
|
249
285
|
const header = options.header?.trim().toLowerCase()
|
|
250
|
-
if (header !== undefined && header.trim() === "") throw new Error(
|
|
286
|
+
if (header !== undefined && header.trim() === "") throw new Error(`${label}: header is empty`)
|
|
251
287
|
const allowGlobalKey = options.allowGlobalKey === true
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
((req
|
|
255
|
-
|
|
256
|
-
|
|
288
|
+
if (options.key !== undefined) return { key: options.key, nativeKey: undefined }
|
|
289
|
+
return {
|
|
290
|
+
key: (req, platform) => defaultKey(req, platform, trustedProxies, header, allowGlobalKey),
|
|
291
|
+
nativeKey: (req, platform) => nativeKey(req, platform, trustedProxies, header, allowGlobalKey),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The limiter both public factories build: `bucketFor` picks the one bucket a request is charged to,
|
|
297
|
+
* from its pathname when `pathAware`. The Node twins exist only when every bucket can key the
|
|
298
|
+
* allocation-light native request and nothing needs a real `Request` (`skip`, a custom `key`).
|
|
299
|
+
*/
|
|
300
|
+
function limiter(
|
|
301
|
+
name: string,
|
|
302
|
+
store: RateLimitStore,
|
|
303
|
+
buckets: readonly Bucket[],
|
|
304
|
+
bucketFor: (pathname: string) => Bucket,
|
|
305
|
+
pathAware: boolean,
|
|
306
|
+
skip: RateLimitOptions["skip"],
|
|
307
|
+
): Middleware {
|
|
308
|
+
const quota = new WeakMap<Request, QuotaInfo>()
|
|
257
309
|
// State for the Node twins, keyed by the NodeRequestContext identity (the same object is passed
|
|
258
310
|
// to the request and response twins - the core identity contract that replaces `Request` keying).
|
|
259
|
-
const nativeQuota = new WeakMap<object,
|
|
260
|
-
|
|
261
|
-
interface QuotaInfo {
|
|
262
|
-
readonly remaining: number
|
|
263
|
-
readonly resetSeconds: number
|
|
264
|
-
}
|
|
311
|
+
const nativeQuota = new WeakMap<object, QuotaInfo>()
|
|
265
312
|
|
|
266
|
-
const
|
|
313
|
+
const charge = async (
|
|
314
|
+
bucket: Bucket,
|
|
267
315
|
key: string | null,
|
|
268
316
|
): Promise<{ info: QuotaInfo; reject: Response | undefined } | Response> => {
|
|
269
317
|
if (typeof key !== "string" || key.trim() === "") {
|
|
270
318
|
return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
|
|
271
319
|
}
|
|
272
|
-
const { count, resetAt } = await store.hit(key, windowMs)
|
|
320
|
+
const { count, resetAt } = await store.hit(bucket.scope + key, bucket.windowMs)
|
|
273
321
|
const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
|
|
274
|
-
const info = { remaining: Math.max(0, max - count), resetSeconds }
|
|
275
|
-
if (count > max) {
|
|
322
|
+
const info = { limit: bucket.max, remaining: Math.max(0, bucket.max - count), resetSeconds }
|
|
323
|
+
if (count > bucket.max) {
|
|
276
324
|
return {
|
|
277
325
|
info,
|
|
278
326
|
reject: new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
|
|
@@ -284,10 +332,13 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
284
332
|
return { info, reject: undefined }
|
|
285
333
|
}
|
|
286
334
|
|
|
335
|
+
const native = skip === undefined && buckets.every((bucket) => bucket.nativeKey !== undefined)
|
|
287
336
|
const middleware: Middleware = {
|
|
288
|
-
name
|
|
337
|
+
name,
|
|
289
338
|
async onRequest(req, platform) {
|
|
290
|
-
|
|
339
|
+
if (skip?.(req, platform) === true) return undefined
|
|
340
|
+
const bucket = bucketFor(pathAware ? pathnameOf(req.url) : "")
|
|
341
|
+
const outcome = await charge(bucket, bucket.key(req, platform))
|
|
291
342
|
if (outcome instanceof Response) return outcome
|
|
292
343
|
quota.set(req, outcome.info)
|
|
293
344
|
return outcome.reject
|
|
@@ -296,20 +347,21 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
296
347
|
const info = quota.get(req)
|
|
297
348
|
if (info === undefined) return res
|
|
298
349
|
quota.delete(req)
|
|
299
|
-
return withHeaders(
|
|
300
|
-
|
|
301
|
-
headers
|
|
302
|
-
|
|
303
|
-
|
|
350
|
+
return withHeaders(
|
|
351
|
+
res,
|
|
352
|
+
(headers) => {
|
|
353
|
+
headers.set("RateLimit-Limit", String(info.limit))
|
|
354
|
+
headers.set("RateLimit-Remaining", String(info.remaining))
|
|
355
|
+
headers.set("RateLimit-Reset", String(info.resetSeconds))
|
|
356
|
+
},
|
|
357
|
+
req,
|
|
358
|
+
)
|
|
304
359
|
},
|
|
305
|
-
|
|
306
|
-
// takes a real `Request`, which is exactly the object the native lane avoids building.
|
|
307
|
-
...(options.key === undefined
|
|
360
|
+
...(native
|
|
308
361
|
? {
|
|
309
362
|
onNodeRequest: async (req: NodeRequestContext, platform?: Platform) => {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
)
|
|
363
|
+
const bucket = bucketFor(pathAware ? pathnameOf(req.url) : "")
|
|
364
|
+
const outcome = await charge(bucket, bucket.nativeKey?.(req, platform) ?? null)
|
|
313
365
|
if (outcome instanceof Response) return outcome
|
|
314
366
|
nativeQuota.set(req, outcome.info)
|
|
315
367
|
return outcome.reject
|
|
@@ -320,16 +372,156 @@ export function rateLimit(options: RateLimitOptions): Middleware {
|
|
|
320
372
|
nativeQuota.delete(req)
|
|
321
373
|
// Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so this
|
|
322
374
|
// is byte-identical output.
|
|
323
|
-
setNodeHeader(res, "ratelimit-limit", String(
|
|
375
|
+
setNodeHeader(res, "ratelimit-limit", String(info.limit))
|
|
324
376
|
setNodeHeader(res, "ratelimit-remaining", String(info.remaining))
|
|
325
377
|
setNodeHeader(res, "ratelimit-reset", String(info.resetSeconds))
|
|
326
378
|
},
|
|
327
379
|
}
|
|
328
380
|
: {}),
|
|
329
381
|
}
|
|
382
|
+
return middleware
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The pathname the router matches: an absolute URL's path or a raw request target, without query
|
|
387
|
+
* or fragment. A dot segment is resolved the way the server resolves it before routing.
|
|
388
|
+
*/
|
|
389
|
+
function pathnameOf(url: string): string {
|
|
390
|
+
let start = 0
|
|
391
|
+
if (!url.startsWith("/")) {
|
|
392
|
+
const authority = url.indexOf("//")
|
|
393
|
+
const slash = url.indexOf("/", authority === -1 ? 0 : authority + 2)
|
|
394
|
+
if (slash === -1) return "/"
|
|
395
|
+
start = slash
|
|
396
|
+
}
|
|
397
|
+
let end = url.length
|
|
398
|
+
const query = url.indexOf("?", start)
|
|
399
|
+
if (query !== -1) end = query
|
|
400
|
+
const hash = url.indexOf("#", start)
|
|
401
|
+
if (hash !== -1 && hash < end) end = hash
|
|
402
|
+
const path = url.slice(start, end)
|
|
403
|
+
return DOT_SEGMENT.test(path) ? new URL(path, "http://n").pathname : path
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const DOT_SEGMENT = /\/(?:\.|%2e)/i
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
|
|
410
|
+
* also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
|
|
411
|
+
* `RateLimit-Limit/Remaining/Reset` (added in `onResponse`, keyed off the request).
|
|
412
|
+
*/
|
|
413
|
+
export function rateLimit(options: RateLimitOptions): Middleware {
|
|
414
|
+
assertWindow(options.max, options.windowMs, "rateLimit")
|
|
415
|
+
if (options.skip !== undefined && typeof options.skip !== "function") {
|
|
416
|
+
throw new TypeError("rateLimit: skip must be a function")
|
|
417
|
+
}
|
|
418
|
+
const bucket: Bucket = {
|
|
419
|
+
max: options.max,
|
|
420
|
+
windowMs: options.windowMs,
|
|
421
|
+
scope: "",
|
|
422
|
+
...defaultKeys(options, "rateLimit"),
|
|
423
|
+
}
|
|
424
|
+
const middleware = limiter(
|
|
425
|
+
guardName("rate-limit"),
|
|
426
|
+
options.store,
|
|
427
|
+
[bucket],
|
|
428
|
+
() => bucket,
|
|
429
|
+
false,
|
|
430
|
+
options.skip,
|
|
431
|
+
)
|
|
330
432
|
return withRouteAssurance(middleware, {
|
|
331
433
|
id: NIFRA_ASSURANCE.RATE_LIMITED,
|
|
332
434
|
source: "rate-limit",
|
|
333
435
|
scope: "global",
|
|
334
436
|
})
|
|
335
437
|
}
|
|
438
|
+
|
|
439
|
+
const STATIC_PREFIX = /^(?:\/[A-Za-z0-9\-._~%!$&'()+,;=@]+)+$/
|
|
440
|
+
|
|
441
|
+
function assertTierPrefix(prefix: unknown): asserts prefix is string {
|
|
442
|
+
const bad = (why: string): never => {
|
|
443
|
+
throw new Error(`rateLimitTiers: tier prefix ${JSON.stringify(prefix)} ${why}`)
|
|
444
|
+
}
|
|
445
|
+
if (typeof prefix !== "string" || !STATIC_PREFIX.test(prefix)) {
|
|
446
|
+
return bad('must be a static path such as "/api/public", with no trailing slash')
|
|
447
|
+
}
|
|
448
|
+
for (const segment of prefix.split("/").slice(1)) {
|
|
449
|
+
if (segment === "." || segment === "..") bad("must not contain a dot segment")
|
|
450
|
+
if (segment.startsWith(":") || segment.startsWith("*"))
|
|
451
|
+
bad("must be static: no params or wildcards")
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Rate limiting in tiers under one app-wide floor, as a single {@link Middleware}. Every request is
|
|
457
|
+
* charged to exactly one bucket: the tier with the longest `prefix` that is the request's path or an
|
|
458
|
+
* ancestor of it, else the `floor`. Tiers never stack, so a tier may sit above the floor
|
|
459
|
+
* (`600/min` for `/api/public` under a `120/min` floor). It runs in `onRequest`, before routing, so
|
|
460
|
+
* a 404, a 405 or a body that fails validation is charged too. Prefixes are validated here; each
|
|
461
|
+
* tier counts in its own keys, so tiers can share one store.
|
|
462
|
+
*/
|
|
463
|
+
export function rateLimitTiers(options: RateLimitTiersOptions): Middleware {
|
|
464
|
+
const label = "rateLimitTiers"
|
|
465
|
+
if (options.floor === null || typeof options.floor !== "object") {
|
|
466
|
+
throw new TypeError(`${label}: floor must be { max, windowMs }`)
|
|
467
|
+
}
|
|
468
|
+
if (!Array.isArray(options.tiers)) throw new TypeError(`${label}: tiers must be an array`)
|
|
469
|
+
if (options.skip !== undefined && typeof options.skip !== "function") {
|
|
470
|
+
throw new TypeError(`${label}: skip must be a function`)
|
|
471
|
+
}
|
|
472
|
+
assertWindow(options.floor.max, options.floor.windowMs, `${label} floor`)
|
|
473
|
+
const keys = defaultKeys(options, label)
|
|
474
|
+
const floor: Bucket = {
|
|
475
|
+
max: options.floor.max,
|
|
476
|
+
windowMs: options.floor.windowMs,
|
|
477
|
+
scope: "floor:",
|
|
478
|
+
...keys,
|
|
479
|
+
}
|
|
480
|
+
const seen = new Set<string>()
|
|
481
|
+
const tiers: (Bucket & { readonly prefix: string })[] = []
|
|
482
|
+
for (const tier of options.tiers) {
|
|
483
|
+
assertTierPrefix(tier.prefix)
|
|
484
|
+
if (seen.has(tier.prefix)) {
|
|
485
|
+
throw new Error(`${label}: duplicate tier prefix ${JSON.stringify(tier.prefix)}`)
|
|
486
|
+
}
|
|
487
|
+
seen.add(tier.prefix)
|
|
488
|
+
assertWindow(tier.max, tier.windowMs, `${label} tier ${tier.prefix}`)
|
|
489
|
+
if (tier.key !== undefined && typeof tier.key !== "function") {
|
|
490
|
+
throw new TypeError(`${label}: tier ${tier.prefix} key must be a function`)
|
|
491
|
+
}
|
|
492
|
+
tiers.push({
|
|
493
|
+
prefix: tier.prefix,
|
|
494
|
+
max: tier.max,
|
|
495
|
+
windowMs: tier.windowMs,
|
|
496
|
+
scope: `tier:${tier.prefix}:`,
|
|
497
|
+
key: tier.key ?? keys.key,
|
|
498
|
+
nativeKey: tier.key === undefined ? keys.nativeKey : undefined,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
// Longest first, so the first tier whose prefix holds the path is the most specific one.
|
|
502
|
+
tiers.sort((a, b) => b.prefix.length - a.prefix.length)
|
|
503
|
+
const bucketFor = (pathname: string): Bucket => {
|
|
504
|
+
for (const tier of tiers) {
|
|
505
|
+
if (
|
|
506
|
+
pathname.startsWith(tier.prefix) &&
|
|
507
|
+
(pathname.length === tier.prefix.length || pathname.charCodeAt(tier.prefix.length) === 47)
|
|
508
|
+
) {
|
|
509
|
+
return tier
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return floor
|
|
513
|
+
}
|
|
514
|
+
const middleware = limiter(
|
|
515
|
+
guardName("rate-limit-tiers"),
|
|
516
|
+
options.store,
|
|
517
|
+
[floor, ...tiers],
|
|
518
|
+
bucketFor,
|
|
519
|
+
true,
|
|
520
|
+
options.skip,
|
|
521
|
+
)
|
|
522
|
+
return withRouteAssurance(middleware, {
|
|
523
|
+
id: NIFRA_ASSURANCE.RATE_LIMITED,
|
|
524
|
+
source: "rate-limit-tiers",
|
|
525
|
+
scope: "global",
|
|
526
|
+
})
|
|
527
|
+
}
|
package/src/request-id.ts
CHANGED
|
@@ -65,9 +65,13 @@ export function requestId(options: RequestIdOptions = {}): ContextPlugin<{ reque
|
|
|
65
65
|
// The id the derive generated; else the inbound header, then a fresh id - so a response
|
|
66
66
|
// no derive saw (a 404, an earlier middleware's short-circuit) is still covered.
|
|
67
67
|
const echo = id ?? inboundOf(req.headers.get(header)) ?? generate()
|
|
68
|
-
return withHeaders(
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
return withHeaders(
|
|
69
|
+
res,
|
|
70
|
+
(headers) => {
|
|
71
|
+
headers.set(header, echo)
|
|
72
|
+
},
|
|
73
|
+
req,
|
|
74
|
+
)
|
|
71
75
|
},
|
|
72
76
|
onNodeResponse(res, req) {
|
|
73
77
|
// The common node path (a handler route, whose derive wrote the id through `c.set`) pays
|
package/src/timing.ts
CHANGED
|
@@ -124,10 +124,14 @@ export function timing(options: TimingOptions = {}): ContextPlugin<{ timing: Tim
|
|
|
124
124
|
if (metrics.length === 0) return res
|
|
125
125
|
|
|
126
126
|
const value = metrics.map((metric) => formatMetric(metric, precision)).join(", ")
|
|
127
|
-
return withHeaders(
|
|
128
|
-
|
|
129
|
-
headers
|
|
130
|
-
|
|
127
|
+
return withHeaders(
|
|
128
|
+
res,
|
|
129
|
+
(headers) => {
|
|
130
|
+
const existing = headers.get("server-timing")
|
|
131
|
+
headers.set("server-timing", existing === null ? value : `${existing}, ${value}`)
|
|
132
|
+
},
|
|
133
|
+
req,
|
|
134
|
+
)
|
|
131
135
|
}),
|
|
132
136
|
)
|
|
133
137
|
}
|