@ultimat3/http 29.4.0 → 30.1.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/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 +22 -0
- package/src/error-map-tier-2.ts +16 -0
- package/src/error-map-tier-3.ts +24 -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 +17 -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 +18 -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-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
|
@@ -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>>;
|
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
|
|
@@ -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>>;
|
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
|
@@ -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
|
|
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
|
-
|
|
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 =>
|