@ultimat3/http 22.15.0 → 24.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/hooks.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // be imported here), and — a seam of a different kind, deciding nothing — what a dev diagnostic
4
4
  // found. All three are declared structurally, which keeps the import boundary intact and keeps
5
5
  // the pipeline testable.
6
- import type { Actor } from '@ultimat3/core';
6
+ import type { Actor, UltimateError } from '@ultimat3/core';
7
7
  import type { RequestContext } from './context';
8
8
  import type { OverlayNotice } from './overlay';
9
9
  import type { UltimateRequest } from './request';
@@ -51,6 +51,17 @@ export interface ServerHooks {
51
51
  status: number,
52
52
  ctx: RequestContext,
53
53
  ) => Promise<string | undefined> | string | undefined;
54
+ /**
55
+ * Why nothing is routed at this path, when a higher tier knows more than "no route". Consulted
56
+ * ONLY on a miss — never for a matched route, a refused method or an unreadable path — and the
57
+ * error it returns is thrown in place of `X_ROUTE_NOT_FOUND`; `undefined` keeps the plain miss.
58
+ * `@ultimat3/action` answers here for a path that is one of its actions under a path style this
59
+ * server does not serve.
60
+ *
61
+ * Synchronous and given only the request line: a miss is reached by any anonymous caller, so
62
+ * what runs on it must not read a body, a session or a database.
63
+ */
64
+ readonly explainMiss?: (method: string, pathname: string) => UltimateError | undefined;
54
65
  }
55
66
 
56
67
  export type Authenticator = NonNullable<ServerHooks['authenticate']>;
@@ -1,20 +1,6 @@
1
- // The two questions every HTML answer this package renders has to ask, in one place: is the caller
2
- // a browser, and how does a value become markup. Both were the dev overlay's private helpers while
3
- // the production error page needs the identical answers — a second accept sniff would let a client
4
- // get the overlay in dev and JSON in production, and a second escape set is one hole away.
5
-
6
- /**
7
- * `'` is escaped even though every attribute the renderers write is double-quoted: the escape set
8
- * is what the next author reads as the guarantee, and a single-quoted attribute written later
9
- * would inherit a hole nothing here would have flagged.
10
- */
11
- export const escapeHtml = (value: string): string =>
12
- value
13
- .replaceAll('&', '&amp;')
14
- .replaceAll('<', '&lt;')
15
- .replaceAll('>', '&gt;')
16
- .replaceAll('"', '&quot;')
17
- .replaceAll("'", '&#39;');
1
+ // Is the caller a browser? One sniff for every HTML answer this package renders — the dev overlay
2
+ // and the production error page need the identical answer, and a second sniff would let a client
3
+ // get the overlay in dev and JSON in production. How a value becomes markup is core's `escapeHtml`.
18
4
 
19
5
  /**
20
6
  * Does this caller render HTML? One sniff, three readers — the dev overlay, the production error
package/src/index.ts CHANGED
@@ -1,12 +1,14 @@
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 type { RenderMode } from '@ultimat3/core';
4
+ export type { PgExecutor, RenderMode } from '@ultimat3/core';
5
5
  // The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
6
6
  // the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
7
7
  // one, and neither may import the other. Re-exported here so a receiver route needs one import.
8
8
  export {
9
+ escapeHtml,
9
10
  isCanonicalWebhookField,
11
+ readCookie,
10
12
  WEBHOOK_ID_HEADER,
11
13
  WEBHOOK_SIGNATURE_HEADER,
12
14
  WEBHOOK_SIGNATURE_VERSION,
@@ -105,9 +107,8 @@ export {
105
107
  } from './forwarded';
106
108
  export type { Authenticator, AuthzDecision, ServerHooks } from './hooks';
107
109
  export { configureAuthenticator, configuredAuthenticator, resetAuthenticator } from './hooks';
108
- export { escapeHtml } from './html-render';
109
110
  export type { LocaleConfig, TimeZoneConfig } from './locale';
110
- export { DEFAULT_LOCALE_CONFIG, readCookie } from './locale';
111
+ export { DEFAULT_LOCALE_CONFIG } from './locale';
111
112
  export type { Middleware } from './middleware';
112
113
  export { compose } from './middleware';
113
114
  export type { NavigationPurpose } from './navigation';
@@ -161,11 +162,9 @@ export {
161
162
  rateLimitStoreUnavailable,
162
163
  tenantBucketUnknown,
163
164
  } from './rate-limit-errors';
164
- export type {
165
- PgExecutor,
166
- PostgresRateLimitStore,
167
- PostgresRateLimitStoreOptions,
168
- } from './rate-limit-postgres';
165
+ export type { RateLimitPeek } from './rate-limit-peek';
166
+ export { rateLimitPeek, refilledTokens } from './rate-limit-peek';
167
+ export type { PostgresRateLimitStore, PostgresRateLimitStoreOptions } from './rate-limit-postgres';
169
168
  export {
170
169
  postgresRateLimitStore,
171
170
  SQL_RATE_LIMIT_TABLE,
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { type LocaleSources, localeConfig, resolveLocale, splitLocalePrefix } from '@ultimat3/i18n';
7
7
  import type { RequestContext } from './context';
8
+ import { normalizePath } from './router';
8
9
 
9
10
  /** Methods whose redirect may change nothing but the URL. Anything else is a 308, never a 301. */
10
11
  const SAFE_METHODS = new Set(['GET', 'HEAD']);
@@ -26,7 +27,10 @@ export function routeLocalePrefix(
26
27
  if (prefix === undefined) return pathname;
27
28
  if (prefix.isDefault) {
28
29
  const mount = basePath === '/' || basePath === '' ? '' : basePath.replace(/\/$/, '');
29
- const location = `${mount}${prefix.path}${ctx.url.search}`;
30
+ // Normalised exactly as the router would match it. The remainder is caller-written, and
31
+ // `//host/x` in a `Location` is a scheme-relative URL: a permanent, cacheable redirect off
32
+ // the site, answered before auth. One leading slash, no empty segment.
33
+ const location = `${mount}${normalizePath(prefix.path)}${ctx.url.search}`;
30
34
  // PERMANENT, and so not `redirect()`, whose statuses are an application's (it refuses 301 on
31
35
  // purpose: a cached permanent redirect cannot be taken back). This one is the framework's URL
32
36
  // scheme, and it IS permanent — the default locale is never prefixed, in any release.
package/src/locale.ts CHANGED
@@ -32,30 +32,3 @@ export const DEFAULT_TZ_CONFIG: TimeZoneConfig = {
32
32
  header: TIMEZONE_HEADER,
33
33
  cookie: 'x_timezone',
34
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
- }
50
- };
51
-
52
- export const readCookie = (header: string | null, name: string): string | null => {
53
- if (header === null) return null;
54
- for (const part of header.split(';')) {
55
- const index = part.indexOf('=');
56
- if (index === -1) continue;
57
- if (part.slice(0, index).trim() !== name) continue;
58
- return decodeCookieValue(part.slice(index + 1).trim());
59
- }
60
- return null;
61
- };
package/src/navigation.ts CHANGED
@@ -24,15 +24,28 @@ export function navigationPurpose(request: Request): NavigationPurpose | null {
24
24
 
25
25
  const SAFE = new Set(['GET', 'HEAD']);
26
26
 
27
+ /** The schemes a navigation may be handed over to. Everything else is not a place to go. */
28
+ const WEB_PROTOCOLS: ReadonlySet<string> = new Set(['http:', 'https:']);
29
+
27
30
  /**
28
31
  * What `x-ultimate-location` says. A target on THIS origin is its path, query and fragment and
29
32
  * nothing else: behind a TLS-terminating proxy the request URL this process sees is
30
33
  * `http://internal…`, so an absolute URL built from it named the public site as `http://` (HSTS
31
34
  * hid it; it was still wrong). The router resolves a path against the page it runs in, whose origin
32
35
  * is the one the visitor typed. A target on ANOTHER origin is kept exactly as the app gave it.
36
+ *
37
+ * Only `http:` and `https:` are ever handed over. The router LOADS what this header says, so a
38
+ * scheme that runs in the page (`javascript:`, `data:`) would be script in the app's own origin
39
+ * whenever a redirect target is caller-influenced. Such a target — and one that will not parse —
40
+ * answers the REQUESTED url instead, as a path: a document load of it re-runs the route, and the
41
+ * browser's own redirect handling refuses what this refused. Total, never a throw: it runs in the
42
+ * `response` stage, after the handler.
33
43
  */
34
44
  export function locationFor(target: string, base: URL): string {
45
+ const requested = `${base.pathname}${base.search}${base.hash}`;
46
+ if (!URL.canParse(target, base)) return requested;
35
47
  const resolved = new URL(target, base);
48
+ if (!WEB_PROTOCOLS.has(resolved.protocol)) return requested;
36
49
  if (resolved.origin !== base.origin) return target;
37
50
  return `${resolved.pathname}${resolved.search}${resolved.hash}`;
38
51
  }
package/src/overlay.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  // `--json` emits, so a code/cause/fix string can never differ between the three
3
3
  // surfaces. Labels here ("cause", "fix", "notices") are protocol strings from the
4
4
  // error contract, not UI copy, so they are not routed through the i18n catalog.
5
+
6
+ import { escapeHtml } from '@ultimat3/core';
5
7
  import { factsOf, renderErrorLines, toProblem } from './error-facts';
6
- import { acceptsHtml, escapeHtml } from './html-render';
8
+ import { acceptsHtml } from './html-render';
7
9
  import { OVERLAY_STYLE } from './overlay-style';
8
10
  import { html } from './response';
9
11
 
@@ -1,8 +1,7 @@
1
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.
2
+ // entire contribution is reading a header it has been TOLD to trust (`trustClientCertHeader`):
3
+ // Envoy's `x-forwarded-client-cert`, at the hop index `x-forwarded-for` is read at. Untrusted,
4
+ // this header is worse than nothing — it authenticates — so an untrusted read answers `null`.
6
5
 
7
6
  import { FORWARDED_CLIENT_CERT, type ForwardedInput, forwardedElement } from './forwarded';
8
7
 
@@ -82,11 +81,16 @@ const pairsOf = (element: string): ReadonlyMap<string, readonly string[]> => {
82
81
  };
83
82
 
84
83
  /**
85
- * The peer identity a TRUSTED proxy asserted, or `null`. `null` is the answer for an untrusted
86
- * deployment, a missing header and a chain shorter than `trustedProxyHops` alike: a certificate
84
+ * The peer identity a TRUSTED proxy asserted, or `null`. `null` is the answer for a deployment
85
+ * that did not declare `trustClientCertHeader`, an untrusted one, a missing header and a chain
86
+ * shorter than `trustedProxyHops` alike: a certificate
87
87
  * identity read from a hop nobody vouched for is a confident name for an attacker's claim.
88
88
  */
89
89
  export const peerIdentity = (input: ForwardedInput): PeerIdentity | null => {
90
+ // Its own declaration, never implied by `trustProxy`: that one says the proxy APPENDS to
91
+ // `x-forwarded-for`, and plenty of ingresses that do pass a client-sent certificate header
92
+ // straight through — where the caller would be naming its own identity.
93
+ if (!input.config.trustClientCertHeader) return null;
90
94
  const element = forwardedElement(
91
95
  input.headers.get(FORWARDED_CLIENT_CERT),
92
96
  input.config.trustedProxyHops,
package/src/pipeline.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // middleware stack, so nothing can skip a stage and `/_x` and `pipeline.test.ts` can both read it.
4
4
  // The other two thirds of the lifecycle are siblings: `stages.ts` owns what each stage does, and
5
5
  // `finalize.ts` owns the promise that the tail always answers rather than rejecting.
6
- import { recordRequest, runWithContext, withSpan } from '@ultimat3/core';
6
+ import { beginWork, recordRequest, runWithContext, withSpan } from '@ultimat3/core';
7
7
  import { defineHttpConfig, type HttpConfig } from './config';
8
8
  import { asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
9
9
  import { readCorrelation } from './correlation';
@@ -56,7 +56,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
56
56
  {
57
57
  name: 'auth',
58
58
  phase: 'request',
59
- why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address',
59
+ why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address — and a request that FAILS a required route is refused here, so this stage spends an allowance keyed on the address alone for it: a failed credential is metered, a signed-in caller behind the same address is not',
60
60
  },
61
61
  {
62
62
  name: 'rate-limit',
@@ -66,7 +66,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
66
66
  {
67
67
  name: 'csrf',
68
68
  phase: 'request',
69
- why: 'after auth so it only judges a caller holding an AMBIENT credential — a bearer token and an anonymous call are both exempt — and before body so a forged write never makes the server allocate its payload. CORS cannot cover this: application/x-www-form-urlencoded is a simple content type, so a cross-site form post is sent and executed and only the RESPONSE is withheld',
69
+ why: 'after auth so it knows who is calling: a bearer token is exempt (not ambient), and an anonymous write is judged like a signed-in one whenever it carries browser evidence, because a forged sign-in is a forged write — and before body so a forged write never makes the server allocate its payload. CORS cannot cover this: application/x-www-form-urlencoded is a simple content type, so a cross-site form post is sent and executed and only the RESPONSE is withheld',
70
70
  },
71
71
  {
72
72
  name: 'body',
@@ -188,8 +188,23 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
188
188
  // own; this race is the half that answers the SOCKET when it does not. `work` keeps its
189
189
  // own handler either way — a rejection arriving after the deadline already won is still
190
190
  // a rejection, and an unhandled one takes the process down.
191
- void work.catch(() => undefined);
192
- await Promise.race([work, deadline.expired]);
191
+ let settled = false;
192
+ const settle = (): void => {
193
+ settled = true;
194
+ };
195
+ void work.then(settle, settle);
196
+ try {
197
+ await Promise.race([work, deadline.expired]);
198
+ } finally {
199
+ // The deadline won and the handler is still running: it holds its pool slot and
200
+ // whatever it was writing. The caller's own count ends when this response leaves, so
201
+ // the orphan is counted in its own right until it settles — a drain that saw an idle
202
+ // process here closed the pool under it.
203
+ if (!settled) {
204
+ const done = beginWork();
205
+ void work.then(done, done);
206
+ }
207
+ }
193
208
  }
194
209
  } catch (error) {
195
210
  ctx.error = error;
@@ -2,10 +2,10 @@
2
2
  // and `auth` calls it again once the actor's saved preference is known. WHERE each value is read
3
3
  // from is this file's; what a locale or a zone IS stays `@ultimat3/i18n`'s and `@ultimat3/time`'s.
4
4
 
5
+ import { readCookie } from '@ultimat3/core';
5
6
  import { resolveTimeZone } from '@ultimat3/time';
6
7
  import type { HttpConfig } from './config';
7
8
  import type { RequestContext } from './context';
8
- import { readCookie } from './locale';
9
9
  import { requestLocale } from './locale-prefix';
10
10
  import type { UltimateRequest } from './request';
11
11
 
@@ -12,6 +12,10 @@
12
12
  // it was. Measured need: an app's `X_SESSION_CHECKOUT_BUSY` carried `{ sessionId, title, state }`
13
13
  // in `meta` and its island recovered the id by running a UUID regex over `cause`.
14
14
 
15
+ // The public-cause predicate and both of its tables are `@ultimat3/core`'s (`public-cause.ts`): the
16
+ // MCP error data and the agent `tool_result` ask the question this package's problem document
17
+ // does. This file only WRITES the app half — a reader imports `hasPublicCause` from core.
18
+ import { registerPublicCause, resetPublicCauses } from '@ultimat3/core';
15
19
  import { ERROR_STATUS } from './error-map';
16
20
  import { problemMetaInvalid } from './errors';
17
21
 
@@ -47,20 +51,6 @@ const RESERVED_KEYS: ReadonlySet<string> = new Set(['issues', '__proto__']);
47
51
  /** Per app-owned code, the `meta` keys its documents carry. A `Map`, for `APP_ERROR_STATUS`'s reason. */
48
52
  const DECLARED = new Map<string, readonly string[]>();
49
53
 
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
54
  /** Per code: the `meta` keys, a public cause, or both. A bare list is the keys alone. */
65
55
  export type ProblemMetaDeclaration =
66
56
  | readonly string[]
@@ -98,7 +88,7 @@ export const registerProblemMeta = (
98
88
  : ((declaration as { keys?: readonly string[] }).keys ?? []);
99
89
  const publicCause = !listed && (declaration as { publicCause?: boolean }).publicCause === true;
100
90
  if (!listed && keys.length === 0 && publicCause) {
101
- APP_PUBLIC_CAUSE.add(code);
91
+ registerPublicCause(code);
102
92
  continue;
103
93
  }
104
94
  if (keys.length === 0) {
@@ -122,20 +112,16 @@ export const registerProblemMeta = (
122
112
  throw problemMetaInvalid(code, `already declared as [${existing.join(', ')}] by this app`);
123
113
  }
124
114
  DECLARED.set(code, [...keys]);
125
- if (publicCause) APP_PUBLIC_CAUSE.add(code);
115
+ if (publicCause) registerPublicCause(code);
126
116
  }
127
117
  };
128
118
 
129
119
  /** Test seam. Production registers once at boot and never unregisters. */
130
120
  export const resetProblemMeta = (): void => {
131
121
  DECLARED.clear();
132
- APP_PUBLIC_CAUSE.clear();
122
+ resetPublicCauses();
133
123
  };
134
124
 
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);
138
-
139
125
  /** The keys declared for a code, or `undefined` when nothing was — which is every framework code. */
140
126
  export const problemMetaKeysFor = (code: string): readonly string[] | undefined =>
141
127
  DECLARED.get(code);
@@ -0,0 +1,32 @@
1
+ // The READ half of a token bucket: what a key holds now, refill applied, with nothing written.
2
+ // Split from `rate-limit.ts` at the size ceiling; every `RateLimitStore.peek` answers through it.
3
+
4
+ import type { Bucket } from './rate-limit';
5
+
6
+ /** A read of a bucket: whole tokens left, and how long until one more is there when none is. */
7
+ export interface RateLimitPeek {
8
+ readonly remaining: number;
9
+ /** `0` while a whole token is left; otherwise the seconds until one is, at least 1. */
10
+ readonly retryAfterSeconds: number;
11
+ }
12
+
13
+ /**
14
+ * The peek every store answers from the tokens it computed — exported for the reason
15
+ * `rateLimitDecision` is: two drivers deriving "when may I come back" separately is two answers.
16
+ * A bucket that never refills is clamped to a day, as a decision's `Retry-After` is.
17
+ */
18
+ export const rateLimitPeek = (bucket: Bucket, tokens: number): RateLimitPeek => {
19
+ if (tokens >= 1) return { remaining: Math.floor(tokens), retryAfterSeconds: 0 };
20
+ const seconds =
21
+ bucket.refillPerSecond > 0 ? Math.min(86_400, (1 - tokens) / bucket.refillPerSecond) : 86_400;
22
+ return { remaining: 0, retryAfterSeconds: Math.max(1, Math.ceil(seconds)) };
23
+ };
24
+
25
+ /** `stored` tokens last written at `lastMs`, refilled to `nowMs` and capped — no mutation. */
26
+ export const refilledTokens = (
27
+ bucket: Bucket,
28
+ stored: number,
29
+ lastMs: number,
30
+ nowMs: number,
31
+ ): number =>
32
+ Math.min(bucket.capacity, stored + (Math.max(0, nowMs - lastMs) / 1000) * bucket.refillPerSecond);
@@ -1,26 +1,13 @@
1
- // The shared rate-limit store: one Postgres table, one `insert … on conflict` per take, so N
2
- // replicas count against one bucket. Without it `config.rateLimit.scope: 'shared'` is a
3
- // declaration nothing can satisfy while `docker/helm/values.yaml` runs `roles.web.replicas: 3`.
1
+ // The shared rate-limit store: one Postgres table, one `insert … on conflict` per take and a plain
2
+ // `select` per peek, so N replicas count against one bucket. Without it `rateLimit.scope: 'shared'`
3
+ // is a declaration nothing can satisfy while `docker/helm/values.yaml` runs `replicas: 3`.
4
4
  // Statements are spelled out so an agent can run the exact one it saw in a log.
5
5
 
6
+ import type { PgExecutor } from '@ultimat3/core';
6
7
  import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
7
8
  import { rateLimitDecision } from './rate-limit';
8
9
  import { rateLimitStoreUnavailable } from './rate-limit-errors';
9
-
10
- /**
11
- * The one thing this store needs from the DB layer, declared structurally rather than imported —
12
- * `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/jobs` declare the same shape for
13
- * the same reason: neither package owns the other's connection. Here it is also the only option:
14
- * `@ultimat3/http` has no `@ultimat3/db` dependency at all, and taking one to type a single method
15
- * would put the whole database package in this package's install graph.
16
- *
17
- * **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template whose
18
- * positional form is `unsafe`. What satisfies it is a client that already speaks `(text, values)`,
19
- * wrapped in one line — `@ultimat3/db`'s `DbClient.query({ text, values })` is the framework's own.
20
- */
21
- export interface PgExecutor {
22
- query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
23
- }
10
+ import { rateLimitPeek, refilledTokens } from './rate-limit-peek';
24
11
 
25
12
  /**
26
13
  * Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows,
@@ -93,6 +80,13 @@ returning tokens, spent
93
80
 
94
81
  export const SQL_RATE_LIMIT_RESET = 'delete from x_rate_limit where key = $1';
95
82
 
83
+ /**
84
+ * `peek`: a plain read, never the upsert — it runs on every request to a required route, and a
85
+ * write there was a row lock and a WAL record per signed-in page. The refill is computed in JS
86
+ * from the row by `refilledTokens`, the memory store's own arithmetic; no row means full.
87
+ */
88
+ export const SQL_RATE_LIMIT_PEEK = 'select tokens, last_ms from x_rate_limit where key = $1';
89
+
96
90
  /**
97
91
  * The memory store's forget rule, in SQL: a bucket back at capacity answers exactly as a missing
98
92
  * one, so dropping it changes no decision. A bucket that never refills
@@ -114,6 +108,12 @@ delete from x_rate_limit
114
108
  * refill_per_second >= capacity
115
109
  `;
116
110
 
111
+ interface PeekRow {
112
+ /** `double precision` / `bigint`, which some clients hand back as strings. */
113
+ readonly tokens: number | string;
114
+ readonly last_ms: number | string;
115
+ }
116
+
117
117
  interface TakeRow {
118
118
  /** `double precision`, which some clients hand back as a string. */
119
119
  readonly tokens: number | string;
@@ -184,6 +184,15 @@ export function postgresRateLimitStore(
184
184
  return rateLimitDecision(bucket, Number(row.tokens), cost, isTrue(row.spent), nowMs);
185
185
  },
186
186
 
187
+ async peek(key, bucket, nowMs) {
188
+ const row = (await exec.query<PeekRow>(SQL_RATE_LIMIT_PEEK, [key]))[0];
189
+ const tokens =
190
+ row === undefined
191
+ ? bucket.capacity
192
+ : refilledTokens(bucket, Number(row.tokens), Number(row.last_ms), Math.floor(nowMs));
193
+ return rateLimitPeek(bucket, tokens);
194
+ },
195
+
187
196
  async reset(key): Promise<void> {
188
197
  await exec.query(SQL_RATE_LIMIT_RESET, [key]);
189
198
  },
@@ -0,0 +1,123 @@
1
+ // What the pipeline SPENDS, and when: the `rate-limit` stage's list of keys for a request that
2
+ // has a caller, and the one allowance the `auth` stage spends for a request that failed to have
3
+ // one. Split out of `stages.ts` at the size ceiling; the bucket maths stays in `rate-limit.ts`.
4
+
5
+ import { actorView, type RequestContext } from './context';
6
+ import {
7
+ type RateLimitConfig,
8
+ type RateLimitDecision,
9
+ type RateLimiter,
10
+ type RateLimitSpend,
11
+ rateLimitSpends,
12
+ } from './rate-limit';
13
+ import { rateLimited } from './rate-limit-errors';
14
+
15
+ /**
16
+ * The namespace failed credentials are counted in. Not route-scoped, for `TENANT_SCOPE`'s reason:
17
+ * a per-route key would hand one address its whole allowance again on every required route.
18
+ */
19
+ export const UNAUTHENTICATED_SCOPE = 'unauthenticated';
20
+
21
+ /**
22
+ * Spends each key in order and stops at the first refusal, so a caller its own bucket already
23
+ * refused costs its tenant nothing. Recorded on the context BEFORE the throw, so the 429 carries
24
+ * `Retry-After` and the `RateLimit-*` headers rather than making the client guess.
25
+ */
26
+ const spendAll = async (
27
+ ctx: RequestContext,
28
+ limiter: RateLimiter,
29
+ spends: readonly RateLimitSpend[],
30
+ ): Promise<void> => {
31
+ let answer: RateLimitDecision | undefined;
32
+ let refusedKey: string | undefined;
33
+ for (const spend of spends) {
34
+ const decision = await limiter.check(spend.key, spend.bucket);
35
+ // The bucket closest to refusing is the one the caller has to plan against: reporting
36
+ // `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that tells a
37
+ // client it may proceed and then refuses its next call.
38
+ if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
39
+ if (!decision.allowed) {
40
+ answer = decision;
41
+ refusedKey = spend.key;
42
+ break;
43
+ }
44
+ }
45
+ if (answer === undefined) return;
46
+ ctx.rateLimit = answer;
47
+ for (const [name, value] of Object.entries(limiter.headers(answer))) {
48
+ ctx.headers.set(name, value);
49
+ }
50
+ if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
51
+ };
52
+
53
+ /**
54
+ * The `rate-limit` stage. A LIST, and the second entry is why: the key builder used to pick ONE
55
+ * subject — actor > org > ip, exclusive — so an authenticated request never touched a tenant
56
+ * bucket and one org's 8,000 seats each ran their own allowance against one shared pool.
57
+ */
58
+ export const spendRequestBuckets = async (
59
+ ctx: RequestContext,
60
+ limiter: RateLimiter,
61
+ config: RateLimitConfig,
62
+ routeName: string,
63
+ ): Promise<void> => {
64
+ if (!config.enabled) return;
65
+ const actor = actorView(ctx.actor);
66
+ await spendAll(
67
+ ctx,
68
+ limiter,
69
+ rateLimitSpends(
70
+ { actorId: actor?.id ?? null, orgId: actor?.orgId ?? null, ip: ctx.ip, routeName },
71
+ {
72
+ route: ctx.route?.meta.rateLimit ?? config.defaultBucket,
73
+ tenant: config.tenantBucket,
74
+ },
75
+ ),
76
+ );
77
+ };
78
+
79
+ /** The one key failed credentials are counted under: the address alone, never the route. */
80
+ const unauthenticatedKey = (ip: string | null): string =>
81
+ `${UNAUTHENTICATED_SCOPE}|ip:${ip ?? 'unknown'}`;
82
+
83
+ /**
84
+ * The `auth` stage's GATE, before `authenticate()` on a required route: an address whose failure
85
+ * allowance is spent is refused here, a valid credential included. Spending only on failure
86
+ * answered a wrong guess 429 and still served a right one, so it bounded nothing a guesser cares
87
+ * about — and every refused guess was still a credential-store read.
88
+ *
89
+ * READ ONLY: `RateLimitStore.peek`, never a take — this runs on every request to a required route,
90
+ * signed-in ones included, and a zero-cost take was a Postgres upsert on each. The refusal carries
91
+ * the peek's own `Retry-After` (`rateLimitPeek`). With no address there is no key
92
+ * worth refusing on — `ip:unknown` is everyone at once — so the gate stands aside and the failure
93
+ * path below still counts.
94
+ */
95
+ export const refuseExhaustedAddress = async (
96
+ ctx: RequestContext,
97
+ limiter: RateLimiter,
98
+ config: RateLimitConfig,
99
+ ): Promise<void> => {
100
+ if (!config.enabled || ctx.ip === null) return;
101
+ const key = unauthenticatedKey(ctx.ip);
102
+ const peek = await limiter.peek(key, config.defaultBucket);
103
+ if (peek.remaining >= 1) return;
104
+ throw rateLimited(key, peek.retryAfterSeconds);
105
+ };
106
+
107
+ /**
108
+ * The `auth` stage's failure path. A request refused 401 never reaches `rate-limit`, so nothing
109
+ * metered it: a caller with no credential, or a wrong one, could ask the credential store about
110
+ * tokens without bound. Spent only on FAILURE and keyed on the address ALONE — a signed-in
111
+ * caller behind the same NAT spends nothing here. Once it is spent, `refuseExhaustedAddress`
112
+ * refuses that address on required routes, signed-in callers included, until it refills.
113
+ */
114
+ export const spendUnauthenticated = (
115
+ ctx: RequestContext,
116
+ limiter: RateLimiter,
117
+ config: RateLimitConfig,
118
+ ): Promise<void> => {
119
+ if (!config.enabled) return Promise.resolve();
120
+ return spendAll(ctx, limiter, [
121
+ { key: unauthenticatedKey(ctx.ip), bucket: config.defaultBucket },
122
+ ]);
123
+ };
package/src/rate-limit.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  rateLimitScopeUnset,
12
12
  tenantBucketUnknown,
13
13
  } from './rate-limit-errors';
14
+ import { type RateLimitPeek, rateLimitPeek, refilledTokens } from './rate-limit-peek';
14
15
 
15
16
  /**
16
17
  * Where a limiter's counters live. A store says which it provides; `RateLimitConfig` says which
@@ -36,6 +37,12 @@ export interface RateLimitStore {
36
37
  /** Declared, never inferred: a driver knows where its counters live; nothing else does. */
37
38
  readonly scope: RateLimitScope;
38
39
  take(key: string, bucket: Bucket, cost: number, nowMs: number): Promise<RateLimitDecision>;
40
+ /**
41
+ * What `key` holds NOW, refill applied — READ ONLY: no write, no row, no entry created. A key
42
+ * never taken is full. Required, because the `auth` stage asks it on every request to a required
43
+ * route, and a zero-cost `take` there was a Postgres upsert on every signed-in page.
44
+ */
45
+ peek(key: string, bucket: Bucket, nowMs: number): Promise<RateLimitPeek>;
39
46
  reset(key: string): Promise<void>;
40
47
  }
41
48
 
@@ -298,6 +305,14 @@ export const memoryRateLimitStore = (
298
305
  if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
299
306
  return Promise.resolve(decision);
300
307
  },
308
+ peek(key, bucket, nowMs) {
309
+ const state = buckets.get(key);
310
+ const tokens =
311
+ state === undefined
312
+ ? bucket.capacity
313
+ : refilledTokens(bucket, state.tokens, state.lastMs, nowMs);
314
+ return Promise.resolve(rateLimitPeek(bucket, tokens));
315
+ },
301
316
  reset(key) {
302
317
  buckets.delete(key);
303
318
  return Promise.resolve();
@@ -377,6 +392,8 @@ export interface RateLimiter {
377
392
  */
378
393
  readonly buckets?: Readonly<Record<string, Bucket>> | undefined;
379
394
  check(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
395
+ /** The store's read-only `peek`, against the named bucket. Spends nothing, writes nothing. */
396
+ peek(key: string, bucketName: string): Promise<RateLimitPeek>;
380
397
  headers(decision: RateLimitDecision): Record<string, string>;
381
398
  /** Throws `X_RATE_LIMITED` when the bucket is empty. */
382
399
  assert(key: string, bucketName: string, cost?: number): Promise<RateLimitDecision>;
@@ -420,6 +437,7 @@ export const createRateLimiter = (options: {
420
437
  // no other way to learn what this limiter can enforce.
421
438
  buckets: options.config.buckets,
422
439
  check,
440
+ peek: (key, bucketName) => store.peek(key, bucketFor(bucketName), now()),
423
441
  headers: (decision) => ({
424
442
  'ratelimit-limit': String(decision.limit),
425
443
  'ratelimit-remaining': String(decision.remaining),