@ultimat3/http 1.1.0 → 2.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.
@@ -0,0 +1,79 @@
1
+ // The per-request deadline: the one thing that makes `ctx.signal` real. Nothing in this package
2
+ // created an `AbortController` before, so `throwIfAborted()` and `fetch(url, { signal })` were
3
+ // documented seams wired to nothing — a hung vendor call held its connection and its DB pool slot
4
+ // until the process died, and SIGTERM then waited out the whole drain budget for work that would
5
+ // never finish.
6
+
7
+ import type { HttpConfig } from './config';
8
+ import { requestTimedOut } from './errors';
9
+
10
+ /**
11
+ * A caller may SHORTEN this request's deadline, never lengthen it. Honoured without trusting the
12
+ * proxy, because the only thing it can buy an attacker is a faster 504 for their own request.
13
+ */
14
+ export const REQUEST_TIMEOUT_HEADER = 'x-request-timeout-ms';
15
+
16
+ export interface Deadline {
17
+ /** Aborted when the deadline passes. Handed to the context as `ctx.signal`. */
18
+ readonly signal: AbortSignal;
19
+ /** Rejects with `X_TIMEOUT` at the deadline; `undefined` when there is no deadline. */
20
+ readonly expired: Promise<never> | undefined;
21
+ readonly timeoutMs: number;
22
+ /** Always call it — a live timer keeps the event loop from going idle. */
23
+ clear(): void;
24
+ }
25
+
26
+ const NEVER_ABORTED: AbortSignal = new AbortController().signal;
27
+
28
+ const NO_DEADLINE: Deadline = {
29
+ signal: NEVER_ABORTED,
30
+ expired: undefined,
31
+ timeoutMs: 0,
32
+ clear: () => undefined,
33
+ };
34
+
35
+ /** The configured budget, or the caller's if theirs is shorter. `0` means "no deadline". */
36
+ export const resolveTimeoutMs = (headers: Headers, config: HttpConfig): number => {
37
+ const configured = config.requestTimeoutMs;
38
+ const raw = headers.get(REQUEST_TIMEOUT_HEADER);
39
+ if (raw === null) return configured;
40
+ const asked = Number.parseInt(raw, 10);
41
+ if (!Number.isFinite(asked) || asked < 1) return configured;
42
+ return configured > 0 ? Math.min(configured, asked) : asked;
43
+ };
44
+
45
+ /**
46
+ * One timer and one controller per request. The abort is the cooperative half — app code that
47
+ * passed `ctx.signal` unwinds on its own — and `expired` is the half that answers the socket
48
+ * either way, because a handler ignoring the signal must still not hold the connection forever.
49
+ */
50
+ export const startDeadline = (input: {
51
+ readonly headers: Headers;
52
+ readonly config: HttpConfig;
53
+ readonly method: string;
54
+ readonly pathname: string;
55
+ }): Deadline => {
56
+ const timeoutMs = resolveTimeoutMs(input.headers, input.config);
57
+ if (timeoutMs <= 0) return NO_DEADLINE;
58
+
59
+ const controller = new AbortController();
60
+ let fire: (() => void) | undefined;
61
+ const expired = new Promise<never>((_resolve, reject) => {
62
+ fire = () => reject(requestTimedOut(input.method, input.pathname, timeoutMs));
63
+ });
64
+ // A rejection nothing is awaiting is an unhandled rejection, and a process that dies on the
65
+ // first slow request is worse than the slow request. `Promise.race` attaches its own handler;
66
+ // this one is for the caller that reads `signal` and never the promise.
67
+ void expired.catch(() => undefined);
68
+ const timer = setTimeout(() => {
69
+ controller.abort(requestTimedOut(input.method, input.pathname, timeoutMs));
70
+ fire?.();
71
+ }, timeoutMs);
72
+
73
+ return {
74
+ signal: controller.signal,
75
+ expired,
76
+ timeoutMs,
77
+ clear: () => clearTimeout(timer),
78
+ };
79
+ };
package/src/error-map.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // The one place a framework error code becomes an HTTP status. A table, not a
2
2
  // switch chain: adding a code elsewhere in the framework means adding a row here,
3
3
  // and a missing row is a loud 500 rather than a silently wrong 200.
4
- import { HTTP_ERROR_TITLES } from './errors';
4
+ import { renderCauseValue } from '@ultimat3/core';
5
+ import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
5
6
 
6
7
  /**
7
8
  * code -> status. Codes owned by other packages are listed here on purpose: HTTP
@@ -12,6 +13,9 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
12
13
  // @ultimat3/http
13
14
  X_ROUTE_NOT_FOUND: 404,
14
15
  X_METHOD_NOT_ALLOWED: 405,
16
+ // The request line itself is unreadable, so there is nothing to route: 400, and never a 500 —
17
+ // a malformed escape is the caller's typo, not this server's defect.
18
+ X_PATH_INVALID: 400,
15
19
  X_BODY_INVALID: 422,
16
20
  X_UNAUTHENTICATED: 401,
17
21
  X_FORBIDDEN: 403,
@@ -20,21 +24,163 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
20
24
  X_ROUTE_CONFLICT: 500,
21
25
  X_SERVER_NOT_STARTED: 500,
22
26
  X_PIPELINE_NO_RESPONSE: 500,
27
+ // The request was answered and the answer could not be finished: the caller gets nothing usable
28
+ // either way, so this is the server's failure, never the caller's.
29
+ X_PIPELINE_FINALIZE_FAILED: 500,
30
+ // Both are wiring bugs, never a caller's mistake: reading a cookie where no request exists,
31
+ // and declaring a status the framework already owns. 500 is the honest answer to either.
32
+ X_NO_REQUEST: 500,
33
+ X_ERROR_STATUS_INVALID: 500,
34
+ // Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
35
+ // because a code with no status is a 500 anyway and this table is the closed one.
36
+ X_CORS_CONFIG_INVALID: 500,
37
+ // Thrown while the server is being constructed, so no request is ever answered with it either.
38
+ // The row exists because this table is the closed one: a code missing from it is a 500 anyway,
39
+ // and a code the framework owns must never fall through to the app's table.
40
+ X_RATE_LIMIT_NOT_SHARED: 500,
41
+ // Same construction-time class as the row above: a route and the config declare one bucket
42
+ // differently, and the process refuses to start rather than pick.
43
+ X_RATE_LIMIT_BUCKET_CONFLICT: 500,
44
+ // Construction time as well: the limiter installed cannot enforce a bucket a route declares.
45
+ X_RATE_LIMIT_BUCKET_UNBOUND: 500,
46
+ // `defineHttpConfig` time, both of them: a declaration the deployment owes and did not make.
47
+ X_RATE_LIMIT_SCOPE_UNSET: 500,
48
+ X_TRUST_PROXY_UNSET: 500,
49
+ // Raised by `toBucket` while a route or an action is being projected, never on the request.
50
+ X_RATE_LIMIT_INVALID: 500,
51
+ // The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry
52
+ // `retry-after`: a shed request that does not say when to come back is a request that comes
53
+ // back immediately, which is the load it was shed to avoid.
54
+ X_DRAINING: 503,
55
+ X_OVERLOADED: 503,
56
+ // 403 and never 401: the caller IS authenticated — that is what makes the forged write work —
57
+ // so a 401 would send a signed-in user to a sign-in page they are already past.
58
+ X_CSRF_BLOCKED: 403,
59
+ // @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
60
+ // the primitive declared. 400 because that is what the published OpenAPI operation promises for
61
+ // it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
62
+ // and the `error-map` stage reported the caller's mistake to the on-call monitor.
63
+ X_INPUT_INVALID: 400,
64
+ // A retried `Idempotency-Key` naming a different payload, or one still in flight. 409 because
65
+ // that is what the action's own OpenAPI operation publishes for it — the runtime answered 500
66
+ // while the document promised 409, and a client written against the spec read the framework
67
+ // working exactly as designed as an outage.
68
+ X_IDEMPOTENCY_CONFLICT: 409,
69
+ // A blank or over-long `Idempotency-Key` HEADER, refused before the handler runs. 400 and not
70
+ // the 422 a body gets: what failed is a parameter the OpenAPI operation publishes a `maxLength`
71
+ // for, which is the same thing `X_INPUT_INVALID` is 400 for.
72
+ X_IDEMPOTENCY_KEY_INVALID: 400,
73
+ // 500, and deliberately not the 409 above. `IdempotencyReplayedFailureError` re-throws the FIRST
74
+ // attempt's own code whenever the store recorded one, so this literal code is reached only when
75
+ // that attempt failed carrying no code at all: an unclassified throw whose commit state nobody
76
+ // knows. That is the server's to explain, and it is worth reporting.
77
+ X_IDEMPOTENCY_REPLAYED_FAILURE: 500,
78
+ // @ultimat3/auth — every one of these is reachable from a request: the OAuth route descriptors
79
+ // are mounted by the app, and `authenticate` throws the session codes inside the pipeline. Without
80
+ // a row each fell to 500, so a user pressing Cancel on a consent screen paged the on-call and a
81
+ // provider this app never enabled read as an outage. `packages/auth/src/oauth-route.ts` answers
82
+ // from this table's values when its descriptors are driven OUTSIDE a pipeline; the pin that keeps
83
+ // the two identical is `scripts/oauth-route-status.test.ts`, since auth is this tier and cannot
84
+ // import this package.
85
+ X_SESSION_EXPIRED: 401,
86
+ X_MFA_REQUIRED: 401,
87
+ X_ACCOUNT_LOCKED: 429,
88
+ X_API_KEY_INVALID: 401,
89
+ X_OAUTH_STATE_INVALID: 400,
90
+ X_OAUTH_TOKEN_INVALID: 400,
91
+ X_OAUTH_PROVIDER_UNKNOWN: 404,
92
+ X_OAUTH_DENIED: 403,
93
+ // 502, not 500: the conversation that failed is with the provider's server, and the on-call
94
+ // question "is it us or them?" is the one a status is read for.
95
+ X_OAUTH_EXCHANGE_FAILED: 502,
96
+ // 422 and not 400: the body parsed, the field is a string, and a policy rejected its CONTENT —
97
+ // the same class as `X_BODY_INVALID` and `X_INVARIANT_VIOLATED` above. Unmapped, a visitor
98
+ // choosing "password" at a signup form was reported to the on-call monitor as a server fault.
99
+ X_PASSWORD_WEAK: 422,
23
100
  // @ultimat3/entity
24
101
  X_NOT_FOUND: 404,
25
102
  X_ENTITY_DUPLICATE: 409,
26
103
  X_INVARIANT_VIOLATED: 422,
27
104
  X_TENANCY_UNSCOPED: 500,
28
105
  X_DB_DRIFT: 500,
106
+ // The three tenancy refusals, all 403, and all deliberately NOT the 404 `X_STORAGE_ORG_MISMATCH`
107
+ // takes: that one answers 404 because a 403 on a KEY the caller supplied confirms the key exists.
108
+ // These three name no resource and read no row — the comparison is the actor against an argument
109
+ // (`X_TENANCY_ACTOR_MISMATCH`), the actor against nothing at all (`X_TENANCY_ACTOR_ORG_REQUIRED`),
110
+ // or the actor's scopes at `crossTenant()` (`X_TENANCY_CROSS_DENIED`) — so the answer is the same
111
+ // whether or not the other tenant's row exists, and a 404 would buy no secrecy for the lie.
112
+ X_TENANCY_ACTOR_MISMATCH: 403,
113
+ // 403 and never 401, for the reason `X_CSRF_BLOCKED` is one: the actor may be fully
114
+ // authenticated and merely carry no org — a service actor minted without one — so a 401 sends a
115
+ // signed-in caller to a sign-in page that cannot give them a tenant.
116
+ X_TENANCY_ACTOR_ORG_REQUIRED: 403,
117
+ X_TENANCY_CROSS_DENIED: 403,
118
+ // @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
119
+ // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
120
+ // same event one layer up — is 409 above; a foreign key rides with it because both halves of it
121
+ // are a conflict with the state that is there (the parent is missing, or the child still points
122
+ // at it), which 422 describes only for the insert.
123
+ X_DB_UNIQUE_VIOLATION: 409,
124
+ X_DB_FOREIGN_KEY_VIOLATION: 409,
29
125
  // @ultimat3/policy
30
126
  X_POLICY_MISSING: 500,
31
127
  X_PERMISSION_UNKNOWN: 500,
128
+ // @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one
129
+ // paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
130
+ // the client sends changes the answer, and the report to the on-call monitor is the point.
131
+ X_QUERY_NOT_PAGEABLE: 500,
132
+ // @ultimat3/i18n — a well-formed tag outside the set this app ships, asserted on a value the
133
+ // caller supplied (`assertSupportedLocale`). 400 rather than 406: the http `locale` stage
134
+ // negotiates `Accept-Language` and never throws, so the tag that reaches here came from a path,
135
+ // query or body the caller wrote — the same place `X_IMAGE_QUERY_INVALID` comes from.
136
+ X_LOCALE_UNSUPPORTED: 400,
137
+ // @ultimat3/money — a well-formed code this process carries no row for. The currency table is
138
+ // OPEN (`registerCurrency`), and every surface between the wire and the throw accepts any
139
+ // `^[A-Z]{3}$`: `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the OpenAPI `pattern` emitted from
140
+ // it, and `@ultimat3/entity`'s `char(3)` CHECK. So `{ minor: 100, currency: 'ZWL' }` parses,
141
+ // reaches `money()` -> `assertCurrency`, and with no row answered 500 — reporting a value the
142
+ // framework's own schema had just accepted to the error monitor. 400 rather than 422, beside
143
+ // `X_LOCALE_UNSUPPORTED`: the same shape of mistake, a well-formed value naming something
144
+ // outside the set this process carries, and money's `fix:` already instructs the caller.
145
+ X_CURRENCY_UNKNOWN: 400,
32
146
  // @ultimat3/seo — a transform query the caller wrote, so the caller is the one who can fix it.
33
147
  X_IMAGE_QUERY_INVALID: 400,
148
+ // @ultimat3/storage — every one of these is reachable from a route: `/media/*` already serves
149
+ // objects, and a mounted `/_storage` serves signed reads and takes signed writes. Without a row
150
+ // a missing image answers 500, which reads as an outage instead of a 404.
151
+ X_STORAGE_NOT_FOUND: 404,
152
+ X_STORAGE_PATH_UNSAFE: 400,
153
+ X_STORAGE_TOO_LARGE: 413,
154
+ X_STORAGE_TYPE_REJECTED: 415,
155
+ X_STORAGE_CHECKSUM_MISMATCH: 422,
156
+ X_STORAGE_URL_INVALID: 403,
157
+ X_STORAGE_URL_EXPIRED: 410,
158
+ // 409, not the 500 it fell through to: the object exists and the request is well formed — the
159
+ // STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
160
+ // refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
161
+ // can do. A 500 would have read as "the server broke" for a workflow working exactly as built.
162
+ X_STORAGE_QUARANTINED: 409,
163
+ // 404, deliberately NOT 403: the org check fires before anything is read, so answering
164
+ // "forbidden" would confirm that a key exists to the one caller who must not learn it.
165
+ X_STORAGE_ORG_MISMATCH: 404,
166
+ // @ultimat3/mail
167
+ // The deployment configured no transport. It reaches a caller only through an inline
168
+ // `send(…, { sync: true })` inside a request; the queued path dead-letters instead. A server-side
169
+ // configuration fault either way, so 500 and never a 4xx — nothing the caller sent is wrong, and
170
+ // this is exactly the condition somebody should be paged for.
171
+ X_MAIL_CREDENTIAL_MISSING: 500,
34
172
  // @ultimat3/core
35
173
  // The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
36
174
  // unsupported representation, which is 415 — not a 500, which would blame the server for it.
37
175
  X_IMAGE_UNSUPPORTED: 415,
176
+ // A page token this server minted and the caller echoed back, and it did not verify — tampered,
177
+ // or replayed against another read. The caller's value and the caller's repair ("request the
178
+ // first page again"), so it belongs beside `X_IMAGE_QUERY_INVALID` at 400 and not at 500.
179
+ X_CURSOR_INVALID: 400,
180
+ // Raised by `markReady()` while a role STARTS, in a process whose lifecycle already drained — so
181
+ // no request is ever answered with it, and the row exists for the reason the construction-time
182
+ // rows above do: this table is the closed one, and a code with no row is a 500 anyway.
183
+ X_LIFECYCLE_DRAINED: 500,
38
184
  X_NOT_IMPLEMENTED: 501,
39
185
  X_TIMEOUT: 504,
40
186
  X_ABORTED: 499,
@@ -43,7 +189,54 @@ export const ERROR_STATUS: Readonly<Record<string, number>> = {
43
189
 
44
190
  export const DEFAULT_STATUS = 500;
45
191
 
46
- export const statusFor = (code: string): number => ERROR_STATUS[code] ?? DEFAULT_STATUS;
192
+ /**
193
+ * Statuses for codes the APP owns. The table above is closed — it has to be, it is the
194
+ * framework's own contract — and every code outside it fell to 500, so a wrong password was an
195
+ * incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
196
+ * whoever was on call. This is the app's half of the same table, kept separate so a registration
197
+ * can never move `X_FORBIDDEN` off 403.
198
+ */
199
+ const APP_ERROR_STATUS = new Map<string, number>();
200
+
201
+ /**
202
+ * Declare the status for the codes this app throws. Call it once at boot, beside the module
203
+ * that declares the codes — importing that module IS the registration, the convention
204
+ * `registerActions` and `registerErrorCodes` already use.
205
+ *
206
+ * ```ts
207
+ * registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
208
+ * ```
209
+ */
210
+ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>): void => {
211
+ for (const [code, status] of Object.entries(statuses)) {
212
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
213
+ throw errorStatusInvalid(code, `${String(status)} is not an HTTP status (100-599)`);
214
+ }
215
+ // The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
216
+ // to 200 would be an app whose 401 contract every client already depends on, changed.
217
+ if (ERROR_STATUS[code] !== undefined) {
218
+ throw errorStatusInvalid(code, `the framework already maps it to ${ERROR_STATUS[code]}`);
219
+ }
220
+ const existing = APP_ERROR_STATUS.get(code);
221
+ if (existing !== undefined && existing !== status) {
222
+ throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
223
+ }
224
+ APP_ERROR_STATUS.set(code, status);
225
+ }
226
+ };
227
+
228
+ /** Test seam. Production registers once at boot and never unregisters. */
229
+ export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
230
+
231
+ /** Every status the app declared, for `x errors list` and the manifest. */
232
+ export const appErrorStatus = (): Readonly<Record<string, number>> =>
233
+ Object.fromEntries([...APP_ERROR_STATUS].sort(([a], [b]) => a.localeCompare(b)));
234
+
235
+ // Framework table first: `registerErrorStatus` already refuses those codes, so the order is
236
+ // belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
237
+ // even if a future caller reaches the map some other way.
238
+ export const statusFor = (code: string): number =>
239
+ ERROR_STATUS[code] ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
47
240
 
48
241
  /** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
49
242
  export interface ErrorFacts {
@@ -82,12 +275,19 @@ export const factsOf = (error: unknown): ErrorFacts => {
82
275
  HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES] ??
83
276
  str(record, 'message') ??
84
277
  'unhandled server error';
85
- const cause = str(record, 'cause') ?? str(record, 'message') ?? String(error);
278
+ // The last fallback is the only one that touches the throwable whole, and every throwable a
279
+ // request produces reaches it. `String()` runs the value's own `toString`, so the value that
280
+ // took the request down took the 500 renderer with it and the server had nothing left to send.
281
+ const cause = str(record, 'cause') ?? str(record, 'message') ?? renderCauseValue(error);
86
282
  return {
87
283
  code,
88
284
  title,
89
285
  cause,
90
- fix: str(record, 'fix') ?? 'x logs tail --json # then fix the throwing call site',
286
+ // `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
287
+ // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
288
+ // `x errors explain` ships, and it is the command that answers "what is this code".
289
+ fix:
290
+ str(record, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
91
291
  docs: str(record, 'docs') ?? `https://ultimate.dev/errors/${code}`,
92
292
  status: statusFor(code),
93
293
  stack: str(record, 'stack'),