@ultimat3/http 6.0.0 → 8.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 +23 -0
- package/README.md +40 -2
- package/package.json +5 -5
- package/src/error-map.ts +8 -0
- package/src/errors.ts +21 -143
- package/src/index.ts +23 -7
- package/src/rate-limit-buckets.ts +1 -1
- package/src/rate-limit-errors.ts +163 -0
- package/src/rate-limit-postgres.ts +202 -0
- package/src/rate-limit.ts +33 -11
- package/src/router.ts +1 -2
- package/src/stages.ts +7 -3
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.
|
|
101
|
-
|
|
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": "
|
|
3
|
+
"version": "8.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": "
|
|
35
|
-
"@ultimat3/i18n": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
37
|
-
"@ultimat3/time": "
|
|
34
|
+
"@ultimat3/core": "8.0.0",
|
|
35
|
+
"@ultimat3/i18n": "8.0.0",
|
|
36
|
+
"@ultimat3/schema": "8.0.0",
|
|
37
|
+
"@ultimat3/time": "8.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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
*
|
|
203
|
-
* `
|
|
204
|
-
*
|
|
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
|
|
211
|
+
export const forbidden = (pathname: string, reason: string, policy?: string): HttpError =>
|
|
208
212
|
new HttpError({
|
|
209
|
-
code: '
|
|
210
|
-
cause:
|
|
211
|
-
fix:
|
|
212
|
-
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The public surface of @ultimat3/http. Explicit, never `export *`: what is not
|
|
2
2
|
// listed here is an implementation detail and may change without a major bump.
|
|
3
3
|
|
|
4
|
+
export type { RenderMode } from '@ultimat3/core';
|
|
4
5
|
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';
|
|
5
6
|
export type { HttpConfig, HttpConfigInput } from './config';
|
|
6
7
|
export { defineHttpConfig, stripBasePath } from './config';
|
|
@@ -51,12 +52,6 @@ export {
|
|
|
51
52
|
overloaded,
|
|
52
53
|
pathInvalid,
|
|
53
54
|
pipelineNoResponse,
|
|
54
|
-
rateLimitBucketConflict,
|
|
55
|
-
rateLimitBucketUnbound,
|
|
56
|
-
rateLimited,
|
|
57
|
-
rateLimitInvalid,
|
|
58
|
-
rateLimitNotShared,
|
|
59
|
-
rateLimitScopeUnset,
|
|
60
55
|
requestTimedOut,
|
|
61
56
|
routeConflict,
|
|
62
57
|
routeNotFound,
|
|
@@ -104,11 +99,33 @@ export {
|
|
|
104
99
|
DEFAULT_MAX_RATE_LIMIT_KEYS,
|
|
105
100
|
DEFAULT_RATE_LIMIT,
|
|
106
101
|
memoryRateLimitStore,
|
|
102
|
+
rateLimitDecision,
|
|
107
103
|
rateLimitKey,
|
|
108
104
|
resolveRateLimitConfig,
|
|
109
105
|
toBucket,
|
|
110
106
|
} from './rate-limit';
|
|
111
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';
|
|
112
129
|
export { setRedirect, takeRedirect } from './redirect';
|
|
113
130
|
export type { QueryValues } from './request';
|
|
114
131
|
export { UltimateRequest } from './request';
|
|
@@ -129,7 +146,6 @@ export {
|
|
|
129
146
|
export type {
|
|
130
147
|
HttpMethod,
|
|
131
148
|
MatchResult,
|
|
132
|
-
RenderMode,
|
|
133
149
|
Route,
|
|
134
150
|
RouteDescription,
|
|
135
151
|
RouteHandler,
|
|
@@ -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 {
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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(
|
|
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/router.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// param branch would also match, and a dead end in the static branch still falls
|
|
11
11
|
// back to the param branch. Two routes that would tie are a build error
|
|
12
12
|
// (`X_ROUTE_CONFLICT`) rather than a coin flip.
|
|
13
|
+
import type { RenderMode } from '@ultimat3/core';
|
|
13
14
|
import type { RequestContext } from './context';
|
|
14
15
|
import { routeConflict } from './errors';
|
|
15
16
|
import type { Bucket } from './rate-limit';
|
|
@@ -29,8 +30,6 @@ export const HTTP_METHODS: readonly HttpMethod[] = [
|
|
|
29
30
|
'OPTIONS',
|
|
30
31
|
];
|
|
31
32
|
|
|
32
|
-
export type RenderMode = 'static' | 'isr' | 'ssr' | 'stream';
|
|
33
|
-
|
|
34
33
|
export type RouteParams = Readonly<Record<string, string>>;
|
|
35
34
|
|
|
36
35
|
export interface RouteMeta {
|
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(
|
|
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
|
|