@ultimat3/http 11.2.0 → 12.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,193 @@
1
+ // Every RENDERING of a throwable the framework has: the normalised facts, the RFC-9457 problem
2
+ // document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
3
+ // 500-line ceiling — that file answers "what status is this code", one closed table, and this one
4
+ // answers "what does a reader see", which is three audiences and one opacity rule.
5
+ import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
6
+ import { declaredStatusFor, statusFor } from './error-map';
7
+ import { HTTP_ERROR_TITLES } from './errors';
8
+
9
+ /** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
10
+ export interface ErrorFacts {
11
+ readonly code: string;
12
+ readonly title: string;
13
+ readonly cause: string;
14
+ readonly fix: string;
15
+ readonly docs: string;
16
+ readonly status: number;
17
+ /** Present only when the process is in dev mode; never sent to a client in prod. */
18
+ readonly stack: string | undefined;
19
+ }
20
+
21
+ /**
22
+ * One string field off the throwable, through core's `stringField`. The read is a getter call —
23
+ * or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
24
+ * place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
25
+ * the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
26
+ * renderings and `handle()` rejected against its own contract.
27
+ */
28
+ const str = (source: unknown, key: string): string | undefined => {
29
+ const value = stringField(source, key);
30
+ return value !== undefined && value.length > 0 ? value : undefined;
31
+ };
32
+
33
+ /**
34
+ * Normalises any throwable into the framework's error contract. Non-Ultimate
35
+ * throwables still get a code and a fix, because "errors are instructions" has to
36
+ * hold for the accidental `TypeError` too.
37
+ */
38
+ export const factsOf = (error: unknown): ErrorFacts => {
39
+ const code = str(error, 'code') ?? 'X_INTERNAL';
40
+ // The error's own title first: every `UltimateError` resolves one from the code registry at
41
+ // construction, so this renders the OWNING package's title — including the codes http only
42
+ // borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
43
+ // Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
44
+ const title =
45
+ str(error, 'title') ??
46
+ // `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
47
+ // function off the prototype and put it in `title`, which is rendered into the problem
48
+ // document and the terminal.
49
+ (Object.hasOwn(HTTP_ERROR_TITLES, code)
50
+ ? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
51
+ : undefined) ??
52
+ str(error, 'message') ??
53
+ 'unhandled server error';
54
+ // The last fallback is the only one that touches the throwable whole, and every throwable a
55
+ // request produces reaches it. `String()` runs the value's own `toString`, so the value that
56
+ // took the request down took the 500 renderer with it and the server had nothing left to send.
57
+ const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
58
+ return {
59
+ code,
60
+ title,
61
+ cause,
62
+ // `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
63
+ // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
64
+ // `x errors explain` ships, and it is the command that answers "what is this code".
65
+ fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
66
+ // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
67
+ // and a code lives there in a table row, which has no anchor. An `UltimateError` already
68
+ // resolved this at construction, so the fallback only fires for a throwable the framework did
69
+ // not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
70
+ // 404 on every problem document this package has ever rendered.
71
+ docs: str(error, 'docs') ?? ERROR_DOCS_URL,
72
+ status: statusFor(code),
73
+ stack: str(error, 'stack'),
74
+ };
75
+ };
76
+
77
+ /**
78
+ * `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
79
+ *
80
+ * The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
81
+ * `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
82
+ * HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
83
+ * same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
84
+ * both told the caller to come back and never said when — which is the shed-with-no-delay pattern
85
+ * the `admit` stage exists to avoid, one layer in.
86
+ *
87
+ * Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
88
+ * not build, and it is read in the frame that decides what the caller sees.
89
+ */
90
+ export function retryAfterOf(error: unknown): number | undefined {
91
+ if (typeof error !== 'object' || error === null) return undefined;
92
+ try {
93
+ const meta: unknown = (error as Record<string, unknown>)['meta'];
94
+ if (typeof meta !== 'object' || meta === null) return undefined;
95
+ const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
96
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
97
+ // At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
98
+ // as "retry now", which is the stampede a Retry-After exists to spread.
99
+ return Math.max(1, Math.ceil(seconds));
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
107
+ * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
108
+ * to read about it, and those stopped being the same string when `docs` became one wiki page for
109
+ * every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
110
+ * forbidden the same identifier, which is the one thing a `type` may not do.
111
+ *
112
+ * A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
113
+ * — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
114
+ * `code` carries the same string as a plain member for a reader that would rather not parse a URI.
115
+ */
116
+ export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
117
+
118
+ /** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
119
+ export interface ProblemDocument {
120
+ readonly type: string;
121
+ readonly title: string;
122
+ readonly status: number;
123
+ readonly detail: string;
124
+ readonly instance: string | undefined;
125
+ readonly code: string;
126
+ readonly cause: string;
127
+ readonly fix: string;
128
+ readonly docs: string;
129
+ readonly requestId: string | undefined;
130
+ }
131
+
132
+ /** The title a caller gets for a failure the framework cannot name. */
133
+ const INTERNAL_TITLE = 'unhandled server error';
134
+
135
+ /**
136
+ * The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
137
+ * falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
138
+ * absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
139
+ * out of exactly this and said so in its header; the two audiences then disagreed about one
140
+ * condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
141
+ * reports every 5xx to the error monitor, both keyed by the request id below.
142
+ */
143
+ const INTERNAL_CAUSE =
144
+ 'the server failed while handling this request; the details are in this process\u2019s logs and ' +
145
+ 'error reports, under this request id';
146
+
147
+ /**
148
+ * A 5xx nobody declared a status for — not the framework's table, not the app's
149
+ * `registerErrorStatus` — or one whose code is `X_INTERNAL`. That is the discriminator, and not
150
+ * `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would
151
+ * take away the one instruction in it.
152
+ *
153
+ * `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's
154
+ * own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and
155
+ * core's `toError()` wraps a caught value into an `InternalError` whose cause is
156
+ * `renderCauseValue(value)` — the driver's message, verbatim. Nothing in an `X_INTERNAL` is
157
+ * actionable by the caller; the code and the request id are.
158
+ */
159
+ const isUnclassifiedFailure = (code: string, status: number): boolean =>
160
+ status >= 500 && (code === 'X_INTERNAL' || declaredStatusFor(code) === undefined);
161
+
162
+ export const toProblem = (
163
+ error: unknown,
164
+ meta: { instance?: string; requestId?: string; dev?: boolean } = {},
165
+ ): ProblemDocument => {
166
+ const facts = factsOf(error);
167
+ const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
168
+ return {
169
+ type: problemTypeFor(facts.code),
170
+ title: opaque ? INTERNAL_TITLE : facts.title,
171
+ status: facts.status,
172
+ detail: opaque ? INTERNAL_CAUSE : facts.cause,
173
+ instance: meta.instance,
174
+ code: facts.code,
175
+ cause: opaque ? INTERNAL_CAUSE : facts.cause,
176
+ fix: facts.fix,
177
+ docs: facts.docs,
178
+ requestId: meta.requestId,
179
+ };
180
+ };
181
+
182
+ /** The exact three lines the terminal prints, reused by the overlay and `--json`. */
183
+ export const renderErrorLines = (error: unknown): string => {
184
+ const facts = factsOf(error);
185
+ // The newlines here are the format's own. Every interpolated field goes through `singleLine`
186
+ // so a caller-controlled value cannot add a third one — this string is rendered into the dev
187
+ // overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
188
+ return [
189
+ `${singleLine(facts.code)}: ${singleLine(facts.title)}`,
190
+ ` cause: ${singleLine(facts.cause)}`,
191
+ ` fix: ${singleLine(facts.fix)}`,
192
+ ].join('\n');
193
+ };
package/src/error-map.ts CHANGED
@@ -1,8 +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 { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
5
- import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
4
+ // Rendering a throwable for a reader is `error-facts.ts`; this file answers only the status.
5
+ import { errorStatusInvalid } from './errors';
6
6
 
7
7
  /**
8
8
  * code -> status. Codes owned by other packages are listed here on purpose: HTTP
@@ -48,8 +48,10 @@ export const ERROR_STATUS = {
48
48
  X_RATE_LIMIT_BUCKET_CONFLICT: 500,
49
49
  // Construction time as well: the limiter installed cannot enforce a bucket a route declares.
50
50
  X_RATE_LIMIT_BUCKET_UNBOUND: 500,
51
- // `defineHttpConfig` time, both of them: a declaration the deployment owes and did not make.
51
+ // `defineHttpConfig` time, all three: a declaration the deployment owes and did not make, or
52
+ // made against a bucket nothing declares.
52
53
  X_RATE_LIMIT_SCOPE_UNSET: 500,
54
+ X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 500,
53
55
  X_TRUST_PROXY_UNSET: 500,
54
56
  // Raised by `toBucket` while a route or an action is being projected, never on the request.
55
57
  X_RATE_LIMIT_INVALID: 500,
@@ -138,6 +140,23 @@ export const ERROR_STATUS = {
138
140
  // signed-in caller to a sign-in page that cannot give them a tenant.
139
141
  X_TENANCY_ACTOR_ORG_REQUIRED: 403,
140
142
  X_TENANCY_CROSS_DENIED: 403,
143
+ // The three aggregate refusals, all 500 and all deliberately NOT a 4xx, for the reason
144
+ // `X_QUERY_NOT_PAGEABLE` below is one: nothing the caller sends changes the answer, and the fix
145
+ // is an edit to the read itself. They earn ROWS rather than a pin in `scripts/error-map-backlog.ts`
146
+ // because each carries an instruction the app's author needs and an unmapped 5xx is blanked —
147
+ // `toProblem` replaces an undeclared code's cause with `INTERNAL_CAUSE`, so pinning them would
148
+ // answer "the server failed while handling this request" for a fault whose own `fix:` names the
149
+ // exact call to write instead. A row costs no extra page: `stages.ts` reports every `status >= 500`
150
+ // either way.
151
+ //
152
+ // Reached with a request waiting, all three, which is why they are not in the backlog's entity
153
+ // group ("misuse a handler's author makes") — the mixed-currency and the ±2^53 refusals are
154
+ // decided by the ROWS, so a read that answered for two years starts failing on the day the data
155
+ // crosses the line, and `approximateCount()` on a chain whose predicates came from the caller's
156
+ // own optional filters is one query string away.
157
+ X_AGGREGATE_UNSUPPORTED: 500,
158
+ X_AGGREGATE_MIXED_CURRENCY: 500,
159
+ X_APPROXIMATE_COUNT_FILTERED: 500,
141
160
  // @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
142
161
  // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
143
162
  // same event one layer up — is 409 above; a foreign key rides with it because both halves of it
@@ -216,6 +235,14 @@ export const ERROR_STATUS = {
216
235
  // configuration fault either way, so 500 and never a 4xx — nothing the caller sent is wrong, and
217
236
  // this is exactly the condition somebody should be paged for.
218
237
  X_MAIL_CREDENTIAL_MISSING: 500,
238
+ // @ultimat3/mcp — the one MCP code that is answered on a REQUEST rather than inside a JSON-RPC
239
+ // envelope, which is what the rest of that package's backlog group says about the others: the
240
+ // transport refused before dispatch, so there is no call to answer. 429 because
241
+ // `mcpHttpRoute` already builds that response by hand (`transport-http.ts`'s `throttled`), with
242
+ // `retry-after` beside it. The row is what keeps the two surfaces from disagreeing the day the
243
+ // MCP host is mounted inside this pipeline — a code that renders 429 on one and 500 on the other
244
+ // is exactly the split this table exists to prevent.
245
+ X_MCP_RATE_LIMITED: 429,
219
246
  // @ultimat3/core
220
247
  // The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
221
248
  // unsupported representation, which is 415 — not a 500, which would blame the server for it.
@@ -303,197 +330,19 @@ export const registerErrorStatus = (statuses: Readonly<Record<string, number>>):
303
330
  /** Test seam. Production registers once at boot and never unregisters. */
304
331
  export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
305
332
 
306
- // Framework table first: `registerErrorStatus` already refuses those codes, so the order is
307
- // belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
308
- // even if a future caller reaches the map some other way.
309
- // `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
310
- // prefer one for anything keyed by a value a caller chose.
311
- export const statusFor = (code: string): number =>
312
- frameworkStatus(code) ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
313
-
314
- /** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
315
- export interface ErrorFacts {
316
- readonly code: string;
317
- readonly title: string;
318
- readonly cause: string;
319
- readonly fix: string;
320
- readonly docs: string;
321
- readonly status: number;
322
- /** Present only when the process is in dev mode; never sent to a client in prod. */
323
- readonly stack: string | undefined;
324
- }
325
-
326
- /**
327
- * One string field off the throwable, through core's `stringField`. The read is a getter call —
328
- * or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
329
- * place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
330
- * the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
331
- * renderings and `handle()` rejected against its own contract.
332
- */
333
- const str = (source: unknown, key: string): string | undefined => {
334
- const value = stringField(source, key);
335
- return value !== undefined && value.length > 0 ? value : undefined;
336
- };
337
-
338
- /**
339
- * Normalises any throwable into the framework's error contract. Non-Ultimate
340
- * throwables still get a code and a fix, because "errors are instructions" has to
341
- * hold for the accidental `TypeError` too.
342
- */
343
- export const factsOf = (error: unknown): ErrorFacts => {
344
- const code = str(error, 'code') ?? 'X_INTERNAL';
345
- // The error's own title first: every `UltimateError` resolves one from the code registry at
346
- // construction, so this renders the OWNING package's title — including the codes http only
347
- // borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
348
- // Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
349
- const title =
350
- str(error, 'title') ??
351
- // `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
352
- // function off the prototype and put it in `title`, which is rendered into the problem
353
- // document and the terminal.
354
- (Object.hasOwn(HTTP_ERROR_TITLES, code)
355
- ? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
356
- : undefined) ??
357
- str(error, 'message') ??
358
- 'unhandled server error';
359
- // The last fallback is the only one that touches the throwable whole, and every throwable a
360
- // request produces reaches it. `String()` runs the value's own `toString`, so the value that
361
- // took the request down took the 500 renderer with it and the server had nothing left to send.
362
- const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
363
- return {
364
- code,
365
- title,
366
- cause,
367
- // `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
368
- // command that throws is axiom 4 inverted: the one instruction the reader is given fails.
369
- // `x errors explain` ships, and it is the command that answers "what is this code".
370
- fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
371
- // Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
372
- // and a code lives there in a table row, which has no anchor. An `UltimateError` already
373
- // resolved this at construction, so the fallback only fires for a throwable the framework did
374
- // not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
375
- // 404 on every problem document this package has ever rendered.
376
- docs: str(error, 'docs') ?? ERROR_DOCS_URL,
377
- status: statusFor(code),
378
- stack: str(error, 'stack'),
379
- };
380
- };
381
-
382
- /**
383
- * `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
384
- *
385
- * The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
386
- * `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
387
- * HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
388
- * same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
389
- * both told the caller to come back and never said when — which is the shed-with-no-delay pattern
390
- * the `admit` stage exists to avoid, one layer in.
391
- *
392
- * Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
393
- * not build, and it is read in the frame that decides what the caller sees.
394
- */
395
- export function retryAfterOf(error: unknown): number | undefined {
396
- if (typeof error !== 'object' || error === null) return undefined;
397
- try {
398
- const meta: unknown = (error as Record<string, unknown>)['meta'];
399
- if (typeof meta !== 'object' || meta === null) return undefined;
400
- const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
401
- if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
402
- // At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
403
- // as "retry now", which is the stampede a Retry-After exists to spread.
404
- return Math.max(1, Math.ceil(seconds));
405
- } catch {
406
- return undefined;
407
- }
408
- }
409
-
410
- /**
411
- * RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
412
- * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
413
- * to read about it, and those stopped being the same string when `docs` became one wiki page for
414
- * every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
415
- * forbidden the same identifier, which is the one thing a `type` may not do.
416
- *
417
- * A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
418
- * — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
419
- * `code` carries the same string as a plain member for a reader that would rather not parse a URI.
420
- */
421
- export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
422
-
423
- /** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
424
- export interface ProblemDocument {
425
- readonly type: string;
426
- readonly title: string;
427
- readonly status: number;
428
- readonly detail: string;
429
- readonly instance: string | undefined;
430
- readonly code: string;
431
- readonly cause: string;
432
- readonly fix: string;
433
- readonly docs: string;
434
- readonly requestId: string | undefined;
435
- }
436
-
437
- /** The title a caller gets for a failure the framework cannot name. */
438
- const INTERNAL_TITLE = 'unhandled server error';
439
-
440
- /**
441
- * The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
442
- * falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
443
- * absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
444
- * out of exactly this and said so in its header; the two audiences then disagreed about one
445
- * condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
446
- * reports every 5xx to the error monitor, both keyed by the request id below.
447
- */
448
- const INTERNAL_CAUSE =
449
- 'the server failed while handling this request; the details are in this process\u2019s logs and ' +
450
- 'error reports, under this request id';
451
-
452
333
  /**
453
- * A 5xx nobody declared a status for not the framework's table, not the app's
454
- * `registerErrorStatus` or one whose code is `X_INTERNAL`. That is the discriminator, and not
455
- * `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would
456
- * take away the one instruction in it.
334
+ * The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
335
+ * nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
336
+ * "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
337
+ * reads to decide whether a 5xx may carry the throwable's own words back to the caller.
457
338
  *
458
- * `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's
459
- * own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and
460
- * core's `toError()` wraps a caught value into an `InternalError` whose cause is
461
- * `renderCauseValue(value)` the driver's message, verbatim. Nothing in an `X_INTERNAL` is
462
- * actionable by the caller; the code and the request id are.
339
+ * Framework table first: `registerErrorStatus` already refuses those codes, so the order is
340
+ * belt-and-braces but it is the belt that makes "the framework's statuses are fixed" true
341
+ * even if a future caller reaches the map some other way.
342
+ * `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
343
+ * prefer one for anything keyed by a value a caller chose.
463
344
  */
464
- const isUnclassifiedFailure = (code: string, status: number): boolean =>
465
- status >= 500 &&
466
- (code === 'X_INTERNAL' || (frameworkStatus(code) === undefined && !APP_ERROR_STATUS.has(code)));
345
+ export const declaredStatusFor = (code: string): number | undefined =>
346
+ frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
467
347
 
468
- export const toProblem = (
469
- error: unknown,
470
- meta: { instance?: string; requestId?: string; dev?: boolean } = {},
471
- ): ProblemDocument => {
472
- const facts = factsOf(error);
473
- const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
474
- return {
475
- type: problemTypeFor(facts.code),
476
- title: opaque ? INTERNAL_TITLE : facts.title,
477
- status: facts.status,
478
- detail: opaque ? INTERNAL_CAUSE : facts.cause,
479
- instance: meta.instance,
480
- code: facts.code,
481
- cause: opaque ? INTERNAL_CAUSE : facts.cause,
482
- fix: facts.fix,
483
- docs: facts.docs,
484
- requestId: meta.requestId,
485
- };
486
- };
487
-
488
- /** The exact three lines the terminal prints, reused by the overlay and `--json`. */
489
- export const renderErrorLines = (error: unknown): string => {
490
- const facts = factsOf(error);
491
- // The newlines here are the format's own. Every interpolated field goes through `singleLine`
492
- // so a caller-controlled value cannot add a third one — this string is rendered into the dev
493
- // overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
494
- return [
495
- `${singleLine(facts.code)}: ${singleLine(facts.title)}`,
496
- ` cause: ${singleLine(facts.cause)}`,
497
- ` fix: ${singleLine(facts.fix)}`,
498
- ].join('\n');
499
- };
348
+ export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
package/src/errors.ts CHANGED
@@ -30,6 +30,7 @@ export const HTTP_OWNED_ERROR_CODES = [
30
30
  'X_RATE_LIMIT_SCOPE_UNSET',
31
31
  'X_RATE_LIMIT_INVALID',
32
32
  'X_RATE_LIMIT_STORE_UNAVAILABLE',
33
+ 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
33
34
  'X_TRUST_PROXY_UNSET',
34
35
  'X_OVERLOADED',
35
36
  'X_CSRF_BLOCKED',
@@ -83,6 +84,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
83
84
  X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
84
85
  X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
85
86
  X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
87
+ X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
86
88
  X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
87
89
  X_OVERLOADED: 'in-flight requests are at the configured ceiling',
88
90
  X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
@@ -289,7 +291,7 @@ export const corsConfigInvalid = (reason: string): HttpError =>
289
291
  new HttpError({
290
292
  code: 'X_CORS_CONFIG_INVALID',
291
293
  cause: `cors config rejected: ${reason}`,
292
- fix: "in app.config.ts set http.cors.credentials: false, or replace http.cors.origins: ['*'] with the exact origins allowed to call this app",
294
+ 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",
293
295
  });
294
296
 
295
297
  /**
@@ -303,7 +305,7 @@ export const cspDirectiveInvalid = (where: string, value: string): HttpError =>
303
305
  new HttpError({
304
306
  code: 'X_CSP_DIRECTIVE_INVALID',
305
307
  cause: `${where} is not a csp token: ${JSON.stringify(value)}`,
306
- fix: 'in app.config.ts write one http.security.csp.extend entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon',
308
+ fix: 'in the configureHttp({ security: { csp: { extend } } }) call write one entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon',
307
309
  });
308
310
 
309
311
  export const routeConflict = (path: string, detail: string): HttpError =>
@@ -325,7 +327,7 @@ export const trustProxyUnset = (): HttpError =>
325
327
  code: 'X_TRUST_PROXY_UNSET',
326
328
  cause:
327
329
  'http.trustProxy is true and http.trustedProxyHops is not set, so x-forwarded-for would be read from a position the client controls',
328
- fix: 'in app.config.ts set http.trustedProxyHops to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — or set http.trustProxy: false when this process is reached directly',
330
+ fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
329
331
  });
330
332
 
331
333
  /**
@@ -351,7 +353,7 @@ export const overloaded = (inflight: number, ceiling: number): HttpError =>
351
353
  new HttpError({
352
354
  code: 'X_OVERLOADED',
353
355
  cause: `${inflight} requests are already in flight and http.maxInflight is ${ceiling}`,
354
- fix: 'retry after the Retry-After header; to serve more at once raise http.maxInflight in app.config.ts, and add replicas to match',
356
+ fix: 'retry after the Retry-After header; to serve more at once call configureHttp({ maxInflight: 2000 }) at module scope in a file under apps/*/, and add replicas to match',
355
357
  });
356
358
 
357
359
  /**
@@ -362,7 +364,7 @@ export const csrfBlocked = (pathname: string, reason: string): HttpError =>
362
364
  new HttpError({
363
365
  code: 'X_CSRF_BLOCKED',
364
366
  cause: `${pathname} refused a credentialed write: ${reason}`,
365
- fix: "call it with an Authorization header instead of the session cookie, add the calling origin to http.cors.origins in app.config.ts, or set http.csrf.mode: 'off' if this app has no cookie session at all",
367
+ fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
366
368
  });
367
369
 
368
370
  /**
@@ -374,6 +376,6 @@ export const requestTimedOut = (method: string, pathname: string, timeoutMs: num
374
376
  new HttpError({
375
377
  code: 'X_TIMEOUT',
376
378
  cause: `${method} ${pathname} did not finish within ${timeoutMs}ms`,
377
- fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or raise http.requestTimeoutMs in app.config.ts',
379
+ fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or call configureHttp({ requestTimeoutMs: 60_000 }) at module scope in a file under apps/*/',
378
380
  meta: { timeoutMs },
379
381
  });
package/src/index.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  // listed here is an implementation detail and may change without a major bump.
3
3
 
4
4
  export type { RenderMode } from '@ultimat3/core';
5
+ export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
6
+ export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
5
7
  export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
6
8
  export type { HttpConfig, HttpConfigInput } from './config';
7
9
  export { defineHttpConfig, stripBasePath } from './config';
@@ -24,18 +26,20 @@ export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
24
26
  export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
25
27
  export type { Deadline } from './deadline';
26
28
  export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
27
- export type { ErrorFacts, ProblemDocument } from './error-map';
29
+ export type { ErrorFacts, ProblemDocument } from './error-facts';
28
30
  export {
29
- DEFAULT_STATUS,
30
- ERROR_STATUS,
31
31
  factsOf,
32
32
  problemTypeFor,
33
- registerErrorStatus,
34
33
  renderErrorLines,
35
- resetErrorStatus,
36
34
  retryAfterOf,
37
- statusFor,
38
35
  toProblem,
36
+ } from './error-facts';
37
+ export {
38
+ DEFAULT_STATUS,
39
+ ERROR_STATUS,
40
+ registerErrorStatus,
41
+ resetErrorStatus,
42
+ statusFor,
39
43
  } from './error-map';
40
44
  export type {
41
45
  ErrorPageAction,
@@ -107,6 +111,7 @@ export type {
107
111
  RateLimiter,
108
112
  RateLimitKeyParts,
109
113
  RateLimitScope,
114
+ RateLimitSpend,
110
115
  RateLimitStore,
111
116
  } from './rate-limit';
112
117
  export {
@@ -116,8 +121,9 @@ export {
116
121
  DEFAULT_RATE_LIMIT,
117
122
  memoryRateLimitStore,
118
123
  rateLimitDecision,
119
- rateLimitKey,
124
+ rateLimitSpends,
120
125
  resolveRateLimitConfig,
126
+ TENANT_SCOPE,
121
127
  toBucket,
122
128
  } from './rate-limit';
123
129
  export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
@@ -129,6 +135,7 @@ export {
129
135
  rateLimitNotShared,
130
136
  rateLimitScopeUnset,
131
137
  rateLimitStoreUnavailable,
138
+ tenantBucketUnknown,
132
139
  } from './rate-limit-errors';
133
140
  export type {
134
141
  PgExecutor,
package/src/overlay.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `--json` emits, so a code/cause/fix string can never differ between the three
3
3
  // surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
4
4
  // error contract, not UI copy, so they are not routed through the i18n catalog.
5
- import { factsOf, renderErrorLines, toProblem } from './error-map';
5
+ import { factsOf, renderErrorLines, toProblem } from './error-facts';
6
6
  import { acceptsHtml, escapeHtml } from './html-render';
7
7
  import { OVERLAY_STYLE } from './overlay-style';
8
8
  import { html } from './response';
package/src/pipeline.ts CHANGED
@@ -237,6 +237,10 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
237
237
  // address — an identity from an untrusted hop authenticates, which is worse than none.
238
238
  peer: peerIdentity(forwarded),
239
239
  signal: deadline.signal,
240
+ // The number behind that signal. `traceHeaders()` in core reads it off the ambient
241
+ // context, so every outbound hop this request makes carries what is LEFT of the budget
242
+ // rather than letting the next service start a fresh one of its own.
243
+ deadlineAt: deadline.deadlineAt,
240
244
  // The context is what app code reaches through core's ALS; without the inbound headers
241
245
  // on it, a cookie the server itself set could never be read back on the next request,
242
246
  // and `ctx.session` had no way to exist.