@nifrajs/core 2.11.0 → 2.12.1

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 (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +429 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +672 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. package/src/tool-contract.ts +896 -0
@@ -1,15 +1,40 @@
1
1
  /**
2
2
  * Bounded request-body reading - the single source of truth for nifra's body-size cap. A lying or
3
3
  * absent `Content-Length` can't force us to buffer an oversized payload: a declared length over the
4
- * cap is rejected *before* buffering, and a chunked / length-less body is aborted mid-stream once the
5
- * running byte count exceeds the cap. Shared by the server's schema path, `c.boundedBody`, and
6
- * `verifyWebhook` so they all enforce the same guarantee.
4
+ * cap is rejected *before* buffering, a chunked / length-less body is aborted mid-stream once the
5
+ * running byte count exceeds the cap, and the fast path re-checks the real byte count after the
6
+ * read - the declared length is a hint, never the enforcement. Shared by the server's schema path,
7
+ * `c.boundedBody`, and `verifyWebhook` so they all enforce the same guarantee.
7
8
  */
8
9
 
10
+ import { jsonError } from "./http.ts"
11
+
9
12
  interface BodySource {
10
13
  readonly headers: Pick<Headers, "get">
11
14
  readonly body: ReadableStream<Uint8Array> | null
12
15
  arrayBuffer(): Promise<ArrayBuffer>
16
+ bytes?(): Promise<Uint8Array>
17
+ }
18
+
19
+ /** The pre-cap reader surface a transport-capped request stashes under {@link RAW_BODY_READERS}. */
20
+ export interface RawBodyReaders {
21
+ readonly headers: Pick<Headers, "get">
22
+ readonly body: ReadableStream<Uint8Array> | null
23
+ arrayBuffer(): Promise<ArrayBuffer>
24
+ /** Runtime-native exact-byte reader, when the request implementation exposes one. */
25
+ bytes?(): Promise<Uint8Array>
26
+ json(): Promise<unknown>
27
+ }
28
+
29
+ /**
30
+ * The raw readers of a transport-capped request (see {@link capTransportBodyReads}), or the source
31
+ * itself when it was never capped. Framework readers (the schema lane, `c.boundedBody`,
32
+ * `verifyWebhook`, the idempotency lane) read through this so the transport cap on *direct* user
33
+ * reads never narrows their own caller-supplied cap - `c.boundedBody(explicitBytes)` must keep
34
+ * overriding the route cap upward, exactly as before the transport cap existed.
35
+ */
36
+ export function rawBodySourceOf<T extends BodySource>(req: T): T {
37
+ return ((req as { [RAW_BODY_READERS]?: T })[RAW_BODY_READERS] as T | undefined) ?? req
13
38
  }
14
39
 
15
40
  /** Security/resource limits must be finite byte counts. Invalid values otherwise make `> maxBytes`
@@ -74,14 +99,16 @@ export async function drainCapped(
74
99
  * Read a request body as **bytes**, capped at `maxBytes`. Rejects a `Content-Length` over the cap
75
100
  * before buffering (`413`) and a malformed `Content-Length` (`400`); a chunked / length-less body
76
101
  * falls through to the streaming byte-cap guard. Fast path: a non-chunked request with a
77
- * `Content-Length` within the cap is read via native `arrayBuffer()` (framing-bounded by the runtime's
78
- * HTTP server), skipping the manual stream loop.
102
+ * `Content-Length` within the cap is read via native `arrayBuffer()`, then the **real** byte count
103
+ * is checked against the declared length - a source that delivers more than it declared (a lying
104
+ * or upstream-decoding adapter) is rejected with `413` even though its header passed the hint.
79
105
  */
80
106
  export async function readBoundedBytes(
81
- req: BodySource,
107
+ source: BodySource,
82
108
  maxBytes: number,
83
109
  ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 400 | 413 }> {
84
110
  assertByteLimit(maxBytes)
111
+ const req = rawBodySourceOf(source)
85
112
  const declared = req.headers.get("content-length")
86
113
  if (declared !== null) {
87
114
  // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A non-numeric
@@ -91,9 +118,250 @@ export async function readBoundedBytes(
91
118
  if (length === undefined) return { ok: false, status: 400 }
92
119
  if (length > maxBytes) return { ok: false, status: 413 }
93
120
  const chunked = req.headers.get("transfer-encoding") !== null
94
- if (!chunked) return { ok: true, bytes: new Uint8Array(await req.arrayBuffer()) }
121
+ if (!chunked) {
122
+ const bytesReader = req.bytes
123
+ const bytes =
124
+ bytesReader === undefined
125
+ ? new Uint8Array(await req.arrayBuffer())
126
+ : await bytesReader.call(req)
127
+ // Content-Length was the fast-reject hint, never the enforcement: a lying or buffering
128
+ // source (an adapter that decoded/expanded the body upstream) can hand over more bytes
129
+ // than it declared. `length <= maxBytes` held above, so one comparison seals the cap.
130
+ if (bytes.byteLength > length) return { ok: false, status: 413 }
131
+ return { ok: true, bytes }
132
+ }
95
133
  }
96
134
  const body = req.body
97
135
  if (body === null) return { ok: true, bytes: new Uint8Array(0) }
98
136
  return drainCapped(body, maxBytes)
99
137
  }
138
+
139
+ /** Key under which a transport-capped request stashes its {@link RawBodyReaders}. */
140
+ export const RAW_BODY_READERS = Symbol("nifra.body.rawReaders")
141
+
142
+ /**
143
+ * Ingress mark for a request whose bytes a runtime HTTP parser already delimited - Bun's compiled
144
+ * native route table, `Deno.serve`, or an edge runtime that hands its own `Request` through
145
+ * untouched. For those the declared Content-Length IS the transport frame, so the JSON lane keeps
146
+ * the runtime's fused decode+parse instead of copying the body out to count it.
147
+ *
148
+ * Everything unmarked - a hand-built `Request`, the in-process client, and above all an adapter
149
+ * that REBUILDS a body from parts an attacker supplied (an event envelope carrying both a header
150
+ * map and a payload) - stays on the delivered-byte check. That is the shape the cap actually
151
+ * defends against: a `content-length` copied from the caller while the body was decoded to a
152
+ * different size.
153
+ *
154
+ * Registered (`Symbol.for`) on purpose, matching `nifra.response.body`: `@nifrajs/deno` mirrors
155
+ * core's types rather than importing them, so an adapter must be able to set this without taking a
156
+ * dependency on core. Forging it needs in-process code execution, which could equally just call
157
+ * `app.fetch` with any `Request` it likes - so the registry costs no ground the process didn't
158
+ * already own.
159
+ */
160
+ const TRUSTED_FRAMING = Symbol.for("nifra.body.trustedFraming")
161
+
162
+ /** Mark a request delivered straight from a runtime HTTP parser. */
163
+ export function markTrustedBodyFraming(source: object): void {
164
+ ;(source as { [TRUSTED_FRAMING]?: true })[TRUSTED_FRAMING] = true
165
+ }
166
+
167
+ /** True only for the runtime-framed ingress mark. */
168
+ export function hasTrustedBodyFraming(source: object): boolean {
169
+ return (source as { [TRUSTED_FRAMING]?: true })[TRUSTED_FRAMING] === true
170
+ }
171
+
172
+ /** A capped request/source is capped exactly once; re-entry is a no-op. */
173
+ const TRANSPORT_CAPPED = new WeakSet<object>()
174
+
175
+ const CAP_DECODER = new TextDecoder()
176
+
177
+ /** Key under which the dispatcher marks a source with its route's transport byte cap. */
178
+ const TRANSPORT_CAP = Symbol("nifra.body.transportCap")
179
+
180
+ /** The transport cap's over-cap rejection: the flat error a shadowed direct reader throws (and the
181
+ * capped `body` stream errors with). The lifecycle already treats a thrown `Response` as control
182
+ * flow (the `c.boundedBody` contract), so an over-cap direct read answers 413 like every other cap. */
183
+ function transportCapError(status: 400 | 413): Response {
184
+ return jsonError(status, status === 413 ? "payload_too_large" : "invalid_content_length")
185
+ }
186
+
187
+ /** First matching own-or-inherited descriptor, so instance shadows and prototype accessors both resolve. */
188
+ function descriptorOf(target: object, key: string): PropertyDescriptor | undefined {
189
+ let current: object | null = target
190
+ while (current !== null) {
191
+ const descriptor = Object.getOwnPropertyDescriptor(current, key)
192
+ if (descriptor !== undefined) return descriptor
193
+ current = Object.getPrototypeOf(current) as object | null
194
+ }
195
+ return undefined
196
+ }
197
+
198
+ /** An `ArrayBuffer` spanning exactly `bytes`, without a copy when the view already owns its buffer. */
199
+ function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
200
+ return bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
201
+ ? (bytes.buffer as ArrayBuffer)
202
+ : (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
203
+ }
204
+
205
+ /** A pass-through stream that errors (with `reject(413)`) once more than `maxBytes` flow through. */
206
+ function cappedStreamOf(
207
+ body: ReadableStream<Uint8Array>,
208
+ maxBytes: number,
209
+ reject: (status: 400 | 413) => unknown,
210
+ ): ReadableStream<Uint8Array> {
211
+ // The reader is acquired on first pull, and `highWaterMark: 0` defers that first pull until a
212
+ // consumer actually reads (the default of 1 pulls eagerly at construction): merely *accessing*
213
+ // the capped `body` getter must not lock the raw stream (native semantics - only reading
214
+ // consumes the body).
215
+ // Typed structurally: the runtimes' lib typings disagree on the reader's shape (Bun's `readMany`,
216
+ // Node's BYOB `read(view)` overload), and only `read`/`cancel` are used here.
217
+ interface CappedReader {
218
+ read(): Promise<{ done: false; value: Uint8Array } | { done: true; value?: undefined }>
219
+ cancel(reason?: unknown): Promise<void>
220
+ }
221
+ let reader: CappedReader | undefined
222
+ let total = 0
223
+ return new ReadableStream<Uint8Array>(
224
+ {
225
+ async pull(controller) {
226
+ reader ??= body.getReader() as unknown as CappedReader
227
+ const active = reader
228
+ const { done, value } = await active.read()
229
+ if (done) {
230
+ controller.close()
231
+ return
232
+ }
233
+ total += value.byteLength
234
+ if (total > maxBytes) {
235
+ await active.cancel().catch(() => {})
236
+ controller.error(reject(413))
237
+ return
238
+ }
239
+ controller.enqueue(value)
240
+ },
241
+ cancel(reason) {
242
+ return reader === undefined ? body.cancel(reason) : reader.cancel(reason)
243
+ },
244
+ },
245
+ { highWaterMark: 0 },
246
+ )
247
+ }
248
+
249
+ /**
250
+ * Shadow a request's direct body readers (`arrayBuffer`/`json`/`text`/`blob`/`bytes`/`formData`,
251
+ * the `body` stream, and `clone`) with byte-capped versions, **on the same object** - the request's
252
+ * identity is never swapped, so `WeakMap` keys, symbol stashes (the transport-codec lane's
253
+ * pre-decoded body), and hook-observed references all survive. Nothing is read eagerly: the cap
254
+ * only pays when user code actually reads, and framework readers bypass it through
255
+ * {@link rawBodySourceOf} (stashed under {@link RAW_BODY_READERS}) to enforce their own caps.
256
+ * An over-cap read rejects with `reject(413)` (a lying `Content-Length` with `reject(400)`), which
257
+ * the lifecycle already treats as control flow - the same contract as `c.boundedBody`.
258
+ */
259
+ function capRequestBodyReads(request: Request, maxBytes: number): void {
260
+ if (TRANSPORT_CAPPED.has(request)) return
261
+ TRANSPORT_CAPPED.add(request)
262
+ const reject = transportCapError
263
+ const bodyDescriptor = descriptorOf(request, "body")
264
+ const raw: RawBodyReaders = {
265
+ get headers() {
266
+ return request.headers
267
+ },
268
+ get body() {
269
+ return (
270
+ bodyDescriptor?.get !== undefined
271
+ ? bodyDescriptor.get.call(request)
272
+ : (bodyDescriptor?.value ?? null)
273
+ ) as ReadableStream<Uint8Array> | null
274
+ },
275
+ arrayBuffer: request.arrayBuffer.bind(request),
276
+ json: request.json.bind(request),
277
+ }
278
+ if (typeof request.bytes === "function") raw.bytes = request.bytes.bind(request)
279
+ // One bounded buffer backs every shadowed reader; a second read replays it instead of failing
280
+ // with "body already used" (a strict superset of the native one-shot contract).
281
+ let buffered: Promise<Uint8Array> | undefined
282
+ const cappedBytes = (): Promise<Uint8Array> =>
283
+ (buffered ??= readBoundedBytes(raw, maxBytes).then((read) => {
284
+ if (!read.ok) throw reject(read.status)
285
+ return read.bytes
286
+ }))
287
+ // The reader methods live on the prototype as writable data properties, so a plain instance
288
+ // assignment shadows them - materially cheaper than defineProperty on this per-read path. Only
289
+ // `body` (a prototype accessor) needs defineProperty below.
290
+ const shadowed = request as unknown as Record<string, unknown> & {
291
+ [RAW_BODY_READERS]?: RawBodyReaders
292
+ }
293
+ shadowed[RAW_BODY_READERS] = raw
294
+ shadowed.arrayBuffer = () => cappedBytes().then(exactArrayBuffer)
295
+ shadowed.bytes = () => cappedBytes().then((bytes) => bytes.slice())
296
+ shadowed.json = () =>
297
+ cappedBytes().then((bytes) => JSON.parse(CAP_DECODER.decode(bytes)) as unknown)
298
+ shadowed.text = () => cappedBytes().then((bytes) => CAP_DECODER.decode(bytes))
299
+ shadowed.blob = () =>
300
+ cappedBytes().then(
301
+ (bytes) =>
302
+ new Blob([exactArrayBuffer(bytes)], {
303
+ type: request.headers.get("content-type") ?? "",
304
+ }),
305
+ )
306
+ shadowed.formData = () =>
307
+ cappedBytes().then((bytes) =>
308
+ new Response(exactArrayBuffer(bytes), {
309
+ headers: { "content-type": request.headers.get("content-type") ?? "" },
310
+ }).formData(),
311
+ )
312
+ // `clone()` must not mint an uncapped escape hatch: the clone gets the same cap.
313
+ if (typeof request.clone === "function") {
314
+ // Node's lib typing returns undici's `Request` here; at runtime it's the same object shape.
315
+ const rawClone = request.clone.bind(request) as () => Request
316
+ shadowed.clone = () => {
317
+ const clone = rawClone()
318
+ capRequestBodyReads(clone, maxBytes)
319
+ return clone
320
+ }
321
+ }
322
+ let cappedBody: ReadableStream<Uint8Array> | null | undefined
323
+ Object.defineProperty(request, "body", {
324
+ configurable: true,
325
+ get(): ReadableStream<Uint8Array> | null {
326
+ if (cappedBody === undefined) {
327
+ const rawBody = raw.body
328
+ cappedBody = rawBody === null ? null : cappedStreamOf(rawBody, maxBytes, reject)
329
+ }
330
+ return cappedBody
331
+ },
332
+ })
333
+ }
334
+
335
+ /**
336
+ * Mark a request source with its route's transport byte cap. Dispatch-time cost is one property
337
+ * write; nothing is shadowed, wrapped, or read here - the hot path stays untouched. The cap is
338
+ * applied by {@link applyTransportCap} only when user code actually reaches for the request
339
+ * (`c.req` / `c.request`), so a route that never direct-reads the body pays nothing.
340
+ */
341
+ export function markTransportCap(source: object, maxBytes: number): void {
342
+ ;(source as { [TRANSPORT_CAP]?: number })[TRANSPORT_CAP] = maxBytes
343
+ }
344
+
345
+ /**
346
+ * Enforce a marked source's transport byte cap on **direct user reads** of its request
347
+ * (`c.req.json()`, `c.req.arrayBuffer()`, the raw `body` stream, `clone()`, ...), without swapping
348
+ * the request's identity and without reading anything eagerly: the direct readers are shadowed with
349
+ * byte-capped versions on the same object, so `WeakMap` keys, symbol stashes (the transport-codec
350
+ * lane's pre-decoded body), and hook-observed references all survive. Framework readers bypass the
351
+ * shadow through {@link rawBodySourceOf} and enforce their own caps - `c.boundedBody(explicit)`
352
+ * still overrides in either direction. An unmarked source (`bodyLimit: "unlimited"`) is untouched.
353
+ */
354
+ export function applyTransportCap(source: object, request: Request): void {
355
+ const maxBytes = (source as { [TRANSPORT_CAP]?: number })[TRANSPORT_CAP]
356
+ // The capped check comes BEFORE the body-null check: post-cap, `request.body` is the shadowed
357
+ // getter, and re-reading it on every `c.req` access would rebuild capped state for nothing.
358
+ if (maxBytes === undefined || TRANSPORT_CAPPED.has(request) || request.body === null) return
359
+ capRequestBodyReads(request, maxBytes)
360
+ // A wrapper source (Node's lazy source) delegates its own body readers to the request once it
361
+ // materializes - which are now the shadowed ones. Mirror the raw-reader stash onto the source so
362
+ // framework readers (`rawBodySourceOf(source)`) keep reading pre-shadow there too.
363
+ const raw = (request as unknown as { [RAW_BODY_READERS]?: RawBodyReaders })[RAW_BODY_READERS]
364
+ if ((source as unknown) !== request && raw !== undefined) {
365
+ ;(source as { [RAW_BODY_READERS]?: RawBodyReaders })[RAW_BODY_READERS] = raw
366
+ }
367
+ }
@@ -62,6 +62,13 @@ export type Params<Path extends string> = Prettify<RawParams<Path>>
62
62
 
63
63
  /** Per-route input schemas. Each is any Standard Schema (zod/valibot/arktype/…). */
64
64
  export interface RouteSchema {
65
+ /** Transport body cap for this route. Defaults to the server's maxBodyBytes. Use a finite
66
+ * smaller/larger limit for a specific endpoint. The 'unlimited' value is an explicit
67
+ * streaming/upload exemption and requires a non-empty bodyLimitReason for auditability. */
68
+ readonly bodyLimit?: number | "unlimited"
69
+ /** Required explanation when bodyLimit is 'unlimited' (for example, a streaming upload is
70
+ * bounded by an upstream object-store protocol). */
71
+ readonly bodyLimitReason?: string
65
72
  /** Declared effect tokens. Reflected for assurance; never read by validation or serialization. */
66
73
  readonly capabilities?: readonly string[]
67
74
  /**
@@ -106,6 +113,10 @@ export interface RouteSchema {
106
113
  * The schema must cover every path param; with the strict default an undeclared path param is rejected.
107
114
  * When omitted, `c.params` stays the path-inferred `Record<name, string>`. */
108
115
  readonly params?: StandardSchemaV1
116
+ /** Optional request-header schema. Header names are normalized to lower-case and the validated
117
+ * output is exposed as `c.headers`; use lower-case keys because HTTP header names are
118
+ * case-insensitive. */
119
+ readonly headers?: StandardSchemaV1
109
120
  readonly body?: StandardSchemaV1
110
121
  readonly query?: StandardSchemaV1
111
122
  /** Optional **response contract**. When declared: the handler's return is type-checked against it
@@ -130,8 +141,8 @@ export interface RouteSchema {
130
141
  */
131
142
  readonly sse?: StandardSchemaV1
132
143
  /**
133
- * Hook fired when the request fails `body`/`query` validation, before the handler runs. `kind` says
134
- * which input failed (`"body"` | `"query"`). Its return value selects one of three outcomes (may be async):
144
+ * Hook fired when the request fails `headers`/`params`/`body`/`query` validation, before the handler
145
+ * runs. `kind` says which input failed. Its return value selects one of three outcomes (may be async):
135
146
  * - a **`Response`** → returned as-is, short-circuiting the route (custom error envelope, redirect, …).
136
147
  * - **any other value** → treated as a repaired payload and **re-validated once** against the same
137
148
  * schema. If it now passes, the handler runs with it; if it still fails, the original `422` stands.
@@ -145,7 +156,7 @@ export interface RouteSchema {
145
156
  readonly onValidationError?: (
146
157
  issues: ReadonlyArray<StandardIssue>,
147
158
  ctx: Context,
148
- kind: "body" | "query" | "params",
159
+ kind: "body" | "query" | "params" | "headers",
149
160
  ) => Response | unknown | Promise<Response | unknown>
150
161
  }
151
162
 
@@ -159,6 +170,13 @@ type QueryOf<S extends RouteSchema> = S extends { query: infer Q extends Standar
159
170
  ? InferOutput<Q>
160
171
  : URLSearchParams
161
172
 
173
+ /** The validated request-header type when a header schema is declared, else a lower-case record. */
174
+ type HeadersOf<S extends RouteSchema> = S extends {
175
+ headers: infer H extends StandardSchemaV1
176
+ }
177
+ ? InferOutput<H>
178
+ : Readonly<Record<string, string>>
179
+
162
180
  /** The validated params type when a params schema is declared, else the path-inferred `Params<Path>`. */
163
181
  type ParamsOf<S extends RouteSchema, Path extends string> = S extends {
164
182
  params: infer P extends StandardSchemaV1
@@ -216,6 +234,8 @@ export interface Context<Path extends string = string, S extends RouteSchema = R
216
234
  /** Read one request header without materializing a Web `Request` on the Node adapter hot path. */
217
235
  readonly header: (name: string) => string | null
218
236
  readonly params: ParamsOf<S, Path>
237
+ /** Lower-case request headers, validated/coerced when `schema.headers` is declared. */
238
+ readonly headers: HeadersOf<S>
219
239
  readonly query: QueryOf<S>
220
240
  readonly body: BodyOf<S>
221
241
  /** The request's cookies, parsed from the `Cookie` header (values URL-decoded). Parsed lazily on
@@ -2,7 +2,8 @@ import type { DataClassification } from "../classification.ts"
2
2
  import { RouteConfigError } from "../errors.ts"
3
3
  import { normalizeRouteCapabilities } from "../internal/capability-runtime.ts"
4
4
  import { METHODS, type Method } from "../router/router.ts"
5
- import type { InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
5
+ import type { InferInput, InferOutput, StandardSchemaV1 } from "../schema/standard.ts"
6
+ import { assertByteLimit } from "./body.ts"
6
7
  import type { Context, IdempotencyConfig, Params, RouteSchema } from "./context.ts"
7
8
  import type { EmptyRegistry, OutputOf, Registry } from "./registry.ts"
8
9
  import { Server } from "./server.ts"
@@ -28,6 +29,8 @@ export interface OperationDef {
28
29
  readonly path: string
29
30
  /** Optional path-params schema (validated + coercible at the boundary; see {@link RouteSchema.params}). */
30
31
  readonly params?: StandardSchemaV1
32
+ /** Optional request-header schema (header names are normalized to lower-case). */
33
+ readonly headers?: StandardSchemaV1
31
34
  readonly body?: StandardSchemaV1
32
35
  readonly query?: StandardSchemaV1
33
36
  readonly response?: StandardSchemaV1
@@ -35,6 +38,9 @@ export interface OperationDef {
35
38
  readonly capabilities?: readonly string[]
36
39
  /** Dedupe retries of this operation on an `Idempotency-Key` header (see {@link RouteSchema.idempotency}). */
37
40
  readonly idempotency?: IdempotencyConfig
41
+ /** Per-route transport body cap; see RouteSchema.bodyLimit. */
42
+ readonly bodyLimit?: number | "unlimited"
43
+ readonly bodyLimitReason?: string
38
44
  /** Highest data-sensitivity the response carries (see {@link RouteSchema.classification}). */
39
45
  readonly classification?: DataClassification
40
46
  /** Inline route-assurance evidence this operation carries (see {@link RouteSchema.assurance}). */
@@ -62,11 +68,14 @@ export interface OperationDef {
62
68
  /** A contract: named operations. Names are the handler keys and OpenAPI operationIds. */
63
69
  export type ContractShape = Record<string, OperationDef>
64
70
 
71
+ /** Client-visible request inputs use the schema's INPUT side (what a caller sends pre-validation,
72
+ * so a defaulted field stays optional on the wire) - mirroring the inline registry's
73
+ * `RegistryBody`/`RegistryQuery`. Handler-facing types (ContextForOp) still use the output side. */
65
74
  type OpBody<O extends OperationDef> = O extends { body: infer B extends StandardSchemaV1 }
66
- ? InferOutput<B>
75
+ ? InferInput<B>
67
76
  : never
68
77
  type OpQuery<O extends OperationDef> = O extends { query: infer Q extends StandardSchemaV1 }
69
- ? InferOutput<Q>
78
+ ? InferInput<Q>
70
79
  : never
71
80
  type OpResponse<O extends OperationDef> = O extends { response: infer R extends StandardSchemaV1 }
72
81
  ? InferOutput<R>
@@ -129,6 +138,9 @@ type SchemaForOp<O extends OperationDef> = (O extends { body: infer B extends St
129
138
  ? { body: B }
130
139
  : Record<never, never>) &
131
140
  (O extends { query: infer Q extends StandardSchemaV1 } ? { query: Q } : Record<never, never>) &
141
+ (O extends { headers: infer H extends StandardSchemaV1 }
142
+ ? { headers: H }
143
+ : Record<never, never>) &
132
144
  (O extends { params: infer P extends StandardSchemaV1 } ? { params: P } : Record<never, never>)
133
145
 
134
146
  /**
@@ -188,6 +200,29 @@ export function defineContract<const C extends ContractShape>(contract: C): C {
188
200
  `operation "${name}": path must start with "/": "${op.path}"`,
189
201
  )
190
202
  }
203
+ if (op.bodyLimitReason !== undefined && op.bodyLimit !== "unlimited") {
204
+ throw new RouteConfigError(
205
+ "INVALID_BODY_LIMIT",
206
+ `operation "${name}": bodyLimitReason is only valid with bodyLimit: "unlimited"`,
207
+ )
208
+ }
209
+ if (op.bodyLimit === "unlimited") {
210
+ if (typeof op.bodyLimitReason !== "string" || op.bodyLimitReason.trim().length === 0) {
211
+ throw new RouteConfigError(
212
+ "INVALID_BODY_LIMIT",
213
+ `operation "${name}": bodyLimit: "unlimited" requires a non-empty bodyLimitReason`,
214
+ )
215
+ }
216
+ } else if (op.bodyLimit !== undefined) {
217
+ try {
218
+ assertByteLimit(op.bodyLimit, "operation bodyLimit")
219
+ } catch {
220
+ throw new RouteConfigError(
221
+ "INVALID_BODY_LIMIT",
222
+ `operation "${name}": bodyLimit must be a non-negative safe integer or "unlimited"`,
223
+ )
224
+ }
225
+ }
191
226
  const key = `${method} ${op.path}`
192
227
  if (seen.has(key)) {
193
228
  throw new RouteConfigError("DUPLICATE_ROUTE", `duplicate operation route: ${key}`)
@@ -265,21 +300,26 @@ export function implement<
265
300
  // A response-less op still yields `undefined`, byte-identical to before.
266
301
  const schema: RouteSchema | undefined =
267
302
  op.params !== undefined ||
303
+ op.headers !== undefined ||
268
304
  op.body !== undefined ||
269
305
  op.query !== undefined ||
270
306
  op.response !== undefined ||
271
307
  op.capabilities !== undefined ||
272
308
  op.idempotency !== undefined ||
309
+ op.bodyLimit !== undefined ||
273
310
  op.classification !== undefined ||
274
311
  op.assurance !== undefined ||
275
312
  op.family !== undefined
276
313
  ? {
277
314
  ...(op.params !== undefined ? { params: op.params } : {}),
315
+ ...(op.headers !== undefined ? { headers: op.headers } : {}),
278
316
  ...(op.body !== undefined ? { body: op.body } : {}),
279
317
  ...(op.query !== undefined ? { query: op.query } : {}),
280
318
  ...(op.response !== undefined ? { response: op.response } : {}),
281
319
  ...(op.capabilities !== undefined ? { capabilities: op.capabilities } : {}),
282
320
  ...(op.idempotency !== undefined ? { idempotency: op.idempotency } : {}),
321
+ ...(op.bodyLimit !== undefined ? { bodyLimit: op.bodyLimit } : {}),
322
+ ...(op.bodyLimitReason !== undefined ? { bodyLimitReason: op.bodyLimitReason } : {}),
283
323
  ...(op.classification !== undefined ? { classification: op.classification } : {}),
284
324
  ...(op.assurance !== undefined ? { assurance: op.assurance } : {}),
285
325
  ...(op.family !== undefined ? { family: op.family } : {}),
@@ -25,6 +25,21 @@ export interface CookieOptions {
25
25
  const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
26
26
  const MAX_COOKIE_BYTES = 4096 // browsers cap a cookie near 4 KB - reject oversized before the wire
27
27
 
28
+ /**
29
+ * RFC 6265bis cookie-name prefix, matched **case-insensitively** the way browsers match it -
30
+ * `__secure-x` triggers the same enforcement as `__Secure-x`. `undefined` for unprefixed names.
31
+ * Writers (set AND delete) must satisfy the prefix contract or the user agent silently discards
32
+ * the whole `Set-Cookie`.
33
+ */
34
+ export function cookieNamePrefix(name: string): "secure" | "host" | undefined {
35
+ // Both prefixes start `__`; two charCode probes keep unprefixed names (the common case) free.
36
+ if (name.charCodeAt(0) !== 95 /* _ */ || name.charCodeAt(1) !== 95) return undefined
37
+ const lowered = name.toLowerCase()
38
+ if (lowered.startsWith("__secure-")) return "secure"
39
+ if (lowered.startsWith("__host-")) return "host"
40
+ return undefined
41
+ }
42
+
28
43
  /** True if `v` contains a character illegal in a cookie attribute (control char or `;` separator) -
29
44
  * the header-injection guard for dev-supplied `Path`/`Domain`. */
30
45
  const hasIllegalChar = (v: string): boolean => {
@@ -102,8 +117,9 @@ export function parseCookies(header: string | null | undefined): Record<string,
102
117
  /**
103
118
  * Serialize a `Set-Cookie` header value. Pure - applies **no** security defaults (the caller, e.g.
104
119
  * `c.set.cookie`, layers `HttpOnly`/`Secure`/`SameSite` on). Throws on an invalid cookie name, a
105
- * header-injecting `Path`/`Domain`, a non-integer `maxAge`, or an oversized result - a serialization
106
- * bug should fail loudly, not silently emit a cookie the browser drops.
120
+ * header-injecting `Path`/`Domain`, a non-integer `maxAge`, a `__Secure-`/`__Host-` name whose
121
+ * attributes violate its prefix contract, or an oversized result - a serialization bug should fail
122
+ * loudly, not silently emit a cookie the browser drops.
107
123
  */
108
124
  export function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
109
125
  if (!COOKIE_NAME.test(name)) {
@@ -111,6 +127,25 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
111
127
  `[nifra] invalid cookie name ${JSON.stringify(name)}: must be an RFC 6265 token`,
112
128
  )
113
129
  }
130
+ // Enforce the prefix contract: a violating cookie would be silently discarded by the user
131
+ // agent (or worse, only by SOME user agents, leaving the rest accepting a cookie whose prefix
132
+ // promises protections it doesn't have).
133
+ const prefix = cookieNamePrefix(name)
134
+ if (prefix !== undefined) {
135
+ if (options.secure !== true) {
136
+ throw new Error(
137
+ `[nifra] cookie ${JSON.stringify(name)}: ${prefix === "host" ? "__Host-" : "__Secure-"} requires Secure`,
138
+ )
139
+ }
140
+ if (prefix === "host") {
141
+ if (options.domain !== undefined) {
142
+ throw new Error(`[nifra] cookie ${JSON.stringify(name)}: __Host- forbids Domain`)
143
+ }
144
+ if (options.path !== "/") {
145
+ throw new Error(`[nifra] cookie ${JSON.stringify(name)}: __Host- requires Path=/`)
146
+ }
147
+ }
148
+ }
114
149
  let str = `${name}=${encodeURIComponent(value)}`
115
150
  if (options.maxAge !== undefined) {
116
151
  if (!Number.isInteger(options.maxAge)) {
@@ -159,8 +194,11 @@ const importHmacKey = (secret: string): Promise<CryptoKey> => {
159
194
  }
160
195
 
161
196
  const toBase64Url = (buf: ArrayBuffer): string => {
197
+ // Indexed, not `for...of`: iterating a typed array runs the iterator protocol per byte, and at a
198
+ // 32-byte HMAC digest that is 238ns on Bun and 174ns on Node - a third of the whole encode.
162
199
  let bin = ""
163
- for (const b of new Uint8Array(buf)) bin += String.fromCharCode(b)
200
+ const bytes = new Uint8Array(buf)
201
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!)
164
202
  return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
165
203
  }
166
204
 
@@ -177,9 +215,24 @@ const fromBase64Url = (s: string): Uint8Array<ArrayBuffer> | null => {
177
215
  }
178
216
  }
179
217
 
180
- /** Append an HMAC-SHA256 signature to a value → `value.signature` (base64url). For signed cookies. */
181
- export async function signValue(value: string, secret: string): Promise<string> {
182
- const key = await importHmacKey(secret)
218
+ /**
219
+ * A signing secret, or a rotation list of them. With a list, **the first secret signs** and
220
+ * verification accepts **any** entry - so rotation is: prepend the new secret, keep the old one
221
+ * until every cookie signed by it has expired, then drop it. An empty list throws.
222
+ */
223
+ export type CookieSecret = string | readonly string[]
224
+
225
+ const firstSecret = (secret: CookieSecret): string => {
226
+ if (typeof secret === "string") return secret
227
+ const first = secret[0]
228
+ if (first === undefined) throw new Error("[nifra] signed-cookie: secret list cannot be empty")
229
+ return first
230
+ }
231
+
232
+ /** Append an HMAC-SHA256 signature to a value → `value.signature` (base64url). For signed cookies.
233
+ * With a {@link CookieSecret} rotation list, signs with the first secret. */
234
+ export async function signValue(value: string, secret: CookieSecret): Promise<string> {
235
+ const key = await importHmacKey(firstSecret(secret))
183
236
  const sig = await crypto.subtle.sign("HMAC", key, TEXT.encode(value))
184
237
  return `${value}.${toBase64Url(sig)}`
185
238
  }
@@ -188,14 +241,31 @@ export async function signValue(value: string, secret: string): Promise<string>
188
241
  * Verify a `value.signature` produced by {@link signValue} and return the value, or `null` if the
189
242
  * signature is missing, malformed, or doesn't match. Verification is **constant-time**
190
243
  * (`crypto.subtle.verify`), so a wrong signature can't be discovered byte-by-byte via timing.
244
+ *
245
+ * With a {@link CookieSecret} rotation list, tries each secret in order and accepts the first
246
+ * match. Every listed secret must meet the 32-byte floor - a weak entry throws even when an
247
+ * earlier secret would have matched, so a rotation list can't quietly carry a weak key. (Which
248
+ * rotation generation matched is not attacker-meaningful, so the early exit between secrets leaks
249
+ * nothing useful; each individual comparison stays constant-time.)
191
250
  */
192
- export async function unsignValue(signed: string, secret: string): Promise<string | null> {
251
+ export async function unsignValue(signed: string, secret: CookieSecret): Promise<string | null> {
193
252
  const dot = signed.lastIndexOf(".")
194
253
  if (dot < 1) return null // no signature segment (or empty value)
195
254
  const value = signed.slice(0, dot)
196
255
  const sig = fromBase64Url(signed.slice(dot + 1))
197
256
  if (sig === null) return null
198
- const key = await importHmacKey(secret)
199
- const ok = await crypto.subtle.verify("HMAC", key, sig, TEXT.encode(value))
200
- return ok ? value : null
257
+ const data = TEXT.encode(value)
258
+ if (typeof secret === "string") {
259
+ // Hot path (single secret) - identical to the pre-rotation behavior, no array allocation.
260
+ const key = await importHmacKey(secret)
261
+ const ok = await crypto.subtle.verify("HMAC", key, sig, data)
262
+ return ok ? value : null
263
+ }
264
+ if (secret.length === 0) throw new Error("[nifra] signed-cookie: secret list cannot be empty")
265
+ for (const s of secret) requireSecretBytes(s, "signed-cookie")
266
+ for (const s of secret) {
267
+ const key = await importHmacKey(s)
268
+ if (await crypto.subtle.verify("HMAC", key, sig, data)) return value
269
+ }
270
+ return null
201
271
  }
@@ -103,6 +103,16 @@ export function toFetchHandler<Env = unknown>(
103
103
  const resolveWs = app.resolveWebSocketUpgrade?.bind(app)
104
104
  return {
105
105
  fetch: (request, env, ctx) => {
106
+ // The platform's own HTTP parser delimited this body, so the declared Content-Length IS the
107
+ // transport frame rather than a caller's hint - the same guarantee Bun's native routes and
108
+ // `Deno.serve` carry, and the reason `serve(request, env, ctx)` is a stronger statement about
109
+ // provenance than a bare `app.fetch(req)` (which anyone in-process can call with a hand-built
110
+ // Request). The mark keeps core's JSON lane on the runtime's fused parse instead of copying
111
+ // the body out to recount its bytes. Written as the registered symbol, not an import of
112
+ // `markTrustedBodyFraming`, so this file keeps its no-runtime-edge property: a GET-only edge
113
+ // app must not pull `body.ts` (and `http.ts` behind it) into its bundle for one flag.
114
+ ;(request as unknown as Record<symbol, unknown>)[Symbol.for("nifra.body.trustedFraming")] =
115
+ true
106
116
  const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
107
117
  // WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
108
118
  // connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself