@ultimat3/http 29.3.2 → 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 (45) 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/byte-range.ts +69 -0
  6. package/src/cache-policy.ts +8 -0
  7. package/src/compress-read.ts +115 -0
  8. package/src/compress.ts +173 -0
  9. package/src/config.ts +1 -7
  10. package/src/context.ts +50 -0
  11. package/src/cors.ts +43 -4
  12. package/src/csrf.ts +41 -8
  13. package/src/deadline.ts +2 -2
  14. package/src/error-facts.ts +26 -1
  15. package/src/error-map-http.ts +3 -0
  16. package/src/error-map-tier-0.ts +26 -0
  17. package/src/error-map-tier-1.ts +24 -0
  18. package/src/error-map-tier-2.ts +16 -0
  19. package/src/error-map-tier-3.ts +30 -0
  20. package/src/error-map-tier-4.ts +33 -1
  21. package/src/error-page.ts +6 -7
  22. package/src/error-status.ts +26 -17
  23. package/src/error-titles.ts +2 -0
  24. package/src/errors.ts +27 -12
  25. package/src/finalize.ts +1 -0
  26. package/src/index.ts +20 -2
  27. package/src/locale.ts +4 -1
  28. package/src/object-response.ts +134 -0
  29. package/src/overlay.ts +13 -2
  30. package/src/pipeline.ts +6 -1
  31. package/src/preferences.ts +4 -0
  32. package/src/problem-meta-framework.ts +21 -0
  33. package/src/problem-meta.ts +23 -15
  34. package/src/rate-limit-degrade.ts +10 -2
  35. package/src/rate-limit-postgres.ts +22 -9
  36. package/src/rate-limit-store-deadline.ts +60 -0
  37. package/src/response.ts +29 -5
  38. package/src/router.ts +9 -0
  39. package/src/server-websocket.ts +35 -0
  40. package/src/server.ts +8 -4
  41. package/src/stages.ts +22 -5
  42. package/src/stored-object-headers.ts +43 -0
  43. package/src/webhook-hmac.ts +73 -0
  44. package/src/webhook-verify.ts +26 -5
  45. package/src/websocket-mounts.ts +104 -0
package/src/stages.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // One function per stage name: what each stage of the lifecycle DOES. The package's three-way
2
2
  // split, each file naming the other two — `pipeline.ts` owns the ORDER the stages run in and the
3
3
  // run itself, `finalize.ts` owns the promise that the tail cannot reject, and this file owns the
4
- // work. The vocabulary (`StageName`, `Stage`) lives here too, beside the fourteen implementations
4
+ // work. The vocabulary (`StageName`, `Stage`) lives here too, beside the fifteen implementations
5
5
  // it names, so `Record<StageName, StageRun>` below is the build error that catches a missing one.
6
6
 
7
7
  import {
@@ -15,6 +15,7 @@ import {
15
15
  } from '@ultimat3/core';
16
16
  import { signInRedirect } from './auth-redirect';
17
17
  import { finalizeCacheHeaders } from './cache-stage';
18
+ import { compressResponse } from './compress';
18
19
  import { type HttpConfig, stripBasePath } from './config';
19
20
  import { elapsedMs, type RequestContext } from './context';
20
21
  import { corsHeaders, preflight } from './cors';
@@ -66,7 +67,8 @@ export type StageName =
66
67
  | 'handler'
67
68
  | 'cache-headers'
68
69
  | 'error-map'
69
- | 'response';
70
+ | 'response'
71
+ | 'compress';
70
72
 
71
73
  /**
72
74
  * `request` may short-circuit by returning a Response.
@@ -79,7 +81,7 @@ export type StagePhase = 'request' | 'terminal' | 'recover' | 'finalize';
79
81
  export interface StageDoc {
80
82
  readonly name: StageName;
81
83
  readonly phase: StagePhase;
82
- /** Why the stage sits at this index. Rendered verbatim by the dev dashboard. */
84
+ /** Why the stage sits at this index, read by `pipeline.test.ts` and anyone reading the list. */
83
85
  readonly why: string;
84
86
  }
85
87
 
@@ -265,12 +267,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
265
267
  selfOrigin: selfOrigin(ctx.url, ctx.https),
266
268
  origin: request.header('origin'),
267
269
  secFetchSite: request.header('sec-fetch-site'),
268
- hasAuthorizationHeader: request.header('authorization') !== null,
270
+ authorization: request.header('authorization'),
269
271
  anonymous: isAnonymous(ctx.actor),
272
+ cookieless: ctx.route?.meta.csrf === 'cookieless',
270
273
  cors: config.cors,
271
274
  config: config.csrf,
272
275
  });
273
- if (!verdict.ok) throw csrfBlocked(ctx.url.pathname, verdict.reason, ctx.ip);
276
+ if (!verdict.ok) {
277
+ throw csrfBlocked(ctx.url.pathname, verdict.reason, ctx.ip, selfOrigin(ctx.url, ctx.https));
278
+ }
274
279
  return undefined;
275
280
  },
276
281
 
@@ -461,11 +466,23 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
461
466
  // policies, both enforced — so a handler can only narrow (a `sandbox` on an uploaded file),
462
467
  // never widen. Overwritten, that `sandbox` never reached a browser.
463
468
  if (isCspHeader(name) && response.headers.has(name)) response.headers.append(name, value);
469
+ // The strictest referrer policy a handler set is KEPT for the same reason — it narrows: a
470
+ // file served at a bearer URL (a query's download) must name that URL to nobody.
471
+ else if (name === 'referrer-policy' && response.headers.get(name) === 'no-referrer')
472
+ continue;
464
473
  else response.headers.set(name, value);
465
474
  }
466
475
  response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
467
476
  return undefined;
468
477
  },
478
+
479
+ compress: (request, ctx) =>
480
+ ctx.response === undefined
481
+ ? undefined
482
+ : compressResponse(request.raw, ctx.response, {
483
+ anonymous: isAnonymous(ctx.actor),
484
+ signal: ctx.signal,
485
+ }),
469
486
  };
470
487
  return table;
471
488
  };
@@ -0,0 +1,43 @@
1
+ // How a stored object may be presented when the app's own origin serves it (plan 101, s1-sec L6):
2
+ // inline only for a type a browser renders without running anything, a sandboxed download otherwise.
3
+ // Read by `objectResponse`, the one way a stored object is answered.
4
+
5
+ /**
6
+ * Raster images, audio and video: what an `<img>`, `<audio>` or `<video>` presents and nothing
7
+ * executes. NOT `image/svg+xml` (it carries `<script>`), `text/html`, `application/xml` or PDF —
8
+ * any of them opened from the app's origin runs with the app's cookies and its `'self'` sources.
9
+ */
10
+ const INLINE_TYPES: ReadonlySet<string> = new Set([
11
+ 'image/png',
12
+ 'image/jpeg',
13
+ 'image/gif',
14
+ 'image/webp',
15
+ 'image/avif',
16
+ 'audio/mpeg',
17
+ 'audio/ogg',
18
+ 'audio/wav',
19
+ 'audio/webm',
20
+ 'video/mp4',
21
+ 'video/webm',
22
+ 'video/ogg',
23
+ ]);
24
+
25
+ /** `image/PNG; charset=…` is `image/png`: parameters and case never widen the list. */
26
+ const essenceOf = (contentType: string): string =>
27
+ (contentType.split(';')[0] ?? '').trim().toLowerCase();
28
+
29
+ export const isInlineSafe = (contentType: string): boolean =>
30
+ INLINE_TYPES.has(essenceOf(contentType));
31
+
32
+ /**
33
+ * The headers a stored object is served with beyond its type. `attachment` makes a navigation to an
34
+ * uploaded `text/html` a download instead of a page on the app's origin; an embedding element
35
+ * (`<img>`, `<video>`) ignores it, so nothing that displays an object inline stops working. The
36
+ * `sandbox` policy is for the browser that renders it anyway: an opaque origin, no script, no
37
+ * cookies. The security stage keeps it and adds the app's policy beside it.
38
+ */
39
+ export function storedObjectHeaders(contentType: string): Readonly<Record<string, string>> {
40
+ return isInlineSafe(contentType)
41
+ ? {}
42
+ : { 'content-disposition': 'attachment', 'content-security-policy': 'sandbox' };
43
+ }
@@ -0,0 +1,73 @@
1
+ // `verifyBodyHmac`: the one vendor-neutral check many inbound webhooks share — HMAC-SHA256 under a
2
+ // UTF-8 secret over bytes the caller names, compared in constant time, under any secret of a
3
+ // rotation list. It does not decode a secret (Svix's base64 `whsec_` key is the mail receivers'),
4
+ // and the bytes and the header are the vendor's: no vendor verifier ships (axiom 8).
5
+
6
+ import { hmac, timingSafeEqual } from '@ultimat3/core';
7
+ import { webhookSignatureInvalid } from './errors';
8
+ import { assertWebhookSecrets } from './webhook-verify';
9
+
10
+ export interface BodyHmacOptions {
11
+ /** The bytes the vendor signed. A string is its UTF-8 encoding. */
12
+ readonly bytes: Uint8Array | string;
13
+ /** The shared secret, or the list a rotation keeps live — any one of them verifies. */
14
+ readonly secret: string | readonly string[];
15
+ /** The mac the request carried, header value as sent. Absent or empty is a refusal. */
16
+ readonly given: string | null | undefined;
17
+ readonly encoding: 'hex' | 'base64';
18
+ /** What precedes the mac in `given`: `sha256=` (GitHub), `v0=` (Slack). Compared, not stripped. */
19
+ readonly prefix?: string;
20
+ }
21
+
22
+ /**
23
+ * What a passing check answers: the bytes it verified, and their text when they are UTF-8. Read
24
+ * the event id from HERE, never from an unsigned header — an id outside the mac lets one captured
25
+ * signature replay under any id the attacker picks.
26
+ */
27
+ export interface VerifiedBody {
28
+ readonly bytes: Uint8Array;
29
+ readonly text: string | null;
30
+ }
31
+
32
+ /** `fatal`: invalid UTF-8 is `null` text, never replacement characters an id could be read from. */
33
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
34
+
35
+ /**
36
+ * Prove `given` is `prefix + HMAC-SHA256(secret, bytes)` for one of the secrets, and answer what
37
+ * was verified. Throws `X_WEBHOOK_SIGNATURE_INVALID` (401) on a mismatch and `X_CONFIG_INVALID`
38
+ * for no usable secret.
39
+ */
40
+ export function verifyBodyHmac(options: BodyHmacOptions): VerifiedBody {
41
+ const secrets = assertWebhookSecrets(options.secret, 'verifyBodyHmac');
42
+ const given = options.given ?? '';
43
+ if (given === '') {
44
+ throw webhookSignatureInvalid(
45
+ 'verifyBodyHmac',
46
+ 'the request carried no signature',
47
+ 'verifyBodyHmac',
48
+ );
49
+ }
50
+ const prefix = options.prefix ?? '';
51
+ let matched = false;
52
+ // Every secret is tried, even after a match: which one matched is not worth a timing signal.
53
+ for (const secret of secrets) {
54
+ const expected = `${prefix}${hmac('sha256', secret, options.bytes, options.encoding)}`;
55
+ if (timingSafeEqual(expected, given)) matched = true;
56
+ }
57
+ if (!matched) {
58
+ throw webhookSignatureInvalid(
59
+ 'verifyBodyHmac',
60
+ `no ${options.encoding} sha256 mac${prefix === '' ? '' : ` behind the "${prefix}" prefix`} under the ${secrets.length} configured secret(s) matches the bytes`,
61
+ 'verifyBodyHmac',
62
+ );
63
+ }
64
+ const bytes =
65
+ typeof options.bytes === 'string' ? new TextEncoder().encode(options.bytes) : options.bytes;
66
+ let text: string | null;
67
+ try {
68
+ text = typeof options.bytes === 'string' ? options.bytes : strictUtf8.decode(bytes);
69
+ } catch {
70
+ text = null;
71
+ }
72
+ return { bytes, text };
73
+ }
@@ -3,11 +3,10 @@
3
3
  // a pipeline stage because a receiver is an ordinary `api/` route — the secret is per sender, and
4
4
  // only the route knows which one applies.
5
5
  //
6
- // THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here. That
7
- // module is at the tier both halves can reach: `@ultimat3/jobs` (tier 3) signs a delivery and its
8
- // boundary forbids this package, and this package (tier 2) may not reach tier 3. What stays here
9
- // is the POLICY — what counts as fresh, how large a body may be, and which refusal a receiver
10
- // answers with.
6
+ // THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here: this
7
+ // package (tier 2) may not reach `@ultimat3/jobs` (tier 3), which signs, so the format sits below
8
+ // both. What stays here is the POLICY — what counts as fresh, how large a body may be, and which
9
+ // refusal a receiver answers with.
11
10
 
12
11
  import type { Clock } from '@ultimat3/core';
13
12
  import {
@@ -22,6 +21,7 @@ import {
22
21
  WEBHOOK_TOPIC_HEADER,
23
22
  webhookMac,
24
23
  } from '@ultimat3/core';
24
+ import type { WebhookVerifier } from './errors';
25
25
  import {
26
26
  bodyInvalid,
27
27
  httpCountInvalid,
@@ -93,6 +93,27 @@ const assertWebhookSecret = (secret: unknown): string => {
93
93
  );
94
94
  };
95
95
 
96
+ /**
97
+ * The same screen over a rotation list (`verifyBodyHmac`): one entry or several, every one
98
+ * non-empty, and at least one. One empty entry refuses the list — `[current, process.env.OLD]`
99
+ * with `OLD` unset would otherwise verify anything under the empty key.
100
+ */
101
+ export const assertWebhookSecrets = (
102
+ secret: unknown,
103
+ via: WebhookVerifier = 'verifyWebhookSignature',
104
+ ): readonly string[] => {
105
+ const list: readonly unknown[] = Array.isArray(secret) ? secret : [secret];
106
+ if (list.length === 0) throw webhookSecretMissing('an empty list', via);
107
+ for (const entry of list) {
108
+ if (typeof entry === 'string' && entry.length > 0) continue;
109
+ throw webhookSecretMissing(
110
+ entry === '' ? 'an empty string' : `a value of type ${typeof entry}`,
111
+ via,
112
+ );
113
+ }
114
+ return list as readonly string[];
115
+ };
116
+
96
117
  export interface WebhookVerifyOptions {
97
118
  /** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
98
119
  readonly secret: string;
@@ -0,0 +1,104 @@
1
+ // Several websocket mounts on ONE listener. `Bun.serve` takes one `websocket` handler set for every
2
+ // socket it opens, so the mounts are joined here: an upgrade is routed by path, and each socket's
3
+ // events go back to the mount that upgraded it — keyed by the `data` object that mount handed
4
+ // `server.upgrade`, which Bun returns as `ws.data`. No mount's socket is wrapped or proxied.
5
+
6
+ import { assert } from '@ultimat3/core';
7
+ import type { UpgradeTarget, WebSocketMount } from './server';
8
+
9
+ /**
10
+ * A mount's handler set with Bun's optional `drain`: the sync node answers it with its
11
+ * backpressure gap repair, and a join that dropped it left a refused frame's socket gapped.
12
+ */
13
+ export type MountHandlers<TSocket> = WebSocketMount<TSocket>['websocket'] & {
14
+ drain?(ws: TSocket): void;
15
+ };
16
+
17
+ /** What `httpServer` dispatches: every path the mounts own, one fetch, one handler set. */
18
+ export interface JoinedWebSocketMounts {
19
+ readonly paths: ReadonlySet<string>;
20
+ fetch(
21
+ request: Request,
22
+ server: UpgradeTarget,
23
+ ): Promise<Response | undefined> | Response | undefined;
24
+ readonly websocket: MountHandlers<{ readonly data: unknown }>;
25
+ }
26
+
27
+ /** Bun's per-listener knobs, joined so no mount gets less than it declared. */
28
+ function joinedLimits(mounts: readonly WebSocketMount<never>[]) {
29
+ const max = (pick: (mount: WebSocketMount<never>) => number | undefined): number | undefined => {
30
+ const values = mounts.map(pick).filter((value): value is number => value !== undefined);
31
+ return values.length === 0 ? undefined : Math.max(...values);
32
+ };
33
+ const idleTimeout = max((mount) => mount.websocket.idleTimeout);
34
+ const backpressureLimit = max((mount) => mount.websocket.backpressureLimit);
35
+ const maxPayloadLength = max((mount) => mount.websocket.maxPayloadLength);
36
+ const sendPings = mounts.some((mount) => mount.websocket.sendPings === true);
37
+ return {
38
+ ...(idleTimeout === undefined ? {} : { idleTimeout }),
39
+ ...(backpressureLimit === undefined ? {} : { backpressureLimit }),
40
+ ...(maxPayloadLength === undefined ? {} : { maxPayloadLength }),
41
+ ...(sendPings ? { sendPings } : {}),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * The mounts, joined. Two at one path is `X_INVARIANT` here — the same fact `httpServer` refuses
47
+ * between a mount and a route. The listener's limits are the widest any mount declared, so Bun
48
+ * enforces only those: a mount whose own bound is tighter (the sync node's `maxFrameBytes`, the
49
+ * device socket's frame cap) refuses past it in its own `message`, never through Bun alone.
50
+ */
51
+ export function joinWebSocketMounts(
52
+ mounts: readonly WebSocketMount<never>[],
53
+ ): JoinedWebSocketMounts {
54
+ const byPath = new Map<string, WebSocketMount<never>>();
55
+ for (const mount of mounts) {
56
+ assert(
57
+ !byPath.has(mount.path),
58
+ `two websocket mounts claim ${mount.path}, and a path is answered by one of them`,
59
+ 'x routes --json # then move one mount to a path of its own',
60
+ );
61
+ byPath.set(mount.path, mount);
62
+ }
63
+ // A socket's owner, by the identity of the data its mount upgraded it with.
64
+ const owners = new WeakMap<object, WebSocketMount<never>>();
65
+ const ownerOf = (ws: { readonly data: unknown }): WebSocketMount<never> | undefined =>
66
+ typeof ws.data === 'object' && ws.data !== null ? owners.get(ws.data) : undefined;
67
+ // `never` is each mount's own socket type, unknown to this file: the socket handed back is the
68
+ // one that mount's upgrade produced, so the widening below is the only way to return it.
69
+ const hand = (ws: { readonly data: unknown }): never => ws as never;
70
+ // Read through the wider type: `drain` is optional, so every mount's handler set is one.
71
+ const handlersOf = (mount?: WebSocketMount<never>): MountHandlers<never> | undefined =>
72
+ mount?.websocket;
73
+ return {
74
+ paths: new Set(byPath.keys()),
75
+ fetch(request, server) {
76
+ const mount = byPath.get(new URL(request.url).pathname);
77
+ if (mount === undefined) return new Response('not found', { status: 404 });
78
+ const recording: UpgradeTarget = {
79
+ upgrade(upgraded, options) {
80
+ const { data } = options;
81
+ assert(
82
+ typeof data === 'object' && data !== null,
83
+ `the websocket mount at ${mount.path} upgraded with data that is not an object, so its socket cannot be routed back to it`,
84
+ 'server.upgrade(request, { data: { socketId } }) # an object, in the mount at that path',
85
+ );
86
+ owners.set(data, mount);
87
+ return server.upgrade(upgraded, options);
88
+ },
89
+ requestIP: (asked) => server.requestIP(asked),
90
+ };
91
+ return mount.fetch(request, recording);
92
+ },
93
+ websocket: {
94
+ open: (ws) => ownerOf(ws)?.websocket.open(hand(ws)),
95
+ message: (ws, message) => ownerOf(ws)?.websocket.message(hand(ws), message),
96
+ close: (ws) => ownerOf(ws)?.websocket.close(hand(ws)),
97
+ // Present only when a mount answers it: Bun calls it once a socket's buffer can take frames.
98
+ ...(mounts.some((mount) => handlersOf(mount)?.drain !== undefined)
99
+ ? { drain: (ws) => handlersOf(ownerOf(ws))?.drain?.(hand(ws)) }
100
+ : {}),
101
+ ...joinedLimits(mounts),
102
+ },
103
+ };
104
+ }