@ultimat3/http 29.4.0 → 30.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CLAUDE.md +26 -51
  2. package/README.md +119 -6
  3. package/package.json +15 -5
  4. package/src/bearer-mount.ts +67 -12
  5. package/src/cache-policy.ts +8 -0
  6. package/src/compress-read.ts +115 -0
  7. package/src/compress.ts +173 -0
  8. package/src/config.ts +1 -7
  9. package/src/context.ts +50 -0
  10. package/src/cors.ts +43 -4
  11. package/src/csrf.ts +41 -8
  12. package/src/deadline.ts +2 -2
  13. package/src/error-facts.ts +26 -1
  14. package/src/error-map-http.ts +3 -0
  15. package/src/error-map-tier-0.ts +26 -0
  16. package/src/error-map-tier-1.ts +22 -0
  17. package/src/error-map-tier-2.ts +16 -0
  18. package/src/error-map-tier-3.ts +24 -0
  19. package/src/error-map-tier-4.ts +33 -1
  20. package/src/error-page.ts +6 -7
  21. package/src/error-status.ts +26 -17
  22. package/src/error-titles.ts +2 -0
  23. package/src/errors.ts +27 -12
  24. package/src/finalize.ts +1 -0
  25. package/src/index.ts +17 -2
  26. package/src/locale.ts +4 -1
  27. package/src/object-response.ts +134 -0
  28. package/src/overlay.ts +13 -2
  29. package/src/pipeline.ts +6 -1
  30. package/src/preferences.ts +4 -0
  31. package/src/problem-meta-framework.ts +21 -0
  32. package/src/problem-meta.ts +23 -15
  33. package/src/rate-limit-degrade.ts +10 -2
  34. package/src/rate-limit-postgres.ts +22 -9
  35. package/src/rate-limit-store-deadline.ts +60 -0
  36. package/src/response.ts +29 -5
  37. package/src/router.ts +9 -0
  38. package/src/server-websocket.ts +35 -0
  39. package/src/server.ts +8 -4
  40. package/src/stages.ts +18 -5
  41. package/src/stored-object-headers.ts +43 -0
  42. package/src/webhook-hmac.ts +73 -0
  43. package/src/webhook-verify.ts +26 -5
  44. package/src/websocket-mounts.ts +104 -0
@@ -0,0 +1,115 @@
1
+ // The one read of a response body the `compress` stage ever does: counted against the length the
2
+ // response declared, bounded by the request's deadline, and able to hand back everything it took
3
+ // so an answer it gave up on still arrives whole. Split from the rule that decides when to read.
4
+
5
+ type Reader = ReadableStreamDefaultReader<Uint8Array>;
6
+ type Read = Awaited<ReturnType<Reader['read']>>;
7
+ type GiveUp = 'longer-than-declared' | 'deadline' | 'read-failed';
8
+
9
+ /** What a counted read ended with: every byte, or the reason it stopped and how to carry on. */
10
+ export type Drained =
11
+ | { readonly complete: true; readonly chunks: readonly Uint8Array[] }
12
+ | {
13
+ readonly complete: false;
14
+ readonly chunks: readonly Uint8Array[];
15
+ readonly reason: GiveUp;
16
+ /** The read already asked of the source when the deadline won; its bytes still belong. */
17
+ readonly pending?: Promise<Read>;
18
+ readonly failure?: unknown;
19
+ };
20
+
21
+ /** Undefined when the signal fires first. The read keeps going, and is the caller's to collect. */
22
+ const readBefore = (
23
+ read: Promise<Read>,
24
+ signal: AbortSignal | undefined,
25
+ ): Promise<Read | undefined> => {
26
+ if (signal === undefined) return read;
27
+ if (signal.aborted) return Promise.resolve(undefined);
28
+ return new Promise((resolve, reject) => {
29
+ const expired = (): void => resolve(undefined);
30
+ signal.addEventListener('abort', expired, { once: true });
31
+ const settle = (): void => signal.removeEventListener('abort', expired);
32
+ read.then(
33
+ (value) => {
34
+ settle();
35
+ resolve(value);
36
+ },
37
+ (failure: unknown) => {
38
+ settle();
39
+ reject(failure);
40
+ },
41
+ );
42
+ });
43
+ };
44
+
45
+ /**
46
+ * Counted, because the header is a claim and only the header was checked. The buffer never holds
47
+ * more than the declared length, which `inBounds` capped: the chunk that overruns is kept aside to
48
+ * be sent on, never added. Bounded in time too: this runs after the deadline's race has ended, and
49
+ * an answer that was going to send its headers at once must not wait on a body that stalled.
50
+ */
51
+ export const drainCounted = async (
52
+ reader: Reader,
53
+ declared: number,
54
+ signal: AbortSignal | undefined,
55
+ ): Promise<Drained> => {
56
+ const chunks: Uint8Array[] = [];
57
+ let held = 0;
58
+ for (;;) {
59
+ const pending = reader.read();
60
+ let next: Read | undefined;
61
+ try {
62
+ next = await readBefore(pending, signal);
63
+ } catch (failure) {
64
+ return { complete: false, chunks, reason: 'read-failed', failure };
65
+ }
66
+ if (next === undefined) {
67
+ // Observed now, so a client that leaves before the replay is pulled cannot turn the source's
68
+ // later failure into an unhandled rejection. The replay's construction pull happens to
69
+ // observe it today (high-water mark 1); this must not depend on that. It still awaits it.
70
+ pending.catch(() => undefined);
71
+ return { complete: false, chunks, reason: 'deadline', pending };
72
+ }
73
+ if (next.done) return { complete: true, chunks };
74
+ if (held + next.value.byteLength > declared) {
75
+ chunks.push(next.value);
76
+ return { complete: false, chunks, reason: 'longer-than-declared' };
77
+ }
78
+ chunks.push(next.value);
79
+ held += next.value.byteLength;
80
+ }
81
+ };
82
+
83
+ /** The bytes the read took, then the rest of the source, as if nothing had looked at them. */
84
+ export const replayDrained = (
85
+ chunks: readonly Uint8Array[],
86
+ reader: Reader,
87
+ pending: Promise<Read> | undefined,
88
+ ): ReadableStream<Uint8Array> => {
89
+ const queue = [...chunks];
90
+ let inFlight = pending;
91
+ return new ReadableStream<Uint8Array>({
92
+ async pull(controller) {
93
+ const held = queue.shift();
94
+ if (held !== undefined) {
95
+ controller.enqueue(held);
96
+ return;
97
+ }
98
+ const next = await (inFlight ?? reader.read());
99
+ inFlight = undefined;
100
+ if (next.done) controller.close();
101
+ else controller.enqueue(next.value);
102
+ },
103
+ cancel: (reason) => reader.cancel(reason),
104
+ });
105
+ };
106
+
107
+ export const concatChunks = (chunks: readonly Uint8Array[]): Uint8Array<ArrayBuffer> => {
108
+ const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0));
109
+ let at = 0;
110
+ for (const chunk of chunks) {
111
+ out.set(chunk, at);
112
+ at += chunk.byteLength;
113
+ }
114
+ return out;
115
+ };
@@ -0,0 +1,173 @@
1
+ // The `compress` stage's one rule: which answers leave the container gzipped, and the read that
2
+ // gets their bytes. Bun's own coder and no option: a handler that must not be coded says
3
+ // `no-transform`. Every other answer is handed back as the same object, its body never touched.
4
+ import { logger } from '@ultimat3/core';
5
+ import { concatChunks, drainCounted, replayDrained } from './compress-read';
6
+ import { addVary } from './response';
7
+
8
+ /** Below this the gzip header and trailer eat what the coding saves. */
9
+ export const COMPRESS_MIN_BYTES = 1_024;
10
+
11
+ /**
12
+ * The most one request may make `gzipSync` hold the event loop for. Measured 2026-11-03, level 6,
13
+ * high-entropy text, median of 15: 2.35 ms at 256 KiB against 9.58 ms at 1 MiB (about 29 ms at
14
+ * 1 MiB on slower hardware, where 1 MiB let ~34 anonymous req/s pin a core). Stylesheets code
15
+ * about 15x faster than that worst case.
16
+ */
17
+ export const COMPRESS_MAX_BYTES = 262_144;
18
+
19
+ const GZIP_LEVEL = 6;
20
+
21
+ /** Text only: images and fonts are coded already, and `text/event-stream` must never be held. */
22
+ const COMPRESSIBLE_TYPES: ReadonlySet<string> = new Set([
23
+ 'text/html',
24
+ 'text/css',
25
+ 'text/javascript',
26
+ 'application/javascript',
27
+ 'application/json',
28
+ 'application/manifest+json',
29
+ 'image/svg+xml',
30
+ 'application/xml',
31
+ 'text/xml',
32
+ 'application/rss+xml',
33
+ 'application/atom+xml',
34
+ 'text/vtt',
35
+ ]);
36
+
37
+ /**
38
+ * BREACH: a private document reflects a secret beside attacker input, and a coded length measures
39
+ * how well the two match. `no-cache` is here because it is the one spelling that passes the
40
+ * `cache-headers` review on a signed-in answer without becoming `private`.
41
+ */
42
+ const WITHHOLDS_CODING = /(?:^|[\s,])(?:private|no-store|no-cache|no-transform)(?=$|[\s,=])/i;
43
+
44
+ const DECLARED_LENGTH = /^\d+$/;
45
+
46
+ const IMMUTABLE = /(?:^|[\s,])immutable(?=$|[\s,=])/i;
47
+
48
+ /**
49
+ * Whether a `content-type` is one this stage codes. Exported for the one reader that must agree
50
+ * with it: `@ultimat3/cli`'s test holding every public framework route to a declared length.
51
+ */
52
+ export const isCompressibleType = (contentType: string | null): boolean => {
53
+ const type = contentType?.split(';')[0]?.trim().toLowerCase();
54
+ return type !== undefined && COMPRESSIBLE_TYPES.has(type);
55
+ };
56
+
57
+ /**
58
+ * A signed-in answer is coded only when `immutable`: a header like `stale-while-revalidate=60`
59
+ * withholds nothing and offers nothing, so the `cache-headers` review leaves it as written, and
60
+ * the body beside it is still the visitor's. The ACTOR is what makes it personal, never the header.
61
+ */
62
+ const compressibleKind = (headers: Headers, anonymous: boolean): boolean => {
63
+ const cacheControl = headers.get('cache-control') ?? '';
64
+ return (
65
+ isCompressibleType(headers.get('content-type')) &&
66
+ !WITHHOLDS_CODING.test(cacheControl) &&
67
+ (anonymous || IMMUTABLE.test(cacheControl))
68
+ );
69
+ };
70
+
71
+ /** HEAD is GET's headers with no body (RFC 9110 §9.3.2), so it is coded exactly as GET is. */
72
+ const CODED_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD']);
73
+
74
+ /** RFC 9110 §12.5.3, `gzip` by name. A `*` is not read as consent to a coding it never named. */
75
+ const acceptsGzip = (header: string | null): boolean => {
76
+ for (const member of header?.split(',') ?? []) {
77
+ const [coding, ...params] = member.split(';').map((part) => part.trim().toLowerCase());
78
+ if (coding !== 'gzip') continue;
79
+ const weight = params.find((param) => param.startsWith('q='));
80
+ return weight === undefined || Number(weight.slice(2)) > 0;
81
+ }
82
+ return false;
83
+ };
84
+
85
+ const inBounds = (length: string | null): length is string =>
86
+ length !== null &&
87
+ DECLARED_LENGTH.test(length) &&
88
+ Number(length) >= COMPRESS_MIN_BYTES &&
89
+ Number(length) <= COMPRESS_MAX_BYTES;
90
+
91
+ const rebuilt = (response: Response, body: BodyInit | null, headers: Headers): Response =>
92
+ new Response(body, { status: response.status, statusText: response.statusText, headers });
93
+
94
+ const coded = (response: Response, bytes: Uint8Array<ArrayBuffer>, head: boolean): Response => {
95
+ const gzipped = Bun.gzipSync(bytes, { level: GZIP_LEVEL });
96
+ const headers = new Headers(response.headers);
97
+ headers.set('content-encoding', 'gzip');
98
+ headers.set('content-length', String(gzipped.byteLength));
99
+ // A byte offset into the identity bytes means nothing in these.
100
+ headers.delete('accept-ranges');
101
+ headers.delete('transfer-encoding');
102
+ const etag = headers.get('etag');
103
+ // A strong validator promises these exact bytes, and the identity answer has other ones.
104
+ if (etag !== null && !etag.startsWith('W/')) headers.set('etag', `W/${etag}`);
105
+ return rebuilt(response, head ? null : gzipped, headers);
106
+ };
107
+
108
+ /** Identity after a read began. Never the declared length: the failed read just disproved it. */
109
+ const identity = (
110
+ response: Response,
111
+ body: ReadableStream<Uint8Array> | Uint8Array<ArrayBuffer>,
112
+ ) => {
113
+ const headers = new Headers(response.headers);
114
+ if (body instanceof Uint8Array) headers.set('content-length', String(body.byteLength));
115
+ else headers.delete('content-length');
116
+ return rebuilt(response, body, headers);
117
+ };
118
+
119
+ export interface CompressContext {
120
+ /** `isAnonymous(ctx.actor)`. Declared, never defaulted: a forgotten actor must not code. */
121
+ readonly anonymous: boolean;
122
+ /** The request's: past it, a body still being read goes out as identity. */
123
+ readonly signal?: AbortSignal;
124
+ }
125
+
126
+ /**
127
+ * The answer to send. Total: a compression failure is never a request failure, and the log line
128
+ * carries it.
129
+ */
130
+ export const compressResponse = async (
131
+ request: Request,
132
+ response: Response,
133
+ { anonymous, signal }: CompressContext,
134
+ ): Promise<Response> => {
135
+ if (!compressibleKind(response.headers, anonymous)) return response;
136
+ // On coded and identity alike, before any other refusal: a shared cache must not hand this
137
+ // client's copy to one that refused the coding, whichever of the two this client got.
138
+ addVary(response, ['accept-encoding']);
139
+ const length = response.headers.get('content-length');
140
+ if (
141
+ !CODED_METHODS.has(request.method) ||
142
+ response.status !== 200 ||
143
+ !acceptsGzip(request.headers.get('accept-encoding')) ||
144
+ response.headers.has('content-encoding') ||
145
+ response.headers.has('content-range') ||
146
+ !inBounds(length) ||
147
+ response.body === null ||
148
+ response.bodyUsed
149
+ ) {
150
+ return response;
151
+ }
152
+ const reader = response.body.getReader();
153
+ const drained = await drainCounted(reader, Number(length), signal);
154
+ if (!drained.complete) {
155
+ logger.warn('http.compress_failed', {
156
+ reason: drained.reason,
157
+ declaredBytes: Number(length),
158
+ ...(drained.failure === undefined ? {} : { error: drained.failure }),
159
+ });
160
+ return identity(response, replayDrained(drained.chunks, reader, drained.pending));
161
+ }
162
+ const bytes = concatChunks(drained.chunks);
163
+ try {
164
+ return coded(response, bytes, request.method === 'HEAD');
165
+ } catch (failure) {
166
+ logger.warn('http.compress_failed', {
167
+ reason: 'gzip-failed',
168
+ declaredBytes: Number(length),
169
+ error: failure,
170
+ });
171
+ return identity(response, bytes);
172
+ }
173
+ };
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  DEFAULT_ENVIRONMENT,
8
8
  DEFAULT_HEALTH_DETAIL_PEERS,
9
+ MAX_TIMER_MS,
9
10
  tryResolveEnvironment,
10
11
  } from '@ultimat3/core';
11
12
  import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
@@ -181,13 +182,6 @@ const assertFiniteCount = (
181
182
 
182
183
  const MAX_PORT = 65_535;
183
184
 
184
- /**
185
- * The longest delay a timer holds: a signed 32-bit count of milliseconds, about 24.8 days. Past
186
- * it `setTimeout` arms ~1 ms, so a "longer" request budget timed every request out at once.
187
- * `requestTimeoutMs` is screened against it and `deadline.ts` reads the inbound header by it.
188
- */
189
- export const MAX_TIMER_MS = 2_147_483_647;
190
-
191
185
  /**
192
186
  * Nobody has 64 proxies in front of one process; a bigger number is a typo, not a topology.
193
187
  *
package/src/context.ts CHANGED
@@ -12,10 +12,12 @@ import {
12
12
  traceId as newTraceId,
13
13
  type Role,
14
14
  readCookie,
15
+ runWithContext,
15
16
  type ServiceBag,
16
17
  systemClock,
17
18
  useContext,
18
19
  uuidV7,
20
+ withChildContext,
19
21
  } from '@ultimat3/core';
20
22
  import { localeConfig } from '@ultimat3/i18n';
21
23
  import { timeConfig } from '@ultimat3/time';
@@ -265,6 +267,54 @@ export const requestContext = (init: RequestContextInit): RequestContext => {
265
267
  */
266
268
  export const asCtx = (ctx: RequestContext): Ctx => ctx;
267
269
 
270
+ /**
271
+ * Run `fn` as this request with nobody behind it: what a SHARED document (`isr`, `static`) renders
272
+ * in. A stored page is answered to every later visitor, so it may vary on what its key carries and
273
+ * nothing else: `url` (already narrowed by the caller to what the key holds) and the locale.
274
+ *
275
+ * Still a request, which is why this is not a bare `withChildContext({ actor })`: that builds a
276
+ * plain `ctxOf`, where `setRedirect()` and `useRequestHeader()` answer `X_NO_REQUEST`. And not a
277
+ * spread of `ctx` alone, whose services were built for the visitor: the same leak by another door.
278
+ * So the child supplies the rebuilt services, and the request's fields go over it, for the reason
279
+ * `requestContext` spreads in that order: a service named `url` never shadows the request's own.
280
+ * `ctx.services` is non-enumerable (`bindRequestServices`), so the spread leaves the child's.
281
+ *
282
+ * Everything the visitor put on the context goes, by allow-list where a list exists. The inbound
283
+ * headers are EMPTY, not filtered: `x-api-key`, `x-forwarded-email` and whatever a proxy adds next
284
+ * are not names a deny-list can know, and no header is in the key — `accept-language` already did
285
+ * its work as `ctx.locale`, which IS. The zone is the default, since an actor's or a cookie's is
286
+ * not in the key (`preferences.ts`). `locale` stays: bounded, keyed, and no secret.
287
+ *
288
+ * What `fn` writes is on `scope`: read a redirect back with `takeRedirect(scope)`, never `ctx`.
289
+ * `scope.headers` is `ctx.headers`, so a response header set inside still reaches the response.
290
+ */
291
+ export const withAnonymousRequest = <T>(
292
+ ctx: RequestContext,
293
+ url: URL,
294
+ fn: (scope: RequestContext) => T,
295
+ ): T =>
296
+ runWithContext(asCtx(ctx), () => {
297
+ const tz = timeConfig().defaultZone;
298
+ return withChildContext({ actor: anonymousActor(), tz }, () => {
299
+ const child = useContext();
300
+ const scope: RequestContext = {
301
+ ...child,
302
+ ...ctx,
303
+ actor: child.actor,
304
+ tz,
305
+ url,
306
+ requestHeaders: new Headers(),
307
+ ip: null,
308
+ peer: null,
309
+ clientBuildId: null,
310
+ authz: undefined,
311
+ rateLimit: undefined,
312
+ readBody: undefined,
313
+ };
314
+ return runWithContext(asCtx(scope), () => fn(scope));
315
+ });
316
+ });
317
+
268
318
  /**
269
319
  * The ambient `Ctx`, WIDENED to the request shape and not yet proven to be one. Private for that
270
320
  * reason: every export below runs it through `assertInRequest` first. The cast is the inverse of
package/src/cors.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  // CORS with a locked default: same-origin only. Cross-origin access is a decision the app makes
2
2
  // once, in `configureHttp({ cors })`, never something a route can quietly opt into.
3
3
 
4
- import { BUILD_ID_HEADER } from '@ultimat3/core';
4
+ import {
5
+ BUILD_ID_HEADER,
6
+ DATES_HEADER,
7
+ IDEMPOTENCY_HEADER,
8
+ RECORDS_HEADER,
9
+ REPLAYED_HEADER,
10
+ REQUEST_TIMEOUT_HEADER,
11
+ } from '@ultimat3/core';
5
12
  import { corsConfigInvalid } from './errors';
6
13
 
7
14
  export interface CorsConfig {
@@ -17,12 +24,38 @@ export interface CorsConfig {
17
24
  export const DEFAULT_CORS: CorsConfig = {
18
25
  origins: [],
19
26
  methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
20
- allowHeaders: ['content-type', 'authorization', BUILD_ID_HEADER, 'x-request-id'],
21
- exposeHeaders: ['x-request-id', BUILD_ID_HEADER, 'retry-after'],
27
+ // What the typed clients send: a keyed write fails preflight without its idempotency key.
28
+ allowHeaders: [
29
+ 'content-type',
30
+ 'authorization',
31
+ BUILD_ID_HEADER,
32
+ 'x-request-id',
33
+ IDEMPOTENCY_HEADER,
34
+ REQUEST_TIMEOUT_HEADER,
35
+ 'traceparent',
36
+ ],
37
+ // What they read: with the records header hidden, a typed call answers the envelope itself.
38
+ exposeHeaders: [
39
+ 'x-request-id',
40
+ BUILD_ID_HEADER,
41
+ 'retry-after',
42
+ RECORDS_HEADER,
43
+ DATES_HEADER,
44
+ REPLAYED_HEADER,
45
+ 'ratelimit-limit',
46
+ 'ratelimit-remaining',
47
+ 'ratelimit-reset',
48
+ 'deprecation',
49
+ 'sunset',
50
+ 'link',
51
+ ],
22
52
  credentials: true,
23
53
  maxAgeSeconds: 600,
24
54
  };
25
55
 
56
+ /** What `Origin` carries, and `URL.origin` answers, when there is no origin to name. */
57
+ const OPAQUE_ORIGIN = 'null';
58
+
26
59
  /**
27
60
  * The one combination the browser refuses — `*` with credentials — refused HERE instead, at the
28
61
  * one moment an author can act on it. It used to resolve to "no CORS headers at all": the natural
@@ -35,6 +68,12 @@ export const assertCorsConfig = (config: CorsConfig): void => {
35
68
  "origins includes '*' while credentials is true — no browser accepts that pair",
36
69
  );
37
70
  }
71
+ if (config.origins.includes(OPAQUE_ORIGIN)) {
72
+ throw corsConfigInvalid(
73
+ "origins includes 'null', the origin every sandboxed iframe on the web sends, so any site could read the reply. URL.parse('localhost:3000').origin is that string",
74
+ "list the scheme too: configureHttp({ cors: { origins: ['http://localhost:3000'] } })",
75
+ );
76
+ }
38
77
  };
39
78
 
40
79
  /**
@@ -46,7 +85,7 @@ export const assertCorsConfig = (config: CorsConfig): void => {
46
85
  * this app allows" and let a cross-site form post through with the session cookie attached.
47
86
  */
48
87
  export const originListed = (config: CorsConfig, origin: string | null): boolean =>
49
- origin !== null && config.origins.includes(origin);
88
+ origin !== null && origin !== OPAQUE_ORIGIN && config.origins.includes(origin);
50
89
 
51
90
  /**
52
91
  * The one answer to "may this origin READ our reply?" — the value of
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, proveSameOrigin } from '@ultimat3/core';
8
+ import { peerAddressClass, proveSameOrigin, renderFixShellArg } from '@ultimat3/core';
9
9
  import type { CorsConfig } from './cors';
10
10
  import { originListed } from './cors';
11
11
  import { HttpError } from './errors';
@@ -25,6 +25,8 @@ export const DEFAULT_CSRF: CsrfConfig = { mode: 'origin' };
25
25
 
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
+ /** RFC 6750 §2.1: the scheme, case-insensitive, then a token. A bare `Bearer` carries nothing. */
29
+ const BEARER = /^bearer +\S/i;
28
30
 
29
31
  export interface CsrfCheckInput {
30
32
  readonly method: string;
@@ -32,14 +34,23 @@ export interface CsrfCheckInput {
32
34
  readonly selfOrigin: string;
33
35
  readonly origin: string | null;
34
36
  readonly secFetchSite: string | null;
35
- /** A bearer token is not ambient: a cross-site page cannot make the browser attach one. */
36
- readonly hasAuthorizationHeader: boolean;
37
+ /**
38
+ * The raw `Authorization` header. Only `Bearer` exempts: no browser attaches one on its own,
39
+ * and a script setting it cross-origin needs a CORS preflight this app's `cors.origins` decides.
40
+ * Basic, Digest and Negotiate are re-sent from the browser's cache — as ambient as a cookie.
41
+ */
42
+ readonly authorization: string | null;
37
43
  /**
38
44
  * No session resolved. NOT an exemption: a sign-in form is forgeable too (login CSRF — the
39
45
  * visitor ends up in the attacker's account), so an anonymous write that carries browser
40
46
  * evidence is judged exactly as a credentialed one. See `checkCsrf` for the one thing it buys.
41
47
  */
42
48
  readonly anonymous: boolean;
49
+ /**
50
+ * The route declared `meta.csrf: 'cookieless'` (and so its own authenticator). Exempt only
51
+ * while `anonymous`: a caller that something DID authenticate is judged as any other.
52
+ */
53
+ readonly cookieless?: boolean | undefined;
43
54
  readonly cors: CorsConfig;
44
55
  readonly config: CsrfConfig;
45
56
  }
@@ -63,8 +74,11 @@ export type CsrfVerdict =
63
74
  export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
64
75
  if (input.config.mode === 'off') return { ok: true };
65
76
  if (SAFE_METHODS.has(input.method)) return { ok: true };
66
- if (input.hasAuthorizationHeader) return { ok: true };
77
+ if (input.authorization !== null && BEARER.test(input.authorization)) return { ok: true };
67
78
  if (input.anonymous && input.origin === null && input.secFetchSite === null) return { ok: true };
79
+ // No cookie can make this request anyone's, and no cookie is minted by it: a cross-origin
80
+ // client posting a code and its verifier is the endpoint's whole purpose, not a forgery.
81
+ if (input.anonymous && input.cookieless === true) return { ok: true };
68
82
 
69
83
  // The rule itself is core's, shared with the sync node's upgrade: one answer to "did this
70
84
  // come from this app?" on every surface an ambient credential reaches.
@@ -99,16 +113,35 @@ export const selfOrigin = (url: URL, https: boolean): string =>
99
113
  export const csrfBlocked = (
100
114
  pathname: string,
101
115
  reason: string,
102
- ip: string | null = null,
116
+ ip: string | null,
117
+ origin: string,
103
118
  ): HttpError =>
104
- ip !== null && classifyAddress(ip) === 'loopback'
119
+ ip !== null && peerAddressClass(ip) === 'loopback'
105
120
  ? new HttpError({
106
121
  code: 'X_CSRF_BLOCKED',
107
122
  cause: `${pathname} refused a write it could not show came from this app: ${reason}`,
108
- fix: "curl -H 'sec-fetch-site: same-origin' -X POST http://localhost:3000/the-path-above # a local client proves same-origin the way a browser does; the session cookie still decides who is calling",
123
+ fix: `curl -H 'sec-fetch-site: same-origin' -X POST ${renderFixShellArg(`${origin}${pathname}`, "'<the url of this request>'")} # a local client proves same-origin the way a browser does; the session cookie still decides who is calling`,
109
124
  })
110
125
  : new HttpError({
111
126
  code: 'X_CSRF_BLOCKED',
112
127
  cause: `${pathname} refused a write it could not show came from this app: ${reason}`,
113
- fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
128
+ fix: "call it with an Authorization: Bearer token instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
114
129
  });
130
+
131
+ /**
132
+ * `meta.csrf: 'cookieless'` is a claim about the route's credential, so it holds only where the
133
+ * route names that credential itself: without `meta.authenticate` the app's cookie authenticator
134
+ * runs there, and the exemption would wave through the forged write this stage exists to refuse.
135
+ */
136
+ export const assertRouteCsrf = (
137
+ meta: { readonly name: string; readonly csrf?: 'cookieless'; readonly authenticate?: unknown },
138
+ path: string,
139
+ ): void => {
140
+ if (meta.csrf !== 'cookieless' || meta.authenticate !== undefined) return;
141
+ throw new HttpError({
142
+ code: 'X_CONFIG_INVALID',
143
+ cause: `route ${meta.name} (${path}) declares csrf: 'cookieless' but no meta.authenticate, so the app's configureAuthenticator() — the session cookie — still decides who calls it`,
144
+ fix: `meta: { csrf: 'cookieless', authenticate: () => null } # on the route declaring ${meta.name}, or its own non-cookie credential; or drop csrf: 'cookieless'`,
145
+ meta: { route: meta.name, path, option: 'csrf' },
146
+ });
147
+ };
package/src/deadline.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  // until the process died, and SIGTERM then waited out the whole drain budget for work that would
5
5
  // never finish.
6
6
 
7
- import { REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
8
- import { type HttpConfig, MAX_TIMER_MS } from './config';
7
+ import { MAX_TIMER_MS, REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
8
+ import type { HttpConfig } from './config';
9
9
  import { requestTimedOut } from './errors';
10
10
 
11
11
  /**
@@ -2,8 +2,10 @@
2
2
  // document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
3
3
  // 500-line ceiling — that file answers "what status is this code", one closed table, and this one
4
4
  // answers "what does a reader see", which is three audiences and one opacity rule.
5
+ import type { ErrorRetry } from '@ultimat3/core';
5
6
  import {
6
7
  ERROR_DOCS_URL,
8
+ errorRetry,
7
9
  FRAMEWORK_CODE,
8
10
  failureStack,
9
11
  hasPublicCause,
@@ -13,6 +15,7 @@ import {
13
15
  stringField,
14
16
  } from '@ultimat3/core';
15
17
  import type { ValidationIssue } from '@ultimat3/schema';
18
+ import { issueRuleFacts } from '@ultimat3/schema';
16
19
  import { declaredStatusFor, statusFor } from './error-status';
17
20
  import { HTTP_ERROR_TITLES } from './errors';
18
21
  import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
@@ -204,6 +207,9 @@ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
204
207
  // Forced, never copied. See the paragraph above.
205
208
  received: '',
206
209
  message,
210
+ // The rule a translator keys on, and its declared params — schema's one reader, which
211
+ // keeps a string `rule` and scalar `params` only, member by member.
212
+ ...issueRuleFacts(entry),
207
213
  });
208
214
  }
209
215
  return issues;
@@ -241,7 +247,20 @@ function metaOf(error: unknown, code: string): ProblemMeta | undefined {
241
247
  */
242
248
  export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
243
249
 
244
- /** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
250
+ /**
251
+ * Core's `errorRetry`, total, for `retryAfterOf`'s reason: `retry` is a getter read on a value this
252
+ * package did not build, in the frame that decides what the caller sees. Refusing to be read is
253
+ * `terminal`, which is what the same value's unclassified failure answers.
254
+ */
255
+ const retryOf = (error: unknown): ErrorRetry => {
256
+ try {
257
+ return errorRetry(error);
258
+ } catch {
259
+ return 'terminal';
260
+ }
261
+ };
262
+
263
+ /** RFC-9457 problem document. `code`/`cause`/`fix`/`docs`/`retry` are our extensions. */
245
264
  export interface ProblemDocument {
246
265
  readonly type: string;
247
266
  readonly title: string;
@@ -252,6 +271,11 @@ export interface ProblemDocument {
252
271
  readonly cause: string;
253
272
  readonly fix: string;
254
273
  readonly docs: string;
274
+ /**
275
+ * May the same call be sent again: core's class, the one a thrown `UltimateError` carries. On
276
+ * every document, hidden ones included: it says what to do, never what happened.
277
+ */
278
+ readonly retry: ErrorRetry;
255
279
  readonly requestId: string | undefined;
256
280
  /**
257
281
  * The rejections, addressed by path, for a failure that produced them. TOP-LEVEL and not an
@@ -351,6 +375,7 @@ export const toProblem = (
351
375
  ? facts.fix
352
376
  : (facts.callerFix ?? (hidden ? explainFix(facts.code) : facts.fix)),
353
377
  docs: facts.docs,
378
+ retry: retryOf(error),
354
379
  requestId: meta.requestId,
355
380
  ...(issues === undefined ? {} : { issues }),
356
381
  ...(carried === undefined ? {} : { meta: carried }),
@@ -55,6 +55,9 @@ export const HTTP_ERROR_STATUS = {
55
55
  X_RATE_LIMIT_INVALID: 500,
56
56
  // The shared store did not answer, so nothing decided. An operator's fault, never the caller's.
57
57
  X_RATE_LIMIT_STORE_UNAVAILABLE: 500,
58
+ // The store's own deadline passed. The store degrades on it rather than throwing, so a request
59
+ // answered with it is the store's bug; 500, and never a 503 the `admit` stage alone answers.
60
+ X_RATE_LIMIT_STORE_TIMEOUT: 500,
58
61
  // The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry
59
62
  // `retry-after`: a shed request that does not say when to come back is a request that comes
60
63
  // back immediately, which is the load it was shed to avoid.