@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
@@ -0,0 +1,134 @@
1
+ // Single responsibility: the one correct answer to a GET for an object held elsewhere — its stored
2
+ // type, its validator, one `Range:` window, the sandbox for a type a browser would run. `/_storage`,
3
+ // `/media`, a download and an app's own route for an object a stranger may fetch all answer here.
4
+ // Structural, because this package imports no storage: anything with these fields is servable.
5
+
6
+ import {
7
+ contentRangeOf,
8
+ parseByteRange,
9
+ RANGE_UNSATISFIABLE,
10
+ type RequestedRange,
11
+ } from './byte-range';
12
+ import type { CacheHint } from './response';
13
+ import { applyCacheHeaders } from './response';
14
+ import { storedObjectHeaders } from './stored-object-headers';
15
+
16
+ /** What the answer needs to know before it opens anything. `StorageObject` is one of these. */
17
+ export interface ServableObject {
18
+ /** The STORED type. An extension in the URL is the only party that can lie. */
19
+ readonly contentType: string;
20
+ readonly size: number;
21
+ /** The object's own validator. Absent or `''` (unknowable): no `etag` header, and no 304. */
22
+ readonly etag?: string | undefined;
23
+ /** Omitted, never invented: a made-up date is a validator a cache would go on to trust. */
24
+ readonly lastModified?: Date | undefined;
25
+ }
26
+
27
+ export interface ObjectSource {
28
+ readonly object: ServableObject;
29
+ /**
30
+ * Exactly those bytes, or the whole object with no range. Opened only once a body will be sent: a
31
+ * 304 and a 416 open nothing. Takes the range so a disk can serve a window, not drop a prefix.
32
+ */
33
+ readonly open: (range?: RequestedRange) => BodyInit | Promise<BodyInit>;
34
+ }
35
+
36
+ /** The one thing asked of the request, so a test or a non-pipeline caller needs no `Request`. */
37
+ export interface ObjectRequest {
38
+ header(name: string): string | null;
39
+ }
40
+
41
+ export interface ObjectResponseOptions {
42
+ /** Applied to every answer. Omitted, the route's declared `meta.cache` decides as for any route. */
43
+ readonly cache?: CacheHint | undefined;
44
+ /** Replaces the stored-object sandbox rule, for a caller with its own, as a download has. */
45
+ readonly headers?: Readonly<Record<string, string>> | undefined;
46
+ }
47
+
48
+ /** `*`, a weak validator and a comma list all count — the etag is what has to match. */
49
+ export function etagMatches(header: string | null, etag: string): boolean {
50
+ if (header === null) return false;
51
+ if (header.trim() === '*') return true;
52
+ return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
53
+ }
54
+
55
+ /**
56
+ * The 304 for a client already holding `etag`, or nothing for the caller to go on and answer. It
57
+ * repeats the validator and whatever posture `headers` carry (RFC 9110 §15.4.5).
58
+ */
59
+ export function notModified(
60
+ request: ObjectRequest,
61
+ etag: string,
62
+ headers?: HeadersInit,
63
+ ): Response | undefined {
64
+ if (!etagMatches(request.header('if-none-match'), etag.replace(/^W\//, ''))) return undefined;
65
+ const answer = new Headers(headers);
66
+ answer.set('etag', etag);
67
+ return new Response(null, { status: 304, headers: answer });
68
+ }
69
+
70
+ /**
71
+ * Whether `if-range` still names these bytes. Compared strongly, and a date only if it is exactly
72
+ * the object's: a window of an older revision spliced onto this one is two files in one download.
73
+ */
74
+ function ifRangeHolds(header: string | null, etag: string | undefined, modified?: Date): boolean {
75
+ if (header === null) return true;
76
+ const validator = header.trim();
77
+ if (validator.startsWith('W/')) return false;
78
+ if (validator.startsWith('"')) return etag === validator;
79
+ return modified?.toUTCString() === validator;
80
+ }
81
+
82
+ /** A provider's etag may arrive already quoted, and quoting it again answers an invalid header. */
83
+ const quotedEtag = (etag: string): string =>
84
+ etag.startsWith('"') || etag.startsWith('W/"') ? etag : `"${etag}"`;
85
+
86
+ /**
87
+ * Revalidation costs a request and no bytes, which is the trade an authorized response wants: the
88
+ * bytes do not change under a validator, but the actor's permission to read them can be revoked.
89
+ * The size is the object's description, so the length declared is the length `open` must deliver.
90
+ */
91
+ export async function objectResponse(
92
+ request: ObjectRequest,
93
+ source: ObjectSource,
94
+ options: ObjectResponseOptions = {},
95
+ ): Promise<Response> {
96
+ const { object } = source;
97
+ const headers = new Headers({
98
+ 'content-type': object.contentType,
99
+ 'accept-ranges': 'bytes',
100
+ // A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
101
+ ...(options.headers ?? storedObjectHeaders(object.contentType)),
102
+ });
103
+ if (object.lastModified !== undefined) {
104
+ headers.set('last-modified', object.lastModified.toUTCString());
105
+ }
106
+ const answer = (body: BodyInit | null, status: number): Response => {
107
+ const response = new Response(body, { status, headers });
108
+ return options.cache === undefined ? response : applyCacheHeaders(response, options.cache);
109
+ };
110
+
111
+ const etag =
112
+ object.etag === undefined || object.etag === '' ? undefined : quotedEtag(object.etag);
113
+ if (etag !== undefined) {
114
+ headers.set('etag', etag);
115
+ if (notModified(request, etag) !== undefined) return answer(null, 304);
116
+ }
117
+
118
+ const { size } = object;
119
+ // A range of another revision is no range at all: the whole object, never a 416 either.
120
+ const range = ifRangeHolds(request.header('if-range'), etag, object.lastModified)
121
+ ? parseByteRange(request.header('range'), size)
122
+ : undefined;
123
+ if (range === RANGE_UNSATISFIABLE) {
124
+ headers.set('content-range', `bytes */${String(size)}`);
125
+ return answer(null, 416);
126
+ }
127
+ if (range === undefined) {
128
+ headers.set('content-length', String(size));
129
+ return answer(await source.open(), 200);
130
+ }
131
+ headers.set('content-range', contentRangeOf(range, size));
132
+ headers.set('content-length', String(range.end - range.start + 1));
133
+ return answer(await source.open(range), 206);
134
+ }
package/src/overlay.ts CHANGED
@@ -75,7 +75,16 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
75
75
  dev: true,
76
76
  });
77
77
  const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim();
78
- return `<style>${OVERLAY_STYLE}</style>
78
+ // A whole document, `lang` and `<title>` included: an e2e test's accessibility audit reads the
79
+ // overlay like any page, and a fragment has neither.
80
+ return `<!doctype html>
81
+ <html lang="en">
82
+ <head>
83
+ <meta charset="utf-8">
84
+ <title>${escapeHtml(`${facts.code} ${facts.title}`)}</title>
85
+ <style>${OVERLAY_STYLE}</style>
86
+ </head>
87
+ <body>
79
88
  <main>
80
89
  <section class="card">
81
90
  <h1>${escapeHtml(facts.code)} <span class="title">${escapeHtml(facts.title)}</span></h1>
@@ -105,7 +114,9 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
105
114
  <h2>json</h2>
106
115
  <pre>${escapeHtml(JSON.stringify(problem, null, 2))}</pre>
107
116
  </section>
108
- </main>`;
117
+ </main>
118
+ </body>
119
+ </html>`;
109
120
  };
110
121
 
111
122
  export const overlayResponse = (error: unknown, meta: OverlayMeta = {}): Response =>
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,