@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.
Files changed (54) hide show
  1. package/CLAUDE.md +75 -31
  2. package/README.md +89 -26
  3. package/package.json +9 -6
  4. package/src/app-config.ts +3 -1
  5. package/src/auth-redirect.ts +56 -17
  6. package/src/bearer-mount.ts +81 -26
  7. package/src/cache-policy.ts +10 -2
  8. package/src/config.ts +62 -32
  9. package/src/context.ts +11 -11
  10. package/src/correlation.ts +2 -2
  11. package/src/cors.ts +3 -2
  12. package/src/csrf.ts +17 -8
  13. package/src/deadline.ts +10 -8
  14. package/src/error-facts.ts +18 -2
  15. package/src/error-map-http.ts +94 -0
  16. package/src/error-map-tier-0.ts +62 -0
  17. package/src/error-map-tier-1.ts +73 -0
  18. package/src/error-map-tier-2.ts +120 -0
  19. package/src/error-map-tier-3.ts +100 -0
  20. package/src/error-map-tier-4.ts +116 -0
  21. package/src/error-map.ts +27 -474
  22. package/src/error-page.ts +3 -4
  23. package/src/error-titles.ts +93 -0
  24. package/src/errors.ts +46 -96
  25. package/src/forwarded.ts +36 -10
  26. package/src/health-disclosure.ts +66 -0
  27. package/src/hooks.ts +6 -5
  28. package/src/html-render.ts +3 -17
  29. package/src/index.ts +36 -27
  30. package/src/locale-prefix.ts +5 -1
  31. package/src/locale.ts +0 -27
  32. package/src/navigation.ts +26 -1
  33. package/src/overlay.ts +3 -1
  34. package/src/peer-identity.ts +10 -6
  35. package/src/pipeline.ts +32 -13
  36. package/src/preferences.ts +1 -1
  37. package/src/problem-meta.ts +7 -21
  38. package/src/rate-limit-buckets.ts +26 -2
  39. package/src/rate-limit-errors.ts +36 -10
  40. package/src/rate-limit-headers.ts +40 -0
  41. package/src/rate-limit-installed.ts +79 -0
  42. package/src/rate-limit-peek.ts +32 -0
  43. package/src/rate-limit-postgres.ts +30 -21
  44. package/src/rate-limit-stage.ts +132 -0
  45. package/src/rate-limit.ts +40 -15
  46. package/src/request-facts.ts +44 -0
  47. package/src/request.ts +12 -5
  48. package/src/response.ts +10 -2
  49. package/src/route-cache.ts +1 -1
  50. package/src/router.ts +36 -8
  51. package/src/server.ts +101 -30
  52. package/src/set-cookie.ts +60 -0
  53. package/src/stages.ts +50 -54
  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 { HttpConfig } from './config';
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, re-exported rather than declared twice: this package READS the header and
16
- * `@ultimat3/core`'s typed-client wire path WRITES it, and a second literal is a propagation that
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
- /** The configured budget, or the caller's if theirs is shorter. `0` means "no deadline". */
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
 
@@ -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 { hasPublicCause, type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
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: meta.dev === true ? facts.fix : (facts.callerFix ?? facts.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>>;