@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.
- package/CLAUDE.md +178 -654
- package/README.md +20 -3
- package/package.json +5 -5
- package/src/cache-policy.ts +9 -0
- package/src/context.ts +7 -1
- package/src/csrf.ts +28 -0
- package/src/error-facts.ts +11 -5
- package/src/error-log-level.ts +12 -0
- package/src/error-map.ts +29 -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/request-services.ts +65 -0
- package/src/request.ts +15 -2
- package/src/response.ts +9 -3
- package/src/security-headers.ts +29 -0
- package/src/server.ts +14 -3
- package/src/stages.ts +53 -22
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 =>
|
|
@@ -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
|
-
|
|
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;
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|