@nifrajs/core 5.1.0 → 5.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 (92) hide show
  1. package/CHANGELOG.md +2409 -0
  2. package/dist/edge-kit.d.ts +1 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +1 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/idempotency.d.ts.map +1 -1
  7. package/dist/idempotency.js +2 -0
  8. package/dist/idempotency.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/internal/hook-origins.d.ts +17 -1
  12. package/dist/internal/hook-origins.d.ts.map +1 -1
  13. package/dist/internal/hook-origins.js +20 -0
  14. package/dist/internal/hook-origins.js.map +1 -1
  15. package/dist/internal/route-program.d.ts +2 -0
  16. package/dist/internal/route-program.d.ts.map +1 -1
  17. package/dist/internal/route-program.js +22 -17
  18. package/dist/internal/route-program.js.map +1 -1
  19. package/dist/mount.d.ts +12 -0
  20. package/dist/mount.d.ts.map +1 -1
  21. package/dist/mount.js +20 -0
  22. package/dist/mount.js.map +1 -1
  23. package/dist/reflection.d.ts +23 -2
  24. package/dist/reflection.d.ts.map +1 -1
  25. package/dist/reflection.js +57 -1
  26. package/dist/reflection.js.map +1 -1
  27. package/dist/server/context.d.ts +7 -4
  28. package/dist/server/context.d.ts.map +1 -1
  29. package/dist/server/core-copies.d.ts +7 -1
  30. package/dist/server/core-copies.d.ts.map +1 -1
  31. package/dist/server/core-copies.js +37 -1
  32. package/dist/server/core-copies.js.map +1 -1
  33. package/dist/server/headers.d.ts +9 -0
  34. package/dist/server/headers.d.ts.map +1 -1
  35. package/dist/server/headers.js +26 -0
  36. package/dist/server/headers.js.map +1 -1
  37. package/dist/server/registry.d.ts +8 -10
  38. package/dist/server/registry.d.ts.map +1 -1
  39. package/dist/server/request-context.d.ts.map +1 -1
  40. package/dist/server/request-context.js +4 -3
  41. package/dist/server/request-context.js.map +1 -1
  42. package/dist/server/request-target.d.ts +9 -7
  43. package/dist/server/request-target.d.ts.map +1 -1
  44. package/dist/server/request-target.js +21 -12
  45. package/dist/server/request-target.js.map +1 -1
  46. package/dist/server/respond.d.ts.map +1 -1
  47. package/dist/server/respond.js +10 -7
  48. package/dist/server/respond.js.map +1 -1
  49. package/dist/server/response-contract-lane.d.ts +37 -2
  50. package/dist/server/response-contract-lane.d.ts.map +1 -1
  51. package/dist/server/response-contract-lane.js +278 -32
  52. package/dist/server/response-contract-lane.js.map +1 -1
  53. package/dist/server/server-types.d.ts +7 -4
  54. package/dist/server/server-types.d.ts.map +1 -1
  55. package/dist/server/server.d.ts +17 -4
  56. package/dist/server/server.d.ts.map +1 -1
  57. package/dist/server/server.js +184 -40
  58. package/dist/server/server.js.map +1 -1
  59. package/dist/server/websocket.d.ts.map +1 -1
  60. package/dist/server/websocket.js +4 -0
  61. package/dist/server/websocket.js.map +1 -1
  62. package/dist/single-copy.d.ts.map +1 -1
  63. package/dist/single-copy.js +46 -5
  64. package/dist/single-copy.js.map +1 -1
  65. package/dist/tool-contract.d.ts.map +1 -1
  66. package/dist/tool-contract.js +8 -4
  67. package/dist/tool-contract.js.map +1 -1
  68. package/dist/transport-codec.d.ts.map +1 -1
  69. package/dist/transport-codec.js +4 -1
  70. package/dist/transport-codec.js.map +1 -1
  71. package/package.json +3 -2
  72. package/src/edge-kit.ts +1 -1
  73. package/src/idempotency.ts +2 -0
  74. package/src/index.ts +1 -1
  75. package/src/internal/hook-origins.ts +35 -0
  76. package/src/internal/route-program.ts +39 -20
  77. package/src/mount.ts +25 -0
  78. package/src/reflection.ts +61 -1
  79. package/src/server/context.ts +7 -4
  80. package/src/server/core-copies.ts +40 -0
  81. package/src/server/headers.ts +39 -0
  82. package/src/server/registry.ts +18 -19
  83. package/src/server/request-context.ts +6 -3
  84. package/src/server/request-target.ts +21 -12
  85. package/src/server/respond.ts +10 -7
  86. package/src/server/response-contract-lane.ts +357 -42
  87. package/src/server/server-types.ts +7 -4
  88. package/src/server/server.ts +235 -35
  89. package/src/server/websocket.ts +3 -0
  90. package/src/single-copy.ts +55 -5
  91. package/src/tool-contract.ts +7 -4
  92. package/src/transport-codec.ts +4 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,2409 @@
1
+ # @nifrajs/core
2
+
3
+ ## 5.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7ab9692: A route answering `status(code, body)` with a code typed as plain `number` now types the client's `data` as that body; it was `unknown`. A code typed as a literal union (`200 | 409`) still splits into one arm per code. A plain return beside `status(200, body)` now types the 200 arm as either body; the two used to be intersected.
8
+
9
+ ### Patch Changes
10
+
11
+ - 808bac8: A response body refused by its `content-length` alone is now cancelled. `readBoundedBytes` (behind the typed client's `maxDecodedBytes` and `decodeTransportResponse`) and the `jwks()` key fetch threw on an oversized declared length without touching the body, which left the stream, and its connection, open until the sender gave up.
12
+ - d4bae56: `server({ maxBodyBytes })` from `@nifrajs/edge` refuses a `maxBodyBytes` that is not a non-negative safe integer at construction, with the same `RangeError` the full `@nifrajs/core` server throws. A `NaN` (an invalid numeric environment value, for example) compared false against every body length, so the edge server accepted bodies of any size.
13
+ - 007060d: An in-process client reads a handler's streamed body under `maxDecodedBytes`, cancelling it once past the cap, as a network client does. It used to read every in-process body whole and check the size afterwards, which is safe for a body the app serialized but let a route returning its own `ReadableStream` buffer without limit before the check. A body the framework serialized keeps the whole-read lane: core records it when an in-process client exists (`hasResidentBody` from `@nifrajs/core/mount`).
14
+ - 190ab39: A raw request target with a character a URL parser percent-encodes in a path (`"`, `<`, `>`, `^`, `` ` ``, `{`, `}`, a control, space or non-ASCII) now routes the same on every runtime. Bun and workerd route the parsed path, so `GET /vault<tag>` reached a route registered as `/vault%3Ctag%3E` there, while Node, Deno and Bun's native route table matched the raw text and could pick a broader `/:id` route with different guards. Node and Deno now encode such a target before anything reads it, and Bun's native table hands a param holding one of those characters to the portable router.
15
+ - 7cf8df3: `ws.subscribe(topic)` on a closing or closed socket is now a no-op. Close drops a connection's topics, so an async handler that subscribed after the close (an `open` or `message` resuming late) left the dead socket in the in-process registry for good, where every `app.publish` kept sending to it.
16
+ - c98049d: No behavior change: the character classes that decide which characters a parsed request path keeps as written now list each character instead of using the ranges `#-;` and `?-]`. They match the same set.
17
+ - 74ec04f: On Bun's `listen()`, a raw `#` in the request target no longer picks a route the parsed URL does not. Bun's native route table read `#` and everything after it as path, so `GET /admin/..#/users/42/delete` ran a `/admin/:org/users/:userId/delete` handler while `c.req.url` and `app.fetch` saw `/`, and a guard that checks the URL path let it through. A param holding `#` now goes to the portable router, which routes the path the URL ends with (`/` here), as Node, Deno and `app.fetch` already did. A sent `%23` routes as before.
18
+ - 288ceca: On Deno, a request whose `Host` header is not an authority (a reg-name or IP literal with an optional port) is refused with `400 {"ok":false,"error":"invalid_host"}` before routing, as Node already refuses it. Deno builds `Request.url` by appending the target to the Host as received, so `GET /public` with `Host: x/admin#` was routed to `/admin`, and `c.req.url` carried the spoofed path. Reading the header costs a bare route on Deno about 4% of its throughput; a route that reads any header already paid it.
19
+ - 0f31edf: A raw `^` in a request path now routes as `%5E` on Deno too, as it already did on Bun, Node and workerd. Deno's URL parser keeps a `^` in a path as written, so `GET /vault/^` reached `/vault/:id` there and the static `/vault/%5E` everywhere else. The cross-runtime request-target matrix gains the case.
20
+ - 4877137: No behavior change: a resolved request path encodes `^` with one unconditional replace, which costs fewer bytes in every server bundle.
21
+ - b2ec456: An idempotent route's response refused by its `content-length` alone now has its body cancelled. The lane replaces it with a 507 and never reads it, so a streaming handler or a proxied upstream stayed open; a response refused while it streams was already cancelled.
22
+ - 365ba87: A tool call whose executor throws or rejects now keeps its idempotency key: a retry with the same key answers `idempotency_duplicate` instead of running the executor again. The key was released on a throw, so an executor that committed its effect and then failed (a lost response, an error after the write) ran that effect a second time on retry. Pre-execution refusals (budget, cancellation before the call) and dry runs still release the key. To retry a failure known to have committed nothing, use a new key.
23
+
24
+ ## 5.2.1
25
+
26
+ No changes in this release.
27
+
28
+ ## 5.2.0
29
+
30
+ ### Minor Changes
31
+
32
+ - 1f3ff32: A closed request-header schema (`headers: t.object({...})`) validates only the header names it declares, each read case-insensitively and keyed as declared. Every client sends headers a route never declares (`host`, `user-agent`, `accept`), so such a schema used to answer every over-the-wire request with a 422; it now matches the OpenAPI document, which lists one `in: header` parameter per declared name. Open schemas, and schemas that do not expose their JSON Schema, still receive every header. `declaredHeaderNames` is exported from `@nifrajs/core/reflection`.
33
+
34
+ `t.headers({...})` is the open header-slot schema: every header arrives with its name lower-cased, and declared `t.integer()` / `t.number()` / `t.boolean()` fields are coerced from text. It refuses an upper-case name.
35
+
36
+ `runAdversarialContract` sends the headers real clients send (`accept`, `accept-encoding`, `accept-language`, `user-agent`), validates a header witness the way the route's slot does, and names the refused fields when a witness is rejected or a valid witness is answered with a validation 422.
37
+ - cdff19d: `responseContract(mode, { extra: "strip" })` projects a union whose object branches all require one key holding literals no two branches share (`ok: true` / `ok: false`, `kind: "a"` / `kind: "b"`): each value is projected onto the branch its tag names. A union with one object branch and one array branch projects both. Unions where more than one branch could accept a value are still left to the validator.
38
+
39
+ `responseContract("enforce", { logStripped: true })` logs the names of the fields that never reached the wire, whether `extra: "strip"` or a stripping validator dropped them: nested fields as `user.email`, array items as `items[].cost`, once per route for each distinct set of names. Values are never logged.
40
+ - 000b4cb: `nifra check` reports NF-C043, a warning, for a `merge()` whose server authenticates none of its routes while the server it is merged into authenticates its own with `authenticate()` or an auth plugin that records `authenticated` for the routes after it. A merged route keeps the hooks of the server that declared it, so that authentication never runs for it. A merged server that authenticates any route of its own is not reported. `// nifra-expect NF-C043: <reason>` on the merge line accepts a module that is public on purpose. The rule reads the app `nifra.assurance.ts` loads.
41
+
42
+ `@nifrajs/core/reflection` exports `reflectMergeAuthGaps(app)`, the record behind the rule, collected by a development load.
43
+ - c5455fb: `responseContract(mode, { onReport })` calls a hook for every response the contract reports on, for a metric or an alert. The kinds are `violation` (enforce mode, a 500 went out instead), `warn` (warn mode, sent anyway) and `stripped` (enforce mode, sent without some fields). Each report carries the route's method and its registered pattern (`/users/:id`), the field names where they apply, and the message the log line carries. Values are never passed. A hook that throws, rejects or is not a function is logged once and changes no response. Logging is unchanged. An app that installs the plugin carries about 0.3 KB more gzipped; the kernel carries nothing.
44
+ - 6082ba5: Capability provenance now also starts from the module that registered each route. While the CLI loads `nifra.assurance.ts`, a development load records the `file:line:col` of each route registration through every `group()` and `merge()`, and `reflectRouteSites()` from `@nifrajs/core/reflection` returns it. The walk adds that module to the ones the static text scan finds, so a route with a computed path, or on a server a helper builds, is covered without `provenance.routeModules`. Recording happens only with `NIFRA_ROUTE_SITES=1` outside production, and a production build carries none of it. The sites stay out of `reflectRoutes()`, so no absolute path reaches a written artifact.
45
+ - fd58beb: The unused order-scoped hook check (`unusedScopedHooks`) now reports three more silent gaps, in development and test only:
46
+
47
+ - A hook followed only by `merge()` calls. A merged route keeps the hooks of the server that declared it, so `app.use(session).merge(users)` ran `session` on none of the merged routes and was never reported, because the merged routes counted as routes declared after the hook. The report marks such a hook "only merge()d routes follow it".
48
+ - `authenticate()`, which the check did not record at all.
49
+ - `responseContract()` or `effectLedger()` installed after routes it would have covered: a route declaring a response, or capabilities, registered or merged before the install. Both are decided per route at registration, so those routes ran without them. The report names up to five of them.
50
+
51
+ With `unusedScopedHooks: "error"` each of these now throws `UNUSED_SCOPED_HOOKS` at the first request or `listen()`, as a dead `derive()` already did. A production build drops the check.
52
+ - ed13e36: `nifra upgrade` carries a 5.1.0 recipe, so an upgrade across 5.1.0 prints what can change for an app: merged routes recompiled under the parent's response contract and effect ledger, cross-site form posts refused with `403`, and `clientIp.trustedHops` with no socket peer. `nifra upgrade --list` prints the notes of every release after the version the project declares, and `--json` carries `installed` and every version's `notes`.
53
+
54
+ `reflectHooks` (and so `nifra routes --hooks`, and the hook-scope diff `nifra upgrade` saves) lists the runtimes a route resolved at registration beside its hooks: `responseContract` named by its mode, `effectLedger` and `idempotency`, each with the site `route`. A merged route that gains a parent's contract on upgrade now shows up in `nifra routes --hooks --since`.
55
+
56
+ The release gate fails a minor or major release that has no upgrade recipe of its own.
57
+
58
+ ### Patch Changes
59
+
60
+ - 9498cf8: The package ships its `CHANGELOG.md`, so the installed package has the notes for every version, as the GitHub release does. Before, the tarball held only the code, `README.md`, `LICENSE` and `package.json`.
61
+ - 4bb8e52: The `mountFetch()` reference says what the WebSockets guide already did: a mounted fetch handler carries HTTP only, so a WebSocket upgrade under its prefix reaches it as an ordinary request. Serve those sockets with `app.ws()` routes or a mounted child app.
62
+ - 5edd0ba: `nifra upgrade` stops before writing anything when a `link:` or `file:` dependency outside the project installs its own `@nifrajs/*` at a version other than the target. Its imports resolve from its own `node_modules`, so after the upgrade a `server()` it builds could no longer be merged into the app, and that only failed at the first `merge()`. The refusal names each linked repository, its versions and the commands that upgrade it; `--allow-linked-skew` upgrades the project anyway. A dry run prints the plan with the same blocker and exits non-zero. A linked copy that resolves back into the project follows its install and does not count.
63
+
64
+ `merge()` given a server from another loaded copy of `@nifrajs/core` names every copy in its error, this one first, with the version of each copy that has built a `server()`, and says how to collapse them. With one copy loaded the message is unchanged. Copies older than this release are named by path alone, and a production build keeps the short message.
65
+ - ea25a61: The single-copy registrar reports the packages a linked tree skews together in one warning: both `node_modules` folders, both versions, the package list and the fix. A linked repository on another release skews every declared package at once, and each process (every CLI command included) printed one near-identical block per package. A single skewed package keeps its own report, and strict mode throws the combined text.
66
+ - 4ba2cf2: An `authenticate()` stage that throws, or returns anything but `authenticated(principal)` or `rejected(...)`, still answers 503 `{ ok: false, error: "auth_unavailable" }`, and its cause now goes where a handler's error goes: to `onError`, whose response replaces the 503, and when no hook answers, to the logger as `unhandled request error`. Before, the cause was dropped, so a database outage during authentication left only a 503 in the access log. A malformed decision is reported as a `TypeError` naming the stage. A stage that throws a `Response` or `status(...)`, such as `requirePrincipal`'s 401 or redirect, now has it sent as thrown, as from a handler, instead of a 503. `authed()` from `@nifrajs/better-auth` no longer catches provider failures itself, so they are reported the same way.
67
+
68
+ ## 5.1.0
69
+
70
+ ### Minor Changes
71
+
72
+ - b276c67: `responseContract("enforce", { extra: "strip" })` drops the fields a `t` schema does not declare before validating, instead of failing the response: a schema exposing its JSON Schema as `jsonSchema` lists its declared keys, so the payload is projected onto them, nested objects, arrays and records included; a closed record (`t.record(v, { additionalProperties: false })`) keeps every key its pattern admits. A union is projected only when exactly one branch can hold an object. The default, `extra: "reject"`, is unchanged: a closed `t.object` still turns an extra field into a 500 whose log names the field. In `"warn"` mode the option makes the log name the fields enforcement would drop. The security guide's example claimed the stripped output for plain `"enforce"`; it now shows all three outcomes.
73
+
74
+ `merge()` now recompiles a merged route under the response contract and effect ledger the parent installed before the merge, when the route declares a response (or capabilities) and its own server installed neither. `group()` routes already inherited both, so the same `.use()` used to enforce a grouped route and let its merged twin leak. A route that resolved its own contract or ledger keeps it. This can change a response: a merged route whose payload carries fields its closed `t.object` does not declare now answers 500 under a parent's `responseContract("enforce")`, as a grouped one already did. The `merge()` JSDoc and the plugins guide list what a parent's `.use()` covers for each.
75
+
76
+ Route assurance policies gain a `hasResponse` selector (routes declaring a success `response` schema) and `baseline` rules, checked on every route they match in addition to the first-match rule that owns it. "Every route that declares a response enforces it" is one baseline entry: `{ name: "response-contract", match: { hasResponse: true }, require: [NIFRA_ASSURANCE.RESPONSE_CONTRACT] }`. A baseline rule does not classify a route, and its name shares the `rules` namespace.
77
+
78
+ `RouteCapabilityEvidence` carries an optional `uncoveredReason`, appended to the `provenance-uncovered` finding.
79
+ - 0b68be9: A body-schema route now refuses a urlencoded, multipart or `text/plain` body whose `Origin` names another site, with `403 { ok: false, error: "forbidden_origin" }`, before the body is read. A body-schema route reads urlencoded as well as JSON, and a form on any site can post urlencoded with the visitor's cookies and no preflight, so a route written for JSON took cross-site form posts. The JSON agent, A2A and AG-UI mounts had the same opening. Same-origin posts, requests without an `Origin` such as server-to-server calls, and `Origin: null` with `Sec-Fetch-Site: same-origin` (a form under `Referrer-Policy: no-referrer`) still pass. JSON bodies are unchanged: a browser preflights them, so CORS decides.
80
+
81
+ A frontend on another origin that posts forms or `FormData` to the API, or an identity provider's `form_post` callback, now needs its origin in the new `formOrigins` server option: an array of exact origins, or a function that receives the `Origin`. `@nifrajs/edge` takes the same option. `isSameOriginForm` is exported next to `isSameOriginRequest`, which now accepts anything with a `url`.
82
+
83
+ ### Patch Changes
84
+
85
+ - 2a0a5d3: The single-copy resolver and the build's duplicate-copy scan treat a `file:` dependency Bun installs as a folder of linked files the way they treat a linked package. Before, only a symlinked package directory counted, so the second React that such a package's own `node_modules` held went unredirected and unreported by `nifra check`.
86
+ - 72e1b4b: The single-copy resolver lets Bun resolve a declared package itself when Bun's own `node_modules` lookup from the importing file already reaches the app's copy. Before, it returned a path for every declared import, and Bun drops a package's `sideEffects: false` pruning for a path a plugin returns. A browser import of one helper from a package entry that also re-exports server modules then pulled in all of them. With `"singleCopy": ["@nifrajs/*"]` (or `true`), `import { canonical } from "@nifrajs/web"` in browser code made `nifra dev` refuse `node:fs`, `node:path` and `node:stream`. Imports that would reach another copy are still redirected to the app's.
87
+ - 0fb2d25: `clientIp: { trustedHops: n }` reads the caller from the same `X-Forwarded-For` entry whether or not the adapter observed a socket peer. With no peer (Workers and edge handlers, Bun on a unix socket, a Node socket already closed) it took the entry one place to the left, which the client itself wrote, so `c.clientIp` and everything keyed on it could be forged. With no peer, `trustedHops: 0` is now `undefined` rather than the rightmost header entry.
88
+ - ed6bb22: `redirect()` and the auth guards' `redirectTo` refuse a path whose dot segments resolve it to `//host`, such as `/.//evil.example/x` or `/a/..//evil.example/x` (`isSameOriginPath` is false for them). A redirect answering a client navigation or a JS-enhanced form submit no longer hands the router such a path either: a same-origin target whose path starts with `//` rides `x-nifra-redirect` as an absolute URL, so the browser stays on this origin instead of reading the path as a protocol-relative URL to another host.
89
+ - 36f1db9: Signed cookies, and the `@nifrajs/auth` sessions built on them, import each secret's HMAC key once instead of on every sign and verify. A verify measured about 16% faster on Bun (one run). Up to 64 secrets are kept, and the list starts over past that.
90
+ - 2e1c425: The `protoPoisoning` option's documentation now matches what it does: every guarded JSON body pays one pass over its parsed object nodes, and `"ignore"` skips it. It used to promise that clean payloads paid only a text pre-scan, which the guard has not done since it began walking the parsed value.
91
+ - ef82971: Decoding transport data now refuses a `bigint` longer than 4,096 decimal digits with `WireDecodeError`. Parsing a long digit string costs more than linear time: a 900 KB token took about 60 ms, and the 16 MiB byte budget allowed far longer. It is reachable from any request body the transport codec decodes. `WireDecodeLimits.maxBigIntDigits` raises or lowers the limit.
92
+ - 0b13a46: On Bun, a mounted app's `wsMaxPayloadBytes` now holds for its own sockets. Before, `listen()` applied only the parent's cap: a child that declared 16 bytes still received a 5000-byte message, and a child that declared more than the parent had its messages cut off at the parent's cap. The listener's frame limit is now the largest cap among the parent and its mounted apps, and a socket whose app declares less closes with `1009` when a message exceeds it.
93
+ - 5ab361c: Checking `wsMaxPayloadBytes` on Deno, and on runtimes wired through `attachWebSocket` (Cloudflare Workers), no longer UTF-8 encodes every text message to measure it: the size is counted in place, and a message whose length alone settles the answer is not scanned at all. Before, each capped text message was copied once just to be measured. The `@nifrajs/deno` docs now state the limit of that check: `Deno.upgradeWebSocket` takes no size limit, so Deno receives each message whole (up to its own 64 MiB on Deno 2.9) before the cap closes the socket with `1009`.
94
+ - 7c8b345: A WebSocket route with `validateSend: true` now checks its outbound frames against `sendSchema` on Node and Deno too, as it already did on Bun: a frame the schema refuses is dropped before it reaches the client and reported to the route's `error()`. Before, `@nifrajs/node` and `@nifrajs/deno` sent every frame unchecked. The upgrade outcome a nifra app hands an adapter now carries a `sender` that adapters wiring their own sockets build `ws.send` with.
95
+
96
+ ## 5.0.0
97
+
98
+ ### Major Changes
99
+
100
+ - 0aed425: Required `basicAuth`, `bearer`, `apiKey` and `jwt` refuse in the `authenticate()` stage: before the body is read or validated and before any `derive` or `beforeHandle`. An unauthenticated request gets the 401 and its challenge, never a 413 or a 422, on every route shape, lane and WebSocket handshake. With valid credentials an invalid body is still a 422. `optional: true` never refuses and keeps validation first. A `verify` that throws in required mode is a 503. A rate limiter written as a `beforeHandle` behind these guards no longer sees refused requests; `rateLimit()` runs in `onRequest` and still counts them.
101
+
102
+ The `authenticate()` input adds `params`, the raw `search`, the parsed and frozen `query` (the parser `c.query` uses; a repeated key is an array) and the `request` (its body must not be read). The query is for short-lived, single-use tickets, such as one for a WebSocket handshake.
103
+
104
+ Project evidence and reflection keep a route's chosen `validationOrder`.
105
+
106
+ `nifra check` reports a route that claims runtime authentication, declares an input schema and authenticates in a hook that runs after validation (NF-S008, warning). It reads the backend the contract-lock check loads, so it runs when `contracts.lock.json` exists. Fix it with `authenticate()`, or with `validationOrder: "auth-before-validation"` on the route.
107
+
108
+ The dev feed (`nifra_logs`, `nifra_errors`, request traces and the persisted log) records a query's keys and never its values.
109
+
110
+ - a5963cb: An order-scoped hook (`derive`, `decorate`, `beforeHandle`, `afterHandle`, `around`, `aroundCapability`, `onError`) added to a `createWebApp` app after it returns now throws a `FrameworkError` with code `HOOK_AFTER_PAGES` outside production, naming the call site. Every page copies its hook chain when it is declared, so such a hook never ran on a page. 4.x accepted it without a word. Apply it in `createWebApp({ use })`, or inside `app.group(prefix, ...)` to scope it to the routes declared there. Global hooks (`onRequest`, `onResponse`), routes, groups and re-applying a named plugin that `use` already applied stay allowed.
111
+ - 4064f36: A `Response` a handler or hook returns or throws gets the request's `c.set.headers` it does not already carry, as well as its queued cookies, on every lane. A guard's `cache-control: no-store` now reaches a handler-built `Response`. The `Response`'s own headers win. `content-type`, `content-length`, `content-encoding`, `content-range` and `transfer-encoding` come from it alone, and `c.set.status` does not change its status. A `Response` that answers many requests is copied first, so one request's headers never reach another. A 101 upgrade and `Response.error()` take no headers. Mounted apps and `onRequest` responses are unchanged.
112
+ - 1290c6d: A WebSocket handshake runs the route chain an HTTP `GET` registered at the same point runs: the `authenticate()` stages, `derive`s and `beforeHandle`s added before `app.ws()`, then `upgrade()`. Auth middleware such as `basicAuth`, `bearer`, `apiKey`, `jwt` and `authed()` guards the sockets registered after it, and a guard refuses the handshake with the response it would send an HTTP request. `upgrade(c)` sees, typed, what the guards derived and the app's decorations. To keep a socket public, register it before the guard.
113
+
114
+ `upgrade()` may reject with `status(...)` as well as a `Response`. A thrown `Response` rejects the handshake with that response; any other throw goes to the route's `onError` hooks, then a flat 500. The guards share the upgrade timeout.
115
+
116
+ In development, a `derive`, `decorate`, `beforeHandle` or `onError` followed only by WebSocket routes is no longer reported as applying to no route.
117
+
118
+ ### Minor Changes
119
+
120
+ - eaf030b: The server records each request an authentication stage accepts, and `isAuthenticatedRequest(req)` from `@nifrajs/core/server` answers for the request response hooks receive, on every runtime and after an `onRequest` hook replaces the request. `markAuthenticatedRequest(req)` records a request an integration verified outside a stage; the optional `bearer`, `apiKey`, `jwt` and `basicAuth` guards call it. `cache()` treats a response to such a request as private, so it is stored only when the response is explicitly `public` or carries `s-maxage`, whatever header carried the credential, including an `apiKey({ header })` outside `authenticatedHeaders`.
121
+ - 4199ff3: The adversarial contract laboratory witnesses and mutates declared `params` and `headers`. A `t` schema synthesizes them, the witness's `params` and `headers` override, and each is validated before any case runs, so a body or query case is rejected by the input it mutates rather than by a placeholder param or a missing header. An opaque params or headers schema without a witness is a `NO_WITNESS` gap; placeholders no longer stand in for it.
122
+
123
+ The laboratory never invents credentials: `authorization`, `proxy-authorization`, `cookie`, `x-api-key`, and names ending in `-key` or containing `token`, `secret` or `signature` come from the witness and `prepareRequest` only and are never mutated, and runtime-owned headers are never set. A schema that requires a credential the witness lacks is a `NO_WITNESS` gap naming it. Cookies are never synthesized: a route with a `cookies` schema needs `headers.cookie` in its witness, else a `cookies` gap. A params or header field typed as bare text is not a `NO_REJECTED_MUTATION` gap. `ContractTarget` adds `"headers"`, `"cookies"` and `"params"`.
124
+
125
+ `autoReflectJsonSchema` resolves zod from the project directory first, then from `@nifrajs/mock`. `explainAutoReflectJsonSchema` says why a zod schema stayed opaque (zod not resolvable, older than 4, or its converter threw), and a laboratory gap for such a schema carries the reason.
126
+
127
+ - 90d9ee6: `securityBaseline()` treats a route under a custom method, such as `PURGE` registered with `method()`, as a mutation. It used to match no rule, so an authenticated custom-method route was never asked to prove CSRF. Every method but GET, HEAD and OPTIONS is now held to the mutation rules; WebSocket routes are still not. Assurance rules gain an `exceptMethods` selector for this ("every method but these"), evidence declarations gain `exceptMethods` and may name custom methods in `methods`, and `@nifrajs/core/methods` exports `isRegistrableMethod`.
128
+ - d91fd44: `@nifrajs/edge` holds a handler's own reads of `c.request` to `maxBodyBytes` on a route with no body schema, as the full server does for `c.req`. A body past the cap, or a declared Content-Length over it, makes the read throw a `413` response; a malformed Content-Length throws a `400`. Before, such a route read the whole body whatever its size. The cap is built only when the handler first touches `c.request`. `@nifrajs/core/edge-kit` exports the helper, `capBodyBytes(request, maxBytes)`.
129
+ - 1a6616f: `reflectHooks(server)` from `@nifrajs/core/reflection` reports, for every route and for each prefix's unmatched paths, the hooks that run in order: their kind, the plugin or function name, and the site that registered them (`app`, `group <prefix>`, `merge #n`, `mount <path>`). Static `responseHeaders()` declarations appear with their header names. Origins are recorded only outside production builds; a production report sets `complete: false`.
130
+
131
+ `nifra routes --hooks [--json]` prints that report, and `--since <file>` prints only what runs differently from a saved one. `nifra upgrade --write` saves the report to `.nifra/hooks-before-upgrade.json` before it edits anything and prints the hook-scope diff (`--no-hooks` skips it). `nifra check --json` includes the report as `hooks` when `nifra.assurance.ts` names a `source`.
132
+
133
+ - 846e2f6: `invariants` in `nifra.assurance.ts` accepts the L4 runner's fixture options - `witnesses`, `prepareRequest`, `isRejected`, `include` and `maxMutationsPerInput` - and `nifra levels` and the `nifra_levels` MCP tool pass them to every L4 run. `invariants.setup` provisions a throwaway target only when L4 runs: it is called once, before the first L4 request and never when the config is imported, and the `teardown` it returns runs after the run, a failing one included. A config needs an `executor`, a `setup`, or both. The contract witness and case-context types now live in `@nifrajs/core/assurance`; `@nifrajs/testing` re-exports them unchanged.
134
+ - fc0284a: Add opt-in server components across React, Vue, Svelte, Solid and Preact. Closed schemas project public props, owner GET admission and read capabilities govern every refresh, and private responses bypass shared caches. React uses native Flight; Vue, Svelte, Solid and Preact preserve native island state and owned ranges.
135
+
136
+ Add coordinated Vite builds and development generations, guarded component references, generated Bun/Node/Deno/Workers/Pages/Vercel outputs, typed outlets, router and server-function refresh integration, and release certification for browsers, packaged consumers, resource limits and performance. Existing applications retain their default rendering pipeline.
137
+
138
+ Reuse registered Web middleware continuations across requests while retaining request-local state.
139
+
140
+ - 9f58295: An opaque mount can declare the routes it serves: `opaque: { reason, routes: [{ method, path, capabilities }] }` on `mount()`, `mountFetch()` and a `createWebApp` `mounts` entry. Capability assurance reports those routes with `analysis: "declared"` beside the known gap, checks their capabilities against the policy (an unknown id, or a domain write on a safe method, fails), and records them in the lockfile. `reflectMounts` returns them, project evidence carries them, the `nifra context` index lists them, and an adversarial contract run notes them as having no contract to test. A malformed declaration or a path outside the mount stops the analysis with an error that names it. A mount without a reason fails as before.
141
+ - 7a165de: A Response returned to more than one request no longer carries one request's headers to another. A Response without a body (a module-level `new Response(null, { status: 204 })`, a kept redirect) can be sent any number of times, so the cookies a handler queues with `c.set.cookie`, the app's `responseHeaders`, and the headers written by `onResponse` hooks, response observers and response middleware go on a copy made for the request; the Response the handler returned stays as it was built. A Response with a body can be sent once: returning it again answers `500` with `{"ok":false,"error":"internal_error"}` and none of the headers written for the earlier request. On `@nifrajs/node`, a response whose body cannot be written is answered with a `500` that carries none of the headers set for it.
142
+
143
+ `writableResponse(response, request?)`, exported from `@nifrajs/core/server`, returns the Response a request may write headers on: the response itself when it has a body and writable headers, otherwise a copy. Pass the request and a copy already made for it is reused by its later writers. `toResponse`, exported from `@nifrajs/core/edge-kit`, takes the same optional last argument: the copies and body-less responses it builds are then reused by that request's later writers instead of being copied again. A Response the handler returned is never treated as the request's own.
144
+
145
+ `renderResult(result)`, exported from `@nifrajs/core/edge-kit`, renders a framework `ResponseResult` exactly as `toResponse` does without taking a handler's `Response`. `@nifrajs/edge` renders its rejections with it; responses are unchanged and the bundle is smaller.
146
+
147
+ - 48aad8c: One `resume` or `compensate` call owns a saga at a time. It claims the saga with a lease that every transition renews (`createSagaEngine({ leaseMs })`, default 5 minutes) and releases it when it returns or throws. A second call while the lease is live throws `SagaConcurrencyError` and changes nothing; a call that died is taken over once its lease lapses, and a step it left executing goes to manual review as before. A call that loses a write to another one stops without compensating, and a step whose record is gone is never written back. `SagaRecord` gains the optional `owner` and `leaseUntil` fields; stored records without them load unchanged.
148
+ - 41050ed: `app.sse()` registers a POST stream when its schema declares a `body`: the body validates as on any POST, the handler reads `c.body`, and the typed client's `.subscribe(onEvent, { body })` sends it, typed from the body schema. A chat stream that posts its prompt no longer needs a hand-written `fetch`. A POST stream does not reconnect unless `reconnect: true` is passed, since a reconnect sends the body again; a GET stream keeps reconnecting by default.
149
+
150
+ `.subscribe()` now runs the client's `onRequest` hook before each connect, as every other request does, so a refreshed auth token reaches the stream.
151
+
152
+ - fc837cb: `sse()` and `.sse()` routes drop a client that stops reading. Once a client has `maxBufferedBytes` of events unread (a new `SSEInit` option, default 1 MiB), `stream.signal` aborts with `sse_client_too_slow`, the queued frames are released, and later sends are no-ops. Before, every `send()` queued in memory until the client read it or disconnected. A producer that sends a burst larger than 1 MiB before the client can read raises the option; `Infinity` turns the limit off.
153
+ - 9b1acc9: The Vite dev server, `buildClientVite` and `buildServerVite` apply the `package.json` single-copy declaration (`"nifra": { "singleCopy": [...] }`): each declared package, and each package installed under a declared `@scope/*`, joins `resolve.dedupe`, so a linked package with its own copy loads the app's copy, as the Bun build does. `singleCopyPackageNames(cwd)` from `@nifrajs/core/single-copy` returns that list.
154
+ - 740109f: `verifyWebhook` accepts `encoding: "base64url"` for providers that sign in the URL-safe alphabet. Padded and unpadded signatures verify; any character outside the alphabet, misplaced padding or an impossible length is refused as `malformed_signature`. Verification stays constant-time.
155
+ - e0a25fe: `app.ws(path, { capabilities })` declares the capability ids a socket's code may reach, as an HTTP route's `capabilities` does. Reflection lists them on the `"WS"` route, and `nifra capabilities check` covers sockets: it finds each `.ws("/path", ...)` registration, compares the declaration with the effects reachable from that module, and reports an undeclared reach. A write whose definition needs request idempotency fails on a socket, which has no request idempotency. The handshake context carries the guard, so a runtime capability beacon in `upgrade()` fails closed on an undeclared capability.
156
+ - 3630b6e: `reflectRoutes()` lists each WebSocket route under the pseudo-method `"WS"`, after the HTTP routes, with the evidence its handshake enforces: `nifra.authenticated` from the auth guards registered before it and `nifra.rate-limited` from the app's request hooks. A socket never claims evidence whose enforcement its message handlers bypass. Route assurance therefore classifies every socket, and an assurance rule selects sockets with `methods: ["WS"]`; a policy whose rules name only HTTP methods reports each socket as `unclassified-route`.
157
+
158
+ ### Patch Changes
159
+
160
+ - a0a02f4: After an async `authenticate` stage, a `derive` or handler that throws synchronously now reaches `onError`. The rest of the route ran inside the stage's promise callback, so the throw rejected the request instead. `app.fetch` rejected, `toFetchHandler` rejected, and `Bun.serve` answered its own 500 without calling `onError` or the logger.
161
+ - 7de0397: A loader, action or render failure that an `_error` page answers is written to the server's `logger` as `unhandled request error`, with the request line and the detail `errorLogDetail` allows, the same entry an error with no boundary gets. A soft-navigation data request that a boundary answers with a 500 is logged the same way. Control flow such as a thrown `redirect()` is not logged. `@nifrajs/core/edge-kit` exports `emitRequestErrorLog` and `ErrorLogDetail`, the function the server itself logs through.
162
+ - 364a234: A streamed request body that passes its byte cap fails with 413 at once, even when the app took a `clone()` it never reads. Before, the rejection waited until the clone was read or cancelled.
163
+ - c06cba5: `server({ clientIp: { trustedHops } })` refuses a hop count that could never resolve a caller. A negative, fractional or non-number `trustedHops`, or a declaration with neither `trustedHops` nor `header`, used to be accepted, and `c.clientIp` was then `undefined` on every request. It now throws when the server is created.
164
+ - f4d3852: An owned effect whose `errorCode` or `failurePhase` mapper throws or returns an invalid value is still settled when it fails. The validation error used to be thrown before the `failed` transition and the lifecycle event, so the durable record stayed unfinished until reconciliation found it. The effect now settles with the default code and phase, and the validation error is thrown after.
165
+ - 4a82ae0: The email pattern in `commonSecretPatterns` scans a long run of address characters once. A logged value like `a.a.a.` repeated restarted the match at every letter, so 64 KB of it took most of a second to redact. It now starts a match only where such a run starts, and redacts the same addresses (plus a leading `+` or `.` that used to be left in front of one).
166
+ - 0ff867a: An idempotency `namespace` resolver receives the request's method, URL and headers, as its type says, instead of a full clone of the request. A resolver that read the clone's body could read past the route's body limit, and the clone kept a copy of the body alive. The resolver no longer gets a body at all.
167
+ - e7391bf: `merge()` refuses a server that holds `mount()`s or `mountFetch()`es, as it already refused one with WebSocket routes, with a `RouteConfigError` that says to add them on the parent. It used to fold in the server's routes and drop its mounts without a word, so every request under a mount prefix answered `404`.
168
+ - 0b80a59: A mounted app's `onRequest` hooks run once for a WebSocket handshake that its HTTP route answers. Under a `stripPrefix` mount the HTTP answer stripped the prefix into a fresh request, so the child ran its hooks again after the handshake check had already run them, and a rate limiter in the child counted the request twice. The child now gets the same stripped request the check gave it.
169
+ - c64db12: With `acceptInboundDeadlines` on, a route Bun's native table serves runs its header-only response hooks (`onResponseHeaders`) on a request that carries no deadline, and answers a render that throws through the app's error path. That lane used to skip the hooks, so the headers they set were missing from those responses.
170
+ - 3753104: `toNetlifyHandler()` and `toLambdaHandler()` keep the Host header's origin when the event carries no `rawUrl` (Netlify) or `requestContext.domainName` (API Gateway). A path that starts with `//`, such as `//evil.example/admin`, used to become the request URL's host (`https://evil.example/admin`), so an origin comparison against `request.url` saw the wrong host. It is now the path `//evil.example/admin` on the Host.
171
+ - 4b3b1fa: Reconciling the in-memory effect and saga stores, or a store that only implements `list()`, no longer skips records. Their scan cursor was an offset into the matching records, so a page reconciled before the next was read shifted the rest past it: 250 stale effects in pages of 100 reached the handler only 150 times. The cursor is now the last id returned, as with the durable adapters, and these scans return records in id order.
172
+ - 3562557: `reflectHooks` (and so `nifra routes --hooks`) finishes on an app that mounts itself, or two servers that mount each other. It used to follow the mount without end and never return. A mount of a server already on the path is now reported once, as that prefix's unmatched entry.
173
+ - 53087f3: `responseContract()` now holds every `status(...)` and `c.json(...)` reply to the route's declared schema, not only the one the handler returns. A reply the handler throws, a reply a `derive` or `beforeHandle` hook returns early, and a reply `onError` answers with used to reach the wire unchecked, undeclared fields included. They are now validated like a returned one: `enforce` strips or refuses them, `warn` logs them. If a schema throws while checking an `onError` reply, the request answers 500 instead of running `onError` again. A hand-built `Response` still passes unchecked.
174
+ - 2def6c7: A route written with non-ASCII text or a space (`.get("/café")`, `.get("/a b")`) now matches: its literal text is registered as a request spells it, composed to NFC and percent-encoded. Such a route used to answer 404 for every request.
175
+ - b5b5bc3: The single-copy runtime plugin hands back a linked copy's file that has no counterpart in the app's copy with the loader its extension names. It always used the `js` loader, so a TypeScript or JSX source file (`.ts`, `.mts`, `.cts`, `.tsx`, `.jsx`) in a linked package failed to parse instead of loading from the linked copy.
176
+ - 22e1777: `executeTool` resolves with the call's own result when the idempotency store's `abandon()` throws. A store that failed while releasing a reservation (after a budget refusal, a cancellation, a dry run or an executor that threw) used to make `executeTool` reject with the store's error instead. The key then stays reserved until its lease lapses.
177
+ - 3a483cd: `toolHttpResult` and `createToolHttpHandler` answer an idempotency refusal as the HTTP idempotency lane does: a key already used is `409`, a call still running under the key is `409` with `Retry-After: 1`, and a full idempotency store is `503` with `Retry-After: 1`. All three used to be `500`, which reads as a server fault a client should not retry.
178
+ - 35f52c7: `transportCodecs()` documents that it decodes a codec request body before routing, so a route's `bodyLimit` and its `authenticate()` stages run after the decode, and that its `maxBytes` option is the only bound on that work.
179
+ - 343cb81: A refused WebSocket handshake carries the app's `responseHeaders()` the way an HTTP answer does. A guard's refusal already did, but the cross-origin 403, a refusal returned by an `onRequest` hook, a malformed-path 400, the upgrade timeout's 503, an internal 500 and a refusal from a mounted child's socket went out without them.
180
+ - b13e071: A route whose result cannot be rendered (a `BigInt` in the JSON, a header value with a line break) is now logged and answered `500` on every lane, with `onResponseFinalized` and the app's response headers applied. The fast lanes a route without `onError` takes threw instead: `app.fetch` threw synchronously or rejected, `onResponseFinalized` never ran, and on `listen()` the 500 lacked the headers `responseHeaders()` and `onResponse` add. On the Node-direct lane the request failed instead of answering.
181
+ - 077657f: `server({ gracefulSignals: { drainMs } })`, and `serve(app, { signals: { drainMs } })` on Node and Deno, set how long in-flight requests get to finish when SIGTERM or SIGINT stops the server before the stop forces them closed. `true` keeps the 10 second default. `nifra doctor` counts the object form as a graceful lifecycle.
182
+ - 05aac0e: `reflectHooks` and `nifra routes --hooks` list each WebSocket route as `WS /path` with the hooks its handshake runs: the request hooks, then the authentication stages, `derive`s, `beforeHandle`s and `onError` declared before it. `nifra upgrade` and `--since` diffs include them.
183
+ - 7775c2b: Recheck current route authentication, authorization and prerequisite hooks before idempotent replay,
184
+ without repeating the handler, effect ledger or stored response transformations. Preserve stored
185
+ status, safe headers and the replay marker after admission.
186
+
187
+ Enforce typed SSE event limits in UTF-8 bytes before dispatch for complete and fragmented frames,
188
+ with bounded, linear fragment accounting independent of chunk boundaries.
189
+
190
+ Bound the in-memory GraphQL pubsub subscriber queue to 1024 events by default. Overloaded subscribers
191
+ detach and fail with `GraphqlPubSubOverflowError`; configure `maxQueuedEvents` or explicit
192
+ `drop-oldest`/`drop-newest` policies when needed. Returning or throwing into an iterator clears queued
193
+ events and ends delivery.
194
+
195
+ - aadd9fc: A merged group's request hooks now run for an `OPTIONS` request to a path the group serves. A group's hooks were scoped to the method and path of its routes, so a CORS preflight to a merged `POST /items` never reached the group's `cors()` and was answered `405`.
196
+ - ea16748: In development, `mount()` refuses an app that leads back to the mounting one without `stripPrefix` (itself, or a chain of mounts whose prefixes overlap) with a `RouteConfigError` coded `MOUNT_CYCLE`. A request under that prefix used to come back to the same app unchanged until the stack overflowed, which ended the process. Production bundles drop the check, as they do the other dev-time diagnostics.
197
+ - 5b22f4d: On Node, a hook that throws past `app.fetch` (an `onRequest` hook that reads `new Request(req)`, a mount handler that throws) used to answer `500` with nothing logged anywhere. The error now goes through the app's own `logger`, as Bun's `listen()` logs it, and the README says to copy a hook's request with `req.clone()`.
198
+ - 1b94593: `onRequest` hooks can return a `status()` result, typed by `OnRequestResult`, to refuse a request through the normal response path: the body is serialized with its content-type, and declared `responseHeaders` and `onResponse` hooks apply. A WebSocket handshake refused this way answers with the same status and body.
199
+ - b9608ac: `runReconciliationWorker` no longer releases its lease while handlers are still running. When one handler in a page failed, the worker rejected at once, so the lease was released while the other handlers kept going. A second worker could then take the lease and handle the same findings at the same time. On a failure the worker now starts no new handlers, waits for the running ones to settle, and only then releases the lease and rethrows the first error.
200
+ - b80ce77: `executeTool` no longer releases a tool's idempotency key once its executor has run. A store that throws while completing the call is now treated as one that refused it (`idempotency_capacity`), and an output validator that throws keeps the key reserved, so a retry cannot repeat a side effect that may have committed.
201
+ - d0a7d58: A GET carrying `Upgrade: websocket` to a path no WebSocket route takes now runs the `onRequest` hooks once. The handshake check ran them, found no socket route, and the HTTP route that answered ran them again, so a rate limiter or request counter in `onRequest` counted such a request twice. The HTTP route now runs with what the hooks returned during the check, including a rewritten request. This holds on Bun, Node and Deno.
202
+
203
+ ## 4.0.2
204
+
205
+ ## 4.0.1
206
+
207
+ ## 4.0.0
208
+
209
+ ### Minor Changes
210
+
211
+ - dde125b: feat(core): `bodyParser(schema, { types, parse })` from `@nifrajs/core/body-parser` lets one route
212
+ read a request body in a media type other than JSON, urlencoded, or multipart. `parse` is handed the
213
+ body's bytes and what it returns is validated by `schema`, so the handler sees the same typed body
214
+ whatever format it arrived in. JSON and urlencoded bodies still reach the schema through their own
215
+ lanes, and a route that does not opt in still answers `415`.
216
+
217
+ ```ts
218
+ import { bodyParser } from "@nifrajs/core/body-parser";
219
+ import { parse } from "yaml";
220
+
221
+ const utf8 = new TextDecoder("utf-8", { fatal: true });
222
+
223
+ app.post(
224
+ "/pipelines",
225
+ {
226
+ body: bodyParser(Pipeline, {
227
+ types: ["application/yaml"],
228
+ parse: (bytes) => parse(utf8.decode(bytes), { maxAliasCount: 0 }),
229
+ }),
230
+ },
231
+ (c) => c.body.name
232
+ );
233
+ ```
234
+
235
+ - The request's media type is matched in full against `types`: case folded, parameters set aside.
236
+ `parse` also receives `{ mediaType, contentType }`.
237
+ - The body is read under the route's `bodyLimit` before `parse` runs: `413 payload_too_large` over
238
+ it, `400 invalid_content_length` for a length that is not one.
239
+ - A `parse` that throws or rejects answers `400 invalid_body`.
240
+ - The decoded value must be a tree. An object, array, `Map` or `Set` reached twice, which is what an
241
+ alias or a cycle decodes to, answers `400 invalid_body` under every `protoPoisoning` setting.
242
+ - `protoPoisoning` applies to the decoded value: a `__proto__` key, a `constructor` that carries a
243
+ `prototype`, and a plain object whose prototype the decoder replaced are rejected or stripped.
244
+ Instances of a class, such as a `Date` or a `Uint8Array`, pass through as they are.
245
+ - `types` takes full `type/subtype` names and throws a `TypeError` for JSON, urlencoded, multipart,
246
+ and `text/plain`. The first three already have readers; `text/plain` is sent by a browser from any
247
+ site without a preflight.
248
+ - A decoder's own limits (nesting depth, alias expansion) are not covered: configure the decoder you
249
+ pass for untrusted input.
250
+
251
+ `bodyParser` and `multipartBody` compose in either order, and two `bodyParser` wraps each read their
252
+ own types; the outer one wins a type both name. `multipartBody` now hands a body that is not
253
+ multipart to a reader the schema already carried instead of answering `415` itself.
254
+
255
+ feat(schema): `toOpenAPI` and `toOpenAPIFromEvidence` list each media type a `bodyParser` schema
256
+ reads as its own entry under the operation's `requestBody.content`, in a fixed order, next to the
257
+ JSON or multipart entry. `reflectSchema` reports them as `mediaTypes`, and a project evidence
258
+ snapshot carries them, so a document built from a stored snapshot matches the live one.
259
+
260
+ - 3b090de: feat(core): a route schema takes `cookies`, validated before the handler like `headers` and `query`.
261
+
262
+ ```ts
263
+ import { server } from "@nifrajs/core/server";
264
+ import { t } from "@nifrajs/schema";
265
+
266
+ export const app = server().get(
267
+ "/dashboard",
268
+ {
269
+ cookies: t.cookies({
270
+ session: t.string(),
271
+ page: t.optional(t.integer()),
272
+ }),
273
+ },
274
+ (c) => ({ session: c.cookies.session, page: c.cookies.page ?? 1 })
275
+ );
276
+ ```
277
+
278
+ The schema checks the cookies parsed from the `Cookie` header (URL-decoded values, the first of a
279
+ repeated name) and its output types `c.cookies`. A request that fails it is a `422`, and
280
+ `onValidationError` receives the kind `"cookies"`; code that lists that parameter's kinds by hand
281
+ needs the new member. `defineContract` operations take `cookies` too. Route reflection, contract
282
+ snapshots and diffs, project evidence and the OpenAPI document carry the schema, and OpenAPI lists
283
+ each declared field as an `in: cookie` parameter.
284
+
285
+ `t.cookies` builds the schema for this slot. Like `t.query` it coerces declared number and boolean
286
+ fields from text and lets undeclared cookies through, since a browser sends every cookie the site
287
+ has set.
288
+
289
+ - 612a296: feat(core): a second copy of `@nifrajs/core` in one process is named when it loads. Two installed
290
+ copies (a linked sibling checkout, a nested install) keep separate request state, so a route from
291
+ one copy's `server()` merged into the other failed per request with an internal TypeError. The
292
+ second copy now prints `[nifra] @nifrajs/core is loaded 2 times` with the path of each copy, and
293
+ `.merge()` throws `merge() requires a server() from this copy of @nifrajs/core` at configuration
294
+ time for a server from another copy or any value that is not a `server()`. `mount()` crosses only
295
+ the fetch boundary and keeps working across copies. A dev server re-evaluating the same file is
296
+ one copy. `nifra check` remains the gate that fails the build on a duplicate install.
297
+ - 4af6f39: A group's `use()` of a plugin its parent server already applied throws a `RouteConfigError` with code `PLUGIN_RECONFIGURED` when it passes another instance under the same name - another `cors()` policy, a second `bearer()` verifier - where it used to skip it, so a group no longer appears to apply a configuration that never runs. Passing the parent's own instance is still a no-op, and a plugin the group applies still shares the parent's copy of a plugin it `use()`s itself. Plugins that name each instance, such as `rateLimit()`, `bodyLimit()`, `csrf()` and `securityHeaders()` with its own configuration, already apply inside a group and are unaffected.
298
+ - b53d64f: `schema.idempotency` takes `pendingTtlMs`, and `IdempotencyStore` an optional `renew()`. With a store that implements it, as `MemoryIdempotencyStore` does, a key whose handler is still running is held by a lease of `pendingTtlMs` (default 60 seconds, `DEFAULT_IDEMPOTENCY_PENDING_TTL_MS`) that the server renews every third of it until the response is stored, so a key reserved by a process that died frees after the lease instead of after `ttlMs`. `begin()` receives the lease as `pendingTtlMs`. A store without `renew()` keeps a running key reserved for `ttlMs` as before, and declaring `pendingTtlMs` on one is a registration error.
299
+ - 9c3d524: Route idempotency (`schema.idempotency`) releases a key when the handler answers 401, 403, 408, 409, 425, or 429 without an owned effect having begun, as it already did for a request refused before the handler. Each of those statuses says the request was not carried out, so a retry under the same key runs again, and callers who are refused no longer keep store entries for the full TTL.
300
+
301
+ A response whose body fails while it is being stored no longer leaves its key in progress until the TTL ends. With no owned effect begun the key is released; once one began, the key keeps a terminal 500 that a retry replays.
302
+
303
+ `MemoryIdempotencyStore` takes `maxEntriesPerNamespace`. Past it, only that namespace's new keys are refused, so one tenant or principal cannot use up `maxEntries` for the rest.
304
+
305
+ - 1b2d53a: `replacedRequestOf(request)` returns the request an `onRequest` hook replaced with this one. Response hooks receive the request the route ran with, so a middleware that keyed state on the request its `onRequest` saw can walk back to it. `idempotency()` uses this to keep its claim when a later hook such as `methodOverride()` rewrites the request: a retry replays the stored response instead of answering 409 and then running the handler a second time. `metrics()` uses it to count such requests and to decrement its in-flight gauge for them.
306
+ - 2e2d8c0: Tool idempotency gets the same lease as route idempotency. `ToolIdempotencyStore` takes an optional `renew()`, and `MemoryToolIdempotencyStore` implements it. With such a store, `executeTool` reserves the key with a lease of `pendingTtlMs` (default 60 seconds, or the tool's `idempotency.pendingTtlMs`) and renews it every third of that while the tool runs, so a key reserved by a process that died frees after the lease instead of after the store's `ttlMs`. A completed key is kept for `ttlMs` from completion. Stores without `renew()` keep a running key for their own TTL as before.
307
+ - 18aa5aa: A request body decoded by `transportCodecs()`, or a WebSocket frame decoded by a route's `transport`, may no longer hold a `RegExp` unless the lane opts in with `acceptRegExp: true`. A pattern from the client is code: one with catastrophic backtracking stalls the event loop the moment anything runs it. A refused body answers the same 400 as an undecodable one, and a refused frame goes to `onInvalidMessage`. Values the server sends are unaffected.
308
+ - ff5a779: A static response header a group declares replaces the value its enclosing scopes declared for that name, on the group's routes. A value the route sets itself still wins. `securityHeaders()` takes its configuration into its plugin name, so a group's `use(securityHeaders({ ... }))` with a different configuration applies over the app's on the group's routes, where it was skipped as a repeat; the same configuration applied twice is still a no-op.
309
+ - 10bc446: feat(core): `tls` serves HTTPS straight from the process, on Bun, Node and Deno.
310
+
311
+ ```ts
312
+ import { readFileSync } from "node:fs";
313
+
314
+ const tls = { cert: readFileSync("cert.pem"), key: readFileSync("key.pem") };
315
+
316
+ app.listen(443, { tls }); // Bun
317
+ await serve(app, { port: 443, tls }); // @nifrajs/node or @nifrajs/deno
318
+ ```
319
+
320
+ `cert` and `key` are PEM, as text or the files' bytes. Requests then arrive with `https:` URLs, and
321
+ WebSocket upgrades use the same port. Bun also takes `passphrase` for an encrypted key. On Node,
322
+ `tls` accepts any `node:tls` server option (`ca` and `requestCert` for client certificates,
323
+ `minVersion`, `SNICallback`), and the request protocol defaults to `https` when it is set. Without
324
+ `tls`, each runtime serves plain HTTP, which is what a proxy or platform that ends TLS expects.
325
+
326
+ - e8270d9: feat(web): SSR loader, action and boundary calls through `ctx.api` now carry the page request's
327
+ platform identity. A backend reached from a loader sees the visitor's `c.clientIp` (derived under the
328
+ page app's `server.clientIp` trust declaration), `c.env` and `c.waitUntil`, so per-visitor rate limits,
329
+ audit logs and bindings work during SSR. Headers are never copied: a loader call stays anonymous unless
330
+ the loader passes `cookie` or `authorization` itself. `inProcessClient()` / `testClient()` used outside a
331
+ render keep dispatching with no platform.
332
+
333
+ New seams in `@nifrajs/core/mount`: `NIFRA_BACKEND_BIND_PLATFORM` (returns a platform-bound view of an
334
+ in-process client) and `NIFRA_PLATFORM_CLIENT_IP_DERIVED` (marks a platform whose `clientIp` an enclosing
335
+ server already derived, so a backend with its own `clientIp` trust keeps it instead of re-reading
336
+ forwarding headers a synthesized request does not carry).
337
+
338
+ - ff4a062: feat(core): one handler can be registered under several methods, and under a method outside the
339
+ standard seven, with `all()` and `method()` from `@nifrajs/core/methods`:
340
+
341
+ ```ts
342
+ import { server } from "@nifrajs/core/server";
343
+ import { all, method } from "@nifrajs/core/methods";
344
+
345
+ const app = server()
346
+ // GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS /echo
347
+ .use(all("/echo", (c) => ({ method: c.req.method })))
348
+ .use(method("PURGE", "/cache/:key", (c) => ({ purged: c.params.key })))
349
+ .use(method(["GET", "POST"], "/search", (c) => ({ q: c.query.get("q") })));
350
+ ```
351
+
352
+ - Each method is an ordinary route: it is listed by `app.routes()`, takes a schema and hooks, works
353
+ inside `group()`, and throws `DUPLICATE_ROUTE` against a route already registered for the same
354
+ method and path. One call is one registration: if any of its routes is refused, none is added.
355
+ - `all()` is the seven standard methods, not a catch-all. A request with any other method is still a
356
+ `405` with an `Allow` header. `mount()` remains the way to pass every method through.
357
+ - A method name is case-insensitive and registered uppercase. It is a token of letters, digits and
358
+ hyphens that starts with a letter, at most 32 characters. `TRACE`, `CONNECT` and `TRACK` cannot be
359
+ registered; those and any other value throw `INVALID_METHOD`.
360
+ - The standard methods in a call join the typed registry and the typed client. A custom method has
361
+ no typed-client call.
362
+ - An assurance policy's `methods` selector takes standard methods only, so it never matches a
363
+ custom-method route. Classify such a route with a path rule; unmatched, it is reported as
364
+ `unclassified-route`.
365
+ - Whether a custom method reaches the app is up to the runtime's HTTP parser: `PROPFIND`, `REPORT`,
366
+ `PURGE` and `QUERY` arrive on Bun, Node, Deno and workerd, and a token the parser does not know
367
+ can be answered by the runtime itself.
368
+ - `Router.add` from `@nifrajs/core/router` accepts the same method tokens, and
369
+ `isRegistrableMethod(name)` from that subpath reports whether a name is one.
370
+ `RouteDescriptor.method` is typed `RouteMethod`: a standard `Method` or another such token.
371
+
372
+ feat(schema): `toOpenAPI` leaves a custom-method route out of the document. A path item has a field
373
+ for each standard method and none for any other.
374
+
375
+ feat(cli): `nifra check` reads the routes `all()` and `method()` register, so the duplicate,
376
+ overlap, reserved-segment and param-modifier rules cover them, reported once per call. The route
377
+ brief and `--json` output list a custom-method route with a `fetch` call. The capability report
378
+ attributes a module to every path an optional-param route serves, and to `all()` and `method()`
379
+ routes.
380
+
381
+ - 7bfa25e: feat(schema): a route body can be a `multipart/form-data` form with file fields. `@nifrajs/schema/form`
382
+ exports a `t` that is the `@nifrajs/schema` builder plus `t.file` and `t.form`; text fields and files
383
+ are validated before the handler runs and typed in `c.body`.
384
+
385
+ ```ts
386
+ import { t } from "@nifrajs/schema/form";
387
+
388
+ app.post(
389
+ "/avatars",
390
+ {
391
+ body: t.form({
392
+ avatar: t.file({
393
+ maxBytes: 5_000_000,
394
+ accept: ["image/png", "image/jpeg"],
395
+ }),
396
+ caption: t.optional(t.string({ maxLength: 200 })),
397
+ }),
398
+ bodyLimit: 6_000_000,
399
+ },
400
+ (c) => ({ bytes: c.body.avatar.size })
401
+ );
402
+ ```
403
+
404
+ `@nifrajs/schema`
405
+
406
+ - `t.file({ maxBytes?, accept? })` validates a `File`. The size is checked before a byte is read.
407
+ `accept` takes exact types and `type/*` wildcards and is matched against the file's leading bytes,
408
+ never the type the client claimed; the validated file then carries the detected type. An `accept`
409
+ entry no signature can prove (`text/csv`, `image/svg+xml`, `*/*`) throws when the schema is built.
410
+ - `t.form(fields, options?)` is a route `body` as is. Text fields are coerced from their string form,
411
+ a repeated field is a list, and an undeclared field fails validation unless `additionalProperties`
412
+ is set. `options` also takes the body limits below.
413
+ - `t.array(file)` and `t.optional(file)` work with either `t`. `t.object`, `t.looseObject`, `t.query`,
414
+ `t.union`, `t.record`, and `t.paginated` throw when handed a file field.
415
+ - A zero-byte file part, or an empty text part in a file field, counts as no file. A required list
416
+ field with no entries is `[]`.
417
+ - A form with no required file also accepts `application/json` and
418
+ `application/x-www-form-urlencoded` bodies.
419
+ - `toOpenAPI` emits a body with a file field as `multipart/form-data` unless `requestContentType`
420
+ says otherwise.
421
+ - `@nifrajs/schema` now depends on `@nifrajs/uploads`.
422
+
423
+ `@nifrajs/core`
424
+
425
+ - `@nifrajs/core/multipart` exports `multipartBody(schema, limits?)`, which marks any Standard Schema
426
+ as a form body, and the `MultipartLimits` and `MultipartValue` types. The route reads the form under
427
+ its `bodyLimit` and hands the schema a record of strings and `File`s; a repeated name is an array.
428
+ - Limits: `maxFields` (default 100), `maxFiles` (10), `maxFieldBytes` (65536), `maxFileBytes`
429
+ (unbounded below `bodyLimit`).
430
+ - Responses: `415 unsupported_media_type` for another content type, `400 invalid_multipart` for a
431
+ body that is not a well-formed form or ends before its closing delimiter, and `413` with
432
+ `payload_too_large`, `too_many_parts`, `too_many_fields`, `too_many_files`, `field_too_large`, or
433
+ `file_too_large`.
434
+ - File names have path separators and control characters removed. Field names follow the server's
435
+ `protoPoisoning` policy.
436
+
437
+ `@nifrajs/uploads`
438
+
439
+ - `DETECTABLE_MIME_TYPES` and `FILE_TYPE_PREFIX_BYTES` are exported, and `@nifrajs/uploads/detect`
440
+ exports them with `detectFileType` and `FileType` on their own.
441
+
442
+ `@nifrajs/client`
443
+
444
+ - A body that holds a `File` or `Blob` is sent as `multipart/form-data`. An array value is sent as a
445
+ repeated field, `null` and `undefined` values are left out, and a caller-set `content-type` is
446
+ dropped so the generated boundary is used. A `FormData` body is sent as is. A nested object or list
447
+ cannot be a form field.
448
+ - `inProcessClient` and `testClient` send a `FormData` body with its `content-length`.
449
+
450
+ `@nifrajs/edge`
451
+
452
+ - A route whose body is a `t.form` or a `multipartBody` schema reads the form, with the same limits
453
+ and responses as the full server.
454
+
455
+ - 4a03d30: feat(core): `notFound(handler)` from `@nifrajs/core/not-found` answers a request no route matched,
456
+ in place of the default `404` body. Apply it with `use()`:
457
+
458
+ ```ts
459
+ import { notFound } from "@nifrajs/core/not-found";
460
+
461
+ app.use(
462
+ notFound(({ pathname, header }) => {
463
+ if (header("accept")?.includes("text/html")) {
464
+ return new Response("<h1>Nothing here</h1>", {
465
+ headers: { "content-type": "text/html; charset=utf-8" },
466
+ });
467
+ }
468
+ return undefined; // the default { ok: false, error: "not_found" }
469
+ })
470
+ );
471
+ ```
472
+
473
+ - It answers a `404` only. A path that exists under another method is still a `405` with `Allow`, a
474
+ malformed path parameter is still a `400`, and a `404` a route or a mounted app returned is left
475
+ alone.
476
+ - A `2xx` answer is sent as a `404`, keeping its body and headers. A `3xx`, `4xx` or `5xx` answer is
477
+ sent unchanged. `undefined` keeps the default body. A thrown `Response` is an answer.
478
+ - The handler is given `method`, `url`, `pathname` (as sent, not percent-decoded), `headers`,
479
+ `header(name)`, `signal` and `platform`. It is never given the request body.
480
+ - A throw, a rejection, or a returned value that is not a `Response` is logged once, honouring
481
+ `errorLogDetail`, and answered with the plain `500` `internal_error` body.
482
+ - With `requestTimeoutMs` set, an async handler that outlives it has `signal` aborted and the request
483
+ answered `503`. A deadline header on the request is not consulted for a request no route matched.
484
+ - The answer takes the normal response path: fixed response headers and `onResponse` hooks apply.
485
+ - One handler per server. A second `notFound()` throws, as does one applied inside a `group()` or
486
+ after `listen()`. `merge()` does not carry a merged server's handler across.
487
+
488
+ To serve unmatched paths - a single-page app's shell, another app behind this one - register a
489
+ wildcard route or a mount: those are matches, with their own status, the request body, and the full
490
+ route lifecycle.
491
+
492
+ feat(edge): `notFound(handler)` from `@nifrajs/edge` builds the same handler for the compact server,
493
+ passed as an option: `server({ notFound: notFound(handler) })`. The rules are the ones above, shared
494
+ with the full server. There is no logger and no request timeout on the compact server, so a fault is
495
+ the plain `500` and the handler bounds its own I/O. An app that does not import `notFound` ships
496
+ none of it.
497
+
498
+ - 8e30090: feat: a mount nifra cannot analyze is a declared known gap, not a silent hole. Routes behind
499
+ `mount()` and `mountFetch()` are invisible to route reflection, so capability assurance could not
500
+ see them and said nothing. Both now take `opaque: "<reason>"`, and so does a `createWebApp`
501
+ `mounts` entry. Capability assurance lists a mount with a reason as a known gap
502
+ (`report.gaps`, `{ kind: "opaque-mount", path, reason }`): `nifra check` and
503
+ `nifra capabilities check` print it on every run, and it fails nothing and lowers no level. A mount
504
+ on the analyzed app without a reason fails with the new `opaque-mount-undeclared` finding, which
505
+ suggests `merge()` for a nifra `server()` and `opaque` for anything else. A mount whose app publishes
506
+ composed evidence (the API `createWebApp` mounts) is not reported. `webProjectEvidence` accepts a
507
+ `mounts` entry without an evidence provider when it declares `opaque`. New exports:
508
+ `reflectMounts` and `ReflectedMount` from `@nifrajs/core/reflection`, `CapabilityGap` from
509
+ `@nifrajs/core/capabilities`; project evidence snapshots gain an optional `mounts` list, absent for
510
+ an app with no mounts.
511
+ - 28f3aaf: feat(core): a path can end in optional params, written `:name?`. The route serves the path with the
512
+ param and without it:
513
+
514
+ ```ts
515
+ app
516
+ // GET /users and GET /users/42
517
+ .get("/users/:id?", (c) =>
518
+ c.params.id === undefined ? { all: true } : { id: c.params.id }
519
+ )
520
+ // GET /archive, GET /archive/2026 and GET /archive/2026/09
521
+ .get("/archive/:year?/:month?", (c) => ({
522
+ year: c.params.year,
523
+ month: c.params.month,
524
+ }));
525
+ ```
526
+
527
+ - Optional params are whole segments at the end of the path. Several in a row are filled left to
528
+ right: a later one is present only when every earlier one is. A `?` anywhere else (`/a/:id?/b`,
529
+ `/v-:id?`) is literal text, as before.
530
+ - An optional param is typed `string | undefined`. When the path omits it, it is absent from
531
+ `c.params`, not an empty string. A `params` schema that requires it answers `422` on the shorter
532
+ path.
533
+ - The route is registered once per path it serves, with the same handler, schema and hooks.
534
+ `app.routes()`, `group()`, `merge()`, `implement()`, `ws()` and the typed registry all see those
535
+ paths, so `GET /users` registered next to `GET /users/:id?` throws `DUPLICATE_ROUTE`. A route that
536
+ is rejected leaves none of its HTTP paths registered.
537
+ - The typed client calls each path: `api.users.get()` and `api.users({ id }).get()`.
538
+ - `defineContract` accepts the same paths; two operations that serve the same method and path are
539
+ refused.
540
+ - `expandOptionalParams(pattern)` from `@nifrajs/core/pattern` returns the paths a pattern serves,
541
+ shortest first. `Router.add` from `@nifrajs/core/router` registers exactly the pattern it is given;
542
+ a caller that wants the optional form adds each expanded pattern.
543
+ - `routePatternOverlap` compares every path each side serves, under one work budget for the call.
544
+
545
+ feat(edge): the compact server accepts the same optional params.
546
+
547
+ feat(schema): `toOpenAPI` emits one operation per path an optional-param route serves. A shorter
548
+ path declares only the parameters it has. For a contract, the operation name is the `operationId` of
549
+ the full path, and the shorter paths carry none.
550
+
551
+ feat(cli): `nifra check` reports `NF-C026` (warning) for a param followed by `?`, `*`, `+`, `{`, `(`
552
+ or `<` that the path grammar reads as literal text, and names a `?` route that no request can reach.
553
+ `// nifra-expect param-modifier` above the registration marks a deliberate literal. Overlap and
554
+ duplicate checks read an optional-param route as every path it serves.
555
+
556
+ - 6d20355: feat(core): a path param can say which values it accepts, written in braces after the name. A
557
+ request whose value does not fit is not served by that route:
558
+
559
+ ```ts
560
+ app
561
+ // /users/me is its own route; /users/42 is this one; /users/ada is a 404
562
+ .get("/users/me", () => ({ me: true }))
563
+ .get("/users/:id{[0-9]+}", (c) => ({ id: Number(c.params.id) }))
564
+ // a list of values, and a count
565
+ .get("/img/:size{thumb|full}/:file", (c) => ({
566
+ size: c.params.size,
567
+ file: c.params.file,
568
+ }))
569
+ .get("/countries/:code{[A-Z]{2}}", (c) => ({ code: c.params.code }))
570
+ // inside a segment, and optional at the end of a path
571
+ .get("/files/:name.:ext{png|jpg}", (c) => ({
572
+ name: c.params.name,
573
+ ext: c.params.ext,
574
+ }))
575
+ .get("/posts/:page{[0-9]+}?", (c) => ({ page: c.params.page ?? "1" }));
576
+ ```
577
+
578
+ - A constraint is one character class with an optional count (`[0-9]`, `[a-z0-9_-]+`, `\d{4}`,
579
+ `\w{2,8}`), or a list of two or more values (`png|jpg|webp`). A class holds letters, digits, ranges
580
+ of them, `\d`, `\w` and `. _ ~ ! $ & ' ( ) + , ; = @ -`; there is no negated class and a count
581
+ starts at one. Anything else in braces (`:id{int}`, `:id{.+}`, `:id{[0-9]+|[a-z]+}`) is literal
582
+ text, as before.
583
+ - The param stays a `string`, keyed by its bare name: `Params<"/users/:id{[0-9]+}">` is
584
+ `{ id: string }`.
585
+ - The value is checked as it was sent, before percent-decoding. `/users/4%32` does not fit
586
+ `:id{[0-9]+}`; a broader route beside it serves that request.
587
+ - The narrowest route answers, whatever the order of registration: literal text, then a list, then a
588
+ class, then a bare `:param`, then a wildcard. Between two constraints of a kind, the one that
589
+ accepts fewer values is tried first. A method the narrowest matching route does not have answers
590
+ `405`, as it does for a literal route beside a param route.
591
+ - Two spellings of one constraint (`[0-9]+`, `\d+`, `[0-9]{1,}`) are one route: the same method
592
+ registered on both throws `DUPLICATE_ROUTE`.
593
+ - Inside a segment the text around the params is placed first and each value is then checked; the
594
+ router does not look for another split.
595
+ - `routePatternOverlap` takes constraints into account: `/users/me` and `/users/:id{[0-9]+}` do not
596
+ overlap.
597
+ - `@nifrajs/core/pattern` exports `paramConstraint(text)`, which reads a constraint at the start of
598
+ `text`, and the `ParamConstraint` type. A param part of a compiled mixed segment carries its
599
+ constraint as `c`.
600
+
601
+ feat(edge): the compact server accepts the same constraints.
602
+
603
+ feat(schema): `toOpenAPI` writes a constrained param into the path template by its bare name
604
+ (`/users/{id}`). Its schema is `{ type: "string", pattern }` for a character class and
605
+ `{ type: "string", enum }` for a list of values; a declared `params` schema still takes precedence.
606
+
607
+ feat(client): a constrained param is passed by its bare name, `api.users({ id: "42" }).get()`. Two
608
+ param routes at one position (`/users/:id{[0-9]+}` beside `/users/:slug`, or a param beside a
609
+ wildcard) are each callable, picked by the name of the key. The client does not check a value
610
+ against its constraint.
611
+
612
+ feat(cli): `nifra check` accepts a supported constraint and keeps reporting other text in braces
613
+ (`NF-C026`); `NF-C024` and `NF-C025` follow the router's reading of a constraint. `nifra routes` and
614
+ the generated client calls print the bare name. `nifra scaffold` refuses a page path that carries a
615
+ constraint.
616
+
617
+ feat(testing): `runAdversarialContract` builds a request path whose values satisfy each param's
618
+ constraint, and fills a part-literal segment (`/files/:name.json`) param by param.
619
+
620
+ feat(web): a route file name that would read as a param constraint (`[id]{a|b}.tsx`) is refused at
621
+ build time with a message that names the file. `llms.txt` prints client calls with the bare name.
622
+
623
+ - b64c3ee: fix(web): the identity preflight behind `nifra check`, `nifra doctor` and the build now scans every
624
+ package declared in `"nifra": { "singleCopy": [...] }`, not only the built-in identity-sensitive set.
625
+ A declared package installed at two versions is a fatal `version-skew` finding with the same
626
+ remediation as a framework skew (align the ranges; nifra never redirects across versions), and one
627
+ version at two paths is reported as deduplicated.
628
+
629
+ feat(core): `@nifrajs/core/single-copy/register` no longer skips silently. Each declared package it
630
+ cannot collapse - a version skew, or a linked file with no counterpart in the app's copy - prints one
631
+ warning per process naming both copies, both versions and the reason. Strict mode turns that into a
632
+ startup failure: declare `"singleCopy": { "packages": [...], "strict": true }` or set
633
+ `NIFRA_SINGLE_COPY_STRICT=1`. `SingleCopySkip` gains optional `to`, `fromVersion` and `toVersion`;
634
+ `SingleCopyOptions` gains `strict` and `onSkip`; new exports `readSingleCopyStrict` and
635
+ `SINGLE_COPY_STRICT_ENV`.
636
+
637
+ - 81c720e: fix(client): a segment that is part literal, part param is callable through the typed client
638
+
639
+ A route such as `/files/:name.json`, `/post-:id` or `/v:major.:minor` could not be reached through
640
+ the typed client: `:name.json` was typed as a param named `name.json` whose call sent the value
641
+ without `.json`, and `post-:id` was typed as a property that sent the pattern text itself. Such a
642
+ segment is now a call with the segment as the request carries it:
643
+
644
+ ```ts
645
+ await api.files("report.json").get(); // GET /files/:name.json, c.params.name === "report"
646
+ await api("post-42").get(); // GET /post-:id
647
+ await api("v1.2").get(); // GET /v:major.:minor
648
+ ```
649
+
650
+ - The argument is typed as the segment's literal text around any string (`` `${string}.json` ``), so
651
+ `api.files("report.txt")` does not compile. A constraint is not part of the text:
652
+ `/img/:id{[0-9]+}.png` takes `` `${string}.png` ``, and the server decides whether the value fits.
653
+ - The value is sent as one encoded segment, as a param value is: a `/` in it never adds a path level.
654
+ - A static segment at the same position keeps its exact text (`api.files("index.json")` is
655
+ `/files/index.json` when that route exists, which is the route the server picks), and a
656
+ whole-segment param keeps its call by name (`api.files({ id })`).
657
+ - Two such segments at one position that accept the same text resolve to the types of the one
658
+ registered first.
659
+ - `@nifrajs/core` exports `RequestPath<Path>`, the same reading for a whole path:
660
+ `RequestPath<"/files/:name.json">` is `` `/files/${string}.json` ``, a constraint reads as
661
+ `${string}`, and a path ending in optional params is one template per form.
662
+ - The cli's route listings and the generated `llms.txt` print the call the same way
663
+ (`` api.files(`${name}.json`) ``), and print an unnamed wildcard as `({ "*": rest })`.
664
+
665
+ - ed60b23: feat(core): `app.group(prefix, build)` declares routes under a static path prefix. The prefix is part
666
+ of every route's served path, so the typed client (`api.admin.users.get()`), `routes()`, OpenAPI,
667
+ capability events, the effect ledger, and idempotency keys all see `/admin/users`. `JoinRoutePath`
668
+ and `PrefixRegistry` are exported for the registry types.
669
+
670
+ - The builder inherits the parent's chain as it stands at the call (`derive`, `decorate`,
671
+ `authenticate`, `beforeHandle`, `afterHandle`, `onError`, `around`, assurance, installed runtimes).
672
+ Middleware the builder adds covers only the group's routes.
673
+ - The group's `onRequest`, `onResponse`, `responseHeaders`, and `onResponseFinalized` hooks run only
674
+ for requests whose path is the prefix or under it. That includes that path's 404 and 405 responses.
675
+ - Groups nest, and a group's `"/"` route serves the prefix itself.
676
+ - A prefix must be static text. Params, wildcards, percent-escapes, empty or dot segments, and a
677
+ trailing slash are refused at registration.
678
+ - The builder must synchronously return the group it was given. The group is closed once the builder
679
+ returns or throws.
680
+ - A collision with an existing route throws `RouteConfigError`, and none of the group's routes are
681
+ added.
682
+ - `.ws()`, mounts, MCP tools, and `merge()` are refused inside a group.
683
+
684
+ `merge()` now also carries the merged server's `onStop` hooks.
685
+
686
+ - a158b74: feat(web): a page file under a mount fails at startup, at build and in `nifra check`, instead of
687
+ answering with the mount's 404. A mount in front of the page router - the backend at `apiPrefix`, a
688
+ `mounts` entry, or an `app.mount()` inside `use` - answers every request under its path, and its 404
689
+ is final (`fallbackOn: 404` only tries the next mount), so a page there could never render.
690
+ `createWebApp` now throws at startup, naming each file, the URL it serves and the mount; `nifra build`
691
+ refuses to build; and `nifra check` reports `NF-C027` for a page under the backend's prefix, reading
692
+ `apiPrefix` when `backend/framework.ts` exports it as a string literal (an `info` finding says so when it does
693
+ not).
694
+
695
+ `backend/framework.ts` can export `apiPrefix`, `apiStrip`, `mounts`, `csp` and `nonce`. `nifra dev`,
696
+ `nifra build` (the generated server entry and the static prerender), `nifra mcp`'s render tool and the
697
+ hydration gate pass the same set to `createWebApp`, and the render tool and the hydration gate now
698
+ apply `use` as well. A field of the wrong type fails at load, naming it, and a value
699
+ `nifra.config.ts` exports must be the one `backend/framework.ts` exports, or the build stops. `nifra routes`
700
+ lists the backend under the configured prefix, at the served path when `apiStrip` is set.
701
+
702
+ New exports: `preRouteMountPaths` from `@nifrajs/core/mount`; `shadowedPages`,
703
+ `formatShadowedPages`, `normalizeMountPath` and `ShadowedPage` from `@nifrajs/web/route-manifest`;
704
+ `SERVER_ENTRY_OPTIONS` and the `optionImports` option of `generateServerEntry` and `buildTarget` from
705
+ `@nifrajs/web/build`.
706
+
707
+ Upgrading: an app with a page file under its backend prefix (`routes/api/*` with a backend)
708
+ started before and answered that page with a 404; it now fails to start until the file moves out of
709
+ the prefix or the prefix changes.
710
+
711
+ ### Patch Changes
712
+
713
+ - 72b62fa: fix(core): on Bun, `listen()` picks the same route for a request as `app.fetch()` does, and as every
714
+ other runtime does. Three cases:
715
+
716
+ - A path segment that mixes literal text and a parameter matches only what it is written to match.
717
+ `/files/:name.json` serves `/files/a.json` with `params.name` of `"a"`, and answers `/files/a` with
718
+ `404`.
719
+ - When the most specific route for a path has no handler for the request's method, the answer is
720
+ `405` with an `Allow` header. With `POST /users/me` and `GET /users/:id`, `GET /users/me` is `405`.
721
+ - A route that is more specific early in the path takes its requests ahead of a broader one. With
722
+ `GET /admin/*rest` and `GET /:section/:page`, `GET /admin/users` is served by `/admin/*rest`.
723
+
724
+ A route none of these touch is still served from Bun's own route table.
725
+
726
+ - aa44e93: fix(core): on Bun, `listen()` starts a server that mounts another nifra app with no WebSocket routes.
727
+ Every mounted nifra app counted as a WebSocket mount, so `listen()` required the `websocket()` runtime
728
+ and threw `INVALID_WS_RUNTIME`. `createWebApp({ api })` mounts its backend this way, so a full-stack
729
+ app with an `api` backend could not start on Bun.
730
+
731
+ A mounted app now takes part in Bun's WebSocket wiring only when it has a WebSocket route, directly or
732
+ in an app it mounts. `listen()` asks when it runs, so a runtime or route added to the child after it
733
+ was mounted is served, and an app mounted under itself to alias a prefix still starts. A mounted
734
+ object that exposes `resolveWebSocketUpgrade` without naming a runtime still needs `websocket()` on
735
+ the parent.
736
+
737
+ - 4a3ee60: fix(core): on Bun, `c.clientIp` is the socket peer on every route `listen()` serves
738
+
739
+ A static or `:param` route answered `undefined` for `c.clientIp` under `listen()` on Bun, while a
740
+ wildcard route, and any route of an app with a request hook, answered the socket peer. Every route
741
+ now answers the peer, as the docs describe. The address is looked up when a handler reads it, so a
742
+ request whose handler never reads it costs no lookup, and a second read returns the first answer.
743
+
744
+ - aad6297: An assurance rule whose selector can match no route is refused when the policy is defined: an empty `methods` or `paths` list, a `capabilities` entry that is not a valid capability id, and, in `defineAssuranceConfig` with a capability policy, a selected capability that policy does not define. Each made the rule inert, so its routes fell through to the next, laxer rule.
745
+ - dad0d41: On a route with a body schema that runs auth before validation, direct `c.req` body reads made by its derive and `beforeHandle` hooks are capped by the route's `bodyLimit`, as they are on a route without a body schema.
746
+ - 538adc2: Capability assurance treats `OPTIONS` as a safe method, as it already treated `GET` and `HEAD`: an `OPTIONS` route that declares a domain write is reported as `safe-method-domain-write`, and one that only reaches it as `unconfined-write-reach`.
747
+ - f47edd1: `aroundCapability`: a `next()` called after its interceptor returned or timed out no longer runs the interceptors after it. It returns a promise rejected with `CapabilityInterceptorProtocolError`, the error a second `next()` call already raises.
748
+ - df9530a: A length-less request body over its cap is answered with 413 promptly when the body was cloned first: through the transport codec lane, an idempotency `namespace` resolver, or a `c.req.clone()` read on a capped route.
749
+ - 3e6973f: The route manifest, project evidence snapshot, capability lockfile, and single-copy report sort by code unit instead of `localeCompare`, so their bytes and the manifest's `contentHash` no longer depend on the runtime's locale or ICU data. A manifest whose routes include paths that the two orders rank differently (mixed case, `-` beside `_`, non-ASCII) gets a new route order and `contentHash` once when it is rebuilt.
750
+ - 25e8edf: A request body decoded by `transportCodecs()` before routing is still held to the matched route's `bodyLimit` (and the server's body cap): one larger than the limit answers 413 like a JSON body would.
751
+ - 2b5e5fc: `responseContract()` holds a `c.json(...)` reply to the route's declared schema, as it does a returned value: `"enforce"` re-serializes the validated value with the reply's status and headers, and `"warn"` reports undeclared fields. In `"warn"` mode a `status(...)` result is now served with its own status and headers.
752
+ - da7d792: `diffRouteSnapshots`, and the `nifra diff`, contract proof, and manifest diffs built on it, compare the object schema around a section's fields as well as the fields. A change to `additionalProperties`, to the `$defs` a field's `$ref` points into, or to a required name that no field declares is reported as breaking, so a diff against an existing baseline may list changes it did not list before.
753
+ - fb14dfa: `DurableObjectRecordBackend.scan` returns every matching record when a reconciliation walk follows its cursor. A page that filled up now resumes after its own last record, where the cursor skipped the next matching record at each page boundary. `MemoryDurableRecordBackend.scan` sorts ids in the same code-unit order its cursor compares with, so mixed-case ids are no longer skipped either.
754
+ - 8ae97f6: The effect ledger, `useCapability`, `executeCapability`, effect lifecycle events, and the durable effect journal take a token field (`target`, `effectId`, `digest`, a capability id, an error code) only when it is a string of the token's shape. A row, a number, `null`, or an array in one of those fields is refused instead of stored, and a durable store record that holds one is refused when it is read.
755
+ - ca8b50d: On the Node direct lane, the header view a response hook receives answers only for real headers: `has("constructor")` is `false` and `get("constructor")` is `null`, as on a Web `Headers`, where it previously reported an inherited property or threw.
756
+ - b00a889: A body that a `derive` or `beforeHandle` hook reads through `c.req` stays readable for the framework readers that come after it. An auth-first route's body schema (`validationOrder: "auth-before-validation"`) and `c.boundedBody()` / `c.boundedJson()` now parse the bytes the hook read, for framed and chunked bodies on every adapter, where they answered 500.
757
+ - 66fd712: On an idempotent route, a request rejected before its handler runs (by an auth stage, validation, or a guard) no longer stores a result under its `Idempotency-Key`: the key is released and can be retried. Rejected requests therefore no longer fill the idempotency store.
758
+ - 738e7a1: `listen()` answers a request whose app-wide `onRequest`/`onResponse` hook throws with the same JSON `500 internal_error` a throwing route gets, and logs it through the app's logger. Bun's development error page, which shows the message, stack and source whenever `NODE_ENV` is not `production`, is no longer served.
759
+ - 4801cac: `diffNifraManifests` and `nifra manifest diff` diff a route that is new in the candidate manifest against an empty route. The capabilities it declares are reported as added and breaking, and a `pii` or `secret` response classification as an increase that is breaking, the same as when an existing route makes that change. A new route that declares no capability and returns public data is still only an added route.
760
+ - 25fe13d: A saga store refuses a `compareAndSet` whose `record.sagaId` differs from the `sagaId` it is written under, on `MemorySagaStore` and every durable execution adapter. `runDurableExecutionAdapterConformance` now checks that an adapter refuses it.
761
+ - 0852290: `sse()`'s `stream.signal` aborts when the runtime cancels the response body as well as when the request signal fires, so a producer looping on `stream.signal.aborted` stops once the client is gone on every runtime.
762
+ - 0589dbe: In development, when a hook reads the request body as a stream through `c.req.body`, the body readers after it - a body schema, `c.boundedJson()`, the handler's `c.req.json()` - fail with an error that says the body was read as a stream and to read it with `c.req.bytes()` in the hook, which later readers replay. Before, they failed on a locked stream or reported the body as invalid JSON. A production `NODE_ENV` build carries none of this check.
763
+ - 856f5ce: A request body or WebSocket frame decoded by a transport codec must be a tree. A value containing a cycle is answered as an invalid payload, and shared references may add at most 100,000 nodes to the tree they expand to. The `protoPoisoning` policy also applies to the members of a decoded `Map` or `Set`.
764
+ - cfd86b3: A 422 validation response lists at most the first 100 issues, and `t` schemas stop collecting issues at 100, so a large invalid body cannot produce a response many times its own size.
765
+ - 8ff96c9: `toFetchHandler` on Workers enforces `wsMaxPayloadBytes` on inbound WebSocket frames, as the Bun, Node, Deno and hub lanes do: a larger frame closes the socket with 1009.
766
+ - 4c46199: A WebSocket handshake resolves the `clientIp` trust declaration the way an HTTP request does, so `onRequest` hooks such as `ipRestriction()` and a route's `upgrade()` see the derived caller rather than the proxy's socket address.
767
+ - eef4932: A WebSocket route with a `messageSchema` parses each frame under the app's `protoPoisoning` policy, as a JSON body is parsed. Under the default `"reject"`, a frame with a `__proto__` key goes to `onInvalidMessage` as invalid JSON. `wrapWebSocketMessageValidation` takes the policy as an optional second argument.
768
+ - 6de8686: Only a GET request is treated as a WebSocket handshake. A POST or PUT that carries `Upgrade: websocket` reaches its HTTP route instead of the socket's `upgrade()` guard and a 426.
769
+ - d7892ea: fix(core): a request method is matched exactly. Method tokens are case-sensitive, so a request sent
770
+ as `post` or `Patch` does not reach a `POST` or `PATCH` route: it answers `405` with `Allow` when the
771
+ path has routes and `404` when it has none. `Router.find` from `@nifrajs/core/router` follows the
772
+ same rule.
773
+
774
+ A token no route can be registered under - anything other than uppercase letters, digits and
775
+ hyphens, a letter first, at most 32 characters - is answered by the route table alone. `onRequest`
776
+ hooks, handlers mounted with `mount` or `mountFetch`, and the WebSocket upgrade lane do not run for
777
+ it, so code that tests `request.method` never reads a token it would not recognise.
778
+
779
+ This is visible on a runtime that hands the token over as sent, such as Deno. Bun and Node refuse
780
+ such a request before it reaches the app.
781
+
782
+ - 4936309: perf(core,node): close the realistic-route Node gap to Fastify
783
+
784
+ The fused derive-before(-after) lifecycle lanes only had Web (`Response`-building)
785
+ renderers, so on the Node-direct lane a derive+before route fell back to the generic
786
+ route program while Bun rode the single closure. The same lanes now build a generic
787
+ (finalize/wrapResponse-parameterized) fused runner that the Node dispatcher prefers
788
+ exactly as it prefers the body-only runner - validate → derive → before → handler →
789
+ (after) in one frame, finalizing through the caller's own outcome renderer. Semantics
790
+ are stage-for-stage identical to the generic program (pinned by a Web-vs-Node-direct
791
+ parity suite); Bun's fused Web lanes are untouched.
792
+
793
+ `@nifrajs/node`'s JSON writer now emits lowercase `content-type`/`content-length`,
794
+ matching the other Node writer paths and the Web runtimes. Proven lowercase,
795
+ mutable records stay in place and receive framing headers without the old
796
+ rename-and-delete dictionary-mode transition; mixed-case, frozen, and explicitly
797
+ framed records keep the isolated normalization fallback.
798
+
799
+ Realistic-shape bench (oha, auth + security headers + CORS + request-id + cookie,
800
+ Node): GET 89% → 96% of Fastify, POST 94% → ~98%, body-hash 92% → 96% (each now at
801
+ or above the raw-`node:http` ceiling).
802
+ The Node adapter's parser-error drain guard keeps the same close-after-active-responses contract with a per-socket active counter and shared response-finish/close release listener, removing the per-request response Set while retaining close-only abort cleanup.
803
+
804
+ - bbdc5a1: fix(core, web): JSON request bodies are matched by media type
805
+
806
+ A body is parsed as JSON when its `Content-Type` media type is `application/json` or an
807
+ `application/*+json` type. A header that only mentions `application/json` in a parameter, such as
808
+ `text/plain; x=application/json`, is no longer parsed as JSON; browsers send that type cross-origin
809
+ without a preflight. Server functions apply the same comparison and answer such requests with 415.
810
+
811
+ - 43ba944: fix(core): `c.params` is typed by the names the router captures for a segment that mixes literal
812
+ text and parameters. `/files/:name.json` types `c.params.name`, `/v:major.:minor` types `major` and
813
+ `minor`, and `/b/:bucket.s3/*key` types `bucket` and `key`; each was previously typed as one
814
+ parameter named after the rest of the segment (`"name.json"`), so reading the real name did not
815
+ compile. A colon that is literal text, as in `/v1/things:batchGet`, no longer types a parameter.
816
+ Paths whose parameters are whole segments are typed as before.
817
+ - 46c741a: fix(core): a path segment that mixes literal text and parameters - `/:name.json`, `/v:major.:minor`,
818
+ `/:a-:b-:c.json` - is matched in one pass over the segment. The time a lookup takes grows with the
819
+ length of the segment and no longer with the number of parameters in it, for a hit and for a miss
820
+ alike. This holds for the server's router, `Router.find` from `@nifrajs/core/router`, and
821
+ `matchRoutePattern` from `@nifrajs/core/pattern`.
822
+
823
+ Which paths match and what each parameter captures are unchanged: a literal between two parameters
824
+ is taken at its first occurrence, a trailing literal ends the segment, and every parameter is at
825
+ least one character.
826
+
827
+ `@nifrajs/core/pattern` also exports the matcher itself: `mixedSegmentShape(parts)` lays a mixed
828
+ segment out once, and `matchMixedSegment(shape, segment, out)` appends the captures to `out` and
829
+ answers whether the segment matched.
830
+
831
+ - 4936309: perf(core): add opt-in Node-direct twin for `onResponseBody` hooks
832
+
833
+ `onResponseBody(fn)` took the portable hook everywhere: on the Node-direct lane it
834
+ ran through the shared native wrapper, allocating a header view per request even for
835
+ twins that only read the bytes and set a header. `onResponseBody(fn, node)` accepts
836
+ an optional `NodeResponseBodyHook` twin receiving the same serialized bytes plus the
837
+ outcome record, returning through the same replacement channel (`undefined` keeps the
838
+ body; bytes or `{ body, status }` are applied by the shared `applyBodyReplacement`).
839
+ The null-body skip, async continuations, and throw propagation are unchanged - the
840
+ twin only skips the view allocation - and Bun/Deno keep the portable hook untouched.
841
+
842
+ A `Middleware` may declare the twin as `onNodeResponseBody` next to `onResponseBody`
843
+ (a lone twin throws at registration, mirroring `onNodeResponseHeaders`).
844
+
845
+ - 6e257a6: On `@nifrajs/node`, a request under both a `mount()` prefix and a longer native `mountFetch()` prefix (a proxy) reaches the `mount()` first, as `app.fetch` does, so the composed app's own guards run.
846
+ - 6907cbe: fix(core): a request path is routed the same way on every runtime. A path with `.` or `..`
847
+ segments, written as-is or percent-encoded (`%2e`, in either case), or with a backslash, is
848
+ resolved the way a WHATWG URL parser resolves it before a route is chosen: `/users/../posts` and
849
+ `/users/%2e%2e/posts` are `/posts`, and `/users/..\posts` is `/posts` too.
850
+
851
+ Bun and workerd already hand an app the resolved URL. Now Deno, Bun's `listen()` route table and
852
+ `@nifrajs/node` route the same path, and `c.req.url` shows it. The same holds for a WebSocket
853
+ handshake. The query string is never touched, and neither is a dot inside a longer segment
854
+ (`/.well-known`, `/a..b`) or an encoded slash or backslash (`%2f`, `%5c`), which stay part of the
855
+ segment.
856
+
857
+ fix(node): the adapter resolves the request target before anything reads it, so static files,
858
+ mounts, the app and `c.req.url` all see the same path.
859
+
860
+ - bda9637: fix(core): a `Response` thrown after `c.set.cookie(...)` or `c.set.deleteCookie(...)` ships those
861
+ cookies, as the same `Response` returned does. A login handler or a guard that sets or clears a
862
+ session and then throws a redirect now sends the `Set-Cookie` on every runtime and lane, whether the
863
+ throw comes from the handler, `derive`, or `beforeHandle`. `c.set.headers` and `c.set.status` still
864
+ do not apply to a `Response`, thrown or returned, and a thrown `Response` still skips `onError`.
865
+
866
+ fix(core): queued cookies reach a `Response` whose headers are immutable, such as
867
+ `Response.redirect(...)` or a `fetch()` result on Node, Deno, and Workers, whether it is returned or
868
+ thrown.
869
+
870
+ - ff25d68: perf(core): an app that mounts nifra apps without WebSocket routes answers the WebSocket upgrade
871
+ check without reading the request's headers. `@nifrajs/deno` and `toFetchHandler` on Workers run that
872
+ check on every request, and Deno builds a request's headers only when they are read, so a plain
873
+ request to such an app no longer pays for them. A WebSocket route or a mount added later, at any
874
+ depth, is still seen.
875
+
876
+ On Bun, an app whose own WebSocket routes sit beside mounted apps without any keeps native pub/sub:
877
+ `ws.subscribe` and `app.publish` stay on Bun's own topic broadcast. While it does, a WebSocket route a
878
+ mounted app gains after `listen()` is not upgraded, since its sockets would share the server's
879
+ topics; the next `listen()` serves it.
880
+
881
+ ## 3.5.0
882
+
883
+ ### Patch Changes
884
+
885
+ - 6046984: Close fresh security and correctness gaps in table allowlists, ISR/cache behavior, streamed response capture, idempotency ownership, and canonical redirects.
886
+ - d5b7c22: Harden request boundaries, error handling, resource limits, signing, and cross-runtime adapters for safer production releases.
887
+
888
+ ## 3.4.0
889
+
890
+ ### Patch Changes
891
+
892
+ - 719d82e: Centralize release-facing evidence and certification seams so generated views, runtime adapters, and
893
+ consumer checks stay aligned.
894
+
895
+ ## 3.3.0
896
+
897
+ ## 3.2.0
898
+
899
+ ### Patch Changes
900
+
901
+ - 8b58d1f: Improve the shared route lifecycle pipeline across Bun, Node, and Deno without changing the public
902
+ route, validation, response, or body-security contracts.
903
+ - 095c320: On Bun, `app.publish(topic, data)` now fans out through the runtime's native topic broadcast when no WebSocket route validates its outbound frames, so a broadcast reaches every subscriber without a per-connection loop. A route with `validateSend: true` keeps the portable per-socket path, since its frames are validated (and dropped when invalid) individually; other runtimes are unchanged.
904
+ - 7504864: Remove sideEffects entries that no longer resolve to a shipped module; the publish gate now verifies every sideEffects path against the packed tarball.
905
+ - e88c23a: Infer typed status responses across lifecycle hooks and contract-first routes, preserve precise
906
+ success and error narrowing in the client, and optionally include supported response types in
907
+ build-time OpenAPI output. Unsupported TypeScript types remain opaque and explicit runtime schemas
908
+ remain authoritative.
909
+ - c39712e: Move route policy compilation and deploy-target planning behind shared internal seams, pin the Solid adapter's Babel type identity, and add a cleanup-safe cross-runtime contract-lab runner.
910
+ - 9010fd3: Keep route-lane selection in a registration-time internal module so request dispatch continues to use the same compiled execution plans and adapter paths.
911
+ - ea2356e: Optimize routes with exactly one `derive`, `beforeHandle`, and `afterHandle` hook using a
912
+ registration-specialized lifecycle lane while preserving existing async, short-circuit, and error
913
+ semantics.
914
+ - a816b87: The Bun WebSocket message path now dispatches each frame without allocating a per-frame closure, bringing echo round-trip throughput to parity with the raw runtime. Error routing is unchanged: a synchronous throw or a rejected promise from a `message` handler still reaches `error()`.
915
+
916
+ ## 3.1.0
917
+
918
+ ### Minor Changes
919
+
920
+ - 1400f6c: Portable response header, body, and raw-response observation is now enabled explicitly with `responseObserver()` from `@nifrajs/core/response-observer`. Official middleware that uses these tiers remains compatible and enables the runtime automatically.
921
+
922
+ ### Patch Changes
923
+
924
+ - 5b78473: New package `@nifrajs/edge`: a compact fetch-handler server for edge and serverless runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy, Bun). It keeps the `server().get().post()` DX and the full request trust boundary - bounded body read, Content-Length pre-reject, prototype-pollution guard, JSON / urlencoded framing - in a fraction of the bundle, and its rejection envelopes are byte-for-byte the full server's, so an app can graduate to `@nifrajs/core`'s `server()` without its clients noticing.
925
+ - a7db515: Frontend framework deduplication is now applied consistently across the Bun build, the Vite build, and the Vite dev server, so React, Preact, and Svelte each load a single copy in every client bundling path.
926
+
927
+ ## 3.0.0
928
+
929
+ ### Minor Changes
930
+
931
+ - f0fd370: The same-origin check behind `redirect()` and the guards' `redirectTo` now rejects the paths a URL parser resolves onto another origin, and lives in one place.
932
+
933
+ A leading `/` that is not `//` is not sufficient to keep a destination on this origin. Under a special scheme a backslash parses as a path separator, and tab, CR and LF are stripped from the input before parsing, so `/\evil.example` and `/<TAB>/evil.example` both pass a `//` test and then resolve to the host `evil.example` - an open redirect reachable from any unvalidated `?next=` parameter. Both forms are now refused: `redirect()` throws as it already did for `//host`, and an auth guard falls back to its configured destination instead of honouring the value.
934
+
935
+ New export `isSameOriginPath` from `@nifrajs/core/server`, which is the single implementation the three gates now share - a security predicate kept in three copies is three chances for one of them to be hardened alone. It answers about a path, so an absolute URL is false even when it names the current origin: the point of the gate is that the value never got to name a host at all.
936
+
937
+ A percent-encoded backslash (`/%5Cevil.example`) is still a same-origin path, because that is what it resolves to.
938
+
939
+ - 381bbf3: Route assurance gains a tiered `securityBaseline()` policy preset and two route selectors, `hasBody` and `bodyLimit`.
940
+
941
+ `securityBaseline({ level })` returns an `AssurancePolicy` built entirely from the public assurance engine, so it inherits fail-closed evaluation, evidence provenance, and selector validation. Its rules are ordered most-specific-first because evaluation is first-match-wins: each route is owned by one rule that carries the full requirement bundle for its class. Three levels, each a superset of the last:
942
+
943
+ - `"essential"` - never false-positives on a reasonable app: a body read must be bounded, an `unlimited` body may never claim `nifra.body-bounded`, an agent tool ingress must be bounded, and an authenticated state change must prove CSRF. Every requirement is either core-published from the route schema or demanded only where the route already opted into the risk.
944
+ - `"standard"` (default) - essential plus: a route the app classified `pii` or higher must be authenticated, read or write.
945
+ - `"strict"` - standard plus the opinionated requirements that need installed middleware: every route carries a response contract, every read carries security headers, every mutation is rate limited.
946
+
947
+ `unmatched` defaults to `"ignore"` for additive adoption; set `"error"` to close it into an allow-list. `requireRuntimeProvenance` defaults to `true` - an author label is not proof.
948
+
949
+ The new selectors let any policy match on body shape: `hasBody` matches routes that declare (or omit) a body schema, and `bodyLimit` distinguishes `"bounded"`, `"unlimited"`, and `"unset"`. Both are validated at `defineAssurancePolicy` time and refuse unknown values, so a typo cannot open a policy hole.
950
+
951
+ Drop the preset into a `nifra.assurance.ts` `policy`, or spread it and append project-specific rules ahead of the baseline so they own their routes first.
952
+
953
+ - 36801ae: New: a static `singleCopy` declaration that collapses an identity-sensitive package to one physical copy, for the duplicate no install can remove.
954
+
955
+ An app that consumes a package by `link:` from a **separate checkout** cannot deduplicate React (or `@nifrajs/*`) by installing differently. The linked files live in the other repo, so their imports resolve from that repo's real path, and that repo's install owns its `node_modules`: peer dependencies are already satisfied there, `overrides` govern the consuming install only, and a deleted nested copy returns on the sibling's next install. The build's existing per-framework dedupe covers bundled output, but nothing covered `bun test`, `bun run`, or a preloaded script - and `nifra check` failed the app with remediation ("deduplicate the install") that the topology makes impossible.
956
+
957
+ An app now declares the packages in its own `package.json`:
958
+
959
+ ```json
960
+ { "nifra": { "singleCopy": ["react", "react-dom", "@nifrajs/*"] } }
961
+ ```
962
+
963
+ Entries are exact names or `@scope/*` patterns; `true` expands to the built-in identity-sensitive set (`@nifrajs/*`, `react`, `react-dom`, `preact`, `solid-js`, `svelte`, `vue`). `@nifrajs/*` is in that set because two copies of `@nifrajs/core` are two distinct `Server` classes, so `.merge()` stops accepting an app built against the other one. The declaration is static so `nifra check` can read it without importing the app's config, which would mean executing app code inside a preflight.
964
+
965
+ `buildClient` and `buildServer` inject the resolver from the declaration, so bundled output needs no wiring. Unbundled phases are not covered automatically - Bun's runtime resolver never offers a bare specifier to a plugin - so an app preloads `@nifrajs/core/single-copy/register` from `bunfig.toml` (`preload` for `bun run`, `[test].preload` for `bun test`). `nifra check` now names the phase that is left uncovered when the declaration exists without the preload.
966
+
967
+ The redirect refuses to cross versions: two copies at different versions are skipped as `version-skew` and stay fatal, because collapsing them would turn a loud install problem into a quiet behavioural one. A declared duplicate is reported, not suppressed - `nifra check` keeps printing the copies as a warning and `nifra doctor` lists them under `deduplicatedInstalls` - so the topology stays visible without failing the gate.
968
+
969
+ New exports: `@nifrajs/core/single-copy` (`singleCopyPlugin`, `registerSingleCopy`, `planSingleCopy`, `readSingleCopyDeclaration`, `readSingleCopyRegistration`, `matchesSingleCopyDeclaration`, `IDENTITY_SENSITIVE_PACKAGES`) and the side-effect entry `@nifrajs/core/single-copy/register`.
970
+
971
+ The registration proof reads `preload` as entries rather than as text: an entry counts only when it **is** the register specifier, not when it merely contains it. A neighbouring path such as `"./vendor/@nifrajs/core/single-copy/register-shim.ts"` used to satisfy the check while Bun loaded that other module and the registrar never ran, so enforcement was reported as armed on a process still loading both copies. A `preload` this cannot read as quoted entries - a multi-line array, an interpolated value - now reports "not registered" instead of standing in for proof.
972
+
973
+ - 9acadba: New `status(code, body?, init?)`: end a request from anywhere in the lifecycle without building a `Response`. Every error the framework renders itself now takes the same lane.
974
+
975
+ ```ts
976
+ import { server, status } from "@nifrajs/core";
977
+
978
+ app.derive((c) => {
979
+ const user = sessionOf(c);
980
+ if (user === undefined)
981
+ return status(401, { ok: false, error: "unauthorized" });
982
+ return { user };
983
+ });
984
+ ```
985
+
986
+ A `beforeHandle` could always short-circuit by returning a value, but a `derive`'s return **is** the context extension, so its only early exit was `throw new Response(...)` - the most expensive way to say 401. It is three costs stacked: constructing a Web `Response`, throwing it, and unwinding a lifecycle stage. On Node there is a fourth, because a `Response` built outside the handler is opaque to the adapter, so its body is drained back out through a Web stream and the reply goes out chunked instead of with a `content-length`.
987
+
988
+ `status(...)` is plain data - a status, optional headers, and a body the rendering lane serializes exactly like a handler's plain return - so a rejection now costs what an accepted request costs. It can be returned (preferred) or thrown, so a guard helper called for effect (`requireSession(c)`) can still end the request from inside a call it makes.
989
+
990
+ Measured on the Linux rig (4 server cores, 50 connections, medians; a rejecting `derive` vs the same `derive` returning `status(...)`, so both exit at the same point in the lifecycle):
991
+
992
+ | runtime | `throw new Response` | `status(...)` |
993
+ | ------- | -------------------- | ------------- |
994
+ | node | 39974 | 68577 |
995
+ | deno | 58403 | 89742 |
996
+ | bun | 79757 | 109328 |
997
+
998
+ The gap is largest on Node because of the drain, but the lifecycle cost was never runtime-specific: throwing to leave a `derive` cost every runtime real throughput.
999
+
1000
+ The same render now serves the errors an application cannot move onto a faster lane itself - 404, 405, 400 on a malformed path, 415, 422 on a validation failure, 500, 504, and the admission rejections. Bytes are unchanged on every lane, and the Node lane gains a `content-length` it did not have. `Response` stays exactly what it was for everything that genuinely needs one: redirects, streams, and any handler that returns or throws one.
1001
+
1002
+ ### Patch Changes
1003
+
1004
+ - f3d2a35: The body cap's 413/400 rejections are plain renders instead of `Response` objects.
1005
+
1006
+ `c.boundedBody()` and `c.boundedJson()` throw on an over-cap or malformed body, and the lifecycle catches that throw as control flow - the same contract as `throw redirect(...)`. What they threw was a built `Response`; it is now the same plain-data value `status(...)` produces, so a rejected request no longer pays for a `Response` on the way out. Identical on the wire: same status, same `{ ok: false, error }` body, same `content-type`.
1007
+
1008
+ The schema lane's own body read goes the same way, so a route with a `body` schema answers its 413/400 from plain data too.
1009
+
1010
+ The distinction is invisible to a handler that lets the throw propagate, which is the documented use. Code that catches it and inspects the value is affected: it is no longer a `Response`, so `.status` and `instanceof Response` are gone. Read `.plain` (`{ status, headers, body }`) or call `toResponse()`.
1011
+
1012
+ The urlencoded form reader takes the same lane, so a route whose schema parses a form answers its 413 and its 400 from plain data too, with the same envelope and the same bytes as the JSON reader's.
1013
+
1014
+ `jsonError` is unchanged and still exported - the surfaces that genuinely need a `Response` object keep it.
1015
+
1016
+ - 6e43c15: A failed boundary load no longer publishes the thrown error's own message. Boundary states are serialized into the document, so a driver or fetch failure was putting hosts, credentials, and query text on the page for every visitor who loaded it while the dependency was down. The client slot is now always `Boundary failed`, an `Error` subclass name is withheld for the same reason it names the failing internal library, and the real error is reported to the server console - the split already used for a rejected deferred value. A boundary that wants to show the user something specific catches its own failure inside `load` and returns that as data.
1017
+
1018
+ Opt-in outbound WebSocket validation (`validateSend`) contains a rejected diagnostic instead of recursing. An `error()` handler that answers a dropped frame by sending one of its own re-entered the reporter through the two paths that bypassed its guard - schema rejection and an async validator - and unwound only when the stack was exhausted, silently. Every failure path now reports through the guard.
1019
+
1020
+ A content index cursor is checked for the query that issued it separately from running off the end: a cursor from a different sort or filter is rejected at any position, and a matching cursor left past the last row by an index that shrank between pages returns an exhausted page rather than throwing.
1021
+
1022
+ - f0fd370: A plain render that Node refuses to write now answers 500 and leaves the server serving, and `status()` rejects an out-of-range code where it is written.
1023
+
1024
+ The plain lane goes straight to the socket, so a header value never passes through the `Headers` constructor that rejects CR/LF on the Web lane. Node rejects it at `writeHead` instead - as it does an invalid status, or any write after the head is already out. On the synchronous lane that throw escaped the request; on the asynchronous ones it surfaced as an unhandled rejection, which by Node's default terminates the process, so an application reflecting request data into `c.set.headers` or `status(...)` had a route-shaped input that could take the server down. Every write is now contained: an unsent head becomes the ordinary flat 500, and a head already on the wire ends the connection, because a status line cannot be recalled and a half-written body must not be left for the client to parse.
1025
+
1026
+ `status(code, ...)` now throws a `RangeError` for a code outside 200-599 or a non-integer. A plain render carries its status to `writeHead` unexamined, so an out-of-range value used to fail at the socket, far from the handler that produced it.
1027
+
1028
+ - 86a555b: The roadmap contract surfaces are now shipped across the public packages: shared island triggers,
1029
+ typed content indexes and joins, client loader/action hooks, and unified static, dynamic, and
1030
+ intercepting boundary modes. WebSocket routes also support opt-in synchronous outbound validation
1031
+ through `sendSchema` + `validateSend`; invalid or asynchronous outbound frames fail closed while the
1032
+ default remains type-level only.
1033
+ - 8c5f4cf: A route that declares an explicit finite `schema.bodyLimit` now publishes `nifra.body-bounded` route assurance evidence (source `route-schema`), the same as a route with a `schema.body`. Before this, the per-route transport cap bounded a body more strongly than the Content-Length `bodyLimit()` middleware - it also binds a raw `c.req.body` stream, which a Content-Length gate cannot - yet published nothing, so adopting the per-route cap could lower an app's L1 assurance level. `bodyLimit: "unlimited"` still publishes nothing, and the server-wide `maxBodyBytes` default is deliberately not evidence: it applies to every route whether or not anyone chose it.
1034
+ - 99fc683: A thrown `status(...)` now costs close to what a returned one costs, and the guards throw one.
1035
+
1036
+ `status(...)` is meant to be **returned** - from a `beforeHandle`, from a `derive`, from the handler. The one place a return cannot work is a helper called for effect: `requireSession(c)` decides the request is over from inside a call the handler makes, and only unwinding gets out of a half-finished handler. That is the whole remaining use of `throw`, and it now runs on the same lane as the return:
1037
+
1038
+ - The lifecycle error path is synchronous again. It was `async`, so a thrown `status(...)` - which needs no `await` at all - still allocated a promise and resumed a microtask later. The `onError` hook loop moved to its own async method, so only routes that registered a hook pay for one.
1039
+ - The two remaining sites that turned a thrown `status(...)` into a `Response` before rendering it (the bare and contextless fast paths) now render it as the plain data it is, through the request's own finalizer.
1040
+ - `requireSession` / `requireUser` / `requireAuthorization` throw a `status(...)` render instead of building a `Response`. The bytes on the wire are unchanged - a 401 JSON envelope, or a 302 with a `location` - and on Node they now carry a `content-length` instead of being drained back out as a stream.
1041
+
1042
+ **Behavior change:** what the guards throw is a `status(...)` render, not a `Response`. Code that catches a guard and tests `err instanceof Response` needs to stop doing that; nifra itself treats the two identically as control flow, so a guard thrown through a route behaves exactly as before.
1043
+
1044
+ Measured on the Linux rig (4 server cores, 50 connections, medians of 5 x 2s; a `derive` that rejects, returning vs throwing the same `status(401, ...)`). Only within-runtime deltas are readable - the host was under other load, and the return arm is included in both columns as the control:
1045
+
1046
+ | runtime | return | throw, before | throw, after |
1047
+ | ------- | ------ | ------------- | ------------ |
1048
+ | node | 85904 | 74471 | 80136 |
1049
+ | deno | 139477 | 85970 | 90355 |
1050
+
1051
+ On Node a throw now lands within the run's own spread of a return. On Deno it does not: a throw still costs ~35%, and it costs the same whether the thrown value is a `status(...)` render or a `Response` - so what remains there is the unwind itself, not the rendering.
1052
+
1053
+ That remainder is the runtime's, not the framework's. The same arms with no framework at all - a bare `Deno.serve` / `Bun.serve` / `node:http` handler answering identical bytes, one returning its payload and one throwing it - reproduce it, in CPU-microseconds per request:
1054
+
1055
+ | | return | throw | throw across await |
1056
+ | ---- | ------ | ----- | ------------------ |
1057
+ | node | 49.35 | +3.85 | +2.85 |
1058
+ | bun | 22.55 | +1.83 | +3.99 |
1059
+ | deno | 18.89 | +7.62 | +11.03 |
1060
+
1061
+ Against that, nifra's own rejection arms cost +3.35 (node), +3.41 (bun), +15.59 (deno). On node and bun the framework's entire remaining throw penalty _is_ the runtime's throw. On deno the runtime's throw-across-await alone accounts for ~70% of it, and the percentage reads worse than the others only because deno's baseline request is the cheapest of the three. Two candidate explanations were tested and ruled out: `Error.stackTraceLimit = 0` changes nothing (so it is not stack capture), and throwing three frames below the handler is no cheaper (so it is not proximity to the runtime boundary).
1062
+
1063
+ - 73d894d: `bodyLimit: "unlimited"` now actually skips the cap on every read path, not only on direct `c.req` reads. A route that declared the exemption alongside a `schema.body` (or that called `c.boundedJson()` / `c.boundedBody()` with no explicit cap) silently fell back to the server-wide `maxBodyBytes` and answered 413 at a bound the route had explicitly opted out of. The resolved route limit is now read through `UNLIMITED_BODY_BYTES` instead of `?? maxBodyBytes`, so `undefined` keeps meaning "the route opted out" everywhere rather than flipping to "use the default" at the reader. Routes that never declared `bodyLimit` are unaffected: their limit is resolved to `maxBodyBytes` at registration, exactly as before.
1064
+
1065
+ An unlimited route no longer publishes the `nifra.body-bounded` assurance id. It used to earn the id from its `body` schema or from a declared numeric limit that `"unlimited"` then overrode, so the one route in an application with no ceiling at all was the one certifying that its body was bounded - and a policy built on that id passed exactly where it should have failed. The exemption is documented as covering the transport cap only: a route that pairs it with a `body` schema, or with `idempotency`, still assembles every delivered byte in memory before anything examines them, so an unlimited body belongs on `c.req.body` as a stream, bounded by the protocol it speaks.
1066
+
1067
+ ## 2.14.1
1068
+
1069
+ ### Patch Changes
1070
+
1071
+ - bf93902: The in-memory durable stores (`MemoryDurableEffectStore`, `MemoryApprovalStore`, `MemorySagaStore`) no longer perform a redundant deep clone when persisting a state transition. Stored records are already fresh and frozen, and reads still hand back isolated copies, so behaviour is unchanged while each transition allocates less.
1072
+
1073
+ ## 2.14.0
1074
+
1075
+ ### Minor Changes
1076
+
1077
+ - 701961a: New `raw<T>(response)` escape hatch keeps a hand-built `Response` inside the typed contract. A route
1078
+ that returns a bare `Response` - a Server-Sent Events stream, a file download, a signed token payload -
1079
+ previously inferred `res.data: never` on the client. Wrap the return as `raw<T>(response)` and the
1080
+ client sees `res.data` as `Jsonify<T>` while the route still ships the exact `Response` at runtime.
1081
+ Branded binary responses from `bytes()` keep their `Blob` typing; only an unbranded `Response` return
1082
+ falls back to `never`.
1083
+ - 62133bf: Order-scoped hooks (`derive`, `decorate`, `beforeHandle`, `afterHandle`, `around`, `aroundCapability`,
1084
+ `onError`) are snapshotted into each route as it is declared, so one added after the last route was
1085
+ registered silently applies to nothing. This is the trap a route-registering factory sets: the app it
1086
+ returns has already declared its routes, so `app.use(requestId())` on it reaches zero of them.
1087
+
1088
+ The server now detects this at seal - the first `listen()`, `fetch`, or `resolveNode` - and reports
1089
+ every order-scoped hook that covers no route, naming the hook kind and the call site so the fix (move
1090
+ it before the routes it should cover) is obvious. A previously-silent mistake now logs once at startup.
1091
+
1092
+ New `unusedScopedHooks` server option: `"warn"` (default) logs the report, `"error"` throws a
1093
+ `FrameworkError` (`UNUSED_SCOPED_HOOKS`), `"off"` skips the check and its bookkeeping entirely. The
1094
+ report routes through a configured `logger`, else `console.warn`. Legitimate group scoping - a hook
1095
+ that intentionally covers only the routes declared after it - is never flagged, and app-global hooks
1096
+ (`onRequest`/`onResponse`/...) are unaffected because they are not order-scoped.
1097
+
1098
+ The audit is a development-time diagnostic: it is guarded by `process.env.NODE_ENV !== "production"`, so
1099
+ any bundler that defines `NODE_ENV` strips it entirely from production builds - it adds zero bytes to
1100
+ the shipped bundle and zero per-request cost. It fires in development, test, and CI, where the mistake
1101
+ is caught, not in production.
1102
+
1103
+ - 8dffdf4: `.use(plugin)` no longer silently collapses the app's typed route registry to `any` when a plugin's own
1104
+ types are unpinned. A plugin whose parameter and return infer as `Server<any, any>` - an auth or router
1105
+ plugin that widened - now makes `.use()` return the non-callable `PluginTypeCollapsed` marker at the
1106
+ call site, rather than an `any` that only surfaces hundreds of lines away as `never`/`any` in the typed
1107
+ client. Build the plugin with `defineIdentityPlugin`/`defineContextPlugin`, or pin its input server
1108
+ type, and it threads the caller's registry and context unchanged.
1109
+
1110
+ `serverFunctions()` now ships as such an identity plugin, so `app.use(serverFunctions(...))` keeps every
1111
+ route declared before and after it fully typed.
1112
+
1113
+ ## 2.13.0
1114
+
1115
+ ### Patch Changes
1116
+
1117
+ - e0b2dd6: Harden three regex-adjacent input paths against pathological input. The byte-range parser bounds an
1118
+ oversized `Range` header before the matcher runs rather than after; the problem-details type builder
1119
+ strips trailing slashes with a linear scan instead of a backtracking pattern; and the dev SSR
1120
+ import-graph specifier pattern no longer has an ambiguous whitespace group. Behavior is unchanged for
1121
+ valid input.
1122
+ - 7535ce1: A direct body read on Node - `c.req.json()`, `c.req.text()`, `c.req.arrayBuffer()`, `c.req.bytes()`
1123
+ on a raw-body route - now reads straight off the socket instead of first building the Web `Request`
1124
+ the adapter had been deferring. The body cap is unchanged and still enforced by the same bounded
1125
+ reader: an over-cap `Content-Length` is rejected before buffering, a chunked body is still aborted
1126
+ mid-stream rather than buffered first, `clone()` inherits the cap, and `c.boundedBody(explicit)`
1127
+ still overrides it in either direction. `c.req` keeps its identity and every other member behaves
1128
+ as before. Net: a raw-body `POST` that reads through `c.req` gets a large throughput gain - roughly
1129
+ +65% on the JSON-body workload in the Bun HTTP framework benchmark on Node - and is no longer the
1130
+ slowest lane in a nifra app.
1131
+ - 1704308: On Node, `c.text(...)` and `c.json(...)` now defer building the Web `Response`. The adapter writes a
1132
+ text or JSON body to the socket from a status, a header record, and the bytes directly, so the
1133
+ `Response` those helpers used to construct up front - about a quarter of the request budget on a
1134
+ small response - is built only if something actually reads the Web surface (a response hook,
1135
+ `app.fetch`, or user code touching `.headers`), and forwarded to from then on. The returned value is
1136
+ still a real `Response`: `instanceof Response` holds, the status, headers, and body are unchanged,
1137
+ and the content-type is byte-identical to what the eager `Response` carried. Bun and Deno, which hand
1138
+ the `Response` to their native server, are unaffected. Net: a plain text or JSON return on Node lands
1139
+ on the adapter's fastest write lane - on the Bun HTTP framework benchmark the text workloads (Ping,
1140
+ Query) go from roughly 0.75x of Fastify to level with it, and clear of Hono.
1141
+
1142
+ ## 2.12.1
1143
+
1144
+ ### Patch Changes
1145
+
1146
+ - fba30c7: A route that validates its body no longer arms the direct-read body cap on `c.req`. That lane
1147
+ already reads and bounds the body - at the route's own limit - before any derive, hook, or handler
1148
+ runs, so on a validated route the extra per-request cap only ever guarded a body that was already
1149
+ consumed. Dropping it removes a per-request write on the request object that, on V8, reshaped it
1150
+ every request and fed avoidable GC churn. Raw-body routes (no body schema), where a direct `c.req`
1151
+ read is the only body boundary, keep the cap exactly as before. Net: lower per-request allocation
1152
+ and a markedly tighter tail on validated `POST`/`PUT` routes, most visible on Deno.
1153
+
1154
+ ## 2.12.0
1155
+
1156
+ ### Minor Changes
1157
+
1158
+ - df100d3: Route-assurance rules can distinguish enforcement from assertion, and can select routes by data
1159
+ sensitivity.
1160
+
1161
+ - **`requireProvenance`** on a rule: `"any"` (default, unchanged) accepts every evidence entry;
1162
+ `"runtime"` accepts only evidence installed by middleware, a plugin, or framework runtime policy
1163
+ and rejects an author's inline `schema.assurance` claim; `"declared"` is the inverse, useful for
1164
+ reviewing what handlers assert about themselves. Provenance is carried non-enumerably on each
1165
+ evidence entry, so existing reflected route descriptors keep their exact serialized shape.
1166
+ - **`requireCsrfWithAuthenticated`** on a rule: an authenticated route selected by that rule must
1167
+ also carry runtime CSRF evidence. Intended for rules covering cookie- or session-authenticated
1168
+ browser routes; bearer-only APIs have no ambient-authority exposure and belong in their own rule.
1169
+ - **`match.classificationAtLeast`**: select routes whose declared response classification is at
1170
+ least `"public"`, `"pii"`, or `"secret"`, so a policy can demand more of the routes that carry
1171
+ more.
1172
+
1173
+ A failure message names the required provenance when it is not `"any"`, so the report says which
1174
+ kind of evidence is missing rather than only which id.
1175
+
1176
+ - 0efacea: Add a generic server `onStop` lifecycle hook and have OTLP tracing exporters flush and shut down automatically when attached to a server. Manual OTLP lifecycle calls remain available for standalone exporters.
1177
+ - df100d3: Canonical project evidence: a single reflected snapshot of a project's routes, schemas, assurance,
1178
+ and capabilities, exported as `@nifrajs/core/evidence` (`snapshotProjectEvidence`). Tools that used
1179
+ to reflect the app a second time now project from the snapshot instead, so the manifest, the check
1180
+ report, and introspection cannot disagree about what the app declares.
1181
+
1182
+ `createManifest` accepts the snapshot as `evidence` and skips its own reflection when given one. It
1183
+ refuses to emit a manifest whose route-assurance or capability evidence is failing, so a manifest is
1184
+ never a record of a project that does not pass its own gates. The previous `source` input still
1185
+ works; one of the two is required.
1186
+
1187
+ - b5f47c0: `__Secure-` and `__Host-` cookie name prefixes (RFC 6265bis) are now enforced, matched
1188
+ case-insensitively the way browsers match them. `serializeCookie` throws on a `Set-Cookie` that
1189
+ violates its name's prefix contract - `__Secure-` requires `Secure`; `__Host-` requires `Secure`
1190
+ and `Path=/` and forbids `Domain` - instead of emitting a cookie the user agent silently discards.
1191
+ `c.set.cookie`'s secure defaults already satisfy both contracts, so prefixed names work with zero
1192
+ configuration, and `c.set.deleteCookie` applies `Secure` to the deletion write for a prefixed name
1193
+ so the browser accepts the deletion (the failure mode behind Hono's CVE-2026-39410 class: a
1194
+ non-conforming deletion leaves the cookie alive after logout). The new `cookieNamePrefix(name)`
1195
+ export classifies a name as `"secure"`, `"host"`, or unprefixed. `@nifrajs/i18n`'s `localeDetector`
1196
+ applies `Secure` automatically when its persist cookie name carries a prefix.
1197
+ - fc33c0f: The server type records which copy of `@nifrajs/core` declared it. `Server` carries a type-only
1198
+ `__nifraCoreVersion` brand holding the package's feature version (`major.minor`), exported as
1199
+ `NifraFeatureVersion`, so a hover over `typeof app` - or an assertion in a test - says which core an
1200
+ app is built against. Nothing is emitted at runtime and no field is allocated per server.
1201
+
1202
+ Two copies of core in one build stay a compile error, as before; `nifra doctor` is what names the
1203
+ two install paths.
1204
+
1205
+ - c4e8bb0: New `errorLogDetail` server option controls how much of an unhandled error reaches the log for a 500:
1206
+ `"full"` (the default, unchanged - name, the error's own text as `detail`, and `stack`), `"message"`
1207
+ (no stack), or `"none"` (name only). None of it ever reaches the client; a 500 response is a bare
1208
+ `internal_error` either way. An error's text can quote the input that produced it, so an app whose log
1209
+ sink is outside its trust boundary can now narrow what is recorded - the sharper instrument stays the
1210
+ redacting logger (`jsonLogger({ valuePatterns: commonSecretPatterns })`), which scrubs tokens and
1211
+ emails out of `detail` and `stack` while keeping the diagnosis intact.
1212
+ - 11d1658: `assure(app, evidence)` from `@nifrajs/core/assurance` publishes enforcement evidence from outside
1213
+ the plugin chain. When the thing enforcing a control is not a nifra plugin - an edge gateway, a
1214
+ service mesh, an outer framework that owns the shell wrapping the app - the assurance policy had no
1215
+ way to see it, and the only way to keep the app green was switching the affected rules off. The
1216
+ evidence can now be attached at the mount site or immediately before `serve`, after every route is
1217
+ registered:
1218
+
1219
+ ```ts
1220
+ import { assure, NIFRA_ASSURANCE } from "@nifrajs/core/assurance";
1221
+
1222
+ const app = buildApp();
1223
+ assure(app, { id: NIFRA_ASSURANCE.AUTHENTICATED, source: "edge-gateway" });
1224
+ serve(app);
1225
+ ```
1226
+
1227
+ `scope` defaults to `global`, so the evidence applies to every route already registered; narrow it
1228
+ with `methods` and `paths` (absolute globs), or pass `scope: "subsequent"` to cover only routes
1229
+ registered after the call. Invalid evidence ids and `scope: "plugin"` fail closed at the call.
1230
+
1231
+ Attached evidence always carries `declared` provenance - nifra did not install the enforcement and
1232
+ cannot observe it - so a rule with `requireProvenance: "runtime"` still rejects the route. Evidence
1233
+ declared through `withRouteAssurance` now carries the provenance it was given rather than always
1234
+ reporting `runtime`.
1235
+
1236
+ It lives on the `assurance` subpath and applies through the ordinary middleware seam, so an app that
1237
+ never calls it carries none of this: the bare server bundle is unchanged.
1238
+
1239
+ - 8847825: Add `app.mountFetch()` for mounting an arbitrary fetch handler below a path prefix, with optional prefix stripping and platform argument forwarding. Mounted handlers are explicitly outside typed response-contract enforcement.
1240
+ - 9a9346e: `merge()` now scopes a group's `onRequest` hooks to the group's own routes, following the same locality rule its `derive`/`beforeHandle` chains already obey: a `bodyLimit()` mounted on an uploads group no longer starts gating every route of the app that composes it in. The guard is a single route probe against a snapshot of the group's catalog, so requests outside the group pay one lookup and pass untouched; the group's Node-native hook twins are scoped the same way. Global assurance declared by a group's middleware follows the enforcement - it is folded onto exactly the merged routes, so `routes()` never claims a group's protection for parent routes its hooks do not see.
1241
+
1242
+ A group with hooks but no routes is a middleware bundle: its hooks can only mean app-wide intent, so they are still appended globally, unchanged. Apps that relied on a route-carrying group's hooks running app-wide should register that middleware on the parent server with `.use()`.
1243
+
1244
+ - 9a9346e: `app.use(plugin)` keeps the caller's server type. A plugin built with `definePlugin` whose input
1245
+ server type is not pinned used to widen the app to `Server<any, any>`, so every route declared
1246
+ before _and_ after the `use` lost its types and the typed client silently degraded to `any`. That
1247
+ case is now a compile error at the `use` call site, naming the definer to switch to; the plugin is
1248
+ unchanged at runtime.
1249
+
1250
+ Pick the definer that matches what the plugin does: `defineContextPlugin<D>` when it adds context
1251
+ via `derive`/`decorate` (the registry threads through and `D` is added to every downstream handler
1252
+ context), `defineRouterPlugin` when it mounts routes/hooks and adds no context (mount as a side
1253
+ effect, return the app). `definePlugin` still works when its input type is pinned - annotate the
1254
+ parameter (`(app: typeof api) => ...`) or pass explicit type arguments.
1255
+
1256
+ Every first-party plugin now threads: `jwt`, `tokenAuth`, `basicAuth`, `durableCommand`, `etag`,
1257
+ `compression`, `problemDetails`, `prettyJson`, `methodOverride`, `trailingSlash`, `cacheControl`,
1258
+ `devtools`, and `metrics` return an `IdentityPlugin`; `timing`, `language`, and `tracing` return a
1259
+ `ContextPlugin` of what they add (`{ timing }`, `{ language, languageMatch }`, and
1260
+ `{ trace, observation, causality }` respectively), so `c.timing` / `c.language` / `c.trace` are
1261
+ typed without a manual annotation. `combine(...)` is typed as an identity bundle and
1262
+ `namedCombine(name, ...)` is its deduped, named form.
1263
+
1264
+ A type-level test asserts the threading for each definer shape, so a regression fails `typecheck`
1265
+ rather than surfacing as `any` in a downstream app.
1266
+
1267
+ - b045f9e: JSON bodies are now guarded against prototype poisoning. A payload carrying an own `__proto__`
1268
+ key, or a `constructor` whose value carries a `prototype`, is rejected with the same flat `400`
1269
+ as malformed JSON - before validation and before the handler. The new `protoPoisoning` server
1270
+ option selects the policy: `"reject"` (default), `"strip"` (delete the keys in place and
1271
+ continue), or `"ignore"` (opt out). The check covers the schema body lane, `c.boundedJson`, the
1272
+ streaming no-length path, and `\u`-escaped spellings of the poisoned keys; the transport-codec
1273
+ lane enforces the same policy on its own decoder via a matching `protoPoisoning` plugin option.
1274
+ The common clean payload keeps the runtime's native fast path - the guard is a single iterative
1275
+ pass over the parsed value, allocation-free, and benchmarks within noise of the unguarded
1276
+ baseline.
1277
+ - 9a9346e: Routes and contract operations accept a `headers` schema, validated at the boundary alongside
1278
+ `body`, `query`, and `params`. Field names are materialized lower-case onto a null-prototype record
1279
+ before validation, so a hostile field name (`__proto__`, `constructor`) cannot reach
1280
+ `Object.prototype`, and repeated fields arrive already comma-joined by the platform. The validated
1281
+ result is available to the handler as typed data instead of ad-hoc `c.req.header(...)` reads, and a
1282
+ failure answers the same flat `400` as any other input-validation failure.
1283
+
1284
+ The section flows through the rest of the surface: contract diffs report `headers` as its own
1285
+ breaking-change section, and OpenAPI generation emits the schema as `in: header` parameters.
1286
+
1287
+ - 9a9346e: Per-route transport body caps. A route or contract operation may declare `bodyLimit` - a byte count,
1288
+ or the explicit string `"unlimited"` paired with a `bodyLimitReason` that records why the route is
1289
+ exempt. The route's cap overrides the server-wide `maxBodyBytes` for that route only, so a single
1290
+ upload endpoint no longer forces the whole app's ceiling upward, and an exemption is a reviewable
1291
+ declaration rather than a silent absence. An invalid value - a cap that is not a non-negative safe
1292
+ integer, `"unlimited"` without a non-empty reason, or a reason given without `"unlimited"` - is a
1293
+ `RouteConfigError` carrying the `INVALID_BODY_LIMIT` code at registration, not a surprise at request
1294
+ time.
1295
+ - dbc0b79: Signing-secret rotation. `signValue`/`unsignValue` (and the new `CookieSecret` type), session `secret`, and CSRF `secret` now also accept a rotation list: the first secret signs, any listed secret verifies, so keys rotate without invalidating live cookies, sessions, or CSRF tokens. Every listed secret must meet the 32-byte floor and an empty list throws; the single-secret path is unchanged.
1296
+ - a5d3f5b: Add stable diagnostic codes, application-supplied rule packs, fix recipes, assurance bundles, contract lock snapshots, hydration assurance hooks, replay metadata, security verification rules, and idempotency proofs.
1297
+ - 00819c5: `createToolHttpHandler` caps its JSON request body. The handler is standalone - it is not mounted on
1298
+ a server and so inherited no `maxBytes` - and used to read the body unbounded, documenting the limit
1299
+ as the mounting platform's job. It now defaults to 1 MiB (`DEFAULT_TOOL_MAX_BYTES`), configurable
1300
+ with `maxBytes`. An oversized body answers a flat `413` and a malformed `Content-Length` a flat
1301
+ `400`, both before the parse, so nothing oversized is ever materialized as a JS value.
1302
+
1303
+ Running uncapped stays possible but must be declared: `maxBytes: "unlimited"` requires a non-empty
1304
+ `maxBytesReason`, and a reason without `"unlimited"`, or a cap that is not a non-negative safe
1305
+ integer, throws when the handler is created rather than at request time.
1306
+
1307
+ **Breaking for bodies over 1 MiB.** A tool whose inputs legitimately exceed that must set `maxBytes`
1308
+ to its real ceiling, or opt out with a reason.
1309
+
1310
+ - e2d1939: Add typed tool contracts with shared fail-closed adapters, static verification work graphs, bounded provider-neutral agent turns, deterministic trajectory replay, and an explicit execution-policy seam with a non-isolating local process adapter.
1311
+ - e83e6eb: A capability provenance rule that matches nothing is now a finding. Seam specifiers in
1312
+ `provenance.imports` and `provenance.routeModules` are compared with the text the code imports, so a
1313
+ rule written as `src/db` when the module is imported as `./src/db.ts` silently governed zero
1314
+ modules - the policy looked satisfied because nothing was ever attributed to it. `nifra check` now
1315
+ reports `unmatched-provenance-seam` for every declared seam no scanned source matched, with the
1316
+ nearest specifiers that were actually seen ("did you mean ...?") and a fix that points at rewriting
1317
+ the rule to match the import, or deleting it.
1318
+
1319
+ `forbiddenImports` is deliberately excluded: zero matches there is the success state.
1320
+
1321
+ A rule that is genuinely absent in some projects sharing one policy can opt out with
1322
+ `optional: true`, which suppresses the finding for that seam only.
1323
+
1324
+ ### Patch Changes
1325
+
1326
+ - cd1732c: The body-size cap now holds on the bytes actually delivered, not on what the request declared.
1327
+ The `Content-Length` fast path re-checks the real byte count after the read: a source that hands
1328
+ over more bytes than it declared - a lying client or an adapter that decodes/expands the body
1329
+ upstream - is rejected with the same flat `413`, even though its header passed the fast-reject
1330
+ hint. One integer comparison on a buffer already in hand; the streaming lane already counted real
1331
+ bytes and is unchanged.
1332
+ - 9a9346e: A contract operation's client-visible `body` and `query` types are now the schema's INPUT side, not
1333
+ its output side - matching what the inline registry already does. A schema that fills in defaults
1334
+ made the contract client demand the post-validation shape, so a caller had to send fields the schema
1335
+ exists to supply. Handler-facing context types are unchanged and still carry the validated output.
1336
+ - 5f71c23: Cut iterator overhead from the async lifecycle chain, the static-header pass, and cookie signing.
1337
+
1338
+ An array iterator declared inside an async function stays live across the `await` that follows it, so
1339
+ neither JSC nor V8 sinks it: the `beforeHandle`/`onError` chains paid 43ns per hook on Bun and ~9ns
1340
+ on Node for iteration alone. The static response-header pass paid it twice per header, once for the
1341
+ list and once for the pair destructuring (211ns per response on a 5-header set), and signed cookies
1342
+ paid it per digest byte (238ns per signature on Bun). All three now index directly.
1343
+
1344
+ - 3788b36: Walk arrays by index in the prototype-poisoning guard.
1345
+
1346
+ A JSON body that is mostly array data was walked through the array iterator protocol, which JSC does
1347
+ not elide: on Bun a 9KB array of numbers cost 27us to guard instead of 2.2us, and the gap scaled
1348
+ with the body cap. The guard now indexes the array directly, which measures identically on V8 and
1349
+ removes the amplification on JSC.
1350
+
1351
+ - ae5338f: Cut per-request overhead on the JSON body lane and the Node adapter.
1352
+
1353
+ The bounded JSON reader now returns the body read's own promise instead of running as an async
1354
+ function, so a framed JSON POST pays one microtask hop rather than several. The prototype-poisoning
1355
+ walk checks the suspect keys during the single enumeration it already performs instead of probing
1356
+ each node twice. The Node adapter memoizes Host-authority normalization, which repeats for every
1357
+ request of a deployment.
1358
+
1359
+ No behaviour change: the same bodies are accepted, the same poisoning shapes are rejected or
1360
+ stripped, and the same Host values normalize to the same authority.
1361
+
1362
+ - 5e4e31a: The Node-direct response path asks "are these header names already the lowercase wire spelling"
1363
+ once per request instead of three times. Core answers it where the answer is already in hand - the
1364
+ static-header fold derives each name's lowercase form anyway, and the native response walk was
1365
+ walking the same keys - and publishes it as a symbol-keyed mark the header view and `@nifrajs/node`'s
1366
+ direct writer read instead of re-scanning. An app that registers a raw `onNodeResponse` twin (one
1367
+ handed the record itself rather than the case-normalizing view) is never marked and keeps the
1368
+ per-reader scans, since such a twin writes after the point the mark would be set. Wire output is
1369
+ unchanged by construction: it is the same answer, from the same pass.
1370
+
1371
+ Header-normalization frames fall from 4.1% to 1.5% of self time on a middleware-heavy route and from
1372
+ 1.6% to 0.6% on a hookless one (V8 CPU profile, `GET /users/:id`). `@nifrajs/middleware`'s Node
1373
+ response twins set one header at a time through a helper that keeps the record in V8's fast property
1374
+ mode rather than re-homing it into a null-prototype object, which demoted every later lookup on the
1375
+ response path to dictionary mode.
1376
+
1377
+ - bd5c624: Collapse the JSON body lane onto one guarded path and make the poisoning walk cheaper.
1378
+
1379
+ Framed JSON bodies now take the runtime's fused `json()` at every size instead of splitting at 1KB,
1380
+ and the guard walks the parsed value directly - the raw-text substring pre-scan is gone, since it
1381
+ cost more than the walk it was meant to avoid. The walk itself only stacks object nodes, so scalar
1382
+ keys and scalar array elements no longer make the round trip through it.
1383
+
1384
+ No behaviour change: the same payloads are accepted, and the same poisoning shapes - including ones
1385
+ spelled with `\uXXXX` escapes - are still rejected or stripped under `protoPoisoning`.
1386
+
1387
+ - e2bdd4a: `createToolHttpHandler` applies the prototype-poisoning policy to its JSON request body. A body
1388
+ carrying an own `__proto__` key, or an own `constructor` key whose value holds an own `prototype`,
1389
+ is rejected with the same `input_invalid` tool result as malformed JSON, so probing cannot
1390
+ distinguish a blocked payload from a syntax error. The handler is standalone (never mounted on a
1391
+ server), so it carries its own `protoPoisoning` option - `"reject"` (default), `"strip"`, or
1392
+ `"ignore"` - mirroring the server option. The body remains read unbounded; cap request size at the
1393
+ platform or server mounting the handler.
1394
+ - f8b0097: A `.ws()` route's `maxPayloadBytes` is now enforced on every runtime, not only the ones whose socket
1395
+ implementation happened to police it. The declared cap travels with the upgrade outcome, so the Node
1396
+ bridge hands it to `ws` as `maxPayload`, and the Deno and Workers/`attachWebSocket` message paths
1397
+ measure the frame and close with `1009 message too large` instead of delivering it. A route that
1398
+ declares no cap is untouched and pays nothing: sizing a text frame costs a UTF-8 encode, so the
1399
+ measurement only runs where a cap exists.
1400
+
1401
+ ## 2.11.0
1402
+
1403
+ ## 2.10.0
1404
+
1405
+ ### Minor Changes
1406
+
1407
+ - 15bffdd: Serve `public/` with byte ranges. Static files now advertise `accept-ranges`, answer a single-range request with `206` and `content-range`, return `416` for an unsatisfiable range, and publish `last-modified` with `if-modified-since` and `if-range` handling. HEAD reports the same `content-type` and length metadata GET does. `parseByteRange` moves to `@nifrajs/core/range` so the static handler and `@nifrajs/middleware`'s `rangeResponse` share one parser; the middleware export is unchanged.
1408
+ - 15bffdd: Add request-bound data capability evidence, resumable bounded channel subscriptions, ISR tag
1409
+ invalidation for memory and KV stores, and dependency-free Open Graph image responses with an
1410
+ optional rasterizer seam.
1411
+ - 15bffdd: Add standards-shaped range, conditional, content-negotiation, and multipart response helpers, plus public token-only data contracts and typed in-memory channel seams.
1412
+
1413
+ ## 2.9.1
1414
+
1415
+ ### Patch Changes
1416
+
1417
+ - 01e36fb: HEAD requests now answer via the matching GET route with identical status and headers (RFC 9110), on both the Bun native-route lane and the portable dispatcher. Previously a HEAD to a GET-only route returned a 405 JSON error, so custom headers and the declared content-type were lost. An explicitly registered HEAD route still takes precedence, and 405 Allow lists now advertise the implicit HEAD support.
1418
+
1419
+ ## 2.9.0
1420
+
1421
+ ### Minor Changes
1422
+
1423
+ - e05e56d: Improve hot paths across runtimes and the browser: a validated-POST fused lane for Bun/Deno Web
1424
+ requests (measured +12.7% Deno, +3.5% Bun on `POST /users`) plus a registration-compiled body
1425
+ validation/handler continuation shared by Web and Node-direct (about 9.6% faster than the generic
1426
+ body lane in-process), client route matching indexed on the core router instead of a linear scan
1427
+ (measured ~18x faster on a 100-route app), search-param parsing in one pass instead of O(keys²), and
1428
+ allocation-free fast paths for static asset URLs and safe SSR script serialization.
1429
+ Bare fused-lane Web requests with no active timeout or deadline now run on the lazy request
1430
+ context (the one the Node direct path already uses), with the platform - `c.env`, `c.clientIp`,
1431
+ `c.waitUntil` - carried through and `c.signal`/`c.budget`/`c.query` resolving lazily to identical
1432
+ values, pinned by a regression test. Measured +4.5% on a bare `GET /users/:id` on Deno. Routing
1433
+ also stops allocating a `{ pathname, search }` pair per request on the portable path.
1434
+ Node serving now keeps synchronous Web request middleware on the direct renderer, adapts in-place Web
1435
+ response middleware back to direct buffered writes, and avoids redundant params/body lifecycle stages
1436
+ for common validated reads. Header-only built-ins (`cache-control`, `powered-by`, and related response
1437
+ mutators) no longer clone buffered responses on Node.
1438
+ New portable middleware hook: `onResponseBody(body, headers, req, status)` - the post-serialization
1439
+ payload tier. The hook receives the FINAL framework-serialized bytes plus the mutable header view,
1440
+ and may return replacement bytes. On the Node direct writer the bytes come straight off the outcome
1441
+ record; on the Web serving paths they ride the framework-built Response as an inert tag (attached
1442
+ only once a body hook is registered), so no body stream is ever drained on any runtime. A
1443
+ handler-returned raw `Response` (a proxied fetch, SSE, a streamed page) is skipped by contract, a
1444
+ structured return (`{ body, status }`) can drop the body or change the status (an ETag `304`), and
1445
+ transforming those remains `onResponse`'s job. A body-observing middleware written this way
1446
+ measures at ~92% of a raw `node:http` server on the realistic route, vs ~50% through the full
1447
+ `onResponse` contract.
1448
+ New middleware hook: `onResponseRaw(response, req)` - the raw-response tier. It runs ONLY for
1449
+ responses the payload tier skips (streams, proxied fetches, and framework-generated error
1450
+ responses); a framework-serialized JSON body stays on `onResponseBody` and, on Node, on the direct
1451
+ socket writer (the raw hook self-pairs with a no-op native twin, so registering one does not force
1452
+ the fallback path). Together the two tiers cover every response without double-processing any.
1453
+ Response-body tagging is now scoped per app instance instead of process-wide: one app registering a
1454
+ body hook no longer makes unrelated apps in the same process pay for tagging, `merge()` keeps the
1455
+ tag readable across merged apps, and a foreign Response carrying a look-alike marker is not treated
1456
+ as a framework-serialized body.
1457
+ Bodiless statuses are normalized on every render path: a handler returning a `204`/`205`/`304` (or
1458
+ a body hook converting to one, e.g. an ETag `304`) always ships with no body and no
1459
+ `content-length`, on the Web paths and the Node direct writer alike.
1460
+ Response-header records are built null-prototyped everywhere user-influenced names can land in
1461
+ them, so header names like `__proto__` stay data instead of touching the record's prototype; the
1462
+ header view over Node outcomes also resolves names via a one-time per-request index instead of
1463
+ scanning the record on every get/set (measured at roughly a fifth of the framework's own CPU on a
1464
+ realistic middleware-carrying route).
1465
+ Guarded response headers (a raw `fetch()`ed Response) are now detected with a reversible probe
1466
+ before any header hook runs, instead of catching the mutation `TypeError` and re-running the hook
1467
+ against a clone - a hook that itself throws `TypeError` no longer runs twice. Framework-constructed
1468
+ responses stamp their headers as known-mutable at construction, so the hot path answers that
1469
+ question with a single weak-set lookup and only a handler-returned foreign `Response` ever pays the
1470
+ probe, once per headers object.
1471
+ New portable middleware hook: `onResponseHeaders(headers, req, status)` - the recommended shape for
1472
+ response middleware that only reads or writes headers. One implementation runs on every runtime: on
1473
+ the Web serving paths it mutates the response's own `Headers` inside the normal response walk (no
1474
+ clone), and on Node it self-pairs as a native hook against the outcome record, so registering one
1475
+ never forces the Node adapter off its direct socket writer the way a full `onResponse(res:
1476
+ Response)` hook does.
1477
+ Native Node hook lanes now engage as a unit - the response-side native hooks run only when the
1478
+ request side is native too - which makes the native request context's identity stable across a
1479
+ request; the context also carries `url`, and both are documented so middleware twins can key
1480
+ per-request state on it. Building a Web `Request` from a Node request fills its header list once
1481
+ from a plain record instead of copying a prebuilt `Headers` a second time.
1482
+ Query and cookie parsing intern repeated key names on V8-based runtimes (Node, Deno) through a small
1483
+ bounded cache - V8 pays ~13x to store a freshly-sliced string key on the null-prototype records the
1484
+ parsers build, so handing back the first-seen key makes the store take the fast path. High-cardinality
1485
+ or oversized keys bypass the cache and behave exactly as before, and JSC (Bun) skips the scheme
1486
+ entirely (it has no such cost).
1487
+ When any route registers `onResponseBody`, every JSON response with caller-set headers (the common
1488
+ shape once a route has middleware) stopped pre-building a throwaway `Headers` instance just to
1489
+ check for an existing `content-type` - that instance was immediately handed to `new Response()`,
1490
+ which does its own header ingestion regardless, so the pre-build was pure waste. The check now runs
1491
+ against whatever shape the headers already are (a plain record gets a shallow copy only when
1492
+ `content-type` is absent; an already-built `Headers` is mutated in place, as before) and that result
1493
+ goes straight into the `Response` constructor. Deno/V8 charged far more for the discarded `Headers`
1494
+ instance than Bun/JSC did - measured previously as the entire gap between the payload tier's Deno
1495
+ row and its own raw ceiling on the realistic-shape benchmark; that row now leads every peer
1496
+ framework and sits within a few percent of raw `Deno.serve` again.
1497
+ On Deno, JSON responses that carry caller-set headers are now built as a bare `Response` whose
1498
+ headers are set individually afterwards, instead of handing the header record to the constructor -
1499
+ Deno charges far more to ingest a header-record init than to mutate a built response's `Headers`.
1500
+ The runtime's own `Response.json` content-type is probed once and reused, so the wire contract is
1501
+ exactly what `Response.json` ships on that runtime, and Bun keeps the constructor path it measures
1502
+ faster on. Measured +7% on the realistic middleware GET row and +12.7% on its body-hash variant;
1503
+ with this, the realistic Deno rows lead the closest peer framework on both GET and POST.
1504
+ The native header view's one-time name index is now authoritative for every operation, including
1505
+ writes of names not yet present: setting a new header no longer walks and lowercases the whole
1506
+ record on the way in, which had made each fresh `set()` cost grow with the headers already written
1507
+ (measured +2.2% end to end on the realistic middleware-carrying Node GET row). Case-insensitive
1508
+ reads still cover the record as first seen plus everything written through the view; a native twin
1509
+ writing the record directly uses lowercase names - the wire form the record documents.
1510
+ New response tier: `app.responseHeaders(record)` (and `responseHeaders` on a middleware bundle) for
1511
+ response headers with no per-request decision behind them. Declaring them registers no response hook,
1512
+ so the values fold into response construction - one prebuilt init for JSON renders, one record merge
1513
+ where the request set its own headers - and an app whose response middleware is only static keeps the
1514
+ lanes a hook closes: Bun's fused native routes, and the Node direct socket writer that a full
1515
+ `onResponse` gives up. They still apply to every response a hook would cover (success, error,
1516
+ 404/405, timeout, short-circuit), byte-identically to registering the same names as an
1517
+ `onResponseHeaders` hook, on every runtime - pinned by parity suites over `app.fetch`, the Node
1518
+ adapter's own socket writes, and the Deno serve path. Declared headers are DEFAULTS: a value the
1519
+ request produced (`c.set.headers`, or a response hook) wins, whatever casing it used, and one name
1520
+ spelled two ways still ships as one header line. Names are lowercased once at wire-up; a non-string
1521
+ value, an invalid name, `__proto__`, or a name the render owns (`content-type`, `content-length`,
1522
+ `transfer-encoding`, `set-cookie`) throws a `TypeError` there instead of surfacing on the wire.
1523
+ Declarations made before any response hook merge into one record; one made after a hook registers as
1524
+ an ordinary header hook so registration order is preserved.
1525
+ `securityHeaders()` and `poweredBy()` (in its default respect-existing configuration) now declare
1526
+ their headers instead of writing them from a hook: measured +11% on a bare Bun `GET` behind
1527
+ `securityHeaders()`, within noise on Node and Deno (where the per-response header writes, not the
1528
+ response walk, dominate). Middleware whose headers depend on the request keeps the hook - `cors`
1529
+ reflects an origin, and `cacheControl` gates on method and status.
1530
+
1531
+ ## 2.8.2
1532
+
1533
+ ### Patch Changes
1534
+
1535
+ - f7d68e8: Numeric limit options (body/payload byte caps, TTLs, cache sizes, concurrency, ISR revalidate windows) are now validated at construction and throw a `RangeError` on non-finite or out-of-range values instead of silently disabling the bound - a `NaN` cap previously made `size > max` comparisons fail open. JWT `requiredClaims` now checks own properties only, so inherited names like `toString` no longer satisfy a required claim. `@nifrajs/mcp-db` gates multi-statement input with a real tokenizer, bounds `run_query` materialization to `maxRows + 1` via a wrapping subquery, and skips SQLite planner pseudo-nodes when verifying the table allowlist. `nifra scaffold` refuses to write through symlinked route directories.
1536
+
1537
+ ## 2.8.1
1538
+
1539
+ ### Patch Changes
1540
+
1541
+ - 78d66a4: Node serving got measurably faster - ahead of Fastify on every workload in our benchmark, where it previously trailed by ~2%. Three changes, all behavior-preserving:
1542
+
1543
+ - Buffered responses (node-direct JSON and SSR body outcomes) now declare `Content-Length` instead of falling back to `Transfer-Encoding: chunked` - a known-length body never needed chunked framing, which cost extra wire bytes and client parsing on every response, and no other runtime chunked these.
1544
+ - The socket-peer platform object is built once per connection and reused across keep-alive requests (the peer address cannot change mid-socket).
1545
+ - Routing on Node now splits pathname/search straight from the origin-form request target; the absolute URL is only synthesized if something actually reads `c.req.url`. `RequestSource` gained an optional `urlParts` field for sources that already hold the split target.
1546
+ - An absent-header probe no longer falls through to the source's `Headers` object: a source-implemented `header()` returning `null` is authoritative. The fallthrough was materializing a full `Headers` on every POST (the body lane's `transfer-encoding` check), measured at ~4% of request CPU - the POST lane on Node is ~9% faster without it.
1547
+
1548
+ - 93fdc89: Fix the unhandled-request-error log so it carries the error's own message.
1549
+
1550
+ The record is built by spreading the caller's fields and then setting the framework's own keys, so
1551
+ `level`, `message`, and `time` always win. The error log passed the thrown error's text as `message`,
1552
+ which meant it was overwritten by the log message itself and never reached the sink:
1553
+
1554
+ ```json
1555
+ {
1556
+ "method": "GET",
1557
+ "path": "/boom",
1558
+ "name": "Error",
1559
+ "message": "unhandled request error",
1560
+ "stack": "Error: kaboom\n at ..."
1561
+ }
1562
+ ```
1563
+
1564
+ The real text survived only incidentally inside `stack`, and was lost outright for a non-`Error`
1565
+ throw, which has no stack to hide in. It is now emitted as `detail`, matching the response-contract
1566
+ logs:
1567
+
1568
+ ```json
1569
+ {
1570
+ "method": "GET",
1571
+ "path": "/boom",
1572
+ "name": "Error",
1573
+ "detail": "kaboom",
1574
+ "stack": "Error: kaboom\n at ..."
1575
+ }
1576
+ ```
1577
+
1578
+ This is a shape change for anything parsing these lines. A consumer reading `message` on an error
1579
+ record now gets the constant `"unhandled request error"` in every case rather than sometimes-there
1580
+ diagnostic text - it was already that constant, so nothing loses information, but a dashboard or
1581
+ alert grouping on that field should move to `detail`.
1582
+
1583
+ ## 2.8.0
1584
+
1585
+ ## 2.7.1
1586
+
1587
+ ### Patch Changes
1588
+
1589
+ - 52c89e0: Query-validated routes now take the fused Web lane. A route whose only lifecycle step is a query schema (no body/params schema, no hooks, no idempotency/ledger, no validation-error recovery) compiles parse + validate + handler + respond into one closure: with a sync validator and handler there is no lifecycle promise at all. Semantics are unchanged - the 422 contract, thrown-Response control flow, repeated-key promotion, async validators, `c.set`, decorations, and `merge()` all behave exactly as the generic lane, and any recovery hook or wrapper keeps the route on that lane. This is the biggest win for validated GET endpoints on V8 runtimes (Node, Deno), and it speeds up Bun too.
1590
+
1591
+ ## 2.7.0
1592
+
1593
+ ## 2.6.1
1594
+
1595
+ ### Patch Changes
1596
+
1597
+ - 5840c98: Two hot-path costs removed. On Bun, the socket peer address is now resolved lazily: `c.clientIp` keeps its documented raw-peer behavior, but the underlying `requestIP()` lookup (surprisingly expensive per request) only runs when something actually reads it - trust-mode routes still resolve it before the handler. On Node, buffered SSR responses no longer clone the response-header record (and every Set-Cookie array) before `writeHead` - the producer already hands over response-normalized names and values.
1598
+
1599
+ ## 2.6.0
1600
+
1601
+ ### Patch Changes
1602
+
1603
+ - e6349e5: Security hardening across input parsing and code generation. Every regex that runs on caller-influenced input (URL paths, route patterns, stylesheet and SVG sources, manifest text) is now linear - no polynomial backtracking on adversarial input. SVG preamble stripping and tag removal can no longer splice removed delimiters into new markers. Static file serving rejects `..` traversal in the request form outright and confines the resolved path with a `relative()` containment check. Generated code embeds strings through an escaper that neutralizes `</script>` breakout and the U+2028/U+2029 line separators, and HTML entity decoding resolves `&amp;` last so double-encoded entities cannot double-unescape.
1604
+
1605
+ ## 2.5.0
1606
+
1607
+ ## 2.4.0
1608
+
1609
+ ### Patch Changes
1610
+
1611
+ - 138bfba: Route precedence now has one home: a `sortRoutesBySpecificity` helper on `@nifrajs/core/pattern` orders compiled routes most-specific-first (a static segment beats a dynamic one). The web router, the mock server, and the editor plugin all order routes through it, so which file a path resolves to stays identical across runtime, client, and editor.
1612
+
1613
+ ## 2.3.0
1614
+
1615
+ ### Minor Changes
1616
+
1617
+ - 6f5b3ad: Assurance rules can match a CLASS of capability rather than a list of token names.
1618
+
1619
+ ```ts
1620
+ { name: "authenticated-write", match: { access: "write", zone: "domain" }, require: [AUTHENTICATED] }
1621
+ ```
1622
+
1623
+ Naming exact tokens is precise but closed. A rule listing `db.write` does not cover `storage.write`, so
1624
+ every policy has to enumerate every write in the system, and a capability introduced next year escapes
1625
+ the rule until someone remembers to widen it. `access` and `zone` are read from the capability
1626
+ definitions, so the rule is keyed on what the capability IS - a new token is covered the day it is
1627
+ declared.
1628
+
1629
+ Both constraints must hold for the SAME capability: a route that reads business state and writes an
1630
+ audit log does not satisfy `{ access: "write", zone: "domain" }` by combining halves of two tokens.
1631
+
1632
+ The selectors resolve through the capability definitions, so `evaluateRouteAssurance` takes them via a
1633
+ new third argument (`{ definitions }`) and `defineAssuranceConfig` refuses a policy that uses them
1634
+ without a `capabilities` block. Without definitions such a rule could only ever match nothing - and a
1635
+ rule that matches nothing does not fail, it lets the route fall past to whatever laxer rule comes next.
1636
+
1637
+ - 85b354d: Assurance rules can match on a route's declared capabilities, and a misspelled selector key is now an error.
1638
+
1639
+ ```ts
1640
+ { name: "authenticated-write", match: { capabilities: ["db.write"] }, require: [NIFRA_ASSURANCE.AUTHENTICATED] }
1641
+ ```
1642
+
1643
+ A path glob is the wrong tool for "anything that writes must prove who asked": it breaks when a route
1644
+ moves, and it cannot see a route that acquires the capability later. The declared tokens already reach
1645
+ reflection, so a policy can be written against what a route DOES. Matches when the route declares any of
1646
+ the listed tokens.
1647
+
1648
+ Every `create-nifra` template ships this rule, which is what stops a server function - a public POST
1649
+ endpoint whose arguments the caller controls - from shipping unauthenticated.
1650
+
1651
+ The selector is rebuilt from an allowlist of known keys, so an unrecognised one used to be dropped
1652
+ silently. A selector that loses its only constraint matches EVERY route, so the rule swallows
1653
+ everything after it - in a policy whose first rule is the lenient one, a single typo disabled the rest
1654
+ of the file. Unknown selector keys are refused rather than ignored.
1655
+
1656
+ - 8514caa: A capability requiring durable idempotency has an adapter that satisfies it.
1657
+
1658
+ ```ts
1659
+ import { createDurableEffectJournal } from "@nifrajs/core/durable-execution";
1660
+ import { durableCommand } from "@nifrajs/middleware";
1661
+
1662
+ const app = server()
1663
+ .use(durableCommand({ journal: createDurableEffectJournal({ store }) }))
1664
+ .post("/charge", { capabilities: ["billing.charge"] }, (c) =>
1665
+ executeCapability(c, "billing.charge", {}, () => gateway.charge(order))
1666
+ );
1667
+ ```
1668
+
1669
+ A capability defined `idempotency: "durable"` requires `nifra.durable-command` evidence, and nothing
1670
+ shipped produced it. The tier was reachable, but only by writing `assurance: ["nifra.durable-command"]`
1671
+ on the route - an assertion with nothing behind it, and wrong in both directions: a route that
1672
+ genuinely journals its effects but omits the string fails `nifra check`, and a route that journals
1673
+ nothing but includes it passes. Every other assurance id has a shipped emitter; this one now does too.
1674
+
1675
+ The evidence is a by-product rather than a claim. Installing the adapter puts the journal on the
1676
+ request, so `executeCapability` records intent before an effect runs and exactly one terminal outcome
1677
+ after - which is what the tier is asking about. `executeCapability` resolves an explicitly passed
1678
+ `journal` first, so existing call sites are untouched, and a journal missing a transition is refused at
1679
+ wiring time rather than surfacing as a TypeError partway through a production effect.
1680
+
1681
+ `attachCapabilityJournal` and `capabilityJournalOf` are exported from `@nifrajs/core/capabilities` as
1682
+ the seam the adapter uses.
1683
+
1684
+ Response replay is still not durable-command evidence, and that is deliberate: if the process dies
1685
+ between the effect and storing its response there is nothing to replay, so replay cannot be what makes
1686
+ an effect exactly-once. The journal survives that; only the journal clears the tier.
1687
+
1688
+ - ea0a27f: Capability provenance says when it could not finish, instead of reporting a clean project.
1689
+
1690
+ The reachability walk stops at a module count and an import depth so a pathological graph cannot hang
1691
+ the check. Hitting either limit used to end the walk quietly, and the route came back covered - a
1692
+ passing report whose subject was partly unexamined, which is the shape of failure this whole gate
1693
+ exists to prevent.
1694
+
1695
+ Both limits now produce a `provenance-truncated` finding naming the route and the chain that reached
1696
+ it, the check fails, and the lockfile refuses to record a snapshot taken from a truncated walk.
1697
+
1698
+ - b271164: Add `responseContract()`, an opt-in plugin that makes a route's declared `response` schema hold at runtime.
1699
+
1700
+ A `response` schema is a lower bound: it says "at least these fields", never "only these". A handler
1701
+ that returns a database row satisfying it also ships every other column, and nothing points at it -
1702
+ TypeScript's excess-property check does not reach a handler's return position, and the client's type
1703
+ reports the contract rather than the bytes. The result can appear with no code change at all: add a
1704
+ column, and the next deploy ships it to browsers.
1705
+
1706
+ ```ts
1707
+ import { responseContract } from "@nifrajs/core/response-contract";
1708
+ app.use(responseContract("enforce"));
1709
+ ```
1710
+
1711
+ - not installed (default) - unchanged behaviour, and the lane is absent from the bundle entirely.
1712
+ - `"warn"` - checks each response, logs the undeclared fields by name, serves the payload unchanged.
1713
+ - `"enforce"` - serializes the validated value, so undeclared data cannot reach the wire.
1714
+
1715
+ Enforcement follows the schema's own semantics, since Standard Schema exposes `validate` and no way to
1716
+ enumerate declared keys: a stripping schema (Zod, Valibot) yields a cleaned value, while a strict one
1717
+ (`@nifrajs/schema`'s `t.object`) reports issues and the response becomes a 500 with the detail logged
1718
+ rather than returned. Routes with a `response` schema leave the fused and native fast paths while this
1719
+ is enabled, the same trade an idempotent route makes.
1720
+
1721
+ It is a plugin rather than a server option so that apps which do not use it do not carry it: as an
1722
+ option the check was statically imported by the kernel and cost every app ~0.5 KB gzip, which the
1723
+ bundle-size gate caught. Behind the `@nifrajs/core/response-contract` subpath the lane arrives only
1724
+ when installed, and the kernel keeps just the install seam (~0.2 KB).
1725
+
1726
+ - 5fe332a: A route can declare that it returns bytes, and the client types it as `Blob`.
1727
+
1728
+ ```ts
1729
+ import { bytes } from "@nifrajs/core/binary";
1730
+
1731
+ app.get("/invoice.pdf", async (c) =>
1732
+ bytes(await render(c.params.id), {
1733
+ type: "application/pdf",
1734
+ filename: "invoice.pdf",
1735
+ })
1736
+ );
1737
+ ```
1738
+
1739
+ Sending bytes was always possible - return a raw `Response` - but a raw `Response` is exactly what the
1740
+ typed client cannot describe. So a download route needed a `// nifra-expect raw-response` pragma to
1741
+ quiet the drift advisory, and its caller got no type at all. One category of endpoint sat outside the
1742
+ contract the framework is otherwise strict about.
1743
+
1744
+ `bytes()` closes that. The brand it carries is a phantom - nothing is added to the value at runtime -
1745
+ and it exists so the type can say a thing the value cannot: that these bytes are the payload rather
1746
+ than a serialization accident. A plain `Response` is unaffected and still types as it did.
1747
+
1748
+ `filename` handles anything a person can type. Characters that would end the header value early are
1749
+ stripped, and a name ASCII cannot carry is encoded per RFC 6266 (`filename*=UTF-8''...`) rather than
1750
+ throwing - setting a header containing `\u62a5\u544a.pdf` or an emoji raises, which on a download route
1751
+ would be a 500 for the ordinary act of naming a file, and the name is usually the user's own.
1752
+
1753
+ - d2840ac: A safe-method route that can reach a domain write is reported once, with the fix it actually has.
1754
+
1755
+ It used to draw two findings giving opposite advice: `undeclared-capability-evidence` (declare what you
1756
+ reach) and `safe-method-domain-write` (a safe method may not declare a domain write). Both are correct
1757
+ and together they are a dead end - the route cannot declare its way out, because the declaration was
1758
+ never the problem.
1759
+
1760
+ Reach is computed from the module that registers a route, so a read endpoint sitting beside a write
1761
+ seam has write powers in scope. The new `unconfined-write-reach` finding says that, and says to move
1762
+ the route or the effect. Still an error, and the report still fails; a GET that explicitly DECLARES a
1763
+ domain write is unchanged, because that one really is an HTTP semantics mistake.
1764
+
1765
+ ### Patch Changes
1766
+
1767
+ - ea0a27f: A durable table prefix cannot collide after PostgreSQL truncates it.
1768
+
1769
+ **Breaking for prefixes longer than 45 characters**, which now fail at construction rather than later.
1770
+ PostgreSQL truncates identifiers to 63 bytes, and this adapter appends up to `_records_reconcile` (18)
1771
+ to the prefix. Two distinct prefixes long enough to be cut short became one table name, silently
1772
+ sharing state between what the caller believed were separate deployments. The accepted length now
1773
+ reserves the longest suffix, so an accepted prefix survives truncation intact.
1774
+
1775
+ Both adapters also assemble their statements through a tagged template that validates every
1776
+ substitution as an identifier at the boundary, so the check is at the seam rather than trusted from a
1777
+ caller several frames up.
1778
+
1779
+ - 8c77d47: The response size limit is reachable, applies to text as well as JSON, and never applies to a download.
1780
+
1781
+ ```ts
1782
+ client<App>(url, { maxDecodedBytes: 64 * 1024 * 1024 });
1783
+ ```
1784
+
1785
+ `maxBytes` lived under `transport`, whose `codec` is required - so raising your own response limit
1786
+ meant opting into a versioned transport representation you had not asked for, and the call did not
1787
+ compile without it. The 16 MB default protected everyone while the knob was reachable by nobody. It is
1788
+ a top-level option now, with a doc comment saying what it bounds.
1789
+
1790
+ It bounds text as well as JSON, because a 2 GB string costs what a 2 GB object costs and one number
1791
+ should answer for both. It deliberately does NOT bound a binary body: that is a download, and a size
1792
+ limit on a download is a bug rather than a defence.
1793
+
1794
+ Exceeding it is a result, not a throw: `{ ok: false, status: 0, error: { error: "response_too_large" } }`,
1795
+ the shape a timeout already takes. It used to throw a `TransportCodecError` straight out of the client,
1796
+ which meant the only safe way to use the option was the try/catch the client's contract exists to
1797
+ remove. The older `transport.maxBytes` spelling still works and still wins for the transport path.
1798
+
1799
+ - ea0a27f: The same-origin check works behind a TLS-terminating proxy, and both seams that use it now agree.
1800
+
1801
+ Nifra had two of these - the WebSocket handshake's cross-origin default and the server-function mount -
1802
+ and they answered differently for one request, so a browser that could open a socket was told its POST
1803
+ was cross-origin. `isSameOriginRequest` from `@nifrajs/core/server` is now the single owner.
1804
+
1805
+ The rule it encodes: the host must match, and the Origin's scheme may be equal or STRONGER, never
1806
+ weaker. A server behind Cloudflare, a tunnel or an ingress sees a plain HTTP socket, so `request.url`
1807
+ says `http:` while the browser correctly reports an `https:` page - the shape almost every deployment
1808
+ produces. Comparing full origins rejects it, which is not the stricter option but an outage: measured,
1809
+ every server-function POST returned 403 behind a terminating proxy, while the cross-origin caller the
1810
+ comparison aimed at had already been rejected on the host alone. The reverse - an `http:` page against
1811
+ an `https:` request URL - is a downgrade with no legitimate cause, and is refused, as is any Origin
1812
+ that is not an http(s) page origin at all.
1813
+
1814
+ `X-Forwarded-Proto` is still not read, and that is the point: a forwarded header is attacker-controlled
1815
+ unless something upstream is proven to overwrite it, so trusting one by default would hand every
1816
+ unproxied deployment a spoofable origin check. Ordering the two schemes reconciles them without it.
1817
+
1818
+ ## 2.2.0
1819
+
1820
+ ### Minor Changes
1821
+
1822
+ - a4645e2: Support path segments that are part literal, part parameter.
1823
+
1824
+ A route segment had to be wholly static, wholly a parameter, or wholly a wildcard. `/:key.txt`,
1825
+ `/post-[id].html` and `/[locale]-sitemap.xml` did not merely fail to match - they failed to
1826
+ **compile**. The trigger was an IndexNow key-verification file, which the protocol requires at
1827
+ `<origin>/<key>.txt` with the key coming from deploy-time config, and at the root, because a key
1828
+ served from a subdirectory only authorises URLs beneath it. The workaround was an exact-match check in
1829
+ the app's server entry, which moved a routing concern out of the router and never ran in dev.
1830
+
1831
+ Both spellings now work: `:key.txt` in a route pattern, and `[inKey].txt.tsx` as a file route. The
1832
+ parameter name is the longest identifier run after `:`; everything else in the segment is literal.
1833
+ Precedence is static > mixed > param > wildcard, decided by shape rather than registration order, so
1834
+ `/robots.txt` still beats `/:key.txt` and `/jobs/:id.txt` beats `/jobs/:id`.
1835
+
1836
+ Inside a mixed segment, `[[optional]]` and `[...catchAll]` are **rejected** at build time rather than
1837
+ given a meaning: there is no sensible absent form for `/[[locale]]-feed.xml`, and a catch-all captures
1838
+ the rest of the path, which a trailing literal can never follow.
1839
+
1840
+ **Literal colons keep their meaning.** A `:` that follows an identifier character and runs to the end
1841
+ of its segment is literal, so the established RPC-style action shape - `/v1/things:batchGet` - still
1842
+ routes as written rather than capturing `batchGet` into a parameter named after the verb. Mixed
1843
+ parameters remain available everywhere they are unambiguous: at the start of a segment (`/:key.txt`),
1844
+ after punctuation (`/post-:id`), or with a literal suffix (`/v:major.json`). A `:` not followed by a
1845
+ valid identifier start (`/ratio:2`) is literal as before.
1846
+
1847
+ Mixed siblings are ordered by ONE total comparator shared between the server's trie router and the
1848
+ browser's matcher. Ordering by literal weight alone left ties broken differently on each side, so
1849
+ `/bar.:value` and `/:value.foo` could resolve to different routes for the same URL - visible only as a
1850
+ soft navigation rendering the wrong page.
1851
+
1852
+ Adding a mixed pattern can also make a previously unambiguous path ambiguous: with both `/jobs/:id` and
1853
+ `/jobs/:id.txt` registered, `/jobs/a.txt` now matches the mixed route with `id="a"` where before it
1854
+ could only match the bare param with `id="a.txt"`. Deterministic, and only for apps that opt in by
1855
+ registering a mixed pattern.
1856
+
1857
+ An app that registers no mixed segment allocates nothing for this and pays one `undefined` check on
1858
+ the match path. The rejected-parameter hint added in the previous release is removed - `:id.json` was
1859
+ the shape it explained, and `:id.json` now compiles.
1860
+
1861
+ ### Patch Changes
1862
+
1863
+ - 5f460db: Fix `nifra init-agents`, and explain rejected route parameters.
1864
+
1865
+ `nifra init-agents` failed for every installed user with `Cannot find module 'create-nifra/agent-files'`.
1866
+ The `./agent-files` subpath resolves through the `bun` condition to `src/agent-files.ts`, which the
1867
+ published tarball did not contain - the package shipped `dist` and the templates only. It now ships
1868
+ that source file, so the subpath resolves from a real install. Reproduced from a packed 2.1.0 tarball
1869
+ before and after.
1870
+
1871
+ An invalid route parameter now says why. Route grammar is per-segment - a segment is wholly static or
1872
+ wholly a parameter - so everything after the colon is the name, and `/v/:id.json` asks for a parameter
1873
+ literally called `id.json`. The previous `invalid parameter ":id.json"` read as a typo rather than a
1874
+ rule; the message now names the limitation and gives both ways out (`/v/:id/json`, or capture the whole
1875
+ segment and split it in the handler). Reserved names, an empty name, and a name that is invalid for
1876
+ some other reason each get their own explanation instead of sharing one.
1877
+
1878
+ Note for anyone who has hit this: a segment that merely _contains_ a colon without starting with one,
1879
+ such as `/a/pre-:id`, is a literal static segment and captures nothing. That is deliberate - a colon is
1880
+ legal inside a URL path segment (`/v1/things:batchGet`) - and is now covered by a test that documents it.
1881
+
1882
+ - e713cab: Let a route loader answer 404 and 410.
1883
+
1884
+ A matched route whose loader finds nothing had no supported way to set its page's status, so the path
1885
+ of least resistance was to return empty data and render "not found" inside a **200**. That is a soft
1886
+ 404: search engines penalise it and keep the dead URL indexed, and because the page looks correct in a
1887
+ browser it ships and stays shipped. It is the most common page shape there is - a detail route whose
1888
+ record may not exist.
1889
+
1890
+ `notFound()`, `gone()`, and `statusPage(status)` are thrown from a loader, the way `redirect()` already
1891
+ is. They render the `_404` page - or `_410.tsx` / `_<status>.tsx` if the app authored one - inside the
1892
+ normal layout chain, hydrated, at the right status. A `headers` option carries the cache policy each
1893
+ status wants: a 404 may be racing publication and wants a short TTL, while a 410 is a promise that the
1894
+ URL is permanently gone. Typed `never`, so a loader narrows without a redundant `return`.
1895
+
1896
+ 410 is not a pedantic 404: it tells a crawler to drop the URL instead of re-fetching it for weeks.
1897
+
1898
+ Existing behaviour is unchanged by construction. The signal is a branded `Response` and the brand is
1899
+ checked before the verbatim pass-through, so `throw redirect(...)`, `throw new Response(...)`, and a
1900
+ real `Error` reaching `_error` all behave exactly as before. Client-side navigation and prerendering
1901
+ already handle a non-ok render correctly and now have tests pinning that: a soft-nav falls back to a
1902
+ full navigation and lands on the same page, and a prerendered path whose loader signals is omitted
1903
+ from the build rather than baked as a static 200 shell.
1904
+
1905
+ `renderPageResult` gains a `headers` option. `content-type` and the ISR freshness header stay
1906
+ framework-owned and cannot be overridden through it.
1907
+
1908
+ Also trims the router's rejected-parameter message added in the previous release. The explanation cost
1909
+ ~0.3 KB gzip in every bundle; it now states the grammar rule and the two ways out without building an
1910
+ example path, which is a third smaller and keeps the base bundle inside its budget.
1911
+
1912
+ - 6aa0aac: Add `previewEndpoint` for draft/preview mode, and make transport codec decode errors uniform.
1913
+
1914
+ `previewEndpoint({ secret, draftSecret })` is a `fetch` handler for the link your CMS's "Preview"
1915
+ button points at: it checks the preview token in constant time, turns draft mode on with the signed
1916
+ `__nifra_draft` cookie, and redirects the editor to the requested page. It is the link-borne sibling
1917
+ of `revalidateEndpoint`, and it exists because gating the route by hand means writing two checks that
1918
+ are easy to get subtly wrong and that fail silently when you do - the token compare must not exit
1919
+ early on the first wrong character, and the `?to=` destination must not be allowed off-site
1920
+ (`//evil.com` and `/\evil.com` both start with a slash yet navigate away). Wrong or missing token
1921
+ gives `401`, an off-site destination `400`, and success a `302` carrying `Cache-Control: no-store`
1922
+ so no shared cache can replay one editor's draft session to a visitor. Param names, the fallback
1923
+ destination, and cookie lifetime/path/`Secure` are all configurable.
1924
+
1925
+ `decodeTransportFrame` and `decodeTransportResponse` now raise `TransportCodecError` for a malformed
1926
+ payload instead of letting the underlying `SyntaxError` through, with the original kept as `cause`.
1927
+ Every other failure in that module was already a `TransportCodecError`, so a malformed payload - the
1928
+ likeliest hostile input - was the one case that slipped past callers catching the documented error
1929
+ type. `TransportCodecError` accepts an `ErrorOptions` second argument to carry that cause. Bytes that
1930
+ are not valid UTF-8 take the same path: the `TypeError` from the strict decoder used to escape ahead
1931
+ of any codec, so the one input that never reached a codec at all was also the one that reported
1932
+ differently from every other decode failure.
1933
+
1934
+ ## 2.1.0
1935
+
1936
+ ### Minor Changes
1937
+
1938
+ - bd294bb: Add `executeCapability()` as a correlated, policy-aware effect boundary.
1939
+
1940
+ - Correlate intent and terminal evidence with a random `effectId`, record committed/failed outcomes
1941
+ automatically, and combine request cancellation with bounded async `aroundCapability()` admission
1942
+ policies while preserving the synchronous `useCapability()` path.
1943
+ - Retain idempotency results for every completed response, including non-2xx outcomes, so a retry
1944
+ cannot repeat an effect that succeeded before a later handler failure.
1945
+ - Add durable approval, effect journal, saga/compensation, and reconciliation primitives behind the
1946
+ `durable-execution` subpath, plus token-only OpenTelemetry effect spans from `@nifrajs/otel/effects`.
1947
+ Reconciliation supports bounded cursor pages, approval resume tokens stay out of ordinary error
1948
+ serialization, durable terminal states are monotonic, crash ambiguity has an effect-ID-bound operator
1949
+ resolution API, and unmatched effect spans have bounded retention.
1950
+ - Add one shared owned-effect scope across capabilities, saga execution, compensation, idempotency
1951
+ evidence, durable transitions, and telemetry. An explicit `markIdempotencySafeToRetry()` outcome
1952
+ releases a resolved 5xx only while the scope proves no effect began.
1953
+ - Add negotiated, versioned transport codecs with bounded plain-JSON and rich-wire adapters for HTTP,
1954
+ the typed client, loader NDJSON, and WebSocket frames.
1955
+ - Add Postgres, SQLite, and Durable Object durable-execution adapters with one reusable conformance
1956
+ suite, plus leased reconciliation workers with bounded pages/concurrency, durable cursors, filters,
1957
+ cancellation, backpressure, and token-only metrics.
1958
+
1959
+ - d3aac63: Add `@nifrajs/core/wire`: a rich-type JSON codec (`encode` / `decode` / `stringify` / `parse`) for RPC
1960
+ bodies, loader payloads, and WebSocket frames.
1961
+
1962
+ Plain `JSON` drops `undefined`, stringifies `Date`, nulls `NaN`/`Infinity`, loses `-0`, throws on
1963
+ `BigInt`, and has no notion of `Map`, `Set`, `RegExp`, `URL`, `ArrayBuffer`, or typed arrays - so a
1964
+ typed client can receive a runtime value whose shape diverges from the type it inferred from the server.
1965
+ The codec round-trips all of those exactly, preserves shared-reference identity, and encodes cycles as
1966
+ back-references instead of throwing. Malformed input decodes to a typed `WireDecodeError`; functions and
1967
+ symbols are rejected on encode rather than silently dropped. Decode is secure by default: object keys
1968
+ cannot mutate prototypes, every node shape is validated, and configurable node/depth/collection/byte
1969
+ budgets reject resource-exhaustion payloads.
1970
+
1971
+ ## 2.0.0
1972
+
1973
+ ### Major Changes
1974
+
1975
+ - 82676e0: Optional server systems are now opt-in `.use()` plugins installed from dedicated subpaths - never server options, side-effect imports, or process-global registries.
1976
+
1977
+ - Enable request idempotency with `.use(idempotency())` from `@nifrajs/core/idempotency-plugin` - pass `{ store }` for a durable app-wide default. The `idempotencyStore` server option is removed.
1978
+ - Enable the per-request effect ledger with `.use(effectLedger({ sink }))` from `@nifrajs/core/effect-ledger`. The `effectLedger` server option is removed.
1979
+ - Enable MCP declarations (`.tool()`, `.resource()`, `.prompt()`) with `.use(mcp())` from `@nifrajs/core/mcp`. The package root does not activate them implicitly.
1980
+ - Enable typed SSE routes (`.sse()`) with `.use(streaming())` from `@nifrajs/core/sse`.
1981
+ - Enable WebSocket routes (`.ws()`) with `.use(websocket())` from `@nifrajs/core/ws`. The old `import "@nifrajs/core/ws"` side-effect no longer installs the runtime.
1982
+ - A route that declares one of these without its plugin installed fails loudly at registration, so a gate can never be silently dropped by a forgotten plugin.
1983
+
1984
+ Each plugin installs its runtime on that server instance only - two servers in one process never share opt-in state. Merging a configured sub-app with `.use(subApp)` carries its installed runtimes across.
1985
+
1986
+ A `server()` that uses none of these pulls none of their code into its bundle, so the minimal server footprint is smaller.
1987
+
1988
+ Migration:
1989
+
1990
+ ```ts
1991
+ // before
1992
+ server({ idempotencyStore, effectLedger: { sink } });
1993
+
1994
+ // after
1995
+ import { effectLedger } from "@nifrajs/core/effect-ledger";
1996
+ import { idempotency } from "@nifrajs/core/idempotency-plugin";
1997
+ import { mcp } from "@nifrajs/core/mcp";
1998
+ import { streaming } from "@nifrajs/core/sse";
1999
+
2000
+ server()
2001
+ .use(idempotency({ store: idempotencyStore }))
2002
+ .use(effectLedger({ sink }))
2003
+ .use(mcp()) // if the app declares tools/resources/prompts
2004
+ .use(streaming()); // if the app declares .sse() routes
2005
+
2006
+ // WebSocket apps:
2007
+ // before: import "@nifrajs/core/ws"; server().ws(...)
2008
+ // after: import { websocket } from "@nifrajs/core/ws"; server().use(websocket()).ws(...)
2009
+ ```
2010
+
2011
+ Standalone callers of `app.resolveNode()` opt in with `.use(nodeDirect())` from `@nifrajs/core/node-direct`. The `@nifrajs/node` adapter installs it on `serve(app)` automatically, so normal Node deployments need no change and keep the direct JSON fast path.
2012
+
2013
+ - a7b1d60: WebSocket routes are now enabled with `.use(websocket())` from `@nifrajs/core/ws`, matching every other opt-in system. The old `import "@nifrajs/core/ws"` side-effect no longer installs the runtime.
2014
+
2015
+ The runtime installs on that server instance only (no process-global), so `app.ws()` without the plugin fails loudly at registration. Adapters and `@nifrajs/workers` still import `attachWebSocket` / `TopicRegistry` from the same subpath.
2016
+
2017
+ ```ts
2018
+ // before
2019
+ import "@nifrajs/core/ws";
2020
+ const app = server().ws("/chat", handler);
2021
+
2022
+ // after
2023
+ import { websocket } from "@nifrajs/core/ws";
2024
+ const app = server().use(websocket()).ws("/chat", handler);
2025
+ ```
2026
+
2027
+ ### Minor Changes
2028
+
2029
+ - a7b1d60: Add `c.clientIp` - the caller's IP, derived correctly and vendor-neutrally.
2030
+
2031
+ By default it is the raw socket peer the serving adapter observed (`listen()`, `@nifrajs/node`, `@nifrajs/deno` supply it; any caller can pass it via `app.fetch(req, { clientIp })`), the one address a client cannot forge - and never a forwarded header. Behind a reverse proxy or CDN, set the `clientIp` server option to derive the real caller from the forwarding chain as far as you trust it:
2032
+
2033
+ - `server({ clientIp: { trustedHops: n } })` reads `X-Forwarded-For` past `n` proxies you operate (a short header fails closed to `undefined`);
2034
+ - `server({ clientIp: { header: "x-real-ip" } })` trusts one edge-set header's first value.
2035
+
2036
+ Declaring trust the app can't enforce would let clients forge their IP, so it stays unset by default. `c.clientIp` is safe to key rate limits and audit logs on, and is resolved once before handlers, `derive`, and hooks run.
2037
+
2038
+ - eaac3d7: Route assurance reaches two places it couldn't before: in-handler guards and dynamic route families.
2039
+
2040
+ - **Inline `schema.assurance`.** A route (or contract op) can declare the enforcement evidence it carries adjacent to the handler - `{ assurance: [NIFRA_ASSURANCE.AUTHENTICATED] }` - and each id reflects as route-scoped `declared` evidence. A route whose guard runs inside the handler body (invisible to reflection) can now satisfy a policy `require:` clause without being rewritten into a `withRouteAssurance`-marked middleware. Invalid evidence ids fail closed at registration.
2041
+ - **`flagClassifiedWithoutEvidence` policy option.** Opt-in. When set, a route matched by a pure-classification rule (no `require`, no `forbid`) that carries no evidence is reported as `classified-no-evidence` - making the "a classification-only policy silently degrades proof to a label" gap visible instead of green. Off by default (a genuinely public route legitimately carries no evidence).
2042
+ - **`schema.family` dynamic route families.** A runtime-resolved template (`/api/:slug/:resource` over tenant-defined tables, a catch-all dispatcher) can be marked `{ family: true }`. It surfaces as `family` in reflection, so the assurance gate and tooling read the one templated route as a deliberate family whose evidence covers every runtime-resolved resource, rather than a single forgotten route. Purely declarative - it does not change dispatch.
2043
+
2044
+ - ade0c7a: Add a curated `@nifrajs/core/server` entry for the common HTTP runtime and dedicated subpaths for
2045
+ contracts, classification, cookies, logging, routing, Standard Schema, SEO, SSE, and webhooks. The
2046
+ package root remains backwards compatible, while new scaffolds and first-party runtime packages avoid
2047
+ eagerly parsing opt-in causality, invariant, manifest, reflection, capability, and assurance tooling.
2048
+ - 1522d06: Path params can now be validated + coerced at the boundary, and query scalars have a coercing constructor - closing the two input slots that lagged behind `body`.
2049
+
2050
+ - **`params` schema slot.** A route (or contract op) can declare `params: t.object({ id: t.string({ format: "uuid" }) })`; a malformed `:id` is now a `422` before the handler runs, exactly like `body`/`query`, instead of an in-handler hand-check. The validated value lands on `c.params` with the schema's output type (a `params` schema can also coerce - use `t.query({ id: t.integer() })` for a numeric path param, and `c.params.id` is a real `number`). Routes without a `params` schema are unchanged: `c.params` stays the path-inferred `Record<name, string>`. The `onValidationError` hook's `kind` gains `"params"`, and params validate first (before body/query). The client's param-call signature is unchanged - a URL segment is still passed as a string.
2051
+ - **`t.query(shape)`.** The query-slot analogue of `t.object`, with string->scalar coercion on. Query values always arrive as strings (`?limit=20` -> `"20"`), so a plain `t.object({ limit: t.integer() })` in a `query` slot never validates; `t.query` makes `t.integer()`/`t.number()`/`t.boolean()` fields real numbers/booleans in `c.query`. Open by default (unknown fields such as tracking params are accepted); pass `{ additionalProperties: false }` to enforce a strict allowlist. `t.object` stays the body-slot constructor (a JSON body is already typed - no coercion).
2052
+
2053
+ - a7b1d60: WebSocket routes join the end-to-end type chain, and client failures discriminate by status.
2054
+
2055
+ - `app.ws()` now enters the type-level registry (pseudo-method `"WS"`). The typed client grows a
2056
+ `.ws()` handle per WS route: `send()` accepts the route's `messageSchema` input type, received
2057
+ frames are typed from the new `sendSchema` option (an outbound, type-level contract), and both
2058
+ fall back to `unknown` when undeclared. The handle queues sends until open, exposes
2059
+ `messages()` (async iteration), `onMessage()`, `opened`, `close()`, and `raw`. Params, path
2060
+ literals, and `client<App>` inference work exactly like HTTP routes. Calling `.ws()` on the
2061
+ in-process client throws with an explanation (an in-process app has no socket to upgrade).
2062
+ - The client's `Result` failure union is now DISCRIMINATED BY STATUS when a route declares an
2063
+ `errors` record: `res.status === 404` narrows `res.data` to the declared 404 body. Undeclared
2064
+ statuses (and `0` for transport errors) fall into a fallback arm whose `data` is `unknown`;
2065
+ routes with no `errors` contract keep the single `unknown` failure arm. Contract operations'
2066
+ non-2xx `responses` discriminate the same way. Breaking for type-level consumers only: code that
2067
+ read the failure `data` after checking just `ok` must also narrow on `status` (the runtime shape
2068
+ is unchanged).
2069
+ - `testClient(app, { validateResponses: true })` asserts every JSON response against the route's
2070
+ declared contract - `response` for 2xx, `errors[status]` for declared failures - and throws a
2071
+ `ResponseContractViolation` on mismatch, so a handler whose real output drifts from its schema
2072
+ fails the test instead of passing silently. Off by default; statuses with no declared schema,
2073
+ non-JSON bodies, and 204/205/HEAD pass through unchecked.
2074
+
2075
+ ## 1.13.0
2076
+
2077
+ ### Minor Changes
2078
+
2079
+ - aae8614: `implement(contract, handlers, app)` accepts a pre-configured app, so a contract-first backend can run
2080
+ middleware. A route captures the server's `derive`/`decorate`/assurance chain at registration, so the
2081
+ chain has to be on the app before its routes exist:
2082
+
2083
+ ```ts
2084
+ const app = implement(
2085
+ contract,
2086
+ handlers,
2087
+ server().use(auth).derive(sessionOf)
2088
+ );
2089
+ ```
2090
+
2091
+ Handlers now receive `Context & Ctx` - the same shape an inline handler gets, so one graduates either
2092
+ way unchanged - and any routes already on the app stay in the returned server's registry. This is also
2093
+ what lets `nifra assure` prove a contract-first app rather than only classify it: the plugin that
2094
+ installs the enforcement is what declares the evidence, and only a plugin installed before
2095
+ registration is captured. The two-argument call is unchanged.
2096
+
2097
+ - 5b6127a: Make route batches atomic, seal server configuration after `listen()`, encode array query values as
2098
+ repeated keys, and align web route matching with the server.
2099
+
2100
+ Three behavior changes to know about:
2101
+
2102
+ - **Configuring a server after `listen()` now throws** instead of reaching some traffic and not the
2103
+ rest. Bun's native route table is compiled when you listen, so a hook added afterwards applied to
2104
+ `app.fetch()` but not to real HTTP requests: an `onRequest` guard installed late was silently
2105
+ skipped on the wire. Register routes, hooks, plugins, and context before listening.
2106
+ - **Array query values serialize as repeated keys** (`?tag=a&tag=b`), not `?tag=a%2Cb`, so a route
2107
+ whose `query` schema declares an array now receives one.
2108
+ - **The web matcher applies the server's trailing-slash rule.** `/users/7/` no longer matches
2109
+ `/users/:id` in the browser, matching the 404 the server already returns, and a malformed percent
2110
+ encoding reports no route instead of throwing.
2111
+
2112
+ A route batch from `implement()` or `merge()` commits only once every route in it validates, so a
2113
+ collision partway through leaves matching and reflection untouched instead of stranding the routes
2114
+ registered before it.
2115
+
2116
+ Each route now owns one immutable compiled execution plan shared by portable, Node-direct, and
2117
+ Bun-native dispatch. This also fixes validation recovery being skipped when a derive moved a route
2118
+ from a specialized lane to the generic lifecycle.
2119
+
2120
+ Core, browser navigation, Bun-native parameter metadata, and mock routing now consume the same
2121
+ compiled pattern kernel. Static routes beat parameters and parameters beat wildcards regardless of
2122
+ manifest order, with one grammar, trailing-slash policy, and malformed-encoding rule.
2123
+
2124
+ ## 1.12.0
2125
+
2126
+ ### Minor Changes
2127
+
2128
+ - 63d3845: Add bounded execution-causality contracts and propagation, OpenTelemetry causal links, event-envelope lineage, and a deterministic durable failure laboratory. `nifra levels` L4 now uses the deep adversarial contract engine through its explicitly isolated executor. Also add hash-verifiable adapter certification profiles and duplicate physical Nifra/React install detection in `nifra doctor`/`nifra check`.
2129
+ - 246f498: `app.listen(port, { hostname })` selects the bind address. It defaults to every interface, as before;
2130
+ pass `"127.0.0.1"` to bind loopback only for an admin surface, a sidecar, or any app that must not be
2131
+ reachable off the box.
2132
+
2133
+ ## 1.11.0
2134
+
2135
+ ### Minor Changes
2136
+
2137
+ - 2dde7e5: Add the effect ledger, sandboxed contract-generated invariant tests, and the verification ladder.
2138
+
2139
+ **Effect ledger** - a per-request, append-only, ordered record of side-effect intents and outcomes.
2140
+ Routes that declare `schema.capabilities` get a bounded, token-only ledger when the server enables
2141
+ `server({ effectLedger })`; each `useCapability(c, id, { target, cost, digest })` beacon
2142
+ records an intent, `recordCapabilityOutcome` records its terminal result without double-debiting
2143
+ admission, and the sink receives the sealed ledger when the response settles - on success and
2144
+ error responses alike, so partial work is audited. Entries carry capability ids, phases, adapter
2145
+ tokens, dimensionless cost counters, an optional keyed-HMAC payload digest, and bounded error codes;
2146
+ the entry type has no payload field, and the sealed ledger names the route _pattern_ plus the declared
2147
+ capability set, never the concrete URL - redaction holds by construction. Includes an optional
2148
+ tamper-evident hash chain, a bounded in-memory sink,
2149
+ and `computeEffectDigest` (keyed HMAC-SHA-256, so low-entropy data cannot be brute-forced from a
2150
+ stored digest). The hash chain binds route identity, declarations, timestamps, and entries. Sink
2151
+ failures are logged without their potentially-sensitive message and do not turn a successful effect
2152
+ into a retryable 500; transactional audit belongs in the effect's owning transaction. Routes without
2153
+ capability declarations keep the existing fast path unchanged.
2154
+
2155
+ **Contract-generated invariant tests** - `runContractInvariants(app, { executor })` fuzzes each route from its
2156
+ declared JSON Schema with a deterministic seeded generator and verifies what the contract promises:
2157
+ valid inputs never crash, 2xx responses conform to the declared response schema, schema-violating
2158
+ bodies are rejected (never accepted, never a crash), and a route-level classification never
2159
+ understates its field-level tags. Findings carry the case seed for exact reproduction; ungeneratable
2160
+ routes are reported as skipped, never silently dropped.
2161
+ Dynamic execution requires an explicit `invariants.executor` backed by a disposable app/sandbox;
2162
+ verification never invokes a live app implicitly, and any skipped route prevents L4.
2163
+
2164
+ **Verification ladder** - `nifra levels` computes L0 typed contract → L1 route assurance → L2
2165
+ capability lockfile → L3 route manifest → L4 invariant-tested from the existing gates. Levels are
2166
+ cumulative and computed, never self-declared; `--min <n>` gates CI on a required floor.
2167
+
2168
+ - 279f80c: Harden the idempotency primitive and add field-level response classification.
2169
+
2170
+ Idempotency now requires a server-resolved namespace (a static string for explicitly shared/public
2171
+ responses or a `(request, platform) => string` principal resolver - never a raw client identity).
2172
+ Routes carrying authenticated assurance must use the resolver form, so the same client key cannot
2173
+ collide across principals. Stored and legacy responses cannot replay `Set-Cookie`, authentication
2174
+ state, or hop-by-hop headers. `begin` returns an opaque reservation token that `complete`/`abandon` must
2175
+ present, so an expired-and-re-reserved key can no longer be overwritten by an older in-flight request.
2176
+ Stored responses are captured under a byte bound (`maxResponseBytes`, throwing
2177
+ `IdempotencyResponseTooLargeError`), fingerprints canonicalize JSON bodies and bind the content type,
2178
+ and a store advertises an honest `durability` marker - a route declaring `scope: "durable"` is rejected
2179
+ at registration unless its store is durable. SSE routes cannot be idempotent.
2180
+
2181
+ `classified(schema, tag)` attaches field-level sensitivity that survives composition through nested
2182
+ objects, arrays, and unions; reflection exposes both the JSON-pointer field tags and the maximum
2183
+ (`public` | `pii` | `secret`). Route-level `schema.classification` remains the fallback, and the
2184
+ capability lockfile continues to record the maximum.
2185
+
2186
+ - 5638ada: Add an explicit symbol-keyed in-process backend mount interface. `inProcessClient` implements the
2187
+ interface and `createWebApp` forwards the outer request's platform context through it, so an
2188
+ auto-mounted backend receives the same Workers `env` bindings and `waitUntil` lifetime as the web app.
2189
+
2190
+ The released `.fetch(url, init)` duck-typed mount remains as a compatibility fallback for custom
2191
+ bridges. `Server.onRequest` now receives the optional platform object as its second argument.
2192
+
2193
+ - 279f80c: Add a deterministic versioned Nifra manifest that joins route schemas, assurance evidence,
2194
+ capabilities, and field-level response classification in one hash-verified artifact. Manifests can be
2195
+ signed through an operator-provided Ed25519 KMS/HSM callback; Nifra never handles private keys.
2196
+
2197
+ `nifra manifest emit` refuses failing assurance and writes byte-stable output, while
2198
+ `nifra manifest diff <before> <after>` hash-verifies both artifacts and fails deployment promotion on
2199
+ breaking contract, lost assurance, expanded effects, or increased data sensitivity.
2200
+
2201
+ ## 1.10.0
2202
+
2203
+ ### Minor Changes
2204
+
2205
+ - 92181be: Add hardened effect and capability assurance: reflected route declarations, fail-closed runtime
2206
+ beacons, static effect-provenance analysis, deterministic capability lockfiles, HTTP safe-method
2207
+ guards, and effect-specific request or durable idempotency requirements.
2208
+
2209
+ Add `nifra capabilities snapshot` and `nifra capabilities check` so capability drift and raw
2210
+ provider bypasses can be enforced in CI without adding work to the default request path.
2211
+
2212
+ - 3773f0a: Add a request idempotency primitive and response data-classification tags.
2213
+
2214
+ `schema.idempotency` dedupes retries of a mutating route on an `Idempotency-Key` header: the first
2215
+ request runs and its response is stored, a retry with the same key replays that response without
2216
+ re-running the handler, a key reused with a different body is rejected (409), a missing key fails
2217
+ closed (400), and only successful responses are cached (an error releases the key so a retry can
2218
+ proceed). Ships an in-memory `IdempotencyStore` with an injectable clock; a durable store implements
2219
+ the same interface. Declaring idempotency also satisfies the capability-assurance idempotency
2220
+ requirement for a write capability (`durable` scope additionally clears the durable-command
2221
+ requirement). Routes without it keep the existing hot path unchanged.
2222
+
2223
+ `schema.classification` declares the highest data-sensitivity a route's response carries
2224
+ (`public` | `pii` | `secret`) - a declarative, compile-time + introspection fact, never enforced at
2225
+ runtime. It is reflected for tooling and recorded in the capability lockfile, so a route that starts
2226
+ returning PII becomes a reviewable change.
2227
+
2228
+ ### Patch Changes
2229
+
2230
+ - 92181be: Move request-deadline mechanics to the dependency-free `@nifrajs/core/budget` subpath while keeping
2231
+ `@nifrajs/budget` as a compatible re-export. Harden adaptive admission across ESM runtimes, reserved
2232
+ capacity, disconnected queued requests, and invalid capacity evidence.
2233
+
2234
+ ## 1.9.1
2235
+
2236
+ ### Patch Changes
2237
+
2238
+ - @nifrajs/budget@1.9.1
2239
+
2240
+ ## 1.9.0
2241
+
2242
+ ### Minor Changes
2243
+
2244
+ - 03cd76f: Add portable absolute request deadlines with monotonic remaining time, child reserves, strict wire
2245
+ parsing, and local-policy admission. Nifra handlers now receive the admitted budget as `c.budget`; it
2246
+ shares the existing `c.signal`, clamps hostile far-future deadlines, and distinguishes malformed,
2247
+ expired, and exhausted inherited deadlines.
2248
+
2249
+ ### Patch Changes
2250
+
2251
+ - 03cd76f: Compile eligible Nifra routes into Bun's native route table while preserving the existing lifecycle
2252
+ and portable-router fallback. Reuse unbounded request state, avoid wall-clock admission work when no
2253
+ deadline exists, lazily parse native-route queries, and inspect only captured parameter values.
2254
+ Inbound wire deadlines are now an explicit trust-boundary opt-in, keeping ordinary public routes on
2255
+ the zero-admission fast path while preserving clamped, fail-closed propagation for participating
2256
+ services.
2257
+ - Updated dependencies [03cd76f]
2258
+ - Updated dependencies [03cd76f]
2259
+ - @nifrajs/budget@1.9.0
2260
+
2261
+ ## 1.8.0
2262
+
2263
+ ### Minor Changes
2264
+
2265
+ - e47c4c5: Add reflection-time route assurance: middleware and plugins can publish lifecycle-accurate enforcement
2266
+ evidence, ordered policies fail closed on unclassified/missing/forbidden evidence, official hardening
2267
+ middleware emits canonical evidence, and `nifra assure` exposes a human/JSON CI gate.
2268
+
2269
+ ## 1.7.0
2270
+
2271
+ ### Minor Changes
2272
+
2273
+ - bd95181: `app.merge(group)` - domain-group composition for large apps, and the documented answer to the ~95-route TS2589 ceiling. A single fluent chain accumulates one type-alias level per route and TypeScript resolves that stack in one recursion, so one chain hits the compiler's instantiation-depth limit at ~95-100 routes (measured; eager-flattening variants all fail - see registry.ts). Build each domain as its own `server()` (its registry resolves independently) and merge: `app.merge(listings).merge(agents)` - each merge adds one level regardless of group size; 120+ routes typecheck with full param/schema fidelity (pinned in many-routes.test-d.ts). Merged routes keep the chains captured where they were defined (the group's own derive/validation/hooks apply exactly as standalone); the group's request-level hooks append to the parent; collisions and WebSocket groups fail closed at merge time. Contract-first `implement()` remains the other supported path - its registry is a single object type with no accumulation at all.
2274
+
2275
+ ## 1.6.0
2276
+
2277
+ ## 1.5.0
2278
+
2279
+ ### Minor Changes
2280
+
2281
+ - 1ac2fde: API breaking-change gate: `snapshotRoutes` + `diffRouteSnapshots` in `@nifrajs/core/diff` (direction-aware - a new required request field or a removed response field breaks; widening a request enum or adding a response field doesn't; fails closed on anything unprovable), and `nifra snapshot` / `nifra diff <baseline>` CLI commands that exit non-zero on breaking changes for CI.
2282
+ - bd3433f: Security + correctness hardening: `FileStorage` refuses paths that cross symbolic links (component-wise `lstat` walk + `O_NOFOLLOW` writes; `list()` skips symlinks) so a planted symlink can no longer redirect reads/writes outside the storage root. OTel spans no longer copy raw `Error.message` into exported attributes (exception text routinely carries credentials/URLs); spans record `error.recorded: true` instead. New `onResponseFinalized` terminal observer on the server (`Middleware.onResponseFinalized` / `ResponseFinalization`) runs after every transforming `onResponse` hook and is fail-open - tracing now records the true final status even when a later hook rewrites or throws. OpenAPI generation sanitizes URI-style `$id` values into valid component names/`$ref` pointers (hex-derived, collision-suffixed) and is immune to `__proto__` key pollution.
2283
+ - 70aa836: End-to-end typed SSE subscriptions. `app.sse(path, { sse: t.object(...) }, (c, stream) => ...)` declares a typed event-stream route: the handler's `stream.send(event)` is compile-time-checked against the schema (JSON-serialized into the SSE `data:` field), the schema flows into the type-level contract and reflection, and query/body validation works exactly as on any route. The typed client grows `.subscribe(onEvent, options?)` on those routes - the event payload is inferred from the backend contract, transport is fetch-based (works over the network client, `inProcessClient`, and `testClient` alike) with EventSource semantics where they matter: auto-reconnect with backoff + jitter honoring the server's `retry:` hint, `Last-Event-ID` resumption, `reconnect: false` for finite streams, `onError`/`onClose` hooks, and an `AbortSignal`. Ordinary routes do not grow a `subscribe` key (type-level tested).
2284
+
2285
+ ## 1.4.0
2286
+
2287
+ ### Minor Changes
2288
+
2289
+ - 4d25970: Add one fail-open request-observation lifecycle shared by tracing, agent telemetry, and DevTools; secured development tooling; contract-based mock responses; validator-neutral schema/route reflection; executable render and storage adapter conformance modules; optional storage pagination/signing/copy capabilities; and metadata-preserving local file storage.
2290
+
2291
+ ## 1.3.1
2292
+
2293
+ ## 1.3.0
2294
+
2295
+ ### Minor Changes
2296
+
2297
+ - 4a4b1c4: feat(core): app-wide default `onValidationError` + `kind` argument
2298
+
2299
+ `server({ onValidationError })` now sets an app-wide fallback that fires when a route **without its own**
2300
+ `onValidationError` fails body/query validation - one place to define your error envelope instead of repeating
2301
+ it per route (like tRPC's `errorFormatter` / Fastify's `setErrorHandler`), while a route's own hook still
2302
+ takes precedence. A route can fall through to the plain `422` by returning `undefined`.
2303
+
2304
+ The hook (route-level and app-level) now also receives a third argument, `kind: "body" | "query"`, telling it
2305
+ which input failed - backward-compatible (existing 2-arg hooks are unaffected). The healed-value re-validation
2306
+ contract is unchanged: an app-level default that returns a repaired value is re-validated against the route's
2307
+ schema before the handler runs.
2308
+
2309
+ - 4a4b1c4: feat: `server().resource()` / `.prompt()` - app-declared MCP resources & prompts
2310
+
2311
+ Completing the MCP trio alongside `.tool()`: an app can now expose its own MCP **resources**
2312
+ (`.resource(uri, { name, description?, mimeType? }, read)`) and **prompts** (`.prompt(name, { description,
2313
+ arguments? }, handler)`). `nifra mcp` surfaces them in `resources/list` + `resources/read` and `prompts/list`
2314
+
2315
+ - `prompts/get` (namespaced per app in a monorepo). The `read`/`handler` closures run in the app process, so
2316
+ they capture whatever app state they need - no HTTP round-trip.
2317
+
2318
+ - 4a4b1c4: feat: MCP tool annotations on `server().tool()`
2319
+
2320
+ `.tool()` config now accepts `annotations` - the MCP spec's per-tool safety hints (`title`, `readOnlyHint`,
2321
+ `destructiveHint`, `idempotentHint`, `openWorldHint`) - surfaced in `tools/list` and `tools/describe`. An
2322
+ agent can now tell a read-only tool from a destructive one and decide whether to auto-invoke or confirm
2323
+ first, instead of treating every exposed tool as equally risky.
2324
+
2325
+ - 4a4b1c4: feat: `errors` response contract on routes + typed client error bodies
2326
+
2327
+ A route's `RouteSchema` may now declare `errors` - a `{ status → Standard Schema }` map of its failure modes.
2328
+ Like `response`, it's a compile-time + introspection contract (not validated at runtime, zero hot-path cost):
2329
+ the declared error bodies flow into OpenAPI as non-2xx `responses` and into the `/llms.txt` context, so
2330
+ tooling and coding agents can read the _whole_ contract, not just the happy path.
2331
+
2332
+ The **typed client** now surfaces them: on a failure `Result`, `data` is the parsed error body typed from the
2333
+ route's `errors` (a union across declared statuses; `unknown` when none declared), discriminated by `ok`.
2334
+ `error` remains the normalized `{ error, issues }` summary. The **decoupled contract client**
2335
+ (`client(contract, url)`) gets the same treatment - its failure `data` is typed from the op's non-2xx
2336
+ `responses` schemas.
2337
+
2338
+ **Behavior change:** on failure, `data` is now the parsed error response body (previously always `null`) - so
2339
+ `const { ok, data } = await api.orders.post(...)` gives you the typed error body in the `!ok` branch. `data`
2340
+ is still `null` only on a transport error (status `0`, no response).
2341
+
2342
+ - 4a4b1c4: feat(core): `onValidationError` route hook + `server().tool()`
2343
+
2344
+ Two additions for building agent-facing endpoints on Nifra:
2345
+
2346
+ - **`onValidationError` route hook**: a `RouteSchema` callback that runs when a request fails schema
2347
+ validation. It receives the Standard Schema issues and the request context, and may return a `Response`
2348
+ (short-circuit the route), a repaired payload (re-validated before dispatch - still invalid → the original
2349
+ `422` stands), or `undefined` (keep the `422`). Makes validation recovery pluggable instead of every
2350
+ handler re-checking by hand.
2351
+
2352
+ - **`server().tool(name, config, handler)`**: register a `.tool()` route (`POST /_nifra/tool/:name`) with
2353
+ typed `input`/`output` Standard Schemas. The handler's `input` is inferred from the input schema; the
2354
+ descriptor is tagged as an MCP tool so `nifra mcp` exposes it in `tools/list`.
2355
+
2356
+ ## 1.2.2
2357
+
2358
+ ## 1.2.1
2359
+
2360
+ ## 1.2.0
2361
+
2362
+ ### Minor Changes
2363
+
2364
+ - 0ac2182: feat(core): validation failures now return **422**, plus a params-decode fast path
2365
+
2366
+ **Behavior change:** a request that fails a route's `body`/`query` schema validation is now rejected
2367
+ with **`422 Unprocessable Entity`** (previously `400`). The response body shape is unchanged
2368
+ (`{ ok: false, error: "validation", issues }`). If your client branches on `status === 400` for
2369
+ validation failures, switch it to `422`. Genuinely malformed requests keep their existing codes -
2370
+ invalid JSON via `boundedJson` and an undecodable path are still `400`.
2371
+
2372
+ Also: route params skip the `decodeURIComponent` pass entirely when the pathname contains no `%`
2373
+ (the overwhelmingly common case) - same behavior, less per-request work, on both the HTTP and
2374
+ WebSocket-upgrade paths.
2375
+
2376
+ ## 1.1.0
2377
+
2378
+ ## 1.0.0
2379
+
2380
+ ### Minor Changes
2381
+
2382
+ - f1f0e18: Context ergonomics, from beta feedback building on Nifra.
2383
+
2384
+ - **`c.json(body, status?)` / `c.text(body, status?)`** - build a `Response` in one line; the second arg is a status number or a full `ResponseInit`, and it works whether you `return` or `throw` it. Ideal for an auth / rate-limit short-circuit from a `derive`/`beforeHandle`: `throw c.json({ error: "unauthorized" }, 401)` instead of `new Response(JSON.stringify(…), { status: 401, headers: … })`. (In a route's happy path keep returning a plain object so the typed client stays in sync.) Added as prototype methods - no per-request allocation.
2385
+ - **One name for the request across routes and loaders.** A route handler's `c.req` is now also `c.request`, and a page loader/action's `ctx.request` is now also `ctx.req` - fixing the `c.req`-vs-`ctx.request` mismatch that was easy to trip over.
2386
+
2387
+ Docs: the API page documents `c.json`/`c.text` + the request alias; a new troubleshooting entry covers a `never` typed client (raw-`Response` return, or a non-identity plugin → `defineIdentityPlugin`).
2388
+
2389
+ - 3efb7cd: Sharper types + names for two footguns hit building on Nifra.
2390
+
2391
+ - **`defineRouterPlugin`** - a clearer-named alias of `defineIdentityPlugin` for a plugin that mounts routes/hooks but adds **no context type** (an auth router, an audit logger). `definePlugin`'s docs now loudly warn that using it for such a plugin silently collapses the typed client to `any` (no type error, no runtime error). The plugins guide leads with `defineRouterPlugin` and shows the side-effect-then-`return app` mount pattern.
2392
+ - **Better error when a route has no `query` schema.** Passing `query` to such a route via the typed client now fails with a message that reads out the fix - `add a \`query\` schema to this route - { query: z.object({ … }) } - so the typed client can accept query params here`- instead of the opaque`not assignable to type 'never'`. The error surfaces at the call site; the fix is at the route. Non-breaking: passing query to a schema-less route was already rejected, just unhelpfully.
2393
+
2394
+ - de9675b: Pre-1.0 security hardening pass. A framework-wide audit found no critical/high issues; these close the medium/low items it surfaced.
2395
+
2396
+ - **`cache()` - no cross-user leak by default.** A `200` to a request bearing `Authorization`/`Cookie` is no longer stored (and replayed to other users) unless the response is explicitly `Cache-Control: public`/`s-maxage` (RFC 9111 §3.5). Opt back in per cache with `cacheAuthenticated: true` for a route that's identical for every caller.
2397
+ - **`idempotency()` - route-scoped keys + a `key` hook.** The default store key is now scoped by method+path, so the same `Idempotency-Key` on a different endpoint can't collide and replay another resource's response. Added a `key(req, header)` option to scope by principal (e.g. user id). Method matching normalized to upper-case.
2398
+ - **`etag()` - a `304` no longer carries the `200`'s `Content-Length`/`Content-Type`.**
2399
+ - **`@nifrajs/core` - inbound WebSocket frames are capped** when serving on Bun (`listen()`): frames over `wsMaxPayloadBytes` (default `maxBodyBytes`, 1 MB) are rejected by the runtime before reaching a handler, so a huge frame can't be buffered/parsed into memory. New `ServerOptions.wsMaxPayloadBytes`.
2400
+ - **`@nifrajs/core` - WebSocket routes are same-origin by default (CSWSH).** A `ws()` route with no `allowedOrigins` now rejects a **cross-origin browser** handshake (an `Origin` whose host differs from the request's) with `403` - closing cross-site WebSocket hijacking, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser clients (no `Origin`) and same-origin browsers are unaffected. **Breaking** for a route that served a cross-origin browser without declaring `allowedOrigins`: set `allowedOrigins` to the permitted origins (or `() => true` for a genuinely public socket).
2401
+ - **`@nifrajs/node` - static file handler** now adds `X-Content-Type-Options: nosniff` and re-checks the real path (symlink containment) before streaming, matching the image server.
2402
+ - **`@nifrajs/mcp` - widget bridge** now rejects `postMessage` events whose source isn't the parent window (including null-source synthetic events), closing a spoofing gap the previous guard left open.
2403
+ - **`@nifrajs/cli` - the MCP `nifra_run`/`nifra_ws` `entry` arg** is kept inside the project root, so a crafted `entry` can't import/execute a module outside the project.
2404
+
2405
+ ## 1.0.0-beta.4
2406
+
2407
+ ## 1.0.0-beta.3
2408
+
2409
+ ## 0.1.0-beta.2