@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
@@ -59,4 +59,30 @@ export const TIER_0_ERROR_STATUS = {
59
59
  X_INTERNAL: 500,
60
60
  // @ultimat3/core — a Set-Cookie value would be dropped, misread or injectable
61
61
  X_COOKIE_INVALID: 500,
62
+ // @ultimat3/core — an outbound HTTP call passed its deadline or went idle
63
+ X_OUTBOUND_TIMEOUT: 504,
64
+ // @ultimat3/core — an outbound HTTP call got no response
65
+ X_OUTBOUND_UNREACHABLE: 503,
66
+ // @ultimat3/core — an outbound HTTP call was told to wait
67
+ X_OUTBOUND_THROTTLED: 503,
68
+ // @ultimat3/core — an outbound HTTP call answered a retryable status
69
+ X_OUTBOUND_UNAVAILABLE: 502,
70
+ // @ultimat3/core — an outbound HTTP call answered a non-retryable status
71
+ X_OUTBOUND_REFUSED: 502,
72
+ // @ultimat3/core — an outbound HTTP response body passed its byte cap
73
+ X_OUTBOUND_TOO_LARGE: 502,
74
+ // @ultimat3/core — an outbound HTTP call refused the address it would reach
75
+ X_OUTBOUND_HOST_BLOCKED: 400,
76
+ // @ultimat3/core — an outbound HTTP call answered a redirect
77
+ X_OUTBOUND_REDIRECTED: 502,
78
+ // @ultimat3/core — an outbound HTTP response did not match its schema
79
+ X_OUTBOUND_BODY_INVALID: 502,
80
+ // @ultimat3/core — a circuit breaker is open and refused the call
81
+ X_BREAKER_OPEN: 503,
82
+ // @ultimat3/core — a path parameter and the request body or query name one key with two values
83
+ X_HTTP_PATH_INPUT_CONFLICT: 400,
84
+ // @ultimat3/core — a handler threw a 4xx code its errors: list does not declare. Reachable: `x dev`
85
+ // raises it inside a request. The handler broke its own contract, so it is a 500 and never the
86
+ // 4xx it replaced; production answers the original and this row is not read.
87
+ X_ERROR_UNDECLARED: 500,
62
88
  } satisfies Readonly<Record<string, number>>;
@@ -84,4 +84,26 @@ export const TIER_1_ERROR_STATUS = {
84
84
  X_SITEMAP_ENTRY_INVALID: 500,
85
85
  // @ultimat3/storage — the requested byte range starts past the end of the object
86
86
  X_STORAGE_RANGE_UNSATISFIABLE: 416,
87
+ // @ultimat3/db — statement on a finished transaction
88
+ X_DB_TRANSACTION_CLOSED: 500,
89
+ // @ultimat3/storage — the object changed while it was being promoted
90
+ X_STORAGE_OBJECT_CHANGED: 409,
91
+ // @ultimat3/db — the connection pooler refused the libpq options a pool connects with
92
+ X_DB_POOLER_OPTIONS_REFUSED: 503,
93
+ // @ultimat3/storage — an upload grant was asked for without the size of the file
94
+ X_STORAGE_UPLOAD_SIZE_UNDECLARED: 422,
95
+ // @ultimat3/storage — a write was sent and its answer lost, and the disk could not say whether it landed
96
+ X_STORAGE_WRITE_OUTCOME_UNKNOWN: 500,
97
+ // @ultimat3/db — a keyset page holds a null in one of its sort keys
98
+ X_KEYSET_NULL_KEY: 500,
99
+ // @ultimat3/storage — this disk cannot take an upload in parts
100
+ X_STORAGE_MULTIPART_UNSUPPORTED: 501,
101
+ // @ultimat3/storage — no open multipart upload has that id
102
+ X_STORAGE_UPLOAD_UNKNOWN: 404,
103
+ // @ultimat3/storage — the parts named do not match the parts stored
104
+ X_STORAGE_PARTS_INVALID: 422,
105
+ // @ultimat3/money — a typed amount could not be read in this locale
106
+ X_MONEY_INPUT_INVALID: 422,
107
+ // @ultimat3/seo — a route meta og declares both image and capture
108
+ X_SEO_OG_IMAGE_CONFLICT: 500,
87
109
  } satisfies Readonly<Record<string, number>>;
@@ -119,4 +119,20 @@ export const TIER_2_ERROR_STATUS = {
119
119
  X_ENTITY_APPEND_ONLY: 409,
120
120
  // @ultimat3/entity — a predicate names an operator outside the vocabulary
121
121
  X_ENTITY_OPERATOR_UNKNOWN: 500,
122
+ // @ultimat3/auth — a consent was approved for a request the authorization server refuses
123
+ X_AUTH_CONSENT_INVALID: 422,
124
+ // @ultimat3/auth — a verification token was issued or consumed for a purpose nothing declared
125
+ X_VERIFICATION_PURPOSE_UNKNOWN: 400,
126
+ // @ultimat3/entity — a repository has no series
127
+ X_ENTITY_SERIES_UNSUPPORTED: 501,
128
+ // @ultimat3/entity — a time series asked for something no bucket can answer
129
+ X_ENTITY_SERIES_INVALID: 422,
130
+ // @ultimat3/entity — a view entity was asked to write a row
131
+ X_ENTITY_VIEW_READ_ONLY: 500,
132
+ // @ultimat3/auth — a passkey ceremony response failed its signature, origin, rp id, flag or counter check
133
+ X_PASSKEY_INVALID: 401,
134
+ // @ultimat3/auth — a passkey ceremony named a challenge that is unknown, already used or past its ttl
135
+ X_PASSKEY_CHALLENGE_EXPIRED: 401,
136
+ // @ultimat3/auth — a sensitive action ran without a fresh user-verified passkey proof for its digest
137
+ X_PASSKEY_STEP_UP_REQUIRED: 401,
122
138
  } satisfies Readonly<Record<string, number>>;
@@ -89,6 +89,10 @@ export const TIER_3_ERROR_STATUS = {
89
89
  X_IDEMPOTENCY_RESERVATION_LOST: 409,
90
90
  // @ultimat3/jobs — the queue holds no job with this id
91
91
  X_JOB_NOT_FOUND: 404,
92
+ // @ultimat3/jobs — an enqueue whose payload names an org the caller is not acting for. Raised in
93
+ // the handler that enqueued, so it reaches the socket; 403 like `X_TENANCY_ACTOR_MISMATCH`: the
94
+ // caller asked for work in another tenant. The worker's run-time refusal never answers a request.
95
+ X_JOB_TENANT_MISMATCH: 403,
92
96
  // @ultimat3/realtime — one principal holds too many sockets on this node
93
97
  X_SOCKET_LIMIT: 429,
94
98
  // @ultimat3/action — an idempotent replay would return a field redacted at rest
@@ -103,4 +107,24 @@ export const TIER_3_ERROR_STATUS = {
103
107
  X_QUERY_DOWNLOAD_TOKEN_INVALID: 403,
104
108
  // @ultimat3/query — a download URL is past its expiry
105
109
  X_QUERY_DOWNLOAD_TOKEN_EXPIRED: 410,
110
+ // @ultimat3/action — an idempotent action's answer cannot be stored as JSON, so no retry could replay it
111
+ X_IDEMPOTENCY_OUTPUT_UNSERIALIZABLE: 500,
112
+ // @ultimat3/action — a keyed run passed its hard ceiling
113
+ X_IDEMPOTENCY_RUN_CEILING: 504,
114
+ // @ultimat3/jobs — an inbound webhook could not be queued, so it was not acknowledged
115
+ X_WEBHOOK_RECEIVER_UNAVAILABLE: 503,
116
+ // @ultimat3/jobs — the event id a webhook receiver verified is not one it can key a job by
117
+ X_WEBHOOK_EVENT_ID_INVALID: 422,
118
+ // @ultimat3/query — a query's pinned http.path is not a valid path template
119
+ X_QUERY_HTTP_PATH_INVALID: 500,
120
+ // @ultimat3/realtime — no socket is open for the device a request addressed
121
+ X_DEVICE_OFFLINE: 503,
122
+ // @ultimat3/realtime — a device did not answer a request before its deadline
123
+ X_DEVICE_REQUEST_TIMEOUT: 504,
124
+ // @ultimat3/realtime — a device socket frame is over the framework frame limit
125
+ X_DEVICE_FRAME_TOO_LARGE: 413,
126
+ // @ultimat3/action — an action's usage meter is spent for this period
127
+ X_METER_EXCEEDED: 429,
128
+ // @ultimat3/action — an action's meter cannot be counted against
129
+ X_METER_INVALID: 500,
106
130
  } satisfies Readonly<Record<string, number>>;
@@ -60,7 +60,7 @@ export const TIER_4_ERROR_STATUS = {
60
60
  // unclassified failure, and `toProblem` blanks the cause of one of those outside dev
61
61
  // (`isUnclassifiedFailure`): a 34-row catalog over the cap took a page down with a problem
62
62
  // document that said "the details are in this process's logs" about an error whose whole
63
- // value is the sentence naming the prop and its bytes. Measured on ai-maxxing, 2026-09-05.
63
+ // value is the sentence naming the prop and its bytes. Measured on one adopter's app, 2026-09-05.
64
64
  X_ISLAND_PROPS_INVALID: 500,
65
65
  // A loader answered `withStatus` with a 3xx, or a status outside 200–599. Raised INSIDE the
66
66
  // request that ran the loader, so it needs a row for the same reason the line above does: an
@@ -148,4 +148,36 @@ export const TIER_4_ERROR_STATUS = {
148
148
  X_AGENT_TOOL_UNDECLARED: 500,
149
149
  // @ultimat3/ai — an agent's next request would not fit its model's context window
150
150
  X_AGENT_CONTEXT_EXHAUSTED: 500,
151
+ // @ultimat3/notify — another attempt holds this delivery
152
+ X_NOTIFY_DELIVERY_HELD: 409,
153
+ // @ultimat3/mcp — the request names an MCP protocol version this server does not serve
154
+ X_MCP_PROTOCOL_UNSUPPORTED: 400,
155
+ // @ultimat3/mcp — an MCP request header is absent or disagrees with the body
156
+ X_MCP_HEADER_MISMATCH: 400,
157
+ // @ultimat3/mcp — an MCP request came from a browser page on another origin
158
+ X_MCP_ORIGIN_REFUSED: 403,
159
+ // @ultimat3/mcp — an MCP request named a Host that is not this machine
160
+ X_MCP_HOST_REFUSED: 421,
161
+ // @ultimat3/ai — a provider refused a request as longer than the model context window
162
+ X_AI_CONTEXT_OVERFLOW: 500,
163
+ // @ultimat3/mcp — a remote MCP server failed or refused the call
164
+ X_MCP_REMOTE_REFUSED: 502,
165
+ // @ultimat3/mcp — a remote MCP server needs authorization this app does not hold
166
+ X_MCP_REMOTE_AUTH_REQUIRED: 502,
167
+ // @ultimat3/scraping — the PDF request names no single page size a print engine can lay out
168
+ X_SCRAPE_PDF_OPTIONS_INVALID: 422,
169
+ // @ultimat3/mcp — an MCP confirmation needs more approvers before the call runs
170
+ X_MCP_CONFIRMATION_QUORUM_PENDING: 409,
171
+ // @ultimat3/mcp — an MCP confirmation approval carries no valid signed evidence
172
+ X_MCP_CONFIRMATION_EVIDENCE_INVALID: 403,
173
+ // @ultimat3/mcp — mcpConfirmations approvers resolved a quorum no approver set can meet
174
+ X_MCP_CONFIRMATION_QUORUM_INVALID: 500,
175
+ // @ultimat3/mcp — a per-caller MCP catalog names one tool twice
176
+ X_MCP_CATALOG_TOOL_DUPLICATE: 500,
177
+ // @ultimat3/mcp — a per-caller MCP catalog could not be resolved
178
+ X_MCP_CATALOG_UNRESOLVED: 500,
179
+ // @ultimat3/mcp — an MCP call names no tenant
180
+ X_MCP_TENANT_REQUIRED: 400,
181
+ // @ultimat3/mcp — the tenant of an MCP call could not be resolved
182
+ X_MCP_TENANT_UNRESOLVED: 500,
151
183
  } satisfies Readonly<Record<string, number>>;
package/src/error-page.ts CHANGED
@@ -12,14 +12,14 @@ import { OVERLAY_STYLE } from './overlay-style';
12
12
  import { html } from './response';
13
13
 
14
14
  /**
15
- * Where "Built with Ultimate" goes. Two literals and not a derivation off `ERROR_DOCS_URL`: the
16
- * wiki page it points at is a different destination that merely shares a prefix today. They live
17
- * here, next to their one renderer — a second surface that wants them (a CLI banner, a scaffolded
18
- * footer) is what would move them to `@ultimat3/core`.
15
+ * Where "Built with Ultimate" goes. A literal and not a derivation off `ERROR_DOCS_URL`: the wiki
16
+ * page it points at is a different destination that merely shares a prefix today. It is the one
17
+ * outside link: this page reaches every app without a page of its own, so a vendor homepage here
18
+ * was an advert on a stranger's site (removed in 30.0.0). A second surface that wants it is what
19
+ * would move it to `@ultimat3/core`.
19
20
  */
20
- export const ERROR_PAGE_LINKS = Object.freeze<Record<'repository' | 'homepage', string>>({
21
+ export const ERROR_PAGE_LINKS = Object.freeze<Record<'repository', string>>({
21
22
  repository: 'https://github.com/developerz-ai/ultimate',
22
- homepage: 'https://www.developerz.ai',
23
23
  });
24
24
 
25
25
  /** One key group in the framework catalog's `errors.*` namespace, which is what this page renders. */
@@ -175,7 +175,6 @@ export function renderErrorPage(input: ErrorPageInput): string {
175
175
  </section>
176
176
  <footer class="card">
177
177
  ${link(ERROR_PAGE_LINKS.repository, t('errors.page.builtWith'))}
178
- ${link(ERROR_PAGE_LINKS.homepage, 'developerz.ai')}
179
178
  </footer>
180
179
  </main>
181
180
  </body>
@@ -1,6 +1,15 @@
1
1
  // Reading the status table: the framework's row for a code through `Object.hasOwn`, the app's
2
2
  // own codes beside it (`registerErrorStatus`), and the answer a response gets. The table itself is
3
- // `error-map.ts`; split out when the table outgrew the file that also read it.
3
+ // `error-map.ts`; the app's half lives in core (`error-status-declared.ts`), where `defineError`
4
+ // writes it too.
5
+ //
6
+ // The table's internal seam, never the barrel: an app's writers are `defineError` and this module's
7
+ // `registerErrorStatus`, and nothing else may write or read the app's half directly.
8
+ import {
9
+ appErrorStatus,
10
+ declareAppErrorStatus,
11
+ resetAppErrorStatus,
12
+ } from '@ultimat3/core/internal/error-status';
4
13
  import { ERROR_STATUS } from './error-map';
5
14
  import { errorStatusInvalid } from './errors';
6
15
 
@@ -21,19 +30,19 @@ const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
21
30
  const frameworkStatus = (code: string): number | undefined =>
22
31
  Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
23
32
 
24
- /**
25
- * Statuses for codes the APP owns. The table above is closed — it has to be, it is the
26
- * framework's own contract — and every code outside it fell to 500, so a wrong password was an
27
- * incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
28
- * whoever was on call. This is the app's half of the same table, kept separate so a registration
29
- * can never move `X_FORBIDDEN` off 403.
33
+ /*
34
+ * Statuses for codes the APP owns live in `@ultimat3/core`'s `error-status-declared.ts`. The table
35
+ * above is closed — it has to be, it is the framework's own contract — and every code outside it
36
+ * fell to 500, so a wrong password was an incident: `pipeline.ts` reports `status >= 500` to the
37
+ * error monitor, and a user's typo paged whoever was on call. The app's half is kept separate so a
38
+ * registration can never move `X_FORBIDDEN` off 403, and it sits in core because `defineError`,
39
+ * the one declaration of an app code, writes it from there.
30
40
  */
31
- const APP_ERROR_STATUS = new Map<string, number>();
32
41
 
33
42
  /**
34
- * Declare the status for the codes this app throws. Call it once at boot, beside the module
35
- * that declares the codes — importing that module IS the registration, the convention
36
- * `registerActions` and `registerErrorCodes` already use.
43
+ * Declare the status for codes this app throws but does not DECLARE — a code it re-raises from a
44
+ * library, say. A code the app owns is declared with core's `defineError`, which takes its status
45
+ * in the same call. Call this once at boot; importing the module that calls it IS the registration.
37
46
  *
38
47
  * ```ts
39
48
  * registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
@@ -53,16 +62,16 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
53
62
  if (framework !== undefined) {
54
63
  throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
55
64
  }
56
- const existing = APP_ERROR_STATUS.get(code);
65
+ const existing = appErrorStatus(code);
57
66
  if (existing !== undefined && existing !== status) {
58
67
  throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
59
68
  }
60
- APP_ERROR_STATUS.set(code, status);
69
+ declareAppErrorStatus(code, status);
61
70
  }
62
71
  };
63
72
 
64
73
  /** Test seam. Production registers once at boot and never unregisters. */
65
- export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
74
+ export const resetErrorStatus = (): void => resetAppErrorStatus();
66
75
 
67
76
  /**
68
77
  * The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
@@ -73,10 +82,10 @@ export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
73
82
  * Framework table first: `registerErrorStatus` already refuses those codes, so the order is
74
83
  * belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
75
84
  * even if a future caller reaches the map some other way.
76
- * `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
77
- * prefer one for anything keyed by a value a caller chose.
85
+ * The app's half is a `Map`, which is why it never had `frameworkStatus`'s defect — prefer one for
86
+ * anything keyed by a value a caller chose.
78
87
  */
79
88
  export const declaredStatusFor = (code: string): number | undefined =>
80
- frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
89
+ frameworkStatus(code) ?? appErrorStatus(code);
81
90
 
82
91
  export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
@@ -37,6 +37,7 @@ export const HTTP_OWNED_ERROR_CODES = [
37
37
  'X_WEBHOOK_SIGNATURE_INVALID',
38
38
  'X_WEBHOOK_SIGNATURE_STALE',
39
39
  'X_BEARER_MOUNT_INVALID',
40
+ 'X_RATE_LIMIT_STORE_TIMEOUT',
40
41
  ] as const;
41
42
 
42
43
  export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
@@ -71,6 +72,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
71
72
  X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
72
73
  X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
73
74
  X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
75
+ X_RATE_LIMIT_STORE_TIMEOUT: 'the shared rate-limit store did not answer inside its own deadline',
74
76
  };
75
77
 
76
78
  // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
package/src/errors.ts CHANGED
@@ -255,12 +255,11 @@ export const problemMetaInvalid = (code: string, reason: string): HttpError =>
255
255
  * "emit no CORS headers at all", which is unreadable from the console: every cross-origin call
256
256
  * fails and nothing on the server said anything.
257
257
  */
258
- export const corsConfigInvalid = (reason: string): HttpError =>
259
- new HttpError({
260
- code: 'X_CORS_CONFIG_INVALID',
261
- cause: `cors config rejected: ${reason}`,
262
- fix: "call configureHttp({ cors: { credentials: false } }) at module scope in a file under apps/*/, or replace origins: ['*'] with the exact origins allowed to call this app",
263
- });
258
+ export const corsConfigInvalid = (
259
+ reason: string,
260
+ fix = "call configureHttp({ cors: { credentials: false } }) at module scope in a file under apps/*/, or replace origins: ['*'] with the exact origins allowed to call this app",
261
+ ): HttpError =>
262
+ new HttpError({ code: 'X_CORS_CONFIG_INVALID', cause: `cors config rejected: ${reason}`, fix });
264
263
 
265
264
  /**
266
265
  * At `defineHttpConfig`. A directive name and a source both go into the header VERBATIM, and the
@@ -409,7 +408,7 @@ export const requestTimedOut = (method: string, pathname: string, timeoutMs: num
409
408
  new HttpError({
410
409
  code: 'X_TIMEOUT',
411
410
  cause: `${method} ${pathname} did not finish within ${timeoutMs}ms`,
412
- fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or call configureHttp({ requestTimeoutMs: 60_000 }) at module scope in a file under apps/*/',
411
+ fix: 'make every vendor call with outbound({ url, vendor, dial, deadlineMs }) from @ultimat3/core/outbound, which hears ctx.signal and the deadline, and call throwIfAborted(ctx) before expensive work, or call configureHttp({ requestTimeoutMs: 60_000 }) at module scope in a file under apps/*/. A write past the deadline is refused: wrap work that must land whole, runToCompletion(() => withTransaction(fn)), never a statement inside the transaction, whose COMMIT stays refused',
413
412
  meta: { timeoutMs },
414
413
  });
415
414
 
@@ -423,13 +422,23 @@ export const requestTimedOut = (method: string, pathname: string, timeoutMs: num
423
422
  * that arrived, never the secret, and never the body — because a `cause` reaches both the caller
424
423
  * and the log store, and a credential in either is a leak wearing a diagnostic's clothes.
425
424
  */
426
- export const webhookSignatureInvalid = (pathname: string, reason: string): HttpError =>
425
+ export const webhookSignatureInvalid = (
426
+ pathname: string,
427
+ reason: string,
428
+ via: WebhookVerifier = 'verifyWebhookSignature',
429
+ ): HttpError =>
427
430
  new HttpError({
428
431
  code: 'X_WEBHOOK_SIGNATURE_INVALID',
429
432
  cause: `${pathname} refused an inbound webhook: ${reason}`,
430
- fix: 'sign the delivery with the secret this endpoint was registered under, or re-read the secret from your sender dashboard and pass it as verifyWebhookSignature(request, { secret })',
433
+ fix:
434
+ via === 'verifyBodyHmac'
435
+ ? 'verifyBodyHmac({ bytes, secret, given, encoding, prefix }) — compare encoding and prefix with the vendor docs, sign over the exact bytes the vendor names, and re-read the secret from the vendor dashboard'
436
+ : 'sign the delivery with the secret this endpoint was registered under, or re-read the secret from your sender dashboard and pass it as verifyWebhookSignature(request, { secret })',
431
437
  });
432
438
 
439
+ /** The two inbound checks, named so a refusal's `fix:` is the call its caller made. */
440
+ export type WebhookVerifier = 'verifyWebhookSignature' | 'verifyBodyHmac';
441
+
433
442
  /**
434
443
  * Signed correctly, and outside the replay window. Its own code because the repair is a different
435
444
  * one: a sender's clock, or a delivery being replayed off a capture. Same 401 — the credential is
@@ -453,10 +462,16 @@ export const webhookSignatureStale = (
453
462
  * repair is the route's configuration and nothing a sender does can fix it. `shape` is `typeof`
454
463
  * or "an empty string" — never the value, which is a credential when it is one.
455
464
  */
456
- export const webhookSecretMissing = (shape: string): HttpError =>
465
+ export const webhookSecretMissing = (
466
+ shape: string,
467
+ via: WebhookVerifier = 'verifyWebhookSignature',
468
+ ): HttpError =>
457
469
  new HttpError({
458
470
  code: 'X_CONFIG_INVALID',
459
- cause: `verifyWebhookSignature was given ${shape} as its secret, and a mac under an empty key is one anyone can compute — every forged delivery would verify`,
460
- fix: "verifyWebhookSignature(request, { secret: process.env.WEBHOOK_SECRET ?? '' }) in the api/ route receiving it, with WEBHOOK_SECRET set in the deployment environment to the signing secret the sender shows for this endpoint — an unset variable is then refused here rather than verified against",
471
+ cause: `${via} was given ${shape} as its secret, and a mac under an empty key is one anyone can compute — every forged delivery would verify`,
472
+ fix:
473
+ via === 'verifyBodyHmac'
474
+ ? "verifyBodyHmac({ bytes, secret: process.env.VENDOR_WEBHOOK_SECRET ?? '', given, encoding }) in the receiver's verify, with VENDOR_WEBHOOK_SECRET set on the web role to the signing secret the vendor shows — an unset variable is then refused here rather than verified against"
475
+ : "verifyWebhookSignature(request, { secret: process.env.WEBHOOK_SECRET ?? '' }) in the receiver's verify, with WEBHOOK_SECRET set in the deployment environment to the signing secret the sender shows for this endpoint — an unset variable is then refused here rather than verified against",
461
476
  meta: { option: 'webhook secret' },
462
477
  });
package/src/finalize.ts CHANGED
@@ -31,6 +31,7 @@ const lastResort = (): Response =>
31
31
  code: 'X_INTERNAL',
32
32
  cause: 'the error renderer itself failed, so nothing of the original error survives here',
33
33
  fix: 'grep the logs for pipeline.problem_failed — it carries the renderer failure this reply could not',
34
+ retry: 'terminal',
34
35
  }),
35
36
  {
36
37
  status: 500,
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ export { bearerMount, bearerTokenOf, mountedPath } from './bearer-mount';
24
24
  /** One `Range:` reader for every route that answers bytes — `/_storage`, site assets, a download. */
25
25
  export type { RequestedRange } from './byte-range';
26
26
  export { byteWindow, contentRangeOf, parseByteRange, RANGE_UNSATISFIABLE } from './byte-range';
27
+ export { PRIVATE_REVALIDATE } from './cache-policy';
28
+ export { COMPRESS_MAX_BYTES, COMPRESS_MIN_BYTES, isCompressibleType } from './compress';
27
29
  export type { HttpConfig, HttpConfigInput } from './config';
28
30
  export { defineHttpConfig, MAX_PROXY_HOPS } from './config';
29
31
  export type { ActorView, RequestContext, RequestContextInit } from './context';
@@ -37,6 +39,7 @@ export {
37
39
  useRequestCookie,
38
40
  useRequestHeader,
39
41
  useRequestHeaders,
42
+ withAnonymousRequest,
40
43
  } from './context';
41
44
  export type { InboundCorrelation } from './correlation';
42
45
  export type { CorsConfig } from './cors';
@@ -114,7 +117,7 @@ export {
114
117
  socketIdleTimeoutSeconds,
115
118
  } from './idle-timeout';
116
119
  export type { LocaleConfig, TimeZoneConfig } from './locale';
117
- export { DEFAULT_LOCALE_CONFIG } from './locale';
120
+ export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG } from './locale';
118
121
  export type { RequestAddress } from './locale-prefix';
119
122
  export { requestAddress } from './locale-prefix';
120
123
  export type { Middleware } from './middleware';
@@ -127,6 +130,13 @@ export {
127
130
  redirectForRouter,
128
131
  relocate,
129
132
  } from './navigation';
133
+ export type {
134
+ ObjectRequest,
135
+ ObjectResponseOptions,
136
+ ObjectSource,
137
+ ServableObject,
138
+ } from './object-response';
139
+ export { notModified, objectResponse } from './object-response';
130
140
  export type { OverlayMeta, OverlayNotice } from './overlay';
131
141
  export { overlayResponse, wantsOverlay } from './overlay';
132
142
  export { OVERLAY_STYLE } from './overlay-style';
@@ -186,6 +196,7 @@ export {
186
196
  postgresRateLimitStore,
187
197
  SQL_RATE_LIMIT_TABLE,
188
198
  } from './rate-limit-postgres';
199
+ export { RATE_LIMIT_STORE_DEADLINE_MS } from './rate-limit-store-deadline';
189
200
  export { setRedirect, takeRedirect } from './redirect';
190
201
  export type { QueryValues } from './request';
191
202
  export { UltimateRequest } from './request';
@@ -233,16 +244,20 @@ export type {
233
244
  WebSocketMount,
234
245
  } from './server';
235
246
  export { httpServer } from './server';
247
+ export type { WebSocketOption } from './server-websocket';
236
248
  export type { DeleteCookieOptions } from './set-cookie';
237
249
  export { deleteCookie, setCookie } from './set-cookie';
238
- // The stage vocabulary comes from its declaration site, beside the fourteen implementations it
250
+ // The stage vocabulary comes from its declaration site, beside the fifteen implementations it
239
251
  // names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
240
252
  export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
241
253
  export type { InferOutput, Schema, ValidationOutcome } from './validate';
242
254
  export { formatIssue, validateBody, validateSync } from './validate';
255
+ export type { BodyHmacOptions, VerifiedBody } from './webhook-hmac';
256
+ export { verifyBodyHmac } from './webhook-hmac';
243
257
  export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
244
258
  export {
245
259
  DEFAULT_WEBHOOK_BODY_LIMIT,
246
260
  DEFAULT_WEBHOOK_TOLERANCE_MS,
247
261
  verifyWebhookSignature,
248
262
  } from './webhook-verify';
263
+ export { type JoinedWebSocketMounts, joinWebSocketMounts } from './websocket-mounts';
package/src/locale.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // lived here and all three disagreed with their owner about the same request.
5
5
 
6
6
  import { LOCALE_COOKIE } from '@ultimat3/i18n';
7
- import { TIMEZONE_HEADER } from '@ultimat3/time';
7
+ import { TIMEZONE_DETECTED_COOKIE, TIMEZONE_HEADER } from '@ultimat3/time';
8
8
 
9
9
  export interface LocaleConfig {
10
10
  /** Cookie the client sets when the user picks a locale explicitly. */
@@ -15,6 +15,8 @@ export interface TimeZoneConfig {
15
15
  /** Header the client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
16
16
  readonly header: string;
17
17
  readonly cookie: string;
18
+ /** The zone script's guess (`@ultimat3/render`'s `zoneScript`): never the chosen `cookie`. */
19
+ readonly detectedCookie: string;
18
20
  }
19
21
 
20
22
  /**
@@ -31,4 +33,5 @@ export const DEFAULT_LOCALE_CONFIG: LocaleConfig = {
31
33
  export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
32
34
  header: TIMEZONE_HEADER,
33
35
  cookie: 'x_timezone',
36
+ detectedCookie: TIMEZONE_DETECTED_COOKIE,
34
37
  };
@@ -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 =>