@ultimat3/http 10.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 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`
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` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "10.0.0",
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": "10.0.0",
35
- "@ultimat3/i18n": "10.0.0",
36
- "@ultimat3/schema": "10.0.0",
37
- "@ultimat3/time": "10.0.0"
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
  }
@@ -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 { wantsOverlay } from './overlay';
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 `wantsOverlay` asks — "does this client render HTML?" — and deliberately
32
- // the same answer, so a client cannot get the overlay in dev and JSON in production.
33
- if (!wantsOverlay(request)) return undefined;
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;
@@ -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 { mode: 'private', maxAgeSeconds: 0 };
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
- /** How long SIGTERM waits for in-flight requests before hard-stopping. */
60
- readonly drainTimeoutMs: number;
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 ?? 15_000,
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
@@ -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.
@@ -424,19 +425,51 @@ export interface ProblemDocument {
424
425
  readonly requestId: string | undefined;
425
426
  }
426
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
+
427
459
  export const toProblem = (
428
460
  error: unknown,
429
- meta: { instance?: string; requestId?: string } = {},
461
+ meta: { instance?: string; requestId?: string; dev?: boolean } = {},
430
462
  ): ProblemDocument => {
431
463
  const facts = factsOf(error);
464
+ const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
432
465
  return {
433
466
  type: problemTypeFor(facts.code),
434
- title: facts.title,
467
+ title: opaque ? INTERNAL_TITLE : facts.title,
435
468
  status: facts.status,
436
- detail: facts.cause,
469
+ detail: opaque ? INTERNAL_CAUSE : facts.cause,
437
470
  instance: meta.instance,
438
471
  code: facts.code,
439
- cause: facts.cause,
472
+ cause: opaque ? INTERNAL_CAUSE : facts.cause,
440
473
  fix: facts.fix,
441
474
  docs: facts.docs,
442
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',
@@ -290,6 +292,20 @@ export const corsConfigInvalid = (reason: string): HttpError =>
290
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",
291
293
  });
292
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
+
293
309
  export const routeConflict = (path: string, detail: string): HttpError =>
294
310
  new HttpError({
295
311
  code: 'X_ROUTE_CONFLICT',
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('&', '&amp;')
14
+ .replaceAll('<', '&lt;')
15
+ .replaceAll('>', '&gt;')
16
+ .replaceAll('"', '&quot;')
17
+ .replaceAll("'", '&#39;');
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
@@ -37,6 +37,19 @@ export {
37
37
  statusFor,
38
38
  toProblem,
39
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';
40
53
  export type { HttpErrorCode } from './errors';
41
54
  export {
42
55
  bodyInvalid,
@@ -73,6 +86,7 @@ export {
73
86
  } from './forwarded';
74
87
  export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
75
88
  export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
89
+ export { acceptsHtml, escapeHtml } from './html-render';
76
90
  export type { LocaleConfig, TimeZoneConfig } from './locale';
77
91
  export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG, readCookie } from './locale';
78
92
  export type { Middleware } from './middleware';
@@ -1,6 +1,8 @@
1
- // The dev overlay's stylesheet, split from the document that carries it so `security-headers.ts`
2
- // can hash it into the CSP without importing the renderer and so the hash is computed from the
3
- // one copy of the text, never from a constant that drifted away from what the `<style>` holds.
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('&', '&amp;')
15
- .replaceAll('<', '&lt;')
16
- .replaceAll('>', '&gt;')
17
- .replaceAll('"', '&quot;')
18
- .replaceAll("'", '&#39;');
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
- /** Dev only, and only when the caller is a browser: agents and RPC want problem+json. */
123
- export const wantsOverlay = (request: Request): boolean =>
124
- (request.headers.get('accept') ?? '').includes('text/html');
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/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 parseQuery = (url: URL): QueryValues => {
30
- const out: Record<string, string | string[]> = Object.create(null);
31
- for (const [key, value] of url.searchParams) {
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
- return Object.fromEntries(form);
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 Object.fromEntries(new URLSearchParams(body));
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: { instance?: string; requestId?: string; headers?: Record<string, string> } = {},
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
- return addVary(
147
- response,
148
- hint.vary ?? (hint.mode === 'public' ? ['accept-language', 'cookie'] : []),
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 => {
@@ -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
- const directives = baseline(config);
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[name] = [...(directives[name] ?? []), ...sources];
109
+ directives.set(name, [...(directives.get(name) ?? []), ...sources]);
79
110
  }
80
- const parts = Object.entries(directives).map(([name, sources]) => `${name} ${sources.join(' ')}`);
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
- configureLifecycle({ deadlineMs: config.drainTimeoutMs });
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
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, wantsOverlay } from './overlay';
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
- if (!response.headers.has('cache-control')) {
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,19 +352,6 @@ 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
- if (config.dev && wantsOverlay(request.raw)) {
340
- // Asked for inside the branch, never above it: the overlay is the only surface a notice
341
- // has, so a production process — or an agent that asked for json — must not pay a
342
- // diagnostic's per-request cost to produce findings nothing will render.
343
- const notices = hooks.devNotices?.(ctx) ?? [];
344
- return overlayResponse(error, {
345
- requestId: ctx.requestId,
346
- method: ctx.method,
347
- path: ctx.url.pathname,
348
- buildId: config.buildId,
349
- ...(notices.length === 0 ? {} : { notices }),
350
- });
351
- }
352
355
  // The limiter's own decision first — it is the live one and it knows this request's bucket —
353
356
  // then whatever the THROWABLE computed. Only the first half existed, so every other refusal
354
357
  // that had a delay to give told the caller to come back without saying when:
@@ -365,10 +368,50 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
365
368
  : undefined;
366
369
  const seconds = decided ?? retryAfterOf(error);
367
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
+ );
406
+ }
368
407
  return problem(error, {
369
408
  instance: ctx.url.pathname,
370
409
  requestId: ctx.requestId,
371
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,
372
415
  });
373
416
  },
374
417