@ultimat3/http 21.0.0 → 22.1.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 +178 -669
- package/README.md +20 -3
- package/package.json +5 -5
- package/src/cache-policy.ts +9 -0
- package/src/csrf.ts +38 -21
- package/src/error-facts.ts +11 -5
- package/src/error-log-level.ts +12 -0
- package/src/error-map.ts +21 -79
- package/src/error-page.ts +9 -1
- package/src/error-status.ts +82 -0
- package/src/errors.ts +0 -11
- package/src/index.ts +14 -26
- package/src/peer-identity.ts +19 -15
- package/src/problem-meta.ts +41 -4
- package/src/response.ts +9 -3
- package/src/security-headers.ts +29 -0
- package/src/server.ts +14 -3
- package/src/stages.ts +22 -12
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ asserts the order; `/_x` renders it. Ordering rules worth restating:
|
|
|
67
67
|
|
|
68
68
|
| Rule | Reason |
|
|
69
69
|
|---|---|
|
|
70
|
-
| admit second | a
|
|
70
|
+
| admit second | a stopped or saturated process refuses before any work — no route match, no auth, no body; a DRAINING one serves and closes the connection |
|
|
71
71
|
| auth before rate-limit | limiter keys per actor/tenant, not per NAT address |
|
|
72
72
|
| csrf after auth | only a caller holding an AMBIENT credential can be forged into; bearer and anonymous are exempt |
|
|
73
73
|
| csrf before body | a forged write never makes the server allocate its payload |
|
|
@@ -80,7 +80,8 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
80
80
|
| Guard | Answer |
|
|
81
81
|
|---|---|
|
|
82
82
|
| a body past `bodyLimitBytes` | read through the stream and abandoned the instant the running total crosses the limit — `content-length` or not, multipart included — as `X_BODY_INVALID` |
|
|
83
|
-
| a request carrying an identity on an `auth: 'public'` route | `cache-control: private`, never `s-maxage`; an anonymous one is shared-cacheable and keyed `vary: accept-language, cookie, x-timezone`. **Whatever the handler wrote**, `As of 2026-08-23`: the `cache-headers` stage REVIEWS a declared `cache-control` instead of standing down, because `@ultimat3/render`'s `ssrHeaders` offers every page without a `policy` to a CDN for 30s. An `immutable` answer is left alone — a content-addressed body is a function of its URL |
|
|
83
|
+
| a request carrying an identity on an `auth: 'public'` route | `cache-control: private`, never `s-maxage`; an anonymous one is shared-cacheable and keyed `vary: accept-language, cookie, x-timezone`. **Whatever the handler wrote**, `As of 2026-08-23`: the `cache-headers` stage REVIEWS a declared `cache-control` instead of standing down, because `@ultimat3/render`'s `ssrHeaders` offers every page without a `policy` to a CDN for 30s. An `immutable` answer is left alone — a content-addressed body is a function of its URL. **A declared hint too**, `As of 2026-09-23`: `meta.cache` / `ctx.cache` with `mode: 'public'` becomes `private` for a signed-in actor (`reviewedHint`) |
|
|
84
|
+
| any 5xx whose code did not opt in with `registerProblemMeta({ CODE: { publicCause: true } })` | `As of 2026-09-23`, not only an undeclared one — `X_DB_STATEMENT_FAILED` has a status row and served the Postgres message and the SQL. The framework opts in `X_DRAINING`, `X_OVERLOADED`, `X_FLIGHT_GATE_OVERLOADED` and `X_TIMEOUT`, whose cause is the instruction. The rest: |
|
|
84
85
|
| a 5xx nobody declared a status for | the code, the request id and a `fix:`; never the exception's own text. `error-page.ts` locked the browser out of it, and the problem document handed the same string to an agent — a driver's DSN, the row Postgres rejected. The real text goes to the log and the error report. `dev: true` renders it in full |
|
|
85
86
|
| a `security.csp.extend` key that is not a CSP token, or a source carrying `;`, `,` or a space | `X_CSP_DIRECTIVE_INVALID` at `defineHttpConfig` — `{ 'x; script-src *': [] }` is a second directive nobody declared |
|
|
86
87
|
| a repeated form field | a LIST, exactly as a repeated query parameter is. One collector for query, urlencoded and multipart; `Object.fromEntries` kept the last value, so a checkbox group reached the schema as one string |
|
|
@@ -97,7 +98,11 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
97
98
|
| a credentialed unsafe method that cannot be shown to be same-origin | `X_CSRF_BLOCKED` (403). `sec-fetch-site: same-origin`, `Origin` equal to this app, or an EXACT listing in `cors.origins` — anything else is refused before the body is read. `origins: ['*']` lists nobody here: `'*'` is a value for the response header, not a per-origin allowance |
|
|
98
99
|
| a request past `requestTimeoutMs` (30s) | `ctx.signal` aborts and the socket is answered `X_TIMEOUT` (504); a caller may shorten the deadline with `x-request-timeout-ms`, never lengthen it |
|
|
99
100
|
| the caller going away mid-request | `ctx.signal` aborts on the inbound `Request.signal` too, so a closed tab unwinds cooperative work instead of holding its pool slot for the rest of the budget. Both halves are one signal (`AbortSignal.any`), and `requestTimeoutMs: 0` still delivers the caller's |
|
|
100
|
-
| a request while the process is draining | `
|
|
101
|
+
| a request while the process is draining | SERVED, with `connection: close` so the client's next request lands on another pod, `As of 2026-09-23` — refusing it failed 598 of 7,690 requests across one helm upgrade on kind, every one on a kept-alive connection inside the readiness grace. Only a STOPPED process (resources closed) answers `X_DRAINING` (503) + `retry-after` |
|
|
102
|
+
| SIGTERM | `/readyz` answers 503 at once, the listener stays open for `drain.readinessGraceMs` (core; 5000 ms outside development/test, 0 inside), then closes and the drain runs. Pass `createServer({ …, drain: appConfig.drain })` so `app.config.ts`'s value is the one applied; omitted, core's default holds and a `configureLifecycle({ readinessGraceMs })` stands |
|
|
103
|
+
| `?locale=es` | the locale source that outranks the cookie and the header (`resolveLocale`'s order), `As of 2026-09-23` — documented in `wiki/I18n.md` and never read before |
|
|
104
|
+
| a 4xx | logged at `warn` (401, 403, 429) or `info` (every other 4xx); only a 5xx is an `error` line |
|
|
105
|
+
| an `x-forwarded-client-cert` value in quotes | unescaped ONCE, after the pairs are split — `Subject="O=Acme; Inc,CN=svc-one"` is that whole subject, not `O=Acme` |
|
|
101
106
|
| a request past `maxInflight` (1000) | `X_OVERLOADED` (503) + `retry-after`, shed in the `admit` stage before any work |
|
|
102
107
|
|
|
103
108
|
`handle()` resolves to a Response, always — a stage that throws after the handler, or while
|
|
@@ -354,6 +359,9 @@ import { registerErrorStatus, registerProblemMeta } from '@ultimat3/http';
|
|
|
354
359
|
|
|
355
360
|
registerErrorStatus({ X_SESSION_CHECKOUT_BUSY: 409 });
|
|
356
361
|
registerProblemMeta({ X_SESSION_CHECKOUT_BUSY: ['sessionId', 'title', 'state'] });
|
|
362
|
+
// A 5xx whose cause is written for the caller, and may be shown in production:
|
|
363
|
+
registerProblemMeta({ X_BILLING_DOWN: { publicCause: true } });
|
|
364
|
+
// Both at once: { keys: ['sessionId'], publicCause: true }
|
|
357
365
|
```
|
|
358
366
|
|
|
359
367
|
The document then carries `meta: { sessionId, title, state }` — the declared keys that are set,
|
|
@@ -366,6 +374,15 @@ as is `issues`, which has its own top-level home. `@ultimat3/action`'s typed cli
|
|
|
366
374
|
member back on the rebuilt error's `meta`, so an island reads `error.meta.sessionId` where it
|
|
367
375
|
used to run a regex over `cause`.
|
|
368
376
|
|
|
377
|
+
### Error classes
|
|
378
|
+
|
|
379
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
380
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
381
|
+
|
|
382
|
+
| Class | Code | Declared in |
|
|
383
|
+
|---|---|---|
|
|
384
|
+
| `HttpError` | any `HttpErrorCode` — `HTTP_ERROR_CODES` | `src/errors.ts` |
|
|
385
|
+
|
|
369
386
|
## Boundaries
|
|
370
387
|
|
|
371
388
|
Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time` —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.1.0",
|
|
4
4
|
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "22.1.0",
|
|
35
|
+
"@ultimat3/i18n": "22.1.0",
|
|
36
|
+
"@ultimat3/schema": "22.1.0",
|
|
37
|
+
"@ultimat3/time": "22.1.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/cache-policy.ts
CHANGED
|
@@ -40,3 +40,12 @@ export const defaultCache = (route: Route | undefined, actor: Actor): CacheHint
|
|
|
40
40
|
if (!isAnonymous(actor)) return PRIVATE_CACHE;
|
|
41
41
|
return { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
|
|
42
42
|
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A DECLARED hint — `meta.cache` or `ctx.cache` — reviewed against the actor, as a handler's own
|
|
46
|
+
* header is. `mode: 'public'` on a route answered `public, s-maxage=3600` over a body carrying the
|
|
47
|
+
* signed-in actor's id, so a CDN served one user's document to every later visitor. `immutable`
|
|
48
|
+
* is exempt for the reason `offersSharedCache` gives: its body is a function of the URL alone.
|
|
49
|
+
*/
|
|
50
|
+
export const reviewedHint = (hint: CacheHint, actor: Actor): CacheHint =>
|
|
51
|
+
hint.mode === 'public' && !isAnonymous(actor) ? PRIVATE_CACHE : hint;
|
package/src/csrf.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// went through. `setRedirect` exists so those form posts work without JS, which makes them a
|
|
6
6
|
// first-class surface here rather than a legacy one.
|
|
7
7
|
|
|
8
|
+
import { classifyAddress, proveSameOrigin } from '@ultimat3/core';
|
|
8
9
|
import type { CorsConfig } from './cors';
|
|
9
10
|
import { originListed } from './cors';
|
|
11
|
+
import { HttpError } from './errors';
|
|
10
12
|
|
|
11
13
|
export type CsrfMode = 'origin' | 'off';
|
|
12
14
|
|
|
@@ -24,9 +26,6 @@ export const DEFAULT_CSRF: CsrfConfig = { mode: 'origin' };
|
|
|
24
26
|
/** Methods with no side effects, per RFC 9110. A CSRF check on these is a check on nothing. */
|
|
25
27
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
|
26
28
|
|
|
27
|
-
/** The complete `Sec-Fetch-Site` vocabulary. Anything else was written by a non-browser. */
|
|
28
|
-
const KNOWN_SITES = new Set(['same-origin', 'same-site', 'cross-site', 'none']);
|
|
29
|
-
|
|
30
29
|
export interface CsrfCheckInput {
|
|
31
30
|
readonly method: string;
|
|
32
31
|
/** The origin this app was reached on — scheme from `ctx.https`, host from the request URL. */
|
|
@@ -58,31 +57,49 @@ export const checkCsrf = (input: CsrfCheckInput): CsrfVerdict => {
|
|
|
58
57
|
if (input.anonymous) return { ok: true };
|
|
59
58
|
if (input.hasAuthorizationHeader) return { ok: true };
|
|
60
59
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// The rule itself is core's, shared with the sync node's upgrade: one answer to "did this
|
|
61
|
+
// come from this app?" on every surface an ambient credential reaches.
|
|
62
|
+
//
|
|
64
63
|
// `originListed`, never `allowedOrigin`: that one answers the RESPONSE header, and for
|
|
65
64
|
// `origins: ['*'], credentials: false` — the only wildcard `assertCorsConfig` admits — its
|
|
66
65
|
// answer is `'*'`, which is not null and so read as "this origin is one we allow". A
|
|
67
66
|
// credentialed cross-site POST from evil.test was therefore accepted by the check that exists
|
|
68
67
|
// to refuse exactly it. An exact match is the only allowance a write may be built on.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
return {
|
|
78
|
-
ok: false,
|
|
79
|
-
reason:
|
|
80
|
-
input.origin === null
|
|
81
|
-
? 'the request carried neither sec-fetch-site nor origin, so it cannot be shown to be same-origin'
|
|
82
|
-
: 'the origin it declares is not this app and is not listed in http.cors.origins',
|
|
83
|
-
};
|
|
68
|
+
return proveSameOrigin({
|
|
69
|
+
selfOrigins: [input.selfOrigin],
|
|
70
|
+
origin: input.origin,
|
|
71
|
+
secFetchSite: input.secFetchSite,
|
|
72
|
+
listed: (origin) => originListed(input.cors, origin),
|
|
73
|
+
listName: 'http.cors.origins',
|
|
74
|
+
});
|
|
84
75
|
};
|
|
85
76
|
|
|
86
77
|
/** The origin a browser compares against — the PUBLIC one, so a TLS-terminating proxy agrees. */
|
|
87
78
|
export const selfOrigin = (url: URL, https: boolean): string =>
|
|
88
79
|
`${https ? 'https' : 'http'}://${url.host}`;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A write that arrived with the browser's ambient credential and could not be shown to come from
|
|
83
|
+
* this app. Never a 401: the caller IS signed in, which is precisely the problem.
|
|
84
|
+
*
|
|
85
|
+
* From a LOOPBACK `ip` the caller is `curl` against `x dev`: it sends neither
|
|
86
|
+
* header, and the remedies below name a token or a config change dev does not need. The header a
|
|
87
|
+
* browser would send is the whole repair there, and it is safe to hand out: CSRF defends a BROWSER
|
|
88
|
+
* from a hostile page, and a client that can set headers is not one a page can drive.
|
|
89
|
+
*/
|
|
90
|
+
export const csrfBlocked = (
|
|
91
|
+
pathname: string,
|
|
92
|
+
reason: string,
|
|
93
|
+
ip: string | null = null,
|
|
94
|
+
): HttpError =>
|
|
95
|
+
ip !== null && classifyAddress(ip) === 'loopback'
|
|
96
|
+
? new HttpError({
|
|
97
|
+
code: 'X_CSRF_BLOCKED',
|
|
98
|
+
cause: `${pathname} refused a credentialed write: ${reason}`,
|
|
99
|
+
fix: "curl -H 'sec-fetch-site: same-origin' -X POST http://localhost:3000/the-path-above # a local client proves same-origin the way a browser does; the session cookie still decides who is calling",
|
|
100
|
+
})
|
|
101
|
+
: new HttpError({
|
|
102
|
+
code: 'X_CSRF_BLOCKED',
|
|
103
|
+
cause: `${pathname} refused a credentialed write: ${reason}`,
|
|
104
|
+
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
|
|
105
|
+
});
|
package/src/error-facts.ts
CHANGED
|
@@ -11,9 +11,9 @@ import {
|
|
|
11
11
|
stringField,
|
|
12
12
|
} from '@ultimat3/core';
|
|
13
13
|
import type { ValidationIssue } from '@ultimat3/schema';
|
|
14
|
-
import { declaredStatusFor, statusFor } from './error-
|
|
14
|
+
import { declaredStatusFor, statusFor } from './error-status';
|
|
15
15
|
import { HTTP_ERROR_TITLES } from './errors';
|
|
16
|
-
import { type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
16
|
+
import { hasPublicCause, type ProblemMeta, problemMetaKeysFor, wireMeta } from './problem-meta';
|
|
17
17
|
|
|
18
18
|
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
|
|
19
19
|
export interface ErrorFacts {
|
|
@@ -300,11 +300,17 @@ export const toProblem = (
|
|
|
300
300
|
): ProblemDocument => {
|
|
301
301
|
const facts = factsOf(error);
|
|
302
302
|
const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
|
|
303
|
+
// Wider than `opaque`: EVERY 5xx cause is the server's own business unless its code declared it
|
|
304
|
+
// public (`hasPublicCause`). A status row made `X_DB_STATEMENT_FAILED` "classified", so its cause
|
|
305
|
+
// — the Postgres message and the statement — was served in production 500 bodies. The title
|
|
306
|
+
// stays: a registered title is framework prose, never the server's words.
|
|
307
|
+
const hidden =
|
|
308
|
+
opaque || (meta.dev !== true && facts.status >= 500 && !hasPublicCause(facts.code));
|
|
303
309
|
// Dropped under EXACTLY the condition that blanks `title`, `detail` and `cause`. An issue list
|
|
304
310
|
// on a failure nobody classified is precisely the internal detail `INTERNAL_CAUSE` exists to
|
|
305
311
|
// withhold — it names the fields and the expectations of something the caller was never meant to
|
|
306
312
|
// see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
|
|
307
|
-
const issues =
|
|
313
|
+
const issues = hidden ? undefined : issuesOf(error);
|
|
308
314
|
// The same condition, for the same reason: a code nobody classified cannot have declared any
|
|
309
315
|
// key either, so this is the belt on `registerProblemMeta`'s "both registrations are needed".
|
|
310
316
|
const carried = opaque ? undefined : metaOf(error, facts.code);
|
|
@@ -312,10 +318,10 @@ export const toProblem = (
|
|
|
312
318
|
type: problemTypeFor(facts.code),
|
|
313
319
|
title: opaque ? INTERNAL_TITLE : facts.title,
|
|
314
320
|
status: facts.status,
|
|
315
|
-
detail:
|
|
321
|
+
detail: hidden ? INTERNAL_CAUSE : facts.cause,
|
|
316
322
|
instance: meta.instance,
|
|
317
323
|
code: facts.code,
|
|
318
|
-
cause:
|
|
324
|
+
cause: hidden ? INTERNAL_CAUSE : facts.cause,
|
|
319
325
|
fix: facts.fix,
|
|
320
326
|
docs: facts.docs,
|
|
321
327
|
requestId: meta.requestId,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Single responsibility: the log level of the one line the `error-map` stage writes per failed
|
|
2
|
+
// request. Every 4xx was logged at `error`, so an alert keyed on error lines paged for a typo'd URL.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `error` for 5xx — the server failed. `warn` for 401/403/429 — a refused credential, a refused
|
|
6
|
+
* permission or a limit, each worth seeing in bulk. `info` for every other 4xx — the caller's own
|
|
7
|
+
* mistake, which the problem document already explained to them.
|
|
8
|
+
*/
|
|
9
|
+
export const errorLogLevel = (status: number): 'error' | 'warn' | 'info' => {
|
|
10
|
+
if (status >= 500) return 'error';
|
|
11
|
+
return status === 401 || status === 403 || status === 429 ? 'warn' : 'info';
|
|
12
|
+
};
|
package/src/error-map.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The one place a framework error code becomes an HTTP status. A table, not a
|
|
2
2
|
// switch chain: adding a code elsewhere in the framework means adding a row here,
|
|
3
3
|
// and a missing row is a loud 500 rather than a silently wrong 200.
|
|
4
|
-
// Rendering a throwable for a reader is `error-facts.ts`; this
|
|
5
|
-
|
|
4
|
+
// Rendering a throwable for a reader is `error-facts.ts`; reading this table, and the app's own
|
|
5
|
+
// half of it, is `error-status.ts`. This file is the table.
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* code -> status. Codes owned by other packages are listed here on purpose: HTTP
|
|
@@ -215,6 +215,12 @@ export const ERROR_STATUS = {
|
|
|
215
215
|
// 500, and it should page: a queue holding rows this build cannot read is an operator's
|
|
216
216
|
// problem, and nothing the caller sent is wrong.
|
|
217
217
|
X_JOB_ROW_STATUS_UNKNOWN: 500,
|
|
218
|
+
// A requeue of a job that is still live — the admin panel's retry, `x jobs retry` over HTTP. The
|
|
219
|
+
// job's STATE is wrong, not the server: 409, like `X_STORAGE_QUARANTINED`.
|
|
220
|
+
X_JOB_NOT_REQUEUEABLE: 409,
|
|
221
|
+
// Thrown by `job()` at declaration, while modules load; no request is answered with it. The row
|
|
222
|
+
// exists for `X_ACTION_JOB_UNBRIDGED`'s reason: the table is closed, and it is a 500 anyway.
|
|
223
|
+
X_JOB_DECLARATION_INVALID: 500,
|
|
218
224
|
// Thrown by `registerJobs()` while the app's modules load, so no request is ever answered with
|
|
219
225
|
// it either — the row exists for the reason `X_CORS_CONFIG_INVALID`'s does: this table is the
|
|
220
226
|
// closed one, and a code with no row is a 500 anyway.
|
|
@@ -277,6 +283,9 @@ export const ERROR_STATUS = {
|
|
|
277
283
|
// paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
|
|
278
284
|
// the client sends changes the answer, and the report to the on-call monitor is the point.
|
|
279
285
|
X_QUERY_NOT_PAGEABLE: 500,
|
|
286
|
+
// A read filtering or sorting on a column its loader never selected: the fix is the loader's
|
|
287
|
+
// `select`, nothing the caller sends changes it — the same server-side decision as the row above.
|
|
288
|
+
X_QUERY_COLUMN_UNSELECTED: 500,
|
|
280
289
|
// @ultimat3/i18n — a well-formed tag outside the set this app ships, asserted on a value the
|
|
281
290
|
// caller supplied (`assertSupportedLocale`). 400 rather than 406: the http `locale` stage
|
|
282
291
|
// negotiates `Accept-Language` and never throws, so the tag that reaches here came from a path,
|
|
@@ -320,6 +329,9 @@ export const ERROR_STATUS = {
|
|
|
320
329
|
// refuses it until the app's own scanner calls `releaseQuarantine`, which is a thing the caller
|
|
321
330
|
// can do. A 500 would have read as "the server broke" for a workflow working exactly as built.
|
|
322
331
|
X_STORAGE_QUARANTINED: 409,
|
|
332
|
+
// 409 for the same reason: `promoteAttachment` on a key that is not a pending upload — promoted
|
|
333
|
+
// twice, or never uploaded — is a state the caller can see and fix, not a server fault.
|
|
334
|
+
X_STORAGE_NOT_PENDING: 409,
|
|
323
335
|
// 404, deliberately NOT 403: the org check fires before anything is read, so answering
|
|
324
336
|
// "forbidden" would confirm that a key exists to the one caller who must not learn it.
|
|
325
337
|
X_STORAGE_ORG_MISMATCH: 404,
|
|
@@ -411,6 +423,13 @@ export const ERROR_STATUS = {
|
|
|
411
423
|
X_LOCAL_STORE_UNAVAILABLE: 500,
|
|
412
424
|
X_MUTATOR_CLOCK_MISSING: 500,
|
|
413
425
|
X_REALTIME_UNINSTALLED: 500,
|
|
426
|
+
// Boot-time: a realtime config and its environment that disagree refuse before any request.
|
|
427
|
+
X_REALTIME_TOPOLOGY: 500,
|
|
428
|
+
// The replicator's own Postgres connection failed TLS. A role that never answers a request, so the
|
|
429
|
+
// row keeps the table closed at 500, beside `X_REALTIME_TOPOLOGY`.
|
|
430
|
+
X_REPLICATION_TLS: 500,
|
|
431
|
+
// A browser page on a foreign origin asked for a socket — refused, not unauthenticated: 403.
|
|
432
|
+
X_SOCKET_ORIGIN_REFUSED: 403,
|
|
414
433
|
X_RECORD_KEY_MISSING: 500,
|
|
415
434
|
X_RECORD_REJECTED: 500,
|
|
416
435
|
X_SYNC_UNCONFIGURED: 500,
|
|
@@ -420,80 +439,3 @@ export const ERROR_STATUS = {
|
|
|
420
439
|
// has to be a compile error rather than an `undefined` a test then asserts `toBeNumber()` on.
|
|
421
440
|
// Read it by a code the framework did not mint through `statusFor`, never by index.
|
|
422
441
|
} satisfies Readonly<Record<string, number>>;
|
|
423
|
-
|
|
424
|
-
export const DEFAULT_STATUS = 500;
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* The framework's row for a code, or `undefined` — through `Object.hasOwn`, never `[code]`.
|
|
428
|
-
*
|
|
429
|
-
* `code` is a STRING read off a throwable this package did not build, and `ERROR_STATUS` is an
|
|
430
|
-
* object literal, so it holds every name on `Object.prototype`: an app throwing
|
|
431
|
-
* `{ code: 'toString' }` read a FUNCTION out of this table. `statusFor` handed it to
|
|
432
|
-
* `new Response(body, { status })` — a `RangeError` raised inside `recoverWith`'s fallback, the
|
|
433
|
-
* one frame with nothing above it, so `Pipeline.handle` REJECTED against its own contract.
|
|
434
|
-
* `scripts/error-map.ts` reads the same table this way already.
|
|
435
|
-
*/
|
|
436
|
-
const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
|
|
437
|
-
|
|
438
|
-
const frameworkStatus = (code: string): number | undefined =>
|
|
439
|
-
Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Statuses for codes the APP owns. The table above is closed — it has to be, it is the
|
|
443
|
-
* framework's own contract — and every code outside it fell to 500, so a wrong password was an
|
|
444
|
-
* incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
|
|
445
|
-
* whoever was on call. This is the app's half of the same table, kept separate so a registration
|
|
446
|
-
* can never move `X_FORBIDDEN` off 403.
|
|
447
|
-
*/
|
|
448
|
-
const APP_ERROR_STATUS = new Map<string, number>();
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Declare the status for the codes this app throws. Call it once at boot, beside the module
|
|
452
|
-
* that declares the codes — importing that module IS the registration, the convention
|
|
453
|
-
* `registerActions` and `registerErrorCodes` already use.
|
|
454
|
-
*
|
|
455
|
-
* ```ts
|
|
456
|
-
* registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
|
|
457
|
-
* ```
|
|
458
|
-
*/
|
|
459
|
-
export const registerErrorStatus = (statuses: Readonly<Record<string, number>>): void => {
|
|
460
|
-
for (const [code, status] of Object.entries(statuses)) {
|
|
461
|
-
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
462
|
-
throw errorStatusInvalid(code, `${String(status)} is not an HTTP status (100-599)`);
|
|
463
|
-
}
|
|
464
|
-
// The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
|
|
465
|
-
// to 200 would be an app whose 401 contract every client already depends on, changed.
|
|
466
|
-
// Through `frameworkStatus`, so this refusal cannot answer for a code the framework does not
|
|
467
|
-
// own: `registerErrorStatus({ toString: 401 })` was rejected with a cause reading `the
|
|
468
|
-
// framework already maps it to function toString() { [native code] }`.
|
|
469
|
-
const framework = frameworkStatus(code);
|
|
470
|
-
if (framework !== undefined) {
|
|
471
|
-
throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
|
|
472
|
-
}
|
|
473
|
-
const existing = APP_ERROR_STATUS.get(code);
|
|
474
|
-
if (existing !== undefined && existing !== status) {
|
|
475
|
-
throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
|
|
476
|
-
}
|
|
477
|
-
APP_ERROR_STATUS.set(code, status);
|
|
478
|
-
}
|
|
479
|
-
};
|
|
480
|
-
|
|
481
|
-
/** Test seam. Production registers once at boot and never unregisters. */
|
|
482
|
-
export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
|
|
486
|
-
* nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
|
|
487
|
-
* "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
|
|
488
|
-
* reads to decide whether a 5xx may carry the throwable's own words back to the caller.
|
|
489
|
-
*
|
|
490
|
-
* Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
491
|
-
* belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
492
|
-
* even if a future caller reaches the map some other way.
|
|
493
|
-
* `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
|
|
494
|
-
* prefer one for anything keyed by a value a caller chose.
|
|
495
|
-
*/
|
|
496
|
-
export const declaredStatusFor = (code: string): number | undefined =>
|
|
497
|
-
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
|
|
498
|
-
|
|
499
|
-
export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
|
package/src/error-page.ts
CHANGED
|
@@ -124,9 +124,17 @@ const hrefFor = (copy: ErrorPageCopy, input: ErrorPageInput): string => {
|
|
|
124
124
|
return input.signInPath;
|
|
125
125
|
// A retry has to be the page the visitor was on; with no request behind the page there is no
|
|
126
126
|
// such address, so it degrades to the one link that is always right.
|
|
127
|
-
return
|
|
127
|
+
return copy.action === 'retry' && input.path !== undefined ? sameOriginPath(input.path) : '/';
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The path as a link that cannot leave this origin. A request for `//evil.com/x` has that
|
|
132
|
+
* pathname, and `href="//evil.com/x"` is protocol-relative — the retry button on the 503 served
|
|
133
|
+
* while draining sent the visitor to another host. A browser reads `\` as `/` in an http href,
|
|
134
|
+
* so the leading run of either collapses to one `/`.
|
|
135
|
+
*/
|
|
136
|
+
const sameOriginPath = (path: string): string => path.replace(/^[/\\]*/, '/');
|
|
137
|
+
|
|
130
138
|
const link = (href: string, label: string): string =>
|
|
131
139
|
`<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
|
|
132
140
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Reading the status table: the framework's row for a code through `Object.hasOwn`, the app's
|
|
2
|
+
// own codes beside it (`registerErrorStatus`), and the answer a response gets. The table itself is
|
|
3
|
+
// `error-map.ts`; split out when the table outgrew the file that also read it.
|
|
4
|
+
import { ERROR_STATUS } from './error-map';
|
|
5
|
+
import { errorStatusInvalid } from './errors';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_STATUS = 500;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The framework's row for a code, or `undefined` — through `Object.hasOwn`, never `[code]`.
|
|
11
|
+
*
|
|
12
|
+
* `code` is a STRING read off a throwable this package did not build, and `ERROR_STATUS` is an
|
|
13
|
+
* object literal, so it holds every name on `Object.prototype`: an app throwing
|
|
14
|
+
* `{ code: 'toString' }` read a FUNCTION out of this table. `statusFor` handed it to
|
|
15
|
+
* `new Response(body, { status })` — a `RangeError` raised inside `recoverWith`'s fallback, the
|
|
16
|
+
* one frame with nothing above it, so `Pipeline.handle` REJECTED against its own contract.
|
|
17
|
+
* `scripts/error-map.ts` reads the same table this way already.
|
|
18
|
+
*/
|
|
19
|
+
const BY_CODE: Readonly<Record<string, number>> = ERROR_STATUS;
|
|
20
|
+
|
|
21
|
+
const frameworkStatus = (code: string): number | undefined =>
|
|
22
|
+
Object.hasOwn(BY_CODE, code) ? BY_CODE[code] : undefined;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Statuses for codes the APP owns. The table above is closed — it has to be, it is the
|
|
26
|
+
* framework's own contract — and every code outside it fell to 500, so a wrong password was an
|
|
27
|
+
* incident: `pipeline.ts` reports `status >= 500` to the error monitor, and a user's typo paged
|
|
28
|
+
* whoever was on call. This is the app's half of the same table, kept separate so a registration
|
|
29
|
+
* can never move `X_FORBIDDEN` off 403.
|
|
30
|
+
*/
|
|
31
|
+
const APP_ERROR_STATUS = new Map<string, number>();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declare the status for the codes this app throws. Call it once at boot, beside the module
|
|
35
|
+
* that declares the codes — importing that module IS the registration, the convention
|
|
36
|
+
* `registerActions` and `registerErrorCodes` already use.
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* registerErrorStatus({ X_CREDENTIALS_INVALID: 401, X_SIGNUP_CLOSED: 403 });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const registerErrorStatus = (statuses: Readonly<Record<string, number>>): void => {
|
|
43
|
+
for (const [code, status] of Object.entries(statuses)) {
|
|
44
|
+
if (!Number.isInteger(status) || status < 100 || status > 599) {
|
|
45
|
+
throw errorStatusInvalid(code, `${String(status)} is not an HTTP status (100-599)`);
|
|
46
|
+
}
|
|
47
|
+
// The framework's own codes are not negotiable: an app that could map `X_UNAUTHENTICATED`
|
|
48
|
+
// to 200 would be an app whose 401 contract every client already depends on, changed.
|
|
49
|
+
// Through `frameworkStatus`, so this refusal cannot answer for a code the framework does not
|
|
50
|
+
// own: `registerErrorStatus({ toString: 401 })` was rejected with a cause reading `the
|
|
51
|
+
// framework already maps it to function toString() { [native code] }`.
|
|
52
|
+
const framework = frameworkStatus(code);
|
|
53
|
+
if (framework !== undefined) {
|
|
54
|
+
throw errorStatusInvalid(code, `the framework already maps it to ${framework}`);
|
|
55
|
+
}
|
|
56
|
+
const existing = APP_ERROR_STATUS.get(code);
|
|
57
|
+
if (existing !== undefined && existing !== status) {
|
|
58
|
+
throw errorStatusInvalid(code, `already registered as ${existing} by this app`);
|
|
59
|
+
}
|
|
60
|
+
APP_ERROR_STATUS.set(code, status);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Test seam. Production registers once at boot and never unregisters. */
|
|
65
|
+
export const resetErrorStatus = (): void => APP_ERROR_STATUS.clear();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
|
|
69
|
+
* nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
|
|
70
|
+
* "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
|
|
71
|
+
* reads to decide whether a 5xx may carry the throwable's own words back to the caller.
|
|
72
|
+
*
|
|
73
|
+
* Framework table first: `registerErrorStatus` already refuses those codes, so the order is
|
|
74
|
+
* belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
|
|
75
|
+
* even if a future caller reaches the map some other way.
|
|
76
|
+
* `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
|
|
77
|
+
* prefer one for anything keyed by a value a caller chose.
|
|
78
|
+
*/
|
|
79
|
+
export const declaredStatusFor = (code: string): number | undefined =>
|
|
80
|
+
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
|
|
81
|
+
|
|
82
|
+
export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;
|
package/src/errors.ts
CHANGED
|
@@ -440,17 +440,6 @@ export const overloaded = (inflight: number, ceiling: number): HttpError =>
|
|
|
440
440
|
fix: 'retry after the Retry-After header; to serve more at once call configureHttp({ maxInflight: 2000 }) at module scope in a file under apps/*/, and add replicas to match',
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
-
/**
|
|
444
|
-
* A write that arrived with the browser's ambient credential and could not be shown to come from
|
|
445
|
-
* this app. Never a 401: the caller IS signed in, which is precisely the problem.
|
|
446
|
-
*/
|
|
447
|
-
export const csrfBlocked = (pathname: string, reason: string): HttpError =>
|
|
448
|
-
new HttpError({
|
|
449
|
-
code: 'X_CSRF_BLOCKED',
|
|
450
|
-
cause: `${pathname} refused a credentialed write: ${reason}`,
|
|
451
|
-
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
|
|
452
|
-
});
|
|
453
|
-
|
|
454
443
|
/**
|
|
455
444
|
* The request ran past its deadline. `X_TIMEOUT` is borrowed (see `HTTP_BORROWED_ERROR_CODES`)
|
|
456
445
|
* and already maps to 504. The abort fires first for cooperative code; this is what the socket
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
|
|
|
16
16
|
export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
|
|
17
17
|
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
18
18
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
19
|
-
export { defineHttpConfig, MAX_PROXY_HOPS
|
|
19
|
+
export { defineHttpConfig, MAX_PROXY_HOPS } from './config';
|
|
20
20
|
export type { ActorView, RequestContext, RequestContextInit } from './context';
|
|
21
21
|
export {
|
|
22
22
|
actorView,
|
|
@@ -29,11 +29,10 @@ export {
|
|
|
29
29
|
useRequestHeaders,
|
|
30
30
|
} from './context';
|
|
31
31
|
export type { InboundCorrelation } from './correlation';
|
|
32
|
-
export { readCorrelation } from './correlation';
|
|
33
32
|
export type { CorsConfig } from './cors';
|
|
34
33
|
export { allowedOrigin, corsHeaders, DEFAULT_CORS, originListed, preflight } from './cors';
|
|
35
34
|
export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
|
|
36
|
-
export { checkCsrf,
|
|
35
|
+
export { checkCsrf, csrfBlocked } from './csrf';
|
|
37
36
|
export type { Deadline } from './deadline';
|
|
38
37
|
export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
|
|
39
38
|
export type { ErrorFacts, ProblemDocument } from './error-facts';
|
|
@@ -44,13 +43,7 @@ export {
|
|
|
44
43
|
retryAfterOf,
|
|
45
44
|
toProblem,
|
|
46
45
|
} from './error-facts';
|
|
47
|
-
export {
|
|
48
|
-
DEFAULT_STATUS,
|
|
49
|
-
ERROR_STATUS,
|
|
50
|
-
registerErrorStatus,
|
|
51
|
-
resetErrorStatus,
|
|
52
|
-
statusFor,
|
|
53
|
-
} from './error-map';
|
|
46
|
+
export { ERROR_STATUS } from './error-map';
|
|
54
47
|
export type {
|
|
55
48
|
ErrorPageAction,
|
|
56
49
|
ErrorPageCopy,
|
|
@@ -64,11 +57,16 @@ export {
|
|
|
64
57
|
renderErrorPage,
|
|
65
58
|
resolveErrorPageCopy,
|
|
66
59
|
} from './error-page';
|
|
60
|
+
export {
|
|
61
|
+
DEFAULT_STATUS,
|
|
62
|
+
registerErrorStatus,
|
|
63
|
+
resetErrorStatus,
|
|
64
|
+
statusFor,
|
|
65
|
+
} from './error-status';
|
|
67
66
|
export type { HttpErrorCode } from './errors';
|
|
68
67
|
export {
|
|
69
68
|
bodyInvalid,
|
|
70
69
|
buildSkew,
|
|
71
|
-
csrfBlocked,
|
|
72
70
|
draining,
|
|
73
71
|
errorStatusInvalid,
|
|
74
72
|
finalizeFailed,
|
|
@@ -94,29 +92,24 @@ export {
|
|
|
94
92
|
export type { ForwardedInput, ForwardedSplit } from './forwarded';
|
|
95
93
|
export {
|
|
96
94
|
clientAddress,
|
|
97
|
-
clientUsedHttps,
|
|
98
|
-
FORWARDED_CLIENT_CERT,
|
|
99
|
-
FORWARDED_FOR,
|
|
100
|
-
FORWARDED_PROTO,
|
|
101
95
|
forwardedElement,
|
|
102
|
-
forwardedValue,
|
|
103
96
|
} from './forwarded';
|
|
104
97
|
export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
|
|
105
98
|
export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
|
|
106
|
-
export {
|
|
99
|
+
export { escapeHtml } from './html-render';
|
|
107
100
|
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
108
|
-
export { DEFAULT_LOCALE_CONFIG,
|
|
101
|
+
export { DEFAULT_LOCALE_CONFIG, readCookie } from './locale';
|
|
109
102
|
export type { Middleware } from './middleware';
|
|
110
103
|
export { compose } from './middleware';
|
|
111
104
|
export type { OverlayMeta, OverlayNotice } from './overlay';
|
|
112
|
-
export { overlayResponse,
|
|
105
|
+
export { overlayResponse, wantsOverlay } from './overlay';
|
|
113
106
|
export { OVERLAY_STYLE } from './overlay-style';
|
|
114
107
|
export type { PeerIdentity } from './peer-identity';
|
|
115
108
|
export { peerIdentity } from './peer-identity';
|
|
116
109
|
export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
|
|
117
110
|
export { createPipeline, PIPELINE_STAGES } from './pipeline';
|
|
118
|
-
export type { ProblemMeta, ProblemMetaValue } from './problem-meta';
|
|
119
|
-
export { MAX_PROBLEM_META_BYTES, registerProblemMeta
|
|
111
|
+
export type { ProblemMeta, ProblemMetaDeclaration, ProblemMetaValue } from './problem-meta';
|
|
112
|
+
export { MAX_PROBLEM_META_BYTES, registerProblemMeta } from './problem-meta';
|
|
120
113
|
export type {
|
|
121
114
|
Bucket,
|
|
122
115
|
MemoryRateLimitStore,
|
|
@@ -138,7 +131,6 @@ export {
|
|
|
138
131
|
rateLimitDecision,
|
|
139
132
|
rateLimitSpends,
|
|
140
133
|
resolveRateLimitConfig,
|
|
141
|
-
TENANT_SCOPE,
|
|
142
134
|
toBucket,
|
|
143
135
|
} from './rate-limit';
|
|
144
136
|
export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
|
|
@@ -159,10 +151,7 @@ export type {
|
|
|
159
151
|
} from './rate-limit-postgres';
|
|
160
152
|
export {
|
|
161
153
|
postgresRateLimitStore,
|
|
162
|
-
SQL_RATE_LIMIT_PURGE,
|
|
163
|
-
SQL_RATE_LIMIT_RESET,
|
|
164
154
|
SQL_RATE_LIMIT_TABLE,
|
|
165
|
-
SQL_RATE_LIMIT_TAKE,
|
|
166
155
|
} from './rate-limit-postgres';
|
|
167
156
|
export { setRedirect, takeRedirect } from './redirect';
|
|
168
157
|
export type { QueryValues } from './request';
|
|
@@ -196,7 +185,6 @@ export {
|
|
|
196
185
|
describeRoutes,
|
|
197
186
|
HTTP_METHODS,
|
|
198
187
|
matchRoute,
|
|
199
|
-
normalizePath,
|
|
200
188
|
} from './router';
|
|
201
189
|
export type { SecurityConfig } from './security-headers';
|
|
202
190
|
export { buildCsp, cspHashSource, DEFAULT_SECURITY, securityHeaders } from './security-headers';
|