@ultimat3/http 1.2.0 → 2.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 +285 -0
- package/README.md +99 -2
- package/package.json +6 -3
- package/src/auth-redirect.ts +81 -0
- package/src/cache-policy.ts +24 -0
- package/src/config.ts +67 -7
- package/src/context.ts +187 -36
- package/src/correlation.ts +44 -0
- package/src/cors.ts +33 -5
- package/src/csrf.ts +85 -0
- package/src/deadline.ts +79 -0
- package/src/error-map.ts +204 -4
- package/src/errors.ts +334 -5
- package/src/finalize.ts +70 -0
- package/src/forwarded.ts +94 -0
- package/src/hooks.ts +39 -4
- package/src/index.ts +60 -23
- package/src/locale.ts +34 -82
- package/src/overlay-style.ts +43 -0
- package/src/overlay.ts +59 -41
- package/src/peer-identity.ts +107 -0
- package/src/pipeline.ts +148 -301
- package/src/rate-limit-buckets.ts +86 -0
- package/src/rate-limit.ts +197 -9
- package/src/redirect.ts +29 -0
- package/src/request.ts +47 -13
- package/src/response.ts +34 -8
- package/src/router.ts +63 -13
- package/src/security-headers.ts +34 -13
- package/src/server.ts +44 -7
- package/src/stages.ts +381 -0
- package/src/type-pins.ts +48 -0
- package/src/validate.ts +13 -2
package/src/errors.ts
CHANGED
|
@@ -1,18 +1,36 @@
|
|
|
1
1
|
// The HTTP layer's stable error codes. Every throw in this package goes through a
|
|
2
2
|
// factory here so a code, a cause and an exact fix always travel together — the
|
|
3
3
|
// terminal, the dev overlay and `--json` all render the same three strings.
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
registerErrorCodes,
|
|
6
|
+
registerErrorRetry,
|
|
7
|
+
renderThrowable,
|
|
8
|
+
UltimateError,
|
|
9
|
+
} from '@ultimat3/core';
|
|
5
10
|
|
|
6
11
|
/** Codes this package declares and owns. */
|
|
7
12
|
export const HTTP_OWNED_ERROR_CODES = [
|
|
8
13
|
'X_ROUTE_NOT_FOUND',
|
|
9
14
|
'X_METHOD_NOT_ALLOWED',
|
|
15
|
+
'X_PATH_INVALID',
|
|
10
16
|
'X_BODY_INVALID',
|
|
11
17
|
'X_RATE_LIMITED',
|
|
12
18
|
'X_BUILD_SKEW',
|
|
13
19
|
'X_ROUTE_CONFLICT',
|
|
14
20
|
'X_SERVER_NOT_STARTED',
|
|
15
21
|
'X_PIPELINE_NO_RESPONSE',
|
|
22
|
+
'X_PIPELINE_FINALIZE_FAILED',
|
|
23
|
+
'X_NO_REQUEST',
|
|
24
|
+
'X_ERROR_STATUS_INVALID',
|
|
25
|
+
'X_CORS_CONFIG_INVALID',
|
|
26
|
+
'X_RATE_LIMIT_NOT_SHARED',
|
|
27
|
+
'X_RATE_LIMIT_BUCKET_CONFLICT',
|
|
28
|
+
'X_RATE_LIMIT_BUCKET_UNBOUND',
|
|
29
|
+
'X_RATE_LIMIT_SCOPE_UNSET',
|
|
30
|
+
'X_RATE_LIMIT_INVALID',
|
|
31
|
+
'X_TRUST_PROXY_UNSET',
|
|
32
|
+
'X_OVERLOADED',
|
|
33
|
+
'X_CSRF_BLOCKED',
|
|
16
34
|
] as const;
|
|
17
35
|
|
|
18
36
|
/**
|
|
@@ -21,7 +39,19 @@ export const HTTP_OWNED_ERROR_CODES = [
|
|
|
21
39
|
* `X_ERROR_CODE_DUPLICATE` at import. No titles for them either — the owner writes the one title
|
|
22
40
|
* every surface renders, and a copy kept here is a copy that goes stale without anything failing.
|
|
23
41
|
*/
|
|
24
|
-
export const HTTP_BORROWED_ERROR_CODES = [
|
|
42
|
+
export const HTTP_BORROWED_ERROR_CODES = [
|
|
43
|
+
'X_UNAUTHENTICATED',
|
|
44
|
+
'X_FORBIDDEN',
|
|
45
|
+
// `X_TIMEOUT` has had its 504 row in `ERROR_STATUS` since the table was written and nothing in
|
|
46
|
+
// the framework threw it. The request deadline does now — borrowed rather than owned because
|
|
47
|
+
// the concept is core's (`Clock`, `throwIfAborted`) and a title registered here would throw
|
|
48
|
+
// `X_ERROR_CODE_DUPLICATE` at import the day core writes its own.
|
|
49
|
+
'X_TIMEOUT',
|
|
50
|
+
// Core's, titled in `CORE_CODE_TITLES` and classified `retryable` in `error-retry.ts`, because
|
|
51
|
+
// the lifecycle that answers `isDraining()` is core's. The `admit` stage is its first thrower:
|
|
52
|
+
// this package documented answering 503 while draining and had no reader of the flag at all.
|
|
53
|
+
'X_DRAINING',
|
|
54
|
+
] as const;
|
|
25
55
|
|
|
26
56
|
/** Every code http can throw: the ones it owns plus the two it borrows. */
|
|
27
57
|
export const HTTP_ERROR_CODES = [...HTTP_OWNED_ERROR_CODES, ...HTTP_BORROWED_ERROR_CODES] as const;
|
|
@@ -33,12 +63,25 @@ export type HttpErrorCode = (typeof HTTP_ERROR_CODES)[number];
|
|
|
33
63
|
export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
34
64
|
X_ROUTE_NOT_FOUND: 'no route matches this request',
|
|
35
65
|
X_METHOD_NOT_ALLOWED: 'route exists but not for this method',
|
|
66
|
+
X_PATH_INVALID: 'a path segment is not valid percent-encoding',
|
|
36
67
|
X_BODY_INVALID: 'request body failed its schema',
|
|
37
68
|
X_RATE_LIMITED: 'rate limit exhausted for this key',
|
|
38
69
|
X_BUILD_SKEW: 'client build id does not match the server build id',
|
|
39
70
|
X_ROUTE_CONFLICT: 'two routes claim the same path',
|
|
40
71
|
X_SERVER_NOT_STARTED: 'server handle used before start()',
|
|
41
72
|
X_PIPELINE_NO_RESPONSE: 'a pipeline stage produced no response',
|
|
73
|
+
X_PIPELINE_FINALIZE_FAILED: 'a finalize stage threw instead of finishing the response',
|
|
74
|
+
X_NO_REQUEST: 'the inbound request is not in scope here',
|
|
75
|
+
X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
|
|
76
|
+
X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
|
|
77
|
+
X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
|
|
78
|
+
X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
|
|
79
|
+
X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
|
|
80
|
+
X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
|
|
81
|
+
X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
|
|
82
|
+
X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
|
|
83
|
+
X_OVERLOADED: 'in-flight requests are at the configured ceiling',
|
|
84
|
+
X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
|
|
42
85
|
};
|
|
43
86
|
|
|
44
87
|
// Registered at module load, unconditionally, in one call, so core's registry renders OUR title
|
|
@@ -48,18 +91,41 @@ registerErrorCodes(
|
|
|
48
91
|
Object.fromEntries(Object.entries(HTTP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
49
92
|
);
|
|
50
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The two codes this package throws that a client is SUPPOSED to come back from, and both say
|
|
96
|
+
* when: each carries `retry-after` on the response. Unclassified defaults to `terminal`, which is
|
|
97
|
+
* right for the rest — a 404, a 422 and a wiring bug all fail the same way forever — but wrong for
|
|
98
|
+
* a shed request, whose whole contract is "not now". Only codes this package OWNS are listed:
|
|
99
|
+
* `X_TIMEOUT` and `X_DRAINING` are borrowed, and core classifies its own.
|
|
100
|
+
*/
|
|
101
|
+
registerErrorRetry({
|
|
102
|
+
X_RATE_LIMITED: 'retry-after',
|
|
103
|
+
X_OVERLOADED: 'retry-after',
|
|
104
|
+
});
|
|
105
|
+
|
|
51
106
|
const docsFor = (code: HttpErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
52
107
|
|
|
53
108
|
/** Base class for every error this package throws. Never throw a bare `Error`. */
|
|
54
109
|
export class HttpError extends UltimateError {
|
|
55
110
|
override readonly name = 'HttpError';
|
|
56
111
|
|
|
57
|
-
constructor(init: {
|
|
112
|
+
constructor(init: {
|
|
113
|
+
code: HttpErrorCode;
|
|
114
|
+
cause: string;
|
|
115
|
+
fix: string;
|
|
116
|
+
/**
|
|
117
|
+
* Facts an operator needs and a CALLER must not be handed. `toProblem` renders code, cause,
|
|
118
|
+
* fix and docs — never this — so the rate limiter's internal key rides here instead of in a
|
|
119
|
+
* 429 body that told an anonymous caller the org id it had been promoted to.
|
|
120
|
+
*/
|
|
121
|
+
meta?: Readonly<Record<string, unknown>>;
|
|
122
|
+
}) {
|
|
58
123
|
super({
|
|
59
124
|
code: init.code,
|
|
60
125
|
cause: init.cause,
|
|
61
126
|
fix: init.fix,
|
|
62
127
|
docs: docsFor(init.code),
|
|
128
|
+
...(init.meta === undefined ? {} : { meta: init.meta }),
|
|
63
129
|
});
|
|
64
130
|
}
|
|
65
131
|
}
|
|
@@ -82,11 +148,27 @@ export const methodNotAllowed = (
|
|
|
82
148
|
fix: `add a ${method} route for ${pathname} or call it with ${allow[0] ?? 'GET'}`,
|
|
83
149
|
});
|
|
84
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The client wrote the path, so the client is who can fix it — 400, not the 500 the bare
|
|
153
|
+
* `URIError` from `decodeURIComponent` used to produce. `X_INTERNAL` reported a typo to the error
|
|
154
|
+
* monitor (`pipeline.ts` pages on `status >= 500`) and told the caller nothing.
|
|
155
|
+
*/
|
|
156
|
+
export const pathInvalid = (pathname: string, segment: string): HttpError =>
|
|
157
|
+
new HttpError({
|
|
158
|
+
code: 'X_PATH_INVALID',
|
|
159
|
+
cause: `${pathname} contains "${segment}", which is not valid percent-encoding`,
|
|
160
|
+
fix: 'send the segment percent-encoded — encodeURIComponent(value); a literal % is %25',
|
|
161
|
+
});
|
|
162
|
+
|
|
85
163
|
export const bodyInvalid = (pathname: string, issues: readonly string[]): HttpError =>
|
|
86
164
|
new HttpError({
|
|
87
165
|
code: 'X_BODY_INVALID',
|
|
88
166
|
cause: `${pathname} body rejected: ${issues.join('; ')}`,
|
|
89
|
-
|
|
167
|
+
// `x schema show` is not a command — not in the registry and not in `PLANNED_COMMANDS`, so it
|
|
168
|
+
// exits `X_CLI_UNKNOWN_COMMAND`. The same axiom-4 inversion `x logs tail` had in `error-map`:
|
|
169
|
+
// the one instruction the reader is given fails when they run it. `x routes` ships, and
|
|
170
|
+
// `hasInputSchema` plus the route's name is what it prints.
|
|
171
|
+
fix: `x routes --json # find ${pathname}, then send a body matching its input schema`,
|
|
90
172
|
});
|
|
91
173
|
|
|
92
174
|
export const unauthenticated = (pathname: string): HttpError =>
|
|
@@ -103,11 +185,18 @@ export const forbidden = (pathname: string, reason: string): HttpError =>
|
|
|
103
185
|
fix: `x policy explain ${pathname} --json # shows which clause denied`,
|
|
104
186
|
});
|
|
105
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The KEY never reaches the caller. `rateLimitKey` is `${routeName}|org:${orgId}` — or
|
|
190
|
+
* `actor:${actorId}` — so the old cause handed an anonymous caller promoted to an org bucket the
|
|
191
|
+
* internal org id, in a 429 anyone can provoke. It rides in `meta`, which the problem document
|
|
192
|
+
* does not render and the error reporter does.
|
|
193
|
+
*/
|
|
106
194
|
export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =>
|
|
107
195
|
new HttpError({
|
|
108
196
|
code: 'X_RATE_LIMITED',
|
|
109
|
-
cause: `
|
|
197
|
+
cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`,
|
|
110
198
|
fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts',
|
|
199
|
+
meta: { key, retryAfterSeconds },
|
|
111
200
|
});
|
|
112
201
|
|
|
113
202
|
export const buildSkew = (clientBuildId: string, serverBuildId: string): HttpError =>
|
|
@@ -131,9 +220,249 @@ export const pipelineNoResponse = (stage: string): HttpError =>
|
|
|
131
220
|
fix: 'return a Response from the route handler, or a Response from the stage that short-circuits',
|
|
132
221
|
});
|
|
133
222
|
|
|
223
|
+
/**
|
|
224
|
+
* A finalize stage threw on the response it was handed. `Pipeline.handle` promises a Response to
|
|
225
|
+
* every caller, so the throw becomes this — a 500 the client can read and report — instead of a
|
|
226
|
+
* rejected promise the server has nothing to send for.
|
|
227
|
+
*/
|
|
228
|
+
export const finalizeFailed = (stage: string, cause: unknown): HttpError =>
|
|
229
|
+
new HttpError({
|
|
230
|
+
code: 'X_PIPELINE_FINALIZE_FAILED',
|
|
231
|
+
// A stage throws whatever the app threw, and this factory is the last thing standing between
|
|
232
|
+
// that value and `finalize.ts`'s promise that `handle()` resolves to a Response. `instanceof`
|
|
233
|
+
// runs a `Proxy`'s `getPrototypeOf` trap and `.message` runs a getter, so both reads go
|
|
234
|
+
// through core's total `renderThrowable` — the fast path was the last unguarded one here.
|
|
235
|
+
cause: `the "${stage}" stage threw while finishing the response: ${renderThrowable(cause)}`,
|
|
236
|
+
fix: 'return a Response built here — json(), text(), html() or redirect() from @ultimat3/http; one whose headers cannot be set, like Response.redirect(), cannot take the final headers',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A request-scoped reader used where no request exists — a job, a task, a boot-time module
|
|
241
|
+
* body. Loud, because the alternative (`null`) reads as "the caller sent no cookie", which is
|
|
242
|
+
* how an unauthenticated job would quietly run as nobody.
|
|
243
|
+
*/
|
|
244
|
+
export const noRequest = (member: string): HttpError =>
|
|
245
|
+
new HttpError({
|
|
246
|
+
code: 'X_NO_REQUEST',
|
|
247
|
+
cause: `${member} was read outside an HTTP request`,
|
|
248
|
+
fix: 'move this call inside a route handler, an action or a page — or, for a job, call useRequestCookie(name) at enqueue time and pass the value in the payload',
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const errorStatusInvalid = (code: string, reason: string): HttpError =>
|
|
252
|
+
new HttpError({
|
|
253
|
+
code: 'X_ERROR_STATUS_INVALID',
|
|
254
|
+
cause: `${code} cannot be mapped: ${reason}`,
|
|
255
|
+
fix: `x errors list --json # then registerErrorStatus({ ${code}: 422 }) with a status the framework does not already own`,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* At `defineHttpConfig`, never on the request. A CORS pair a browser can never accept resolves to
|
|
260
|
+
* "emit no CORS headers at all", which is unreadable from the console: every cross-origin call
|
|
261
|
+
* fails and nothing on the server said anything.
|
|
262
|
+
*/
|
|
263
|
+
export const corsConfigInvalid = (reason: string): HttpError =>
|
|
264
|
+
new HttpError({
|
|
265
|
+
code: 'X_CORS_CONFIG_INVALID',
|
|
266
|
+
cause: `cors config rejected: ${reason}`,
|
|
267
|
+
fix: "in app.config.ts set http.cors.credentials: false, or replace http.cors.origins: ['*'] with the exact origins allowed to call this app",
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means
|
|
272
|
+
* each process holds its own counters, so every configured number is enforced three times over —
|
|
273
|
+
* a green `x verify` and a limit that is not the limit. The declaration is the app's because the
|
|
274
|
+
* framework cannot see its replica count, and a framework that guessed would guess wrong.
|
|
275
|
+
*/
|
|
276
|
+
export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
|
|
277
|
+
new HttpError({
|
|
278
|
+
code: 'X_RATE_LIMIT_NOT_SHARED',
|
|
279
|
+
cause:
|
|
280
|
+
found === 'disabled'
|
|
281
|
+
? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
|
|
282
|
+
: "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
|
|
283
|
+
fix: "pass a store whose scope is 'shared' — createServer({ routes, rateLimitStore }) — or set http.rateLimit.scope: 'process' in app.config.ts to accept per-replica limits",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The numbers of one bucket, spelled structurally so `errors.ts` stays free of an import from
|
|
288
|
+
* `rate-limit.ts` — which imports this file.
|
|
289
|
+
*/
|
|
290
|
+
interface BucketNumbers {
|
|
291
|
+
readonly capacity: number;
|
|
292
|
+
readonly refillPerSecond: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that
|
|
299
|
+
* configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
|
|
300
|
+
* disagree about what is enforced, and whichever a merge picked would leave the other a number
|
|
301
|
+
* someone read and nothing applies — the failure this seam exists to end. The message speaks
|
|
302
|
+
* capacity and refill rather than the `limit`/`windowMs` an action declares, because that is what
|
|
303
|
+
* the limiter runs on; `toBucket` (`rate-limit.ts`, this package) is the conversion between them —
|
|
304
|
+
* it lives here because http owns `Bucket` and the maths, and both tier-3 callers need it.
|
|
305
|
+
*/
|
|
306
|
+
export const rateLimitBucketConflict = (input: {
|
|
307
|
+
bucket: string;
|
|
308
|
+
/** `null` when the other declaration is `app.config.ts` rather than a second route. */
|
|
309
|
+
otherRoute: string | null;
|
|
310
|
+
route: string;
|
|
311
|
+
other: BucketNumbers;
|
|
312
|
+
declared: BucketNumbers;
|
|
313
|
+
}): HttpError =>
|
|
314
|
+
new HttpError({
|
|
315
|
+
code: 'X_RATE_LIMIT_BUCKET_CONFLICT',
|
|
316
|
+
cause: `bucket "${input.bucket}" has two declarations: ${
|
|
317
|
+
input.otherRoute === null
|
|
318
|
+
? 'http.rateLimit.buckets in app.config.ts'
|
|
319
|
+
: `route "${input.otherRoute}"`
|
|
320
|
+
} says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${
|
|
321
|
+
input.otherRoute === null
|
|
322
|
+
? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and app.config.ts is not where to say so`
|
|
323
|
+
: ''
|
|
324
|
+
}`,
|
|
325
|
+
// One edit, named. Two joined by "or" leaves the reader to decide which declaration is
|
|
326
|
+
// authoritative — and the route is, always: it sits beside the handler and it is what the
|
|
327
|
+
// OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale.
|
|
328
|
+
fix:
|
|
329
|
+
input.otherRoute === null
|
|
330
|
+
? `delete http.rateLimit.buckets.${input.bucket} from app.config.ts — the route's declaration is the one the OpenAPI operation publishes, so edit the numbers there if ${numbers(input.declared)} is wrong`
|
|
331
|
+
: `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A route declares its own bucket and the INSTALLED limiter cannot enforce it — at
|
|
336
|
+
* `createPipeline`, never on the request. `createRateLimiter` closes over the config it was built
|
|
337
|
+
* with, so a limiter constructed before the routes existed resolves the route's bucket name
|
|
338
|
+
* through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
|
|
339
|
+
* requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
|
|
340
|
+
*
|
|
341
|
+
* Refused rather than rebound, for two reasons. A `RateLimiter` is opaque — no store and no table
|
|
342
|
+
* are reachable through it — so "binding" it would mean discarding the caller's limiter and the
|
|
343
|
+
* store it carries, which is a different silent failure. And a caller who built their own limiter
|
|
344
|
+
* may have meant their own numbers; picking for them is the precedence mistake
|
|
345
|
+
* `X_RATE_LIMIT_BUCKET_CONFLICT` exists to refuse.
|
|
346
|
+
*/
|
|
347
|
+
export const rateLimitBucketUnbound = (input: {
|
|
348
|
+
bucket: string;
|
|
349
|
+
route: string;
|
|
350
|
+
declared: BucketNumbers;
|
|
351
|
+
/** What the limiter holds under that name, or `null` for "holds nothing / declares no table". */
|
|
352
|
+
found: BucketNumbers | null;
|
|
353
|
+
}): HttpError =>
|
|
354
|
+
new HttpError({
|
|
355
|
+
code: 'X_RATE_LIMIT_BUCKET_UNBOUND',
|
|
356
|
+
cause: `route "${input.route}" declares bucket "${input.bucket}" as ${numbers(input.declared)} (capacity / refill per second) and the installed limiter ${
|
|
357
|
+
input.found === null
|
|
358
|
+
? 'does not hold that bucket, so the route would run on the default one'
|
|
359
|
+
: `holds ${numbers(input.found)} for it`
|
|
360
|
+
}`,
|
|
361
|
+
fix: 'pass the STORE and let the pipeline build the limiter — createServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
|
|
362
|
+
});
|
|
363
|
+
|
|
134
364
|
export const routeConflict = (path: string, detail: string): HttpError =>
|
|
135
365
|
new HttpError({
|
|
136
366
|
code: 'X_ROUTE_CONFLICT',
|
|
137
367
|
cause: `${path} conflicts with an already registered route: ${detail}`,
|
|
138
368
|
fix: `x routes list --json # remove or rename one of the two routes at ${path}`,
|
|
139
369
|
});
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* At `defineHttpConfig`, never on the request. `scope` used to DEFAULT to `'process'`, so an app
|
|
373
|
+
* that declared nothing enforced every configured number once per replica — three times over on
|
|
374
|
+
* the chart this repo ships — with a green `x verify` and nothing to read. The boot check that
|
|
375
|
+
* catches the other half (`assertRateLimitScope`) only fires for an app that said `'shared'`, so
|
|
376
|
+
* the silent case was exactly the one nobody declared. One process is still a legal answer; it is
|
|
377
|
+
* no longer an assumed one.
|
|
378
|
+
*/
|
|
379
|
+
export const rateLimitScopeUnset = (): HttpError =>
|
|
380
|
+
new HttpError({
|
|
381
|
+
code: 'X_RATE_LIMIT_SCOPE_UNSET',
|
|
382
|
+
cause:
|
|
383
|
+
'http.rateLimit is enabled and the deployment has not declared http.rateLimit.scope, so the numbers below it are per replica rather than per fleet',
|
|
384
|
+
fix: "in app.config.ts set http.rateLimit.scope: 'process' if this app runs as ONE replica, or 'shared' plus createServer({ routes, rateLimitStore }) for a fleet-wide limit",
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A `{ limit, windowMs }` pair the limiter cannot run on. Raised by `toBucket` (`rate-limit.ts`),
|
|
389
|
+
* which lives in this PACKAGE because http owns `Bucket` and the maths, and two tier-3 packages
|
|
390
|
+
* (`action`, `query`) need the same conversion without importing each other.
|
|
391
|
+
*/
|
|
392
|
+
export const rateLimitInvalid = (input: {
|
|
393
|
+
readonly owner: string;
|
|
394
|
+
readonly limit: number;
|
|
395
|
+
readonly windowMs: number;
|
|
396
|
+
readonly reason: string;
|
|
397
|
+
}): HttpError =>
|
|
398
|
+
new HttpError({
|
|
399
|
+
code: 'X_RATE_LIMIT_INVALID',
|
|
400
|
+
cause: `"${input.owner}" declares rateLimit { limit: ${input.limit}, windowMs: ${input.windowMs} }: ${input.reason}`,
|
|
401
|
+
fix: `edit the \`rateLimit:\` on ${input.owner} to a whole allowance over a real window — e.g. { limit: 5, windowMs: 600_000 } for five per ten minutes — or delete it to keep the default bucket`,
|
|
402
|
+
meta: { owner: input.owner, limit: input.limit, windowMs: input.windowMs },
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* At `defineHttpConfig`. `trustProxy` is a claim about the DEPLOYMENT — that something in front
|
|
407
|
+
* rewrites `x-forwarded-for` — and the leftmost value in that header is whatever the client
|
|
408
|
+
* typed. Without a hop count there is no way to tell the proxy's entry from the caller's, so
|
|
409
|
+
* trusting the header at all is trusting the caller. Asked in the same shape as
|
|
410
|
+
* `X_RATE_LIMIT_NOT_SHARED`, and for the same reason: only the app knows its own topology.
|
|
411
|
+
*/
|
|
412
|
+
export const trustProxyUnset = (): HttpError =>
|
|
413
|
+
new HttpError({
|
|
414
|
+
code: 'X_TRUST_PROXY_UNSET',
|
|
415
|
+
cause:
|
|
416
|
+
'http.trustProxy is true and http.trustedProxyHops is not set, so x-forwarded-for would be read from a position the client controls',
|
|
417
|
+
fix: 'in app.config.ts set http.trustedProxyHops to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — or set http.trustProxy: false when this process is reached directly',
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* SIGTERM has run the `accept` phase: `readyz` is already 503 and the socket is closing, but a
|
|
422
|
+
* connection the load balancer had not yet stopped using still arrives. Answering it with a
|
|
423
|
+
* coded 503 and a `Retry-After` is what `packages/http/CLAUDE.md` claimed the layer did — until
|
|
424
|
+
* this stage, `isDraining()` had no reader in this package at all.
|
|
425
|
+
*/
|
|
426
|
+
export const draining = (): HttpError =>
|
|
427
|
+
new HttpError({
|
|
428
|
+
code: 'X_DRAINING',
|
|
429
|
+
cause: 'this process is draining and will not start new work',
|
|
430
|
+
fix: 'retry after the Retry-After header — another replica is already serving, and this one is being replaced',
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Shed BEFORE any work: no route match, no auth, no body, no query. The alternative is not
|
|
435
|
+
* "serve everyone", it is "serve nobody" — every request queues behind the same pool, p99 walks
|
|
436
|
+
* off the chart and client retries multiply the load. `@ultimat3/realtime`'s `AcceptBudget` is
|
|
437
|
+
* the same decision for sockets; this is the one HTTP never had.
|
|
438
|
+
*/
|
|
439
|
+
export const overloaded = (inflight: number, ceiling: number): HttpError =>
|
|
440
|
+
new HttpError({
|
|
441
|
+
code: 'X_OVERLOADED',
|
|
442
|
+
cause: `${inflight} requests are already in flight and http.maxInflight is ${ceiling}`,
|
|
443
|
+
fix: 'retry after the Retry-After header; to serve more at once raise http.maxInflight in app.config.ts, and add replicas to match',
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* A write that arrived with the browser's ambient credential and could not be shown to come from
|
|
448
|
+
* this app. Never a 401: the caller IS signed in, which is precisely the problem.
|
|
449
|
+
*/
|
|
450
|
+
export const csrfBlocked = (pathname: string, reason: string): HttpError =>
|
|
451
|
+
new HttpError({
|
|
452
|
+
code: 'X_CSRF_BLOCKED',
|
|
453
|
+
cause: `${pathname} refused a credentialed write: ${reason}`,
|
|
454
|
+
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to http.cors.origins in app.config.ts, or set http.csrf.mode: 'off' if this app has no cookie session at all",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The request ran past its deadline. `X_TIMEOUT` is borrowed (see `HTTP_BORROWED_ERROR_CODES`)
|
|
459
|
+
* and already maps to 504. The abort fires first for cooperative code; this is what the socket
|
|
460
|
+
* gets when the handler never looked at `ctx.signal`.
|
|
461
|
+
*/
|
|
462
|
+
export const requestTimedOut = (method: string, pathname: string, timeoutMs: number): HttpError =>
|
|
463
|
+
new HttpError({
|
|
464
|
+
code: 'X_TIMEOUT',
|
|
465
|
+
cause: `${method} ${pathname} did not finish within ${timeoutMs}ms`,
|
|
466
|
+
fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or raise http.requestTimeoutMs in app.config.ts',
|
|
467
|
+
meta: { timeoutMs },
|
|
468
|
+
});
|
package/src/finalize.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The tail of the lifecycle, guarded. `Pipeline.handle` promises a Response to every caller, and
|
|
2
|
+
// the recover and finalize stages are the ones with nothing above them to catch a throw. One of three:
|
|
3
|
+
// `pipeline.ts` owns the ORDER of the stages, `stages.ts` owns what each one does, this file owns
|
|
4
|
+
// the promise.
|
|
5
|
+
import { logger } from '@ultimat3/core';
|
|
6
|
+
import type { RequestContext } from './context';
|
|
7
|
+
import { finalizeFailed } from './errors';
|
|
8
|
+
import type { UltimateRequest } from './request';
|
|
9
|
+
import { problem } from './response';
|
|
10
|
+
import type { Stage } from './stages';
|
|
11
|
+
|
|
12
|
+
/** Renders whatever sits on `ctx.error` into a Response. Never throws, by construction. */
|
|
13
|
+
export type Recover = (request: UltimateRequest, ctx: RequestContext) => Promise<Response>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The recover stage is the single place a throw becomes a status — so a throw INSIDE it (an app's
|
|
17
|
+
* `onError` sink, a `devNotices` producer) has nothing left to render it. Rethrowing would break
|
|
18
|
+
* the one guarantee `handle` makes, so the problem document is built here instead, from the error
|
|
19
|
+
* the request actually hit: the caller is told about the defect it met, and the log line carries
|
|
20
|
+
* the second one.
|
|
21
|
+
*/
|
|
22
|
+
export const recoverWith =
|
|
23
|
+
(stage: Stage | undefined): Recover =>
|
|
24
|
+
async (request, ctx) => {
|
|
25
|
+
try {
|
|
26
|
+
const rendered = await stage?.run(request, ctx);
|
|
27
|
+
if (rendered !== undefined) return rendered;
|
|
28
|
+
} catch (failure) {
|
|
29
|
+
logger.error(
|
|
30
|
+
`the recover stage threw and cannot render itself [${ctx.requestId}]: ${String(failure)}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return problem(ctx.error, { instance: ctx.url.pathname, requestId: ctx.requestId });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Runs every finalize stage, and cannot throw. A stage that refuses the response it was handed —
|
|
38
|
+
* headers that cannot be set, on a `Response.redirect` or anything else the handler built — used
|
|
39
|
+
* to reject `handle()` against its own contract, which leaves the server with no answer at all
|
|
40
|
+
* and the client with whatever the runtime prints. It degrades to the coded 500 instead.
|
|
41
|
+
*
|
|
42
|
+
* Two passes at most. The second runs over the response the failure produced, a fresh problem
|
|
43
|
+
* document whose headers ARE writable, so the request id, CORS and the security headers still
|
|
44
|
+
* reach the client that has to report this. A failure on the second pass keeps its 500 and stops:
|
|
45
|
+
* looping over a response nothing can finish is the same outage with more log lines.
|
|
46
|
+
*/
|
|
47
|
+
export const runFinalize = async (
|
|
48
|
+
stages: readonly Stage[],
|
|
49
|
+
request: UltimateRequest,
|
|
50
|
+
ctx: RequestContext,
|
|
51
|
+
recover: Recover,
|
|
52
|
+
): Promise<void> => {
|
|
53
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
54
|
+
let failed = false;
|
|
55
|
+
for (const stage of stages) {
|
|
56
|
+
try {
|
|
57
|
+
const replaced = await stage.run(request, ctx);
|
|
58
|
+
if (replaced !== undefined) ctx.response = replaced;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
// Through the recover stage, never around it: reporting, logging and the dev overlay are
|
|
61
|
+
// its job, and a second reporting call site here is a 500 that pages twice or not at all.
|
|
62
|
+
ctx.error = finalizeFailed(stage.name, error);
|
|
63
|
+
ctx.response = await recover(request, ctx);
|
|
64
|
+
failed = true;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!failed) return;
|
|
69
|
+
}
|
|
70
|
+
};
|
package/src/forwarded.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Who the caller really is when a proxy is in front of us. `trustProxy` documented reading
|
|
2
|
+
// `x-forwarded-for` and nothing in the framework ever read it: every anonymous request behind an
|
|
3
|
+
// ingress keyed the rate limiter to the proxy's address — ONE bucket for the whole internet — and
|
|
4
|
+
// `ctx.https` was false on a TLS-terminated hop, so HSTS was never emitted in the shape the
|
|
5
|
+
// framework's own chart ships.
|
|
6
|
+
|
|
7
|
+
import type { HttpConfig } from './config';
|
|
8
|
+
|
|
9
|
+
export const FORWARDED_FOR = 'x-forwarded-for';
|
|
10
|
+
export const FORWARDED_PROTO = 'x-forwarded-proto';
|
|
11
|
+
/** Envoy's XFCC. Read here rather than in a second module, so there is ONE trust rule. */
|
|
12
|
+
export const FORWARDED_CLIENT_CERT = 'x-forwarded-client-cert';
|
|
13
|
+
|
|
14
|
+
/** How a header's comma-separated list is cut. XFCC needs quote awareness; the others do not. */
|
|
15
|
+
export type ForwardedSplit = (value: string, separator: string) => readonly string[];
|
|
16
|
+
|
|
17
|
+
const plainSplit: ForwardedSplit = (value, separator) =>
|
|
18
|
+
value
|
|
19
|
+
.split(separator)
|
|
20
|
+
.map((entry) => entry.trim())
|
|
21
|
+
.filter((entry) => entry.length > 0);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The element a TRUSTED proxy wrote, counting from the right. Every proxy appends its own peer,
|
|
25
|
+
* so with `hops` trusted proxies in front of this process the caller is `list.length - hops` —
|
|
26
|
+
* never `list[0]`, which is whatever the client typed. Fewer entries than the deployment declared
|
|
27
|
+
* means the chain is not the one configured, so there is no trusted entry at all and this answers
|
|
28
|
+
* `undefined`: falling back to the leftmost value is exactly the spoof the count exists to stop.
|
|
29
|
+
*
|
|
30
|
+
* Every proxy-supplied header goes through this one function — address, protocol and peer
|
|
31
|
+
* certificate alike. A second trust rule is a second thing to get wrong, and the one that reads
|
|
32
|
+
* a certificate would be the one that authenticates.
|
|
33
|
+
*/
|
|
34
|
+
export const forwardedElement = (
|
|
35
|
+
header: string | null,
|
|
36
|
+
hops: number,
|
|
37
|
+
split: ForwardedSplit = plainSplit,
|
|
38
|
+
): string | undefined => {
|
|
39
|
+
if (header === null || hops < 1) return undefined;
|
|
40
|
+
const list = split(header, ',');
|
|
41
|
+
const index = list.length - hops;
|
|
42
|
+
if (index < 0 || index >= list.length) return undefined;
|
|
43
|
+
return list[index];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** The plain-list form: `x-forwarded-for`, `x-forwarded-proto`. */
|
|
47
|
+
export const forwardedValue = (header: string | null, hops: number): string | undefined =>
|
|
48
|
+
forwardedElement(header, hops);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `1.2.3.4:5678` and `[::1]:443` are both legal in an `x-forwarded-for` entry; the port is the
|
|
52
|
+
* proxy's bookkeeping and would make every connection its own rate-limit bucket.
|
|
53
|
+
*/
|
|
54
|
+
const withoutPort = (address: string): string => {
|
|
55
|
+
if (address.startsWith('[')) {
|
|
56
|
+
const close = address.indexOf(']');
|
|
57
|
+
return close === -1 ? address : address.slice(1, close);
|
|
58
|
+
}
|
|
59
|
+
// A bare IPv6 has several colons and no port; only a single colon is host:port.
|
|
60
|
+
const colon = address.indexOf(':');
|
|
61
|
+
if (colon === -1 || address.indexOf(':', colon + 1) !== -1) return address;
|
|
62
|
+
return address.slice(0, colon);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export interface ForwardedInput {
|
|
66
|
+
readonly headers: Headers;
|
|
67
|
+
readonly config: HttpConfig;
|
|
68
|
+
/** What the socket says. Always the fallback, and the only answer when nothing is trusted. */
|
|
69
|
+
readonly socketAddress: string | null;
|
|
70
|
+
/** The scheme of the URL this process was reached on. */
|
|
71
|
+
readonly urlProtocol: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The address the rate limiter keys on and the audit trail records. */
|
|
75
|
+
export const clientAddress = (input: ForwardedInput): string | null => {
|
|
76
|
+
const forwarded = forwardedValue(input.headers.get(FORWARDED_FOR), input.config.trustedProxyHops);
|
|
77
|
+
if (forwarded === undefined) return input.socketAddress;
|
|
78
|
+
const address = withoutPort(forwarded);
|
|
79
|
+
return address.length > 0 ? address : input.socketAddress;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the CLIENT's leg of the connection was TLS. Read at the same hop index as the address,
|
|
84
|
+
* because `x-forwarded-proto` is as forgeable as `x-forwarded-for` — and this one decides whether
|
|
85
|
+
* a two-year `includeSubDomains` HSTS policy goes out.
|
|
86
|
+
*/
|
|
87
|
+
export const clientUsedHttps = (input: ForwardedInput): boolean => {
|
|
88
|
+
const forwarded = forwardedValue(
|
|
89
|
+
input.headers.get(FORWARDED_PROTO),
|
|
90
|
+
input.config.trustedProxyHops,
|
|
91
|
+
);
|
|
92
|
+
if (forwarded === undefined) return input.urlProtocol === 'https:';
|
|
93
|
+
return forwarded.toLowerCase() === 'https';
|
|
94
|
+
};
|
package/src/hooks.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// which keeps the import boundary intact and keeps
|
|
1
|
+
// The seams the HTTP layer cannot own itself: who the actor is (auth lives in `@ultimat3/auth`,
|
|
2
|
+
// tier 3), whether a policy allows the call (`@ultimat3/policy` is a sibling tier, so it cannot
|
|
3
|
+
// be imported here), and — a seam of a different kind, deciding nothing — what a dev diagnostic
|
|
4
|
+
// found. All three are declared structurally, which keeps the import boundary intact and keeps
|
|
5
|
+
// the pipeline testable.
|
|
5
6
|
import type { Actor } from '@ultimat3/core';
|
|
6
7
|
import type { RequestContext } from './context';
|
|
8
|
+
import type { OverlayNotice } from './overlay';
|
|
7
9
|
import type { UltimateRequest } from './request';
|
|
8
10
|
import type { Route } from './router';
|
|
9
11
|
|
|
@@ -29,4 +31,37 @@ export interface ServerHooks {
|
|
|
29
31
|
) => Promise<AuthzDecision> | AuthzDecision;
|
|
30
32
|
/** Observability sink; the pipeline still maps the error to a response itself. */
|
|
31
33
|
readonly onError?: (error: unknown, ctx: RequestContext) => void;
|
|
34
|
+
/**
|
|
35
|
+
* Dev-only: non-fatal findings a diagnostic accumulated for this request, rendered next to the
|
|
36
|
+
* error in the overlay. Consulted ONLY on the overlay path (`config.dev` and an HTML caller), so
|
|
37
|
+
* a production process never calls it; `x dev` is the only host that supplies one.
|
|
38
|
+
*/
|
|
39
|
+
readonly devNotices?: (ctx: RequestContext) => readonly OverlayNotice[];
|
|
32
40
|
}
|
|
41
|
+
|
|
42
|
+
export type Authenticator = NonNullable<ServerHooks['authenticate']>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The app's authenticator, if it declared one. A single value and not a list: two functions
|
|
46
|
+
* answering "who is this?" is two identities per request, and the one that ran first wins —
|
|
47
|
+
* the same failure `enforcedBy` exists to prevent one layer up.
|
|
48
|
+
*
|
|
49
|
+
* It is process-global for the reason `registerActions` and `defineService` are: the app has
|
|
50
|
+
* exactly one boot, and every host that starts a server (`x dev`, `apps/web/server.ts`) would
|
|
51
|
+
* otherwise need its own way to be handed the same function. `@ultimat3/auth` cannot supply it
|
|
52
|
+
* — it is tier 2, as this package is, so it can never import this one; the app is the only
|
|
53
|
+
* place both are in scope, and that is where the wire belongs.
|
|
54
|
+
*/
|
|
55
|
+
let configured: Authenticator | undefined;
|
|
56
|
+
|
|
57
|
+
export const configureAuthenticator = (authenticate: Authenticator): void => {
|
|
58
|
+
configured = authenticate;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** What a host passes as `hooks.authenticate`. `undefined` means every request is anonymous. */
|
|
62
|
+
export const configuredAuthenticator = (): Authenticator | undefined => configured;
|
|
63
|
+
|
|
64
|
+
/** Test seam. Production configures once at boot and never unconfigures. */
|
|
65
|
+
export const resetAuthenticator = (): void => {
|
|
66
|
+
configured = undefined;
|
|
67
|
+
};
|