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