@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.
- package/CLAUDE.md +37 -19
- package/README.md +59 -17
- 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/config.ts +27 -29
- package/src/context.ts +10 -10
- package/src/correlation.ts +2 -2
- package/src/cors.ts +3 -2
- package/src/deadline.ts +2 -5
- package/src/error-map-tier-0.ts +2 -0
- package/src/error-map-tier-1.ts +2 -0
- package/src/error-map-tier-2.ts +2 -0
- package/src/error-map-tier-3.ts +8 -0
- package/src/error-map-tier-4.ts +24 -0
- package/src/error-page.ts +2 -2
- package/src/error-titles.ts +93 -0
- package/src/errors.ts +44 -94
- package/src/forwarded.ts +20 -5
- package/src/health-disclosure.ts +0 -3
- package/src/hooks.ts +6 -5
- package/src/index.ts +31 -21
- package/src/navigation.ts +14 -2
- package/src/pipeline.ts +12 -8
- 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-postgres.ts +3 -3
- package/src/rate-limit-stage.ts +13 -4
- package/src/rate-limit.ts +22 -15
- package/src/request-facts.ts +44 -0
- package/src/request.ts +2 -2
- package/src/response.ts +2 -2
- package/src/route-cache.ts +1 -1
- package/src/router.ts +16 -1
- package/src/server.ts +74 -24
- package/src/set-cookie.ts +60 -0
- package/src/stages.ts +15 -9
- package/src/validate.ts +2 -2
package/src/bearer-mount.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
189
|
-
//
|
|
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 {
|
|
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 {
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. `
|
|
40
|
-
* `
|
|
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 `
|
|
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
|
|
169
|
+
export const requestContext = (init: RequestContextInit): RequestContext => {
|
|
170
170
|
const clock = init.clock ?? systemClock;
|
|
171
|
-
const requestId = init.requestId ??
|
|
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. `
|
|
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
|
-
// `
|
|
190
|
-
const base =
|
|
189
|
+
// `ctxOf`'s and this is `ctxOf`.
|
|
190
|
+
const base = ctxOf({
|
|
191
191
|
requestId,
|
|
192
192
|
traceId,
|
|
193
193
|
role: init.role,
|
package/src/correlation.ts
CHANGED
|
@@ -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,
|
|
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 :
|
|
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',
|
|
20
|
-
exposeHeaders: ['x-request-id',
|
|
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
|
|
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`. */
|
package/src/error-map-tier-0.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-1.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-2.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-3.ts
CHANGED
|
@@ -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>>;
|
package/src/error-map-tier-4.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
});
|