@ultimat3/http 1.2.0 → 3.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/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // The public surface of @ultimat3/http. Explicit, never `export *`: what is not
2
2
  // listed here is an implementation detail and may change without a major bump.
3
3
 
4
+ export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
4
5
  export type { HttpConfig, HttpConfigInput } from './config';
5
6
  export { defineHttpConfig, stripBasePath } from './config';
6
7
  export type { ActorView, RequestContext, RequestContextInit } from './context';
@@ -10,15 +11,26 @@ export {
10
11
  createRequestContext,
11
12
  elapsedMs,
12
13
  useRequestContext,
14
+ useRequestCookie,
15
+ useRequestHeader,
16
+ useRequestHeaders,
13
17
  } from './context';
18
+ export type { InboundCorrelation } from './correlation';
19
+ export { readCorrelation } from './correlation';
14
20
  export type { CorsConfig } from './cors';
15
- export { corsHeaders, DEFAULT_CORS, preflight } from './cors';
21
+ export { allowedOrigin, corsHeaders, DEFAULT_CORS, preflight } from './cors';
22
+ export type { CsrfCheckInput, CsrfConfig, CsrfMode, CsrfVerdict } from './csrf';
23
+ export { checkCsrf, DEFAULT_CSRF, selfOrigin } from './csrf';
24
+ export type { Deadline } from './deadline';
25
+ export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
16
26
  export type { ErrorFacts, ProblemDocument } from './error-map';
17
27
  export {
18
28
  DEFAULT_STATUS,
19
29
  ERROR_STATUS,
20
30
  factsOf,
31
+ registerErrorStatus,
21
32
  renderErrorLines,
33
+ resetErrorStatus,
22
34
  statusFor,
23
35
  toProblem,
24
36
  } from './error-map';
@@ -26,60 +38,81 @@ export type { HttpErrorCode } from './errors';
26
38
  export {
27
39
  bodyInvalid,
28
40
  buildSkew,
41
+ csrfBlocked,
42
+ draining,
43
+ errorStatusInvalid,
44
+ finalizeFailed,
29
45
  forbidden,
30
46
  HTTP_ERROR_CODES,
31
47
  HTTP_ERROR_TITLES,
32
48
  HttpError,
33
49
  methodNotAllowed,
50
+ noRequest,
51
+ overloaded,
52
+ pathInvalid,
34
53
  pipelineNoResponse,
54
+ rateLimitBucketConflict,
55
+ rateLimitBucketUnbound,
35
56
  rateLimited,
57
+ rateLimitInvalid,
58
+ rateLimitNotShared,
59
+ rateLimitScopeUnset,
60
+ requestTimedOut,
36
61
  routeConflict,
37
62
  routeNotFound,
38
63
  serverNotStarted,
64
+ trustProxyUnset,
39
65
  unauthenticated,
40
66
  } from './errors';
41
- export type { AuthzDecision, ServerHooks } from './hooks';
42
- export type { LocaleConfig, TimeZoneConfig } from './locale';
67
+ export type { ForwardedInput, ForwardedSplit } from './forwarded';
43
68
  export {
44
- DEFAULT_LOCALE_CONFIG,
45
- DEFAULT_TZ_CONFIG,
46
- isValidTimeZone,
47
- negotiateLocale,
48
- readCookie,
49
- resolveTimeZone,
50
- } from './locale';
69
+ clientAddress,
70
+ clientUsedHttps,
71
+ FORWARDED_CLIENT_CERT,
72
+ FORWARDED_FOR,
73
+ FORWARDED_PROTO,
74
+ forwardedElement,
75
+ forwardedValue,
76
+ } from './forwarded';
77
+ export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
78
+ export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
79
+ export type { LocaleConfig, TimeZoneConfig } from './locale';
80
+ export { DEFAULT_LOCALE_CONFIG, DEFAULT_TZ_CONFIG, readCookie } from './locale';
51
81
  export type { Middleware } from './middleware';
52
82
  export { compose } from './middleware';
53
- export type { OverlayMeta } from './overlay';
83
+ export type { OverlayMeta, OverlayNotice } from './overlay';
54
84
  export { overlayResponse, renderOverlay, wantsOverlay } from './overlay';
55
- export type {
56
- HandleInit,
57
- Pipeline,
58
- PipelineDeps,
59
- Stage,
60
- StageDoc,
61
- StageName,
62
- StagePhase,
63
- StageRun,
64
- } from './pipeline';
85
+ export { OVERLAY_STYLE } from './overlay-style';
86
+ export type { PeerIdentity } from './peer-identity';
87
+ export { peerIdentity } from './peer-identity';
88
+ export type { HandleInit, Pipeline, PipelineDeps } from './pipeline';
65
89
  export { createPipeline, PIPELINE_STAGES } from './pipeline';
66
90
  export type {
67
91
  Bucket,
92
+ MemoryRateLimitStore,
68
93
  RateLimitConfig,
69
94
  RateLimitDecision,
95
+ RateLimitDeclaration,
70
96
  RateLimiter,
71
97
  RateLimitKeyParts,
98
+ RateLimitScope,
72
99
  RateLimitStore,
73
100
  } from './rate-limit';
74
101
  export {
102
+ assertRateLimitScope,
75
103
  createRateLimiter,
104
+ DEFAULT_MAX_RATE_LIMIT_KEYS,
76
105
  DEFAULT_RATE_LIMIT,
77
106
  memoryRateLimitStore,
78
107
  rateLimitKey,
108
+ resolveRateLimitConfig,
109
+ toBucket,
79
110
  } from './rate-limit';
111
+ export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
112
+ export { setRedirect, takeRedirect } from './redirect';
80
113
  export type { QueryValues } from './request';
81
114
  export { UltimateRequest } from './request';
82
- export type { CacheHint } from './response';
115
+ export type { CacheHint, RedirectIntent, RedirectStatus } from './response';
83
116
  export {
84
117
  applyCacheHeaders,
85
118
  cacheControl,
@@ -112,8 +145,11 @@ export {
112
145
  normalizePath,
113
146
  } from './router';
114
147
  export type { SecurityConfig } from './security-headers';
115
- export { buildCsp, DEFAULT_SECURITY, securityHeaders } from './security-headers';
148
+ export { buildCsp, cspHashSource, DEFAULT_SECURITY, securityHeaders } from './security-headers';
116
149
  export type { LifecycleState, ServerHandle, ServerOptions } from './server';
117
150
  export { createServer } from './server';
151
+ // The stage vocabulary comes from its declaration site, beside the fourteen implementations it
152
+ // names; `PIPELINE_STAGES` — the ORDER — stays `pipeline.ts`'s.
153
+ export type { Stage, StageDoc, StageName, StagePhase, StageRun } from './stages';
118
154
  export type { InferOutput, Schema, ValidationOutcome } from './validate';
119
155
  export { formatIssue, validate, validateSync } from './validate';
package/src/locale.ts CHANGED
@@ -1,31 +1,52 @@
1
- // Locale and time zone are resolved once per request, before any handler runs, so
2
- // no code path can format a date or a number without them. `@ultimat3/i18n` owns
3
- // catalogs; this file only owns the negotiation of the two request-scoped values.
1
+ // WHERE the request's locale and zone are read from the header and cookie NAMES, and nothing
2
+ // else. What a locale or a zone IS belongs to `@ultimat3/i18n` and `@ultimat3/time` (tier 1, a
3
+ // legal downward import), which is why this file negotiates nothing: three re-implementations
4
+ // lived here and all three disagreed with their owner about the same request.
5
+
6
+ import { LOCALE_COOKIE } from '@ultimat3/i18n';
7
+ import { TIMEZONE_HEADER } from '@ultimat3/time';
4
8
 
5
9
  export interface LocaleConfig {
6
- readonly supported: readonly string[];
7
- readonly default: string;
8
10
  /** Cookie the client sets when the user picks a locale explicitly. */
9
11
  readonly cookie: string;
10
12
  }
11
13
 
12
14
  export interface TimeZoneConfig {
13
- readonly default: string;
14
15
  /** Header the client sets from `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
15
16
  readonly header: string;
16
17
  readonly cookie: string;
17
18
  }
18
19
 
20
+ /**
21
+ * The supported set and the fallback are NOT here: `defineCatalogs()` already declares them, and
22
+ * a second copy in this config meant an app shipping `{ en, fr }` still resolved `ctx.locale` to
23
+ * `'en'` forever. The cookie name is `@ultimat3/i18n`'s own constant for the same reason — a
24
+ * language switcher written against the documented `LOCALE_COOKIE` was read by nothing.
25
+ */
19
26
  export const DEFAULT_LOCALE_CONFIG: LocaleConfig = {
20
- supported: ['en'],
21
- default: 'en',
22
- cookie: 'x-locale',
27
+ cookie: LOCALE_COOKIE,
23
28
  };
24
29
 
30
+ /** The default zone is `configureTime({ defaultZone })`'s, so a process has one answer, not two. */
25
31
  export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
26
- default: 'UTC',
27
- header: 'x-timezone',
28
- cookie: 'x-timezone',
32
+ header: TIMEZONE_HEADER,
33
+ cookie: 'x_timezone',
34
+ };
35
+
36
+ /**
37
+ * The raw value on a malformed escape, never a throw. `Cookie:` is attacker-controlled and
38
+ * `decodeURIComponent('%')` is a bare `URIError` — thrown from the `locale` stage, which runs on
39
+ * every request, so `curl -H 'Cookie: x-locale=%'` answered 500 and paged the on-call. Nothing is
40
+ * loosened: a locale that is not in `supported` still falls back, and a time zone `Intl` cannot
41
+ * format is still refused. `@ultimat3/auth` guards its own cookie reader the same way and for the
42
+ * same reason; the guard is four lines and this package can never import that one (same tier).
43
+ */
44
+ const decodeCookieValue = (raw: string): string => {
45
+ try {
46
+ return decodeURIComponent(raw);
47
+ } catch {
48
+ return raw;
49
+ }
29
50
  };
30
51
 
31
52
  export const readCookie = (header: string | null, name: string): string | null => {
@@ -34,76 +55,7 @@ export const readCookie = (header: string | null, name: string): string | null =
34
55
  const index = part.indexOf('=');
35
56
  if (index === -1) continue;
36
57
  if (part.slice(0, index).trim() !== name) continue;
37
- return decodeURIComponent(part.slice(index + 1).trim());
58
+ return decodeCookieValue(part.slice(index + 1).trim());
38
59
  }
39
60
  return null;
40
61
  };
41
-
42
- interface Weighted {
43
- readonly tag: string;
44
- readonly q: number;
45
- }
46
-
47
- const parseAcceptLanguage = (header: string): readonly Weighted[] =>
48
- header
49
- .split(',')
50
- .map((entry): Weighted => {
51
- const [tag = '', ...params] = entry.trim().split(';');
52
- const qParam = params.find((p) => p.trim().startsWith('q='));
53
- const q = qParam === undefined ? 1 : Number.parseFloat(qParam.trim().slice(2));
54
- return { tag: tag.trim().toLowerCase(), q: Number.isFinite(q) ? q : 0 };
55
- })
56
- .filter((entry) => entry.tag.length > 0 && entry.q > 0)
57
- .sort((a, b) => b.q - a.q);
58
-
59
- /**
60
- * Exact match wins, then primary-subtag match (`de-CH` -> `de`), then the config
61
- * default. `*` is treated as "no preference" rather than "any locale" so the
62
- * default stays predictable for SEO and cache keys.
63
- */
64
- export const negotiateLocale = (
65
- acceptLanguage: string | null,
66
- config: LocaleConfig = DEFAULT_LOCALE_CONFIG,
67
- explicit?: string | null,
68
- ): string => {
69
- const supported = config.supported.map((locale) => locale.toLowerCase());
70
- const pick = (candidate: string): string | undefined => {
71
- const wanted = candidate.toLowerCase();
72
- const exact = supported.indexOf(wanted);
73
- if (exact !== -1) return config.supported[exact];
74
- const primary = wanted.split('-')[0] ?? wanted;
75
- const loose = supported.findIndex((locale) => (locale.split('-')[0] ?? locale) === primary);
76
- return loose === -1 ? undefined : config.supported[loose];
77
- };
78
-
79
- if (explicit !== undefined && explicit !== null) {
80
- const chosen = pick(explicit);
81
- if (chosen !== undefined) return chosen;
82
- }
83
- if (acceptLanguage !== null) {
84
- for (const entry of parseAcceptLanguage(acceptLanguage)) {
85
- if (entry.tag === '*') break;
86
- const chosen = pick(entry.tag);
87
- if (chosen !== undefined) return chosen;
88
- }
89
- }
90
- return config.default;
91
- };
92
-
93
- /** A time zone is only accepted if `Intl` can actually format with it. */
94
- export const isValidTimeZone = (candidate: string): boolean => {
95
- try {
96
- new Intl.DateTimeFormat('en', { timeZone: candidate }).format(0);
97
- return true;
98
- } catch {
99
- return false;
100
- }
101
- };
102
-
103
- export const resolveTimeZone = (
104
- candidate: string | null,
105
- config: TimeZoneConfig = DEFAULT_TZ_CONFIG,
106
- ): string => {
107
- if (candidate !== null && candidate.length > 0 && isValidTimeZone(candidate)) return candidate;
108
- return config.default;
109
- };
@@ -0,0 +1,43 @@
1
+ // The dev overlay's stylesheet, split from the document that carries it so `security-headers.ts`
2
+ // can hash it into the CSP without importing the renderer — and so the hash is computed from the
3
+ // one copy of the text, never from a constant that drifted away from what the `<style>` holds.
4
+
5
+ // Token definitions live here and nowhere else; every rule below uses var().
6
+ export const OVERLAY_STYLE = `
7
+ :root {
8
+ --x-bg: #fbfbfd; --x-surface: #ffffff; --x-border: #e3e3ea;
9
+ --x-text: #1b1b1f; --x-muted: #6b6b76; --x-danger: #b3261e; --x-accent: #2f4fd8;
10
+ --x-code-bg: #f3f3f7;
11
+ }
12
+ @media (prefers-color-scheme: dark) {
13
+ :root {
14
+ --x-bg: #111115; --x-surface: #1a1a20; --x-border: #2e2e38;
15
+ --x-text: #ececf1; --x-muted: #9b9baa; --x-danger: #ff8a80; --x-accent: #9db2ff;
16
+ --x-code-bg: #22222b;
17
+ }
18
+ }
19
+ * { box-sizing: border-box; }
20
+ body {
21
+ margin: 0; padding: 2rem; background: var(--x-bg); color: var(--x-text);
22
+ font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
23
+ }
24
+ main { max-width: 60rem; margin: 0 auto; }
25
+ .card {
26
+ background: var(--x-surface); border: 1px solid var(--x-border);
27
+ border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
28
+ }
29
+ h1 { font-size: 1.1rem; margin: 0 0 .25rem; color: var(--x-danger); }
30
+ h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--x-muted);
31
+ text-transform: uppercase; letter-spacing: .08em; }
32
+ dl { display: grid; grid-template-columns: 5rem 1fr; gap: .35rem 1rem; margin: 0; }
33
+ dt { color: var(--x-muted); }
34
+ dd { margin: 0; overflow-wrap: anywhere; }
35
+ pre { background: var(--x-code-bg); border-radius: 6px; padding: .75rem;
36
+ margin: 0; overflow-x: auto; }
37
+ a { color: var(--x-accent); }
38
+ .title { color: var(--x-muted); font-weight: normal; }
39
+ /* A notice's term is an X_ code, not a five-character label: at the shared 5rem the codes this
40
+ card exists to show would sit on top of their own causes. */
41
+ .notices dl { grid-template-columns: 14rem 1fr; }
42
+ .notices dt { overflow-wrap: anywhere; }
43
+ `;
package/src/overlay.ts CHANGED
@@ -1,59 +1,77 @@
1
1
  // The dev error overlay. It renders the SAME facts object the terminal prints and
2
2
  // `--json` emits, so a code/cause/fix string can never differ between the three
3
- // surfaces. Labels here ("cause", "fix") are protocol strings from the error
4
- // contract, not UI copy, so they are not routed through the i18n catalog.
3
+ // surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
4
+ // error contract, not UI copy, so they are not routed through the i18n catalog.
5
5
  import { factsOf, renderErrorLines, toProblem } from './error-map';
6
+ import { OVERLAY_STYLE } from './overlay-style';
6
7
  import { html } from './response';
7
8
 
9
+ // `'` is escaped even though every attribute below is double-quoted: the escape set is what the
10
+ // next author reads as the guarantee, and a single-quoted attribute written later would inherit a
11
+ // hole nothing here would have flagged.
8
12
  const escapeHtml = (value: string): string =>
9
13
  value
10
14
  .replaceAll('&', '&amp;')
11
15
  .replaceAll('<', '&lt;')
12
16
  .replaceAll('>', '&gt;')
13
- .replaceAll('"', '&quot;');
17
+ .replaceAll('"', '&quot;')
18
+ .replaceAll("'", '&#39;');
19
+
20
+ /**
21
+ * An `href` is a SCHEME decision, not an escaping one: `javascript:alert(1)` survives every entity
22
+ * replacement intact and becomes a link the agent debugging this page clicks. A `docs` value comes
23
+ * from whichever package raised the error, so anything that is not plainly http(s) renders as text.
24
+ */
25
+ const docsLink = (value: string): string => {
26
+ const protocol = URL.parse(value)?.protocol;
27
+ const safe = protocol === 'https:' || protocol === 'http:';
28
+ return safe ? `<a href="${escapeHtml(value)}">${escapeHtml(value)}</a>` : escapeHtml(value);
29
+ };
30
+
31
+ /**
32
+ * A non-fatal finding rendered next to the error: the same code/cause/fix contract, from a
33
+ * diagnostic that did not stop the request. Declared structurally — the packages that produce
34
+ * one (`@ultimat3/entity`'s N+1 codes, reported by `x dev`) are this tier or above and can
35
+ * never be imported here, exactly as `AuthzDecision` is declared and never imported.
36
+ */
37
+ export interface OverlayNotice {
38
+ readonly code: string;
39
+ readonly cause: string;
40
+ readonly fix: string;
41
+ readonly docs?: string;
42
+ }
14
43
 
15
44
  export interface OverlayMeta {
16
45
  readonly requestId?: string;
17
46
  readonly method?: string;
18
47
  readonly path?: string;
19
48
  readonly buildId?: string | null;
49
+ readonly notices?: readonly OverlayNotice[];
20
50
  }
21
51
 
22
- // Token definitions live here and nowhere else; every rule below uses var().
23
- const STYLE = `
24
- :root {
25
- --x-bg: #fbfbfd; --x-surface: #ffffff; --x-border: #e3e3ea;
26
- --x-text: #1b1b1f; --x-muted: #6b6b76; --x-danger: #b3261e; --x-accent: #2f4fd8;
27
- --x-code-bg: #f3f3f7;
28
- }
29
- @media (prefers-color-scheme: dark) {
30
- :root {
31
- --x-bg: #111115; --x-surface: #1a1a20; --x-border: #2e2e38;
32
- --x-text: #ececf1; --x-muted: #9b9baa; --x-danger: #ff8a80; --x-accent: #9db2ff;
33
- --x-code-bg: #22222b;
34
- }
35
- }
36
- * { box-sizing: border-box; }
37
- body {
38
- margin: 0; padding: 2rem; background: var(--x-bg); color: var(--x-text);
39
- font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
40
- }
41
- main { max-width: 60rem; margin: 0 auto; }
42
- .card {
43
- background: var(--x-surface); border: 1px solid var(--x-border);
44
- border-radius: 8px; padding: 1.25rem 1.5rem; margin-bottom: 1rem;
45
- }
46
- h1 { font-size: 1.1rem; margin: 0 0 .25rem; color: var(--x-danger); }
47
- h2 { font-size: .8rem; margin: 0 0 .5rem; color: var(--x-muted);
48
- text-transform: uppercase; letter-spacing: .08em; }
49
- dl { display: grid; grid-template-columns: 5rem 1fr; gap: .35rem 1rem; margin: 0; }
50
- dt { color: var(--x-muted); }
51
- dd { margin: 0; overflow-wrap: anywhere; }
52
- pre { background: var(--x-code-bg); border-radius: 6px; padding: .75rem;
53
- margin: 0; overflow-x: auto; }
54
- a { color: var(--x-accent); }
55
- .title { color: var(--x-muted); font-weight: normal; }
56
- `;
52
+ // One `<dd>` per finding, holding the cause and the runnable fix the same two lines the
53
+ // terminal prints under a code, so a notice cannot say something the CLI would not.
54
+ const noticeRow = (notice: OverlayNotice): string =>
55
+ `<dt>${escapeHtml(notice.code)}</dt><dd>${escapeHtml(notice.cause)}<br><code>${escapeHtml(
56
+ notice.fix,
57
+ )}</code>${notice.docs === undefined ? '' : `<br>${docsLink(notice.docs)}`}</dd>`;
58
+
59
+ /**
60
+ * Nothing at all when there is nothing to report — not an empty card. A request with no findings
61
+ * must render the bytes this file rendered before notices existed, or every host that never
62
+ * supplies one still pays for the feature with a card that says nothing. The trailing indent is
63
+ * part of that: it leaves the card after this one exactly where it already sat.
64
+ */
65
+ const noticesCard = (notices: readonly OverlayNotice[]): string =>
66
+ notices.length === 0
67
+ ? ''
68
+ : `<section class="card notices">
69
+ <h2>notices</h2>
70
+ <dl>
71
+ ${notices.map(noticeRow).join('\n ')}
72
+ </dl>
73
+ </section>
74
+ `;
57
75
 
58
76
  export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string => {
59
77
  const facts = factsOf(error);
@@ -62,14 +80,14 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
62
80
  ...(meta.path === undefined ? {} : { instance: meta.path }),
63
81
  });
64
82
  const where = `${meta.method ?? ''} ${meta.path ?? ''}`.trim();
65
- return `<style>${STYLE}</style>
83
+ return `<style>${OVERLAY_STYLE}</style>
66
84
  <main>
67
85
  <section class="card">
68
86
  <h1>${escapeHtml(facts.code)} <span class="title">${escapeHtml(facts.title)}</span></h1>
69
87
  <dl>
70
88
  <dt>cause</dt><dd>${escapeHtml(facts.cause)}</dd>
71
89
  <dt>fix</dt><dd><code>${escapeHtml(facts.fix)}</code></dd>
72
- <dt>docs</dt><dd><a href="${escapeHtml(facts.docs)}">${escapeHtml(facts.docs)}</a></dd>
90
+ <dt>docs</dt><dd>${docsLink(facts.docs)}</dd>
73
91
  ${where === '' ? '' : `<dt>route</dt><dd>${escapeHtml(where)}</dd>`}
74
92
  ${meta.requestId === undefined ? '' : `<dt>request</dt><dd>${escapeHtml(meta.requestId)}</dd>`}
75
93
  ${
@@ -79,7 +97,7 @@ export const renderOverlay = (error: unknown, meta: OverlayMeta = {}): string =>
79
97
  }
80
98
  </dl>
81
99
  </section>
82
- <section class="card">
100
+ ${noticesCard(meta.notices ?? [])}<section class="card">
83
101
  <h2>terminal</h2>
84
102
  <pre>${escapeHtml(renderErrorLines(error))}</pre>
85
103
  </section>
@@ -0,0 +1,107 @@
1
+ // Who the MESH says is calling. TLS termination is the mesh's job (axiom 7) so the framework's
2
+ // entire contribution is reading a header it has been TOLD to trust: Envoy's
3
+ // `x-forwarded-client-cert`, at the same hop index `x-forwarded-for` is read at. Untrusted, this
4
+ // header is worse than nothing — it authenticates — so an untrusted read answers `null` and
5
+ // there is no second proxy-trust path to get it wrong in.
6
+
7
+ import { FORWARDED_CLIENT_CERT, type ForwardedInput, forwardedElement } from './forwarded';
8
+
9
+ /**
10
+ * The peer's certificate facts, declared structurally rather than imported: `@ultimat3/auth` owns
11
+ * `ServiceIdentity` and is this tier, so it can never be imported here — the same reason
12
+ * `AuthzDecision` and `OverlayNotice` are declared in this package. An app maps this onto a
13
+ * `ServiceIdentity` inside its `configureAuthenticator()`, which is the ONE funnel:
14
+ * `verifyWorkloadToken(...)` -> `actorFromService(identity)` -> `ctx.actor`. Never a second
15
+ * authz system that reads a certificate and decides on its own.
16
+ */
17
+ export interface PeerIdentity {
18
+ /**
19
+ * The SPIFFE ID if the peer presented one, else the subject DN, else the first URI SAN. What an
20
+ * app keys a service on — and always a value a trusted proxy wrote, never the caller.
21
+ */
22
+ readonly id: string;
23
+ /** `spiffe://…` from the URI SANs, when there is one. */
24
+ readonly spiffeId: string | null;
25
+ /** The certificate subject DN, e.g. `CN=checkout,OU=payments`. */
26
+ readonly subject: string | null;
27
+ readonly uriSans: readonly string[];
28
+ readonly dnsSans: readonly string[];
29
+ /** The proxy that terminated the TLS connection, as it named itself. */
30
+ readonly by: string | null;
31
+ }
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[] => {
35
+ const out: string[] = [];
36
+ let current = '';
37
+ let quoted = false;
38
+ let escaped = false;
39
+ for (const char of value) {
40
+ if (escaped) {
41
+ current += char;
42
+ escaped = false;
43
+ continue;
44
+ }
45
+ if (char === '\\') {
46
+ escaped = true;
47
+ continue;
48
+ }
49
+ if (char === '"') {
50
+ quoted = !quoted;
51
+ continue;
52
+ }
53
+ if (char === separator && !quoted) {
54
+ out.push(current.trim());
55
+ current = '';
56
+ continue;
57
+ }
58
+ current += char;
59
+ }
60
+ out.push(current.trim());
61
+ return out.filter((entry) => entry.length > 0);
62
+ };
63
+
64
+ /** `Key=Value` pairs, keys lowercased. `URI` and `DNS` may repeat, so values collect. */
65
+ const pairsOf = (element: string): ReadonlyMap<string, readonly string[]> => {
66
+ const out = new Map<string, string[]>();
67
+ for (const pair of splitUnquoted(element, ';')) {
68
+ const eq = pair.indexOf('=');
69
+ if (eq <= 0) continue;
70
+ const key = pair.slice(0, eq).trim().toLowerCase();
71
+ const value = pair.slice(eq + 1).trim();
72
+ if (value.length === 0) continue;
73
+ const existing = out.get(key);
74
+ if (existing === undefined) out.set(key, [value]);
75
+ else existing.push(value);
76
+ }
77
+ return out;
78
+ };
79
+
80
+ /**
81
+ * The peer identity a TRUSTED proxy asserted, or `null`. `null` is the answer for an untrusted
82
+ * deployment, a missing header and a chain shorter than `trustedProxyHops` alike: a certificate
83
+ * identity read from a hop nobody vouched for is a confident name for an attacker's claim.
84
+ */
85
+ export const peerIdentity = (input: ForwardedInput): PeerIdentity | null => {
86
+ const element = forwardedElement(
87
+ input.headers.get(FORWARDED_CLIENT_CERT),
88
+ input.config.trustedProxyHops,
89
+ splitUnquoted,
90
+ );
91
+ if (element === undefined) return null;
92
+ const pairs = pairsOf(element);
93
+ const uriSans = pairs.get('uri') ?? [];
94
+ const dnsSans = pairs.get('dns') ?? [];
95
+ const subject = pairs.get('subject')?.[0] ?? null;
96
+ const spiffeId = uriSans.find((uri) => uri.startsWith('spiffe://')) ?? null;
97
+ const id = spiffeId ?? subject ?? uriSans[0] ?? null;
98
+ if (id === null) return null;
99
+ return {
100
+ id,
101
+ spiffeId,
102
+ subject,
103
+ uriSans,
104
+ dnsSans,
105
+ by: pairs.get('by')?.[0] ?? null,
106
+ };
107
+ };