@ultimat3/http 21.0.0 → 22.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +178 -669
- package/README.md +20 -3
- package/package.json +5 -5
- package/src/cache-policy.ts +9 -0
- package/src/csrf.ts +38 -21
- package/src/error-facts.ts +11 -5
- package/src/error-log-level.ts +12 -0
- package/src/error-map.ts +21 -79
- package/src/error-page.ts +9 -1
- package/src/error-status.ts +82 -0
- package/src/errors.ts +0 -11
- package/src/index.ts +14 -26
- package/src/peer-identity.ts +19 -15
- package/src/problem-meta.ts +41 -4
- package/src/response.ts +9 -3
- package/src/security-headers.ts +29 -0
- package/src/server.ts +14 -3
- package/src/stages.ts +22 -12
package/src/peer-identity.ts
CHANGED
|
@@ -30,27 +30,25 @@ export interface PeerIdentity {
|
|
|
30
30
|
readonly by: string | null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
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
|
-
|
|
44
|
-
}
|
|
45
|
-
if (char === '\\') {
|
|
47
|
+
} else if (char === '\\') {
|
|
46
48
|
escaped = true;
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
if (char === '"') {
|
|
49
|
+
} else if (char === '"') {
|
|
50
50
|
quoted = !quoted;
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
93
|
+
splitRaw,
|
|
90
94
|
);
|
|
91
95
|
if (element === undefined) return null;
|
|
92
96
|
const pairs = pairsOf(element);
|
package/src/problem-meta.ts
CHANGED
|
@@ -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
|
|
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,
|
|
89
|
+
declarations: Readonly<Record<string, ProblemMetaDeclaration>>,
|
|
70
90
|
): void => {
|
|
71
|
-
for (const [code,
|
|
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 =>
|
|
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 =>
|
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;
|
|
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
|
-
|
|
211
|
-
|
|
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
|
package/src/security-headers.ts
CHANGED
|
@@ -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
|
|
308
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
@@ -268,7 +274,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
268
274
|
cors: config.cors,
|
|
269
275
|
config: config.csrf,
|
|
270
276
|
});
|
|
271
|
-
if (!verdict.ok) throw csrfBlocked(ctx.url.pathname, verdict.reason);
|
|
277
|
+
if (!verdict.ok) throw csrfBlocked(ctx.url.pathname, verdict.reason, ctx.ip);
|
|
272
278
|
return undefined;
|
|
273
279
|
},
|
|
274
280
|
|
|
@@ -315,10 +321,8 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
315
321
|
if (response === undefined) return undefined;
|
|
316
322
|
const declared = response.headers.get('cache-control');
|
|
317
323
|
if (declared === null) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor),
|
|
321
|
-
);
|
|
324
|
+
const hint = ctx.cache ?? ctx.route?.meta.cache ?? defaultCache(ctx.route, ctx.actor);
|
|
325
|
+
applyCacheHeaders(response, reviewedHint(hint, ctx.actor));
|
|
322
326
|
return undefined;
|
|
323
327
|
}
|
|
324
328
|
// A declaration is the MODE's intent, never the last word: `@ultimat3/render`'s `ssrHeaders`
|
|
@@ -364,7 +368,10 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
364
368
|
// The other half of this is `@ultimat3/schema`'s, and it is the load-bearing one: an issue
|
|
365
369
|
// message must stop echoing the rejected value at all. This change makes the value
|
|
366
370
|
// redactable; it does not make it absent.
|
|
367
|
-
ctx.logger.
|
|
371
|
+
ctx.logger[errorLogLevel(facts.status)](facts.code, {
|
|
372
|
+
cause: facts.cause,
|
|
373
|
+
status: facts.status,
|
|
374
|
+
});
|
|
368
375
|
// Before the overlay and before the problem document: a browser with no session has not
|
|
369
376
|
// hit a defect to debug, it has hit a login wall, and the answer to that is the sign-in
|
|
370
377
|
// page. `signInPath` is null until an app declares one, so this is off by default.
|
|
@@ -451,7 +458,7 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
|
|
|
451
458
|
else response.headers.set(name, value);
|
|
452
459
|
}
|
|
453
460
|
for (const [name, value] of Object.entries(
|
|
454
|
-
|
|
461
|
+
responseSecurityHeaders(config.security, ctx.https),
|
|
455
462
|
)) {
|
|
456
463
|
response.headers.set(name, value);
|
|
457
464
|
}
|
|
@@ -475,6 +482,9 @@ function resolvePreferences(
|
|
|
475
482
|
): void {
|
|
476
483
|
const cookies = request.header('cookie');
|
|
477
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'),
|
|
478
488
|
header: request.header('accept-language'),
|
|
479
489
|
cookie: readCookie(cookies, config.locale.cookie),
|
|
480
490
|
user: ctx.actor.locale,
|