@ultimat3/http 9.0.0 → 11.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 +55 -0
- package/README.md +11 -1
- package/package.json +5 -5
- package/src/auth-redirect.ts +4 -4
- package/src/cache-policy.ts +19 -1
- package/src/config.ts +20 -9
- package/src/error-map.ts +93 -7
- package/src/error-page.ts +201 -0
- package/src/errors.ts +22 -2
- package/src/finalize.ts +4 -1
- package/src/hooks.ts +14 -0
- package/src/html-render.ts +25 -0
- package/src/index.ts +16 -0
- package/src/overlay-style.ts +11 -3
- package/src/overlay.ts +11 -14
- package/src/rate-limit.ts +9 -2
- package/src/request.ts +10 -5
- package/src/response.ts +26 -6
- package/src/security-headers.ts +34 -3
- package/src/server.ts +6 -2
- package/src/stages.ts +77 -22
package/CLAUDE.md
CHANGED
|
@@ -84,12 +84,28 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
84
84
|
TLS-terminating ingress would be refused. **`mode: 'token'` is deliberately NOT shipped** — a
|
|
85
85
|
double-submit token needs a cookie issuer and a form-field helper at tier 4/5, and a half-built
|
|
86
86
|
token mode is worse than an honest `'origin' | 'off'`.
|
|
87
|
+
- **An unclassified 5xx tells the CALLER nothing off the throwable** (`As of 2026-08-23`).
|
|
88
|
+
`error-page.ts` has always shown a browser the status, the code and the request id and said so in
|
|
89
|
+
its header; `toProblem` rendered `facts.cause`, which for a 500 nobody classified falls through to
|
|
90
|
+
the exception's own `message` — a driver's DSN, the row Postgres rejected, an absolute path. One
|
|
91
|
+
condition, two audiences, and they disagreed. The discriminator is a code nobody declared a status
|
|
92
|
+
for, plus `X_INTERNAL` itself: core's `toError()` wraps a caught value into an `InternalError`
|
|
93
|
+
whose cause is `renderCauseValue(value)`, so the framework's own word for "unclassified" is where
|
|
94
|
+
the leak arrives. `toProblem(error, { dev })` is the seam and `dev` DEFAULTS TO FALSE: the
|
|
95
|
+
`error-map` stage is the one call site that can see the config, and every degraded `problem()` in
|
|
96
|
+
the tail must stay opaque. The real text is not lost — it is the log field and the error report,
|
|
97
|
+
both keyed by the request id the caller was given.
|
|
87
98
|
- **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,
|
|
88
99
|
`contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the
|
|
89
100
|
`error-map` stage wrote a rejected password verbatim into the log store, at 4xx, which is logged
|
|
90
101
|
and not reported and therefore kept for the full retention. The message is the CODE alone. The
|
|
91
102
|
other half is `@ultimat3/schema`'s `describeValue` (shape, never content) and it is the
|
|
92
103
|
load-bearing one; this half is what makes the value redactable at all.
|
|
104
|
+
- **A repeated field is a LIST, in all three parsers.** `collectFields` in `request.ts` is the one
|
|
105
|
+
collector for the query, `application/x-www-form-urlencoded` and `multipart/form-data`. The last
|
|
106
|
+
two were `Object.fromEntries`, which keeps the LAST value: a checkbox group posting `tags` three
|
|
107
|
+
times reached the body schema as one string, while the query parser three functions up had built
|
|
108
|
+
an array for the same shape since it shipped.
|
|
93
109
|
- **A rejected BODY is not a log field either — `bodyInvalid`'s `issues` may name only what the
|
|
94
110
|
framework chose** (`As of 2026-08-19`). `request.ts` built `could not parse ${type}: ${String(error)}`,
|
|
95
111
|
and the runtime's `SyntaxError` quotes the token it choked on: a `POST` of
|
|
@@ -120,6 +136,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
120
136
|
goes through the same capped bytes (re-parsed by `Response.formData()` off the announced
|
|
121
137
|
boundary) rather than being handed to the runtime as an unbounded stream, which is what left it
|
|
122
138
|
with no byte guard at all when the length was undeclared.
|
|
139
|
+
- **The `cache-headers` stage is the ONE owner of the final cache answer, `As of 2026-08-23`.** It
|
|
140
|
+
used to apply the actor-aware default only when nothing had set a header — and every page route
|
|
141
|
+
in every app sets one, because `@ultimat3/render`'s `ssrHeaders` writes
|
|
142
|
+
`public, max-age=0, s-maxage=30, stale-while-revalidate=300` for any route that declares no
|
|
143
|
+
`policy`, which is exactly what `x g route --surface app` scaffolds. So the rule below was
|
|
144
|
+
unreachable for the surface it was written for. A render mode states the MODE's intent; this
|
|
145
|
+
stage decides, and `offersSharedCache` is the discriminator: a shared answer for an identified
|
|
146
|
+
request becomes `PRIVATE_CACHE`, an anonymous one gains `SHARED_CACHE_VARY`. `immutable` is left
|
|
147
|
+
alone in both directions — it asserts the body is a function of the URL, which is what a
|
|
148
|
+
content-addressed island chunk is, and demoting those would re-download every chunk on every
|
|
149
|
+
navigation for every signed-in user.
|
|
123
150
|
- **The cache default reads the ACTOR, not just the route, and `vary` is added and never set.**
|
|
124
151
|
`meta.auth` is only `'public' | 'required'`, so the page that greets a signed-in visitor by name
|
|
125
152
|
is a `'public'` route: keying the default off the route alone put that visitor's personalised
|
|
@@ -127,6 +154,23 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
127
154
|
an anonymous one stays shared-cacheable and carries `vary: accept-language, cookie`. Both halves
|
|
128
155
|
are required — either alone leaves the hole. `addVary` (`response.ts`) is how the `response`
|
|
129
156
|
stage merges CORS's `vary: origin` into the cache stage's key instead of replacing it.
|
|
157
|
+
- **A `security.csp.extend` entry is refused at `defineHttpConfig` unless it can only emit the
|
|
158
|
+
directive it names.** A directive name and a source both go into the header VERBATIM and the
|
|
159
|
+
header's own separators are `;` and ` `, so `extend: { 'x; script-src *': [] }` was not one badly
|
|
160
|
+
named directive — it was a second directive nobody declared, widening the one this package locks
|
|
161
|
+
down hardest. `X_CSP_DIRECTIVE_INVALID`, at boot, because there is no encoding for a CSP source
|
|
162
|
+
and escaping one at emission time is not a repair. `buildCsp` builds through a **`Map`** for the
|
|
163
|
+
other half of the same class: `directives[name]` was a computed read of an object literal keyed by
|
|
164
|
+
a caller-chosen name, so `extend: { toString: [...] }` spread a function off `Object.prototype`
|
|
165
|
+
and threw a bare `TypeError` at boot. `proto-index` cannot see it — `baseline()` is what produces
|
|
166
|
+
the object.
|
|
167
|
+
- **`config.drainTimeoutMs` is `number | null`, and `null` is the default** (`As of 2026-08-23`).
|
|
168
|
+
`createServer` calls `configureLifecycle({ deadlineMs })` only when an app DECLARED one. It used
|
|
169
|
+
to call it unconditionally with a value `defineHttpConfig` had defaulted to 15s, so an app that
|
|
170
|
+
wrote `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s own `fix:`
|
|
171
|
+
prints — had it silently reverted by the next line of boot, in every process that serves web.
|
|
172
|
+
"Nobody said" and "the app said 15 seconds" are different claims and only one of them may move a
|
|
173
|
+
process-global deadline.
|
|
130
174
|
- **`cors.origins: ['*']` with `credentials: true` is refused at `defineHttpConfig`.** No browser
|
|
131
175
|
accepts that pair, and `allowedOrigin` answering `null` for it meant the natural "open it up"
|
|
132
176
|
edit emitted no CORS headers at all, silently, on every request — with `DEFAULT_CORS.credentials`
|
|
@@ -187,6 +231,17 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
|
|
|
187
231
|
typo is a compile error; `error-map.test.ts` pins that with a `@ts-expect-error`. Read it by a
|
|
188
232
|
code the framework did not mint through `statusFor()`, which goes via the file-local `BY_CODE`
|
|
189
233
|
view and keeps `Object.hasOwn`.
|
|
234
|
+
- **A problem document's `type` and its `docs` are two different questions, `As of 2026-08-23`.**
|
|
235
|
+
Both used to be `https://ultimate.dev/errors/<code>` — one string, twice, and a host that
|
|
236
|
+
answers **404** on every 4xx and 5xx this package has ever rendered. `docs` is now core's
|
|
237
|
+
`ERROR_DOCS_URL`, one wiki page for every code, and it is never spelled here: a construction site
|
|
238
|
+
omits `docs:` and `UltimateError` resolves it. `type` did NOT follow it there. It is RFC 9457's
|
|
239
|
+
primary identifier for the problem KIND — a client switches on it — so collapsing it onto one
|
|
240
|
+
page would have given a 422 and a 403 the same identifier. `problemTypeFor(code)` answers
|
|
241
|
+
`urn:ultimate:error:<CODE>`: per code, stable, and a URN has no host to rot. `finalize.ts`'s
|
|
242
|
+
`lastResort` spells its one `type` as a literal because that function calls nothing, and
|
|
243
|
+
`pipeline-finalize.test.ts` pins the literal against `problemTypeFor('X_INTERNAL')` so the two
|
|
244
|
+
cannot drift. Never assert either value as a copied string — import the constant.
|
|
190
245
|
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's
|
|
191
246
|
table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with
|
|
192
247
|
`registerErrorStatus()`, which refuses a code the framework already holds. Without that half,
|
package/README.md
CHANGED
|
@@ -47,7 +47,10 @@ What the lifecycle refuses on the caller's behalf, `As of 2026-08`:
|
|
|
47
47
|
| Guard | Answer |
|
|
48
48
|
|---|---|
|
|
49
49
|
| 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` |
|
|
50
|
-
| 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` |
|
|
50
|
+
| 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 |
|
|
51
|
+
| 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 |
|
|
52
|
+
| 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 |
|
|
53
|
+
| 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 |
|
|
51
54
|
| a cross-origin request from an origin the allow-list refuses | no `access-control-allow-origin`, but always `vary: origin`, so a shared cache never answers an allowed origin out of the refusal's slot |
|
|
52
55
|
| `cors.origins: ['*']` with `credentials: true` | `X_CORS_CONFIG_INVALID` at `defineHttpConfig`, because a browser accepts that pair from nobody |
|
|
53
56
|
| `?next=` carrying anything but a same-origin path | the fallback — including a value whose TAB/CR/LF a browser strips back into `//evil.test` |
|
|
@@ -205,6 +208,13 @@ through to `fetch`. Method resolution stays ours so a 405 still carries problem+
|
|
|
205
208
|
One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev
|
|
206
209
|
overlay — so the `code`/`cause`/`fix` strings can never diverge.
|
|
207
210
|
|
|
211
|
+
A problem document's `type` and `docs` answer different questions and are two different
|
|
212
|
+
values. `type` is `problemTypeFor(code)` — `urn:ultimate:error:X_BODY_INVALID`, the RFC-9457
|
|
213
|
+
identifier a client switches on, per code, with no host to resolve or rot. `docs` is
|
|
214
|
+
`@ultimat3/core`'s `ERROR_DOCS_URL`, one wiki page for every code, because a code lives there
|
|
215
|
+
in a table row and a table row has no anchor. Assert against `problemTypeFor` and
|
|
216
|
+
`ERROR_DOCS_URL`, never against a copy of either string.
|
|
217
|
+
|
|
208
218
|
## Boundaries
|
|
209
219
|
|
|
210
220
|
Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. Authentication and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/http",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.0.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": "11.0.0",
|
|
35
|
+
"@ultimat3/i18n": "11.0.0",
|
|
36
|
+
"@ultimat3/schema": "11.0.0",
|
|
37
|
+
"@ultimat3/time": "11.0.0"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/auth-redirect.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// sign-in page. One condition, two audiences, decided here so the error stage stays one branch.
|
|
4
4
|
|
|
5
5
|
import type { RequestContext } from './context';
|
|
6
|
-
import {
|
|
6
|
+
import { acceptsHtml } from './html-render';
|
|
7
7
|
import type { RedirectIntent } from './response';
|
|
8
8
|
|
|
9
9
|
/** The query parameter carrying where the visitor was going. One spelling, both halves. */
|
|
@@ -28,9 +28,9 @@ export function signInRedirect(input: {
|
|
|
28
28
|
}): RedirectIntent | undefined {
|
|
29
29
|
const { code, signInPath, request, ctx } = input;
|
|
30
30
|
if (code !== 'X_UNAUTHENTICATED' || signInPath === null) return undefined;
|
|
31
|
-
// The same question
|
|
32
|
-
// the same answer, so a client cannot get
|
|
33
|
-
if (!
|
|
31
|
+
// The same question the overlay and the error page ask — "does this client render HTML?" — and
|
|
32
|
+
// deliberately the same answer, so a client cannot get a page in dev and JSON in production.
|
|
33
|
+
if (!acceptsHtml(request)) return undefined;
|
|
34
34
|
// A sign-in page that declares `auth: 'required'` by mistake would otherwise redirect to
|
|
35
35
|
// itself forever, and a browser reports that as a bare "too many redirects" with no code.
|
|
36
36
|
if (ctx.url.pathname === signInPath) return undefined;
|
package/src/cache-policy.ts
CHANGED
|
@@ -7,6 +7,24 @@ import { isAnonymous } from '@ultimat3/core';
|
|
|
7
7
|
import type { CacheHint } from './response';
|
|
8
8
|
import type { Route } from './router';
|
|
9
9
|
|
|
10
|
+
/** The one answer for an identified request, whoever asked. */
|
|
11
|
+
export const PRIVATE_CACHE: CacheHint = { mode: 'private', maxAgeSeconds: 0 };
|
|
12
|
+
|
|
13
|
+
// `public` or an `s-maxage` is an OFFER to a shared cache; `immutable` withdraws the question,
|
|
14
|
+
// because it asserts the body is a function of the URL alone — which is what a content-addressed
|
|
15
|
+
// island chunk or image is, and demoting those would re-download every chunk on every navigation
|
|
16
|
+
// for every signed-in user.
|
|
17
|
+
const OFFERS_SHARED = /(?:^|,)\s*(?:public\b|s-maxage=)/i;
|
|
18
|
+
const IMMUTABLE = /(?:^|,)\s*immutable\b/i;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether a `cache-control` a HANDLER wrote offers the response to a shared cache. A render mode
|
|
22
|
+
* states the MODE's intent — `ssr` offers an ungated page to a CDN for 30 seconds — and the actor
|
|
23
|
+
* is the half it cannot see, so the stage reviews the declaration rather than deferring to it.
|
|
24
|
+
*/
|
|
25
|
+
export const offersSharedCache = (declared: string): boolean =>
|
|
26
|
+
OFFERS_SHARED.test(declared) && !IMMUTABLE.test(declared);
|
|
27
|
+
|
|
10
28
|
/**
|
|
11
29
|
* Authenticated responses are never shared-cacheable; that default is not overridable.
|
|
12
30
|
*
|
|
@@ -19,6 +37,6 @@ import type { Route } from './router';
|
|
|
19
37
|
*/
|
|
20
38
|
export const defaultCache = (route: Route | undefined, actor: Actor): CacheHint => {
|
|
21
39
|
if (route === undefined || route.meta.auth === 'required') return { mode: 'no-store' };
|
|
22
|
-
if (!isAnonymous(actor)) return
|
|
40
|
+
if (!isAnonymous(actor)) return PRIVATE_CACHE;
|
|
23
41
|
return { mode: 'public', maxAgeSeconds: 0, sMaxAgeSeconds: 60, staleWhileRevalidateSeconds: 600 };
|
|
24
42
|
};
|
package/src/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type TimeZoneConfig,
|
|
12
12
|
} from './locale';
|
|
13
13
|
import { type RateLimitConfig, resolveRateLimitConfig } from './rate-limit';
|
|
14
|
-
import { DEFAULT_SECURITY, type SecurityConfig } from './security-headers';
|
|
14
|
+
import { assertCspExtend, DEFAULT_SECURITY, type SecurityConfig } from './security-headers';
|
|
15
15
|
|
|
16
16
|
export interface HttpConfig {
|
|
17
17
|
readonly port: number;
|
|
@@ -56,8 +56,18 @@ export interface HttpConfig {
|
|
|
56
56
|
* retries multiply the load.
|
|
57
57
|
*/
|
|
58
58
|
readonly maxInflight: number;
|
|
59
|
-
/**
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* How long SIGTERM waits for in-flight requests before hard-stopping, or `null` when this app
|
|
61
|
+
* has not said and core's own deadline stands.
|
|
62
|
+
*
|
|
63
|
+
* `null` and not a 15s default, because the two are different claims and only one of them may
|
|
64
|
+
* reach `configureLifecycle`. `createServer` applied the resolved number unconditionally, so an
|
|
65
|
+
* app that had already written `configureLifecycle({ deadlineMs: 600_000 })` — the edit
|
|
66
|
+
* `X_SHUTDOWN_TIMEOUT`'s own `fix:` line prints — had it silently reverted by the next line of
|
|
67
|
+
* boot, in every process that serves web. Declaring this key IS declaring the drain budget for
|
|
68
|
+
* the whole process; leaving it out is declining to.
|
|
69
|
+
*/
|
|
70
|
+
readonly drainTimeoutMs: number | null;
|
|
61
71
|
readonly locale: LocaleConfig;
|
|
62
72
|
readonly tz: TimeZoneConfig;
|
|
63
73
|
readonly cors: CorsConfig;
|
|
@@ -123,6 +133,11 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
123
133
|
// Refused here, not on the first request: "trust the header" and "know which entry of it" are
|
|
124
134
|
// one declaration, and half of it is a header the caller writes.
|
|
125
135
|
if (trustProxy && input.trustedProxyHops === undefined) throw trustProxyUnset();
|
|
136
|
+
const csp = { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp };
|
|
137
|
+
// Beside `assertCorsConfig`, and for its reason: a merged value is the only one that can be
|
|
138
|
+
// judged, and a directive name that is not a token would otherwise be a bare `TypeError` out of
|
|
139
|
+
// the first response's header build — or worse, a second directive nobody declared.
|
|
140
|
+
assertCspExtend(csp.extend);
|
|
126
141
|
return {
|
|
127
142
|
port: input.port ?? Number.parseInt(env('PORT') ?? '3000', 10),
|
|
128
143
|
hostname: input.hostname ?? env('HOSTNAME') ?? '0.0.0.0',
|
|
@@ -138,16 +153,12 @@ export const defineHttpConfig = (input: HttpConfigInput = {}): HttpConfig => {
|
|
|
138
153
|
// two, so a rolling restart cannot be held open by work started just before SIGTERM.
|
|
139
154
|
requestTimeoutMs: input.requestTimeoutMs ?? 30_000,
|
|
140
155
|
maxInflight: input.maxInflight ?? 1_000,
|
|
141
|
-
drainTimeoutMs: input.drainTimeoutMs ??
|
|
156
|
+
drainTimeoutMs: input.drainTimeoutMs ?? null,
|
|
142
157
|
locale: { ...DEFAULT_LOCALE_CONFIG, ...input.locale },
|
|
143
158
|
tz: { ...DEFAULT_TZ_CONFIG, ...input.tz },
|
|
144
159
|
cors,
|
|
145
160
|
csrf: { ...DEFAULT_CSRF, ...input.csrf },
|
|
146
|
-
security: {
|
|
147
|
-
...DEFAULT_SECURITY,
|
|
148
|
-
...input.security,
|
|
149
|
-
csp: { ...DEFAULT_SECURITY.csp, reportOnly: dev, ...input.security?.csp },
|
|
150
|
-
},
|
|
161
|
+
security: { ...DEFAULT_SECURITY, ...input.security, csp },
|
|
151
162
|
rateLimit: resolveRateLimitConfig(input.rateLimit),
|
|
152
163
|
};
|
|
153
164
|
};
|
package/src/error-map.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
|
-
import { renderCauseValue, singleLine, stringField } from '@ultimat3/core';
|
|
4
|
+
import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
|
|
5
5
|
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -38,6 +38,7 @@ export const ERROR_STATUS = {
|
|
|
38
38
|
// Thrown while `app.config.ts` resolves, so no request is ever answered with it — the row exists
|
|
39
39
|
// because a code with no status is a 500 anyway and this table is the closed one.
|
|
40
40
|
X_CORS_CONFIG_INVALID: 500,
|
|
41
|
+
X_CSP_DIRECTIVE_INVALID: 500,
|
|
41
42
|
// Thrown while the server is being constructed, so no request is ever answered with it either.
|
|
42
43
|
// The row exists because this table is the closed one: a code missing from it is a 500 anyway,
|
|
43
44
|
// and a code the framework owns must never fall through to the app's table.
|
|
@@ -155,6 +156,13 @@ export const ERROR_STATUS = {
|
|
|
155
156
|
// @ultimat3/policy
|
|
156
157
|
X_POLICY_MISSING: 500,
|
|
157
158
|
X_PERMISSION_UNKNOWN: 500,
|
|
159
|
+
// 500, and the page IS the point — deliberately not a 4xx to keep this table quiet.
|
|
160
|
+
// `enforce()` was handed a surface no adapter answers to, which reaches a request only through a
|
|
161
|
+
// config-driven route table, a surface name off the wire or a JS host; none of those is a value
|
|
162
|
+
// the caller can correct, and a 400 would tell them to fix a request that is not the problem.
|
|
163
|
+
// It is the third authz-dispatch fault beside the two rows above and takes their status for the
|
|
164
|
+
// same reason: the declaration is wrong, not the call.
|
|
165
|
+
X_POLICY_SURFACE_UNKNOWN: 500,
|
|
158
166
|
// @ultimat3/query — the read declares no id, so no cursor can name a position in it. The one
|
|
159
167
|
// paging failure that is NOT the caller's: the fix is an edit to the read's own select, nothing
|
|
160
168
|
// the client sends changes the answer, and the report to the on-call monitor is the point.
|
|
@@ -351,12 +359,58 @@ export const factsOf = (error: unknown): ErrorFacts => {
|
|
|
351
359
|
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
|
|
352
360
|
// `x errors explain` ships, and it is the command that answers "what is this code".
|
|
353
361
|
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
|
|
354
|
-
|
|
362
|
+
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
|
|
363
|
+
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
|
|
364
|
+
// resolved this at construction, so the fallback only fires for a throwable the framework did
|
|
365
|
+
// not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
|
|
366
|
+
// 404 on every problem document this package has ever rendered.
|
|
367
|
+
docs: str(error, 'docs') ?? ERROR_DOCS_URL,
|
|
355
368
|
status: statusFor(code),
|
|
356
369
|
stack: str(error, 'stack'),
|
|
357
370
|
};
|
|
358
371
|
};
|
|
359
372
|
|
|
373
|
+
/**
|
|
374
|
+
* `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
|
|
375
|
+
*
|
|
376
|
+
* The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
|
|
377
|
+
* `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
|
|
378
|
+
* HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
|
|
379
|
+
* same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
|
|
380
|
+
* both told the caller to come back and never said when — which is the shed-with-no-delay pattern
|
|
381
|
+
* the `admit` stage exists to avoid, one layer in.
|
|
382
|
+
*
|
|
383
|
+
* Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
|
|
384
|
+
* not build, and it is read in the frame that decides what the caller sees.
|
|
385
|
+
*/
|
|
386
|
+
export function retryAfterOf(error: unknown): number | undefined {
|
|
387
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
388
|
+
try {
|
|
389
|
+
const meta: unknown = (error as Record<string, unknown>)['meta'];
|
|
390
|
+
if (typeof meta !== 'object' || meta === null) return undefined;
|
|
391
|
+
const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
|
|
392
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
393
|
+
// At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
|
|
394
|
+
// as "retry now", which is the stampede a Retry-After exists to spread.
|
|
395
|
+
return Math.max(1, Math.ceil(seconds));
|
|
396
|
+
} catch {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
|
|
403
|
+
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
|
|
404
|
+
* to read about it, and those stopped being the same string when `docs` became one wiki page for
|
|
405
|
+
* every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
|
|
406
|
+
* forbidden the same identifier, which is the one thing a `type` may not do.
|
|
407
|
+
*
|
|
408
|
+
* A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
|
|
409
|
+
* — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
|
|
410
|
+
* `code` carries the same string as a plain member for a reader that would rather not parse a URI.
|
|
411
|
+
*/
|
|
412
|
+
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
|
|
413
|
+
|
|
360
414
|
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
|
|
361
415
|
export interface ProblemDocument {
|
|
362
416
|
readonly type: string;
|
|
@@ -371,19 +425,51 @@ export interface ProblemDocument {
|
|
|
371
425
|
readonly requestId: string | undefined;
|
|
372
426
|
}
|
|
373
427
|
|
|
428
|
+
/** The title a caller gets for a failure the framework cannot name. */
|
|
429
|
+
const INTERNAL_TITLE = 'unhandled server error';
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
|
|
433
|
+
* falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
|
|
434
|
+
* absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
|
|
435
|
+
* out of exactly this and said so in its header; the two audiences then disagreed about one
|
|
436
|
+
* condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
|
|
437
|
+
* reports every 5xx to the error monitor, both keyed by the request id below.
|
|
438
|
+
*/
|
|
439
|
+
const INTERNAL_CAUSE =
|
|
440
|
+
'the server failed while handling this request; the details are in this process\u2019s logs and ' +
|
|
441
|
+
'error reports, under this request id';
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A 5xx nobody declared a status for — not the framework's table, not the app's
|
|
445
|
+
* `registerErrorStatus` — or one whose code is `X_INTERNAL`. That is the discriminator, and not
|
|
446
|
+
* `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would
|
|
447
|
+
* take away the one instruction in it.
|
|
448
|
+
*
|
|
449
|
+
* `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's
|
|
450
|
+
* own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and
|
|
451
|
+
* core's `toError()` wraps a caught value into an `InternalError` whose cause is
|
|
452
|
+
* `renderCauseValue(value)` — the driver's message, verbatim. Nothing in an `X_INTERNAL` is
|
|
453
|
+
* actionable by the caller; the code and the request id are.
|
|
454
|
+
*/
|
|
455
|
+
const isUnclassifiedFailure = (code: string, status: number): boolean =>
|
|
456
|
+
status >= 500 &&
|
|
457
|
+
(code === 'X_INTERNAL' || (frameworkStatus(code) === undefined && !APP_ERROR_STATUS.has(code)));
|
|
458
|
+
|
|
374
459
|
export const toProblem = (
|
|
375
460
|
error: unknown,
|
|
376
|
-
meta: { instance?: string; requestId?: string } = {},
|
|
461
|
+
meta: { instance?: string; requestId?: string; dev?: boolean } = {},
|
|
377
462
|
): ProblemDocument => {
|
|
378
463
|
const facts = factsOf(error);
|
|
464
|
+
const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
|
|
379
465
|
return {
|
|
380
|
-
type: facts.
|
|
381
|
-
title: facts.title,
|
|
466
|
+
type: problemTypeFor(facts.code),
|
|
467
|
+
title: opaque ? INTERNAL_TITLE : facts.title,
|
|
382
468
|
status: facts.status,
|
|
383
|
-
detail: facts.cause,
|
|
469
|
+
detail: opaque ? INTERNAL_CAUSE : facts.cause,
|
|
384
470
|
instance: meta.instance,
|
|
385
471
|
code: facts.code,
|
|
386
|
-
cause: facts.cause,
|
|
472
|
+
cause: opaque ? INTERNAL_CAUSE : facts.cause,
|
|
387
473
|
fix: facts.fix,
|
|
388
474
|
docs: facts.docs,
|
|
389
475
|
requestId: meta.requestId,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The page a BROWSER gets when a request fails and this process is not in dev: Rails' `404.html`,
|
|
2
|
+
// with the framework's own words. The dev overlay stays the dev answer — it prints the cause, the
|
|
3
|
+
// fix and the stack, which is exactly what a visitor may never see — so this renderer shows the
|
|
4
|
+
// status, the code and the request id and nothing else off the throwable.
|
|
5
|
+
//
|
|
6
|
+
// An app overrides it with a file per status; `ServerHooks.errorPage` is the seam that reads one,
|
|
7
|
+
// because this package cannot see a disk.
|
|
8
|
+
import { singleLine } from '@ultimat3/core';
|
|
9
|
+
import type { InterpolationVars } from '@ultimat3/i18n';
|
|
10
|
+
import { t } from '@ultimat3/i18n';
|
|
11
|
+
import { escapeHtml } from './html-render';
|
|
12
|
+
import { OVERLAY_STYLE } from './overlay-style';
|
|
13
|
+
import { html } from './response';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where "Built with Ultimate" goes. Two literals and not a derivation off `ERROR_DOCS_URL`: the
|
|
17
|
+
* wiki page it points at is a different destination that merely shares a prefix today. They live
|
|
18
|
+
* here, next to their one renderer — a second surface that wants them (a CLI banner, a scaffolded
|
|
19
|
+
* footer) is what would move them to `@ultimat3/core`.
|
|
20
|
+
*/
|
|
21
|
+
export const ERROR_PAGE_LINKS = Object.freeze<Record<'repository' | 'homepage', string>>({
|
|
22
|
+
repository: 'https://github.com/developerz-ai/ultimate',
|
|
23
|
+
homepage: 'https://www.developerz.ai',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/** One key group in the framework catalog's `errors.*` namespace, which is what this page renders. */
|
|
27
|
+
export type ErrorPageGroup =
|
|
28
|
+
| 'notFound'
|
|
29
|
+
| 'forbidden'
|
|
30
|
+
| 'unauthorized'
|
|
31
|
+
| 'rateLimited'
|
|
32
|
+
| 'serverError'
|
|
33
|
+
| 'unavailable'
|
|
34
|
+
| 'badRequest';
|
|
35
|
+
|
|
36
|
+
/** Where the page's one link goes. A closed set, because a fourth destination is a design change. */
|
|
37
|
+
export type ErrorPageAction = 'home' | 'retry' | 'signIn';
|
|
38
|
+
|
|
39
|
+
export interface ErrorPageInput {
|
|
40
|
+
readonly status: number;
|
|
41
|
+
readonly code: string;
|
|
42
|
+
/**
|
|
43
|
+
* The pathname that failed, when there IS one. Supplied to the translator so an app that
|
|
44
|
+
* overrides `errors.notFound.body` can name it; the framework's own sentence deliberately does
|
|
45
|
+
* not, because the same renderer writes `404.html` into a static export, where no request
|
|
46
|
+
* exists and a reflected path would be a sentence with a hole in it.
|
|
47
|
+
*/
|
|
48
|
+
readonly path?: string | undefined;
|
|
49
|
+
/** `x-request-id` for this request. Absent for a page built with no request behind it. */
|
|
50
|
+
readonly requestId?: string | undefined;
|
|
51
|
+
/** BCP-47 tag for `<html lang>`; the copy itself comes from the ambient translator. */
|
|
52
|
+
readonly locale: string;
|
|
53
|
+
/** `route.meta.policy` — the only thing a 403 page can name, and it names a rule, never a row. */
|
|
54
|
+
readonly permission?: string | undefined;
|
|
55
|
+
readonly retryAfterSeconds?: number | undefined;
|
|
56
|
+
/** `config.signInPath`, or null when the app has not declared one. */
|
|
57
|
+
readonly signInPath?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ErrorPageCopy {
|
|
61
|
+
readonly group: ErrorPageGroup;
|
|
62
|
+
readonly action: ErrorPageAction;
|
|
63
|
+
readonly vars: InterpolationVars;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The statuses with words of their own. A `Map` and not an object literal for `statusFor`'s
|
|
68
|
+
* reason one file over: the key is a number this package computed, but a table read by a value is
|
|
69
|
+
* a prototype member away from answering a function.
|
|
70
|
+
*/
|
|
71
|
+
const BY_STATUS = new Map<number, { group: ErrorPageGroup; action: ErrorPageAction }>([
|
|
72
|
+
[401, { group: 'unauthorized', action: 'signIn' }],
|
|
73
|
+
[403, { group: 'forbidden', action: 'home' }],
|
|
74
|
+
[404, { group: 'notFound', action: 'home' }],
|
|
75
|
+
[429, { group: 'rateLimited', action: 'retry' }],
|
|
76
|
+
[503, { group: 'unavailable', action: 'retry' }],
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The variables that group's sentence needs, or `undefined` when this request cannot supply one.
|
|
81
|
+
* `interpolate` renders a missing variable as `⟦permission⟧` — loud, and correct for an author,
|
|
82
|
+
* but it is a bracketed token in front of a visitor — so a group that cannot be filled degrades to
|
|
83
|
+
* the class below instead of shipping the hole.
|
|
84
|
+
*/
|
|
85
|
+
function varsFor(group: ErrorPageGroup, input: ErrorPageInput): InterpolationVars | undefined {
|
|
86
|
+
if (group === 'notFound') return input.path === undefined ? {} : { path: singleLine(input.path) };
|
|
87
|
+
if (group === 'serverError')
|
|
88
|
+
return input.requestId === undefined ? undefined : { traceId: singleLine(input.requestId) };
|
|
89
|
+
if (group === 'forbidden')
|
|
90
|
+
return input.permission === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: { permission: singleLine(input.permission) };
|
|
93
|
+
if (group === 'rateLimited')
|
|
94
|
+
return input.retryAfterSeconds === undefined ? undefined : { seconds: input.retryAfterSeconds };
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 4xx and 5xx each have one sentence that needs nothing, which is what a degrade falls to. */
|
|
99
|
+
const classPlan = (status: number): { group: ErrorPageGroup; action: ErrorPageAction } =>
|
|
100
|
+
status < 500
|
|
101
|
+
? { group: 'badRequest', action: 'home' }
|
|
102
|
+
: { group: 'serverError', action: 'retry' };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Which words this status gets. Derived from the status alone — never from the code — because
|
|
106
|
+
* `ERROR_STATUS` already decided what a code MEANS to a client, and a second table keyed by code
|
|
107
|
+
* would be a second answer to that question.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveErrorPageCopy(input: ErrorPageInput): ErrorPageCopy {
|
|
110
|
+
const plan = BY_STATUS.get(input.status) ?? classPlan(input.status);
|
|
111
|
+
const vars = varsFor(plan.group, input);
|
|
112
|
+
if (vars !== undefined) return { ...plan, vars };
|
|
113
|
+
const fallback = classPlan(input.status);
|
|
114
|
+
const fallbackVars = varsFor(fallback.group, input);
|
|
115
|
+
if (fallbackVars !== undefined) return { ...fallback, vars: fallbackVars };
|
|
116
|
+
// The last rung, and it is a rung rather than a `?? {}` because the ladder has to END on copy
|
|
117
|
+
// that needs nothing: one group in this table asks for no variable, and landing anywhere else
|
|
118
|
+
// ships `⟦traceId⟧` to a visitor.
|
|
119
|
+
return { group: 'badRequest', action: 'home', vars: {} };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const hrefFor = (copy: ErrorPageCopy, input: ErrorPageInput): string => {
|
|
123
|
+
if (copy.action === 'signIn' && input.signInPath !== null && input.signInPath !== undefined)
|
|
124
|
+
return input.signInPath;
|
|
125
|
+
// A retry has to be the page the visitor was on; with no request behind the page there is no
|
|
126
|
+
// such address, so it degrades to the one link that is always right.
|
|
127
|
+
return (copy.action === 'retry' ? input.path : undefined) ?? '/';
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const link = (href: string, label: string): string =>
|
|
131
|
+
`<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The framework's page. One document, every status — the words are a catalog lookup and the shape
|
|
135
|
+
* never changes, so an app that wants a different shape ships its own file rather than configuring
|
|
136
|
+
* this one into something else.
|
|
137
|
+
*
|
|
138
|
+
* The key is built from the group, which is what makes `errors.*` one namespace with one reader
|
|
139
|
+
* instead of seven call sites that can each drift from the table above.
|
|
140
|
+
*/
|
|
141
|
+
export function renderErrorPage(input: ErrorPageInput): string {
|
|
142
|
+
const copy = resolveErrorPageCopy(input);
|
|
143
|
+
const title = t(`errors.${copy.group}.title`);
|
|
144
|
+
return `<!doctype html>
|
|
145
|
+
<html lang="${escapeHtml(input.locale)}">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="utf-8">
|
|
148
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
149
|
+
<meta name="robots" content="noindex">
|
|
150
|
+
<title>${escapeHtml(`${String(input.status)} ${title}`)}</title>
|
|
151
|
+
<style>${OVERLAY_STYLE}</style>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
<main>
|
|
155
|
+
<section class="card">
|
|
156
|
+
<p class="status">${escapeHtml(String(input.status))}</p>
|
|
157
|
+
<h1>${escapeHtml(title)}</h1>
|
|
158
|
+
<p class="lede">${escapeHtml(t(`errors.${copy.group}.body`, copy.vars))}</p>
|
|
159
|
+
<p>${link(hrefFor(copy, input), t(`errors.${copy.group}.action`))}</p>
|
|
160
|
+
<dl>
|
|
161
|
+
<dt>code</dt><dd>${escapeHtml(singleLine(input.code))}</dd>${
|
|
162
|
+
input.requestId === undefined
|
|
163
|
+
? ''
|
|
164
|
+
: `
|
|
165
|
+
<dt>request</dt><dd>${escapeHtml(singleLine(input.requestId))}</dd>`
|
|
166
|
+
}
|
|
167
|
+
</dl>
|
|
168
|
+
</section>
|
|
169
|
+
<footer class="card">
|
|
170
|
+
${link(ERROR_PAGE_LINKS.repository, t('errors.page.builtWith'))}
|
|
171
|
+
${link(ERROR_PAGE_LINKS.homepage, 'developerz.ai')}
|
|
172
|
+
</footer>
|
|
173
|
+
</main>
|
|
174
|
+
</body>
|
|
175
|
+
</html>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ErrorPageOptions {
|
|
179
|
+
/** The app's own file for this status, if it has one. */
|
|
180
|
+
readonly override?: string | undefined;
|
|
181
|
+
/** Headers the refusal computed — `retry-after` today, and nothing else so far. */
|
|
182
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The answer itself. `override` is the app's own file, served BYTE FOR BYTE — a framework that
|
|
187
|
+
* interpolated a `{{status}}` into it would be a second template language, and a page an app
|
|
188
|
+
* cannot predict is not an override.
|
|
189
|
+
*
|
|
190
|
+
* `no-store`, always: an error page filed by a shared cache under the URL that failed is served to
|
|
191
|
+
* the next visitor after the incident is over. `retry-after` rides along for the same reason it
|
|
192
|
+
* rides on the problem document — a 503 that does not say when to come back comes back at once.
|
|
193
|
+
*/
|
|
194
|
+
export const errorPageResponse = (
|
|
195
|
+
input: ErrorPageInput,
|
|
196
|
+
options: ErrorPageOptions = {},
|
|
197
|
+
): Response =>
|
|
198
|
+
html(options.override ?? renderErrorPage(input), {
|
|
199
|
+
status: input.status,
|
|
200
|
+
headers: { 'cache-control': 'no-store', ...options.headers },
|
|
201
|
+
});
|
package/src/errors.ts
CHANGED
|
@@ -23,6 +23,7 @@ export const HTTP_OWNED_ERROR_CODES = [
|
|
|
23
23
|
'X_NO_REQUEST',
|
|
24
24
|
'X_ERROR_STATUS_INVALID',
|
|
25
25
|
'X_CORS_CONFIG_INVALID',
|
|
26
|
+
'X_CSP_DIRECTIVE_INVALID',
|
|
26
27
|
'X_RATE_LIMIT_NOT_SHARED',
|
|
27
28
|
'X_RATE_LIMIT_BUCKET_CONFLICT',
|
|
28
29
|
'X_RATE_LIMIT_BUCKET_UNBOUND',
|
|
@@ -75,6 +76,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
|
|
|
75
76
|
X_NO_REQUEST: 'the inbound request is not in scope here',
|
|
76
77
|
X_ERROR_STATUS_INVALID: 'an error code cannot be mapped to that status',
|
|
77
78
|
X_CORS_CONFIG_INVALID: 'the cors config can never produce a working response',
|
|
79
|
+
X_CSP_DIRECTIVE_INVALID: 'a csp extension would emit something other than the directive it names',
|
|
78
80
|
X_RATE_LIMIT_NOT_SHARED: 'the rate limit is declared fleet-wide and the store is per-process',
|
|
79
81
|
X_RATE_LIMIT_BUCKET_CONFLICT: 'a route and the config declare different numbers for one bucket',
|
|
80
82
|
X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
|
|
@@ -105,7 +107,12 @@ registerErrorRetry({
|
|
|
105
107
|
X_OVERLOADED: 'retry-after',
|
|
106
108
|
});
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
// No `docs:` below. `UltimateError` fills it from `describeErrorCode(code).docs`, which is
|
|
111
|
+
// `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
|
|
112
|
+
// `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE
|
|
113
|
+
// ROW, which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until
|
|
114
|
+
// 9.x answered 404, host included — and this package put them in `type` AND `docs` of every
|
|
115
|
+
// problem document, so the dead link was on every 4xx and 5xx an app has ever served.
|
|
109
116
|
|
|
110
117
|
/** Base class for every error this package throws. Never throw a bare `Error`. */
|
|
111
118
|
export class HttpError extends UltimateError {
|
|
@@ -126,7 +133,6 @@ export class HttpError extends UltimateError {
|
|
|
126
133
|
code: init.code,
|
|
127
134
|
cause: init.cause,
|
|
128
135
|
fix: init.fix,
|
|
129
|
-
docs: docsFor(init.code),
|
|
130
136
|
...(init.meta === undefined ? {} : { meta: init.meta }),
|
|
131
137
|
});
|
|
132
138
|
}
|
|
@@ -286,6 +292,20 @@ export const corsConfigInvalid = (reason: string): HttpError =>
|
|
|
286
292
|
fix: "in app.config.ts set http.cors.credentials: false, or replace http.cors.origins: ['*'] with the exact origins allowed to call this app",
|
|
287
293
|
});
|
|
288
294
|
|
|
295
|
+
/**
|
|
296
|
+
* At `defineHttpConfig`. A directive name and a source both go into the header VERBATIM, and the
|
|
297
|
+
* header's own separators are `;` and ` ` — so `extend: { 'x; script-src *': [] }` is not one
|
|
298
|
+
* badly named directive, it is a second directive nobody declared, widening the one this
|
|
299
|
+
* framework locks down hardest. Refused where it is written rather than escaped where it is
|
|
300
|
+
* emitted: there is no encoding for a CSP directive, so the only total answer is not to have one.
|
|
301
|
+
*/
|
|
302
|
+
export const cspDirectiveInvalid = (where: string, value: string): HttpError =>
|
|
303
|
+
new HttpError({
|
|
304
|
+
code: 'X_CSP_DIRECTIVE_INVALID',
|
|
305
|
+
cause: `${where} is not a csp token: ${JSON.stringify(value)}`,
|
|
306
|
+
fix: 'in app.config.ts write one http.security.csp.extend entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon',
|
|
307
|
+
});
|
|
308
|
+
|
|
289
309
|
export const routeConflict = (path: string, detail: string): HttpError =>
|
|
290
310
|
new HttpError({
|
|
291
311
|
code: 'X_ROUTE_CONFLICT',
|
package/src/finalize.ts
CHANGED
|
@@ -21,7 +21,10 @@ export type Recover = (request: UltimateRequest, ctx: RequestContext) => Promise
|
|
|
21
21
|
const lastResort = (): Response =>
|
|
22
22
|
new Response(
|
|
23
23
|
JSON.stringify({
|
|
24
|
-
|
|
24
|
+
// Spelled out rather than built: this function calls nothing, which is what "last resort"
|
|
25
|
+
// means. `problemTypeFor('X_INTERNAL')` is the one it must equal, and
|
|
26
|
+
// `pipeline-finalize.test.ts` asserts that equality so the two cannot drift apart in silence.
|
|
27
|
+
type: 'urn:ultimate:error:X_INTERNAL',
|
|
25
28
|
title: 'unhandled server error',
|
|
26
29
|
status: 500,
|
|
27
30
|
detail: 'the error renderer itself failed, so nothing of the original error survives here',
|
package/src/hooks.ts
CHANGED
|
@@ -37,6 +37,20 @@ export interface ServerHooks {
|
|
|
37
37
|
* a production process never calls it; `x dev` is the only host that supplies one.
|
|
38
38
|
*/
|
|
39
39
|
readonly devNotices?: (ctx: RequestContext) => readonly OverlayNotice[];
|
|
40
|
+
/**
|
|
41
|
+
* The app's OWN error page for a status, served byte for byte, or `undefined` to render the
|
|
42
|
+
* framework's. A seam and not a config value because the answer lives on a disk this package
|
|
43
|
+
* cannot see: `@ultimat3/cli` reads `apps/web/site/errors/<status>.html`, per request, so a file
|
|
44
|
+
* dropped into a running server takes effect without a restart — the rule `/favicon.ico`
|
|
45
|
+
* already follows for the same class of file.
|
|
46
|
+
*
|
|
47
|
+
* Consulted only on the production HTML path: a dev process answers a browser with the overlay,
|
|
48
|
+
* and an agent gets the problem document in both.
|
|
49
|
+
*/
|
|
50
|
+
readonly errorPage?: (
|
|
51
|
+
status: number,
|
|
52
|
+
ctx: RequestContext,
|
|
53
|
+
) => Promise<string | undefined> | string | undefined;
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export type Authenticator = NonNullable<ServerHooks['authenticate']>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The two questions every HTML answer this package renders has to ask, in one place: is the caller
|
|
2
|
+
// a browser, and how does a value become markup. Both were the dev overlay's private helpers while
|
|
3
|
+
// the production error page needs the identical answers — a second accept sniff would let a client
|
|
4
|
+
// get the overlay in dev and JSON in production, and a second escape set is one hole away.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `'` is escaped even though every attribute the renderers write is double-quoted: the escape set
|
|
8
|
+
* is what the next author reads as the guarantee, and a single-quoted attribute written later
|
|
9
|
+
* would inherit a hole nothing here would have flagged.
|
|
10
|
+
*/
|
|
11
|
+
export const escapeHtml = (value: string): string =>
|
|
12
|
+
value
|
|
13
|
+
.replaceAll('&', '&')
|
|
14
|
+
.replaceAll('<', '<')
|
|
15
|
+
.replaceAll('>', '>')
|
|
16
|
+
.replaceAll('"', '"')
|
|
17
|
+
.replaceAll("'", ''');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Does this caller render HTML? One sniff, three readers — the dev overlay, the production error
|
|
21
|
+
* page and the sign-in redirect — so a browser cannot be handed a page by one of them and a
|
|
22
|
+
* problem document by the next for the same request.
|
|
23
|
+
*/
|
|
24
|
+
export const acceptsHtml = (request: Request): boolean =>
|
|
25
|
+
(request.headers.get('accept') ?? '').includes('text/html');
|
package/src/index.ts
CHANGED
|
@@ -29,12 +29,27 @@ export {
|
|
|
29
29
|
DEFAULT_STATUS,
|
|
30
30
|
ERROR_STATUS,
|
|
31
31
|
factsOf,
|
|
32
|
+
problemTypeFor,
|
|
32
33
|
registerErrorStatus,
|
|
33
34
|
renderErrorLines,
|
|
34
35
|
resetErrorStatus,
|
|
36
|
+
retryAfterOf,
|
|
35
37
|
statusFor,
|
|
36
38
|
toProblem,
|
|
37
39
|
} from './error-map';
|
|
40
|
+
export type {
|
|
41
|
+
ErrorPageAction,
|
|
42
|
+
ErrorPageCopy,
|
|
43
|
+
ErrorPageGroup,
|
|
44
|
+
ErrorPageInput,
|
|
45
|
+
ErrorPageOptions,
|
|
46
|
+
} from './error-page';
|
|
47
|
+
export {
|
|
48
|
+
ERROR_PAGE_LINKS,
|
|
49
|
+
errorPageResponse,
|
|
50
|
+
renderErrorPage,
|
|
51
|
+
resolveErrorPageCopy,
|
|
52
|
+
} from './error-page';
|
|
38
53
|
export type { HttpErrorCode } from './errors';
|
|
39
54
|
export {
|
|
40
55
|
bodyInvalid,
|
|
@@ -71,6 +86,7 @@ export {
|
|
|
71
86
|
} from './forwarded';
|
|
72
87
|
export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
|
|
73
88
|
export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
|
|
89
|
+
export { acceptsHtml, escapeHtml } from './html-render';
|
|
74
90
|
export type { LocaleConfig, TimeZoneConfig } from './locale';
|
|
75
91
|
export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG, readCookie } from './locale';
|
|
76
92
|
export type { Middleware } from './middleware';
|
package/src/overlay-style.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The stylesheet of every document this package renders — the dev overlay and the production
|
|
2
|
+
// error page — split from both so `security-headers.ts` can hash it into the CSP without importing
|
|
3
|
+
// a renderer, and so the hash is computed from the one copy of the text rather than from a constant
|
|
4
|
+
// that drifted away from what the `<style>` holds. ONE stylesheet and not two: a second inline body
|
|
5
|
+
// is a second `style-src` hash, and the page that shipped without one renders unstyled.
|
|
4
6
|
|
|
5
7
|
// Token definitions live here and nowhere else; every rule below uses var().
|
|
6
8
|
export const OVERLAY_STYLE = `
|
|
@@ -40,4 +42,10 @@ a { color: var(--x-accent); }
|
|
|
40
42
|
card exists to show would sit on top of their own causes. */
|
|
41
43
|
.notices dl { grid-template-columns: 14rem 1fr; }
|
|
42
44
|
.notices dt { overflow-wrap: anywhere; }
|
|
45
|
+
/* The production error page's three rules. The status is the one number a visitor reads first, so
|
|
46
|
+
it leads at display size in the muted role — never the danger role, which the overlay's h1 owns
|
|
47
|
+
and which says "a defect" rather than "this page moved". */
|
|
48
|
+
.status { font-size: 2.5rem; line-height: 1.1; margin: 0; color: var(--x-muted); }
|
|
49
|
+
.lede { margin: .25rem 0 1rem; }
|
|
50
|
+
footer { display: flex; flex-wrap: wrap; gap: 1rem; color: var(--x-muted); }
|
|
43
51
|
`;
|
package/src/overlay.ts
CHANGED
|
@@ -3,20 +3,10 @@
|
|
|
3
3
|
// surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
|
|
4
4
|
// error contract, not UI copy, so they are not routed through the i18n catalog.
|
|
5
5
|
import { factsOf, renderErrorLines, toProblem } from './error-map';
|
|
6
|
+
import { acceptsHtml, escapeHtml } from './html-render';
|
|
6
7
|
import { OVERLAY_STYLE } from './overlay-style';
|
|
7
8
|
import { html } from './response';
|
|
8
9
|
|
|
9
|
-
// `'` is escaped even though every attribute below is double-quoted: the escape set is what the
|
|
10
|
-
// next author reads as the guarantee, and a single-quoted attribute written later would inherit a
|
|
11
|
-
// hole nothing here would have flagged.
|
|
12
|
-
const escapeHtml = (value: string): string =>
|
|
13
|
-
value
|
|
14
|
-
.replaceAll('&', '&')
|
|
15
|
-
.replaceAll('<', '<')
|
|
16
|
-
.replaceAll('>', '>')
|
|
17
|
-
.replaceAll('"', '"')
|
|
18
|
-
.replaceAll("'", ''');
|
|
19
|
-
|
|
20
10
|
/**
|
|
21
11
|
* An `href` is a SCHEME decision, not an escaping one: `javascript:alert(1)` survives every entity
|
|
22
12
|
* replacement intact and becomes a link the agent debugging this page clicks. A `docs` value comes
|
|
@@ -78,6 +68,9 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
|
|
|
78
68
|
const problem = toProblem(error, {
|
|
79
69
|
...(meta.requestId === undefined ? {} : { requestId: meta.requestId }),
|
|
80
70
|
...(meta.path === undefined ? {} : { instance: meta.path }),
|
|
71
|
+
// The overlay is rendered on the dev branch and nowhere else (`stages.ts`), and its whole job
|
|
72
|
+
// is showing the developer the cause a production caller may not see.
|
|
73
|
+
dev: true,
|
|
81
74
|
});
|
|
82
75
|
const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim();
|
|
83
76
|
return `<style>${OVERLAY_STYLE}</style>
|
|
@@ -119,6 +112,10 @@ export const overlayResponse = (error: unknown, meta: OverlayMeta = {}): Respons
|
|
|
119
112
|
headers: { 'cache-control': 'no-store' },
|
|
120
113
|
});
|
|
121
114
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Dev only, and only when the caller is a browser: agents and RPC want problem+json. The sniff
|
|
117
|
+
* itself is `acceptsHtml`, shared with the production error page — the two documents answer the
|
|
118
|
+
* same caller in two environments, so asking the question twice is how one of them starts
|
|
119
|
+
* disagreeing with the other.
|
|
120
|
+
*/
|
|
121
|
+
export const wantsOverlay = (request: Request): boolean => acceptsHtml(request);
|
package/src/rate-limit.ts
CHANGED
|
@@ -326,9 +326,16 @@ export const createRateLimiter = (options: {
|
|
|
326
326
|
const store = options.store ?? memoryRateLimitStore();
|
|
327
327
|
const clock = options.clock ?? systemClock;
|
|
328
328
|
const now = (): number => clock.now().getTime();
|
|
329
|
+
// `Object.hasOwn`, never `buckets[name]` — the same read `error-map.ts`'s `statusFor` and
|
|
330
|
+
// `naming.ts` already take for a table of this shape. `buckets` is a plain object literal, so it
|
|
331
|
+
// holds every name on `Object.prototype`: `rateLimit: 'constructor'` read `Object` itself out of
|
|
332
|
+
// it, and a `Bucket` whose `capacity` is `undefined` is a limiter that decides nothing. Author-
|
|
333
|
+
// controlled, and still the one form — a table indexed by a name is indexed through `hasOwn`.
|
|
334
|
+
const declared = (name: string): Bucket | undefined =>
|
|
335
|
+
Object.hasOwn(options.config.buckets, name) ? options.config.buckets[name] : undefined;
|
|
329
336
|
const bucketFor = (name: string): Bucket =>
|
|
330
|
-
|
|
331
|
-
options.config.
|
|
337
|
+
declared(name) ??
|
|
338
|
+
declared(options.config.defaultBucket) ??
|
|
332
339
|
DEFAULT_RATE_LIMIT.buckets['default'] ?? { capacity: 60, refillPerSecond: 1 };
|
|
333
340
|
|
|
334
341
|
const check: RateLimiter['check'] = (key, bucketName, cost = 1) =>
|
package/src/request.ts
CHANGED
|
@@ -26,9 +26,9 @@ const contentTypeOf = (request: Request): string =>
|
|
|
26
26
|
* `coerceQuery` decides whether to coerce a declared property — stops answering true for every
|
|
27
27
|
* member of `Object.prototype`.
|
|
28
28
|
*/
|
|
29
|
-
const
|
|
30
|
-
const out: Record<string,
|
|
31
|
-
for (const [key, value] of
|
|
29
|
+
const collectFields = <T>(entries: Iterable<[string, T]>): Record<string, T | T[]> => {
|
|
30
|
+
const out: Record<string, T | T[]> = Object.create(null);
|
|
31
|
+
for (const [key, value] of entries) {
|
|
32
32
|
const existing = out[key];
|
|
33
33
|
if (existing === undefined) out[key] = value;
|
|
34
34
|
else if (Array.isArray(existing)) existing.push(value);
|
|
@@ -37,6 +37,8 @@ const parseQuery = (url: URL): QueryValues => {
|
|
|
37
37
|
return out;
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
const parseQuery = (url: URL): QueryValues => collectFields(url.searchParams);
|
|
41
|
+
|
|
40
42
|
export class UltimateRequest {
|
|
41
43
|
readonly raw: Request;
|
|
42
44
|
readonly ctx: RequestContext;
|
|
@@ -184,7 +186,10 @@ export class UltimateRequest {
|
|
|
184
186
|
const form = await new Response(new Uint8Array(read.bytes), {
|
|
185
187
|
headers: { 'content-type': this.raw.headers.get('content-type') ?? type },
|
|
186
188
|
}).formData();
|
|
187
|
-
|
|
189
|
+
// `collectFields`, never `Object.fromEntries`: a repeated name is a LIST here for the
|
|
190
|
+
// reason it is one in the query — a checkbox group posts its name once per checked box,
|
|
191
|
+
// and collapsing to the last one silently discards every other answer.
|
|
192
|
+
return collectFields(form);
|
|
188
193
|
} catch (error) {
|
|
189
194
|
// The parser's own message is a diagnostic, not an instruction, and it quotes the bytes
|
|
190
195
|
// it choked on — so it rides in `meta`, rendered by core rather than by `String(error)`,
|
|
@@ -199,7 +204,7 @@ export class UltimateRequest {
|
|
|
199
204
|
try {
|
|
200
205
|
if (type === 'application/json' || type.endsWith('+json')) return JSON.parse(body);
|
|
201
206
|
if (type === 'application/x-www-form-urlencoded') {
|
|
202
|
-
return
|
|
207
|
+
return collectFields(new URLSearchParams(body));
|
|
203
208
|
}
|
|
204
209
|
if (type.startsWith('text/')) return body;
|
|
205
210
|
} catch (error) {
|
package/src/response.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Response constructors. Every response in the framework is built here so that
|
|
2
2
|
// content types, charsets and cache semantics are decided once instead of per route.
|
|
3
|
+
import { TIMEZONE_HEADER } from '@ultimat3/time';
|
|
3
4
|
import { toProblem } from './error-map';
|
|
4
5
|
|
|
5
6
|
type HeaderSource = { readonly headers?: HeadersInit | undefined } | undefined;
|
|
@@ -73,11 +74,21 @@ export interface RedirectIntent {
|
|
|
73
74
|
*/
|
|
74
75
|
export const problem = (
|
|
75
76
|
error: unknown,
|
|
76
|
-
meta: {
|
|
77
|
+
meta: {
|
|
78
|
+
instance?: string;
|
|
79
|
+
requestId?: string;
|
|
80
|
+
headers?: Record<string, string>;
|
|
81
|
+
/**
|
|
82
|
+
* `config.dev`. Absent means NOT dev: a degraded path that cannot see the config must not be
|
|
83
|
+
* the one that reveals an unclassified 500's text, and the stage that can see it passes it.
|
|
84
|
+
*/
|
|
85
|
+
dev?: boolean;
|
|
86
|
+
} = {},
|
|
77
87
|
): Response => {
|
|
78
88
|
const document = toProblem(error, {
|
|
79
89
|
...(meta.instance === undefined ? {} : { instance: meta.instance }),
|
|
80
90
|
...(meta.requestId === undefined ? {} : { requestId: meta.requestId }),
|
|
91
|
+
...(meta.dev === undefined ? {} : { dev: meta.dev }),
|
|
81
92
|
});
|
|
82
93
|
return new Response(JSON.stringify(document), {
|
|
83
94
|
status: document.status,
|
|
@@ -134,6 +145,16 @@ export const addVary = (response: Response, values: readonly string[]): Response
|
|
|
134
145
|
return response;
|
|
135
146
|
};
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The request dimensions a SHARED copy of a response is keyed on. `cookie` because every session
|
|
150
|
+
* in this framework travels in one; `accept-language` and the time-zone header because both are
|
|
151
|
+
* ambient inputs to a server render — they become `ctx.locale` and `ctx.tz`, which is what
|
|
152
|
+
* `@ultimat3/ui` formats every date with — so the body is a function of them and a cache that
|
|
153
|
+
* ignores one hands the next visitor the previous one's document. One list, two readers: the hint
|
|
154
|
+
* this file applies, and the `cache-headers` stage for a shared `cache-control` a handler wrote.
|
|
155
|
+
*/
|
|
156
|
+
export const SHARED_CACHE_VARY: readonly string[] = ['accept-language', 'cookie', TIMEZONE_HEADER];
|
|
157
|
+
|
|
137
158
|
/** Mutates the response headers in place — responses are per-request, never shared. */
|
|
138
159
|
export const applyCacheHeaders = (response: Response, hint: CacheHint): Response => {
|
|
139
160
|
response.headers.set('cache-control', cacheControl(hint));
|
|
@@ -142,11 +163,10 @@ export const applyCacheHeaders = (response: Response, hint: CacheHint): Response
|
|
|
142
163
|
}
|
|
143
164
|
// `cookie` is not optional on the shared path. A `public` response is stored by a CDN under the
|
|
144
165
|
// URL, and every session in this framework travels in a cookie — so without it the first
|
|
145
|
-
// signed-in render of a public page is what every later visitor is served.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
);
|
|
166
|
+
// signed-in render of a public page is what every later visitor is served. `SHARED_CACHE_VARY`
|
|
167
|
+
// rather than a literal: the stage that reviews a handler's own `cache-control` adds the same
|
|
168
|
+
// dimensions, and two lists is one of them missing the key that mattered.
|
|
169
|
+
return addVary(response, hint.vary ?? (hint.mode === 'public' ? SHARED_CACHE_VARY : []));
|
|
150
170
|
};
|
|
151
171
|
|
|
152
172
|
export const withHeaders = (response: Response, headers: Record<string, string>): Response => {
|
package/src/security-headers.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// config change. Inline style is admitted by sha256 hash, never `'unsafe-inline'`: a prerendered
|
|
4
4
|
// document is a file on disk, so no per-response nonce can reach it, but its body is fixed.
|
|
5
5
|
|
|
6
|
+
import { cspDirectiveInvalid } from './errors';
|
|
6
7
|
import { OVERLAY_STYLE } from './overlay-style';
|
|
7
8
|
|
|
8
9
|
export interface SecurityConfig {
|
|
@@ -72,12 +73,42 @@ const baseline = (config: SecurityConfig): Record<string, readonly string[]> =>
|
|
|
72
73
|
'object-src': ["'none'"],
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
/** A CSP directive name, per the grammar. Lowercase because that is what this file emits. */
|
|
77
|
+
const DIRECTIVE_NAME = /^[a-z][a-z0-9-]*$/;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A source expression may not carry one of the header's own separators. Checked and never escaped:
|
|
81
|
+
* there is no encoding for a CSP source, so the only total answer is refusing the value.
|
|
82
|
+
*/
|
|
83
|
+
const SOURCE_DELIMITER = /[\s;,]/;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Refuse an `extend` entry that would emit something other than the directive it names. Called
|
|
87
|
+
* from `defineHttpConfig`, beside `assertCorsConfig`, so the refusal lands at boot rather than on
|
|
88
|
+
* the first response — and never per request, where this runs for every header built.
|
|
89
|
+
*/
|
|
90
|
+
export const assertCspExtend = (extend: Readonly<Record<string, readonly string[]>>): void => {
|
|
91
|
+
for (const [name, sources] of Object.entries(extend)) {
|
|
92
|
+
if (!DIRECTIVE_NAME.test(name)) throw cspDirectiveInvalid('a csp directive name', name);
|
|
93
|
+
for (const source of sources) {
|
|
94
|
+
if (SOURCE_DELIMITER.test(source)) {
|
|
95
|
+
throw cspDirectiveInvalid(`a source of ${name}`, source);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
75
101
|
export const buildCsp = (config: SecurityConfig): string => {
|
|
76
|
-
|
|
102
|
+
// A `Map`, never the record: `directives[name]` was a computed read of an object LITERAL keyed
|
|
103
|
+
// by a name the caller chose, so `extend: { toString: [...] }` read a FUNCTION off
|
|
104
|
+
// `Object.prototype` and the spread beside it threw a bare `TypeError` at boot — and
|
|
105
|
+
// `directives['__proto__'] = […]` would have run the prototype setter instead of adding a
|
|
106
|
+
// directive. `proto-index` cannot see either, because `baseline()` is what produces the object.
|
|
107
|
+
const directives = new Map<string, readonly string[]>(Object.entries(baseline(config)));
|
|
77
108
|
for (const [name, sources] of Object.entries(config.csp.extend)) {
|
|
78
|
-
directives
|
|
109
|
+
directives.set(name, [...(directives.get(name) ?? []), ...sources]);
|
|
79
110
|
}
|
|
80
|
-
const parts =
|
|
111
|
+
const parts = [...directives].map(([name, sources]) => `${name} ${sources.join(' ')}`);
|
|
81
112
|
if (config.csp.reportUri !== null) parts.push(`report-uri ${config.csp.reportUri}`);
|
|
82
113
|
return parts.join('; ');
|
|
83
114
|
};
|
package/src/server.ts
CHANGED
|
@@ -93,8 +93,12 @@ export const createServer = (options: ServerOptions): ServerHandle => {
|
|
|
93
93
|
}),
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
// The one HTTP-owned knob feeds core's deadline, so there is a single drain budget
|
|
97
|
-
|
|
96
|
+
// The one HTTP-owned knob feeds core's deadline, so there is a single drain budget — and only
|
|
97
|
+
// when this app DECLARED it. Unconditional, with `defineHttpConfig` defaulting the number, this
|
|
98
|
+
// line reverted `configureLifecycle({ deadlineMs: 600_000 })` — the edit `X_SHUTDOWN_TIMEOUT`'s
|
|
99
|
+
// own `fix:` prints — back to 15s on every boot that serves web, silently. "Nobody said" and
|
|
100
|
+
// "the app said 15 seconds" are different claims and `null` is what keeps them apart.
|
|
101
|
+
if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
|
|
98
102
|
|
|
99
103
|
let server: BunServer | undefined;
|
|
100
104
|
let unregister: (() => void) | undefined;
|
package/src/stages.ts
CHANGED
|
@@ -14,12 +14,13 @@ import {
|
|
|
14
14
|
import { resolveLocale } from '@ultimat3/i18n';
|
|
15
15
|
import { resolveTimeZone } from '@ultimat3/time';
|
|
16
16
|
import { signInRedirect } from './auth-redirect';
|
|
17
|
-
import { defaultCache } from './cache-policy';
|
|
17
|
+
import { defaultCache, offersSharedCache, PRIVATE_CACHE } from './cache-policy';
|
|
18
18
|
import { type HttpConfig, stripBasePath } from './config';
|
|
19
19
|
import { actorView, elapsedMs, type RequestContext } from './context';
|
|
20
20
|
import { corsHeaders, preflight } from './cors';
|
|
21
21
|
import { checkCsrf, selfOrigin } from './csrf';
|
|
22
|
-
import { factsOf } from './error-map';
|
|
22
|
+
import { factsOf, retryAfterOf } from './error-map';
|
|
23
|
+
import { errorPageResponse } from './error-page';
|
|
23
24
|
import {
|
|
24
25
|
bodyInvalid,
|
|
25
26
|
csrfBlocked,
|
|
@@ -32,13 +33,14 @@ import {
|
|
|
32
33
|
unauthenticated,
|
|
33
34
|
} from './errors';
|
|
34
35
|
import type { ServerHooks } from './hooks';
|
|
36
|
+
import { acceptsHtml } from './html-render';
|
|
35
37
|
import { readCookie } from './locale';
|
|
36
38
|
import { compose, type Middleware } from './middleware';
|
|
37
|
-
import { overlayResponse
|
|
39
|
+
import { overlayResponse } from './overlay';
|
|
38
40
|
import { type RateLimiter, rateLimitKey } from './rate-limit';
|
|
39
41
|
import { rateLimited } from './rate-limit-errors';
|
|
40
42
|
import type { UltimateRequest } from './request';
|
|
41
|
-
import { addVary, applyCacheHeaders, problem, redirect } from './response';
|
|
43
|
+
import { addVary, applyCacheHeaders, problem, redirect, SHARED_CACHE_VARY } from './response';
|
|
42
44
|
import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
|
|
43
45
|
import { securityHeaders } from './security-headers';
|
|
44
46
|
import { validate } from './validate';
|
|
@@ -288,16 +290,30 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
288
290
|
'cache-headers': (_request, ctx) => {
|
|
289
291
|
const response = ctx.response;
|
|
290
292
|
if (response === undefined) return undefined;
|
|
291
|
-
|
|
293
|
+
const declared = response.headers.get('cache-control');
|
|
294
|
+
if (declared === null) {
|
|
292
295
|
applyCacheHeaders(
|
|
293
296
|
response,
|
|
294
297
|
ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor),
|
|
295
298
|
);
|
|
299
|
+
return undefined;
|
|
296
300
|
}
|
|
301
|
+
// A declaration is the MODE's intent, never the last word: `@ultimat3/render`'s `ssrHeaders`
|
|
302
|
+
// offers any route without a `policy` to a CDN for 30 seconds, and `meta.auth` is
|
|
303
|
+
// `'public' | 'required'` — so the page that greets a signed-in visitor by name is a
|
|
304
|
+
// `'public'` route whose own header says `s-maxage`. This stage is the one owner of the
|
|
305
|
+
// final answer, which is why it REVIEWS what the handler wrote instead of standing down;
|
|
306
|
+
// the rule beside it was otherwise unreachable for every page route in every app.
|
|
307
|
+
if (!offersSharedCache(declared)) return undefined;
|
|
308
|
+
if (!isAnonymous(ctx.actor)) {
|
|
309
|
+
applyCacheHeaders(response, PRIVATE_CACHE);
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
addVary(response, SHARED_CACHE_VARY);
|
|
297
313
|
return undefined;
|
|
298
314
|
},
|
|
299
315
|
|
|
300
|
-
'error-map': (request, ctx) => {
|
|
316
|
+
'error-map': async (request, ctx) => {
|
|
301
317
|
const error = ctx.error;
|
|
302
318
|
const facts = factsOf(error);
|
|
303
319
|
// This package's ONE error-reporting call site, and it is the framework's own — `onError`
|
|
@@ -336,27 +352,66 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
336
352
|
ctx,
|
|
337
353
|
});
|
|
338
354
|
if (toSignIn !== undefined) return redirect(toSignIn.location, toSignIn.status);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
355
|
+
// The limiter's own decision first — it is the live one and it knows this request's bucket —
|
|
356
|
+
// then whatever the THROWABLE computed. Only the first half existed, so every other refusal
|
|
357
|
+
// that had a delay to give told the caller to come back without saying when:
|
|
358
|
+
// `X_ACCOUNT_LOCKED` is a 429 with no `Retry-After` at all, and `X_OVERLOADED` from
|
|
359
|
+
// `@ultimat3/auth`'s KDF gate carries the number in `meta` under a comment saying the host
|
|
360
|
+
// reads it. Nothing was the host.
|
|
361
|
+
// `> 0` and not merely `!== undefined`: `RateLimitDecision.retryAfterSeconds` is `0` on an
|
|
362
|
+
// ALLOWED request, so a handler raising `X_RATE_LIMITED` from a limiter of its own — an
|
|
363
|
+
// action's declared `rateLimit`, `@ultimat3/auth`'s credential limiter — was answered
|
|
364
|
+
// `retry-after: 0`, which is "retry now" and is the stampede the header exists to spread.
|
|
365
|
+
const decided =
|
|
366
|
+
facts.code === 'X_RATE_LIMITED' && (ctx.rateLimit?.retryAfterSeconds ?? 0) > 0
|
|
367
|
+
? ctx.rateLimit?.retryAfterSeconds
|
|
368
|
+
: undefined;
|
|
369
|
+
const seconds = decided ?? retryAfterOf(error);
|
|
370
|
+
const retryAfter = seconds === undefined ? {} : { 'retry-after': String(seconds) };
|
|
371
|
+
// One sniff, two documents: a browser is never handed a problem document, and an agent is
|
|
372
|
+
// never handed a page. Which of the two a browser gets is the ENVIRONMENT — the overlay
|
|
373
|
+
// prints the cause, the fix and the stack, which is what a visitor may never see.
|
|
374
|
+
if (acceptsHtml(request.raw)) {
|
|
375
|
+
if (config.dev) {
|
|
376
|
+
// Asked for inside the branch, never above it: the overlay is the only surface a notice
|
|
377
|
+
// has, so a production process — or an agent that asked for json — must not pay a
|
|
378
|
+
// diagnostic's per-request cost to produce findings nothing will render.
|
|
379
|
+
const notices = hooks.devNotices?.(ctx) ?? [];
|
|
380
|
+
return overlayResponse(error, {
|
|
381
|
+
requestId: ctx.requestId,
|
|
382
|
+
method: ctx.method,
|
|
383
|
+
path: ctx.url.pathname,
|
|
384
|
+
buildId: config.buildId,
|
|
385
|
+
...(notices.length === 0 ? {} : { notices }),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return errorPageResponse(
|
|
389
|
+
{
|
|
390
|
+
status: facts.status,
|
|
391
|
+
code: facts.code,
|
|
392
|
+
path: ctx.url.pathname,
|
|
393
|
+
requestId: ctx.requestId,
|
|
394
|
+
locale: ctx.locale,
|
|
395
|
+
// The rule the 403 page may name, and the one the `authz` stage was evaluating —
|
|
396
|
+
// never a row, never the actor. `forbidden`'s own `fix:` already cites this field.
|
|
397
|
+
...(ctx.route?.meta.policy === undefined ? {} : { permission: ctx.route.meta.policy }),
|
|
398
|
+
...(seconds === undefined ? {} : { retryAfterSeconds: seconds }),
|
|
399
|
+
signInPath: config.signInPath,
|
|
400
|
+
},
|
|
401
|
+
// The app's own file, read per request by whoever mounted the hook. A throw here is
|
|
402
|
+
// caught by `recoverWith` and degrades to the problem document, which is the answer a
|
|
403
|
+
// page whose renderer failed can still give.
|
|
404
|
+
{ override: await hooks.errorPage?.(facts.status, ctx), headers: retryAfter },
|
|
405
|
+
);
|
|
351
406
|
}
|
|
352
|
-
const retryAfter =
|
|
353
|
-
facts.code === 'X_RATE_LIMITED' && ctx.rateLimit !== undefined
|
|
354
|
-
? { 'retry-after': String(ctx.rateLimit.retryAfterSeconds) }
|
|
355
|
-
: {};
|
|
356
407
|
return problem(error, {
|
|
357
408
|
instance: ctx.url.pathname,
|
|
358
409
|
requestId: ctx.requestId,
|
|
359
410
|
headers: retryAfter,
|
|
411
|
+
// The one call site that can see the config, so it is the one that may reveal an
|
|
412
|
+
// unclassified 500's own text. Every other `problem()` in this package is a degraded
|
|
413
|
+
// path and stays opaque by default.
|
|
414
|
+
dev: config.dev,
|
|
360
415
|
});
|
|
361
416
|
},
|
|
362
417
|
|