@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.
- 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/admission.d.ts +4 -2
- package/dist/admission.d.ts.map +1 -1
- package/dist/admission.js +17 -5
- package/dist/admission.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 +20 -4
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +44 -17
- 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 +17 -2
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +70 -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 +19 -4
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +24 -8
- 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/admission.ts +15 -5
- 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 +77 -20
- package/src/index.ts +3 -0
- package/src/jwt.ts +106 -34
- package/src/language.ts +11 -0
- package/src/openapi.ts +52 -10
- 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/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
|
}
|
package/src/token-auth.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
2
|
import { defineIdentityPlugin, type IdentityPlugin } from "@nifrajs/core/server"
|
|
3
|
-
import { bearerToken, guardName } from "./_utils.ts"
|
|
3
|
+
import { bearerToken, guardName, type HeaderGet, installAuthGuard } from "./_utils.ts"
|
|
4
4
|
|
|
5
5
|
type MaybePromise<T> = T | Promise<T>
|
|
6
6
|
|
|
@@ -29,8 +29,8 @@ export type AuthPlugin<P> = IdentityPlugin & {
|
|
|
29
29
|
|
|
30
30
|
interface TokenAuthConfig<P> {
|
|
31
31
|
readonly name: string
|
|
32
|
-
/** Pull the credential out of the request (
|
|
33
|
-
readonly extract: (
|
|
32
|
+
/** Pull the credential out of the request's headers (the `Bearer` token, an `x-api-key`). */
|
|
33
|
+
readonly extract: (header: HeaderGet) => string | undefined
|
|
34
34
|
/** Verify the credential → a principal (truthy) or `null`/`undefined` (rejected). */
|
|
35
35
|
readonly verify: (token: string) => MaybePromise<P | null | undefined>
|
|
36
36
|
readonly optional: boolean
|
|
@@ -39,7 +39,7 @@ interface TokenAuthConfig<P> {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
|
|
42
|
-
// Per-request principal cache: verify runs once
|
|
42
|
+
// Per-request principal cache: verify runs once, in the guard; reads are free and can't desync.
|
|
43
43
|
const store = new WeakMap<Request, P>()
|
|
44
44
|
const reject = (): Response =>
|
|
45
45
|
Response.json(
|
|
@@ -50,16 +50,18 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
|
|
|
50
50
|
},
|
|
51
51
|
)
|
|
52
52
|
const plugin = defineIdentityPlugin(guardName(config.name), (app) =>
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
installAuthGuard(
|
|
54
|
+
app,
|
|
55
|
+
config.name,
|
|
56
|
+
config.optional,
|
|
57
|
+
store,
|
|
58
|
+
(header) => {
|
|
59
|
+
const token = config.extract(header)
|
|
60
|
+
// Empty string is treated as "no credential" - never passed to verify.
|
|
61
|
+
return token !== undefined && token !== "" ? config.verify(token) : null
|
|
62
|
+
},
|
|
63
|
+
reject,
|
|
64
|
+
),
|
|
63
65
|
)
|
|
64
66
|
const instrumented = config.optional
|
|
65
67
|
? plugin
|
|
@@ -97,7 +99,7 @@ export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
|
|
|
97
99
|
const realm = options.realm ?? "api"
|
|
98
100
|
return createTokenAuth({
|
|
99
101
|
name: "bearer",
|
|
100
|
-
extract: (
|
|
102
|
+
extract: (header) => bearerToken(header("authorization")) ?? undefined,
|
|
101
103
|
verify: options.verify,
|
|
102
104
|
optional: options.optional === true,
|
|
103
105
|
challenge: `Bearer realm="${realm}"`,
|
|
@@ -171,7 +173,7 @@ export function apiKey<P>(
|
|
|
171
173
|
const verify = "keys" in options ? staticKeyVerify(options.keys) : options.verify
|
|
172
174
|
return createTokenAuth<P | string>({
|
|
173
175
|
name: "apiKey",
|
|
174
|
-
extract: (
|
|
176
|
+
extract: (get) => get(header) ?? undefined,
|
|
175
177
|
verify,
|
|
176
178
|
optional: options.optional === true,
|
|
177
179
|
})
|