@ultimat3/http 22.1.0 → 22.2.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/README.md +33 -0
- package/package.json +5 -5
- package/src/context.ts +20 -0
- package/src/index.ts +1 -0
- package/src/request.ts +39 -11
package/README.md
CHANGED
|
@@ -330,6 +330,39 @@ sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
|
|
|
330
330
|
The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
|
|
331
331
|
canonical string is stated in both and pinned by one literal vector asserted in both test files.
|
|
332
332
|
|
|
333
|
+
## The exact body bytes
|
|
334
|
+
|
|
335
|
+
`As of 2026-09-24`. `UltimateRequest#bodyBytes()` returns the body exactly as it was sent,
|
|
336
|
+
whatever its content type. It is size-capped (`bodyLimitBytes`), read once and cached, and it is
|
|
337
|
+
the same byte array that `bodyRaw()` parses. An action handler receives a `Ctx` and not the
|
|
338
|
+
request, so it calls `useRequestBodyBytes()` to read the request in scope:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { action, t } from '@ultimat3/action';
|
|
342
|
+
import { useRequestBodyBytes, useRequestHeader } from '@ultimat3/http';
|
|
343
|
+
import { allow } from '@ultimat3/policy';
|
|
344
|
+
|
|
345
|
+
// The app's own check: SNS signs a canonical string of fields, a gateway signs the bytes.
|
|
346
|
+
declare function verifySender(bytes: Uint8Array, header: string | null, body: string): Promise<void>;
|
|
347
|
+
|
|
348
|
+
export const ingestSesEvent = action({
|
|
349
|
+
input: t.string.max(600_000), // SNS posts text/plain: the decoded body
|
|
350
|
+
output: t.object({ ok: t.boolean }),
|
|
351
|
+
policy: allow('public'), // the signature below authenticates the sender
|
|
352
|
+
async handle({ input }) {
|
|
353
|
+
const bytes = await useRequestBodyBytes(); // what the sender signed, byte for byte
|
|
354
|
+
await verifySender(bytes, useRequestHeader('x-signature'), input);
|
|
355
|
+
return { ok: true };
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
| | |
|
|
361
|
+
|---|---|
|
|
362
|
+
| why not the decoded string | a decode can rewrite a BOM or an invalid UTF-8 byte, and the signature then fails with no visible cause |
|
|
363
|
+
| one reader | `bodyRaw()` and `bodyBytes()` share one capped read, so an action that reads both reads the stream once |
|
|
364
|
+
| off HTTP | `X_NO_REQUEST`: a job or an MCP call has no body bytes |
|
|
365
|
+
|
|
333
366
|
## Errors
|
|
334
367
|
|
|
335
368
|
`X_ROUTE_NOT_FOUND` · `X_METHOD_NOT_ALLOWED` · `X_BODY_INVALID` · `X_UNAUTHENTICATED`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.2.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "22.
|
|
35
|
-
"@ultimat3/i18n": "22.
|
|
36
|
-
"@ultimat3/schema": "22.
|
|
37
|
-
"@ultimat3/time": "22.
|
|
34
|
+
"@ultimat3/core": "22.2.0",
|
|
35
|
+
"@ultimat3/i18n": "22.2.0",
|
|
36
|
+
"@ultimat3/schema": "22.2.0",
|
|
37
|
+
"@ultimat3/time": "22.2.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/context.ts
CHANGED
|
@@ -127,6 +127,13 @@ export interface RequestContext extends Ctx {
|
|
|
127
127
|
* "answer 303" can live without a second return protocol.
|
|
128
128
|
*/
|
|
129
129
|
redirect: RedirectIntent | undefined;
|
|
130
|
+
/**
|
|
131
|
+
* The request body's exact bytes, through `UltimateRequest`'s one capped, cached read — set by
|
|
132
|
+
* its constructor, so there is still one reader and one limit. A slot and not the request: the
|
|
133
|
+
* context is what an action handler can reach (`useRequestBodyBytes()`), and a signature over
|
|
134
|
+
* the body (SNS, a payment gateway) is over bytes a decode would have rewritten.
|
|
135
|
+
*/
|
|
136
|
+
readBody: (() => Promise<Uint8Array>) | undefined;
|
|
130
137
|
response: Response | undefined;
|
|
131
138
|
error: unknown;
|
|
132
139
|
}
|
|
@@ -228,6 +235,7 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
|
|
|
228
235
|
rateLimit: undefined,
|
|
229
236
|
cache: undefined,
|
|
230
237
|
redirect: undefined,
|
|
238
|
+
readBody: undefined,
|
|
231
239
|
response: undefined,
|
|
232
240
|
error: undefined,
|
|
233
241
|
};
|
|
@@ -284,6 +292,18 @@ export const useRequestContext = (member = 'the request context'): RequestContex
|
|
|
284
292
|
*/
|
|
285
293
|
export const useRequestHeaders = (): Headers => assertInRequest('request headers').requestHeaders;
|
|
286
294
|
|
|
295
|
+
/**
|
|
296
|
+
* The exact bytes of the request in scope's body — what a webhook action verifies a signature
|
|
297
|
+
* over. `bodyRaw()` decodes `text/*` to a string, and a decode rewrites a BOM, an invalid byte
|
|
298
|
+
* and nothing else visibly: the signature then fails for a reason no log shows. Same cap, same
|
|
299
|
+
* cache as the parse the action's input came from, so reading both costs one read.
|
|
300
|
+
*/
|
|
301
|
+
export const useRequestBodyBytes = (): Promise<Uint8Array> => {
|
|
302
|
+
const read = assertInRequest('request body bytes').readBody;
|
|
303
|
+
if (read === undefined) throw noRequest('request body bytes');
|
|
304
|
+
return read();
|
|
305
|
+
};
|
|
306
|
+
|
|
287
307
|
export const useRequestHeader = (name: string): string | null => useRequestHeaders().get(name);
|
|
288
308
|
|
|
289
309
|
/** The one way app code reads a cookie the browser sent — a session cookie included. */
|
package/src/index.ts
CHANGED
package/src/request.ts
CHANGED
|
@@ -43,10 +43,14 @@ export class UltimateRequest {
|
|
|
43
43
|
readonly raw: Request;
|
|
44
44
|
readonly ctx: RequestContext;
|
|
45
45
|
#body: { parsed: unknown } | undefined;
|
|
46
|
+
#bytes: Promise<Uint8Array> | undefined;
|
|
46
47
|
|
|
47
48
|
constructor(raw: Request, ctx: RequestContext) {
|
|
48
49
|
this.raw = raw;
|
|
49
50
|
this.ctx = ctx;
|
|
51
|
+
// Published on the context so an action handler — which gets a `Ctx`, never this object —
|
|
52
|
+
// reaches the same cached read (`useRequestBodyBytes()`), not a second reader of the stream.
|
|
53
|
+
ctx.readBody = () => this.bodyBytes();
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
get method(): string {
|
|
@@ -138,6 +142,16 @@ export class UltimateRequest {
|
|
|
138
142
|
return parsed;
|
|
139
143
|
}
|
|
140
144
|
|
|
145
|
+
/**
|
|
146
|
+
* The body exactly as sent, whatever its content type: size-capped, read once and cached, and
|
|
147
|
+
* the very bytes `bodyRaw()` parses. Empty for GET/HEAD and for no body. What a signature over
|
|
148
|
+
* the raw body is verified against, and what a signed-upload PUT stores.
|
|
149
|
+
*/
|
|
150
|
+
bodyBytes(): Promise<Uint8Array> {
|
|
151
|
+
this.#bytes ??= this.#readBytes();
|
|
152
|
+
return this.#bytes;
|
|
153
|
+
}
|
|
154
|
+
|
|
141
155
|
async body<Out>(schema: Schema<Out>): Promise<Out> {
|
|
142
156
|
const outcome = await validate(schema, await this.bodyRaw());
|
|
143
157
|
if (!outcome.ok) throw bodyInvalid(this.pathname, outcome.issues);
|
|
@@ -156,8 +170,8 @@ export class UltimateRequest {
|
|
|
156
170
|
throw buildSkew(client, server);
|
|
157
171
|
}
|
|
158
172
|
|
|
159
|
-
|
|
160
|
-
|
|
173
|
+
/** The declared length, refused up front when it is already over the cap. */
|
|
174
|
+
#declaredLength(): number | null {
|
|
161
175
|
const limit = this.ctx.config.bodyLimitBytes;
|
|
162
176
|
const header = this.header('content-length');
|
|
163
177
|
// A missing content-length means "unknown", not "empty" — only an explicit 0 is
|
|
@@ -166,6 +180,25 @@ export class UltimateRequest {
|
|
|
166
180
|
if (declared !== null && Number.isFinite(declared) && declared > limit) {
|
|
167
181
|
throw bodyInvalid(this.pathname, [`body is ${declared} bytes, limit is ${limit}`]);
|
|
168
182
|
}
|
|
183
|
+
return declared;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async #readBytes(): Promise<Uint8Array> {
|
|
187
|
+
if (this.method === 'GET' || this.method === 'HEAD') return new Uint8Array(0);
|
|
188
|
+
if (this.#declaredLength() === 0) return new Uint8Array(0);
|
|
189
|
+
// One capped read for every content type, multipart included: the parser runs on bytes this
|
|
190
|
+
// process already agreed to hold, never on a stream it hands to the runtime unbounded.
|
|
191
|
+
const limit = this.ctx.config.bodyLimitBytes;
|
|
192
|
+
const read = await readWithinLimit(this.raw.body, limit);
|
|
193
|
+
if ('over' in read) {
|
|
194
|
+
throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
|
|
195
|
+
}
|
|
196
|
+
return read.bytes;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async #read(): Promise<unknown> {
|
|
200
|
+
if (this.method === 'GET' || this.method === 'HEAD') return undefined;
|
|
201
|
+
const declared = this.#declaredLength();
|
|
169
202
|
const type = contentTypeOf(this.raw);
|
|
170
203
|
// An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
|
|
171
204
|
// `content-length: 0`, and reading that as `undefined` failed every schema with 400.
|
|
@@ -182,13 +215,8 @@ export class UltimateRequest {
|
|
|
182
215
|
}
|
|
183
216
|
if (declared === 0) return form ? {} : undefined;
|
|
184
217
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const read = await readWithinLimit(this.raw.body, limit);
|
|
188
|
-
if ('over' in read) {
|
|
189
|
-
throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
|
|
190
|
-
}
|
|
191
|
-
if (read.bytes.byteLength === 0) return form ? {} : undefined;
|
|
218
|
+
const bytes = await this.bodyBytes();
|
|
219
|
+
if (bytes.byteLength === 0) return form ? {} : undefined;
|
|
192
220
|
|
|
193
221
|
if (type === 'multipart/form-data') {
|
|
194
222
|
try {
|
|
@@ -196,7 +224,7 @@ export class UltimateRequest {
|
|
|
196
224
|
// in — `Response` is the one multipart parser here, exactly as `Request` was.
|
|
197
225
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
198
226
|
// `SharedArrayBuffer`, which `Response` does not accept.
|
|
199
|
-
const form = await new Response(new Uint8Array(
|
|
227
|
+
const form = await new Response(new Uint8Array(bytes), {
|
|
200
228
|
headers: { 'content-type': this.raw.headers.get('content-type') ?? type },
|
|
201
229
|
}).formData();
|
|
202
230
|
// `collectFields`, never `Object.fromEntries`: a repeated name is a LIST here for the
|
|
@@ -213,7 +241,7 @@ export class UltimateRequest {
|
|
|
213
241
|
}
|
|
214
242
|
}
|
|
215
243
|
|
|
216
|
-
const body = new TextDecoder().decode(
|
|
244
|
+
const body = new TextDecoder().decode(bytes);
|
|
217
245
|
try {
|
|
218
246
|
if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(body);
|
|
219
247
|
if (type === 'application/x-www-form-urlencoded') {
|