@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
package/src/pipeline.ts CHANGED
@@ -93,7 +93,12 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
93
93
  {
94
94
  name: 'response',
95
95
  phase: 'finalize',
96
- why: 'last: a redirect answered to a client-router request becomes 204 + x-ultimate-location (the router follows it once, never fetch), then CORS, security headers, server-timing and the accumulated context headers are merged onto whatever the stages produced',
96
+ why: 'after every stage that decides a header: a redirect answered to a client-router request becomes 204 + x-ultimate-location (the router follows it once, never fetch), then CORS, security headers, server-timing and the accumulated context headers are merged onto whatever the stages produced',
97
+ },
98
+ {
99
+ name: 'compress',
100
+ phase: 'finalize',
101
+ why: 'last: it reads the final cache-control, so a private answer can never be coded (BREACH), and rebuilds the response with every header already on it. Only a public text answer with a declared length is ever read, so no other response pays for this stage',
97
102
  },
98
103
  ];
99
104
 
@@ -31,7 +31,11 @@ export function resolvePreferences(
31
31
  });
32
32
  ctx.tz = resolveTimeZone({
33
33
  cookie: readCookie(cookies, config.tz.cookie),
34
+ // Documented as a source (`?tz=` for share links) and never read until 2026-11, as `?locale=`.
35
+ query: ctx.url.searchParams.get('tz'),
34
36
  header: request.header(config.tz.header),
37
+ // A document navigation sends no header: this is the browser's zone on a first `app/` page.
38
+ detected: readCookie(cookies, config.tz.detectedCookie),
35
39
  user: ctx.actor.tz ?? null,
36
40
  }).zone;
37
41
  ctx.headers.set('content-language', ctx.locale);
@@ -0,0 +1,21 @@
1
+ // The FRAMEWORK's half of `problem-meta.ts`: per framework code, the `meta` keys its problem
2
+ // document carries. A framework code's `meta` is operator-only by default (`problem-meta.ts`'s
3
+ // header names what lives there), and `registerProblemMeta` refuses every framework code — so
4
+ // without this table a refusal whose meta IS the caller's instruction never reached the caller.
5
+ //
6
+ // One reviewed table, never a per-class or per-instance option: an option on the error would let
7
+ // any throw site publish any key, and the rule is that a key crosses only where a reviewer wrote
8
+ // it here. `problem-meta-framework.test.ts` holds every row to the rules: a framework code, a 4xx,
9
+ // a key an app's own declaration could carry, and no secret-shaped name.
10
+
11
+ /**
12
+ * Code → the `meta` keys its document carries. A `Map`, read by own key (`APP_ERROR_STATUS`'s
13
+ * reason). A new row names the key's producer, so a rename there fails that package's test.
14
+ */
15
+ export const FRAMEWORK_PROBLEM_META: ReadonlyMap<string, readonly string[]> = new Map([
16
+ // `@ultimat3/action`'s `MeterExceededError`: which allowance, its size, and when it resets — so a
17
+ // client can say "100 drafts a day, again at midnight UTC" without parsing `cause`. NOT
18
+ // `retryAfterSeconds` (already the `Retry-After` header) and not the counter's subject, which
19
+ // names a tenant (`meter-problem.test.ts` pins the producer's keys).
20
+ ['X_METER_EXCEEDED', Object.freeze(['meter', 'limit', 'resetsAt'])],
21
+ ]);
@@ -15,9 +15,10 @@
15
15
  // The public-cause predicate and both of its tables are `@ultimat3/core`'s (`public-cause.ts`): the
16
16
  // MCP error data and the agent `tool_result` ask the question this package's problem document
17
17
  // does. This file only WRITES the app half — a reader imports `hasPublicCause` from core.
18
- import { registerPublicCause, resetPublicCauses } from '@ultimat3/core';
18
+ import { registerPublicCause, renderCauseValue, resetPublicCauses } from '@ultimat3/core';
19
19
  import { ERROR_STATUS } from './error-map';
20
20
  import { problemMetaInvalid } from './errors';
21
+ import { FRAMEWORK_PROBLEM_META } from './problem-meta-framework';
21
22
 
22
23
  /** What a problem document's `meta` member holds — JSON, and nothing JSON cannot carry. */
23
24
  export type ProblemMetaValue =
@@ -48,6 +49,18 @@ export const MAX_PROBLEM_META_BYTES = 4096;
48
49
  const KEY = /^[A-Za-z_$][\w$]*$/;
49
50
  const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
50
51
 
52
+ /**
53
+ * Why `key` may not ride a document, or `undefined` when it may. The one rule for both halves: an
54
+ * app's `registerProblemMeta` throws on it, and the framework's table is held to it by a test.
55
+ */
56
+ export function metaKeyRefusal(key: unknown): string | undefined {
57
+ if (typeof key !== 'string' || !KEY.test(key))
58
+ return `${renderCauseValue(key)} is not a meta key`;
59
+ if (key === 'issues') return '`issues` already rides at the top level of the document';
60
+ if (RESERVED_KEYS.has(key)) return `\`${key}\` is not a key a document may carry`;
61
+ return undefined;
62
+ }
63
+
51
64
  /** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
52
65
  const DECLARED = new Map<string, readonly string[]>();
53
66
 
@@ -73,7 +86,8 @@ const sameKeys = (left: readonly string[], right: readonly string[]): boolean =>
73
86
  *
74
87
  * Framework-owned codes are refused, exactly as `registerErrorStatus` refuses them: their `meta`
75
88
  * is where the framework keeps what a caller must not be handed, and an app that could declare
76
- * `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429.
89
+ * `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429. The framework's
90
+ * own public keys are `problem-meta-framework.ts`'s one reviewed table, never an app's call.
77
91
  */
78
92
  export const registerProblemMeta = (
79
93
  declarations: Readonly<Record<string, ProblemMetaDeclaration>>,
@@ -95,17 +109,8 @@ export const registerProblemMeta = (
95
109
  throw problemMetaInvalid(code, 'the key list is empty — omit the code instead');
96
110
  }
97
111
  for (const key of keys) {
98
- if (typeof key !== 'string' || !KEY.test(key)) {
99
- throw problemMetaInvalid(code, `${JSON.stringify(key)} is not a meta key`);
100
- }
101
- if (RESERVED_KEYS.has(key)) {
102
- throw problemMetaInvalid(
103
- code,
104
- key === 'issues'
105
- ? '`issues` already rides at the top level of the document'
106
- : `\`${key}\` is not a key a document may carry`,
107
- );
108
- }
112
+ const refusal = metaKeyRefusal(key);
113
+ if (refusal !== undefined) throw problemMetaInvalid(code, refusal);
109
114
  }
110
115
  const existing = DECLARED.get(code);
111
116
  if (existing !== undefined && !sameKeys(existing, keys)) {
@@ -122,9 +127,12 @@ export const resetProblemMeta = (): void => {
122
127
  resetPublicCauses();
123
128
  };
124
129
 
125
- /** The keys declared for a code, or `undefined` when nothing was — which is every framework code. */
130
+ /**
131
+ * The keys declared for a code, or `undefined` when nothing was: the framework's reviewed rows
132
+ * (`problem-meta-framework.ts`) first, then the app's. Every other framework code answers nothing.
133
+ */
126
134
  export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
127
- DECLARED.get(code);
135
+ FRAMEWORK_PROBLEM_META.get(code) ?? DECLARED.get(code);
128
136
 
129
137
  /**
130
138
  * A code this package's table maps is the framework's. A `Set` of the table's own keys, for the
@@ -22,8 +22,16 @@ import { isUltimateError, logger } from '@ultimat3/core';
22
22
  import type { RateLimitStore } from './rate-limit';
23
23
  import { memoryRateLimitStore } from './rate-limit';
24
24
 
25
- /** Codes `@ultimat3/db` raises when no connection could run the statement at all. */
26
- const UNREACHABLE_CODES: ReadonlySet<string> = new Set(['X_DB_UNAVAILABLE', 'X_DB_POOL_EXHAUSTED']);
25
+ /**
26
+ * Codes `@ultimat3/db` raises when no connection could run the statement at all, and the store's
27
+ * own deadline (`rate-limit-store-deadline.ts`): a database that has not answered in time is one
28
+ * this decision cannot wait for.
29
+ */
30
+ const UNREACHABLE_CODES: ReadonlySet<string> = new Set([
31
+ 'X_DB_UNAVAILABLE',
32
+ 'X_DB_POOL_EXHAUSTED',
33
+ 'X_RATE_LIMIT_STORE_TIMEOUT',
34
+ ]);
27
35
 
28
36
  /**
29
37
  * SQLSTATEs that mean "this connection, right now", carried on `meta.sqlState` of an
@@ -3,12 +3,13 @@
3
3
  // is a declaration nothing can satisfy while `docker/helm/values.yaml` runs `replicas: 3`.
4
4
  // Statements are spelled out so an agent can run the exact one it saw in a log.
5
5
 
6
- import type { PgExecutor } from '@ultimat3/core';
6
+ import type { PgExecutor, Scheduler } from '@ultimat3/core';
7
7
  import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
8
8
  import { rateLimitDecision } from './rate-limit';
9
9
  import { degradedFallback, isRateLimitStoreUnreachable } from './rate-limit-degrade';
10
10
  import { rateLimitStoreUnavailable } from './rate-limit-errors';
11
11
  import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
12
+ import { storeDeadline } from './rate-limit-store-deadline';
12
13
 
13
14
  /**
14
15
  * Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows,
@@ -124,6 +125,14 @@ interface TakeRow {
124
125
 
125
126
  export interface PostgresRateLimitStoreOptions {
126
127
  readonly executor: PgExecutor;
128
+ /**
129
+ * How long one `take` or `peek` may wait for the database before this decision degrades to the
130
+ * pod's memory. Default `RATE_LIMIT_STORE_DEADLINE_MS` (250), measured in
131
+ * `rate-limit-store-deadline.ts`. The pool's own bound is the app's work's; this one is the limiter's.
132
+ */
133
+ readonly deadlineMs?: number | undefined;
134
+ /** Injection seam for that deadline; production passes none. */
135
+ readonly schedule?: Scheduler | undefined;
127
136
  }
128
137
 
129
138
  export interface PostgresRateLimitStore extends RateLimitStore {
@@ -165,6 +174,7 @@ export function postgresRateLimitStore(
165
174
  options: PostgresRateLimitStoreOptions,
166
175
  ): PostgresRateLimitStore {
167
176
  const exec = options.executor;
177
+ const deadline = storeDeadline(options.deadlineMs, options.schedule);
168
178
  // Where a decision is taken while the database is unreachable (`rate-limit-degrade.ts`). Still
169
179
  // `scope: 'shared'` below: the store IS the fleet's, and the degraded window is logged, bounded
170
180
  // by the outage, and a weaker limit rather than none.
@@ -181,13 +191,16 @@ export function postgresRateLimitStore(
181
191
  async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> {
182
192
  let rows: readonly TakeRow[];
183
193
  try {
184
- rows = await exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [
185
- key,
186
- bucket.capacity,
187
- bucket.refillPerSecond,
188
- cost,
189
- Math.floor(nowMs),
190
- ]);
194
+ rows = await deadline.bound(
195
+ 'take',
196
+ exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [
197
+ key,
198
+ bucket.capacity,
199
+ bucket.refillPerSecond,
200
+ cost,
201
+ Math.floor(nowMs),
202
+ ]),
203
+ );
191
204
  } catch (error) {
192
205
  return degrade(error, nowMs, () => fallback.store.take(key, bucket, cost, nowMs));
193
206
  }
@@ -202,7 +215,7 @@ export function postgresRateLimitStore(
202
215
  async peek(key, bucket, nowMs) {
203
216
  let rows: readonly PeekRow[];
204
217
  try {
205
- rows = await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]);
218
+ rows = await deadline.bound('peek', exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]));
206
219
  } catch (error) {
207
220
  return degrade(error, nowMs, () => fallback.store.peek(key, bucket, nowMs));
208
221
  }
@@ -0,0 +1,60 @@
1
+ // The shared rate-limit store's own deadline on one round trip. The pool's unanswered-statement
2
+ // bound (statement_timeout + 5 s) is sized for the app's work; a limiter that waits it out spends
3
+ // a request's whole budget deciding whether to admit it, then the request's own statement waits a
4
+ // second bound. Past this deadline the store degrades (`rate-limit-degrade.ts`) instead.
5
+
6
+ import { assert, finiteCount, MAX_TIMER_MS, type Scheduler } from '@ultimat3/core';
7
+ import { HttpError } from './errors';
8
+
9
+ /**
10
+ * Measured 2026-11-06, Bun 1.3, Postgres 17 on loopback, `web` pool (max 20), three runs each: a
11
+ * cold first statement (connect + SCRAM) 15–31 ms; a warm take p99 0.4 ms; 100 concurrent takes on
12
+ * ONE hot key (row-lock queue) max 11.3 ms. 250 ms is ~8x the worst cold connect and ~20x the
13
+ * worst contended take, and costs a blackholed request a quarter second instead of ~16 s.
14
+ */
15
+ export const RATE_LIMIT_STORE_DEADLINE_MS = 250;
16
+
17
+ /** Unreffed: a deadline guards work in flight and must never keep a finished process alive. */
18
+ const unrefSchedule: Scheduler = (fn, ms) => {
19
+ const timer = setTimeout(fn, ms);
20
+ timer.unref?.();
21
+ return () => clearTimeout(timer);
22
+ };
23
+
24
+ export interface StoreDeadline {
25
+ /** `sending`'s answer, or `X_RATE_LIMIT_STORE_TIMEOUT` once `deadlineMs` passes first. */
26
+ bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T>;
27
+ }
28
+
29
+ export const rateLimitStoreTimeout = (statement: string, deadlineMs: number): HttpError =>
30
+ new HttpError({
31
+ code: 'X_RATE_LIMIT_STORE_TIMEOUT',
32
+ cause: `the shared rate-limit store's ${statement} statement did not answer within ${String(deadlineMs)} ms, so this decision was taken from this pod's memory`,
33
+ fix: 'x doctor --json # names the database binding this pod cannot reach; a database that is slow rather than gone takes postgresRateLimitStore({ executor, deadlineMs }) raised in the boot that builds it',
34
+ meta: { statement, deadlineMs },
35
+ });
36
+
37
+ /**
38
+ * The abandoned statement is not cancelled — the executor has no seam for it — and the pool's own
39
+ * sweep still retires it at its bound; `Promise.race` keeps its late rejection handled meanwhile.
40
+ */
41
+ export function storeDeadline(
42
+ deadlineMs: number = RATE_LIMIT_STORE_DEADLINE_MS,
43
+ schedule: Scheduler = unrefSchedule,
44
+ ): StoreDeadline {
45
+ finiteCount('postgresRateLimitStore', 'deadlineMs', deadlineMs, 1);
46
+ assert(
47
+ deadlineMs <= MAX_TIMER_MS,
48
+ `postgresRateLimitStore deadlineMs is ${String(deadlineMs)}, past the longest delay a timer holds, so it would fire at once`,
49
+ `postgresRateLimitStore({ executor, deadlineMs: ${String(RATE_LIMIT_STORE_DEADLINE_MS)} }) # or any whole number of ms up to ${String(MAX_TIMER_MS)}`,
50
+ );
51
+ return {
52
+ bound<T>(statement: 'take' | 'peek', sending: Promise<T>): Promise<T> {
53
+ let cancel: () => void = () => undefined;
54
+ const late = new Promise<never>((_, reject) => {
55
+ cancel = schedule(() => reject(rateLimitStoreTimeout(statement, deadlineMs)), deadlineMs);
56
+ });
57
+ return Promise.race([sending, late]).finally(() => cancel());
58
+ },
59
+ };
60
+ }
package/src/response.ts CHANGED
@@ -14,22 +14,46 @@ const withDefaults = (init: HeaderSource, defaults: Record<string, string>): Hea
14
14
  return headers;
15
15
  };
16
16
 
17
- export const jsonResponse = <T>(body: T, init?: ResponseInit): Response =>
18
- new Response(JSON.stringify(body), {
17
+ /** RFC 9110: these carry no content, so a `content-length` on one is a false statement. */
18
+ const CONTENTLESS_STATUS: ReadonlySet<number> = new Set([204, 205, 304]);
19
+
20
+ /**
21
+ * The length is the BYTES this body encodes to, declared because `new Response(string)` declares
22
+ * nothing and the `compress` stage never reads what it cannot bound. `stream` has none to declare.
23
+ * `string | undefined`: `JSON.stringify` answers `undefined` for a value it cannot render.
24
+ */
25
+ const declaredLength = (body: string | undefined, init?: ResponseInit): Record<string, string> =>
26
+ body === undefined || (init?.status !== undefined && CONTENTLESS_STATUS.has(init.status))
27
+ ? {}
28
+ : { 'content-length': String(Buffer.byteLength(body)) };
29
+
30
+ export const jsonResponse = <T>(body: T, init?: ResponseInit): Response => {
31
+ const text: string | undefined = JSON.stringify(body);
32
+ return new Response(text, {
19
33
  ...init,
20
- headers: withDefaults(init, { 'content-type': 'application/json; charset=utf-8' }),
34
+ headers: withDefaults(init, {
35
+ 'content-type': 'application/json; charset=utf-8',
36
+ ...declaredLength(text, init),
37
+ }),
21
38
  });
39
+ };
22
40
 
23
41
  export const textResponse = (body: string, init?: ResponseInit): Response =>
24
42
  new Response(body, {
25
43
  ...init,
26
- headers: withDefaults(init, { 'content-type': 'text/plain; charset=utf-8' }),
44
+ headers: withDefaults(init, {
45
+ 'content-type': 'text/plain; charset=utf-8',
46
+ ...declaredLength(body, init),
47
+ }),
27
48
  });
28
49
 
29
50
  export const html = (markup: string, init?: ResponseInit): Response =>
30
51
  new Response(markup, {
31
52
  ...init,
32
- headers: withDefaults(init, { 'content-type': 'text/html; charset=utf-8' }),
53
+ headers: withDefaults(init, {
54
+ 'content-type': 'text/html; charset=utf-8',
55
+ ...declaredLength(markup, init),
56
+ }),
33
57
  });
34
58
 
35
59
  /**
package/src/router.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // (`X_ROUTE_CONFLICT`) rather than a coin flip.
13
13
  import type { Actor, RenderMode } from '@ultimat3/core';
14
14
  import type { RequestContext } from './context';
15
+ import { assertRouteCsrf } from './csrf';
15
16
  import { routeConflict } from './errors';
16
17
  import type { Bucket } from './rate-limit';
17
18
  import type { UltimateRequest } from './request';
@@ -120,6 +121,13 @@ export interface RouteMeta {
120
121
  request: UltimateRequest,
121
122
  ctx: RequestContext,
122
123
  ) => Promise<Actor | null> | Actor | null;
124
+ /**
125
+ * `'cookieless'`: the `csrf` stage does not judge this route while its caller is anonymous —
126
+ * an RFC 6749 token, revocation or registration endpoint, client-authenticated or PKCE-bound,
127
+ * that a cross-origin client is MEANT to POST to. Only with `authenticate` declared
128
+ * (`X_CONFIG_INVALID` from `httpRouter`), so the app's cookie authenticator never runs here.
129
+ */
130
+ readonly csrf?: 'cookieless';
123
131
  }
124
132
 
125
133
  export type RouteHandler = (
@@ -172,6 +180,7 @@ export const httpRouter = (routes: readonly Route[]): RouteTable => {
172
180
  // not on the response path — `cacheControl` is total there by design, so the alternative is a
173
181
  // log line once per request and a response that quietly falls back to heuristic caching.
174
182
  assertRouteCache(route.meta.cache, route.meta.name, route.path);
183
+ assertRouteCsrf(route.meta, route.path);
175
184
  let current = root;
176
185
  const segments = pathSegments(route.path);
177
186
  for (const [index, segment] of segments.entries()) {
@@ -0,0 +1,35 @@
1
+ // What `httpServer` serves for its `websocket:` option — one mount or a list — as the ONE shape its
2
+ // listener dispatches: every owned path, one fetch, one handler set. A lone mount is handed through
3
+ // unwrapped (no socket of the sync node's pays for a join it does not need); two or more are joined
4
+ // by `joinWebSocketMounts`, which routes each upgrade, and each socket's frames, to its own mount.
5
+
6
+ import type { WebSocketMount } from './server';
7
+ import { type JoinedWebSocketMounts, joinWebSocketMounts } from './websocket-mounts';
8
+
9
+ /** `ServerOptions.websocket`: the sync node's socket, a device link, or both on one listener. */
10
+ export type WebSocketOption = WebSocketMount | readonly WebSocketMount[];
11
+
12
+ const isList = (option: WebSocketOption): option is readonly WebSocketMount[] =>
13
+ Array.isArray(option);
14
+
15
+ /**
16
+ * The served mounts, or `undefined` when there are none — then the listener opens no websocket at
17
+ * all, as it did before the option existed. `assertFree` is asked about EVERY path before anything
18
+ * is joined: a static route on the second mount's path shadows it exactly as it would the first.
19
+ */
20
+ export function serverWebSockets(
21
+ option: WebSocketOption | undefined,
22
+ assertFree: (path: string) => void,
23
+ ): JoinedWebSocketMounts | undefined {
24
+ const mounts = option === undefined ? [] : isList(option) ? option : [option];
25
+ for (const mount of mounts) assertFree(mount.path);
26
+ const [only] = mounts;
27
+ if (only === undefined) return undefined;
28
+ if (mounts.length > 1) return joinWebSocketMounts(mounts);
29
+ return {
30
+ paths: new Set([only.path]),
31
+ fetch: (request, server) => only.fetch(request, server),
32
+ // `never` is the mount's own socket type: the socket Bun hands back is the one its upgrade made.
33
+ websocket: only.websocket as JoinedWebSocketMounts['websocket'],
34
+ };
35
+ }
package/src/server.ts CHANGED
@@ -31,6 +31,7 @@ import { withRouteBuckets } from './rate-limit-buckets';
31
31
  import { adoptRateLimitStore } from './rate-limit-installed';
32
32
  import { jsonResponse } from './response';
33
33
  import { describeRoutes, httpRouter, type Route } from './router';
34
+ import { serverWebSockets, type WebSocketOption } from './server-websocket';
34
35
 
35
36
  /**
36
37
  * Beside its one caller rather than in `errors.ts`, which is at the 500-line ceiling — the
@@ -86,8 +87,12 @@ export interface ServerOptions {
86
87
  * So a host may mount the socket on the port it already publishes, and the two-port topology
87
88
  * stays exactly as it was: `x dev` does BOTH, `docker/` keeps its own `sync` service, and a
88
89
  * client picks whichever origin it can actually reach.
90
+ *
91
+ * A LIST serves several on this one listener — the sync node's beside every declared
92
+ * `deviceSocket()` — each path checked free, each upgrade and its frames routed to its own mount
93
+ * (`server-websocket.ts`). An empty list is no websocket.
89
94
  */
90
- readonly websocket?: WebSocketMount;
95
+ readonly websocket?: WebSocketOption;
91
96
  /**
92
97
  * `app.config.ts`'s `drain` section — pass `appConfig.drain`. Its `readinessGraceMs` is how long
93
98
  * `/readyz` answers 503 before the `accept` phase closes this listener. Omitted, core's own
@@ -208,7 +213,6 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
208
213
  const readinessGraceMs = options.drain?.readinessGraceMs;
209
214
  if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
210
215
 
211
- const mount = options.websocket;
212
216
  let server: BunServer | undefined;
213
217
  let unregister: (() => void) | undefined;
214
218
  let unregisterBodies: (() => void) | undefined;
@@ -304,7 +308,7 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
304
308
  throw websocketPathTaken(path, `the route \`${description.name}\``);
305
309
  }
306
310
  };
307
- if (mount !== undefined) assertMountPathFree(mount.path);
311
+ const mount = serverWebSockets(options.websocket, assertMountPathFree);
308
312
 
309
313
  const handle: ServerHandle = {
310
314
  role,
@@ -360,7 +364,7 @@ const buildServer = (options: ServerOptions, releaseStore: () => void): ServerHa
360
364
  : Bun.serve({
361
365
  ...listen,
362
366
  fetch: async (request, socket) =>
363
- pathOf(request) === mount.path
367
+ mount.paths.has(pathOf(request))
364
368
  ? await mount.fetch(request, socket as unknown as UpgradeTarget)
365
369
  : await dispatch(request, socket),
366
370
  websocket: mount.websocket,
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
 
@@ -470,6 +475,14 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
470
475
  response.headers.set('server-timing', `total;dur=${elapsedMs(ctx)}`);
471
476
  return undefined;
472
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
+ }),
473
486
  };
474
487
  return table;
475
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
+ }