@ultimat3/http 22.0.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 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.0.0",
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.0.0",
35
- "@ultimat3/i18n": "22.0.0",
36
- "@ultimat3/schema": "22.0.0",
37
- "@ultimat3/time": "22.0.0"
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/csrf.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // went through. `setRedirect` exists so those form posts work without JS, which makes them a
6
6
  // first-class surface here rather than a legacy one.
7
7
 
8
- import { classifyAddress } from '@ultimat3/core';
8
+ import { classifyAddress, proveSameOrigin } from '@ultimat3/core';
9
9
  import type { CorsConfig } from './cors';
10
10
  import { originListed } from './cors';
11
11
  import { HttpError } from './errors';
@@ -26,9 +26,6 @@ export const DEFAULT_CSRF: CsrfConfig = { mode: 'origin' };
26
26
  /** Methods with no side effects, per RFC 9110. A CSRF check on these is a check on nothing. */
27
27
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
28
28
 
29
- /** The complete `Sec-Fetch-Site` vocabulary. Anything else was written by a non-browser. */
30
- const KNOWN_SITES = new Set(['same-origin', 'same-site', 'cross-site', 'none']);
31
-
32
29
  export interface CsrfCheckInput {
33
30
  readonly method: string;
34
31
  /** The origin this app was reached on — scheme from `ctx.https`, host from the request URL. */
@@ -60,29 +57,21 @@ export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
60
57
  if (input.anonymous) return { ok: true };
61
58
  if (input.hasAuthorizationHeader) return { ok: true };
62
59
 
63
- const site = input.secFetchSite;
64
- if (site === 'same-origin' || site === 'none') return { ok: true };
65
- if (input.origin === input.selfOrigin) return { ok: true };
60
+ // The rule itself is core's, shared with the sync node's upgrade: one answer to "did this
61
+ // come from this app?" on every surface an ambient credential reaches.
62
+ //
66
63
  // `originListed`, never `allowedOrigin`: that one answers the RESPONSE header, and for
67
64
  // `origins: ['*'], credentials: false` — the only wildcard `assertCorsConfig` admits — its
68
65
  // answer is `'*'`, which is not null and so read as "this origin is one we allow". A
69
66
  // credentialed cross-site POST from evil.test was therefore accepted by the check that exists
70
67
  // to refuse exactly it. An exact match is the only allowance a write may be built on.
71
- if (originListed(input.cors, input.origin)) return { ok: true };
72
- // Only the four values a browser can send are quoted back. Anything else is a client that
73
- // wrote the header itself, and echoing what it wrote is how a rejected value reaches the log
74
- // store and the response body — the same defect the error-map stage's log line had.
75
- if (site !== null) {
76
- const known = KNOWN_SITES.has(site) ? site : 'a value no browser sends';
77
- return { ok: false, reason: `the request reported sec-fetch-site: ${known}` };
78
- }
79
- return {
80
- ok: false,
81
- reason:
82
- input.origin === null
83
- ? 'the request carried neither sec-fetch-site nor origin, so it cannot be shown to be same-origin'
84
- : 'the origin it declares is not this app and is not listed in http.cors.origins',
85
- };
68
+ return proveSameOrigin({
69
+ selfOrigins: [input.selfOrigin],
70
+ origin: input.origin,
71
+ secFetchSite: input.secFetchSite,
72
+ listed: (origin) => originListed(input.cors, origin),
73
+ listName: 'http.cors.origins',
74
+ });
86
75
  };
87
76
 
88
77
  /** The origin a browser compares against — the PUBLIC one, so a TLS-terminating proxy agrees. */
package/src/error-map.ts CHANGED
@@ -425,6 +425,11 @@ export const ERROR_STATUS = {
425
425
  X_REALTIME_UNINSTALLED: 500,
426
426
  // Boot-time: a realtime config and its environment that disagree refuse before any request.
427
427
  X_REALTIME_TOPOLOGY: 500,
428
+ // The replicator's own Postgres connection failed TLS. A role that never answers a request, so the
429
+ // row keeps the table closed at 500, beside `X_REALTIME_TOPOLOGY`.
430
+ X_REPLICATION_TLS: 500,
431
+ // A browser page on a foreign origin asked for a socket — refused, not unauthenticated: 403.
432
+ X_SOCKET_ORIGIN_REFUSED: 403,
428
433
  X_RECORD_KEY_MISSING: 500,
429
434
  X_RECORD_REJECTED: 500,
430
435
  X_SYNC_UNCONFIGURED: 500,
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  asCtx,
24
24
  createRequestContext,
25
25
  elapsedMs,
26
+ useRequestBodyBytes,
26
27
  useRequestContext,
27
28
  useRequestCookie,
28
29
  useRequestHeader,
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
- async #read(): Promise<unknown> {
160
- if (this.method === 'GET' || this.method === 'HEAD') return undefined;
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
- // One capped read for every content type, multipart included: the parser runs on bytes this
186
- // process already agreed to hold, never on a stream it hands to the runtime unbounded.
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(read.bytes), {
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(read.bytes);
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') {