@ultimat3/http 23.0.0 → 25.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.
Files changed (54) hide show
  1. package/CLAUDE.md +75 -31
  2. package/README.md +89 -26
  3. package/package.json +9 -6
  4. package/src/app-config.ts +3 -1
  5. package/src/auth-redirect.ts +56 -17
  6. package/src/bearer-mount.ts +81 -26
  7. package/src/cache-policy.ts +10 -2
  8. package/src/config.ts +62 -32
  9. package/src/context.ts +11 -11
  10. package/src/correlation.ts +2 -2
  11. package/src/cors.ts +3 -2
  12. package/src/csrf.ts +17 -8
  13. package/src/deadline.ts +10 -8
  14. package/src/error-facts.ts +18 -2
  15. package/src/error-map-http.ts +94 -0
  16. package/src/error-map-tier-0.ts +62 -0
  17. package/src/error-map-tier-1.ts +73 -0
  18. package/src/error-map-tier-2.ts +120 -0
  19. package/src/error-map-tier-3.ts +100 -0
  20. package/src/error-map-tier-4.ts +116 -0
  21. package/src/error-map.ts +27 -474
  22. package/src/error-page.ts +3 -4
  23. package/src/error-titles.ts +93 -0
  24. package/src/errors.ts +46 -96
  25. package/src/forwarded.ts +36 -10
  26. package/src/health-disclosure.ts +66 -0
  27. package/src/hooks.ts +6 -5
  28. package/src/html-render.ts +3 -17
  29. package/src/index.ts +36 -27
  30. package/src/locale-prefix.ts +5 -1
  31. package/src/locale.ts +0 -27
  32. package/src/navigation.ts +26 -1
  33. package/src/overlay.ts +3 -1
  34. package/src/peer-identity.ts +10 -6
  35. package/src/pipeline.ts +32 -13
  36. package/src/preferences.ts +1 -1
  37. package/src/problem-meta.ts +7 -21
  38. package/src/rate-limit-buckets.ts +26 -2
  39. package/src/rate-limit-errors.ts +36 -10
  40. package/src/rate-limit-headers.ts +40 -0
  41. package/src/rate-limit-installed.ts +79 -0
  42. package/src/rate-limit-peek.ts +32 -0
  43. package/src/rate-limit-postgres.ts +30 -21
  44. package/src/rate-limit-stage.ts +132 -0
  45. package/src/rate-limit.ts +40 -15
  46. package/src/request-facts.ts +44 -0
  47. package/src/request.ts +12 -5
  48. package/src/response.ts +10 -2
  49. package/src/route-cache.ts +1 -1
  50. package/src/router.ts +36 -8
  51. package/src/server.ts +101 -30
  52. package/src/set-cookie.ts +60 -0
  53. package/src/stages.ts +50 -54
  54. package/src/validate.ts +2 -2
@@ -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,17 +24,42 @@ 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. A same-origin pathname is never emitted starting `//`
43
+ * (`hostlessPath`).
33
44
  */
34
45
  export function locationFor(target: string, base: URL): string {
46
+ const requested = `${base.pathname}${base.search}${base.hash}`;
47
+ if (!URL.canParse(target, base)) return requested;
35
48
  const resolved = new URL(target, base);
49
+ if (!WEB_PROTOCOLS.has(resolved.protocol)) return requested;
36
50
  if (resolved.origin !== base.origin) return target;
37
- return `${resolved.pathname}${resolved.search}${resolved.hash}`;
51
+ return `${hostlessPath(resolved.pathname)}${resolved.search}${resolved.hash}`;
52
+ }
53
+
54
+ /**
55
+ * A pathname that starts `//` — `/.//evil.test`, `/..//evil.test`, a request for `//evil.test` —
56
+ * is a path on THIS origin only while the URL keeps its host. Emitted alone it is a
57
+ * scheme-relative reference, and the router resolves it to another host and assigns that to
58
+ * `window.location`. `/.` in front is how WHATWG URL serialisation keeps a host-less `//` path a
59
+ * path; it resolves back to the same pathname, so nothing legitimate changes.
60
+ */
61
+ function hostlessPath(pathname: string): string {
62
+ return pathname.startsWith('//') ? `/.${pathname}` : pathname;
38
63
  }
39
64
 
40
65
  /** "Load this with a real navigation" — never stored anywhere, it answers one request. */
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,9 +3,9 @@
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
- import { asCtx, createRequestContext, elapsedMs, type RequestContext } from './context';
8
+ import { asCtx, elapsedMs, type RequestContext, requestContext } from './context';
9
9
  import { readCorrelation } from './correlation';
10
10
  import { type Deadline, startDeadline } from './deadline';
11
11
  import { pipelineNoResponse } from './errors';
@@ -14,8 +14,9 @@ import { clientAddress, clientUsedHttps } from './forwarded';
14
14
  import type { ServerHooks } from './hooks';
15
15
  import type { Middleware } from './middleware';
16
16
  import { peerIdentity } from './peer-identity';
17
- import { assertRateLimitScope, createRateLimiter, type RateLimiter } from './rate-limit';
17
+ import { assertRateLimitScope, type RateLimiter, rateLimiter } from './rate-limit';
18
18
  import { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
19
+ import { assertInstalledRateLimitScope } from './rate-limit-installed';
19
20
  import { UltimateRequest } from './request';
20
21
  import { problem } from './response';
21
22
  import type { RouteTable } from './router';
@@ -56,7 +57,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
56
57
  {
57
58
  name: 'auth',
58
59
  phase: 'request',
59
- why: 'before rate limiting so the limiter keys per actor and per tenant instead of punishing a shared NAT address',
60
+ 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
61
  },
61
62
  {
62
63
  name: 'rate-limit',
@@ -66,7 +67,7 @@ export const PIPELINE_STAGES: readonly StageDoc[] = [
66
67
  {
67
68
  name: 'csrf',
68
69
  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',
70
+ 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
71
  },
71
72
  {
72
73
  name: 'body',
@@ -109,7 +110,7 @@ export interface PipelineDeps {
109
110
  readonly hooks?: ServerHooks;
110
111
  readonly middleware?: readonly Middleware[];
111
112
  /**
112
- * A limiter built elsewhere — `createServer({ rateLimitStore })` is the one supported way, and
113
+ * A limiter built elsewhere — `httpServer({ rateLimitStore })` is the one supported way, and
113
114
  * it hands over a limiter built from the SAME merged config this constructor would have built.
114
115
  * One passed from anywhere else resolves bucket names against the table IT closed over, which
115
116
  * is why `assertRouteBuckets` compares that table against the routes' declarations and refuses
@@ -135,16 +136,19 @@ export interface Pipeline {
135
136
  handle(request: Request, init: HandleInit): Promise<Response>;
136
137
  }
137
138
 
138
- export const createPipeline = (deps: PipelineDeps): Pipeline => {
139
+ export const httpPipeline = (deps: PipelineDeps): Pipeline => {
139
140
  // Routes first, config second, and the merge here: `defineHttpConfig` cannot see a route, so a
140
141
  // bucket a route declares only becomes real at the construction that holds both. Idempotent, so
141
- // a config `createServer` already merged passes through unchanged.
142
+ // a config `httpServer` already merged passes through unchanged.
142
143
  const config = withRouteBuckets(deps.config ?? defineHttpConfig(), deps.table.routes);
143
- const limiter = deps.limiter ?? createRateLimiter({ config: config.rateLimit });
144
- // Here rather than in `createServer`: this is the one construction path every server, test and
144
+ const limiter = deps.limiter ?? rateLimiter({ config: config.rateLimit });
145
+ // Here rather than in `httpServer`: this is the one construction path every server, test and
145
146
  // embedder shares, so a limiter that cannot keep the app's declaration is refused exactly once.
146
147
  // Two halves of one question — where the counters live, and which buckets the limiter holds.
147
148
  assertRateLimitScope(config.rateLimit, limiter);
149
+ // The third half: where an action's or a query's declared `rateLimit:` is counted. Under a
150
+ // `'shared'` declaration that store is held to the same promise as this pipeline's own.
151
+ assertInstalledRateLimitScope(config.rateLimit);
148
152
  assertRouteBuckets(limiter, deps.table.routes);
149
153
  const run = stageRunners({
150
154
  table: deps.table,
@@ -188,8 +192,23 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
188
192
  // own; this race is the half that answers the SOCKET when it does not. `work` keeps its
189
193
  // own handler either way — a rejection arriving after the deadline already won is still
190
194
  // a rejection, and an unhandled one takes the process down.
191
- void work.catch(() => undefined);
192
- await Promise.race([work, deadline.expired]);
195
+ let settled = false;
196
+ const settle = (): void => {
197
+ settled = true;
198
+ };
199
+ void work.then(settle, settle);
200
+ try {
201
+ await Promise.race([work, deadline.expired]);
202
+ } finally {
203
+ // The deadline won and the handler is still running: it holds its pool slot and
204
+ // whatever it was writing. The caller's own count ends when this response leaves, so
205
+ // the orphan is counted in its own right until it settles — a drain that saw an idle
206
+ // process here closed the pool under it.
207
+ if (!settled) {
208
+ const done = beginWork();
209
+ void work.then(done, done);
210
+ }
211
+ }
193
212
  }
194
213
  } catch (error) {
195
214
  ctx.error = error;
@@ -228,7 +247,7 @@ export const createPipeline = (deps: PipelineDeps): Pipeline => {
228
247
  socketAddress: init.ip ?? null,
229
248
  urlProtocol: url.protocol,
230
249
  };
231
- const ctx = createRequestContext({
250
+ const ctx = requestContext({
232
251
  url,
233
252
  method: raw.method,
234
253
  role: init.role,
@@ -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);
@@ -5,7 +5,11 @@
5
5
 
6
6
  import type { HttpConfig } from './config';
7
7
  import type { Bucket, RateLimiter } from './rate-limit';
8
- import { rateLimitBucketConflict, rateLimitBucketUnbound } from './rate-limit-errors';
8
+ import {
9
+ rateLimitBucketConflict,
10
+ rateLimitBucketForPrimitive,
11
+ rateLimitBucketUnbound,
12
+ } from './rate-limit-errors';
9
13
  import type { Route } from './router';
10
14
 
11
15
  const same = (a: Bucket, b: Bucket): boolean =>
@@ -20,10 +24,30 @@ const same = (a: Bucket, b: Bucket): boolean =>
20
24
  * number an author read, an OpenAPI document published and nothing enforced, which is the exact
21
25
  * failure this seam closes. Same shape as `@ultimat3/auth`'s `AuthLimiter` policy check.
22
26
  *
23
- * Idempotent, so both construction paths (`createServer`, and `createPipeline` under it) can apply
27
+ * Idempotent, so both construction paths (`httpServer`, and `httpPipeline` under it) can apply
24
28
  * it: a second pass compares each bucket against the copy the first pass registered.
25
29
  */
30
+ /**
31
+ * A configured bucket keyed by a mounted primitive's NAME, which since 25.0.0 limits nothing.
32
+ * `default`, the tenant bucket and any name a route really selects are live, so they pass.
33
+ */
34
+ const assertNoPrimitiveBuckets = (config: HttpConfig, routes: readonly Route[]): void => {
35
+ const selected = new Set<string>(['default']);
36
+ if (config.rateLimit.tenantBucket !== null) selected.add(config.rateLimit.tenantBucket);
37
+ for (const route of routes) {
38
+ if (route.meta.rateLimit !== undefined) selected.add(route.meta.rateLimit);
39
+ }
40
+ for (const route of routes) {
41
+ const primitive = route.meta.primitive;
42
+ const name = route.meta.name;
43
+ if (primitive === undefined || selected.has(name)) continue;
44
+ if (!Object.hasOwn(config.rateLimit.buckets, name)) continue;
45
+ throw rateLimitBucketForPrimitive({ bucket: name, primitive });
46
+ }
47
+ };
48
+
26
49
  export const withRouteBuckets = (config: HttpConfig, routes: readonly Route[]): HttpConfig => {
50
+ assertNoPrimitiveBuckets(config, routes);
27
51
  const declared = new Map<string, { readonly bucket: Bucket; readonly route: string }>();
28
52
  for (const route of routes) {
29
53
  const bucket = route.meta.rateLimitBucket;
@@ -1,7 +1,7 @@
1
1
  // Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration
2
2
  // faults the boot refuses. Split from `errors.ts` at the 500-line ceiling, on the seam it already
3
- // had. The codes and their TITLES stay there, which is the one registry — `registerErrorCodes`
4
- // must see them all in a single call.
3
+ // had. The codes, their TITLES and the `retry-after` class are `error-titles.ts`'s, the one
4
+ // registry — `registerErrorCodes` must see them all in a single call.
5
5
  import { HttpError } from './errors';
6
6
 
7
7
  /**
@@ -19,19 +19,24 @@ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =
19
19
  });
20
20
 
21
21
  /**
22
- * At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means
22
+ * At `httpServer`/`httpPipeline`, never on the request. `replicas: 3` behind one config means
23
23
  * each process holds its own counters, so every configured number is enforced three times over —
24
24
  * a green `x verify` and a limit that is not the limit. The declaration is the app's because the
25
25
  * framework cannot see its replica count, and a framework that guessed would guess wrong.
26
26
  */
27
- export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
27
+ export const rateLimitNotShared = (found: 'process' | 'disabled' | 'installed'): HttpError =>
28
28
  new HttpError({
29
29
  code: 'X_RATE_LIMIT_NOT_SHARED',
30
30
  cause:
31
31
  found === 'disabled'
32
32
  ? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
33
- : "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
34
- fix: "createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
33
+ : found === 'installed'
34
+ ? "http.rateLimit.scope is 'shared' but the store actions and queries spend their declared rateLimit from keeps its counters in this process, so each replica would enforce every declared limit on its own"
35
+ : "http.rateLimit.scope is 'shared' but the installed store keeps its counters in this process, so each replica would enforce the full bucket on its own",
36
+ fix:
37
+ found === 'installed'
38
+ ? 'installRateLimitStore(rateLimitStore) at boot, with the same postgresRateLimitStore({ executor }) passed to httpServer({ routes, rateLimitStore })'
39
+ : "httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
35
40
  });
36
41
 
37
42
  /**
@@ -46,7 +51,28 @@ interface BucketNumbers {
46
51
  const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
47
52
 
48
53
  /**
49
- * Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that
54
+ * `rateLimit.buckets.<name>` where `<name>` is a mounted action's or query's own name. Since
55
+ * 25.0.0 a primitive's route carries no `meta.rateLimit` — the primitive spends its own declared
56
+ * limit on every surface — so that bucket limits nothing. Refused rather than kept: a number an
57
+ * operator reads in `configureHttp()` and nothing enforces is the failure every 12a deletion turns
58
+ * into a code. `X_CONFIG_INVALID`, borrowed, because it is a config key the framework cannot honour.
59
+ */
60
+ export const rateLimitBucketForPrimitive = (input: {
61
+ bucket: string;
62
+ primitive: 'action' | 'query';
63
+ }): HttpError =>
64
+ new HttpError({
65
+ code: 'X_CONFIG_INVALID',
66
+ cause: `rateLimit.buckets.${input.bucket} is named after the mounted ${input.primitive} "${input.bucket}", and since 25.0.0 an ${input.primitive}'s route spends no named bucket — the ${input.primitive} spends its own declared rateLimit on every surface — so this bucket would limit nothing`,
67
+ fix:
68
+ input.primitive === 'query'
69
+ ? `query({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits a query since 25.0.0 — delete it from configureHttp() and declare the limit on the query`
70
+ : `action({ …, rateLimit: { limit, windowMs } }) # rateLimit.buckets.${input.bucket} no longer limits an action since 25.0.0 — delete it from configureHttp() and declare the limit on the action`,
71
+ meta: { option: `rateLimit.buckets.${input.bucket}`, primitive: input.primitive },
72
+ });
73
+
74
+ /**
75
+ * Two declarations of one bucket, at `httpServer`/`httpPipeline`. Neither wins: an app that
50
76
  * configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
51
77
  * disagree about what is enforced, and whichever a merge picked would leave the other a number
52
78
  * someone read and nothing applies — the failure this seam exists to end. The message speaks
@@ -84,7 +110,7 @@ export const rateLimitBucketConflict = (input: {
84
110
 
85
111
  /**
86
112
  * A route declares its own bucket and the INSTALLED limiter cannot enforce it — at
87
- * `createPipeline`, never on the request. `createRateLimiter` closes over the config it was built
113
+ * `httpPipeline`, never on the request. `rateLimiter` closes over the config it was built
88
114
  * with, so a limiter constructed before the routes existed resolves the route's bucket name
89
115
  * through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
90
116
  * requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
@@ -109,7 +135,7 @@ export const rateLimitBucketUnbound = (input: {
109
135
  ? 'does not hold that bucket, so the route would run on the default one'
110
136
  : `holds ${numbers(input.found)} for it`
111
137
  }`,
112
- fix: 'pass the STORE and let the pipeline build the limiter — createServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
138
+ fix: 'pass the STORE and let the pipeline build the limiter — httpServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
113
139
  });
114
140
 
115
141
  /**
@@ -125,7 +151,7 @@ export const rateLimitScopeUnset = (): HttpError =>
125
151
  code: 'X_RATE_LIMIT_SCOPE_UNSET',
126
152
  cause:
127
153
  'http.rateLimit is enabled and the deployment has not declared http.rateLimit.scope, so the numbers below it are per replica rather than per fleet',
128
- fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
154
+ fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus httpServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
129
155
  });
130
156
 
131
157
  /**
@@ -0,0 +1,40 @@
1
+ // A rate-limit decision as the three `RateLimit-*` response headers. One rendering, so the
2
+ // pipeline's limiter, a bearer mount's per-token allowance and a primitive's declared bucket can
3
+ // never answer a client in two shapes.
4
+
5
+ import type { RequestContext } from './context';
6
+ import type { RateLimitDecision } from './rate-limit';
7
+
8
+ /**
9
+ * Lowercase names, as every writer sets them on `ctx.headers`. The reset is whole seconds from
10
+ * `nowMs`, floored at 0: a decision read after its own reset passed is a full bucket, not a
11
+ * negative wait.
12
+ */
13
+ export const rateLimitHeaders = (
14
+ decision: RateLimitDecision,
15
+ nowMs: number,
16
+ ): Record<string, string> => ({
17
+ 'ratelimit-limit': String(decision.limit),
18
+ 'ratelimit-remaining': String(decision.remaining),
19
+ 'ratelimit-reset': String(Math.max(0, Math.ceil((decision.resetAtMs - nowMs) / 1000))),
20
+ });
21
+
22
+ /**
23
+ * A handler-spent bucket onto the answer (`RouteMeta.rateLimitedBy: 'handler'`), when it is the
24
+ * one closest to refusing — the rule the `rate-limit` stage follows across its own buckets:
25
+ * `remaining: 118` off the tenant's while the primitive's holds 1 tells a client to proceed and
26
+ * then refuses its next call. A refusal always wins. Set on the CONTEXT before the handler throws,
27
+ * so the `error-map` stage's 429 carries these headers and reads `Retry-After` off `ctx.rateLimit`.
28
+ */
29
+ export const publishRateLimit = (
30
+ ctx: RequestContext,
31
+ decision: RateLimitDecision,
32
+ nowMs: number,
33
+ ): void => {
34
+ const prior = ctx.rateLimit;
35
+ if (decision.allowed && prior !== undefined && prior.remaining < decision.remaining) return;
36
+ ctx.rateLimit = decision;
37
+ for (const [name, value] of Object.entries(rateLimitHeaders(decision, nowMs))) {
38
+ ctx.headers.set(name, value);
39
+ }
40
+ };
@@ -0,0 +1,79 @@
1
+ // The store a PRIMITIVE's declared `rateLimit:` is counted in — `@ultimat3/action`'s `invoke` and
2
+ // `@ultimat3/query`'s read path both spend from it, on every surface. One slot, here, so the two
3
+ // tier-3 packages (which cannot import each other) count in one place, and the boot fills it with
4
+ // the same instance it hands `httpServer({ rateLimitStore })`.
5
+
6
+ import type { RateLimitConfig, RateLimitStore } from './rate-limit';
7
+ import { memoryRateLimitStore } from './rate-limit';
8
+ import { rateLimitNotShared } from './rate-limit-errors';
9
+
10
+ /** What a process falls back to when nothing chose: one process' memory. */
11
+ let base: RateLimitStore = memoryRateLimitStore();
12
+ let baseChosen = false;
13
+ /**
14
+ * Adopted stores, newest last. A STACK of frames, each popped only by its own adopter: two
15
+ * servers started A-then-B and stopped A-then-B used to have A's stop restore what A replaced —
16
+ * reverting the slot under B while B still served. A frame object is its own identity, so the
17
+ * same store adopted twice is two frames.
18
+ */
19
+ const frames: { readonly store: RateLimitStore }[] = [];
20
+
21
+ const current = (): RateLimitStore => frames.at(-1)?.store ?? base;
22
+ const isChosen = (): boolean => baseChosen || frames.length > 0;
23
+
24
+ /**
25
+ * The embedder's one-line install, for a process that builds its own boot: the base everything
26
+ * else stacks over. Left at the default it is one process' memory — right for dev and tests, and
27
+ * N × every declared limit behind N replicas, which `assertInstalledRateLimitScope` refuses under
28
+ * a `'shared'` declaration. A boot that has to give the slot back uses `adoptRateLimitStore`.
29
+ */
30
+ export const installRateLimitStore = (store: RateLimitStore): void => {
31
+ base = store;
32
+ baseChosen = true;
33
+ };
34
+
35
+ export const installedRateLimitStore = (): RateLimitStore => current();
36
+
37
+ /** Test-only. Every frame and the base, back to one process' memory. */
38
+ export const resetRateLimitStore = (): void => {
39
+ frames.length = 0;
40
+ base = memoryRateLimitStore();
41
+ baseChosen = false;
42
+ };
43
+
44
+ export interface AdoptRateLimitStoreOptions {
45
+ /**
46
+ * `httpServer`'s rule: never over a store something already CHOSE. The boot installs its own
47
+ * before any server exists, and a server handed a different one must not move every action's
48
+ * counters out from under it — the chosen one stands, and `assertInstalledRateLimitScope` still
49
+ * holds it to the declaration.
50
+ */
51
+ readonly keepChosen?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Push `store` as the one primitives spend from, and answer the undo that removes THIS frame and
56
+ * no other — whatever order the adopters stop in. The boot (`startServices`, `startRoles`) and
57
+ * `httpServer` all go through it, so every install has a matching, order-proof release.
58
+ */
59
+ export const adoptRateLimitStore = (
60
+ store: RateLimitStore,
61
+ options: AdoptRateLimitStoreOptions = {},
62
+ ): (() => void) => {
63
+ if (options.keepChosen === true && isChosen() && current() !== store) return () => undefined;
64
+ const frame = { store };
65
+ frames.push(frame);
66
+ return () => {
67
+ const at = frames.indexOf(frame);
68
+ if (at !== -1) frames.splice(at, 1);
69
+ };
70
+ };
71
+
72
+ /**
73
+ * `assertRateLimitScope`'s rule for the primitives' half: a `'shared'` declaration says every
74
+ * number is the fleet's, and an action or query limit counted in one process' memory is not.
75
+ */
76
+ export const assertInstalledRateLimitScope = (config: RateLimitConfig): void => {
77
+ if (config.scope !== 'shared') return;
78
+ if (current().scope !== 'shared') throw rateLimitNotShared('installed');
79
+ };
@@ -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);