@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.
- package/CLAUDE.md +26 -51
- package/README.md +119 -6
- package/package.json +15 -5
- package/src/bearer-mount.ts +67 -12
- package/src/byte-range.ts +69 -0
- package/src/cache-policy.ts +8 -0
- package/src/compress-read.ts +115 -0
- package/src/compress.ts +173 -0
- package/src/config.ts +1 -7
- package/src/context.ts +50 -0
- package/src/cors.ts +43 -4
- package/src/csrf.ts +41 -8
- package/src/deadline.ts +2 -2
- package/src/error-facts.ts +26 -1
- package/src/error-map-http.ts +3 -0
- package/src/error-map-tier-0.ts +26 -0
- package/src/error-map-tier-1.ts +24 -0
- package/src/error-map-tier-2.ts +16 -0
- package/src/error-map-tier-3.ts +30 -0
- package/src/error-map-tier-4.ts +33 -1
- package/src/error-page.ts +6 -7
- package/src/error-status.ts +26 -17
- package/src/error-titles.ts +2 -0
- package/src/errors.ts +27 -12
- package/src/finalize.ts +1 -0
- package/src/index.ts +20 -2
- package/src/locale.ts +4 -1
- package/src/object-response.ts +134 -0
- package/src/overlay.ts +13 -2
- package/src/pipeline.ts +6 -1
- package/src/preferences.ts +4 -0
- package/src/problem-meta-framework.ts +21 -0
- package/src/problem-meta.ts +23 -15
- package/src/rate-limit-degrade.ts +10 -2
- package/src/rate-limit-postgres.ts +22 -9
- package/src/rate-limit-store-deadline.ts +60 -0
- package/src/response.ts +29 -5
- package/src/router.ts +9 -0
- package/src/server-websocket.ts +35 -0
- package/src/server.ts +8 -4
- package/src/stages.ts +22 -5
- package/src/stored-object-headers.ts +43 -0
- package/src/webhook-hmac.ts +73 -0
- package/src/webhook-verify.ts +26 -5
- package/src/websocket-mounts.ts +104 -0
package/src/error-facts.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
// document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
|
|
3
3
|
// 500-line ceiling — that file answers "what status is this code", one closed table, and this one
|
|
4
4
|
// answers "what does a reader see", which is three audiences and one opacity rule.
|
|
5
|
+
import type { ErrorRetry } from '@ultimat3/core';
|
|
5
6
|
import {
|
|
6
7
|
ERROR_DOCS_URL,
|
|
8
|
+
errorRetry,
|
|
7
9
|
FRAMEWORK_CODE,
|
|
8
10
|
failureStack,
|
|
9
11
|
hasPublicCause,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
stringField,
|
|
14
16
|
} from '@ultimat3/core';
|
|
15
17
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
18
|
+
import { issueRuleFacts } from '@ultimat3/schema';
|
|
16
19
|
import { declaredStatusFor, statusFor } from './error-status';
|
|
17
20
|
import { HTTP_ERROR_TITLES } from './errors';
|
|
18
21
|
import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
@@ -204,6 +207,9 @@ function issuesOf(error: unknown): readonly ValidationIssue[] | undefined {
|
|
|
204
207
|
// Forced, never copied. See the paragraph above.
|
|
205
208
|
received: '',
|
|
206
209
|
message,
|
|
210
|
+
// The rule a translator keys on, and its declared params — schema's one reader, which
|
|
211
|
+
// keeps a string `rule` and scalar `params` only, member by member.
|
|
212
|
+
...issueRuleFacts(entry),
|
|
207
213
|
});
|
|
208
214
|
}
|
|
209
215
|
return issues;
|
|
@@ -241,7 +247,20 @@ function metaOf(error: unknown, code: string): ProblemMeta | undefined {
|
|
|
241
247
|
*/
|
|
242
248
|
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
|
|
243
249
|
|
|
244
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Core's `errorRetry`, total, for `retryAfterOf`'s reason: `retry` is a getter read on a value this
|
|
252
|
+
* package did not build, in the frame that decides what the caller sees. Refusing to be read is
|
|
253
|
+
* `terminal`, which is what the same value's unclassified failure answers.
|
|
254
|
+
*/
|
|
255
|
+
const retryOf = (error: unknown): ErrorRetry => {
|
|
256
|
+
try {
|
|
257
|
+
return errorRetry(error);
|
|
258
|
+
} catch {
|
|
259
|
+
return 'terminal';
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs`/`retry` are our extensions. */
|
|
245
264
|
export interface ProblemDocument {
|
|
246
265
|
readonly type: string;
|
|
247
266
|
readonly title: string;
|
|
@@ -252,6 +271,11 @@ export interface ProblemDocument {
|
|
|
252
271
|
readonly cause: string;
|
|
253
272
|
readonly fix: string;
|
|
254
273
|
readonly docs: string;
|
|
274
|
+
/**
|
|
275
|
+
* May the same call be sent again: core's class, the one a thrown `UltimateError` carries. On
|
|
276
|
+
* every document, hidden ones included: it says what to do, never what happened.
|
|
277
|
+
*/
|
|
278
|
+
readonly retry: ErrorRetry;
|
|
255
279
|
readonly requestId: string | undefined;
|
|
256
280
|
/**
|
|
257
281
|
* The rejections, addressed by path, for a failure that produced them. TOP-LEVEL and not an
|
|
@@ -351,6 +375,7 @@ export const toProblem = (
|
|
|
351
375
|
? facts.fix
|
|
352
376
|
: (facts.callerFix ?? (hidden ? explainFix(facts.code) : facts.fix)),
|
|
353
377
|
docs: facts.docs,
|
|
378
|
+
retry: retryOf(error),
|
|
354
379
|
requestId: meta.requestId,
|
|
355
380
|
...(issues === undefined ? {} : { issues }),
|
|
356
381
|
...(carried === undefined ? {} : { meta: carried }),
|
package/src/error-map-http.ts
CHANGED
|
@@ -55,6 +55,9 @@ export const HTTP_ERROR_STATUS = {
|
|
|
55
55
|
X_RATE_LIMIT_INVALID: 500,
|
|
56
56
|
// The shared store did not answer, so nothing decided. An operator's fault, never the caller's.
|
|
57
57
|
X_RATE_LIMIT_STORE_UNAVAILABLE: 500,
|
|
58
|
+
// The store's own deadline passed. The store degrades on it rather than throwing, so a request
|
|
59
|
+
// answered with it is the store's bug; 500, and never a 503 the `admit` stage alone answers.
|
|
60
|
+
X_RATE_LIMIT_STORE_TIMEOUT: 500,
|
|
58
61
|
// The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry
|
|
59
62
|
// `retry-after`: a shed request that does not say when to come back is a request that comes
|
|
60
63
|
// back immediately, which is the load it was shed to avoid.
|
package/src/error-map-tier-0.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-1.ts
CHANGED
|
@@ -82,4 +82,28 @@ export const TIER_1_ERROR_STATUS = {
|
|
|
82
82
|
X_STORAGE_ALREADY_PROMOTED: 409,
|
|
83
83
|
// @ultimat3/seo — a sitemap entry carries a lastmod or a locale the sitemap cannot write
|
|
84
84
|
X_SITEMAP_ENTRY_INVALID: 500,
|
|
85
|
+
// @ultimat3/storage — the requested byte range starts past the end of the object
|
|
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,
|
|
85
109
|
} satisfies Readonly<Record<string, number>>;
|
package/src/error-map-tier-2.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-3.ts
CHANGED
|
@@ -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
|
|
@@ -97,4 +101,30 @@ export const TIER_3_ERROR_STATUS = {
|
|
|
97
101
|
X_QUERY_AUDIT_SINK_MISSING: 500,
|
|
98
102
|
// @ultimat3/query — an audited read was refused because the audit sink rejected its record
|
|
99
103
|
X_QUERY_AUDIT_SINK_FAILED: 503,
|
|
104
|
+
// @ultimat3/query — rows were asked of a query that answers a file
|
|
105
|
+
X_QUERY_DOWNLOAD_ONLY: 400,
|
|
106
|
+
// @ultimat3/query — a download URL carries a token this app did not mint for it
|
|
107
|
+
X_QUERY_DOWNLOAD_TOKEN_INVALID: 403,
|
|
108
|
+
// @ultimat3/query — a download URL is past its expiry
|
|
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,
|
|
100
130
|
} satisfies Readonly<Record<string, number>>;
|
package/src/error-map-tier-4.ts
CHANGED
|
@@ -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
|
|
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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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'
|
|
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>
|
package/src/error-status.ts
CHANGED
|
@@ -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`;
|
|
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
|
|
26
|
-
* framework's own contract — and every code outside it
|
|
27
|
-
* incident: `pipeline.ts` reports `status >= 500` to the
|
|
28
|
-
* whoever was on call.
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
*
|
|
77
|
-
*
|
|
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) ??
|
|
89
|
+
frameworkStatus(code) ?? appErrorStatus(code);
|
|
81
90
|
|
|
82
91
|
export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
|
package/src/error-titles.ts
CHANGED
|
@@ -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 = (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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: '
|
|
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 = (
|
|
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:
|
|
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 = (
|
|
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:
|
|
460
|
-
fix:
|
|
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
|
@@ -21,6 +21,11 @@ export type {
|
|
|
21
21
|
BearerResolver,
|
|
22
22
|
} from './bearer-mount';
|
|
23
23
|
export { bearerMount, bearerTokenOf, mountedPath } from './bearer-mount';
|
|
24
|
+
/** One `Range:` reader for every route that answers bytes — `/_storage`, site assets, a download. */
|
|
25
|
+
export type { RequestedRange } from './byte-range';
|
|
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';
|
|
24
29
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
25
30
|
export { defineHttpConfig, MAX_PROXY_HOPS } from './config';
|
|
26
31
|
export type { ActorView, RequestContext, RequestContextInit } from './context';
|
|
@@ -34,6 +39,7 @@ export {
|
|
|
34
39
|
useRequestCookie,
|
|
35
40
|
useRequestHeader,
|
|
36
41
|
useRequestHeaders,
|
|
42
|
+
withAnonymousRequest,
|
|
37
43
|
} from './context';
|
|
38
44
|
export type { InboundCorrelation } from './correlation';
|
|
39
45
|
export type { CorsConfig } from './cors';
|
|
@@ -111,7 +117,7 @@ export {
|
|
|
111
117
|
socketIdleTimeoutSeconds,
|
|
112
118
|
} from './idle-timeout';
|
|
113
119
|
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
114
|
-
export { DEFAULT_LOCALE_CONFIG } from './locale';
|
|
120
|
+
export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG } from './locale';
|
|
115
121
|
export type { RequestAddress } from './locale-prefix';
|
|
116
122
|
export { requestAddress } from './locale-prefix';
|
|
117
123
|
export type { Middleware } from './middleware';
|
|
@@ -124,6 +130,13 @@ export {
|
|
|
124
130
|
redirectForRouter,
|
|
125
131
|
relocate,
|
|
126
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';
|
|
127
140
|
export type { OverlayMeta, OverlayNotice } from './overlay';
|
|
128
141
|
export { overlayResponse, wantsOverlay } from './overlay';
|
|
129
142
|
export { OVERLAY_STYLE } from './overlay-style';
|
|
@@ -183,6 +196,7 @@ export {
|
|
|
183
196
|
postgresRateLimitStore,
|
|
184
197
|
SQL_RATE_LIMIT_TABLE,
|
|
185
198
|
} from './rate-limit-postgres';
|
|
199
|
+
export { RATE_LIMIT_STORE_DEADLINE_MS } from './rate-limit-store-deadline';
|
|
186
200
|
export { setRedirect, takeRedirect } from './redirect';
|
|
187
201
|
export type { QueryValues } from './request';
|
|
188
202
|
export { UltimateRequest } from './request';
|
|
@@ -230,16 +244,20 @@ export type {
|
|
|
230
244
|
WebSocketMount,
|
|
231
245
|
} from './server';
|
|
232
246
|
export { httpServer } from './server';
|
|
247
|
+
export type { WebSocketOption } from './server-websocket';
|
|
233
248
|
export type { DeleteCookieOptions } from './set-cookie';
|
|
234
249
|
export { deleteCookie, setCookie } from './set-cookie';
|
|
235
|
-
// The stage vocabulary comes from its declaration site, beside the
|
|
250
|
+
// The stage vocabulary comes from its declaration site, beside the fifteen implementations it
|
|
236
251
|
// names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
|
|
237
252
|
export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
|
|
238
253
|
export type { InferOutput, Schema, ValidationOutcome } from './validate';
|
|
239
254
|
export { formatIssue, validateBody, validateSync } from './validate';
|
|
255
|
+
export type { BodyHmacOptions, VerifiedBody } from './webhook-hmac';
|
|
256
|
+
export { verifyBodyHmac } from './webhook-hmac';
|
|
240
257
|
export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
|
|
241
258
|
export {
|
|
242
259
|
DEFAULT_WEBHOOK_BODY_LIMIT,
|
|
243
260
|
DEFAULT_WEBHOOK_TOLERANCE_MS,
|
|
244
261
|
verifyWebhookSignature,
|
|
245
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
|
};
|