@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.
Files changed (60) hide show
  1. package/README.md +22 -22
  2. package/dist/_utils.d.ts +3 -3
  3. package/dist/_utils.js +3 -3
  4. package/dist/admission.d.ts +2 -2
  5. package/dist/admission.js +2 -2
  6. package/dist/cache-control.d.ts +1 -1
  7. package/dist/cache-control.js +1 -1
  8. package/dist/cache.js +1 -1
  9. package/dist/compression.d.ts +1 -1
  10. package/dist/compression.js +4 -4
  11. package/dist/cors.d.ts +1 -1
  12. package/dist/cors.js +2 -2
  13. package/dist/durable-command.d.ts +34 -0
  14. package/dist/durable-command.d.ts.map +1 -0
  15. package/dist/durable-command.js +50 -0
  16. package/dist/durable-command.js.map +1 -0
  17. package/dist/etag.d.ts +2 -2
  18. package/dist/etag.js +3 -3
  19. package/dist/healthcheck.d.ts +2 -2
  20. package/dist/healthcheck.js +1 -1
  21. package/dist/idempotency.d.ts +4 -4
  22. package/dist/idempotency.js +3 -3
  23. package/dist/index.d.ts +3 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/logger.d.ts +2 -2
  28. package/dist/logger.js +2 -2
  29. package/dist/openapi.d.ts +4 -4
  30. package/dist/openapi.js +4 -4
  31. package/dist/pretty-json.d.ts.map +1 -1
  32. package/dist/pretty-json.js +70 -18
  33. package/dist/pretty-json.js.map +1 -1
  34. package/dist/rate-limit.d.ts +8 -8
  35. package/dist/rate-limit.js +5 -5
  36. package/dist/request-id.d.ts +1 -1
  37. package/dist/request-id.js +1 -1
  38. package/dist/security-headers.d.ts +1 -1
  39. package/dist/security-headers.js +1 -1
  40. package/dist/token-auth.d.ts +6 -6
  41. package/dist/token-auth.js +4 -4
  42. package/package.json +4 -4
  43. package/src/_utils.ts +3 -3
  44. package/src/admission.ts +4 -4
  45. package/src/cache-control.ts +1 -1
  46. package/src/cache.ts +1 -1
  47. package/src/compression.ts +4 -4
  48. package/src/cors.ts +2 -2
  49. package/src/durable-command.ts +62 -0
  50. package/src/etag.ts +4 -4
  51. package/src/healthcheck.ts +3 -3
  52. package/src/idempotency.ts +7 -7
  53. package/src/index.ts +3 -2
  54. package/src/logger.ts +2 -2
  55. package/src/openapi.ts +8 -8
  56. package/src/pretty-json.ts +78 -16
  57. package/src/rate-limit.ts +10 -10
  58. package/src/request-id.ts +1 -1
  59. package/src/security-headers.ts +2 -2
  60. 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 — `@nifrajs/schema/openapi` is pure (no TypeBox runtime), so the openapi()
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 — see {@link buildOpenApiDocument}) at
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 — so the plugin's own position in the chain doesn't matter.
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 — so all routes exist regardless of where the plugin sits.
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;AAqCD;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,iBAAsB,kIA0CzD"}
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"}
@@ -10,29 +10,75 @@ function parseLength(value) {
10
10
  return undefined;
11
11
  return Number(value);
12
12
  }
13
- async function readTextCapped(res, maxBytes) {
14
- const body = res.clone().body;
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 null;
60
+ return { response: res };
17
61
  const reader = body.getReader();
18
- const decoder = new TextDecoder();
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
- return text + decoder.decode();
26
- total += value.byteLength;
27
- if (total > maxBytes) {
28
- await reader.cancel();
29
- return null;
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
- text += decoder.decode(value, { stream: true });
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 null;
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 text = await readTextCapped(res, maxBytes);
70
- if (text === null)
71
- return res;
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
- return res;
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");
@@ -1 +1 @@
1
- {"version":3,"file":"pretty-json.js","sourceRoot":"","sources":["../src/pretty-json.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pretty-json.js","sourceRoot":"","sources":["../src/pretty-json.ts"],"names":[],"mappings":"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"}
@@ -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 — that's a user dependency, not ours, hence the
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 — a per-instance limiter is unsafe across instances. */
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 — and the unbounded key space of a bot scan
32
- * — would accumulate in the map forever and OOM a single-instance deploy.
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 — an eviction-pressure
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 — the entry `trustedProxies` from the right (1 proxy → the rightmost hop; 2 → the
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 — the old behavior — let any client mint a fresh bucket per request and defeat the
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 — set this for accurate
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
  */
@@ -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 — the abuse the key cap defends against. */
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 — and the unbounded key space of a bot scan
13
- * — would accumulate in the map forever and OOM a single-instance deploy.
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 — an eviction-pressure
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 — the exact abuse the cap defends
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`.
@@ -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 — applying it twice is a
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
@@ -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 — applying it twice is a
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 — opt in once you're sure you're HTTPS-only. */
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;
@@ -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 — the old always-clone was ~3% of a realistic request).
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);
@@ -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)` — it rejects unauthorized
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 — never `===`. */
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 — see {@link AuthPlugin}.
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 })` — 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.
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 — see {@link AuthPlugin}.
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>;
@@ -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" — never passed to verify.
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 — continue to the handler
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 — see {@link AuthPlugin}.
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 — never on the secret value or which key
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.1.0",
4
- "description": "Composable middleware for nifra — auth, CSRF, JWT/JWKS, IP restriction, CORS, body limits, response cache, timing, and operational helpers.",
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.1.0"
49
+ "@nifrajs/schema": "2.3.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@nifrajs/core": "^2.1.0"
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 — so the common path
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
- * — the first mutation throws before any partial change), so a mutation chain is safe to pass.
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 — in-flight count + event-loop lag — briefly
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 — a hook disables the native
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 — the fallback when an explicitly supplied monitor has no histogram. */
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 — reject regardless of slot count.
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.
@@ -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 — e.g. cache by path).
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 — otherwise a personalized 200 leaks
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 &&
@@ -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 — the buffered prefix is replayed and the remainder streamed. (Runtimes rarely
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 — nothing to compress
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 — too small to be worth gzipping. Emit as-is.
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 — all through gzip.
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: "*"` — the
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: "*"` — list explicit origin(s).',
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
+ }