@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.
Files changed (56) 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/rate-limit.d.ts +8 -8
  32. package/dist/rate-limit.js +5 -5
  33. package/dist/request-id.d.ts +1 -1
  34. package/dist/request-id.js +1 -1
  35. package/dist/security-headers.d.ts +1 -1
  36. package/dist/security-headers.js +1 -1
  37. package/dist/token-auth.d.ts +6 -6
  38. package/dist/token-auth.js +4 -4
  39. package/package.json +4 -4
  40. package/src/_utils.ts +3 -3
  41. package/src/admission.ts +4 -4
  42. package/src/cache-control.ts +1 -1
  43. package/src/cache.ts +1 -1
  44. package/src/compression.ts +4 -4
  45. package/src/cors.ts +2 -2
  46. package/src/durable-command.ts +62 -0
  47. package/src/etag.ts +4 -4
  48. package/src/healthcheck.ts +3 -3
  49. package/src/idempotency.ts +7 -7
  50. package/src/index.ts +3 -2
  51. package/src/logger.ts +2 -2
  52. package/src/openapi.ts +8 -8
  53. package/src/rate-limit.ts +10 -10
  54. package/src/request-id.ts +1 -1
  55. package/src/security-headers.ts +2 -2
  56. 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,
@@ -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.2.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.2.0"
49
+ "@nifrajs/schema": "2.3.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@nifrajs/core": "^2.2.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
+ }
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 — not crypto. */
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` — it's a content hash, not a byte-for-byte promise. */
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 — saving bandwidth on unchanged
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 — drop the body-describing headers so strict intermediaries don't see a
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")
@@ -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 — always `200` while the process is up. Default `"/health"`. */
6
+ /** Liveness path - always `200` while the process is up. Default `"/health"`. */
7
7
  readonly path?: string
8
- /** Readiness path — runs `checks`; `200` if all pass, `503` otherwise. Default `"/ready"`. */
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 — and the whole probe — not ready.
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 {
@@ -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 — a client retrying a `POST` (dropped connection, impatient
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 — this stops the *retry*, the constraint is the source of
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 — see {@link idempotency}). */
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 — a per-instance store can't dedupe across instances. */
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** — a cookie set on the first request is
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 — no key ⇒ no dedupe
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 — return it, but don't cache
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` — composable, dependency-free middleware + plugins applied via `app.use()`.
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 — named (idempotent dedupe), and `requestId` threads
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 — method, path, status,
19
- * and duration — via `onRequest`/`onResponse` (so it covers 404s and errors too). The start time is
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 = {}) {