@ultimat3/http 7.0.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -144,6 +144,14 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
144
144
  because `@ultimat3/action`'s `invoke` loads the row a row-level rule reads and this stage
145
145
  cannot. Deciding in both places is two authz systems, and the one that answers first is the
146
146
  one holding less.
147
+ - **A 403's `fix:` names the POLICY, never the pathname** (`As of 2026-08`). `forbidden` emitted
148
+ `x policy explain ${ctx.url.pathname}`, and `x policy explain` resolves a policy SUBJECT — a
149
+ permission, an action name or a query name. A page pathname is none of them, so the one command
150
+ the error told the reader to run exited `X_DECLARATION_UNKNOWN` (`x policy explain /settings`,
151
+ reproduced in `examples/dummy`). The third argument is `route.meta.policy`, which is what the
152
+ `authz` stage was evaluating and what the index can resolve; anything that is not a bare
153
+ `resource:verb` — a composite renders `and(a:b, c:d)` — degrades to `x routes --json`, the shape
154
+ `bodyInvalid` already uses. A fix that names the wrong thing is not a fix.
147
155
  - **`ctx.actor` is never null.** `asCtx` publishes the request context itself as core's `Ctx`,
148
156
  and `Ctx.actor` is an `Actor` — so "nobody" is core's anonymous actor, not `null`. The
149
157
  `authenticate` hook still says it with `null`; the `auth` stage is where that becomes
@@ -280,6 +288,19 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
280
288
  supported way to install one is `createServer({ rateLimitStore })`, which builds the limiter
281
289
  through `createRateLimiter` and hands it to the `PipelineDeps.limiter` seam that already
282
290
  existed; never add a second limiter entry point beside it.
291
+ - **The shared store is `postgresRateLimitStore({ executor })`, and it is what makes
292
+ `scope: 'shared'` satisfiable** (`As of 2026-08`). Before it, `assertRateLimitScope` refused
293
+ every store the framework shipped, so the declaration required by a chart with `replicas: 3` had no
294
+ answer. `PgExecutor` is declared STRUCTURALLY here, exactly as `@ultimat3/action`'s idempotency
295
+ store declares it: this package has no `@ultimat3/db` dependency, and taking one to type a single
296
+ method would put the database package in http's install graph. The refill expression is repeated
297
+ four times inside `on conflict do update` **on purpose** — only a direct `x_rate_limit.<column>`
298
+ reference reads the row as it is after the lock, so a CTE computing it once would compute from
299
+ the statement's own snapshot and lose a concurrent spend. `spent` is a stored column because the
300
+ token count alone cannot tell a take that landed at 0.5 from a refusal with 0.5 left, and the
301
+ invented answer would be "allowed". `purgeExpired(nowMs)` takes the CALLER's clock and never
302
+ `now()`: `last_ms` is written from the caller's, so measuring against the server's reads the
303
+ offset between the two as refill and deletes buckets a throttled caller is still sitting in.
283
304
  - **A bucket a route names is a bucket something must register.** `meta.rateLimit` selects by
284
305
  name and `meta.rateLimitBucket` carries the numbers; `withRouteBuckets` (`rate-limit-buckets.ts`)
285
306
  merges them into `config.rateLimit.buckets` at construction, in `createServer` and again in
@@ -336,6 +357,8 @@ Owned request lifecycle over `Bun.serve`. Tier 2.
336
357
  | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |
337
358
  | `cache-policy.ts` | the default `CacheHint` for a route that declared none — route AND actor |
338
359
  | `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` |
360
+ | `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` |
361
+ | `rate-limit-errors.ts` | every refusal a rate limit produces — the 429 and the six declaration faults. Split off `errors.ts` at the ceiling; the codes and titles stay there, one registry |
339
362
  | `correlation.ts` | the inbound request id and trace, read before the context and the span exist |
340
363
  | `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes |
341
364
  | `peer-identity.ts` | Envoy XFCC -> `ctx.peer`, on that same trust rule |
package/README.md CHANGED
@@ -97,8 +97,46 @@ the one nobody declared. A limiter with `enabled: false` owes no declaration: no
97
97
  so nothing can be wrong.
98
98
 
99
99
  `rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket
100
- maths stays in `createRateLimiter`, so every driver agrees on the numbers. **No shared store ships
101
- yet, `As of 2026-08`** — `memoryRateLimitStore()` is the only implementation in the framework.
100
+ maths stays in `createRateLimiter`, so every driver agrees on the numbers.
101
+
102
+ **A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table
103
+ and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed,
104
+ `scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded
105
+ `replicas: 2`. `executor` is a `PgExecutor` — anything speaking `query(text, values)`, which is one
106
+ line over the client the boot already opened; **never `Bun.sql`**, whose `.query` is `undefined`.
107
+
108
+ ```ts
109
+ import { db, type SqlFragment } from '@ultimat3/db';
110
+ import {
111
+ createServer,
112
+ defineHttpConfig,
113
+ type PgExecutor,
114
+ postgresRateLimitStore,
115
+ type Route,
116
+ } from '@ultimat3/http';
117
+
118
+ declare const routes: readonly Route[];
119
+
120
+ // The client this process already opened, wrapped in one line. `@ultimat3/cli`'s `pgExecutorFor`
121
+ // is this exact function, and it is what the boot passes when it installs the store for you.
122
+ const client = db();
123
+ const executor: PgExecutor = {
124
+ query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
125
+ client.query<R>({ text, values } satisfies SqlFragment),
126
+ };
127
+
128
+ createServer({
129
+ routes,
130
+ config: defineHttpConfig({ rateLimit: { scope: 'shared' } }),
131
+ rateLimitStore: postgresRateLimitStore({ executor }),
132
+ });
133
+ ```
134
+
135
+ The table bounds itself only when something asks it to: `store.purgeExpired(ctx.now().getTime())`
136
+ from a `task` drops every bucket that has refilled to capacity, which is the memory store's forget
137
+ rule. `nowMs` is required and must come from the same clock the takes use — measured against the
138
+ server's clock instead, the offset between the two reads as refill and deletes buckets a throttled
139
+ caller is still sitting in.
102
140
 
103
141
  The maths reads an injected `Clock`, defaulting to `systemClock`: `createRateLimiter({ config,
104
142
  clock })`. **Breaking, `As of 2026-08-19`** — it took `now?: () => number` before and read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/http",
3
- "version": "7.0.0",
3
+ "version": "9.0.0",
4
4
  "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "7.0.0",
35
- "@ultimat3/i18n": "7.0.0",
36
- "@ultimat3/schema": "7.0.0",
37
- "@ultimat3/time": "7.0.0"
34
+ "@ultimat3/core": "9.0.0",
35
+ "@ultimat3/i18n": "9.0.0",
36
+ "@ultimat3/schema": "9.0.0",
37
+ "@ultimat3/time": "9.0.0"
38
38
  }
39
39
  }
package/src/error-map.ts CHANGED
@@ -52,6 +52,8 @@ export const ERROR_STATUS = {
52
52
  X_TRUST_PROXY_UNSET: 500,
53
53
  // Raised by `toBucket` while a route or an action is being projected, never on the request.
54
54
  X_RATE_LIMIT_INVALID: 500,
55
+ // The shared store did not answer, so nothing decided. An operator's fault, never the caller's.
56
+ X_RATE_LIMIT_STORE_UNAVAILABLE: 500,
55
57
  // The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry
56
58
  // `retry-after`: a shed request that does not say when to come back is a request that comes
57
59
  // back immediately, which is the load it was shed to avoid.
@@ -79,6 +81,12 @@ export const ERROR_STATUS = {
79
81
  // that attempt failed carrying no code at all: an unclassified throw whose commit state nobody
80
82
  // knows. That is the server's to explain, and it is worth reporting.
81
83
  X_IDEMPOTENCY_REPLAYED_FAILURE: 500,
84
+ // Same shape as the line above and 500 for the same reason: the store holds a record this
85
+ // build cannot turn into a result. Deliberately NOT 503 — a rolling deploy is the usual
86
+ // cause, so a retry may well reach a newer pod and succeed, but this code carries no
87
+ // `retry-after` and the two 503s above are the only ones that do. Telling a caller to come
88
+ // back without saying when is the load-shedding mistake, one layer up.
89
+ X_IDEMPOTENCY_STATUS_UNKNOWN: 500,
82
90
  // @ultimat3/auth — every one of these is reachable from a request: the OAuth route descriptors
83
91
  // are mounted by the app, and `authenticate` throws the session codes inside the pipeline. Without
84
92
  // a row each fell to 500, so a user pressing Cancel on a consent screen paged the on-call and a
package/src/errors.ts CHANGED
@@ -28,6 +28,7 @@ export const HTTP_OWNED_ERROR_CODES = [
28
28
  'X_RATE_LIMIT_BUCKET_UNBOUND',
29
29
  'X_RATE_LIMIT_SCOPE_UNSET',
30
30
  'X_RATE_LIMIT_INVALID',
31
+ 'X_RATE_LIMIT_STORE_UNAVAILABLE',
31
32
  'X_TRUST_PROXY_UNSET',
32
33
  'X_OVERLOADED',
33
34
  'X_CSRF_BLOCKED',
@@ -79,6 +80,7 @@ export const HTTP_ERROR_TITLES: Readonly<Record<HttpOwnedErrorCode, string>> = {
79
80
  X_RATE_LIMIT_BUCKET_UNBOUND: 'the installed limiter cannot enforce a bucket a route declares',
80
81
  X_RATE_LIMIT_SCOPE_UNSET: 'the deployment has not said where the rate limiter keeps its counters',
81
82
  X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on',
83
+ X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
82
84
  X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',
83
85
  X_OVERLOADED: 'in-flight requests are at the configured ceiling',
84
86
  X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
@@ -191,25 +193,29 @@ export const unauthenticated = (pathname: string): HttpError =>
191
193
  fix: "send a session cookie or Authorization header, or set meta.auth to 'public'",
192
194
  });
193
195
 
194
- export const forbidden = (pathname: string, reason: string): HttpError =>
195
- new HttpError({
196
- code: 'X_FORBIDDEN',
197
- cause: `${pathname} denied: ${reason}`,
198
- fix: `x policy explain ${pathname} --json # shows which clause denied`,
199
- });
196
+ /**
197
+ * `x policy explain` resolves a policy SUBJECT — a permission, an action name or a query name.
198
+ * A route pathname is none of those, and the only callers of this factory (`stages.ts`' `authz`)
199
+ * had nothing but `ctx.url.pathname` to hand it: `x policy explain /settings` exits
200
+ * `X_DECLARATION_UNKNOWN`, so the one command a 403 told the reader to run was the one command
201
+ * that could not work. `route.meta.policy` is what the stage was evaluating and what the index
202
+ * can resolve, so that is the argument.
203
+ */
204
+ const POLICY_SUBJECT = /^[a-z0-9_-]+:[a-z0-9_-]+$/;
200
205
 
201
206
  /**
202
- * The KEY never reaches the caller. `rateLimitKey` is `${routeName}|org:${orgId}`or
203
- * `actor:${actorId}` so the old cause handed an anonymous caller promoted to an org bucket the
204
- * internal org id, in a 429 anyone can provoke. It rides in `meta`, which the problem document
205
- * does not render and the error reporter does.
207
+ * A composite policy renders `and(a:b, c:d)`, which is not a subject either so anything that is
208
+ * not a bare `resource:verb` degrades to the route table, the shape `bodyInvalid` above uses. A
209
+ * fix that names the wrong thing is not a fix; a fix that resolves is.
206
210
  */
207
- export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =>
211
+ export const forbidden = (pathname: string, reason: string, policy?: string): HttpError =>
208
212
  new HttpError({
209
- code: 'X_RATE_LIMITED',
210
- cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`,
211
- fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts',
212
- meta: { key, retryAfterSeconds },
213
+ code: 'X_FORBIDDEN',
214
+ cause: `${pathname} denied: ${reason}`,
215
+ fix:
216
+ policy !== undefined && POLICY_SUBJECT.test(policy)
217
+ ? `x policy explain ${policy} --json # shows which clause denied`
218
+ : `x routes --json # find ${pathname}, then read the policy it declares`,
213
219
  });
214
220
 
215
221
  export const buildSkew = (clientBuildId: string, serverBuildId: string): HttpError =>
@@ -280,100 +286,6 @@ export const corsConfigInvalid = (reason: string): HttpError =>
280
286
  fix: "in app.config.ts set http.cors.credentials: false, or replace http.cors.origins: ['*'] with the exact origins allowed to call this app",
281
287
  });
282
288
 
283
- /**
284
- * At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means
285
- * each process holds its own counters, so every configured number is enforced three times over —
286
- * a green `x verify` and a limit that is not the limit. The declaration is the app's because the
287
- * framework cannot see its replica count, and a framework that guessed would guess wrong.
288
- */
289
- export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
290
- new HttpError({
291
- code: 'X_RATE_LIMIT_NOT_SHARED',
292
- cause:
293
- found === 'disabled'
294
- ? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere"
295
- : "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",
296
- fix: "pass a store whose scope is 'shared' — createServer({ routes, rateLimitStore }) — or set http.rateLimit.scope: 'process' in app.config.ts to accept per-replica limits",
297
- });
298
-
299
- /**
300
- * The numbers of one bucket, spelled structurally so `errors.ts` stays free of an import from
301
- * `rate-limit.ts` — which imports this file.
302
- */
303
- interface BucketNumbers {
304
- readonly capacity: number;
305
- readonly refillPerSecond: number;
306
- }
307
-
308
- const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
309
-
310
- /**
311
- * Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that
312
- * configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
313
- * disagree about what is enforced, and whichever a merge picked would leave the other a number
314
- * someone read and nothing applies — the failure this seam exists to end. The message speaks
315
- * capacity and refill rather than the `limit`/`windowMs` an action declares, because that is what
316
- * the limiter runs on; `toBucket` (`rate-limit.ts`, this package) is the conversion between them —
317
- * it lives here because http owns `Bucket` and the maths, and both tier-3 callers need it.
318
- */
319
- export const rateLimitBucketConflict = (input: {
320
- bucket: string;
321
- /** `null` when the other declaration is `app.config.ts` rather than a second route. */
322
- otherRoute: string | null;
323
- route: string;
324
- other: BucketNumbers;
325
- declared: BucketNumbers;
326
- }): HttpError =>
327
- new HttpError({
328
- code: 'X_RATE_LIMIT_BUCKET_CONFLICT',
329
- cause: `bucket "${input.bucket}" has two declarations: ${
330
- input.otherRoute === null
331
- ? 'http.rateLimit.buckets in app.config.ts'
332
- : `route "${input.otherRoute}"`
333
- } says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${
334
- input.otherRoute === null
335
- ? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and app.config.ts is not where to say so`
336
- : ''
337
- }`,
338
- // One edit, named. Two joined by "or" leaves the reader to decide which declaration is
339
- // authoritative — and the route is, always: it sits beside the handler and it is what the
340
- // OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale.
341
- fix:
342
- input.otherRoute === null
343
- ? `delete http.rateLimit.buckets.${input.bucket} from app.config.ts — the route's declaration is the one the OpenAPI operation publishes, so edit the numbers there if ${numbers(input.declared)} is wrong`
344
- : `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`,
345
- });
346
-
347
- /**
348
- * A route declares its own bucket and the INSTALLED limiter cannot enforce it — at
349
- * `createPipeline`, never on the request. `createRateLimiter` closes over the config it was built
350
- * with, so a limiter constructed before the routes existed resolves the route's bucket name
351
- * through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
352
- * requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
353
- *
354
- * Refused rather than rebound, for two reasons. A `RateLimiter` is opaque — no store and no table
355
- * are reachable through it — so "binding" it would mean discarding the caller's limiter and the
356
- * store it carries, which is a different silent failure. And a caller who built their own limiter
357
- * may have meant their own numbers; picking for them is the precedence mistake
358
- * `X_RATE_LIMIT_BUCKET_CONFLICT` exists to refuse.
359
- */
360
- export const rateLimitBucketUnbound = (input: {
361
- bucket: string;
362
- route: string;
363
- declared: BucketNumbers;
364
- /** What the limiter holds under that name, or `null` for "holds nothing / declares no table". */
365
- found: BucketNumbers | null;
366
- }): HttpError =>
367
- new HttpError({
368
- code: 'X_RATE_LIMIT_BUCKET_UNBOUND',
369
- cause: `route "${input.route}" declares bucket "${input.bucket}" as ${numbers(input.declared)} (capacity / refill per second) and the installed limiter ${
370
- input.found === null
371
- ? 'does not hold that bucket, so the route would run on the default one'
372
- : `holds ${numbers(input.found)} for it`
373
- }`,
374
- fix: 'pass the STORE and let the pipeline build the limiter — createServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered',
375
- });
376
-
377
289
  export const routeConflict = (path: string, detail: string): HttpError =>
378
290
  new HttpError({
379
291
  code: 'X_ROUTE_CONFLICT',
@@ -381,40 +293,6 @@ export const routeConflict = (path: string, detail: string): HttpError =>
381
293
  fix: `x routes list --json # remove or rename one of the two routes at ${path}`,
382
294
  });
383
295
 
384
- /**
385
- * At `defineHttpConfig`, never on the request. `scope` used to DEFAULT to `'process'`, so an app
386
- * that declared nothing enforced every configured number once per replica — three times over on
387
- * the chart this repo ships — with a green `x verify` and nothing to read. The boot check that
388
- * catches the other half (`assertRateLimitScope`) only fires for an app that said `'shared'`, so
389
- * the silent case was exactly the one nobody declared. One process is still a legal answer; it is
390
- * no longer an assumed one.
391
- */
392
- export const rateLimitScopeUnset = (): HttpError =>
393
- new HttpError({
394
- code: 'X_RATE_LIMIT_SCOPE_UNSET',
395
- cause:
396
- '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',
397
- fix: "in app.config.ts set http.rateLimit.scope: 'process' if this app runs as ONE replica, or 'shared' plus createServer({ routes, rateLimitStore }) for a fleet-wide limit",
398
- });
399
-
400
- /**
401
- * A `{ limit, windowMs }` pair the limiter cannot run on. Raised by `toBucket` (`rate-limit.ts`),
402
- * which lives in this PACKAGE because http owns `Bucket` and the maths, and two tier-3 packages
403
- * (`action`, `query`) need the same conversion without importing each other.
404
- */
405
- export const rateLimitInvalid = (input: {
406
- readonly owner: string;
407
- readonly limit: number;
408
- readonly windowMs: number;
409
- readonly reason: string;
410
- }): HttpError =>
411
- new HttpError({
412
- code: 'X_RATE_LIMIT_INVALID',
413
- cause: `"${input.owner}" declares rateLimit { limit: ${input.limit}, windowMs: ${input.windowMs} }: ${input.reason}`,
414
- fix: `edit the \`rateLimit:\` on ${input.owner} to a whole allowance over a real window — e.g. { limit: 5, windowMs: 600_000 } for five per ten minutes — or delete it to keep the default bucket`,
415
- meta: { owner: input.owner, limit: input.limit, windowMs: input.windowMs },
416
- });
417
-
418
296
  /**
419
297
  * At `defineHttpConfig`. `trustProxy` is a claim about the DEPLOYMENT — that something in front
420
298
  * rewrites `x-forwarded-for` — and the leftmost value in that header is whatever the client
package/src/index.ts CHANGED
@@ -52,12 +52,6 @@ export {
52
52
  overloaded,
53
53
  pathInvalid,
54
54
  pipelineNoResponse,
55
- rateLimitBucketConflict,
56
- rateLimitBucketUnbound,
57
- rateLimited,
58
- rateLimitInvalid,
59
- rateLimitNotShared,
60
- rateLimitScopeUnset,
61
55
  requestTimedOut,
62
56
  routeConflict,
63
57
  routeNotFound,
@@ -105,11 +99,33 @@ export {
105
99
  DEFAULT_MAX_RATE_LIMIT_KEYS,
106
100
  DEFAULT_RATE_LIMIT,
107
101
  memoryRateLimitStore,
102
+ rateLimitDecision,
108
103
  rateLimitKey,
109
104
  resolveRateLimitConfig,
110
105
  toBucket,
111
106
  } from './rate-limit';
112
107
  export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets';
108
+ export {
109
+ rateLimitBucketConflict,
110
+ rateLimitBucketUnbound,
111
+ rateLimited,
112
+ rateLimitInvalid,
113
+ rateLimitNotShared,
114
+ rateLimitScopeUnset,
115
+ rateLimitStoreUnavailable,
116
+ } from './rate-limit-errors';
117
+ export type {
118
+ PgExecutor,
119
+ PostgresRateLimitStore,
120
+ PostgresRateLimitStoreOptions,
121
+ } from './rate-limit-postgres';
122
+ export {
123
+ postgresRateLimitStore,
124
+ SQL_RATE_LIMIT_PURGE,
125
+ SQL_RATE_LIMIT_RESET,
126
+ SQL_RATE_LIMIT_TABLE,
127
+ SQL_RATE_LIMIT_TAKE,
128
+ } from './rate-limit-postgres';
113
129
  export { setRedirect, takeRedirect } from './redirect';
114
130
  export type { QueryValues } from './request';
115
131
  export { UltimateRequest } from './request';
@@ -4,8 +4,8 @@
4
4
  // and a bucket name with nothing behind it falls through `bucketFor` to `default`.
5
5
 
6
6
  import type { HttpConfig } from './config';
7
- import { rateLimitBucketConflict, rateLimitBucketUnbound } from './errors';
8
7
  import type { Bucket, RateLimiter } from './rate-limit';
8
+ import { rateLimitBucketConflict, rateLimitBucketUnbound } from './rate-limit-errors';
9
9
  import type { Route } from './router';
10
10
 
11
11
  const same = (a: Bucket, b: Bucket): boolean =>
@@ -0,0 +1,163 @@
1
+ // Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration
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.
5
+ import { HttpError } from './errors';
6
+
7
+ /**
8
+ * The KEY never reaches the caller. `rateLimitKey` is `${routeName}|org:${orgId}` — or
9
+ * `actor:${actorId}` — so the old cause handed an anonymous caller promoted to an org bucket the
10
+ * internal org id, in a 429 anyone can provoke. It rides in `meta`, which the problem document
11
+ * does not render and the error reporter does.
12
+ */
13
+ export const rateLimited = (key: string, retryAfterSeconds: number): HttpError =>
14
+ new HttpError({
15
+ code: 'X_RATE_LIMITED',
16
+ cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`,
17
+ fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts',
18
+ meta: { key, retryAfterSeconds },
19
+ });
20
+
21
+ /**
22
+ * At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means
23
+ * each process holds its own counters, so every configured number is enforced three times over —
24
+ * a green `x verify` and a limit that is not the limit. The declaration is the app's because the
25
+ * framework cannot see its replica count, and a framework that guessed would guess wrong.
26
+ */
27
+ export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError =>
28
+ new HttpError({
29
+ code: 'X_RATE_LIMIT_NOT_SHARED',
30
+ cause:
31
+ found === 'disabled'
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 set http.rateLimit.scope: 'process' in app.config.ts to accept per-replica limits",
35
+ });
36
+
37
+ /**
38
+ * The numbers of one bucket, spelled structurally so `errors.ts` stays free of an import from
39
+ * `rate-limit.ts` — which imports this file.
40
+ */
41
+ interface BucketNumbers {
42
+ readonly capacity: number;
43
+ readonly refillPerSecond: number;
44
+ }
45
+
46
+ const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`;
47
+
48
+ /**
49
+ * Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that
50
+ * configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name
51
+ * disagree about what is enforced, and whichever a merge picked would leave the other a number
52
+ * someone read and nothing applies — the failure this seam exists to end. The message speaks
53
+ * capacity and refill rather than the `limit`/`windowMs` an action declares, because that is what
54
+ * the limiter runs on; `toBucket` (`rate-limit.ts`, this package) is the conversion between them —
55
+ * it lives here because http owns `Bucket` and the maths, and both tier-3 callers need it.
56
+ */
57
+ export const rateLimitBucketConflict = (input: {
58
+ bucket: string;
59
+ /** `null` when the other declaration is `app.config.ts` rather than a second route. */
60
+ otherRoute: string | null;
61
+ route: string;
62
+ other: BucketNumbers;
63
+ declared: BucketNumbers;
64
+ }): HttpError =>
65
+ new HttpError({
66
+ code: 'X_RATE_LIMIT_BUCKET_CONFLICT',
67
+ cause: `bucket "${input.bucket}" has two declarations: ${
68
+ input.otherRoute === null
69
+ ? 'http.rateLimit.buckets in app.config.ts'
70
+ : `route "${input.otherRoute}"`
71
+ } says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${
72
+ input.otherRoute === null
73
+ ? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and app.config.ts is not where to say so`
74
+ : ''
75
+ }`,
76
+ // One edit, named. Two joined by "or" leaves the reader to decide which declaration is
77
+ // authoritative — and the route is, always: it sits beside the handler and it is what the
78
+ // OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale.
79
+ fix:
80
+ input.otherRoute === null
81
+ ? `delete http.rateLimit.buckets.${input.bucket} from app.config.ts — the route's declaration is the one the OpenAPI operation publishes, so edit the numbers there if ${numbers(input.declared)} is wrong`
82
+ : `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`,
83
+ });
84
+
85
+ /**
86
+ * 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
88
+ * with, so a limiter constructed before the routes existed resolves the route's bucket name
89
+ * through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21
90
+ * requests allowed for a route declaring 5. Silent, and looser than what the author wrote.
91
+ *
92
+ * Refused rather than rebound, for two reasons. A `RateLimiter` is opaque — no store and no table
93
+ * are reachable through it — so "binding" it would mean discarding the caller's limiter and the
94
+ * store it carries, which is a different silent failure. And a caller who built their own limiter
95
+ * may have meant their own numbers; picking for them is the precedence mistake
96
+ * `X_RATE_LIMIT_BUCKET_CONFLICT` exists to refuse.
97
+ */
98
+ export const rateLimitBucketUnbound = (input: {
99
+ bucket: string;
100
+ route: string;
101
+ declared: BucketNumbers;
102
+ /** What the limiter holds under that name, or `null` for "holds nothing / declares no table". */
103
+ found: BucketNumbers | null;
104
+ }): HttpError =>
105
+ new HttpError({
106
+ code: 'X_RATE_LIMIT_BUCKET_UNBOUND',
107
+ cause: `route "${input.route}" declares bucket "${input.bucket}" as ${numbers(input.declared)} (capacity / refill per second) and the installed limiter ${
108
+ input.found === null
109
+ ? 'does not hold that bucket, so the route would run on the default one'
110
+ : `holds ${numbers(input.found)} for it`
111
+ }`,
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',
113
+ });
114
+
115
+ /**
116
+ * At `defineHttpConfig`, never on the request. `scope` used to DEFAULT to `'process'`, so an app
117
+ * that declared nothing enforced every configured number once per replica — three times over on
118
+ * the chart this repo ships — with a green `x verify` and nothing to read. The boot check that
119
+ * catches the other half (`assertRateLimitScope`) only fires for an app that said `'shared'`, so
120
+ * the silent case was exactly the one nobody declared. One process is still a legal answer; it is
121
+ * no longer an assumed one.
122
+ */
123
+ export const rateLimitScopeUnset = (): HttpError =>
124
+ new HttpError({
125
+ code: 'X_RATE_LIMIT_SCOPE_UNSET',
126
+ cause:
127
+ '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: "in app.config.ts set http.rateLimit.scope: 'process' if this app runs as ONE replica, or 'shared' plus createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit",
129
+ });
130
+
131
+ /**
132
+ * The shared store ran its statement and answered nothing. An `insert … on conflict … returning`
133
+ * always yields one row, so this is a driver that is not running what it was handed — a wrapped
134
+ * client that swallows `returning`, or a pooler in a mode that discards it.
135
+ *
136
+ * 500 and never an allowed request: the invented decision would have to be "allowed", which is the
137
+ * limiter switched off with nothing saying so. `@ultimat3/action`'s idempotency store makes the
138
+ * same call in the same direction for the same reason.
139
+ */
140
+ export const rateLimitStoreUnavailable = (statement: string): HttpError =>
141
+ new HttpError({
142
+ code: 'X_RATE_LIMIT_STORE_UNAVAILABLE',
143
+ cause: `the shared rate-limit store answered no row for its ${statement} statement, so no limit was applied to this request`,
144
+ fix: 'psql "$DATABASE_URL" -c "select * from x_rate_limit limit 1" # then confirm the PgExecutor passed to postgresRateLimitStore returns the rows of `returning`',
145
+ });
146
+
147
+ /**
148
+ * A `{ limit, windowMs }` pair the limiter cannot run on. Raised by `toBucket` (`rate-limit.ts`),
149
+ * which lives in this PACKAGE because http owns `Bucket` and the maths, and two tier-3 packages
150
+ * (`action`, `query`) need the same conversion without importing each other.
151
+ */
152
+ export const rateLimitInvalid = (input: {
153
+ readonly owner: string;
154
+ readonly limit: number;
155
+ readonly windowMs: number;
156
+ readonly reason: string;
157
+ }): HttpError =>
158
+ new HttpError({
159
+ code: 'X_RATE_LIMIT_INVALID',
160
+ cause: `"${input.owner}" declares rateLimit { limit: ${input.limit}, windowMs: ${input.windowMs} }: ${input.reason}`,
161
+ fix: `edit the \`rateLimit:\` on ${input.owner} to a whole allowance over a real window — e.g. { limit: 5, windowMs: 600_000 } for five per ten minutes — or delete it to keep the default bucket`,
162
+ meta: { owner: input.owner, limit: input.limit, windowMs: input.windowMs },
163
+ });
@@ -0,0 +1,202 @@
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`.
4
+ // Statements are spelled out so an agent can run the exact one it saw in a log.
5
+
6
+ import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit';
7
+ import { rateLimitDecision } from './rate-limit';
8
+ 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
+ }
24
+
25
+ /**
26
+ * Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows,
27
+ * so `x dev`, the container's `web` role and the release-phase `ROLE=migrate` all apply it.
28
+ *
29
+ * `capacity` and `refill_per_second` are STORED, though every take passes them: they are what
30
+ * makes `purgeExpired` able to ask "has this bucket refilled to full?" — the same question the
31
+ * memory store answers with `forgetAtMs` — instead of guessing an idle TTL that is wrong for any
32
+ * app declaring a window longer than the guess.
33
+ */
34
+ export const SQL_RATE_LIMIT_TABLE = `
35
+ create table if not exists x_rate_limit (
36
+ key text primary key,
37
+ tokens double precision not null,
38
+ capacity double precision not null,
39
+ refill_per_second double precision not null,
40
+ last_ms bigint not null,
41
+ spent boolean not null,
42
+ updated_at timestamptz not null default now()
43
+ );
44
+
45
+ create index if not exists x_rate_limit_updated_at_idx on x_rate_limit (updated_at);
46
+ `;
47
+
48
+ /**
49
+ * What the bucket holds after the elapsed refill, before this caller spends anything — capped at
50
+ * `capacity`, and never negative elapsed, so a replica whose clock runs behind grants nothing.
51
+ *
52
+ * It appears four times in the statement below and that repetition is REQUIRED, not sloppiness.
53
+ * Only a direct `x_rate_limit.<column>` reference inside `on conflict do update` reads the row as
54
+ * it is after the lock is taken; a CTE computing it once would read the statement's own snapshot,
55
+ * so two concurrent takes would each compute from the same pre-refill row and one spend would be
56
+ * lost — a free request per race, on the control that exists to refuse them.
57
+ */
58
+ const REFILLED =
59
+ 'least($2::double precision, x_rate_limit.tokens + ' +
60
+ 'greatest(0, ($5::bigint - x_rate_limit.last_ms))::double precision / 1000 * $3::double precision)';
61
+
62
+ /**
63
+ * `$1` key, `$2` capacity, `$3` refill per second, `$4` cost, `$5` the caller's `nowMs`.
64
+ *
65
+ * `spent` is persisted because the token count alone cannot answer the caller: a take that landed
66
+ * at 0.5 and a refusal that left 0.5 are the same number, and `rateLimitDecision` needs the
67
+ * verdict to compute `retryAfterSeconds`. `last_ms` only ever moves FORWARD (`greatest`) — two
68
+ * replicas do not share a clock, and a `last_ms` dragged backwards hands the next take a longer
69
+ * elapsed than really passed, which is refill the caller did not earn.
70
+ */
71
+ export const SQL_RATE_LIMIT_TAKE = `
72
+ insert into x_rate_limit (key, tokens, capacity, refill_per_second, last_ms, spent)
73
+ values (
74
+ $1,
75
+ case when $2::double precision >= $4::double precision
76
+ then $2::double precision - $4::double precision
77
+ else $2::double precision end,
78
+ $2::double precision, $3::double precision, $5::bigint,
79
+ $2::double precision >= $4::double precision
80
+ )
81
+ on conflict (key) do update
82
+ set tokens = case
83
+ when ${REFILLED} >= $4::double precision
84
+ then ${REFILLED} - $4::double precision
85
+ else ${REFILLED} end,
86
+ capacity = $2::double precision,
87
+ refill_per_second = $3::double precision,
88
+ last_ms = greatest(x_rate_limit.last_ms, $5::bigint),
89
+ spent = ${REFILLED} >= $4::double precision,
90
+ updated_at = now()
91
+ returning tokens, spent
92
+ `;
93
+
94
+ export const SQL_RATE_LIMIT_RESET = 'delete from x_rate_limit where key = $1';
95
+
96
+ /**
97
+ * The memory store's forget rule, in SQL: a bucket back at capacity answers exactly as a missing
98
+ * one, so dropping it changes no decision. A bucket that never refills
99
+ * (`refill_per_second <= 0`) is never forgotten, exactly as the memory store's `Infinity` forget
100
+ * instant says.
101
+ *
102
+ * `$1` is the CALLER's `nowMs`, and using `extract(epoch from now())` instead is a bug this
103
+ * statement shipped with for one afternoon. `last_ms` is written from the caller's clock, so a
104
+ * purge measuring against the SERVER's clock computes a refill out of the offset between the two
105
+ * — and every bucket a throttled caller is sitting in is deleted, which is a free reset. Measured:
106
+ * the framework's own test preload freezes the clock at 2026-01-01, the server said 2026-08-22,
107
+ * and the purge dropped a bucket holding 0 of 4 tokens.
108
+ */
109
+ export const SQL_RATE_LIMIT_PURGE = `
110
+ delete from x_rate_limit
111
+ where refill_per_second > 0
112
+ and tokens
113
+ + greatest(0, $1::bigint - last_ms)::double precision / 1000
114
+ * refill_per_second >= capacity
115
+ `;
116
+
117
+ interface TakeRow {
118
+ /** `double precision`, which some clients hand back as a string. */
119
+ readonly tokens: number | string;
120
+ /** `boolean`, which a text-mode client hands back as `'t'`. */
121
+ readonly spent: boolean | string;
122
+ }
123
+
124
+ export interface PostgresRateLimitStoreOptions {
125
+ readonly executor: PgExecutor;
126
+ }
127
+
128
+ export interface PostgresRateLimitStore extends RateLimitStore {
129
+ readonly scope: RateLimitScope;
130
+ /**
131
+ * Delete every bucket that has refilled to capacity, and answer how many. The table is the one
132
+ * part of this store that does not bound itself — Postgres forgets nothing on its own, and the
133
+ * key falls back to the connection address, so a scan rotating through an IPv6 /64 mints a row
134
+ * per request. An app runs this from a `task` on whatever cadence its traffic deserves.
135
+ *
136
+ * `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a
137
+ * task. There is no default, because the only defensible default would be this process' own
138
+ * `Date.now()`, and a store that reads a clock nobody handed it is the thing `createRateLimiter`
139
+ * took a `Clock` to stop.
140
+ */
141
+ purgeExpired(nowMs: number): Promise<number>;
142
+ }
143
+
144
+ /**
145
+ * **Install it at boot, beside the config that declares the scope.** The app owes two lines:
146
+ *
147
+ * ```ts
148
+ * // app.config.ts — what this deployment REQUIRES
149
+ * http: { rateLimit: { scope: 'shared' } }
150
+ *
151
+ * // apps/web/server.ts — what PROVIDES it
152
+ * const client = db();
153
+ * createServer({
154
+ * rateLimitStore: postgresRateLimitStore({
155
+ * executor: { query: (text, values) => client.query({ text, values }) },
156
+ * }),
157
+ * });
158
+ * ```
159
+ *
160
+ * `assertRateLimitScope` compares the two once, inside `createPipeline`, and a `'shared'`
161
+ * declaration over any other store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens.
162
+ */
163
+ export function postgresRateLimitStore(
164
+ options: PostgresRateLimitStoreOptions,
165
+ ): PostgresRateLimitStore {
166
+ const exec = options.executor;
167
+
168
+ return {
169
+ scope: 'shared',
170
+
171
+ async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> {
172
+ const rows = await exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [
173
+ key,
174
+ bucket.capacity,
175
+ bucket.refillPerSecond,
176
+ cost,
177
+ Math.floor(nowMs),
178
+ ]);
179
+ const row = rows[0];
180
+ // An upsert with `returning` answers exactly one row, so none means the executor is not
181
+ // running the statement it was handed. Refusing loudly beats inventing a decision: the
182
+ // invented one would be "allowed", which is the limiter silently switched off.
183
+ if (row === undefined) throw rateLimitStoreUnavailable('take');
184
+ return rateLimitDecision(bucket, Number(row.tokens), cost, isTrue(row.spent), nowMs);
185
+ },
186
+
187
+ async reset(key): Promise<void> {
188
+ await exec.query(SQL_RATE_LIMIT_RESET, [key]);
189
+ },
190
+
191
+ async purgeExpired(nowMs): Promise<number> {
192
+ const rows = await exec.query<{ readonly key: string }>(
193
+ `${SQL_RATE_LIMIT_PURGE} returning key`,
194
+ [Math.floor(nowMs)],
195
+ );
196
+ return rows.length;
197
+ },
198
+ };
199
+ }
200
+
201
+ /** A `boolean` column, read from a client that may be in text mode. */
202
+ const isTrue = (value: boolean | string): boolean => value === true || value === 't';
package/src/rate-limit.ts CHANGED
@@ -3,7 +3,12 @@
3
3
  // `createServer({ rateLimitStore })`, and refused at boot when its scope cannot keep the app's
4
4
  // declaration; the bucket maths lives here so every driver agrees on the numbers.
5
5
  import { type Clock, systemClock } from '@ultimat3/core';
6
- import { rateLimited, rateLimitInvalid, rateLimitNotShared, rateLimitScopeUnset } from './errors';
6
+ import {
7
+ rateLimited,
8
+ rateLimitInvalid,
9
+ rateLimitNotShared,
10
+ rateLimitScopeUnset,
11
+ } from './rate-limit-errors';
7
12
 
8
13
  /**
9
14
  * Where a limiter's counters live. A store says which it provides; `RateLimitConfig` says which
@@ -148,19 +153,21 @@ const forgetAt = (state: BucketState, bucket: Bucket, nowMs: number): number =>
148
153
  return nowMs + Math.ceil((toFull / bucket.refillPerSecond) * 1000);
149
154
  };
150
155
 
151
- const decide = (
152
- state: BucketState,
156
+ /**
157
+ * The numbers a caller is owed, given what the bucket holds AFTER the take. Exported because a
158
+ * store that keeps its counters in Postgres does the refill and the spend in SQL and has nothing
159
+ * left to compute them with — and two drivers deriving `retryAfterSeconds` separately is two
160
+ * answers to "when may I come back", one of which is wrong. `allowed` is passed rather than
161
+ * inferred: `tokens` alone cannot tell a spend that landed at 0.5 from a refusal with 0.5 left.
162
+ */
163
+ export const rateLimitDecision = (
153
164
  bucket: Bucket,
165
+ tokens: number,
154
166
  cost: number,
167
+ allowed: boolean,
155
168
  nowMs: number,
156
169
  ): RateLimitDecision => {
157
- const elapsedSeconds = Math.max(0, (nowMs - state.lastMs) / 1000);
158
- const tokens = Math.min(bucket.capacity, state.tokens + elapsedSeconds * bucket.refillPerSecond);
159
- state.lastMs = nowMs;
160
- const allowed = tokens >= cost;
161
- state.tokens = allowed ? tokens - cost : tokens;
162
- state.forgetAtMs = forgetAt(state, bucket, nowMs);
163
- const deficit = allowed ? bucket.capacity - state.tokens : cost - state.tokens;
170
+ const deficit = allowed ? bucket.capacity - tokens : cost - tokens;
164
171
  // A bucket that never refills would give an infinite reset; clamp to a day so the
165
172
  // Retry-After header stays a number a client can act on.
166
173
  const secondsToRefill =
@@ -168,12 +175,27 @@ const decide = (
168
175
  return {
169
176
  allowed,
170
177
  limit: bucket.capacity,
171
- remaining: Math.floor(state.tokens),
178
+ remaining: Math.floor(tokens),
172
179
  resetAtMs: nowMs + Math.ceil(secondsToRefill * 1000),
173
180
  retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil(secondsToRefill)),
174
181
  };
175
182
  };
176
183
 
184
+ const decide = (
185
+ state: BucketState,
186
+ bucket: Bucket,
187
+ cost: number,
188
+ nowMs: number,
189
+ ): RateLimitDecision => {
190
+ const elapsedSeconds = Math.max(0, (nowMs - state.lastMs) / 1000);
191
+ const tokens = Math.min(bucket.capacity, state.tokens + elapsedSeconds * bucket.refillPerSecond);
192
+ state.lastMs = nowMs;
193
+ const allowed = tokens >= cost;
194
+ state.tokens = allowed ? tokens - cost : tokens;
195
+ state.forgetAtMs = forgetAt(state, bucket, nowMs);
196
+ return rateLimitDecision(bucket, state.tokens, cost, allowed, nowMs);
197
+ };
198
+
177
199
  /**
178
200
  * Hard bound on tracked keys. A key is `route|subject`, so one subject throttled on N routes is
179
201
  * N entries — a higher natural cardinality than an identity table, which is why this cap is
package/src/stages.ts CHANGED
@@ -28,7 +28,6 @@ import {
28
28
  methodNotAllowed,
29
29
  overloaded,
30
30
  pathInvalid,
31
- rateLimited,
32
31
  routeNotFound,
33
32
  unauthenticated,
34
33
  } from './errors';
@@ -37,6 +36,7 @@ import { readCookie } from './locale';
37
36
  import { compose, type Middleware } from './middleware';
38
37
  import { overlayResponse, wantsOverlay } from './overlay';
39
38
  import { type RateLimiter, rateLimitKey } from './rate-limit';
39
+ import { rateLimited } from './rate-limit-errors';
40
40
  import type { UltimateRequest } from './request';
41
41
  import { addVary, applyCacheHeaders, problem, redirect } from './response';
42
42
  import { matchRoute, type Route, type RouteHandler, type RouteTable } from './router';
@@ -266,11 +266,15 @@ export const stageRunners = (input: StageRunnersInput): Record<StageName, StageR
266
266
  if (hooks.authorize === undefined) {
267
267
  // A declared policy with no evaluator is a wiring bug, and failing open
268
268
  // here is exactly how a framework ends up with two authz systems.
269
- throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`);
269
+ throw forbidden(
270
+ ctx.url.pathname,
271
+ `no authorizer wired for policy ${route.meta.policy}`,
272
+ route.meta.policy,
273
+ );
270
274
  }
271
275
  const decision = await hooks.authorize(route, request, ctx);
272
276
  ctx.authz = decision;
273
- if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason);
277
+ if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason, route.meta.policy);
274
278
  return undefined;
275
279
  },
276
280