@ultimat3/http 24.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.
@@ -13,8 +13,12 @@ import { bearerMountInvalid, routeNotFound } from './errors';
13
13
  import type { RateLimitDecision, RateLimitStore } from './rate-limit';
14
14
  import { memoryRateLimitStore, toBucket } from './rate-limit';
15
15
  import { rateLimited } from './rate-limit-errors';
16
+ import { rateLimitHeaders } from './rate-limit-headers';
16
17
  import type { UltimateRequest } from './request';
18
+ import type { RequestFacts } from './request-facts';
19
+ import { requestFacts } from './request-facts';
17
20
  import type { Route, RouteMeta } from './router';
21
+ import { HTTP_METHODS } from './router';
18
22
 
19
23
  /**
20
24
  * What a token resolves to. Structurally `@ultimat3/mcp`'s `ResolvedToken`, so an app hands the
@@ -26,8 +30,16 @@ export interface BearerCaller {
26
30
  readonly scopes: ReadonlySet<string>;
27
31
  }
28
32
 
29
- /** `null` for a malformed, unknown, revoked or expired token — all one indistinguishable 401. */
30
- export type BearerResolver = (token: string) => Promise<BearerCaller | null> | BearerCaller | null;
33
+ /**
34
+ * `null` for a malformed, unknown, revoked or expired token — all one indistinguishable 401.
35
+ * `facts` is how the request arrived (`RequestFacts`: the pipeline's address, user agent, `Origin`,
36
+ * path) — the same shape `@ultimat3/mcp` hands its resolver, so one resolver serves both. A
37
+ * one-argument resolver is still one.
38
+ */
39
+ export type BearerResolver = (
40
+ token: string,
41
+ facts: RequestFacts,
42
+ ) => Promise<BearerCaller | null> | BearerCaller | null;
31
43
 
32
44
  export interface BearerMountRateLimit {
33
45
  readonly limit: number;
@@ -86,6 +98,9 @@ const tokenKey = (token: string): string =>
86
98
  * The mounted routes. Refuses at construction (`X_BEARER_MOUNT_INVALID`) a prefix that is not a
87
99
  * plain path or would shadow `/api` / `/_x`, a scope naming a primitive no route carries, and two
88
100
  * primitives that would land on one mounted path.
101
+ *
102
+ * The last routes returned are the prefix's catch-all (`<prefix>/*rest`, every method), so no
103
+ * path under the prefix answers differently for being served: see `opaquePrefix`.
89
104
  */
90
105
  export function bearerMount(input: BearerMountInput): readonly Route[] {
91
106
  const { prefix } = input;
@@ -134,20 +149,38 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
134
149
  // concurrent requests could share.
135
150
  const callers = new WeakMap<RequestContext, { caller: BearerCaller; key: string }>();
136
151
 
137
- const authenticate: NonNullable<RouteMeta['authenticate']> = async (request, ctx) => {
138
- const token = bearerTokenOf(request.header('authorization'));
139
- if (token === null) {
140
- ctx.headers.set('www-authenticate', 'Bearer');
141
- return null;
142
- }
143
- const caller = await input.resolveToken(token);
144
- if (caller === null) {
145
- ctx.headers.set('www-authenticate', 'Bearer error="invalid_token"');
146
- return null;
147
- }
148
- callers.set(ctx, { caller, key: tokenKey(token) });
149
- return caller.actor;
150
- };
152
+ // One authenticator per mounted route, closed over ITS scope, so the cut is decided in the `auth`
153
+ // stage — before `body` and `authz`. Decided in the handler, an out-of-scope token was answered
154
+ // 422 with the schema's issues or 403 with the policy's reason, both confirming the primitive.
155
+ // `null` is the catch-all's: a scope no token holds, so a valid token is told 404 exactly where
156
+ // an out-of-scope one is, and a missing or bad one gets the same challenge as on a served path.
157
+ const authenticatorOf =
158
+ (scope: string | null): NonNullable<RouteMeta['authenticate']> =>
159
+ async (request, ctx) => {
160
+ const token = bearerTokenOf(request.header('authorization'));
161
+ if (token === null) {
162
+ ctx.headers.set('www-authenticate', 'Bearer');
163
+ return null;
164
+ }
165
+ // `ctx.ip` is the pipeline's trusted-hop answer — the facts never read a forwarding header.
166
+ const facts = requestFacts({ headers: request.raw.headers, url: ctx.url, address: ctx.ip });
167
+ const caller = await input.resolveToken(token, facts);
168
+ if (caller === null) {
169
+ ctx.headers.set('www-authenticate', 'Bearer error="invalid_token"');
170
+ return null;
171
+ }
172
+ // Hidden, never forbidden: a 403 would confirm the primitive exists to a token that was
173
+ // not issued for it — the enumeration MCP's catalog refuses the same way.
174
+ // And never free: the refusal spends the token's allowance as a served call does (which the
175
+ // handler charges, once, so the two paths never both spend) — else a token walks the cut
176
+ // unmetered, and `RateLimit-*` on one 404 but not another tells hidden from unknown.
177
+ if (scope === null || !caller.scopes.has(scope)) {
178
+ await spend(tokenKey(token), ctx);
179
+ throw routeNotFound(ctx.method, ctx.url.pathname);
180
+ }
181
+ callers.set(ctx, { caller, key: tokenKey(token) });
182
+ return caller.actor;
183
+ };
151
184
 
152
185
  const spend = async (key: string, ctx: RequestContext): Promise<void> => {
153
186
  if (bucket === undefined) return;
@@ -157,12 +190,9 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
157
190
  1,
158
191
  clock.now().getTime(),
159
192
  );
160
- ctx.headers.set('ratelimit-limit', String(decision.limit));
161
- ctx.headers.set('ratelimit-remaining', String(decision.remaining));
162
- ctx.headers.set(
163
- 'ratelimit-reset',
164
- String(Math.max(0, Math.ceil((decision.resetAtMs - clock.now().getTime()) / 1000))),
165
- );
193
+ for (const [name, value] of Object.entries(rateLimitHeaders(decision, clock.now().getTime()))) {
194
+ ctx.headers.set(name, value);
195
+ }
166
196
  // `rateLimited` carries the seconds in `meta`, which the error-map stage turns into
167
197
  // `Retry-After` — the same 429 every other limit in the framework answers.
168
198
  if (!decision.allowed) throw rateLimited(`${prefix}|token`, decision.retryAfterSeconds);
@@ -182,11 +212,11 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
182
212
  mounted.push({
183
213
  method: route.method,
184
214
  path,
185
- meta: { ...route.meta, auth: 'required', authenticate },
215
+ meta: { ...route.meta, auth: 'required', authenticate: authenticatorOf(scope) },
186
216
  handler: async (request: UltimateRequest, ctx: RequestContext) => {
187
217
  const resolved = callers.get(ctx);
188
- // Hidden, never forbidden: a 403 would confirm the primitive exists to a token that
189
- // was not issued for it — the enumeration MCP's catalog refuses the same way.
218
+ // The authenticator already answered an out-of-scope token; this is the fail-closed
219
+ // floor for a handler driven without the `auth` stage.
190
220
  if (resolved === undefined || !resolved.caller.scopes.has(scope)) {
191
221
  throw routeNotFound(ctx.method, ctx.url.pathname);
192
222
  }
@@ -196,5 +226,30 @@ export function bearerMount(input: BearerMountInput): readonly Route[] {
196
226
  });
197
227
  }
198
228
  }
199
- return mounted;
229
+ return [...mounted, ...opaquePrefix(prefix, authenticatorOf(null))];
200
230
  }
231
+
232
+ /** Every method a client can send. HEAD is GET's (`router.ts`'s `routeFor`), never its own route. */
233
+ const CATCH_ALL_METHODS = HTTP_METHODS.filter((method) => method !== 'HEAD');
234
+
235
+ /**
236
+ * `<prefix>/*rest` for every method, behind the mount's own authenticator. Without it a request
237
+ * with no token answered 401 on a served path and 404 — or 405 naming the served methods — on
238
+ * every other, so an anonymous walk of the prefix listed the cut. 401 with the `Bearer` challenge
239
+ * is the uniform answer because it is the TRUE one for a real client anywhere under the prefix:
240
+ * nothing there is reachable without a token. A static or param route still outranks a wildcard,
241
+ * so the mounted routes, and any route the app serves under the prefix itself, keep answering.
242
+ */
243
+ const opaquePrefix = (
244
+ prefix: string,
245
+ authenticate: NonNullable<RouteMeta['authenticate']>,
246
+ ): readonly Route[] =>
247
+ CATCH_ALL_METHODS.map((method) => ({
248
+ method,
249
+ path: `${prefix}/*rest`,
250
+ meta: { name: `bearer-mount:${prefix}`, auth: 'required', authenticate },
251
+ // Reached only if the authenticator did not run: fail closed, with the miss it stands for.
252
+ handler: (_request: UltimateRequest, ctx: RequestContext) => {
253
+ throw routeNotFound(ctx.method, ctx.url.pathname);
254
+ },
255
+ }));
package/src/config.ts CHANGED
@@ -3,11 +3,20 @@
3
3
  // `app.config.ts`, which this file claimed for four majors while `AppConfig` has never carried an
4
4
  // `http` key: an app declares its half through `configureHttp()` (`app-config.ts`) and the boot
5
5
  // lays its own facts over it before calling this.
6
- import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
6
+ import {
7
+ DEFAULT_ENVIRONMENT,
8
+ DEFAULT_HEALTH_DETAIL_PEERS,
9
+ tryResolveEnvironment,
10
+ } from '@ultimat3/core';
7
11
  import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';
8
12
  import { type CsrfConfig, DEFAULT_CSRF } from './csrf';
9
- import { httpCountInvalid, trustProxyUnset } from './errors';
10
- import { assertHealthDetailPeers, DEFAULT_HEALTH_DETAIL_PEERS } from './health-disclosure';
13
+ import {
14
+ buildIdHeaderDeleted,
15
+ drainTimeoutDeleted,
16
+ httpCountInvalid,
17
+ trustProxyUnset,
18
+ } from './errors';
19
+ import { assertHealthDetailPeers } from './health-disclosure';
11
20
  import {
12
21
  DEFAULT_LOCALE_CONFIG,
13
22
  DEFAULT_TZ_CONFIG,
@@ -24,7 +33,6 @@ export interface HttpConfig {
24
33
  readonly basePath: string;
25
34
  /** Build id this process serves; `null` disables skew detection (dev). */
26
35
  readonly buildId: string | null;
27
- readonly buildIdHeader: string;
28
36
  readonly dev: boolean;
29
37
  /**
30
38
  * Where a browser that failed `auth: 'required'` is sent, or `null` to answer it with the
@@ -74,18 +82,6 @@ export interface HttpConfig {
74
82
  * retries multiply the load.
75
83
  */
76
84
  readonly maxInflight: number;
77
- /**
78
- * How long SIGTERM waits for in-flight requests before hard-stopping, or `null` when this app
79
- * has not said and core's own deadline stands.
80
- *
81
- * `null` and not a 15s default, because the two are different claims and only one of them may
82
- * reach `configureLifecycle`. `createServer` applied the resolved number unconditionally, so an
83
- * app that had already written `configureLifecycle({ deadlineMs: 600_000 })` — the edit
84
- * `X_SHUTDOWN_TIMEOUT`'s own `fix:` line prints — had it silently reverted by the next line of
85
- * boot, in every process that serves web. Declaring this key IS declaring the drain budget for
86
- * the whole process; leaving it out is declining to.
87
- */
88
- readonly drainTimeoutMs: number | null;
89
85
  readonly locale: LocaleConfig;
90
86
  readonly tz: TimeZoneConfig;
91
87
  readonly cors: CorsConfig;
@@ -99,7 +95,6 @@ export interface HttpConfigInput {
99
95
  readonly hostname?: string;
100
96
  readonly basePath?: string;
101
97
  readonly buildId?: string | null;
102
- readonly buildIdHeader?: string;
103
98
  readonly dev?: boolean;
104
99
  readonly signInPath?: string | null;
105
100
  readonly trustProxy?: boolean;
@@ -109,7 +104,6 @@ export interface HttpConfigInput {
109
104
  readonly bodyLimitBytes?: number;
110
105
  readonly requestTimeoutMs?: number;
111
106
  readonly maxInflight?: number;
112
- readonly drainTimeoutMs?: number;
113
107
  readonly locale?: Partial<LocaleConfig>;
114
108
  readonly tz?: Partial<TimeZoneConfig>;
115
109
  readonly cors?: Partial<CorsConfig>;
@@ -235,7 +229,22 @@ const resolveTrustedProxyHops = (trustProxy: boolean, declared: number | undefin
235
229
  );
236
230
  };
237
231
 
232
+ /**
233
+ * Keys this config once carried and no longer reads. A typed caller is stopped by
234
+ * `HttpConfigInput` itself; this is for the one that is not — plain JS, a spread of a parsed
235
+ * object, a cast — so a deleted knob is an instruction rather than a number ignored in silence.
236
+ */
237
+ export const refuseDeletedHttpKeys = (input: object): void => {
238
+ // Core's rule for a removed key (`core/src/config-removed.ts`): `undefined` is a layer not
239
+ // saying, so a spread carrying the key unset passes on both surfaces; any other value is written.
240
+ const written = (key: string): boolean =>
241
+ Object.hasOwn(input, key) && (input as Record<string, unknown>)[key] !== undefined;
242
+ if (written('drainTimeoutMs')) throw drainTimeoutDeleted();
243
+ if (written('buildIdHeader')) throw buildIdHeaderDeleted();
244
+ };
245
+
238
246
  export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
247
+ refuseDeletedHttpKeys(input);
239
248
  // `ULTIMATE_ENV` is the framework's one environment key and `NODE_ENV` is only its fallback, so
240
249
  // reading `NODE_ENV` alone made a deployment that declared production the documented way serve
241
250
  // the dev overlay and a report-only CSP. Non-throwing and `?? DEFAULT_ENVIRONMENT`, the same
@@ -272,7 +281,6 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
272
281
  // `undefined` falls back to the environment; an explicit `null` is the declaration that
273
282
  // switches skew detection off, and `??` would have read it as unset.
274
283
  buildId: input.buildId === undefined ? (env('BUILD_ID') ?? null) : input.buildId,
275
- buildIdHeader: input.buildIdHeader ?? 'x-ultimate-build',
276
284
  dev,
277
285
  signInPath: input.signInPath ?? null,
278
286
  trustProxy,
@@ -304,16 +312,6 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
304
312
  'a whole number of requests, where 0 means never shed',
305
313
  'maxInflight: 1_000',
306
314
  ),
307
- drainTimeoutMs:
308
- input.drainTimeoutMs === undefined || input.drainTimeoutMs === null
309
- ? null
310
- : assertFiniteCount(
311
- 'drainTimeoutMs',
312
- input.drainTimeoutMs,
313
- Number.MAX_SAFE_INTEGER,
314
- 'a whole number of milliseconds, or null for the lifecycle default',
315
- 'drainTimeoutMs: 15_000',
316
- ),
317
315
  locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale },
318
316
  tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
319
317
  cors,
package/src/context.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  anonymousActor,
7
7
  type Clock,
8
8
  type Ctx,
9
- createContext,
9
+ ctxOf,
10
10
  isAnonymous,
11
11
  type Logger,
12
12
  traceId as newTraceId,
@@ -15,7 +15,7 @@ import {
15
15
  type ServiceBag,
16
16
  systemClock,
17
17
  useContext,
18
- uuid,
18
+ uuidV7,
19
19
  } from '@ultimat3/core';
20
20
  import { localeConfig } from '@ultimat3/i18n';
21
21
  import { timeConfig } from '@ultimat3/time';
@@ -36,8 +36,8 @@ import type { Route, RouteParams } from './router';
36
36
  * `CtxServices` with `declare module` to declare `ctx.posts`, and every service it declared then
37
37
  * became a required member of every context literal in the framework — this file failed to
38
38
  * compile inside `examples/dummy` with `TS2739: missing posts, orgs` while the framework's own
39
- * gate, which augments nothing, stayed green. `createRequestContext` now spreads
40
- * `createContext()`'s result, so the members only an app's boot can supply arrive with it and the
39
+ * gate, which augments nothing, stayed green. `requestContext` now spreads
40
+ * `ctxOf()`'s result, so the members only an app's boot can supply arrive with it and the
41
41
  * literal below is checked in full.
42
42
  *
43
43
  * The `extends` is therefore back to doing what it was always claimed to do: a member core adds
@@ -118,7 +118,7 @@ export interface RequestContext extends Ctx {
118
118
  */
119
119
  pathLocale: string | undefined;
120
120
  tz: string;
121
- /** What the CLIENT says it is running, from `config.buildIdHeader`. `assertBuild()` reads it. */
121
+ /** What the CLIENT says it is running, from core's `BUILD_ID_HEADER`. `assertBuild()` reads it. */
122
122
  clientBuildId: string | null;
123
123
  input: unknown;
124
124
  authz: AuthzDecision | undefined;
@@ -166,15 +166,15 @@ export interface RequestContextInit {
166
166
  readonly services?: ServiceBag;
167
167
  }
168
168
 
169
- export const createRequestContext = (init: RequestContextInit): RequestContext => {
169
+ export const requestContext = (init: RequestContextInit): RequestContext => {
170
170
  const clock = init.clock ?? systemClock;
171
- const requestId = init.requestId ?? uuid(clock);
171
+ const requestId = init.requestId ?? uuidV7(clock);
172
172
  // core's `traceId()`, never `uuid()`: a dashed UUIDv7 is not a 32-hex W3C trace id, and a
173
173
  // collector rejects the span that carries one — while the log lines beside it, which quote the
174
174
  // same field, look fine. Two ids for one request that cannot be joined.
175
175
  const traceId = init.traceId ?? newTraceId();
176
176
  // COMPOSED from core's constructor rather than built beside it, and that is what deletes the
177
- // last cast in this file. `createContext` returns a `Ctx` that already carries the app's
177
+ // last cast in this file. `ctxOf` returns a `Ctx` that already carries the app's
178
178
  // `CtxServices` augmentation, so spreading it hands this literal the members only the app's boot
179
179
  // could supply — and the return below is checked in full, with nothing asserted anywhere.
180
180
  //
@@ -186,8 +186,8 @@ export const createRequestContext = (init: RequestContextInit): RequestContext =
186
186
  // `ctx.services.posts` beside it was populated. Composing makes that class of drift unwritable.
187
187
  //
188
188
  // `defineService` factories now install on this surface too, for the same reason: they are
189
- // `createContext`'s and this is `createContext`.
190
- const base = createContext({
189
+ // `ctxOf`'s and this is `ctxOf`.
190
+ const base = ctxOf({
191
191
  requestId,
192
192
  traceId,
193
193
  role: init.role,
@@ -3,7 +3,7 @@
3
3
  // frozen the span's context: the caller's trace was discarded, the root span carried a fresh
4
4
  // UUIDv7 no collector accepts as a trace id, and the log lines quoted a third value.
5
5
 
6
- import { traceId as newTraceId, parseTraceparent, type SpanContext, uuid } from '@ultimat3/core';
6
+ import { traceId as newTraceId, parseTraceparent, type SpanContext, uuidV7 } from '@ultimat3/core';
7
7
  import type { HttpConfig } from './config';
8
8
 
9
9
  /**
@@ -33,7 +33,7 @@ export interface InboundCorrelation {
33
33
  */
34
34
  export const readCorrelation = (headers: Headers, config: HttpConfig): InboundCorrelation => {
35
35
  const inboundId = config.trustProxy ? headers.get('x-request-id') : null;
36
- const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuid();
36
+ const requestId = inboundId !== null && REQUEST_ID.test(inboundId) ? inboundId : uuidV7();
37
37
  // Deliberately NOT gated on `trustProxy`, unlike the id above, and the asymmetry is the rule:
38
38
  // `x-request-id` is ECHOED back as this response's identity, so an untrusted caller choosing it
39
39
  // poisons log correlation for everyone — while `traceparent` is W3C context continuation, which
package/src/cors.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // CORS with a locked default: same-origin only. Cross-origin access is a decision the app makes
2
2
  // once, in `configureHttp({ cors })`, never something a route can quietly opt into.
3
3
 
4
+ import { BUILD_ID_HEADER } from '@ultimat3/core';
4
5
  import { corsConfigInvalid } from './errors';
5
6
 
6
7
  export interface CorsConfig {
@@ -16,8 +17,8 @@ export interface CorsConfig {
16
17
  export const DEFAULT_CORS: CorsConfig = {
17
18
  origins: [],
18
19
  methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
19
- allowHeaders: ['content-type', 'authorization', 'x-ultimate-build', 'x-request-id'],
20
- exposeHeaders: ['x-request-id', 'x-ultimate-build', 'retry-after'],
20
+ allowHeaders: ['content-type', 'authorization', BUILD_ID_HEADER, 'x-request-id'],
21
+ exposeHeaders: ['x-request-id', BUILD_ID_HEADER, 'retry-after'],
21
22
  credentials: true,
22
23
  maxAgeSeconds: 600,
23
24
  };
package/src/deadline.ts CHANGED
@@ -12,12 +12,9 @@ import { requestTimedOut } from './errors';
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`. */
@@ -57,4 +57,6 @@ export const TIER_0_ERROR_STATUS = {
57
57
  X_RECORD_REJECTED: 500,
58
58
  X_SYNC_UNCONFIGURED: 500,
59
59
  X_INTERNAL: 500,
60
+ // @ultimat3/core — a Set-Cookie value would be dropped, misread or injectable
61
+ X_COOKIE_INVALID: 500,
60
62
  } satisfies Readonly<Record<string, number>>;
@@ -68,4 +68,6 @@ export const TIER_1_ERROR_STATUS = {
68
68
  X_STORAGE_PUT_FAILED: 500,
69
69
  // @ultimat3/storage — the object could not be read
70
70
  X_STORAGE_READ_FAILED: 500,
71
+ // @ultimat3/storage — the object is under retention or a legal hold
72
+ X_STORAGE_OBJECT_LOCKED: 409,
71
73
  } satisfies Readonly<Record<string, number>>;
@@ -115,4 +115,6 @@ export const TIER_2_ERROR_STATUS = {
115
115
  X_POLICY_SURFACE_UNKNOWN: 500,
116
116
  // @ultimat3/auth — a stored totp secret is not sealed, so it is never read as one
117
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,
118
120
  } satisfies Readonly<Record<string, number>>;
@@ -89,4 +89,12 @@ export const TIER_3_ERROR_STATUS = {
89
89
  X_IDEMPOTENCY_RESERVATION_LOST: 409,
90
90
  // @ultimat3/jobs — the queue holds no job with this id
91
91
  X_JOB_NOT_FOUND: 404,
92
+ // @ultimat3/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,
92
100
  } satisfies Readonly<Record<string, number>>;
@@ -89,4 +89,28 @@ export const TIER_4_ERROR_STATUS = {
89
89
  X_MCP_BODY_TOO_LARGE: 413,
90
90
  // @ultimat3/ai — a vector store was read with no tenant bound inside an org request
91
91
  X_VECTOR_UNSCOPED: 500,
92
+ // @ultimat3/ai — an image or document block the role, model or wire format cannot take
93
+ X_AI_CONTENT_UNSUPPORTED: 422,
94
+ // @ultimat3/mcp — a gated MCP tool call waits for a person to approve it
95
+ X_MCP_CONFIRMATION_PENDING: 409,
96
+ // @ultimat3/mcp — an MCP confirmation expired before it was used or decided
97
+ X_MCP_CONFIRMATION_EXPIRED: 410,
98
+ // @ultimat3/mcp — a person rejected this MCP tool call
99
+ X_MCP_CONFIRMATION_REJECTED: 403,
100
+ // @ultimat3/mcp — an MCP confirmation was already decided
101
+ X_MCP_CONFIRMATION_DECIDED: 409,
102
+ // @ultimat3/mcp — no MCP confirmation has that id
103
+ X_MCP_CONFIRMATION_UNKNOWN: 404,
104
+ // @ultimat3/mcp — identical concurrent MCP calls kept racing one confirmation
105
+ X_MCP_CONFIRMATION_CONTESTED: 503,
106
+ // @ultimat3/mail — a delivery notification is not signed by its provider
107
+ X_MAIL_EVENT_UNVERIFIED: 401,
108
+ // @ultimat3/mail — a delivery notification is not a shape this receiver reads
109
+ X_MAIL_EVENT_INVALID: 400,
110
+ // @ultimat3/mail — an SNS endpoint the receiver needed did not answer
111
+ X_MAIL_EVENT_PROVIDER_UNREACHABLE: 503,
112
+ // @ultimat3/mcp — an MCP confirmation approval did not carry the arguments the agent sent
113
+ X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH: 409,
114
+ // @ultimat3/ai — a model call named no model
115
+ X_AI_MODEL_UNRESOLVED: 500,
92
116
  } satisfies Readonly<Record<string, number>>;
package/src/error-page.ts CHANGED
@@ -165,11 +165,11 @@ export function renderErrorPage(input: ErrorPageInput): string {
165
165
  <p class="lede">${escapeHtml(t(`errors.${copy.group}.body`, copy.vars))}</p>
166
166
  <p>${link(hrefFor(copy, input), t(`errors.${copy.group}.action`))}</p>
167
167
  <dl>
168
- <dt>code</dt><dd>${escapeHtml(singleLine(input.code))}</dd>${
168
+ <dt>${escapeHtml(t('errors.page.code'))}</dt><dd>${escapeHtml(singleLine(input.code))}</dd>${
169
169
  input.requestId === undefined
170
170
  ? ''
171
171
  : `
172
- <dt>request</dt><dd>${escapeHtml(singleLine(input.requestId))}</dd>`
172
+ <dt>${escapeHtml(t('errors.page.request'))}</dt><dd>${escapeHtml(singleLine(input.requestId))}</dd>`
173
173
  }
174
174
  </dl>
175
175
  </section>
@@ -0,0 +1,93 @@
1
+ // @ultimat3/http's owned codes, their TITLES and the two retry classes, registered at import — the
2
+ // one module `sideEffects` names, imported bare by the barrel and by `errors.ts`. Its readers are
3
+ // in the SERVER realm (`x errors list`, a problem document, a job's retry), which reaches the
4
+ // barrel. A browser never needs it: core's decoders take an unregistered code's title from the
5
+ // problem body and a 429's `retry-after` from its `Retry-After` header. Until the package declared
6
+ // `sideEffects`, all of http — router, limiter, error maps, 11.5 kB — rode into every action island.
7
+
8
+ import { registerErrorCodes, registerErrorRetry } from '@ultimat3/core';
9
+
10
+ /** Codes this package declares and owns. */
11
+ export const HTTP_OWNED_ERROR_CODES = [
12
+ 'X_ROUTE_NOT_FOUND',
13
+ 'X_METHOD_NOT_ALLOWED',
14
+ 'X_PATH_INVALID',
15
+ 'X_BODY_INVALID',
16
+ 'X_RATE_LIMITED',
17
+ 'X_BUILD_SKEW',
18
+ 'X_ROUTE_CONFLICT',
19
+ 'X_SERVER_NOT_STARTED',
20
+ 'X_PIPELINE_NO_RESPONSE',
21
+ 'X_PIPELINE_FINALIZE_FAILED',
22
+ 'X_NO_REQUEST',
23
+ 'X_ERROR_STATUS_INVALID',
24
+ 'X_PROBLEM_META_INVALID',
25
+ 'X_CORS_CONFIG_INVALID',
26
+ 'X_CSP_DIRECTIVE_INVALID',
27
+ 'X_RATE_LIMIT_NOT_SHARED',
28
+ 'X_RATE_LIMIT_BUCKET_CONFLICT',
29
+ 'X_RATE_LIMIT_BUCKET_UNBOUND',
30
+ 'X_RATE_LIMIT_SCOPE_UNSET',
31
+ 'X_RATE_LIMIT_INVALID',
32
+ 'X_RATE_LIMIT_STORE_UNAVAILABLE',
33
+ 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
34
+ 'X_TRUST_PROXY_UNSET',
35
+ 'X_OVERLOADED',
36
+ 'X_CSRF_BLOCKED',
37
+ 'X_WEBHOOK_SIGNATURE_INVALID',
38
+ 'X_WEBHOOK_SIGNATURE_STALE',
39
+ 'X_BEARER_MOUNT_INVALID',
40
+ ] as const;
41
+
42
+ export type HttpOwnedErrorCode = (typeof HTTP_OWNED_ERROR_CODES)[number];
43
+
44
+ /** Human title per owned code. Kept next to the codes so one edit updates every surface. */
45
+ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
46
+ X_ROUTE_NOT_FOUND: 'no route matches this request',
47
+ X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
48
+ X_PATH_INVALID: 'a path segment is not valid percent-encoding',
49
+ X_BODY_INVALID: 'request body failed its schema',
50
+ X_RATE_LIMITED: 'rate limit exhausted for this key',
51
+ X_BUILD_SKEW: 'client build id does not match the server build id',
52
+ X_ROUTE_CONFLICT: 'two routes claim the same path',
53
+ X_SERVER_NOT_STARTED: 'server handle used before start()',
54
+ X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
55
+ X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
56
+ X_NO_REQUEST: 'the inbound request is not in scope here',
57
+ X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
58
+ X_PROBLEM_META_INVALID: 'a problem document cannot carry that meta declaration',
59
+ X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
60
+ X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
61
+ X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
62
+ X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
63
+ X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
64
+ X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
65
+ X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
66
+ X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
67
+ X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
68
+ X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
69
+ X_OVERLOADED: 'in-flight requests are at the configured ceiling',
70
+ X_CSRF_BLOCKED: 'an unsafe request that did not prove same-origin',
71
+ X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
72
+ X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
73
+ X_BEARER_MOUNT_INVALID: 'a bearer mount declaration cannot be served as written',
74
+ };
75
+
76
+ // Registered at module load, unconditionally, in one call, so core's registry renders OUR title
77
+ // everywhere. Without this the registry humanises the code (`X_BUILD_SKEW` → "build skew"); with a
78
+ // presence guard, a package that claimed one of these first would silently keep its own title.
79
+ registerErrorCodes(
80
+ Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
81
+ );
82
+
83
+ /**
84
+ * The two codes this package throws that a client is SUPPOSED to come back from, and both say
85
+ * when: each carries `retry-after` on the response. Unclassified defaults to `terminal`, which is
86
+ * right for the rest — a 404, a 422 and a wiring bug all fail the same way forever — but wrong for
87
+ * a shed request, whose whole contract is "not now". Only codes this package OWNS are listed:
88
+ * `X_TIMEOUT` and `X_DRAINING` are borrowed, and core classifies its own.
89
+ */
90
+ registerErrorRetry({
91
+ X_RATE_LIMITED: 'retry-after',
92
+ X_OVERLOADED: 'retry-after',
93
+ });