@ultimat3/http 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +75 -31
- package/README.md +89 -26
- package/package.json +9 -6
- package/src/app-config.ts +3 -1
- package/src/auth-redirect.ts +56 -17
- package/src/bearer-mount.ts +81 -26
- package/src/cache-policy.ts +10 -2
- package/src/config.ts +62 -32
- package/src/context.ts +11 -11
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/csrf.ts +17 -8
- package/src/deadline.ts +10 -8
- package/src/error-facts.ts +18 -2
- package/src/error-map-http.ts +94 -0
- package/src/error-map-tier-0.ts +62 -0
- package/src/error-map-tier-1.ts +73 -0
- package/src/error-map-tier-2.ts +120 -0
- package/src/error-map-tier-3.ts +100 -0
- package/src/error-map-tier-4.ts +116 -0
- package/src/error-map.ts +27 -474
- package/src/error-page.ts +3 -4
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +46 -96
- package/src/forwarded.ts +36 -10
- package/src/health-disclosure.ts +66 -0
- package/src/hooks.ts +6 -5
- package/src/html-render.ts +3 -17
- package/src/index.ts +36 -27
- package/src/locale-prefix.ts +5 -1
- package/src/locale.ts +0 -27
- package/src/navigation.ts +26 -1
- package/src/overlay.ts +3 -1
- package/src/peer-identity.ts +10 -6
- package/src/pipeline.ts +32 -13
- package/src/preferences.ts +1 -1
- package/src/problem-meta.ts +7 -21
- package/src/rate-limit-buckets.ts +26 -2
- package/src/rate-limit-errors.ts +36 -10
- package/src/rate-limit-headers.ts +40 -0
- package/src/rate-limit-installed.ts +79 -0
- package/src/rate-limit-peek.ts +32 -0
- package/src/rate-limit-postgres.ts +30 -21
- package/src/rate-limit-stage.ts +132 -0
- package/src/rate-limit.ts +40 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +12 -5
- package/src/response.ts +10 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +36 -8
- package/src/server.ts +101 -30
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +50 -54
- package/src/validate.ts +2 -2
package/src/deadline.ts
CHANGED
|
@@ -5,19 +5,16 @@
|
|
|
5
5
|
// never finish.
|
|
6
6
|
|
|
7
7
|
import { REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
|
|
8
|
-
import type
|
|
8
|
+
import { type HttpConfig, MAX_TIMER_MS } from './config';
|
|
9
9
|
import { requestTimedOut } from './errors';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* A caller may SHORTEN this request's deadline, never lengthen it. Honoured without trusting the
|
|
13
13
|
* proxy, because the only thing it can buy an attacker is a faster 504 for their own request.
|
|
14
14
|
*
|
|
15
|
-
* The name is core's
|
|
16
|
-
*
|
|
17
|
-
* stops working the day one of the two strings is edited. Same shape as `logger.ts` re-exporting
|
|
18
|
-
* `REDACTED` — one definition, one public path.
|
|
15
|
+
* The header name is core's `REQUEST_TIMEOUT_HEADER`, imported: this package READS it and core's
|
|
16
|
+
* typed-client wire path WRITES it, so one literal, at the tier both reach, with one import path.
|
|
19
17
|
*/
|
|
20
|
-
export { REQUEST_TIMEOUT_HEADER };
|
|
21
18
|
|
|
22
19
|
export interface Deadline {
|
|
23
20
|
/** Aborted when the deadline passes. Handed to the context as `ctx.signal`. */
|
|
@@ -46,13 +43,18 @@ const NO_DEADLINE: Deadline = {
|
|
|
46
43
|
clear: () => undefined,
|
|
47
44
|
};
|
|
48
45
|
|
|
49
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* The configured budget, or the caller's if theirs is shorter. `0` means "no deadline".
|
|
48
|
+
*
|
|
49
|
+
* An ask above `MAX_TIMER_MS` is no ask: no timer can hold it — the runtime arms such a delay as
|
|
50
|
+
* ~1 ms, so "a very long deadline" was `X_TIMEOUT` within a tick — and a caller may only shorten.
|
|
51
|
+
*/
|
|
50
52
|
export const resolveTimeoutMs = (headers: Headers, config: HttpConfig): number => {
|
|
51
53
|
const configured = config.requestTimeoutMs;
|
|
52
54
|
const raw = headers.get(REQUEST_TIMEOUT_HEADER);
|
|
53
55
|
if (raw === null) return configured;
|
|
54
56
|
const asked = Number.parseInt(raw, 10);
|
|
55
|
-
if (!Number.isFinite(asked) || asked < 1) return configured;
|
|
57
|
+
if (!Number.isFinite(asked) || asked < 1 || asked > MAX_TIMER_MS) return configured;
|
|
56
58
|
return configured > 0 ? Math.min(configured, asked) : asked;
|
|
57
59
|
};
|
|
58
60
|
|
package/src/error-facts.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
ERROR_DOCS_URL,
|
|
7
7
|
FRAMEWORK_CODE,
|
|
8
|
+
hasPublicCause,
|
|
8
9
|
isUltimateError,
|
|
9
10
|
renderCauseValue,
|
|
10
11
|
singleLine,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
14
15
|
import { declaredStatusFor, statusFor } from './error-status';
|
|
15
16
|
import { HTTP_ERROR_TITLES } from './errors';
|
|
16
|
-
import {
|
|
17
|
+
import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
17
18
|
|
|
18
19
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
19
20
|
export interface ErrorFacts {
|
|
@@ -270,6 +271,14 @@ export interface ProblemDocument {
|
|
|
270
271
|
readonly meta?: ProblemMeta | undefined;
|
|
271
272
|
}
|
|
272
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The fix a hidden document carries: the code's own registry entry, nothing off the throwable.
|
|
276
|
+
* `FRAMEWORK_CODE` gates it for `factsOf`'s reason — this is a command a reader pastes, and `code`
|
|
277
|
+
* is a string field off a value this package did not build.
|
|
278
|
+
*/
|
|
279
|
+
const explainFix = (code: string): string =>
|
|
280
|
+
FRAMEWORK_CODE.test(code) ? `x errors explain ${code} --json` : 'x errors list --json';
|
|
281
|
+
|
|
273
282
|
/** The title a caller gets for a failure the framework cannot name. */
|
|
274
283
|
const INTERNAL_TITLE = 'unhandled server error';
|
|
275
284
|
|
|
@@ -331,7 +340,14 @@ export const toProblem = (
|
|
|
331
340
|
// The CALLER's fix outside dev: a production client cannot run `x policy explain`, and the
|
|
332
341
|
// developer's line stays in the log this request wrote. Dev keeps the developer's, because the
|
|
333
342
|
// reader of a dev response is the developer.
|
|
334
|
-
fix
|
|
343
|
+
// HIDDEN, the developer's fix is withheld with the cause it was built beside: a driver or repo
|
|
344
|
+
// error writes its fix from the statement, the row or the path it failed on, so serving it
|
|
345
|
+
// under a blanked cause put back what the blanking took out. A declared `callerFix` is
|
|
346
|
+
// authored FOR the caller and still goes; otherwise the line names the code and nothing else.
|
|
347
|
+
fix:
|
|
348
|
+
meta.dev === true
|
|
349
|
+
? facts.fix
|
|
350
|
+
: (facts.callerFix ?? (hidden ? explainFix(facts.code) : facts.fix)),
|
|
335
351
|
docs: facts.docs,
|
|
336
352
|
requestId: meta.requestId,
|
|
337
353
|
...(issues === undefined ? {} : { issues }),
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// One slice of the status table `error-map.ts` composes: `@ultimat3/http`'s own codes.
|
|
2
|
+
// A row here is a code owned by `@ultimat3/http`; `bun run new-error-code` appends to the slice its
|
|
3
|
+
// `--package` belongs to, so no one file grows with every code the framework adds.
|
|
4
|
+
|
|
5
|
+
export const HTTP_ERROR_STATUS = {
|
|
6
|
+
// @ultimat3/http
|
|
7
|
+
X_ROUTE_NOT_FOUND: 404,
|
|
8
|
+
X_METHOD_NOT_ALLOWED: 405,
|
|
9
|
+
// The request line itself is unreadable, so there is nothing to route: 400, and never a 500 —
|
|
10
|
+
// a malformed escape is the caller's typo, not this server's defect.
|
|
11
|
+
X_PATH_INVALID: 400,
|
|
12
|
+
X_BODY_INVALID: 422,
|
|
13
|
+
X_UNAUTHENTICATED: 401,
|
|
14
|
+
X_FORBIDDEN: 403,
|
|
15
|
+
X_RATE_LIMITED: 429,
|
|
16
|
+
X_BUILD_SKEW: 409,
|
|
17
|
+
X_ROUTE_CONFLICT: 500,
|
|
18
|
+
X_SERVER_NOT_STARTED: 500,
|
|
19
|
+
X_PIPELINE_NO_RESPONSE: 500,
|
|
20
|
+
// The request was answered and the answer could not be finished: the caller gets nothing usable
|
|
21
|
+
// either way, so this is the server's failure, never the caller's.
|
|
22
|
+
X_PIPELINE_FINALIZE_FAILED: 500,
|
|
23
|
+
// Both are wiring bugs, never a caller's mistake: reading a cookie where no request exists,
|
|
24
|
+
// and declaring a status the framework already owns. 500 is the honest answer to either.
|
|
25
|
+
X_NO_REQUEST: 500,
|
|
26
|
+
X_ERROR_STATUS_INVALID: 500,
|
|
27
|
+
X_PROBLEM_META_INVALID: 500,
|
|
28
|
+
// A `hive()` whose `split()` returned no members. The caller cannot fix it by sending
|
|
29
|
+
// different input — the guard belongs in the app, either by returning at least one member
|
|
30
|
+
// or by skipping the hive when the source is empty — so it is the server's bug, not theirs.
|
|
31
|
+
X_HIVE_EMPTY: 500,
|
|
32
|
+
// Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
|
|
33
|
+
// because a code with no status is a 500 anyway and this table is the closed one.
|
|
34
|
+
X_CORS_CONFIG_INVALID: 500,
|
|
35
|
+
// Core's code, borrowed by `defineHttpConfig` for a numeric knob that is not a count. Same
|
|
36
|
+
// construction-time shelf as the row above, and it needs a row for the same reason: this table
|
|
37
|
+
// is closed over every code the package can throw, borrowed ones included.
|
|
38
|
+
X_CONFIG_INVALID: 500,
|
|
39
|
+
X_CSP_DIRECTIVE_INVALID: 500,
|
|
40
|
+
// Thrown while the server is being constructed, so no request is ever answered with it either.
|
|
41
|
+
// The row exists because this table is the closed one: a code missing from it is a 500 anyway,
|
|
42
|
+
// and a code the framework owns must never fall through to the app's table.
|
|
43
|
+
X_RATE_LIMIT_NOT_SHARED: 500,
|
|
44
|
+
// Same construction-time class as the row above: a route and the config declare one bucket
|
|
45
|
+
// differently, and the process refuses to start rather than pick.
|
|
46
|
+
X_RATE_LIMIT_BUCKET_CONFLICT: 500,
|
|
47
|
+
// Construction time as well: the limiter installed cannot enforce a bucket a route declares.
|
|
48
|
+
X_RATE_LIMIT_BUCKET_UNBOUND: 500,
|
|
49
|
+
// `defineHttpConfig` time, all three: a declaration the deployment owes and did not make, or
|
|
50
|
+
// made against a bucket nothing declares.
|
|
51
|
+
X_RATE_LIMIT_SCOPE_UNSET: 500,
|
|
52
|
+
X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 500,
|
|
53
|
+
X_TRUST_PROXY_UNSET: 500,
|
|
54
|
+
// Raised by `toBucket` while a route or an action is being projected, never on the request.
|
|
55
|
+
X_RATE_LIMIT_INVALID: 500,
|
|
56
|
+
// The shared store did not answer, so nothing decided. An operator's fault, never the caller's.
|
|
57
|
+
X_RATE_LIMIT_STORE_UNAVAILABLE: 500,
|
|
58
|
+
// The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry
|
|
59
|
+
// `retry-after`: a shed request that does not say when to come back is a request that comes
|
|
60
|
+
// back immediately, which is the load it was shed to avoid.
|
|
61
|
+
X_DRAINING: 503,
|
|
62
|
+
X_OVERLOADED: 503,
|
|
63
|
+
// Core's flight gate refusing past `maxQueued` is the same answer one tier down: it carries
|
|
64
|
+
// `retryAfterSeconds` in `meta`, which `stages.ts` renders onto `Retry-After`, so a caller that
|
|
65
|
+
// already handles a shed request needs no second branch for a refused one.
|
|
66
|
+
X_FLIGHT_GATE_OVERLOADED: 503,
|
|
67
|
+
// 403 and never 401: the caller IS authenticated — that is what makes the forged write work —
|
|
68
|
+
// so a 401 would send a signed-in user to a sign-in page they are already past.
|
|
69
|
+
X_CSRF_BLOCKED: 403,
|
|
70
|
+
// 401 for both, and never 400: an inbound webhook is well formed and carries a CREDENTIAL — a
|
|
71
|
+
// timestamped hmac over its own bytes — so what failed is authentication, not the request. Never
|
|
72
|
+
// 403 either, which means an authenticated caller was refused, and there is no authenticated
|
|
73
|
+
// caller here. Two codes rather than one because the repairs differ and a sender's dashboard
|
|
74
|
+
// shows the status: `INVALID` is the wrong secret or a rewritten body, `STALE` is a skewed clock
|
|
75
|
+
// or a delivery being replayed off a capture. Neither triggers `signInRedirect`, which keys on
|
|
76
|
+
// `X_UNAUTHENTICATED` alone — a webhook sender is not a browser and has no session to go get.
|
|
77
|
+
X_WEBHOOK_SIGNATURE_INVALID: 401,
|
|
78
|
+
X_WEBHOOK_SIGNATURE_STALE: 401,
|
|
79
|
+
// Boot refusals of the app's HTTP declaration (a bearer mount, an action's pinned path or the
|
|
80
|
+
// app's path style or a path derived before it, the openapi block, the MCP oauth block or an
|
|
81
|
+
// idempotent tool shadowing its key argument, a page's `post` or `navigation`) — never a request's
|
|
82
|
+
// answer; 500 if one ever escaped into a response (`x dev` re-evaluating an edited route module):
|
|
83
|
+
// the deployment is wrong, the caller is not.
|
|
84
|
+
X_BEARER_MOUNT_INVALID: 500,
|
|
85
|
+
X_ACTION_HTTP_PATH_INVALID: 500,
|
|
86
|
+
X_ACTION_PATH_STYLE_INVALID: 500,
|
|
87
|
+
X_ACTION_PATH_DERIVED_EARLY: 500,
|
|
88
|
+
X_OPENAPI_CONFIG_INVALID: 500,
|
|
89
|
+
X_MCP_OAUTH_INVALID: 500,
|
|
90
|
+
X_MCP_PATH_DUPLICATE: 500,
|
|
91
|
+
X_MCP_IDEMPOTENCY_KEY_SHADOWED: 500,
|
|
92
|
+
X_ROUTE_POST_INVALID: 500,
|
|
93
|
+
X_ROUTE_NAVIGATION_INVALID: 500,
|
|
94
|
+
} satisfies Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// One slice of the status table `error-map.ts` composes: tier 0 — `core`, `schema`.
|
|
2
|
+
// A row here is a code owned by a tier-0 package; `bun run new-error-code` appends to the slice its
|
|
3
|
+
// `--package` belongs to, so no one file grows with every code the framework adds.
|
|
4
|
+
|
|
5
|
+
export const TIER_0_ERROR_STATUS = {
|
|
6
|
+
// @ultimat3/core (declared beside `assertLocale`; `@ultimat3/time` owned it until 16.x) — the
|
|
7
|
+
// sibling of the row above, and strictly more the caller's fault: not a tag at all. It was
|
|
8
|
+
// pinned as unable to reach a request because the `locale` stage negotiates and never throws,
|
|
9
|
+
// which is true of that stage and irrelevant to `?locale=`, a path segment or an action input
|
|
10
|
+
// reaching `formatDate` / `formatMoney` / `describeCron`. Those raised it and answered 500,
|
|
11
|
+
// paging the on-call for a string the caller typed.
|
|
12
|
+
X_LOCALE_INVALID: 400,
|
|
13
|
+
// @ultimat3/core — `seal()` / `open()`. All three are reachable from a request (a sealed column
|
|
14
|
+
// read inside an action) and none is the caller's doing: the deploy has no key, the ring lost a
|
|
15
|
+
// retired key, or a stored value did not authenticate. 500, stated rather than defaulted.
|
|
16
|
+
X_SEAL_INVALID: 500,
|
|
17
|
+
X_SEAL_KEY_MISSING: 500,
|
|
18
|
+
X_SEAL_KEY_UNKNOWN: 500,
|
|
19
|
+
// @ultimat3/core
|
|
20
|
+
// The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an
|
|
21
|
+
// unsupported representation, which is 415 — not a 500, which would blame the server for it.
|
|
22
|
+
X_IMAGE_UNSUPPORTED: 415,
|
|
23
|
+
// A page token this server minted and the caller echoed back, and it did not verify — tampered,
|
|
24
|
+
// or replayed against another read. The caller's value and the caller's repair ("request the
|
|
25
|
+
// first page again"), so it belongs beside `X_IMAGE_QUERY_INVALID` at 400 and not at 500.
|
|
26
|
+
X_CURSOR_INVALID: 400,
|
|
27
|
+
// Raised by `markReady()` while a role STARTS, in a process whose lifecycle already drained — so
|
|
28
|
+
// no request is ever answered with it, and the row exists for the reason the construction-time
|
|
29
|
+
// rows above do: this table is the closed one, and a code with no row is a 500 anyway.
|
|
30
|
+
X_LIFECYCLE_DRAINED: 500,
|
|
31
|
+
X_NOT_IMPLEMENTED: 501,
|
|
32
|
+
X_TIMEOUT: 504,
|
|
33
|
+
X_ABORTED: 499,
|
|
34
|
+
// The twin of `X_ABORTED`, and it answers the same because the outcome is the same: the caller
|
|
35
|
+
// went away there, the caller's generation moved on here, and in both cases nobody will act on
|
|
36
|
+
// the answer. Deliberately not 409 — that spelling asks the client to reconcile and try again,
|
|
37
|
+
// and a fenced answer has nothing to reconcile against.
|
|
38
|
+
X_SUPERSEDED: 499,
|
|
39
|
+
// Plan 101's client seam. A typed call made from a server handler: an upstream that gave no usable
|
|
40
|
+
// answer is 502, the fence is `X_SUPERSEDED`'s twin. The rest are browser-side or declaration-time
|
|
41
|
+
// refusals no request carries; their rows keep the table closed.
|
|
42
|
+
X_CLIENT_TRANSPORT_FAILED: 502,
|
|
43
|
+
X_CLIENT_RECORD_ENVELOPE_INVALID: 502,
|
|
44
|
+
X_CLIENT_SCOPE_CHANGED: 499,
|
|
45
|
+
X_CHANNEL_DECLARATION_INVALID: 500,
|
|
46
|
+
X_LOCAL_STORE_UNAVAILABLE: 500,
|
|
47
|
+
X_MUTATOR_CLOCK_MISSING: 500,
|
|
48
|
+
X_REALTIME_UNINSTALLED: 500,
|
|
49
|
+
// Boot-time: a realtime config and its environment that disagree refuse before any request.
|
|
50
|
+
X_REALTIME_TOPOLOGY: 500,
|
|
51
|
+
// The replicator's own Postgres connection failed TLS. A role that never answers a request, so the
|
|
52
|
+
// row keeps the table closed at 500, beside `X_REALTIME_TOPOLOGY`.
|
|
53
|
+
X_REPLICATION_TLS: 500,
|
|
54
|
+
// A browser page on a foreign origin asked for a socket — refused, not unauthenticated: 403.
|
|
55
|
+
X_SOCKET_ORIGIN_REFUSED: 403,
|
|
56
|
+
X_RECORD_KEY_MISSING: 500,
|
|
57
|
+
X_RECORD_REJECTED: 500,
|
|
58
|
+
X_SYNC_UNCONFIGURED: 500,
|
|
59
|
+
X_INTERNAL: 500,
|
|
60
|
+
// @ultimat3/core — a Set-Cookie value would be dropped, misread or injectable
|
|
61
|
+
X_COOKIE_INVALID: 500,
|
|
62
|
+
} satisfies Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// One slice of the status table `error-map.ts` composes: tier 1 — `i18n`, `money`, `time`, `cache`, `seo`, `db`, `storage`, `flags`.
|
|
2
|
+
// A row here is a code owned by a tier-1 package; `bun run new-error-code` appends to the slice its
|
|
3
|
+
// `--package` belongs to, so no one file grows with every code the framework adds.
|
|
4
|
+
|
|
5
|
+
export const TIER_1_ERROR_STATUS = {
|
|
6
|
+
// @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique
|
|
7
|
+
// violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the
|
|
8
|
+
// same event one layer up — is 409 above; a foreign key rides with it because both halves of it
|
|
9
|
+
// are a conflict with the state that is there (the parent is missing, or the child still points
|
|
10
|
+
// at it), which 422 describes only for the insert.
|
|
11
|
+
X_DB_UNIQUE_VIOLATION: 409,
|
|
12
|
+
X_DB_FOREIGN_KEY_VIOLATION: 409,
|
|
13
|
+
// @ultimat3/i18n — a well-formed tag outside the set this app ships, asserted on a value the
|
|
14
|
+
// caller supplied (`assertSupportedLocale`). 400 rather than 406: the http `locale` stage
|
|
15
|
+
// negotiates `Accept-Language` and never throws, so the tag that reaches here came from a path,
|
|
16
|
+
// query or body the caller wrote — the same place `X_IMAGE_QUERY_INVALID` comes from.
|
|
17
|
+
X_LOCALE_UNSUPPORTED: 400,
|
|
18
|
+
// @ultimat3/money — a well-formed code this process carries no row for. The currency table is
|
|
19
|
+
// OPEN (`registerCurrency`), and every surface between the wire and the throw accepts any
|
|
20
|
+
// `^[A-Z]{3}$`: `@ultimat3/schema`'s `CURRENCY_CODE_PATTERN`, the OpenAPI `pattern` emitted from
|
|
21
|
+
// it, and `@ultimat3/entity`'s `char(3)` CHECK. So `{ minor: 100, currency: 'ZWL' }` parses,
|
|
22
|
+
// reaches `money()` -> `assertCurrency`, and with no row answered 500 — reporting a value the
|
|
23
|
+
// framework's own schema had just accepted to the error monitor. 400 rather than 422, beside
|
|
24
|
+
// `X_LOCALE_UNSUPPORTED`: the same shape of mistake, a well-formed value naming something
|
|
25
|
+
// outside the set this process carries, and money's `fix:` already instructs the caller.
|
|
26
|
+
X_CURRENCY_UNKNOWN: 400,
|
|
27
|
+
// @ultimat3/seo — a transform query the caller wrote, so the caller is the one who can fix it.
|
|
28
|
+
X_IMAGE_QUERY_INVALID: 400,
|
|
29
|
+
// @ultimat3/storage — every one of these is reachable from a route: `/media/*` already serves
|
|
30
|
+
// objects, and a mounted `/_storage` serves signed reads and takes signed writes. Without a row
|
|
31
|
+
// a missing image answers 500, which reads as an outage instead of a 404.
|
|
32
|
+
X_STORAGE_NOT_FOUND: 404,
|
|
33
|
+
X_STORAGE_PATH_UNSAFE: 400,
|
|
34
|
+
X_STORAGE_TOO_LARGE: 413,
|
|
35
|
+
X_STORAGE_TYPE_REJECTED: 415,
|
|
36
|
+
X_STORAGE_CHECKSUM_MISMATCH: 422,
|
|
37
|
+
X_STORAGE_URL_INVALID: 403,
|
|
38
|
+
X_STORAGE_URL_EXPIRED: 410,
|
|
39
|
+
// 500, not 403, and the distinction is the whole reason this code exists rather than
|
|
40
|
+
// reusing X_STORAGE_URL_INVALID: nothing is wrong with the caller's URL. The disk was
|
|
41
|
+
// built with no way to check a signature, which is the operator's misconfiguration and
|
|
42
|
+
// not an attacker — reporting it as 403 sends the on-call hunting somebody who is not there.
|
|
43
|
+
X_STORAGE_URL_UNVERIFIABLE: 500,
|
|
44
|
+
// 409, not the 500 it fell through to: the object exists and the request is well formed — the
|
|
45
|
+
// STATE is wrong. A validated upload lands under the quarantine segment and `promoteAttachment`
|
|
46
|
+
// refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
|
|
47
|
+
// can do. A 500 would have read as "the server broke" for a workflow working exactly as built.
|
|
48
|
+
X_STORAGE_QUARANTINED: 409,
|
|
49
|
+
// 409 for the same reason: `promoteAttachment` on a key that is not a pending upload — promoted
|
|
50
|
+
// twice, or never uploaded — is a state the caller can see and fix, not a server fault.
|
|
51
|
+
X_STORAGE_NOT_PENDING: 409,
|
|
52
|
+
// 404, deliberately NOT 403: the org check fires before anything is read, so answering
|
|
53
|
+
// "forbidden" would confirm that a key exists to the one caller who must not learn it.
|
|
54
|
+
X_STORAGE_ORG_MISMATCH: 404,
|
|
55
|
+
// @ultimat3/seo — a `meta.links` entry an `ssr` page's `meta()` built from data: an unsafe href,
|
|
56
|
+
// a preload with no `as`. Thrown while that request renders its head, so it reaches the caller —
|
|
57
|
+
// an authoring defect, never the visitor's, hence 500 with the cause naming the link intact.
|
|
58
|
+
X_SEO_LINK_INVALID: 500,
|
|
59
|
+
// @ultimat3/db — the server rolled the transaction back
|
|
60
|
+
X_DB_TRANSACTION_ABORTED: 500,
|
|
61
|
+
// @ultimat3/db — the connection was lost while COMMIT was in flight
|
|
62
|
+
X_DB_COMMIT_UNKNOWN: 500,
|
|
63
|
+
// @ultimat3/db — a nested transaction scope waited too long for its sibling
|
|
64
|
+
X_DB_SIBLING_SCOPE_TIMEOUT: 500,
|
|
65
|
+
// @ultimat3/storage — the key collides with another key on the local disk
|
|
66
|
+
X_STORAGE_KEY_CONFLICT: 409,
|
|
67
|
+
// @ultimat3/storage — the object could not be written
|
|
68
|
+
X_STORAGE_PUT_FAILED: 500,
|
|
69
|
+
// @ultimat3/storage — the object could not be read
|
|
70
|
+
X_STORAGE_READ_FAILED: 500,
|
|
71
|
+
// @ultimat3/storage — the object is under retention or a legal hold
|
|
72
|
+
X_STORAGE_OBJECT_LOCKED: 409,
|
|
73
|
+
} satisfies Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// One slice of the status table `error-map.ts` composes: tier 2 but `http` itself — `entity`, `policy`, `auth`.
|
|
2
|
+
// A row here is a code owned by `entity`, `policy` or `auth`; `bun run new-error-code` appends to the slice its
|
|
3
|
+
// `--package` belongs to, so no one file grows with every code the framework adds.
|
|
4
|
+
|
|
5
|
+
export const TIER_2_ERROR_STATUS = {
|
|
6
|
+
// @ultimat3/auth — every one of these is reachable from a request: the OAuth route descriptors
|
|
7
|
+
// are mounted by the app, and `authenticate` throws the session codes inside the pipeline. Without
|
|
8
|
+
// a row each fell to 500, so a user pressing Cancel on a consent screen paged the on-call and a
|
|
9
|
+
// provider this app never enabled read as an outage. `packages/auth/src/oauth-route.ts` answers
|
|
10
|
+
// from this table's values when its descriptors are driven OUTSIDE a pipeline; the pin that keeps
|
|
11
|
+
// the two identical is `scripts/oauth-route-status.test.ts`, since auth is this tier and cannot
|
|
12
|
+
// import this package.
|
|
13
|
+
X_SESSION_EXPIRED: 401,
|
|
14
|
+
X_MFA_REQUIRED: 401,
|
|
15
|
+
X_ACCOUNT_LOCKED: 429,
|
|
16
|
+
X_API_KEY_INVALID: 401,
|
|
17
|
+
X_OAUTH_STATE_INVALID: 400,
|
|
18
|
+
X_OAUTH_TOKEN_INVALID: 400,
|
|
19
|
+
X_OAUTH_PROVIDER_UNKNOWN: 404,
|
|
20
|
+
X_OAUTH_DENIED: 403,
|
|
21
|
+
// 502, not 500: the conversation that failed is with the provider's server, and the on-call
|
|
22
|
+
// question "is it us or them?" is the one a status is read for.
|
|
23
|
+
X_OAUTH_EXCHANGE_FAILED: 502,
|
|
24
|
+
// 422 and not 400: the body parsed, the field is a string, and a policy rejected its CONTENT —
|
|
25
|
+
// the same class as `X_BODY_INVALID` and `X_INVARIANT_VIOLATED` above. Unmapped, a visitor
|
|
26
|
+
// choosing "password" at a signup form was reported to the on-call monitor as a server fault.
|
|
27
|
+
X_PASSWORD_WEAK: 422,
|
|
28
|
+
// 422 for the same reason as the row above, and deliberately not 500: `enrolTotp` throws it for a
|
|
29
|
+
// secret the CALLER supplied — an import from another MFA system, or a value off a form — and
|
|
30
|
+
// what failed is that value's content, not the server. `verifyTotp` never throws it (an
|
|
31
|
+
// unreadable stored secret is a non-verdict there, the rule `verifyAgainst` follows for a hash
|
|
32
|
+
// Bun cannot read), so a login checking a broken row cannot reach this status at all.
|
|
33
|
+
X_MFA_SECRET_INVALID: 422,
|
|
34
|
+
// @ultimat3/entity
|
|
35
|
+
X_NOT_FOUND: 404,
|
|
36
|
+
X_ENTITY_DUPLICATE: 409,
|
|
37
|
+
X_INVARIANT_VIOLATED: 422,
|
|
38
|
+
X_TENANCY_UNSCOPED: 500,
|
|
39
|
+
X_DB_DRIFT: 500,
|
|
40
|
+
// Both are the server's verdict on a statement the app wrote, which is the app's fault and not
|
|
41
|
+
// the caller's: a request cannot repair a missing migration or a wrong column list.
|
|
42
|
+
X_DB_SCHEMA_STALE: 500,
|
|
43
|
+
X_DB_STATEMENT_FAILED: 500,
|
|
44
|
+
// Logged at boot, never thrown into a request; the row exists so the table stays closed.
|
|
45
|
+
X_MCP_APP_UNMOUNTED: 500,
|
|
46
|
+
// The three tenancy refusals, all 403, and all deliberately NOT the 404 `X_STORAGE_ORG_MISMATCH`
|
|
47
|
+
// takes: that one answers 404 because a 403 on a KEY the caller supplied confirms the key exists.
|
|
48
|
+
// These three name no resource and read no row — the comparison is the actor against an argument
|
|
49
|
+
// (`X_TENANCY_ACTOR_MISMATCH`), the actor against nothing at all (`X_TENANCY_ACTOR_ORG_REQUIRED`),
|
|
50
|
+
// or the actor's scopes at `crossTenant()` (`X_TENANCY_CROSS_DENIED`) — so the answer is the same
|
|
51
|
+
// whether or not the other tenant's row exists, and a 404 would buy no secrecy for the lie.
|
|
52
|
+
X_TENANCY_ACTOR_MISMATCH: 403,
|
|
53
|
+
// 403 and never 401, for the reason `X_CSRF_BLOCKED` is one: the actor may be fully
|
|
54
|
+
// authenticated and merely carry no org — a service actor minted without one — so a 401 sends a
|
|
55
|
+
// signed-in caller to a sign-in page that cannot give them a tenant.
|
|
56
|
+
X_TENANCY_ACTOR_ORG_REQUIRED: 403,
|
|
57
|
+
X_TENANCY_CROSS_DENIED: 403,
|
|
58
|
+
// The three aggregate refusals, all 500 and all deliberately NOT a 4xx, for the reason
|
|
59
|
+
// `X_QUERY_NOT_PAGEABLE` below is one: nothing the caller sends changes the answer, and the fix
|
|
60
|
+
// is an edit to the read itself. They earn ROWS rather than a pin in `scripts/error-map-backlog.ts`
|
|
61
|
+
// because each carries an instruction the app's author needs and an unmapped 5xx is blanked —
|
|
62
|
+
// `toProblem` replaces an undeclared code's cause with `INTERNAL_CAUSE`, so pinning them would
|
|
63
|
+
// answer "the server failed while handling this request" for a fault whose own `fix:` names the
|
|
64
|
+
// exact call to write instead. A row costs no extra page: `stages.ts` reports every `status >= 500`
|
|
65
|
+
// either way.
|
|
66
|
+
//
|
|
67
|
+
// Reached with a request waiting, all three, which is why they are not in the backlog's entity
|
|
68
|
+
// group ("misuse a handler's author makes") — the mixed-currency and the ±2^53 refusals are
|
|
69
|
+
// decided by the ROWS, so a read that answered for two years starts failing on the day the data
|
|
70
|
+
// crosses the line, and `approximateCount()` on a chain whose predicates came from the caller's
|
|
71
|
+
// own optional filters is one query string away.
|
|
72
|
+
X_AGGREGATE_UNSUPPORTED: 500,
|
|
73
|
+
X_AGGREGATE_MIXED_CURRENCY: 500,
|
|
74
|
+
X_APPROXIMATE_COUNT_FILTERED: 500,
|
|
75
|
+
// The two search refusals, 500 for the reason the three above are: nothing the caller sends
|
|
76
|
+
// changes either answer. An entity with no searchable column needs a `searchable()` on one, and
|
|
77
|
+
// a driver that cannot answer a full-text match needs the Postgres one — both are edits to the
|
|
78
|
+
// app, and both carry a `fix:` that an unmapped 5xx would blank (`toProblem` replaces an
|
|
79
|
+
// undeclared code's cause with `INTERNAL_CAUSE`).
|
|
80
|
+
X_SEARCH_UNDECLARED: 500,
|
|
81
|
+
X_SEARCH_IN_MEMORY: 500,
|
|
82
|
+
// The three state-machine refusals, and they are deliberately THREE statuses rather than one:
|
|
83
|
+
// the machine says the transition does not exist, the row says it is somewhere else, or the
|
|
84
|
+
// column says there is no machine at all — three different readers and three different repairs.
|
|
85
|
+
//
|
|
86
|
+
// 422 and not 400: the request is well formed and its schema passed. The transition the caller
|
|
87
|
+
// named is not one this machine has, which is the same shape as `X_INVARIANT_VIOLATED` above and
|
|
88
|
+
// takes its status. Refused before any statement opens a connection, so nothing was written.
|
|
89
|
+
X_STATE_TRANSITION_ILLEGAL: 422,
|
|
90
|
+
// 409, the lost update caught. The row moved between the read the caller decided on and the
|
|
91
|
+
// write it asked for — nothing is wrong with either, and the repair is re-read and retry, which
|
|
92
|
+
// is precisely what a 409 tells a client to do. A 422 would say "your request is unusable",
|
|
93
|
+
// which is false: the identical request succeeds a moment later.
|
|
94
|
+
X_STATE_CONFLICT: 409,
|
|
95
|
+
// 500, the same shelf as `X_SEARCH_UNDECLARED`: a column with no machine is a declaration the
|
|
96
|
+
// app has not written, and no request changes that.
|
|
97
|
+
X_STATE_UNDECLARED: 500,
|
|
98
|
+
// A sealed column named where the database would compare it, or in a view. Both are the app's
|
|
99
|
+
// own declaration or call, never the caller's input — the same shelf as the two above.
|
|
100
|
+
X_ENTITY_SEALED_PREDICATE: 500,
|
|
101
|
+
X_ENTITY_SEALED_IN_VIEW: 500,
|
|
102
|
+
// @ultimat3/policy
|
|
103
|
+
X_POLICY_MISSING: 500,
|
|
104
|
+
// A declaration fault raised at module evaluation, the same shelf as the line above. The row is
|
|
105
|
+
// not a claim it reaches a request — an unmapped code already answers 500 — it is the answer
|
|
106
|
+
// being REVIEWED instead of accidental, which is why this table is closed.
|
|
107
|
+
X_POLICY_CLAUSE_EMPTY: 500,
|
|
108
|
+
X_PERMISSION_UNKNOWN: 500,
|
|
109
|
+
// 500, and the page IS the point — deliberately not a 4xx to keep this table quiet.
|
|
110
|
+
// `enforce()` was handed a surface no adapter answers to, which reaches a request only through a
|
|
111
|
+
// config-driven route table, a surface name off the wire or a JS host; none of those is a value
|
|
112
|
+
// the caller can correct, and a 400 would tell them to fix a request that is not the problem.
|
|
113
|
+
// It is the third authz-dispatch fault beside the two rows above and takes their status for the
|
|
114
|
+
// same reason: the declaration is wrong, not the call.
|
|
115
|
+
X_POLICY_SURFACE_UNKNOWN: 500,
|
|
116
|
+
// @ultimat3/auth — a stored totp secret is not sealed, so it is never read as one
|
|
117
|
+
X_MFA_SECRET_UNSEALED: 500,
|
|
118
|
+
// @ultimat3/entity — an append-only entity was asked to rewrite or remove a row
|
|
119
|
+
X_ENTITY_APPEND_ONLY: 409,
|
|
120
|
+
} satisfies Readonly<Record<string, number>>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// One slice of the status table `error-map.ts` composes: tier 3 — `action`, `query`, `jobs`, `realtime`.
|
|
2
|
+
// A row here is a code owned by a tier-3 package; `bun run new-error-code` appends to the slice its
|
|
3
|
+
// `--package` belongs to, so no one file grows with every code the framework adds.
|
|
4
|
+
|
|
5
|
+
export const TIER_3_ERROR_STATUS = {
|
|
6
|
+
// @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema
|
|
7
|
+
// the primitive declared. 400 because that is what the published OpenAPI operation promises for
|
|
8
|
+
// it, and because a missing row made a typo'd uuid a 500: the caller was told the server broke,
|
|
9
|
+
// and the `error-map` stage reported the caller's mistake to the on-call monitor.
|
|
10
|
+
X_INPUT_INVALID: 400,
|
|
11
|
+
// The one way a SERVER answers this code: a POST to the path an action has under a path style
|
|
12
|
+
// this app does not serve (`hooks.explainMiss`). 404 because it is still a miss — nothing is
|
|
13
|
+
// routed there and nothing ran, so the status a proxy or a monitor counts is the one the plain
|
|
14
|
+
// `X_ROUTE_NOT_FOUND` had; the code and the `fix:` are what changed. Every other raise of it is
|
|
15
|
+
// client-side or a contract test, and never reaches this table.
|
|
16
|
+
X_CONTRACT_DRIFT: 404,
|
|
17
|
+
// A retried `Idempotency-Key` naming a different payload, or one still in flight. 409 because
|
|
18
|
+
// that is what the action's own OpenAPI operation publishes for it — the runtime answered 500
|
|
19
|
+
// while the document promised 409, and a client written against the spec read the framework
|
|
20
|
+
// working exactly as designed as an outage.
|
|
21
|
+
X_IDEMPOTENCY_CONFLICT: 409,
|
|
22
|
+
// A blank or over-long `Idempotency-Key` HEADER, refused before the handler runs. 400 and not
|
|
23
|
+
// the 422 a body gets: what failed is a parameter the OpenAPI operation publishes a `maxLength`
|
|
24
|
+
// for, which is the same thing `X_INPUT_INVALID` is 400 for.
|
|
25
|
+
X_IDEMPOTENCY_KEY_INVALID: 400,
|
|
26
|
+
// 500, and deliberately not the 409 above. `IdempotencyReplayedFailureError` re-throws the FIRST
|
|
27
|
+
// attempt's own code whenever the store recorded one, so this literal code is reached only when
|
|
28
|
+
// that attempt failed carrying no code at all: an unclassified throw whose commit state nobody
|
|
29
|
+
// knows. That is the server's to explain, and it is worth reporting.
|
|
30
|
+
X_IDEMPOTENCY_REPLAYED_FAILURE: 500,
|
|
31
|
+
// Same shape as the line above and 500 for the same reason: the store holds a record this
|
|
32
|
+
// build cannot turn into a result. Deliberately NOT 503 — a rolling deploy is the usual
|
|
33
|
+
// cause, so a retry may well reach a newer pod and succeed, but this code carries no
|
|
34
|
+
// `retry-after` and the two 503s above are the only ones that do. Telling a caller to come
|
|
35
|
+
// back without saying when is the load-shedding mistake, one layer up.
|
|
36
|
+
X_IDEMPOTENCY_STATUS_UNKNOWN: 500,
|
|
37
|
+
// @ultimat3/jobs — the ONE jobs code with a row here, and the reason the rest are pinned in
|
|
38
|
+
// `scripts/error-map-backlog.ts` does not cover it. That pin says "a job runs with no socket
|
|
39
|
+
// attached; `ROLE=worker` opens no HTTP port at all" — true of `X_JOB_TIMEOUT` and every other
|
|
40
|
+
// worker-runtime code, and NOT true of a decode failure: `toJobRecord` runs wherever a row is
|
|
41
|
+
// READ, which includes the admin dashboard's job panel and `x jobs show` served over HTTP.
|
|
42
|
+
// 500, and it should page: a queue holding rows this build cannot read is an operator's
|
|
43
|
+
// problem, and nothing the caller sent is wrong.
|
|
44
|
+
X_JOB_ROW_STATUS_UNKNOWN: 500,
|
|
45
|
+
// A requeue of a job that is still live — the admin panel's retry, `x jobs retry` over HTTP. The
|
|
46
|
+
// job's STATE is wrong, not the server: 409, like `X_STORAGE_QUARANTINED`.
|
|
47
|
+
X_JOB_NOT_REQUEUEABLE: 409,
|
|
48
|
+
// Thrown by `job()` at declaration, while modules load; no request is answered with it. The row
|
|
49
|
+
// exists for `X_ACTION_JOB_UNBRIDGED`'s reason: the table is closed, and it is a 500 anyway.
|
|
50
|
+
X_JOB_DECLARATION_INVALID: 500,
|
|
51
|
+
// Thrown by `registerJobs()` while the app's modules load, so no request is ever answered with
|
|
52
|
+
// it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
|
|
53
|
+
// closed one, and a code with no row is a 500 anyway.
|
|
54
|
+
X_ACTION_JOB_UNBRIDGED: 500,
|
|
55
|
+
// Every `X_WEBHOOK_*` below is OUTBOUND and is thrown inside a worker: `ROLE=worker` opens no
|
|
56
|
+
// HTTP port, so none of them ever answers a request. The rows exist for the reason
|
|
57
|
+
// `X_ACTION_JOB_UNBRIDGED`'s does — this table is the closed one, and a code with no row is a
|
|
58
|
+
// 500 anyway. The INBOUND pair (`X_WEBHOOK_SIGNATURE_*`, 401) is @ultimat3/http's and sits with
|
|
59
|
+
// the rest of this package's codes above; these are the ones a delivery ends on.
|
|
60
|
+
X_WEBHOOK_ENDPOINT_UNKNOWN: 500,
|
|
61
|
+
X_WEBHOOK_ENDPOINT_INVALID: 500,
|
|
62
|
+
X_WEBHOOK_ENDPOINT_DISABLED: 500,
|
|
63
|
+
X_WEBHOOK_EVENT_UNKNOWN: 500,
|
|
64
|
+
X_WEBHOOK_EVENT_INVALID: 500,
|
|
65
|
+
X_WEBHOOK_DELIVERY_FAILED: 500,
|
|
66
|
+
X_WEBHOOK_DELIVERY_THROTTLED: 500,
|
|
67
|
+
X_WEBHOOK_DELIVERY_REJECTED: 500,
|
|
68
|
+
// Same class again: an export pass runs in a worker, and both codes refuse the DECLARATION —
|
|
69
|
+
// a `row()` that answers columns nobody declared, and a page too big to hold. Neither is
|
|
70
|
+
// anything a caller sent.
|
|
71
|
+
X_EXPORT_ROW_INVALID: 500,
|
|
72
|
+
X_EXPORT_PART_TOO_LARGE: 500,
|
|
73
|
+
// @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one
|
|
74
|
+
// paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
|
|
75
|
+
// the client sends changes the answer, and the report to the on-call monitor is the point.
|
|
76
|
+
X_QUERY_NOT_PAGEABLE: 500,
|
|
77
|
+
// A read filtering or sorting on a column its loader never selected: the fix is the loader's
|
|
78
|
+
// `select`, nothing the caller sends changes it — the same server-side decision as the row above.
|
|
79
|
+
X_QUERY_COLUMN_UNSELECTED: 500,
|
|
80
|
+
// @ultimat3/jobs — the job is running and cannot be removed
|
|
81
|
+
X_JOB_NOT_REMOVABLE: 409,
|
|
82
|
+
// @ultimat3/jobs — the job is not waiting on its run time
|
|
83
|
+
X_JOB_NOT_PROMOTABLE: 409,
|
|
84
|
+
// @ultimat3/jobs — a job list page was asked for outside its bounds
|
|
85
|
+
X_JOB_PAGE_INVALID: 400,
|
|
86
|
+
// @ultimat3/action — a mutator is declared without idempotent: true
|
|
87
|
+
X_MUTATOR_NOT_IDEMPOTENT: 500,
|
|
88
|
+
// @ultimat3/action — an idempotent action's reservation was taken over before its transaction could settle it
|
|
89
|
+
X_IDEMPOTENCY_RESERVATION_LOST: 409,
|
|
90
|
+
// @ultimat3/jobs — the queue holds no job with this id
|
|
91
|
+
X_JOB_NOT_FOUND: 404,
|
|
92
|
+
// @ultimat3/realtime — one principal holds too many sockets on this node
|
|
93
|
+
X_SOCKET_LIMIT: 429,
|
|
94
|
+
// @ultimat3/action — an idempotent replay would return a field redacted at rest
|
|
95
|
+
X_IDEMPOTENT_REPLAY_REDACTED: 409,
|
|
96
|
+
// @ultimat3/query — a query declares audit: true and no audit sink is installed
|
|
97
|
+
X_QUERY_AUDIT_SINK_MISSING: 500,
|
|
98
|
+
// @ultimat3/query — an audited read was refused because the audit sink rejected its record
|
|
99
|
+
X_QUERY_AUDIT_SINK_FAILED: 503,
|
|
100
|
+
} satisfies Readonly<Record<string, number>>;
|