@nifrajs/core 2.11.0 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assurance.d.ts +61 -3
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +152 -5
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +5 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +3 -1
- package/dist/diff.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +89 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +170 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-policy.d.ts +23 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +28 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-assurance.d.ts +19 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +38 -5
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-execution.d.ts +18 -0
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +67 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +22 -4
- package/dist/manifest.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +13 -4
- package/dist/reflection.js.map +1 -1
- package/dist/replay.d.ts +26 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +63 -0
- package/dist/replay.js.map +1 -0
- package/dist/schema/standard.d.ts +2 -0
- package/dist/schema/standard.d.ts.map +1 -1
- package/dist/schema/standard.js +13 -0
- package/dist/schema/standard.js.map +1 -1
- package/dist/server/body.d.ts +49 -6
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +226 -8
- package/dist/server/body.js.map +1 -1
- package/dist/server/context.d.ts +20 -3
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -3
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +22 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +26 -5
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +76 -9
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +11 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/header-case.d.ts +9 -0
- package/dist/server/header-case.d.ts.map +1 -0
- package/dist/server/header-case.js +54 -0
- package/dist/server/header-case.js.map +1 -0
- package/dist/server/headers.d.ts +8 -0
- package/dist/server/headers.d.ts.map +1 -0
- package/dist/server/headers.js +13 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +7 -11
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +8 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +10 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +66 -7
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +14 -10
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +30 -0
- package/dist/server/proto-guard.d.ts.map +1 -0
- package/dist/server/proto-guard.js +116 -0
- package/dist/server/proto-guard.js.map +1 -0
- package/dist/server/registry.d.ts +6 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +24 -6
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +157 -30
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -2
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +34 -8
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +45 -13
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-hooks.d.ts +10 -0
- package/dist/server/response-hooks.d.ts.map +1 -0
- package/dist/server/response-hooks.js +52 -0
- package/dist/server/response-hooks.js.map +1 -0
- package/dist/server/runtime-core.d.ts +12 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +60 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +80 -23
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +424 -213
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +14 -3
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +28 -6
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +12 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tool-catalog.d.ts +21 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +16 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/tool-contract.d.ts +238 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +569 -0
- package/dist/tool-contract.js.map +1 -0
- package/package.json +26 -1
- package/src/assurance.ts +236 -6
- package/src/capabilities.ts +14 -1
- package/src/diff.ts +5 -2
- package/src/errors.ts +1 -0
- package/src/evidence.ts +319 -0
- package/src/execution-policy.ts +57 -0
- package/src/index.ts +1 -1
- package/src/internal/route-assurance.ts +67 -4
- package/src/internal/route-execution.ts +276 -1
- package/src/manifest.ts +27 -5
- package/src/reflection.ts +24 -4
- package/src/replay.ts +96 -0
- package/src/schema/standard.ts +16 -0
- package/src/server/body.ts +275 -7
- package/src/server/context.ts +23 -3
- package/src/server/contract.ts +43 -3
- package/src/server/cookies.ts +80 -10
- package/src/server/edge.ts +10 -0
- package/src/server/header-case.ts +54 -0
- package/src/server/headers.ts +11 -0
- package/src/server/node-outcome-hook.ts +6 -11
- package/src/server/node-outcome.ts +10 -2
- package/src/server/plugin.ts +87 -8
- package/src/server/proto-guard.ts +118 -0
- package/src/server/registry.ts +6 -2
- package/src/server/request-context.ts +242 -26
- package/src/server/respond.ts +35 -4
- package/src/server/response-contract-lane.ts +6 -17
- package/src/server/response-hooks.ts +51 -0
- package/src/server/runtime-core.ts +14 -0
- package/src/server/server-types.ts +66 -6
- package/src/server/server.ts +667 -287
- package/src/server/static-headers.ts +27 -3
- package/src/server/transport-lane.ts +19 -8
- package/src/server/websocket.ts +18 -2
- package/src/server.ts +7 -0
- package/src/tool-catalog.ts +40 -0
- package/src/tool-contract.ts +896 -0
package/src/server/body.ts
CHANGED
|
@@ -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,
|
|
5
|
-
* running byte count exceeds the cap
|
|
6
|
-
*
|
|
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()
|
|
78
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
+
}
|
package/src/server/context.ts
CHANGED
|
@@ -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
|
|
134
|
-
* which input failed
|
|
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
|
package/src/server/contract.ts
CHANGED
|
@@ -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
|
-
?
|
|
75
|
+
? InferInput<B>
|
|
67
76
|
: never
|
|
68
77
|
type OpQuery<O extends OperationDef> = O extends { query: infer Q extends StandardSchemaV1 }
|
|
69
|
-
?
|
|
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 } : {}),
|
package/src/server/cookies.ts
CHANGED
|
@@ -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`,
|
|
106
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
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
|
|
199
|
-
|
|
200
|
-
|
|
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
|
}
|
package/src/server/edge.ts
CHANGED
|
@@ -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
|