@nifrajs/middleware 2.1.0 → 2.3.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 +22 -22
- package/dist/_utils.d.ts +3 -3
- package/dist/_utils.js +3 -3
- package/dist/admission.d.ts +2 -2
- package/dist/admission.js +2 -2
- package/dist/cache-control.d.ts +1 -1
- package/dist/cache-control.js +1 -1
- package/dist/cache.js +1 -1
- package/dist/compression.d.ts +1 -1
- package/dist/compression.js +4 -4
- package/dist/cors.d.ts +1 -1
- package/dist/cors.js +2 -2
- package/dist/durable-command.d.ts +34 -0
- package/dist/durable-command.d.ts.map +1 -0
- package/dist/durable-command.js +50 -0
- package/dist/durable-command.js.map +1 -0
- package/dist/etag.d.ts +2 -2
- package/dist/etag.js +3 -3
- package/dist/healthcheck.d.ts +2 -2
- package/dist/healthcheck.js +1 -1
- package/dist/idempotency.d.ts +4 -4
- package/dist/idempotency.js +3 -3
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +2 -2
- package/dist/openapi.d.ts +4 -4
- package/dist/openapi.js +4 -4
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +70 -18
- package/dist/pretty-json.js.map +1 -1
- package/dist/rate-limit.d.ts +8 -8
- package/dist/rate-limit.js +5 -5
- package/dist/request-id.d.ts +1 -1
- package/dist/request-id.js +1 -1
- package/dist/security-headers.d.ts +1 -1
- package/dist/security-headers.js +1 -1
- package/dist/token-auth.d.ts +6 -6
- package/dist/token-auth.js +4 -4
- package/package.json +4 -4
- package/src/_utils.ts +3 -3
- package/src/admission.ts +4 -4
- package/src/cache-control.ts +1 -1
- package/src/cache.ts +1 -1
- package/src/compression.ts +4 -4
- package/src/cors.ts +2 -2
- package/src/durable-command.ts +62 -0
- package/src/etag.ts +4 -4
- package/src/healthcheck.ts +3 -3
- package/src/idempotency.ts +7 -7
- package/src/index.ts +3 -2
- package/src/logger.ts +2 -2
- package/src/openapi.ts +8 -8
- package/src/pretty-json.ts +78 -16
- package/src/rate-limit.ts +10 -10
- package/src/request-id.ts +1 -1
- package/src/security-headers.ts +2 -2
- package/src/token-auth.ts +9 -9
package/src/etag.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { definePlugin } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
|
-
/** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags
|
|
3
|
+
/** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags - not crypto. */
|
|
4
4
|
function fnv1a(bytes: Uint8Array): string {
|
|
5
5
|
let h = 0x811c9dc5
|
|
6
6
|
for (let i = 0; i < bytes.length; i++) {
|
|
@@ -11,7 +11,7 @@ function fnv1a(bytes: Uint8Array): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface ETagOptions {
|
|
14
|
-
/** Emit a weak validator (`W/"…"`). Default `true`
|
|
14
|
+
/** Emit a weak validator (`W/"…"`). Default `true` - it's a content hash, not a byte-for-byte promise. */
|
|
15
15
|
readonly weak?: boolean
|
|
16
16
|
/** Maximum response bytes to hash. Default `1_000_000`; larger responses pass through unchanged. */
|
|
17
17
|
readonly maxBytes?: number
|
|
@@ -19,7 +19,7 @@ export interface ETagOptions {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* A {@link definePlugin} plugin that adds a content-hash `ETag` to `GET` `200` responses and returns
|
|
22
|
-
* **`304 Not Modified`** when the client's `If-None-Match` matches
|
|
22
|
+
* **`304 Not Modified`** when the client's `If-None-Match` matches - saving bandwidth on unchanged
|
|
23
23
|
* responses. It reads and rebuilds small bodies only; larger responses pass through unchanged.
|
|
24
24
|
* Idempotent.
|
|
25
25
|
*/
|
|
@@ -40,7 +40,7 @@ export function etag(options: ETagOptions = {}) {
|
|
|
40
40
|
const headers = new Headers(res.headers)
|
|
41
41
|
headers.set("ETag", tag)
|
|
42
42
|
if (matchesIfNoneMatch(req.headers.get("if-none-match"), tag)) {
|
|
43
|
-
// A 304 carries no body
|
|
43
|
+
// A 304 carries no body - drop the body-describing headers so strict intermediaries don't see a
|
|
44
44
|
// null body with a non-zero Content-Length.
|
|
45
45
|
headers.delete("content-length")
|
|
46
46
|
headers.delete("content-type")
|
package/src/healthcheck.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { definePlugin } from "@nifrajs/core/server"
|
|
|
3
3
|
type MaybePromise<T> = T | Promise<T>
|
|
4
4
|
|
|
5
5
|
export interface HealthcheckOptions {
|
|
6
|
-
/** Liveness path
|
|
6
|
+
/** Liveness path - always `200` while the process is up. Default `"/health"`. */
|
|
7
7
|
readonly path?: string
|
|
8
|
-
/** Readiness path
|
|
8
|
+
/** Readiness path - runs `checks`; `200` if all pass, `503` otherwise. Default `"/ready"`. */
|
|
9
9
|
readyPath?: string
|
|
10
10
|
/** Readiness checks (DB ping, cache reachable, …). Each returns a boolean or throws; a throw counts
|
|
11
11
|
* as failing. `/ready` reports each by name and is `200` only when all pass. */
|
|
@@ -38,7 +38,7 @@ export function healthcheck(options: HealthcheckOptions = {}) {
|
|
|
38
38
|
app.register("GET", readyPath, undefined, async () => {
|
|
39
39
|
const results: Record<string, boolean> = {}
|
|
40
40
|
let ready = true
|
|
41
|
-
// Run checks concurrently; a throw (or false) marks that check
|
|
41
|
+
// Run checks concurrently; a throw (or false) marks that check - and the whole probe - not ready.
|
|
42
42
|
await Promise.all(
|
|
43
43
|
checkNames.map(async (name) => {
|
|
44
44
|
try {
|
package/src/idempotency.ts
CHANGED
|
@@ -2,12 +2,12 @@ import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
|
2
2
|
import { METHODS, type Middleware } from "@nifrajs/core/server"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Idempotency keys for unsafe requests
|
|
5
|
+
* Idempotency keys for unsafe requests - a client retrying a `POST` (dropped connection, impatient
|
|
6
6
|
* tap) with the same `Idempotency-Key` gets the **first** response replayed instead of the side effect
|
|
7
7
|
* running twice (double-charge, double-publish). Runs in `onRequest` (before the handler), so a replay
|
|
8
8
|
* or an in-flight collision short-circuits before any mutation.
|
|
9
9
|
*
|
|
10
|
-
* Pair it with a DB uniqueness constraint
|
|
10
|
+
* Pair it with a DB uniqueness constraint - this stops the *retry*, the constraint is the source of
|
|
11
11
|
* truth for genuinely-concurrent distinct requests. Production MUST use a shared {@link IdempotencyStore}
|
|
12
12
|
* (Redis, etc.) so the guarantee holds across instances; {@link MemoryIdempotencyStore} is dev-only.
|
|
13
13
|
*/
|
|
@@ -15,7 +15,7 @@ import { METHODS, type Middleware } from "@nifrajs/core/server"
|
|
|
15
15
|
/** A captured response, replayed verbatim on a retry. Body is base64 (binary-safe + JSON-serializable). */
|
|
16
16
|
export interface IdempotencyRecord {
|
|
17
17
|
readonly status: number
|
|
18
|
-
/** Response headers, **excluding `Set-Cookie`** (cookies are session-specific
|
|
18
|
+
/** Response headers, **excluding `Set-Cookie`** (cookies are session-specific - see {@link idempotency}). */
|
|
19
19
|
readonly headers: ReadonlyArray<readonly [string, string]>
|
|
20
20
|
/** Response body, base64-encoded. */
|
|
21
21
|
readonly body: string
|
|
@@ -45,7 +45,7 @@ export interface IdempotencyStore {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
export interface MemoryIdempotencyStoreOptions {
|
|
48
|
-
/** Allow the in-memory store in production. Off by default
|
|
48
|
+
/** Allow the in-memory store in production. Off by default - a per-instance store can't dedupe across instances. */
|
|
49
49
|
readonly allowInProduction?: boolean
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -253,7 +253,7 @@ function replay(record: IdempotencyRecord): Response {
|
|
|
253
253
|
/**
|
|
254
254
|
* Idempotency-key middleware. Apply with `app.use(idempotency({ store }))`.
|
|
255
255
|
*
|
|
256
|
-
* **`Set-Cookie` is intentionally not cached or replayed**
|
|
256
|
+
* **`Set-Cookie` is intentionally not cached or replayed** - a cookie set on the first request is
|
|
257
257
|
* session-specific, so replaying it to a different caller (key collision or abuse) would leak/fixate a
|
|
258
258
|
* session. Cache the body + status + the rest of the headers; let auth cookies re-issue per request.
|
|
259
259
|
*
|
|
@@ -286,7 +286,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
286
286
|
async onRequest(req) {
|
|
287
287
|
if (!methods.has(req.method.toUpperCase())) return undefined
|
|
288
288
|
const key = keyOf(req, header)
|
|
289
|
-
if (key === null || key === "") return undefined // opt-in per request
|
|
289
|
+
if (key === null || key === "") return undefined // opt-in per request - no key ⇒ no dedupe
|
|
290
290
|
const claim = await store.begin(key, lockTtlMs)
|
|
291
291
|
if (claim.state === "replay") return replay(claim.record)
|
|
292
292
|
if (claim.state === "in_flight") {
|
|
@@ -318,7 +318,7 @@ export function idempotency(options: IdempotencyOptions): Middleware {
|
|
|
318
318
|
throw err
|
|
319
319
|
}
|
|
320
320
|
if (!("bytes" in captured)) {
|
|
321
|
-
await store.release(key) // too large to store
|
|
321
|
+
await store.release(key) // too large to store - return it, but don't cache
|
|
322
322
|
return captured.response
|
|
323
323
|
}
|
|
324
324
|
const bytes = captured.bytes
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/middleware`
|
|
2
|
+
* `@nifrajs/middleware` - composable, dependency-free middleware + plugins applied via `app.use()`.
|
|
3
3
|
* The hardening set (`cors`, `rateLimit`, `securityHeaders`) returns a `@nifrajs/core` `Middleware`
|
|
4
4
|
* (a hook bundle, context-agnostic). The seeded plugins (`requestId`, `logger`, `etag`, `bearer`,
|
|
5
|
-
* `apiKey`) use the `definePlugin` convention
|
|
5
|
+
* `apiKey`) use the `definePlugin` convention - named (idempotent dedupe), and `requestId` threads
|
|
6
6
|
* `c.requestId` into the handler context.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -44,6 +44,7 @@ export {
|
|
|
44
44
|
csrf,
|
|
45
45
|
verifyCsrfToken,
|
|
46
46
|
} from "./csrf.ts"
|
|
47
|
+
export { type DurableCommandOptions, durableCommand } from "./durable-command.ts"
|
|
47
48
|
export { type ETagOptions, etag } from "./etag.ts"
|
|
48
49
|
export { type HealthcheckOptions, healthcheck } from "./healthcheck.ts"
|
|
49
50
|
export {
|
package/src/logger.ts
CHANGED
|
@@ -15,8 +15,8 @@ export interface LoggerOptions {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* A {@link definePlugin} plugin that logs one structured line per request
|
|
19
|
-
* and duration
|
|
18
|
+
* A {@link definePlugin} plugin that logs one structured line per request - method, path, status,
|
|
19
|
+
* and duration - via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
|
|
20
20
|
* paired to the request through a `WeakMap` (no per-request allocation leak). Idempotent.
|
|
21
21
|
*/
|
|
22
22
|
export function logger(options: LoggerOptions = {}) {
|
package/src/openapi.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { definePlugin } from "@nifrajs/core/server"
|
|
2
|
-
// The OpenAPI generator only
|
|
2
|
+
// The OpenAPI generator only - `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
|
|
3
3
|
// plugin gets full `t`-schema introspection without pulling the `t` builder into every consumer.
|
|
4
4
|
import { toOpenAPI } from "@nifrajs/schema/openapi"
|
|
5
5
|
|
|
6
|
-
/** A registered route as seen by {@link buildOpenApiDocument}
|
|
6
|
+
/** A registered route as seen by {@link buildOpenApiDocument} - structurally a `@nifrajs/core`
|
|
7
7
|
* `RouteDescriptor` (so `app.routes()` is passed straight through). */
|
|
8
8
|
export interface RouteLike {
|
|
9
9
|
readonly method: string
|
|
10
10
|
readonly path: string
|
|
11
|
-
// `| undefined` (not just `?`) so a core `RouteDescriptor`
|
|
12
|
-
// `RouteSchema | undefined`
|
|
11
|
+
// `| undefined` (not just `?`) so a core `RouteDescriptor` - whose `schema` is a *required*
|
|
12
|
+
// `RouteSchema | undefined` - is assignable under `exactOptionalPropertyTypes`.
|
|
13
13
|
readonly schema?:
|
|
14
14
|
| { readonly body?: unknown; readonly query?: unknown; readonly response?: unknown }
|
|
15
15
|
| undefined
|
|
@@ -57,7 +57,7 @@ export interface OpenApiOptions {
|
|
|
57
57
|
readonly path?: string
|
|
58
58
|
/** Exclude routes from the document (the doc path itself is always excluded). */
|
|
59
59
|
readonly exclude?: (route: { readonly method: string; readonly path: string }) => boolean
|
|
60
|
-
/** Per-operation overrides keyed by `"GET /users/:id"`, shallow-merged over the generated skeleton
|
|
60
|
+
/** Per-operation overrides keyed by `"GET /users/:id"`, shallow-merged over the generated skeleton -
|
|
61
61
|
* the escape hatch for rich request/response schemas, tags, and per-op security (Standard Schema
|
|
62
62
|
* can't be introspected). */
|
|
63
63
|
readonly operations?: Readonly<Record<string, Record<string, unknown>>>
|
|
@@ -127,10 +127,10 @@ function scalarPage(specUrl: string, title: string, cdn: string): string {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
* Serve an OpenAPI 3.1 document (a structural subset
|
|
130
|
+
* Serve an OpenAPI 3.1 document (a structural subset - see {@link buildOpenApiDocument}) at
|
|
131
131
|
* `options.path` (default `/openapi.json`), generated from the app's registered routes. Generation is
|
|
132
132
|
* **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
|
|
133
|
-
* registered
|
|
133
|
+
* registered - so the plugin's own position in the chain doesn't matter.
|
|
134
134
|
*
|
|
135
135
|
* Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page (default
|
|
136
136
|
* `/reference`) that renders the spec. The page loads Scalar from a CDN; pin/self-host via `ui.cdn`,
|
|
@@ -149,7 +149,7 @@ export function openapi(options: OpenApiOptions = {}) {
|
|
|
149
149
|
let uiCached: string | undefined
|
|
150
150
|
return definePlugin("openapi", (app) => {
|
|
151
151
|
// Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
|
|
152
|
-
// routes lazily, at request time
|
|
152
|
+
// routes lazily, at request time - so all routes exist regardless of where the plugin sits.
|
|
153
153
|
app.register("GET", docPath, undefined, () => {
|
|
154
154
|
cached ??= JSON.stringify(
|
|
155
155
|
buildOpenApiDocument(app.routes(), {
|
package/src/pretty-json.ts
CHANGED
|
@@ -25,26 +25,82 @@ function parseLength(value: string | null): number | undefined {
|
|
|
25
25
|
return Number(value)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The two reader methods this file uses, structurally.
|
|
30
|
+
*
|
|
31
|
+
* Named by use rather than imported: `lib.dom`'s `ReadableStreamDefaultReader` and Bun's augmented one
|
|
32
|
+
* are not mutually assignable (Bun adds `readMany`), and `ReturnType<…getReader>` resolves to the BYOB
|
|
33
|
+
* overload, whose `read` requires an argument. A structural shape sidesteps all of it without a cast.
|
|
34
|
+
*/
|
|
35
|
+
interface ChunkReader {
|
|
36
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>
|
|
37
|
+
cancel(reason?: unknown): Promise<void>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Re-emit the bytes already pulled, then the untouched remainder of the same reader. */
|
|
41
|
+
function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkReader): Response {
|
|
42
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
43
|
+
start(controller) {
|
|
44
|
+
for (const chunk of chunks) controller.enqueue(chunk)
|
|
45
|
+
},
|
|
46
|
+
async pull(controller) {
|
|
47
|
+
try {
|
|
48
|
+
const { done, value } = await reader.read()
|
|
49
|
+
// No value and not done cannot happen for a byte stream, but it means the same thing here:
|
|
50
|
+
// there is nothing further to emit.
|
|
51
|
+
if (done || value === undefined) controller.close()
|
|
52
|
+
else controller.enqueue(value)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// The upstream body failed; surface that to the client rather than truncating silently.
|
|
55
|
+
controller.error(error)
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
cancel: (reason) => reader.cancel(reason),
|
|
59
|
+
})
|
|
60
|
+
return new Response(stream, {
|
|
61
|
+
status: res.status,
|
|
62
|
+
statusText: res.statusText,
|
|
63
|
+
headers: res.headers,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Peeked = { readonly text: string } | { readonly response: Response }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read up to `maxBytes` of a response, without disturbing it when the answer is "pass through".
|
|
71
|
+
*
|
|
72
|
+
* `Response.clone()` is the obvious way to peek and is a trap here. Cancelling the clone's reader once
|
|
73
|
+
* the cap is exceeded also stalls the ORIGINAL body in Bun, so an oversized streamed response hung the
|
|
74
|
+
* client instead of passing through - a hang, from a middleware whose entire job is cosmetic. (A clone
|
|
75
|
+
* read to completion is fine; only the cancel breaks it, which is why every buffered case passed and
|
|
76
|
+
* only a streamed one failed.) Abandoning the clone rather than cancelling it trades the hang for
|
|
77
|
+
* buffering the whole oversized body, which is the exact cost the cap exists to avoid.
|
|
78
|
+
*
|
|
79
|
+
* So the body is read directly and, when it proves too large, replayed: the bytes already pulled are
|
|
80
|
+
* re-emitted ahead of the rest of the same reader. No tee, nothing buffered past the cap, and the
|
|
81
|
+
* response the client receives is byte-for-byte the one the handler produced.
|
|
82
|
+
*/
|
|
83
|
+
async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
|
|
84
|
+
const body = res.body
|
|
85
|
+
if (body === null) return { response: res }
|
|
31
86
|
const reader = body.getReader()
|
|
32
|
-
const
|
|
87
|
+
const chunks: Uint8Array[] = []
|
|
33
88
|
let total = 0
|
|
34
|
-
let text = ""
|
|
35
89
|
try {
|
|
36
90
|
while (true) {
|
|
37
91
|
const { done, value } = await reader.read()
|
|
38
|
-
if (done)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
92
|
+
if (done) {
|
|
93
|
+
const decoder = new TextDecoder()
|
|
94
|
+
let text = ""
|
|
95
|
+
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
|
|
96
|
+
return { text: text + decoder.decode() }
|
|
43
97
|
}
|
|
44
|
-
|
|
98
|
+
chunks.push(value)
|
|
99
|
+
total += value.byteLength
|
|
100
|
+
if (total > maxBytes) return { response: replayFrom(res, chunks, reader) }
|
|
45
101
|
}
|
|
46
102
|
} catch {
|
|
47
|
-
return
|
|
103
|
+
return { response: replayFrom(res, chunks, reader) }
|
|
48
104
|
}
|
|
49
105
|
}
|
|
50
106
|
|
|
@@ -76,13 +132,19 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
|
|
|
76
132
|
const declared = parseLength(res.headers.get("content-length"))
|
|
77
133
|
if (declared !== undefined && declared > maxBytes) return res
|
|
78
134
|
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
135
|
+
const peeked = await peekText(res, maxBytes)
|
|
136
|
+
if ("response" in peeked) return peeked.response
|
|
81
137
|
let parsed: unknown
|
|
82
138
|
try {
|
|
83
|
-
parsed = JSON.parse(text)
|
|
139
|
+
parsed = JSON.parse(peeked.text)
|
|
84
140
|
} catch {
|
|
85
|
-
|
|
141
|
+
// The body was read to decide that, so `res` can no longer be returned as-is. Rebuild it from
|
|
142
|
+
// the text: re-encoding UTF-8 reproduces the original bytes, so `content-length` still holds.
|
|
143
|
+
return new Response(peeked.text, {
|
|
144
|
+
status: res.status,
|
|
145
|
+
statusText: res.statusText,
|
|
146
|
+
headers: res.headers,
|
|
147
|
+
})
|
|
86
148
|
}
|
|
87
149
|
|
|
88
150
|
const headers = new Headers(res.headers)
|
package/src/rate-limit.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface RateLimitResult {
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Counter backend. Production deploys MUST use a shared store (Redis, etc.) so the
|
|
13
|
-
* limit holds across instances
|
|
13
|
+
* limit holds across instances - that's a user dependency, not ours, hence the
|
|
14
14
|
* interface. {@link MemoryStore} is for dev / single-instance only.
|
|
15
15
|
*/
|
|
16
16
|
export interface RateLimitStore {
|
|
@@ -18,7 +18,7 @@ export interface RateLimitStore {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export interface MemoryStoreOptions {
|
|
21
|
-
/** Allow the in-memory store in production. Off by default
|
|
21
|
+
/** Allow the in-memory store in production. Off by default - a per-instance limiter is unsafe across instances. */
|
|
22
22
|
readonly allowInProduction?: boolean
|
|
23
23
|
/** Hard cap on tracked client keys; expired keys are evicted first, then oldest active keys. Default `100_000`.
|
|
24
24
|
* Bounds memory against an unbounded key space (bot scans, per-IP buckets). */
|
|
@@ -29,7 +29,7 @@ export interface MemoryStoreOptions {
|
|
|
29
29
|
|
|
30
30
|
/** Max entries scanned (oldest-first) per eviction looking for an expired victim before falling back
|
|
31
31
|
* to evicting the oldest-inserted. Bounds eviction to O(1) per insertion instead of a full O(n) sweep
|
|
32
|
-
* under a distinct-key flood
|
|
32
|
+
* under a distinct-key flood - the abuse the key cap defends against. */
|
|
33
33
|
const MAX_EVICTION_SCAN = 64
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -38,8 +38,8 @@ const MAX_EVICTION_SCAN = 64
|
|
|
38
38
|
* Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
|
|
39
39
|
* `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
|
|
40
40
|
* then oldest active keys).
|
|
41
|
-
* Without this, expired entries for keys never seen again
|
|
42
|
-
*
|
|
41
|
+
* Without this, expired entries for keys never seen again - and the unbounded key space of a bot scan
|
|
42
|
+
* - would accumulate in the map forever and OOM a single-instance deploy.
|
|
43
43
|
*/
|
|
44
44
|
export class MemoryStore implements RateLimitStore {
|
|
45
45
|
private readonly windows = new Map<string, { count: number; resetAt: number }>()
|
|
@@ -48,7 +48,7 @@ export class MemoryStore implements RateLimitStore {
|
|
|
48
48
|
private lastSweep = 0
|
|
49
49
|
private evictionScans = 0
|
|
50
50
|
|
|
51
|
-
/** Cumulative entries inspected by the eviction scan since construction
|
|
51
|
+
/** Cumulative entries inspected by the eviction scan since construction - an eviction-pressure
|
|
52
52
|
* gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
|
|
53
53
|
* regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
|
|
54
54
|
get evictionScanCount(): number {
|
|
@@ -98,7 +98,7 @@ export class MemoryStore implements RateLimitStore {
|
|
|
98
98
|
private enforceMaxKeys(now: number): void {
|
|
99
99
|
if (this.windows.size <= this.maxKeys) return
|
|
100
100
|
// Prefer evicting an expired window over an active user's, but bound the work. A full sweep on
|
|
101
|
-
// every insertion is O(n)/request under a distinct-key flood
|
|
101
|
+
// every insertion is O(n)/request under a distinct-key flood - the exact abuse the cap defends
|
|
102
102
|
// against. Scan only a small fixed budget (oldest-first, where expired entries cluster) for a
|
|
103
103
|
// victim; if none is found, evict the oldest-inserted (O(1)). Expired entries beyond the budget
|
|
104
104
|
// are still reclaimed by the amortized sweep in `hit`.
|
|
@@ -132,13 +132,13 @@ export interface RateLimitOptions {
|
|
|
132
132
|
* Default `0`.
|
|
133
133
|
*
|
|
134
134
|
* The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
|
|
135
|
-
* observed
|
|
135
|
+
* observed - the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
|
|
136
136
|
* second-from-right; …). Your proxies append on the right, so a client can only inject fake hops on
|
|
137
137
|
* the *left*, which this skips → not spoofable when `trustedProxies` matches your topology.
|
|
138
138
|
*
|
|
139
139
|
* ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
|
|
140
140
|
* Reading the
|
|
141
|
-
* *first* XFF hop
|
|
141
|
+
* *first* XFF hop - the old behavior - let any client mint a fresh bucket per request and defeat the
|
|
142
142
|
* limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
|
|
143
143
|
* trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
|
|
144
144
|
*/
|
|
@@ -151,7 +151,7 @@ export interface RateLimitOptions {
|
|
|
151
151
|
*/
|
|
152
152
|
readonly allowGlobalKey?: boolean
|
|
153
153
|
/**
|
|
154
|
-
* Bucket key for a request. Overrides the default XFF-based key entirely
|
|
154
|
+
* Bucket key for a request. Overrides the default XFF-based key entirely - set this for accurate
|
|
155
155
|
* per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
|
|
156
156
|
* can't see the socket IP (that needs the server instance).
|
|
157
157
|
*/
|
package/src/request-id.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface RequestIdOptions {
|
|
|
10
10
|
/**
|
|
11
11
|
* A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
|
|
12
12
|
* `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
|
|
13
|
-
* threaded by `derive`), and echoes it on the response header. Idempotent
|
|
13
|
+
* threaded by `derive`), and echoes it on the response header. Idempotent - applying it twice is a
|
|
14
14
|
* no-op.
|
|
15
15
|
*
|
|
16
16
|
* ```ts
|
package/src/security-headers.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Middleware } from "@nifrajs/core/server"
|
|
|
3
3
|
import { withHeaders } from "./_utils.ts"
|
|
4
4
|
|
|
5
5
|
export interface SecurityHeadersOptions {
|
|
6
|
-
/** `Strict-Transport-Security`. Off by default
|
|
6
|
+
/** `Strict-Transport-Security`. Off by default - opt in once you're sure you're HTTPS-only. */
|
|
7
7
|
readonly hsts?: {
|
|
8
8
|
readonly maxAge: number
|
|
9
9
|
readonly includeSubDomains?: boolean
|
|
@@ -40,7 +40,7 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
|
|
|
40
40
|
{
|
|
41
41
|
name: "security-headers",
|
|
42
42
|
// Mutate in place on the common (mutable-headers) response; clone only for an immutable one
|
|
43
|
-
// (see withHeaders
|
|
43
|
+
// (see withHeaders - the old always-clone was ~3% of a realistic request).
|
|
44
44
|
onResponse: (res) =>
|
|
45
45
|
withHeaders(res, (headers) => {
|
|
46
46
|
headers.set("X-Content-Type-Options", "nosniff")
|
package/src/token-auth.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { definePlugin, type NifraPlugin } from "@nifrajs/core/server"
|
|
|
4
4
|
type MaybePromise<T> = T | Promise<T>
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)`
|
|
7
|
+
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` - it rejects unauthorized
|
|
8
8
|
* requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
|
|
9
9
|
* inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
|
|
10
10
|
* {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
|
|
@@ -51,11 +51,11 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
|
|
|
51
51
|
const plugin = definePlugin(config.name, (app) =>
|
|
52
52
|
app.beforeHandle(async (c: { readonly req: Request }) => {
|
|
53
53
|
const token = config.extract(c.req)
|
|
54
|
-
// Empty string is treated as "no credential"
|
|
54
|
+
// Empty string is treated as "no credential" - never passed to verify.
|
|
55
55
|
const principal = token !== undefined && token !== "" ? await config.verify(token) : null
|
|
56
56
|
if (principal !== null && principal !== undefined) {
|
|
57
57
|
store.set(c.req, principal)
|
|
58
|
-
return undefined // authorized
|
|
58
|
+
return undefined // authorized - continue to the handler
|
|
59
59
|
}
|
|
60
60
|
return config.optional ? undefined : reject() // 401 short-circuits (skips the handler)
|
|
61
61
|
}),
|
|
@@ -79,7 +79,7 @@ function createTokenAuth<P>(config: TokenAuthConfig<P>): AuthPlugin<P> {
|
|
|
79
79
|
|
|
80
80
|
export interface BearerOptions<P> {
|
|
81
81
|
/** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
|
|
82
|
-
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare
|
|
82
|
+
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare - never `===`. */
|
|
83
83
|
readonly verify: (token: string) => MaybePromise<P | null | undefined>
|
|
84
84
|
/** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
|
|
85
85
|
readonly optional?: boolean
|
|
@@ -90,7 +90,7 @@ export interface BearerOptions<P> {
|
|
|
90
90
|
/**
|
|
91
91
|
* `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
|
|
92
92
|
* `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
|
|
93
|
-
* verified principal is read via the returned instance
|
|
93
|
+
* verified principal is read via the returned instance - see {@link AuthPlugin}.
|
|
94
94
|
*/
|
|
95
95
|
export function bearer<P>(options: BearerOptions<P>): AuthPlugin<P> {
|
|
96
96
|
const realm = options.realm ?? "api"
|
|
@@ -140,7 +140,7 @@ function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
140
140
|
/**
|
|
141
141
|
* Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
|
|
142
142
|
* 32 bytes); per request the candidate is hashed and compared against every digest **without early
|
|
143
|
-
* exit**, so timing depends only on the (public) key count
|
|
143
|
+
* exit**, so timing depends only on the (public) key count - never on the secret value or which key
|
|
144
144
|
* matched. Portable across all runtimes via WebCrypto.
|
|
145
145
|
*/
|
|
146
146
|
function staticKeyVerify(keys: readonly string[]): (candidate: string) => Promise<string | null> {
|
|
@@ -158,11 +158,11 @@ function staticKeyVerify(keys: readonly string[]): (candidate: string) => Promis
|
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
160
|
* API-key authentication via a header (default `x-api-key`). Two forms:
|
|
161
|
-
* - `apiKey({ keys })`
|
|
162
|
-
* - `apiKey({ verify })`
|
|
161
|
+
* - `apiKey({ keys })` - a fixed key set, compared in **constant time**; the matched key is the principal.
|
|
162
|
+
* - `apiKey({ verify })` - custom (e.g. DB-backed) verification returning a typed principal.
|
|
163
163
|
*
|
|
164
164
|
* Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
|
|
165
|
-
* instance
|
|
165
|
+
* instance - see {@link AuthPlugin}.
|
|
166
166
|
*/
|
|
167
167
|
export function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>
|
|
168
168
|
export function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>
|