@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/dist/openapi.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
/**
|
|
@@ -55,10 +55,10 @@ function scalarPage(specUrl, title, cdn) {
|
|
|
55
55
|
</html>`;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
|
-
* Serve an OpenAPI 3.1 document (a structural subset
|
|
58
|
+
* Serve an OpenAPI 3.1 document (a structural subset - see {@link buildOpenApiDocument}) at
|
|
59
59
|
* `options.path` (default `/openapi.json`), generated from the app's registered routes. Generation is
|
|
60
60
|
* **lazy + memoized**: it reads `app.routes()` on the first request, by which point every route is
|
|
61
|
-
* registered
|
|
61
|
+
* registered - so the plugin's own position in the chain doesn't matter.
|
|
62
62
|
*
|
|
63
63
|
* Pass `ui: true` (or `ui: { path, title, cdn }`) to also serve a Scalar API-reference page (default
|
|
64
64
|
* `/reference`) that renders the spec. The page loads Scalar from a CDN; pin/self-host via `ui.cdn`,
|
|
@@ -76,7 +76,7 @@ export function openapi(options = {}) {
|
|
|
76
76
|
let uiCached;
|
|
77
77
|
return definePlugin("openapi", (app) => {
|
|
78
78
|
// Capture `app` (the same mutable builder every later `.get()`/`.post()` registers on) and read its
|
|
79
|
-
// routes lazily, at request time
|
|
79
|
+
// routes lazily, at request time - so all routes exist regardless of where the plugin sits.
|
|
80
80
|
app.register("GET", docPath, undefined, () => {
|
|
81
81
|
cached ??= JSON.stringify(buildOpenApiDocument(app.routes(), {
|
|
82
82
|
...options,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pretty-json.d.ts","sourceRoot":"","sources":["../src/pretty-json.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,iBAAiB;IAChC,4CAA4C;IAC5C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,6FAA6F;IAC7F,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,8CAA8C;IAC9C,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,gGAAgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,CAAA;CAC7D;
|
|
1
|
+
{"version":3,"file":"pretty-json.d.ts","sourceRoot":"","sources":["../src/pretty-json.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,iBAAiB;IAChC,4CAA4C;IAC5C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,6FAA6F;IAC7F,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;IAC1B,8CAA8C;IAC9C,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,gGAAgG;IAChG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAC/B,2DAA2D;IAC3D,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,CAAA;CAC7D;AA6FD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,kIAgDzD"}
|
package/dist/pretty-json.js
CHANGED
|
@@ -10,29 +10,75 @@ function parseLength(value) {
|
|
|
10
10
|
return undefined;
|
|
11
11
|
return Number(value);
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** Re-emit the bytes already pulled, then the untouched remainder of the same reader. */
|
|
14
|
+
function replayFrom(res, chunks, reader) {
|
|
15
|
+
const stream = new ReadableStream({
|
|
16
|
+
start(controller) {
|
|
17
|
+
for (const chunk of chunks)
|
|
18
|
+
controller.enqueue(chunk);
|
|
19
|
+
},
|
|
20
|
+
async pull(controller) {
|
|
21
|
+
try {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
// No value and not done cannot happen for a byte stream, but it means the same thing here:
|
|
24
|
+
// there is nothing further to emit.
|
|
25
|
+
if (done || value === undefined)
|
|
26
|
+
controller.close();
|
|
27
|
+
else
|
|
28
|
+
controller.enqueue(value);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
// The upstream body failed; surface that to the client rather than truncating silently.
|
|
32
|
+
controller.error(error);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
cancel: (reason) => reader.cancel(reason),
|
|
36
|
+
});
|
|
37
|
+
return new Response(stream, {
|
|
38
|
+
status: res.status,
|
|
39
|
+
statusText: res.statusText,
|
|
40
|
+
headers: res.headers,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read up to `maxBytes` of a response, without disturbing it when the answer is "pass through".
|
|
45
|
+
*
|
|
46
|
+
* `Response.clone()` is the obvious way to peek and is a trap here. Cancelling the clone's reader once
|
|
47
|
+
* the cap is exceeded also stalls the ORIGINAL body in Bun, so an oversized streamed response hung the
|
|
48
|
+
* client instead of passing through - a hang, from a middleware whose entire job is cosmetic. (A clone
|
|
49
|
+
* read to completion is fine; only the cancel breaks it, which is why every buffered case passed and
|
|
50
|
+
* only a streamed one failed.) Abandoning the clone rather than cancelling it trades the hang for
|
|
51
|
+
* buffering the whole oversized body, which is the exact cost the cap exists to avoid.
|
|
52
|
+
*
|
|
53
|
+
* So the body is read directly and, when it proves too large, replayed: the bytes already pulled are
|
|
54
|
+
* re-emitted ahead of the rest of the same reader. No tee, nothing buffered past the cap, and the
|
|
55
|
+
* response the client receives is byte-for-byte the one the handler produced.
|
|
56
|
+
*/
|
|
57
|
+
async function peekText(res, maxBytes) {
|
|
58
|
+
const body = res.body;
|
|
15
59
|
if (body === null)
|
|
16
|
-
return
|
|
60
|
+
return { response: res };
|
|
17
61
|
const reader = body.getReader();
|
|
18
|
-
const
|
|
62
|
+
const chunks = [];
|
|
19
63
|
let total = 0;
|
|
20
|
-
let text = "";
|
|
21
64
|
try {
|
|
22
65
|
while (true) {
|
|
23
66
|
const { done, value } = await reader.read();
|
|
24
|
-
if (done)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
67
|
+
if (done) {
|
|
68
|
+
const decoder = new TextDecoder();
|
|
69
|
+
let text = "";
|
|
70
|
+
for (const chunk of chunks)
|
|
71
|
+
text += decoder.decode(chunk, { stream: true });
|
|
72
|
+
return { text: text + decoder.decode() };
|
|
30
73
|
}
|
|
31
|
-
|
|
74
|
+
chunks.push(value);
|
|
75
|
+
total += value.byteLength;
|
|
76
|
+
if (total > maxBytes)
|
|
77
|
+
return { response: replayFrom(res, chunks, reader) };
|
|
32
78
|
}
|
|
33
79
|
}
|
|
34
80
|
catch {
|
|
35
|
-
return
|
|
81
|
+
return { response: replayFrom(res, chunks, reader) };
|
|
36
82
|
}
|
|
37
83
|
}
|
|
38
84
|
/**
|
|
@@ -66,15 +112,21 @@ export function prettyJson(options = {}) {
|
|
|
66
112
|
const declared = parseLength(res.headers.get("content-length"));
|
|
67
113
|
if (declared !== undefined && declared > maxBytes)
|
|
68
114
|
return res;
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
return
|
|
115
|
+
const peeked = await peekText(res, maxBytes);
|
|
116
|
+
if ("response" in peeked)
|
|
117
|
+
return peeked.response;
|
|
72
118
|
let parsed;
|
|
73
119
|
try {
|
|
74
|
-
parsed = JSON.parse(text);
|
|
120
|
+
parsed = JSON.parse(peeked.text);
|
|
75
121
|
}
|
|
76
122
|
catch {
|
|
77
|
-
|
|
123
|
+
// The body was read to decide that, so `res` can no longer be returned as-is. Rebuild it from
|
|
124
|
+
// the text: re-encoding UTF-8 reproduces the original bytes, so `content-length` still holds.
|
|
125
|
+
return new Response(peeked.text, {
|
|
126
|
+
status: res.status,
|
|
127
|
+
statusText: res.statusText,
|
|
128
|
+
headers: res.headers,
|
|
129
|
+
});
|
|
78
130
|
}
|
|
79
131
|
const headers = new Headers(res.headers);
|
|
80
132
|
headers.delete("content-length");
|
package/dist/pretty-json.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pretty-json.js","sourceRoot":"","sources":["../src/pretty-json.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAA;AAenD,MAAM,SAAS,GAAG,sDAAsD,CAAA;AAExE,SAAS,MAAM,CAAC,WAAmB;IACjC,OAAO,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;AACpC,CAAC;AAED,SAAS,WAAW,CAAC,KAAoB;IACvC,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAA;IACpC,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,SAAS,CAAA;IACrD,OAAO,MAAM,CAAC,KAAK,CAAC,CAAA;AACtB,CAAC;
|
|
1
|
+
{"version":3,"file":"pretty-json.js","sourceRoot":"","sources":["../src/pretty-json.ts"],"names":[],"mappings":"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"}
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -7,14 +7,14 @@ export interface RateLimitResult {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Counter backend. Production deploys MUST use a shared store (Redis, etc.) so the
|
|
10
|
-
* limit holds across instances
|
|
10
|
+
* limit holds across instances - that's a user dependency, not ours, hence the
|
|
11
11
|
* interface. {@link MemoryStore} is for dev / single-instance only.
|
|
12
12
|
*/
|
|
13
13
|
export interface RateLimitStore {
|
|
14
14
|
hit(key: string, windowMs: number): Promise<RateLimitResult>;
|
|
15
15
|
}
|
|
16
16
|
export interface MemoryStoreOptions {
|
|
17
|
-
/** Allow the in-memory store in production. Off by default
|
|
17
|
+
/** Allow the in-memory store in production. Off by default - a per-instance limiter is unsafe across instances. */
|
|
18
18
|
readonly allowInProduction?: boolean;
|
|
19
19
|
/** Hard cap on tracked client keys; expired keys are evicted first, then oldest active keys. Default `100_000`.
|
|
20
20
|
* Bounds memory against an unbounded key space (bot scans, per-IP buckets). */
|
|
@@ -28,8 +28,8 @@ export interface MemoryStoreOptions {
|
|
|
28
28
|
* Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
|
|
29
29
|
* `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
|
|
30
30
|
* then oldest active keys).
|
|
31
|
-
* Without this, expired entries for keys never seen again
|
|
32
|
-
*
|
|
31
|
+
* Without this, expired entries for keys never seen again - and the unbounded key space of a bot scan
|
|
32
|
+
* - would accumulate in the map forever and OOM a single-instance deploy.
|
|
33
33
|
*/
|
|
34
34
|
export declare class MemoryStore implements RateLimitStore {
|
|
35
35
|
private readonly windows;
|
|
@@ -37,7 +37,7 @@ export declare class MemoryStore implements RateLimitStore {
|
|
|
37
37
|
private readonly sweepIntervalMs;
|
|
38
38
|
private lastSweep;
|
|
39
39
|
private evictionScans;
|
|
40
|
-
/** Cumulative entries inspected by the eviction scan since construction
|
|
40
|
+
/** Cumulative entries inspected by the eviction scan since construction - an eviction-pressure
|
|
41
41
|
* gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
|
|
42
42
|
* regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
|
|
43
43
|
get evictionScanCount(): number;
|
|
@@ -58,13 +58,13 @@ export interface RateLimitOptions {
|
|
|
58
58
|
* Default `0`.
|
|
59
59
|
*
|
|
60
60
|
* The default key reads the client IP from `X-Forwarded-For` as the address your **edge** proxy
|
|
61
|
-
* observed
|
|
61
|
+
* observed - the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
|
|
62
62
|
* second-from-right; …). Your proxies append on the right, so a client can only inject fake hops on
|
|
63
63
|
* the *left*, which this skips → not spoofable when `trustedProxies` matches your topology.
|
|
64
64
|
*
|
|
65
65
|
* ⚠️ With the default `0`, `X-Forwarded-For` is treated as fully client-controlled and **ignored**.
|
|
66
66
|
* Reading the
|
|
67
|
-
* *first* XFF hop
|
|
67
|
+
* *first* XFF hop - the old behavior - let any client mint a fresh bucket per request and defeat the
|
|
68
68
|
* limiter. Set `trustedProxies` (only safe behind a proxy you control that appends XFF), configure a
|
|
69
69
|
* trusted single-IP {@link header}, or supply a custom {@link key} (e.g. an authenticated user id).
|
|
70
70
|
*/
|
|
@@ -77,7 +77,7 @@ export interface RateLimitOptions {
|
|
|
77
77
|
*/
|
|
78
78
|
readonly allowGlobalKey?: boolean;
|
|
79
79
|
/**
|
|
80
|
-
* Bucket key for a request. Overrides the default XFF-based key entirely
|
|
80
|
+
* Bucket key for a request. Overrides the default XFF-based key entirely - set this for accurate
|
|
81
81
|
* per-client limiting (e.g. an authenticated user id, or a header your proxy sets). A `Middleware`
|
|
82
82
|
* can't see the socket IP (that needs the server instance).
|
|
83
83
|
*/
|
package/dist/rate-limit.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance";
|
|
2
2
|
/** Max entries scanned (oldest-first) per eviction looking for an expired victim before falling back
|
|
3
3
|
* to evicting the oldest-inserted. Bounds eviction to O(1) per insertion instead of a full O(n) sweep
|
|
4
|
-
* under a distinct-key flood
|
|
4
|
+
* under a distinct-key flood - the abuse the key cap defends against. */
|
|
5
5
|
const MAX_EVICTION_SCAN = 64;
|
|
6
6
|
/**
|
|
7
7
|
* In-process fixed-window store. Refuses to run in production unless explicitly allowed.
|
|
@@ -9,8 +9,8 @@ const MAX_EVICTION_SCAN = 64;
|
|
|
9
9
|
* Bounded against unbounded growth: expired windows are swept lazily (amortized, at most once per
|
|
10
10
|
* `sweepIntervalMs`) and the key set is hard-capped at `maxKeys` (expired keys are evicted first,
|
|
11
11
|
* then oldest active keys).
|
|
12
|
-
* Without this, expired entries for keys never seen again
|
|
13
|
-
*
|
|
12
|
+
* Without this, expired entries for keys never seen again - and the unbounded key space of a bot scan
|
|
13
|
+
* - would accumulate in the map forever and OOM a single-instance deploy.
|
|
14
14
|
*/
|
|
15
15
|
export class MemoryStore {
|
|
16
16
|
windows = new Map();
|
|
@@ -18,7 +18,7 @@ export class MemoryStore {
|
|
|
18
18
|
sweepIntervalMs;
|
|
19
19
|
lastSweep = 0;
|
|
20
20
|
evictionScans = 0;
|
|
21
|
-
/** Cumulative entries inspected by the eviction scan since construction
|
|
21
|
+
/** Cumulative entries inspected by the eviction scan since construction - an eviction-pressure
|
|
22
22
|
* gauge. The bounded scan keeps this ~O(1) per over-cap insert (≤ {@link MAX_EVICTION_SCAN}); a
|
|
23
23
|
* regressed full O(n) sweep would make it ~maxKeys per insert (what the regression test asserts). */
|
|
24
24
|
get evictionScanCount() {
|
|
@@ -65,7 +65,7 @@ export class MemoryStore {
|
|
|
65
65
|
if (this.windows.size <= this.maxKeys)
|
|
66
66
|
return;
|
|
67
67
|
// Prefer evicting an expired window over an active user's, but bound the work. A full sweep on
|
|
68
|
-
// every insertion is O(n)/request under a distinct-key flood
|
|
68
|
+
// every insertion is O(n)/request under a distinct-key flood - the exact abuse the cap defends
|
|
69
69
|
// against. Scan only a small fixed budget (oldest-first, where expired entries cluster) for a
|
|
70
70
|
// victim; if none is found, evict the oldest-inserted (O(1)). Expired entries beyond the budget
|
|
71
71
|
// are still reclaimed by the amortized sweep in `hit`.
|
package/dist/request-id.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface RequestIdOptions {
|
|
|
7
7
|
/**
|
|
8
8
|
* A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
|
|
9
9
|
* `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
|
|
10
|
-
* threaded by `derive`), and echoes it on the response header. Idempotent
|
|
10
|
+
* threaded by `derive`), and echoes it on the response header. Idempotent - applying it twice is a
|
|
11
11
|
* no-op.
|
|
12
12
|
*
|
|
13
13
|
* ```ts
|
package/dist/request-id.js
CHANGED
|
@@ -2,7 +2,7 @@ import { definePlugin } from "@nifrajs/core/server";
|
|
|
2
2
|
/**
|
|
3
3
|
* A {@link definePlugin} plugin that gives every request a stable id: it reuses an inbound
|
|
4
4
|
* `x-request-id` (or generates one), exposes it on the handler context as **`c.requestId`** (typed,
|
|
5
|
-
* threaded by `derive`), and echoes it on the response header. Idempotent
|
|
5
|
+
* threaded by `derive`), and echoes it on the response header. Idempotent - applying it twice is a
|
|
6
6
|
* no-op.
|
|
7
7
|
*
|
|
8
8
|
* ```ts
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core/server";
|
|
2
2
|
export interface SecurityHeadersOptions {
|
|
3
|
-
/** `Strict-Transport-Security`. Off by default
|
|
3
|
+
/** `Strict-Transport-Security`. Off by default - opt in once you're sure you're HTTPS-only. */
|
|
4
4
|
readonly hsts?: {
|
|
5
5
|
readonly maxAge: number;
|
|
6
6
|
readonly includeSubDomains?: boolean;
|
package/dist/security-headers.js
CHANGED
|
@@ -22,7 +22,7 @@ export function securityHeaders(options = {}) {
|
|
|
22
22
|
return withRouteAssurance({
|
|
23
23
|
name: "security-headers",
|
|
24
24
|
// Mutate in place on the common (mutable-headers) response; clone only for an immutable one
|
|
25
|
-
// (see withHeaders
|
|
25
|
+
// (see withHeaders - the old always-clone was ~3% of a realistic request).
|
|
26
26
|
onResponse: (res) => withHeaders(res, (headers) => {
|
|
27
27
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
28
28
|
headers.set("X-Frame-Options", frameOptions);
|
package/dist/token-auth.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type NifraPlugin } from "@nifrajs/core/server";
|
|
2
2
|
type MaybePromise<T> = T | Promise<T>;
|
|
3
3
|
/**
|
|
4
|
-
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)`
|
|
4
|
+
* A token-auth plugin (`bearer` / `apiKey`). Apply it with `app.use(auth)` - it rejects unauthorized
|
|
5
5
|
* requests to **routes defined after it** with `401` (unless `optional`). Read the verified principal
|
|
6
6
|
* inside a handler/loader via {@link AuthPlugin.principal} (nullable) or
|
|
7
7
|
* {@link AuthPlugin.requirePrincipal} (throws `401` when absent). The principal is verified once per
|
|
@@ -24,7 +24,7 @@ export type AuthPlugin<P> = NifraPlugin & {
|
|
|
24
24
|
};
|
|
25
25
|
export interface BearerOptions<P> {
|
|
26
26
|
/** Verify a bearer token → a principal (truthy) or `null`/`undefined` (rejected). May be async
|
|
27
|
-
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare
|
|
27
|
+
* (DB/JWT lookup). For a constant-secret comparison, use a constant-time compare - never `===`. */
|
|
28
28
|
readonly verify: (token: string) => MaybePromise<P | null | undefined>;
|
|
29
29
|
/** When `true`, requests without a valid token pass through (`principal` is `null`) instead of `401`. */
|
|
30
30
|
readonly optional?: boolean;
|
|
@@ -34,7 +34,7 @@ export interface BearerOptions<P> {
|
|
|
34
34
|
/**
|
|
35
35
|
* `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
|
|
36
36
|
* `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
|
|
37
|
-
* verified principal is read via the returned instance
|
|
37
|
+
* verified principal is read via the returned instance - see {@link AuthPlugin}.
|
|
38
38
|
*/
|
|
39
39
|
export declare function bearer<P>(options: BearerOptions<P>): AuthPlugin<P>;
|
|
40
40
|
export interface ApiKeyVerifyOptions<P> {
|
|
@@ -53,11 +53,11 @@ export interface ApiKeyStaticOptions {
|
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* API-key authentication via a header (default `x-api-key`). Two forms:
|
|
56
|
-
* - `apiKey({ keys })`
|
|
57
|
-
* - `apiKey({ verify })`
|
|
56
|
+
* - `apiKey({ keys })` - a fixed key set, compared in **constant time**; the matched key is the principal.
|
|
57
|
+
* - `apiKey({ verify })` - custom (e.g. DB-backed) verification returning a typed principal.
|
|
58
58
|
*
|
|
59
59
|
* Rejects missing/invalid keys with `401` (unless `optional`). Read the principal via the returned
|
|
60
|
-
* instance
|
|
60
|
+
* instance - see {@link AuthPlugin}.
|
|
61
61
|
*/
|
|
62
62
|
export declare function apiKey(options: ApiKeyStaticOptions): AuthPlugin<string>;
|
|
63
63
|
export declare function apiKey<P>(options: ApiKeyVerifyOptions<P>): AuthPlugin<P>;
|
package/dist/token-auth.js
CHANGED
|
@@ -9,11 +9,11 @@ function createTokenAuth(config) {
|
|
|
9
9
|
});
|
|
10
10
|
const plugin = definePlugin(config.name, (app) => app.beforeHandle(async (c) => {
|
|
11
11
|
const token = config.extract(c.req);
|
|
12
|
-
// Empty string is treated as "no credential"
|
|
12
|
+
// Empty string is treated as "no credential" - never passed to verify.
|
|
13
13
|
const principal = token !== undefined && token !== "" ? await config.verify(token) : null;
|
|
14
14
|
if (principal !== null && principal !== undefined) {
|
|
15
15
|
store.set(c.req, principal);
|
|
16
|
-
return undefined; // authorized
|
|
16
|
+
return undefined; // authorized - continue to the handler
|
|
17
17
|
}
|
|
18
18
|
return config.optional ? undefined : reject(); // 401 short-circuits (skips the handler)
|
|
19
19
|
}));
|
|
@@ -37,7 +37,7 @@ function createTokenAuth(config) {
|
|
|
37
37
|
/**
|
|
38
38
|
* `Authorization: Bearer <token>` authentication. Parses the header, runs `verify`, and rejects with
|
|
39
39
|
* `401` (+ `WWW-Authenticate: Bearer`) when the token is missing/invalid (unless `optional`). The
|
|
40
|
-
* verified principal is read via the returned instance
|
|
40
|
+
* verified principal is read via the returned instance - see {@link AuthPlugin}.
|
|
41
41
|
*/
|
|
42
42
|
export function bearer(options) {
|
|
43
43
|
const realm = options.realm ?? "api";
|
|
@@ -69,7 +69,7 @@ function timingSafeEqualBytes(a, b) {
|
|
|
69
69
|
/**
|
|
70
70
|
* Build a constant-time verifier for a fixed key set. Each valid key is hashed once (SHA-256, fixed
|
|
71
71
|
* 32 bytes); per request the candidate is hashed and compared against every digest **without early
|
|
72
|
-
* exit**, so timing depends only on the (public) key count
|
|
72
|
+
* exit**, so timing depends only on the (public) key count - never on the secret value or which key
|
|
73
73
|
* matched. Portable across all runtimes via WebCrypto.
|
|
74
74
|
*/
|
|
75
75
|
function staticKeyVerify(keys) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/middleware",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Composable middleware for nifra
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Composable middleware for nifra - auth, CSRF, JWT/JWKS, IP restriction, CORS, body limits, response cache, timing, and operational helpers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@nifrajs/schema": "2.
|
|
49
|
+
"@nifrajs/schema": "2.3.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@nifrajs/core": "^2.
|
|
52
|
+
"@nifrajs/core": "^2.3.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@nifrajs/core": "workspace:*",
|
package/src/_utils.ts
CHANGED
|
@@ -144,12 +144,12 @@ export async function verifyHmacSha256(
|
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
146
|
* Apply header mutations to a response, in place when possible. Framework-built responses
|
|
147
|
-
* (`new Response`, `Response.json`) have mutable headers across every runtime
|
|
147
|
+
* (`new Response`, `Response.json`) have mutable headers across every runtime - so the common path
|
|
148
148
|
* mutates `res` directly and returns it, allocating nothing. Only an *immutable*-headers response
|
|
149
|
-
* (`Response.redirect()`/`Response.error()`, or a proxied `fetch()` response on Node/Deno/workerd
|
|
149
|
+
* (`Response.redirect()`/`Response.error()`, or a proxied `fetch()` response on Node/Deno/workerd -
|
|
150
150
|
* never on Bun) makes `.set`/`.append` throw; that path clones into a fresh `Headers` + `Response`,
|
|
151
151
|
* exactly the old always-clone behavior. `apply` runs once either way (immutability is all-or-nothing
|
|
152
|
-
*
|
|
152
|
+
* - the first mutation throws before any partial change), so a mutation chain is safe to pass.
|
|
153
153
|
*/
|
|
154
154
|
export function withHeaders(res: Response, apply: (headers: Headers) => void): Response {
|
|
155
155
|
try {
|
package/src/admission.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AdmissionController, AdmissionDecision } from "@nifrajs/core/serve
|
|
|
3
3
|
/**
|
|
4
4
|
* Adaptive capacity admission. Rate limiting bounds request *frequency* and `@nifrajs/core/budget` bounds
|
|
5
5
|
* request *duration*; neither stops a healthy instance from accepting more *concurrent* work than it
|
|
6
|
-
* can finish. This gate admits on live capacity evidence
|
|
6
|
+
* can finish. This gate admits on live capacity evidence - in-flight count + event-loop lag - briefly
|
|
7
7
|
* queues at the edge, and sheds the rest with `429` + `Retry-After`, so p99 stays bounded under load
|
|
8
8
|
* instead of collapsing.
|
|
9
9
|
*
|
|
@@ -11,7 +11,7 @@ import type { AdmissionController, AdmissionDecision } from "@nifrajs/core/serve
|
|
|
11
11
|
* hook layers tenant priority and reserved capacity on top without leaking
|
|
12
12
|
* those concerns into the OSS core.
|
|
13
13
|
*
|
|
14
|
-
* Wire it as the server's `admission` option (NOT an `onRequest` hook
|
|
14
|
+
* Wire it as the server's `admission` option (NOT an `onRequest` hook - a hook disables the native
|
|
15
15
|
* route table). Off by default: when unset, the request path is untouched.
|
|
16
16
|
*/
|
|
17
17
|
|
|
@@ -97,7 +97,7 @@ export interface LoopDelayHistogram {
|
|
|
97
97
|
*/
|
|
98
98
|
export type LoopDelayMonitor = (resolutionMs: number) => LoopDelayHistogram | undefined
|
|
99
99
|
|
|
100
|
-
/** Shared no-op sampler
|
|
100
|
+
/** Shared no-op sampler - the fallback when an explicitly supplied monitor has no histogram. */
|
|
101
101
|
const NO_LAG: () => number = () => 0
|
|
102
102
|
|
|
103
103
|
/**
|
|
@@ -274,7 +274,7 @@ export function createAdmissionController(options: AdmissionOptions): AdmissionC
|
|
|
274
274
|
}
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
// Loop-lag shed: the box is behind on the event loop
|
|
277
|
+
// Loop-lag shed: the box is behind on the event loop - reject regardless of slot count.
|
|
278
278
|
if (lag > maxLagMs) return shedResponse("loop-lag")
|
|
279
279
|
|
|
280
280
|
// FAST PATH (O(1)): a slot is free and lag is fine. No timer, no queue, no extra await.
|
package/src/cache-control.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface CacheControlOptions {
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Set a `Cache-Control` header on matching responses. `value` is either a fixed directive string or a
|
|
14
|
-
* function of the request (return `undefined` to leave a response untouched
|
|
14
|
+
* function of the request (return `undefined` to leave a response untouched - e.g. cache by path).
|
|
15
15
|
* Defaults to `GET`/`HEAD` + 2xx, and never clobbers a `Cache-Control` the handler set itself.
|
|
16
16
|
*
|
|
17
17
|
* ```ts
|
package/src/cache.ts
CHANGED
|
@@ -249,7 +249,7 @@ export function cache(options: CacheOptions): Middleware {
|
|
|
249
249
|
return withStatusHeader(res, cacheStatusHeader, "BYPASS")
|
|
250
250
|
}
|
|
251
251
|
// Shared-cache safety (RFC 9111 §3.5): never store a response to an authenticated request
|
|
252
|
-
// (Authorization or Cookie) unless it's explicitly public
|
|
252
|
+
// (Authorization or Cookie) unless it's explicitly public - otherwise a personalized 200 leaks
|
|
253
253
|
// across users. Opt in with `cacheAuthenticated: true` for a route that's the same for everyone.
|
|
254
254
|
if (
|
|
255
255
|
!cacheAuthenticated &&
|
package/src/compression.ts
CHANGED
|
@@ -44,7 +44,7 @@ const gzipHeaders = (source: Headers): Headers => {
|
|
|
44
44
|
*
|
|
45
45
|
* The body is **peeked** up to `threshold` bytes to enforce the size floor: a response that ends below
|
|
46
46
|
* the threshold is sent uncompressed (gzip's ~20-byte overhead would enlarge it), and only larger
|
|
47
|
-
* bodies are compressed
|
|
47
|
+
* bodies are compressed - the buffered prefix is replayed and the remainder streamed. (Runtimes rarely
|
|
48
48
|
* set `Content-Length` on a constructed `Response`, so the header alone can't gate this.)
|
|
49
49
|
*
|
|
50
50
|
* Skips: clients that don't accept gzip, already-encoded responses, bodyless responses (204/304/HEAD),
|
|
@@ -61,7 +61,7 @@ export function compression(options: CompressionOptions = {}) {
|
|
|
61
61
|
return definePlugin("compression", (app) =>
|
|
62
62
|
app.onResponse(async (res, req) => {
|
|
63
63
|
const body = res.body
|
|
64
|
-
if (body === null) return res // 204/304/HEAD
|
|
64
|
+
if (body === null) return res // 204/304/HEAD - nothing to compress
|
|
65
65
|
if (!(req.headers.get("accept-encoding") ?? "").toLowerCase().includes("gzip")) return res
|
|
66
66
|
if (res.headers.has("content-encoding")) return res // already encoded
|
|
67
67
|
if (res.status === 206 || res.headers.has("content-range")) return res // ranges pass through
|
|
@@ -77,7 +77,7 @@ export function compression(options: CompressionOptions = {}) {
|
|
|
77
77
|
while (total < threshold) {
|
|
78
78
|
const { done, value } = await reader.read()
|
|
79
79
|
if (done) {
|
|
80
|
-
// Body ended below the threshold
|
|
80
|
+
// Body ended below the threshold - too small to be worth gzipping. Emit as-is.
|
|
81
81
|
return new Response(concat(buffered, total), {
|
|
82
82
|
status: res.status,
|
|
83
83
|
statusText: res.statusText,
|
|
@@ -87,7 +87,7 @@ export function compression(options: CompressionOptions = {}) {
|
|
|
87
87
|
buffered.push(value)
|
|
88
88
|
total += value.byteLength
|
|
89
89
|
}
|
|
90
|
-
// Over the threshold: replay the buffered prefix, then stream the rest
|
|
90
|
+
// Over the threshold: replay the buffered prefix, then stream the rest - all through gzip.
|
|
91
91
|
// (No explicit generic: it lets the chunk type infer from the non-shared reader so the stream
|
|
92
92
|
// matches `CompressionStream`'s `Uint8Array` typing.)
|
|
93
93
|
const source = new ReadableStream({
|
package/src/cors.ts
CHANGED
|
@@ -43,7 +43,7 @@ function resolveAllowOrigin(
|
|
|
43
43
|
* short-circuits to `204` via `onRequest`; the origin/credentials headers are added in
|
|
44
44
|
* `onResponse`, so they also land on errors, 404s, and the preflight itself.
|
|
45
45
|
*
|
|
46
|
-
* Throws at construction if `credentials: true` is paired with `origin: "*"`
|
|
46
|
+
* Throws at construction if `credentials: true` is paired with `origin: "*"` - the
|
|
47
47
|
* browser rejects that combination, so we fail loud instead of shipping dead CORS.
|
|
48
48
|
*/
|
|
49
49
|
export function cors(options: CorsOptions = {}): Middleware {
|
|
@@ -51,7 +51,7 @@ export function cors(options: CorsOptions = {}): Middleware {
|
|
|
51
51
|
const credentials = options.credentials ?? false
|
|
52
52
|
if (credentials && origin === "*") {
|
|
53
53
|
throw new Error(
|
|
54
|
-
'cors: `credentials: true` cannot be combined with `origin: "*"`
|
|
54
|
+
'cors: `credentials: true` cannot be combined with `origin: "*"` - list explicit origin(s).',
|
|
55
55
|
)
|
|
56
56
|
}
|
|
57
57
|
const methods = (options.methods ?? DEFAULT_METHODS).join(", ")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
|
|
2
|
+
import {
|
|
3
|
+
attachCapabilityJournal,
|
|
4
|
+
type CapabilityExecutionJournal,
|
|
5
|
+
} from "@nifrajs/core/capabilities"
|
|
6
|
+
import { definePlugin, type NifraPlugin } from "@nifrajs/core/server"
|
|
7
|
+
|
|
8
|
+
export interface DurableCommandOptions {
|
|
9
|
+
/** Where effect transitions are recorded. `createDurableEffectJournal` from
|
|
10
|
+
* `@nifrajs/core/durable-execution` is the one this is built for; any implementation of the seam
|
|
11
|
+
* works, and a memory-backed one is a test double, not a durable command. */
|
|
12
|
+
readonly journal: CapabilityExecutionJournal
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Journal every capability effect on the routes below it, and declare the evidence that says so.
|
|
17
|
+
*
|
|
18
|
+
* A capability defined `idempotency: "durable"` requires `nifra.durable-command` evidence, and until
|
|
19
|
+
* this adapter existed nothing in the framework produced it. The only way to satisfy the tier was to
|
|
20
|
+
* write `assurance: ["nifra.durable-command"]` on the route - an assertion with nothing behind it,
|
|
21
|
+
* wrong in both directions: a route that genuinely journals its effects but omits the string fails
|
|
22
|
+
* `nifra check`, and a route that journals nothing but includes it passes.
|
|
23
|
+
*
|
|
24
|
+
* The evidence is a by-product here rather than a claim. Installing the adapter puts the journal on
|
|
25
|
+
* the request, so `executeCapability` records intent before an effect runs and exactly one terminal
|
|
26
|
+
* outcome after - which is what the tier is asking about. A call that passes its own `journal` still
|
|
27
|
+
* uses that one.
|
|
28
|
+
*
|
|
29
|
+
* const commands = durableCommand({ journal: createDurableEffectJournal({ store }) })
|
|
30
|
+
* const app = server().use(commands).post("/charge", { capabilities: ["billing.charge"] }, …)
|
|
31
|
+
*
|
|
32
|
+
* `beforeHandle` is order-scoped, like the auth plugins: routes registered before `.use(...)` are not
|
|
33
|
+
* covered, and the `subsequent` scope on the evidence says the same thing to `nifra check`.
|
|
34
|
+
*
|
|
35
|
+
* Note what this does NOT claim. Response replay (`schema.idempotency`) is a different guarantee and
|
|
36
|
+
* deliberately never proves this one: replaying a stored response after a crash mid-effect does not
|
|
37
|
+
* make the effect exactly-once, and the journal is what survives the crash.
|
|
38
|
+
*/
|
|
39
|
+
export function durableCommand(options: DurableCommandOptions): NifraPlugin {
|
|
40
|
+
const journal = options.journal
|
|
41
|
+
if (journal === null || typeof journal !== "object") {
|
|
42
|
+
throw new TypeError("durableCommand: journal must be a CapabilityExecutionJournal")
|
|
43
|
+
}
|
|
44
|
+
for (const method of ["intent", "executing", "committed", "failed"] as const) {
|
|
45
|
+
if (typeof journal[method] !== "function") {
|
|
46
|
+
// Fail at wiring time. A journal missing a transition would otherwise surface as a mid-effect
|
|
47
|
+
// TypeError on the first write in production, with the evidence already declared.
|
|
48
|
+
throw new TypeError(`durableCommand: journal.${method} must be a function`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const plugin = definePlugin("nifra:durable-command", (app) =>
|
|
52
|
+
app.beforeHandle((c: object) => {
|
|
53
|
+
attachCapabilityJournal(c, journal)
|
|
54
|
+
return undefined
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
return withRouteAssurance(plugin, {
|
|
58
|
+
id: NIFRA_ASSURANCE.DURABLE_COMMAND,
|
|
59
|
+
source: "durable-command",
|
|
60
|
+
scope: "subsequent",
|
|
61
|
+
})
|
|
62
|
+
}
|