@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.
@@ -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 =>
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.
@@ -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
- applyCacheHeaders(
319
- response,
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.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
+ });
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
- securityHeaders(config.security, { https: ctx.https }),
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,