@ultimat3/http 20.2.1 → 22.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.
@@ -30,27 +30,25 @@ export interface PeerIdentity {
30
30
  readonly by: string | null;
31
31
  }
32
32
 
33
- /** Splits on a separator that is not inside a double-quoted value. `Subject="CN=a,OU=b"`. */
34
- const splitUnquoted = (value: string, separator: string): readonly string[] => {
33
+ /**
34
+ * Splits on a separator outside double quotes, keeping the quotes and escapes EXACTLY as written.
35
+ * Raw on purpose: the element split and the pair split both run over this text, and an element
36
+ * split that also stripped quotes left `pairsOf` splitting `O=Acme; Inc` a second time, unquoted.
37
+ * Unescaping happens once, per value, in `unquote`.
38
+ */
39
+ const splitRaw = (value: string, separator: string): readonly string[] => {
35
40
  const out: string[] = [];
36
41
  let current = '';
37
42
  let quoted = false;
38
43
  let escaped = false;
39
44
  for (const char of value) {
40
45
  if (escaped) {
41
- current += char;
42
46
  escaped = false;
43
- continue;
44
- }
45
- if (char === '\\') {
47
+ } else if (char === '\\') {
46
48
  escaped = true;
47
- continue;
48
- }
49
- if (char === '"') {
49
+ } else if (char === '"') {
50
50
  quoted = !quoted;
51
- continue;
52
- }
53
- if (char === separator && !quoted) {
51
+ } else if (char === separator && !quoted) {
54
52
  out.push(current.trim());
55
53
  current = '';
56
54
  continue;
@@ -61,14 +59,20 @@ const splitUnquoted = (value: string, separator: string): readonly string[] => {
61
59
  return out.filter((entry) => entry.length > 0);
62
60
  };
63
61
 
62
+ /** `"CN=checkout\""` → `CN=checkout"`. An unquoted value is taken as written. */
63
+ const unquote = (value: string): string => {
64
+ if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) return value;
65
+ return value.slice(1, -1).replace(/\\(.)/g, '$1');
66
+ };
67
+
64
68
  /** `Key=Value` pairs, keys lowercased. `URI` and `DNS` may repeat, so values collect. */
65
69
  const pairsOf = (element: string): ReadonlyMap<string, readonly string[]> => {
66
70
  const out = new Map<string, string[]>();
67
- for (const pair of splitUnquoted(element, ';')) {
71
+ for (const pair of splitRaw(element, ';')) {
68
72
  const eq = pair.indexOf('=');
69
73
  if (eq <= 0) continue;
70
74
  const key = pair.slice(0, eq).trim().toLowerCase();
71
- const value = pair.slice(eq + 1).trim();
75
+ const value = unquote(pair.slice(eq + 1).trim());
72
76
  if (value.length === 0) continue;
73
77
  const existing = out.get(key);
74
78
  if (existing === undefined) out.set(key, [value]);
@@ -86,7 +90,7 @@ export const peerIdentity = (input: ForwardedInput): PeerIdentity | null => {
86
90
  const element = forwardedElement(
87
91
  input.headers.get(FORWARDED_CLIENT_CERT),
88
92
  input.config.trustedProxyHops,
89
- splitUnquoted,
93
+ splitRaw,
90
94
  );
91
95
  if (element === undefined) return null;
92
96
  const pairs = pairsOf(element);
@@ -47,11 +47,31 @@ const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
47
47
  /** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
48
48
  const DECLARED = new Map<string, readonly string[]>();
49
49
 
50
+ /**
51
+ * The 5xx codes whose `cause` a caller may read. Every other 5xx document carries the code and the
52
+ * request id and a fixed sentence: `X_DB_STATEMENT_FAILED` has a status row, so the old "blank only
53
+ * what nobody classified" rule served the Postgres message and the SQL statement in a production
54
+ * 500. The framework's four are refusals whose cause IS the instruction — back off, retry.
55
+ */
56
+ const FRAMEWORK_PUBLIC_CAUSE: ReadonlySet<string> = new Set([
57
+ 'X_DRAINING',
58
+ 'X_OVERLOADED',
59
+ 'X_FLIGHT_GATE_OVERLOADED',
60
+ 'X_TIMEOUT',
61
+ ]);
62
+ const APP_PUBLIC_CAUSE = new Set<string>();
63
+
64
+ /** Per code: the `meta` keys, a public cause, or both. A bare list is the keys alone. */
65
+ export type ProblemMetaDeclaration =
66
+ | readonly string[]
67
+ | { readonly keys?: readonly string[] | undefined; readonly publicCause?: boolean | undefined };
68
+
50
69
  const sameKeys = (left: readonly string[], right: readonly string[]): boolean =>
51
70
  left.length === right.length && left.every((key, at) => key === right[at]);
52
71
 
53
72
  /**
54
- * Declare, per code, which `meta` keys the problem document carries. Call it once at boot, in
73
+ * Declare, per code, which `meta` keys the problem document carries — and, for a 5xx, whether its
74
+ * `cause` may be shown at all (`{ publicCause: true }`; hidden by default). Call it once at boot, in
55
75
  * the module that declares the codes — beside `registerErrorStatus`, and BOTH are needed: a code
56
76
  * with no declared status is an unclassified 5xx, and `toProblem` blanks everything but the code
57
77
  * and the request id on one of those, `meta` included.
@@ -66,12 +86,21 @@ const sameKeys = (left: readonly string[], right: readonly string[]): boolean =>
66
86
  * `X_RATE_LIMITED: ['key']` would publish the limiter's internal key on every 429.
67
87
  */
68
88
  export const registerProblemMeta = (
69
- declarations: Readonly<Record<string, readonly string[]>>,
89
+ declarations: Readonly<Record<string, ProblemMetaDeclaration>>,
70
90
  ): void => {
71
- for (const [code, keys] of Object.entries(declarations)) {
91
+ for (const [code, declaration] of Object.entries(declarations)) {
72
92
  if (frameworkOwns(code)) {
73
93
  throw problemMetaInvalid(code, 'the framework owns that code, and its meta is operator-only');
74
94
  }
95
+ const listed = Array.isArray(declaration);
96
+ const keys: readonly string[] = listed
97
+ ? declaration
98
+ : ((declaration as { keys?: readonly string[] }).keys ?? []);
99
+ const publicCause = !listed && (declaration as { publicCause?: boolean }).publicCause === true;
100
+ if (!listed && keys.length === 0 && publicCause) {
101
+ APP_PUBLIC_CAUSE.add(code);
102
+ continue;
103
+ }
75
104
  if (keys.length === 0) {
76
105
  throw problemMetaInvalid(code, 'the key list is empty — omit the code instead');
77
106
  }
@@ -93,11 +122,19 @@ export const registerProblemMeta = (
93
122
  throw problemMetaInvalid(code, `already declared as [${existing.join(', ')}] by this app`);
94
123
  }
95
124
  DECLARED.set(code, [...keys]);
125
+ if (publicCause) APP_PUBLIC_CAUSE.add(code);
96
126
  }
97
127
  };
98
128
 
99
129
  /** Test seam. Production registers once at boot and never unregisters. */
100
- export const resetProblemMeta = (): void => DECLARED.clear();
130
+ export const resetProblemMeta = (): void => {
131
+ DECLARED.clear();
132
+ APP_PUBLIC_CAUSE.clear();
133
+ };
134
+
135
+ /** Whether a 5xx document for `code` may carry its authored `cause`. */
136
+ export const hasPublicCause = (code: string): boolean =>
137
+ FRAMEWORK_PUBLIC_CAUSE.has(code) || APP_PUBLIC_CAUSE.has(code);
101
138
 
102
139
  /** The keys declared for a code, or `undefined` when nothing was — which is every framework code. */
103
140
  export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
@@ -0,0 +1,65 @@
1
+ // A request's registered services (`defineService`), bound to the actor that AUTHENTICATED. The
2
+ // context exists before the `auth` stage names anyone, so core's constructor is told to install
3
+ // nothing and every registered name becomes a lazy member here instead: built on first read, and
4
+ // built again only if the actor, locale or time zone it closed over has since changed.
5
+
6
+ import type { CtxFacts, ServiceBag } from '@ultimat3/core';
7
+ import { installedServices, registeredServiceNames } from '@ultimat3/core';
8
+ import type { RequestContext } from './context';
9
+
10
+ interface Built {
11
+ readonly actor: CtxFacts['actor'];
12
+ readonly locale: string;
13
+ readonly tz: string;
14
+ readonly bag: ServiceBag;
15
+ }
16
+
17
+ /**
18
+ * Makes `ctx.services` and each registered `ctx.<name>` lazy. Non-enumerable, so building the
19
+ * preview a factory reads — the context's facts, no sibling service — never reads one back.
20
+ * An explicit service (`init.services`) stays what it was: a caller's mock overrides the real one.
21
+ */
22
+ export function bindRequestServices(ctx: RequestContext, explicit: ServiceBag): void {
23
+ let built: Built | undefined;
24
+ const bag = (): ServiceBag => {
25
+ if (
26
+ built !== undefined &&
27
+ built.actor === ctx.actor &&
28
+ built.locale === ctx.locale &&
29
+ built.tz === ctx.tz
30
+ ) {
31
+ return built.bag;
32
+ }
33
+ const preview: CtxFacts = Object.freeze({
34
+ ...explicit,
35
+ requestId: ctx.requestId,
36
+ traceId: ctx.traceId,
37
+ actor: ctx.actor,
38
+ locale: ctx.locale,
39
+ tz: ctx.tz,
40
+ buildId: ctx.buildId,
41
+ role: ctx.role,
42
+ clock: ctx.clock,
43
+ now: ctx.now,
44
+ logger: ctx.logger,
45
+ signal: ctx.signal,
46
+ deadlineAt: ctx.deadlineAt,
47
+ services: explicit,
48
+ });
49
+ const next = Object.freeze({ ...installedServices(preview), ...explicit });
50
+ built = { actor: ctx.actor, locale: ctx.locale, tz: ctx.tz, bag: next };
51
+ return next;
52
+ };
53
+ Object.defineProperty(ctx, 'services', { get: bag, enumerable: false, configurable: true });
54
+ for (const name of registeredServiceNames()) {
55
+ // Already an own property: a framework field (which keeps its meaning whatever an app named a
56
+ // service — core's rule) or an explicit `init.services` entry, which core's constructor spread
57
+ // onto the context and which overrides the registered factory of the same name.
58
+ if (Object.hasOwn(ctx, name)) continue;
59
+ Object.defineProperty(ctx, name, {
60
+ get: () => bag()[name],
61
+ enumerable: false,
62
+ configurable: true,
63
+ });
64
+ }
65
+ }
package/src/request.ts CHANGED
@@ -167,7 +167,20 @@ export class UltimateRequest {
167
167
  throw bodyInvalid(this.pathname, [`body is ${declared} bytes, limit is ${limit}`]);
168
168
  }
169
169
  const type = contentTypeOf(this.raw);
170
- if (type === '' || declared === 0) return undefined;
170
+ // An EMPTY form is a form with no fields, never "no input": a button-only `<form>` posts
171
+ // `content-length: 0`, and reading that as `undefined` failed every schema with 400.
172
+ const form = type === 'application/x-www-form-urlencoded' || type === 'multipart/form-data';
173
+ if (type === '') return undefined;
174
+ // A multipart body is only a FORM with the boundary its header must announce. Checked before
175
+ // the empty-form shortcut below, so a zero-length body cannot launder a malformed header into
176
+ // `{}` — without it the parser would have refused, and an empty body must refuse the same.
177
+ if (
178
+ type === 'multipart/form-data' &&
179
+ !/;\s*boundary=[^;\s]/i.test(this.header('content-type') ?? '')
180
+ ) {
181
+ throw bodyInvalid(this.pathname, ['multipart/form-data without a boundary parameter']);
182
+ }
183
+ if (declared === 0) return form ? {} : undefined;
171
184
 
172
185
  // One capped read for every content type, multipart included: the parser runs on bytes this
173
186
  // process already agreed to hold, never on a stream it hands to the runtime unbounded.
@@ -175,7 +188,7 @@ export class UltimateRequest {
175
188
  if ('over' in read) {
176
189
  throw bodyInvalid(this.pathname, [`body is at least ${read.over} bytes, limit is ${limit}`]);
177
190
  }
178
- if (read.bytes.byteLength === 0) return undefined;
191
+ if (read.bytes.byteLength === 0) return form ? {} : undefined;
179
192
 
180
193
  if (type === 'multipart/form-data') {
181
194
  try {
package/src/response.ts CHANGED
@@ -110,7 +110,7 @@ export interface CacheHint {
110
110
  /** Shared/CDN age. `isr` routes set this and rely on tag purges to revalidate. */
111
111
  readonly sMaxAgeSeconds?: number;
112
112
  readonly staleWhileRevalidateSeconds?: number;
113
- /** Cache tags a purge can target; mirrored into `x-cache-tags`. */
113
+ /** Cache tags a purge can target, in wire form; emitted as `Surrogate-Key` and `Cache-Tag`. */
114
114
  readonly tags?: readonly string[];
115
115
  readonly vary?: readonly string[];
116
116
  }
@@ -207,8 +207,14 @@ export const SHARED_CACHE_VARY: readonly string[] = ['accept-language', 'cookie'
207
207
  /** Mutates the response headers in place — responses are per-request, never shared. */
208
208
  export const applyCacheHeaders = (response: Response, hint: CacheHint): Response => {
209
209
  response.headers.set('cache-control', cacheControl(hint));
210
- if (hint.tags !== undefined && hint.tags.length > 0) {
211
- response.headers.set('x-cache-tags', hint.tags.join(','));
210
+ // The two headers a CDN reads, and only on a response a CDN may hold: Fastly's `Surrogate-Key`
211
+ // (space-separated) and Cloudflare's `Cache-Tag` (comma-separated), the same wire forms
212
+ // `@ultimat3/cache`'s purge sends. This wrote `x-cache-tags`, which neither reads — so every
213
+ // purge by tag "succeeded" and cleared nothing.
214
+ const shared = hint.mode === 'public' || hint.mode === 'immutable';
215
+ if (shared && hint.tags !== undefined && hint.tags.length > 0) {
216
+ response.headers.set('surrogate-key', hint.tags.join(' '));
217
+ response.headers.set('cache-tag', hint.tags.join(','));
212
218
  }
213
219
  // `cookie` is not optional on the shared path. A `public` response is stored by a CDN under the
214
220
  // URL, and every session in this framework travels in a cookie — so without it the first
@@ -140,3 +140,32 @@ export const securityHeaders = (
140
140
  }
141
141
  return headers;
142
142
  };
143
+
144
+ /** One frozen record per config object and scheme. Weak, so a discarded config is not retained. */
145
+ const memo = new WeakMap<
146
+ SecurityConfig,
147
+ { https?: Readonly<Record<string, string>>; plain?: Readonly<Record<string, string>> }
148
+ >();
149
+
150
+ /**
151
+ * What the pipeline stamps on every response: `securityHeaders`, built ONCE per `(config, https)`.
152
+ * Rebuilding it per request re-joined the CSP and re-hashed `OVERLAY_STYLE` — 20.9 µs, 18.8% of a
153
+ * trivial GET — for an answer that cannot change while the server runs. Frozen, because every
154
+ * request shares it; `securityHeaders` stays the builder that hands a caller its own copy.
155
+ */
156
+ export const responseSecurityHeaders = (
157
+ config: SecurityConfig,
158
+ https: boolean,
159
+ ): Readonly<Record<string, string>> => {
160
+ let entry = memo.get(config);
161
+ if (entry === undefined) {
162
+ entry = {};
163
+ memo.set(config, entry);
164
+ }
165
+ const slot = https ? 'https' : 'plain';
166
+ const cached = entry[slot];
167
+ if (cached !== undefined) return cached;
168
+ const built = Object.freeze(securityHeaders(config, { https }));
169
+ entry[slot] = built;
170
+ return built;
171
+ };
package/src/server.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // context, tracing and authz must be impossible to skip — a route is a data
3
3
  // declaration, never a chance to hand-roll a request handler.
4
4
 
5
- import type { HealthPayload, HealthState, Role } from '@ultimat3/core';
5
+ import type { DrainConfig, HealthPayload, HealthState, Role } from '@ultimat3/core';
6
6
  import {
7
7
  beginWork,
8
8
  configureLifecycle,
@@ -82,6 +82,12 @@ export interface ServerOptions {
82
82
  * client picks whichever origin it can actually reach.
83
83
  */
84
84
  readonly websocket?: WebSocketMount;
85
+ /**
86
+ * `app.config.ts`'s `drain` section — pass `appConfig.drain`. Its `readinessGraceMs` is how long
87
+ * `/readyz` answers 503 before the `accept` phase closes this listener. Omitted, core's own
88
+ * default holds (0 in development/test, 5000 elsewhere) and an app's `configureLifecycle` stands.
89
+ */
90
+ readonly drain?: Partial<DrainConfig>;
85
91
  }
86
92
 
87
93
  /**
@@ -172,6 +178,10 @@ export const createServer = (options: ServerOptions): ServerHandle => {
172
178
  // own `fix:` prints — back to 15s on every boot that serves web, silently. "Nobody said" and
173
179
  // "the app said 15 seconds" are different claims and `null` is what keeps them apart.
174
180
  if (config.drainTimeoutMs !== null) configureLifecycle({ deadlineMs: config.drainTimeoutMs });
181
+ // Same rule: only a declared grace is applied. Core waits it out between the readiness flip and
182
+ // the `accept` hook below, so endpoints stop routing here before the socket closes.
183
+ const readinessGraceMs = options.drain?.readinessGraceMs;
184
+ if (readinessGraceMs !== undefined) configureLifecycle({ readinessGraceMs });
175
185
 
176
186
  const mount = options.websocket;
177
187
  let server: BunServer | undefined;
@@ -304,8 +314,9 @@ export const createServer = (options: ServerOptions): ServerHandle => {
304
314
  // so the test seal can let it through without an allowlist entry per random port.
305
315
  stopListening = markListening(server.url.origin);
306
316
 
307
- // 'accept' runs first on SIGTERM: readyz flips to 503 here, while the socket is
308
- // still open, so the load balancer stops sending new work before we close it.
317
+ // 'accept' runs first on SIGTERM, but only after core's readiness grace: readyz flips to
318
+ // 503 the moment the drain starts, the socket stays open for `readinessGraceMs`, so the
319
+ // endpoints controller stops routing here before this closes it — not a 502 in between.
309
320
  unregister = onShutdown(
310
321
  `http:${role}`,
311
322
  async () => {
package/src/stages.ts CHANGED
@@ -9,21 +9,22 @@ import {
9
9
  inflightCount,
10
10
  isAnonymous,
11
11
  isDraining,
12
+ lifecycleState,
12
13
  reportError,
13
14
  } from '@ultimat3/core';
14
15
  import { resolveLocale } from '@ultimat3/i18n';
15
16
  import { resolveTimeZone } from '@ultimat3/time';
16
17
  import { signInRedirect } from './auth-redirect';
17
- import { defaultCache, offersSharedCache, PRIVATE_CACHE } from './cache-policy';
18
+ import { defaultCache, offersSharedCache, PRIVATE_CACHE, reviewedHint } from './cache-policy';
18
19
  import { type HttpConfig, stripBasePath } from './config';
19
20
  import { actorView, elapsedMs, type RequestContext } from './context';
20
21
  import { corsHeaders, preflight } from './cors';
21
- import { checkCsrf, selfOrigin } from './csrf';
22
+ import { checkCsrf, csrfBlocked, selfOrigin } from './csrf';
22
23
  import { factsOf, retryAfterOf } from './error-facts';
24
+ import { errorLogLevel } from './error-log-level';
23
25
  import { errorPageResponse } from './error-page';
24
26
  import {
25
27
  bodyInvalid,
26
- csrfBlocked,
27
28
  draining,
28
29
  forbidden,
29
30
  methodNotAllowed,
@@ -42,7 +43,7 @@ import { rateLimited } from './rate-limit-errors';
42
43
  import type { UltimateRequest } from './request';
43
44
  import { addVary, applyCacheHeaders, problem, redirect, SHARED_CACHE_VARY } from './response';
44
45
  import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
45
- import { securityHeaders } from './security-headers';
46
+ import { responseSecurityHeaders } from './security-headers';
46
47
  import { validate } from './validate';
47
48
 
48
49
  export type StageName =
@@ -134,10 +135,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
134
135
  // Before the trace, the route match, auth, the body — everything. A refusal that costs as
135
136
  // much as a served request is not load shedding, and this is the stage that makes
136
137
  // "reject 40% fast, serve 60% at p99" expressible at all.
137
- if (isDraining()) {
138
+ // DRAINING is served, never refused: a request reaching the pipeline mid-drain came in on
139
+ // a kept-alive connection or before the listener closed, and the readiness grace exists so
140
+ // it is answered. `connection: close` moves the client's NEXT request to another pod.
141
+ // Refusing it failed 598 of 7,690 requests in one helm upgrade (kind). Only STOPPED refuses.
142
+ if (lifecycleState() === 'stopped') {
138
143
  ctx.headers.set('retry-after', SHED_RETRY_AFTER_SECONDS);
139
144
  throw draining();
140
145
  }
146
+ if (isDraining()) ctx.headers.set('connection', 'close');
141
147
  const ceiling = config.maxInflight;
142
148
  // `beginWork()` in `server.ts` counted THIS request before the pipeline was entered, so the
143
149
  // ceiling is compared against a number that already includes it.
@@ -188,16 +194,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
188
194
  * get one answer in the framework rather than one per package.
189
195
  */
190
196
  locale: (request, ctx) => {
191
- const cookies = request.header('cookie');
192
- ctx.locale = resolveLocale({
193
- header: request.header('accept-language'),
194
- cookie: readCookie(cookies, config.locale.cookie),
195
- }).locale;
196
- ctx.tz = resolveTimeZone({
197
- cookie: readCookie(cookies, config.tz.cookie),
198
- header: request.header(config.tz.header),
199
- }).zone;
200
- ctx.headers.set('content-language', ctx.locale);
197
+ resolvePreferences(request, ctx, config);
201
198
  return undefined;
202
199
  },
203
200
 
@@ -206,6 +203,11 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
206
203
  // The hook says "anonymous" with null; the context says it with core's anonymous actor,
207
204
  // because `asCtx` publishes this object as a `Ctx` and `Ctx.actor` is never null.
208
205
  ctx.actor = (await hooks.authenticate(request, ctx)) ?? anonymousActor();
206
+ // The `user` rung the `locale` stage could not know: re-resolved through the same owners,
207
+ // so a cookie the reader chose still beats a saved locale where i18n's order says it does.
208
+ if (ctx.actor.locale !== undefined || ctx.actor.tz !== undefined) {
209
+ resolvePreferences(request, ctx, config);
210
+ }
209
211
  }
210
212
  if (ctx.route?.meta.auth === 'required' && isAnonymous(ctx.actor)) {
211
213
  throw unauthenticated(ctx.url.pathname);
@@ -272,7 +274,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
272
274
  cors: config.cors,
273
275
  config: config.csrf,
274
276
  });
275
- if (!verdict.ok) throw csrfBlocked(ctx.url.pathname, verdict.reason);
277
+ if (!verdict.ok) throw csrfBlocked(ctx.url.pathname, verdict.reason, ctx.ip);
276
278
  return undefined;
277
279
  },
278
280
 
@@ -319,10 +321,8 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
319
321
  if (response === undefined) return undefined;
320
322
  const declared = response.headers.get('cache-control');
321
323
  if (declared === null) {
322
- applyCacheHeaders(
323
- response,
324
- ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor),
325
- );
324
+ const hint = ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor);
325
+ applyCacheHeaders(response, reviewedHint(hint, ctx.actor));
326
326
  return undefined;
327
327
  }
328
328
  // A declaration is the MODE's intent, never the last word: `@ultimat3/render`'s `ssrHeaders`
@@ -368,7 +368,10 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
368
368
  // The other half of this is `@ultimat3/schema`'s, and it is the load-bearing one: an issue
369
369
  // message must stop echoing the rejected value at all. This change makes the value
370
370
  // redactable; it does not make it absent.
371
- ctx.logger.error(facts.code, { cause: facts.cause, status: facts.status });
371
+ ctx.logger[errorLogLevel(facts.status)](facts.code, {
372
+ cause: facts.cause,
373
+ status: facts.status,
374
+ });
372
375
  // Before the overlay and before the problem document: a browser with no session has not
373
376
  // hit a defect to debug, it has hit a login wall, and the answer to that is the sign-in
374
377
  // page. `signInPath` is null until an app declares one, so this is off by default.
@@ -455,7 +458,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
455
458
  else response.headers.set(name, value);
456
459
  }
457
460
  for (const [name, value] of Object.entries(
458
- securityHeaders(config.security, { https: ctx.https }),
461
+ responseSecurityHeaders(config.security, ctx.https),
459
462
  )) {
460
463
  response.headers.set(name, value);
461
464
  }
@@ -465,3 +468,31 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
465
468
  };
466
469
  return table;
467
470
  };
471
+
472
+ /**
473
+ * `ctx.locale`, `ctx.tz` and `content-language` from every source the request carries — the
474
+ * cookie, the header and, once `auth` has run, the actor's saved preference. One function for both
475
+ * stages, so the order is always the owners' (`resolveLocale`, `resolveTimeZone`) and never this
476
+ * file's.
477
+ */
478
+ function resolvePreferences(
479
+ request: UltimateRequest,
480
+ ctx: RequestContext,
481
+ config: HttpConfig,
482
+ ): void {
483
+ const cookies = request.header('cookie');
484
+ ctx.locale = resolveLocale({
485
+ // Documented as a source and never read until 2026-09: an email preview link's `?locale=es`
486
+ // rendered in the visitor's cookie locale. The ORDER stays `resolveLocale`'s.
487
+ query: ctx.url.searchParams.get('locale'),
488
+ header: request.header('accept-language'),
489
+ cookie: readCookie(cookies, config.locale.cookie),
490
+ user: ctx.actor.locale,
491
+ }).locale;
492
+ ctx.tz = resolveTimeZone({
493
+ cookie: readCookie(cookies, config.tz.cookie),
494
+ header: request.header(config.tz.header),
495
+ user: ctx.actor.tz ?? null,
496
+ }).zone;
497
+ ctx.headers.set('content-language', ctx.locale);
498
+ }