@nifrajs/middleware 2.2.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/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/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/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/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>
|