@ultimat3/http
Advanced tools
| // Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration | ||
| // faults the boot refuses. Split from `errors.ts` at the 500-line ceiling, on the seam it already | ||
| // had. The codes and their TITLES stay there, which is the one registry — `registerErrorCodes` | ||
| // must see them all in a single call. | ||
| import { HttpError } from './errors'; | ||
| /** | ||
| * The KEY never reaches the caller. `rateLimitKey` is `${routeName}|org:${orgId}` — or | ||
| * `actor:${actorId}` — so the old cause handed an anonymous caller promoted to an org bucket the | ||
| * internal org id, in a 429 anyone can provoke. It rides in `meta`, which the problem document | ||
| * does not render and the error reporter does. | ||
| */ | ||
| export const rateLimited = (key: string, retryAfterSeconds: number): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMITED', | ||
| cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`, | ||
| fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts', | ||
| meta: { key, retryAfterSeconds }, | ||
| }); | ||
| /** | ||
| * At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means | ||
| * each process holds its own counters, so every configured number is enforced three times over — | ||
| * a green `x verify` and a limit that is not the limit. The declaration is the app's because the | ||
| * framework cannot see its replica count, and a framework that guessed would guess wrong. | ||
| */ | ||
| export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_NOT_SHARED', | ||
| cause: | ||
| found === 'disabled' | ||
| ? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere" | ||
| : "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", | ||
| 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", | ||
| }); | ||
| /** | ||
| * The numbers of one bucket, spelled structurally so `errors.ts` stays free of an import from | ||
| * `rate-limit.ts` — which imports this file. | ||
| */ | ||
| interface BucketNumbers { | ||
| readonly capacity: number; | ||
| readonly refillPerSecond: number; | ||
| } | ||
| const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`; | ||
| /** | ||
| * Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that | ||
| * configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name | ||
| * disagree about what is enforced, and whichever a merge picked would leave the other a number | ||
| * someone read and nothing applies — the failure this seam exists to end. The message speaks | ||
| * capacity and refill rather than the `limit`/`windowMs` an action declares, because that is what | ||
| * the limiter runs on; `toBucket` (`rate-limit.ts`, this package) is the conversion between them — | ||
| * it lives here because http owns `Bucket` and the maths, and both tier-3 callers need it. | ||
| */ | ||
| export const rateLimitBucketConflict = (input: { | ||
| bucket: string; | ||
| /** `null` when the other declaration is `app.config.ts` rather than a second route. */ | ||
| otherRoute: string | null; | ||
| route: string; | ||
| other: BucketNumbers; | ||
| declared: BucketNumbers; | ||
| }): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_BUCKET_CONFLICT', | ||
| cause: `bucket "${input.bucket}" has two declarations: ${ | ||
| input.otherRoute === null | ||
| ? 'http.rateLimit.buckets in app.config.ts' | ||
| : `route "${input.otherRoute}"` | ||
| } says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${ | ||
| input.otherRoute === null | ||
| ? `; 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` | ||
| : '' | ||
| }`, | ||
| // One edit, named. Two joined by "or" leaves the reader to decide which declaration is | ||
| // authoritative — and the route is, always: it sits beside the handler and it is what the | ||
| // OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale. | ||
| fix: | ||
| input.otherRoute === null | ||
| ? `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` | ||
| : `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`, | ||
| }); | ||
| /** | ||
| * A route declares its own bucket and the INSTALLED limiter cannot enforce it — at | ||
| * `createPipeline`, never on the request. `createRateLimiter` closes over the config it was built | ||
| * with, so a limiter constructed before the routes existed resolves the route's bucket name | ||
| * through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21 | ||
| * requests allowed for a route declaring 5. Silent, and looser than what the author wrote. | ||
| * | ||
| * Refused rather than rebound, for two reasons. A `RateLimiter` is opaque — no store and no table | ||
| * are reachable through it — so "binding" it would mean discarding the caller's limiter and the | ||
| * store it carries, which is a different silent failure. And a caller who built their own limiter | ||
| * may have meant their own numbers; picking for them is the precedence mistake | ||
| * `X_RATE_LIMIT_BUCKET_CONFLICT` exists to refuse. | ||
| */ | ||
| export const rateLimitBucketUnbound = (input: { | ||
| bucket: string; | ||
| route: string; | ||
| declared: BucketNumbers; | ||
| /** What the limiter holds under that name, or `null` for "holds nothing / declares no table". */ | ||
| found: BucketNumbers | null; | ||
| }): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_BUCKET_UNBOUND', | ||
| cause: `route "${input.route}" declares bucket "${input.bucket}" as ${numbers(input.declared)} (capacity / refill per second) and the installed limiter ${ | ||
| input.found === null | ||
| ? 'does not hold that bucket, so the route would run on the default one' | ||
| : `holds ${numbers(input.found)} for it` | ||
| }`, | ||
| fix: 'pass the STORE and let the pipeline build the limiter — createServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered', | ||
| }); | ||
| /** | ||
| * At `defineHttpConfig`, never on the request. `scope` used to DEFAULT to `'process'`, so an app | ||
| * that declared nothing enforced every configured number once per replica — three times over on | ||
| * the chart this repo ships — with a green `x verify` and nothing to read. The boot check that | ||
| * catches the other half (`assertRateLimitScope`) only fires for an app that said `'shared'`, so | ||
| * the silent case was exactly the one nobody declared. One process is still a legal answer; it is | ||
| * no longer an assumed one. | ||
| */ | ||
| export const rateLimitScopeUnset = (): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_SCOPE_UNSET', | ||
| cause: | ||
| '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', | ||
| 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", | ||
| }); | ||
| /** | ||
| * The shared store ran its statement and answered nothing. An `insert … on conflict … returning` | ||
| * always yields one row, so this is a driver that is not running what it was handed — a wrapped | ||
| * client that swallows `returning`, or a pooler in a mode that discards it. | ||
| * | ||
| * 500 and never an allowed request: the invented decision would have to be "allowed", which is the | ||
| * limiter switched off with nothing saying so. `@ultimat3/action`'s idempotency store makes the | ||
| * same call in the same direction for the same reason. | ||
| */ | ||
| export const rateLimitStoreUnavailable = (statement: string): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_STORE_UNAVAILABLE', | ||
| cause: `the shared rate-limit store answered no row for its ${statement} statement, so no limit was applied to this request`, | ||
| fix: 'psql "$DATABASE_URL" -c "select * from x_rate_limit limit 1" # then confirm the PgExecutor passed to postgresRateLimitStore returns the rows of `returning`', | ||
| }); | ||
| /** | ||
| * A `{ limit, windowMs }` pair the limiter cannot run on. Raised by `toBucket` (`rate-limit.ts`), | ||
| * which lives in this PACKAGE because http owns `Bucket` and the maths, and two tier-3 packages | ||
| * (`action`, `query`) need the same conversion without importing each other. | ||
| */ | ||
| export const rateLimitInvalid = (input: { | ||
| readonly owner: string; | ||
| readonly limit: number; | ||
| readonly windowMs: number; | ||
| readonly reason: string; | ||
| }): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_INVALID', | ||
| cause: `"${input.owner}" declares rateLimit { limit: ${input.limit}, windowMs: ${input.windowMs} }: ${input.reason}`, | ||
| 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`, | ||
| meta: { owner: input.owner, limit: input.limit, windowMs: input.windowMs }, | ||
| }); |
| // The shared rate-limit store: one Postgres table, one `insert … on conflict` per take, so N | ||
| // replicas count against one bucket. Without it `config.rateLimit.scope: 'shared'` is a | ||
| // declaration nothing can satisfy while `docker/helm/values.yaml` runs `roles.web.replicas: 3`. | ||
| // Statements are spelled out so an agent can run the exact one it saw in a log. | ||
| import type { RateLimitDecision, RateLimitScope, RateLimitStore } from './rate-limit'; | ||
| import { rateLimitDecision } from './rate-limit'; | ||
| import { rateLimitStoreUnavailable } from './rate-limit-errors'; | ||
| /** | ||
| * The one thing this store needs from the DB layer, declared structurally rather than imported — | ||
| * `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/jobs` declare the same shape for | ||
| * the same reason: neither package owns the other's connection. Here it is also the only option: | ||
| * `@ultimat3/http` has no `@ultimat3/db` dependency at all, and taking one to type a single method | ||
| * would put the whole database package in this package's install graph. | ||
| * | ||
| * **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template whose | ||
| * positional form is `unsafe`. What satisfies it is a client that already speaks `(text, values)`, | ||
| * wrapped in one line — `@ultimat3/db`'s `DbClient.query({ text, values })` is the framework's own. | ||
| */ | ||
| export interface PgExecutor { | ||
| query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>; | ||
| } | ||
| /** | ||
| * Installed by the boot, not by an app migration — the same rule `SQL_IDEMPOTENCY_TABLE` follows, | ||
| * so `x dev`, the container's `web` role and the release-phase `ROLE=migrate` all apply it. | ||
| * | ||
| * `capacity` and `refill_per_second` are STORED, though every take passes them: they are what | ||
| * makes `purgeExpired` able to ask "has this bucket refilled to full?" — the same question the | ||
| * memory store answers with `forgetAtMs` — instead of guessing an idle TTL that is wrong for any | ||
| * app declaring a window longer than the guess. | ||
| */ | ||
| export const SQL_RATE_LIMIT_TABLE = ` | ||
| create table if not exists x_rate_limit ( | ||
| key text primary key, | ||
| tokens double precision not null, | ||
| capacity double precision not null, | ||
| refill_per_second double precision not null, | ||
| last_ms bigint not null, | ||
| spent boolean not null, | ||
| updated_at timestamptz not null default now() | ||
| ); | ||
| create index if not exists x_rate_limit_updated_at_idx on x_rate_limit (updated_at); | ||
| `; | ||
| /** | ||
| * What the bucket holds after the elapsed refill, before this caller spends anything — capped at | ||
| * `capacity`, and never negative elapsed, so a replica whose clock runs behind grants nothing. | ||
| * | ||
| * It appears four times in the statement below and that repetition is REQUIRED, not sloppiness. | ||
| * Only a direct `x_rate_limit.<column>` reference inside `on conflict do update` reads the row as | ||
| * it is after the lock is taken; a CTE computing it once would read the statement's own snapshot, | ||
| * so two concurrent takes would each compute from the same pre-refill row and one spend would be | ||
| * lost — a free request per race, on the control that exists to refuse them. | ||
| */ | ||
| const REFILLED = | ||
| 'least($2::double precision, x_rate_limit.tokens + ' + | ||
| 'greatest(0, ($5::bigint - x_rate_limit.last_ms))::double precision / 1000 * $3::double precision)'; | ||
| /** | ||
| * `$1` key, `$2` capacity, `$3` refill per second, `$4` cost, `$5` the caller's `nowMs`. | ||
| * | ||
| * `spent` is persisted because the token count alone cannot answer the caller: a take that landed | ||
| * at 0.5 and a refusal that left 0.5 are the same number, and `rateLimitDecision` needs the | ||
| * verdict to compute `retryAfterSeconds`. `last_ms` only ever moves FORWARD (`greatest`) — two | ||
| * replicas do not share a clock, and a `last_ms` dragged backwards hands the next take a longer | ||
| * elapsed than really passed, which is refill the caller did not earn. | ||
| */ | ||
| export const SQL_RATE_LIMIT_TAKE = ` | ||
| insert into x_rate_limit (key, tokens, capacity, refill_per_second, last_ms, spent) | ||
| values ( | ||
| $1, | ||
| case when $2::double precision >= $4::double precision | ||
| then $2::double precision - $4::double precision | ||
| else $2::double precision end, | ||
| $2::double precision, $3::double precision, $5::bigint, | ||
| $2::double precision >= $4::double precision | ||
| ) | ||
| on conflict (key) do update | ||
| set tokens = case | ||
| when ${REFILLED} >= $4::double precision | ||
| then ${REFILLED} - $4::double precision | ||
| else ${REFILLED} end, | ||
| capacity = $2::double precision, | ||
| refill_per_second = $3::double precision, | ||
| last_ms = greatest(x_rate_limit.last_ms, $5::bigint), | ||
| spent = ${REFILLED} >= $4::double precision, | ||
| updated_at = now() | ||
| returning tokens, spent | ||
| `; | ||
| export const SQL_RATE_LIMIT_RESET = 'delete from x_rate_limit where key = $1'; | ||
| /** | ||
| * The memory store's forget rule, in SQL: a bucket back at capacity answers exactly as a missing | ||
| * one, so dropping it changes no decision. A bucket that never refills | ||
| * (`refill_per_second <= 0`) is never forgotten, exactly as the memory store's `Infinity` forget | ||
| * instant says. | ||
| * | ||
| * `$1` is the CALLER's `nowMs`, and using `extract(epoch from now())` instead is a bug this | ||
| * statement shipped with for one afternoon. `last_ms` is written from the caller's clock, so a | ||
| * purge measuring against the SERVER's clock computes a refill out of the offset between the two | ||
| * — and every bucket a throttled caller is sitting in is deleted, which is a free reset. Measured: | ||
| * the framework's own test preload freezes the clock at 2026-01-01, the server said 2026-08-22, | ||
| * and the purge dropped a bucket holding 0 of 4 tokens. | ||
| */ | ||
| export const SQL_RATE_LIMIT_PURGE = ` | ||
| delete from x_rate_limit | ||
| where refill_per_second > 0 | ||
| and tokens | ||
| + greatest(0, $1::bigint - last_ms)::double precision / 1000 | ||
| * refill_per_second >= capacity | ||
| `; | ||
| interface TakeRow { | ||
| /** `double precision`, which some clients hand back as a string. */ | ||
| readonly tokens: number | string; | ||
| /** `boolean`, which a text-mode client hands back as `'t'`. */ | ||
| readonly spent: boolean | string; | ||
| } | ||
| export interface PostgresRateLimitStoreOptions { | ||
| readonly executor: PgExecutor; | ||
| } | ||
| export interface PostgresRateLimitStore extends RateLimitStore { | ||
| readonly scope: RateLimitScope; | ||
| /** | ||
| * Delete every bucket that has refilled to capacity, and answer how many. The table is the one | ||
| * part of this store that does not bound itself — Postgres forgets nothing on its own, and the | ||
| * key falls back to the connection address, so a scan rotating through an IPv6 /64 mints a row | ||
| * per request. An app runs this from a `task` on whatever cadence its traffic deserves. | ||
| * | ||
| * `nowMs` is required and comes from the SAME clock the takes use — `ctx.now().getTime()` in a | ||
| * task. There is no default, because the only defensible default would be this process' own | ||
| * `Date.now()`, and a store that reads a clock nobody handed it is the thing `createRateLimiter` | ||
| * took a `Clock` to stop. | ||
| */ | ||
| purgeExpired(nowMs: number): Promise<number>; | ||
| } | ||
| /** | ||
| * **Install it at boot, beside the config that declares the scope.** The app owes two lines: | ||
| * | ||
| * ```ts | ||
| * // app.config.ts — what this deployment REQUIRES | ||
| * http: { rateLimit: { scope: 'shared' } } | ||
| * | ||
| * // apps/web/server.ts — what PROVIDES it | ||
| * const client = db(); | ||
| * createServer({ | ||
| * rateLimitStore: postgresRateLimitStore({ | ||
| * executor: { query: (text, values) => client.query({ text, values }) }, | ||
| * }), | ||
| * }); | ||
| * ``` | ||
| * | ||
| * `assertRateLimitScope` compares the two once, inside `createPipeline`, and a `'shared'` | ||
| * declaration over any other store is `X_RATE_LIMIT_NOT_SHARED` before the socket opens. | ||
| */ | ||
| export function postgresRateLimitStore( | ||
| options: PostgresRateLimitStoreOptions, | ||
| ): PostgresRateLimitStore { | ||
| const exec = options.executor; | ||
| return { | ||
| scope: 'shared', | ||
| async take(key, bucket, cost, nowMs): Promise<RateLimitDecision> { | ||
| const rows = await exec.query<TakeRow>(SQL_RATE_LIMIT_TAKE, [ | ||
| key, | ||
| bucket.capacity, | ||
| bucket.refillPerSecond, | ||
| cost, | ||
| Math.floor(nowMs), | ||
| ]); | ||
| const row = rows[0]; | ||
| // An upsert with `returning` answers exactly one row, so none means the executor is not | ||
| // running the statement it was handed. Refusing loudly beats inventing a decision: the | ||
| // invented one would be "allowed", which is the limiter silently switched off. | ||
| if (row === undefined) throw rateLimitStoreUnavailable('take'); | ||
| return rateLimitDecision(bucket, Number(row.tokens), cost, isTrue(row.spent), nowMs); | ||
| }, | ||
| async reset(key): Promise<void> { | ||
| await exec.query(SQL_RATE_LIMIT_RESET, [key]); | ||
| }, | ||
| async purgeExpired(nowMs): Promise<number> { | ||
| const rows = await exec.query<{ readonly key: string }>( | ||
| `${SQL_RATE_LIMIT_PURGE} returning key`, | ||
| [Math.floor(nowMs)], | ||
| ); | ||
| return rows.length; | ||
| }, | ||
| }; | ||
| } | ||
| /** A `boolean` column, read from a client that may be in text mode. */ | ||
| const isTrue = (value: boolean | string): boolean => value === true || value === 't'; |
+23
-0
@@ -147,2 +147,10 @@ # @ultimat3/http | ||
| one holding less. | ||
| - **A 403's `fix:` names the POLICY, never the pathname** (`As of 2026-08`). `forbidden` emitted | ||
| `x policy explain ${ctx.url.pathname}`, and `x policy explain` resolves a policy SUBJECT — a | ||
| permission, an action name or a query name. A page pathname is none of them, so the one command | ||
| the error told the reader to run exited `X_DECLARATION_UNKNOWN` (`x policy explain /settings`, | ||
| reproduced in `examples/dummy`). The third argument is `route.meta.policy`, which is what the | ||
| `authz` stage was evaluating and what the index can resolve; anything that is not a bare | ||
| `resource:verb` — a composite renders `and(a:b, c:d)` — degrades to `x routes --json`, the shape | ||
| `bodyInvalid` already uses. A fix that names the wrong thing is not a fix. | ||
| - **`ctx.actor` is never null.** `asCtx` publishes the request context itself as core's `Ctx`, | ||
@@ -284,2 +292,15 @@ and `Ctx.actor` is an `Actor` — so "nobody" is core's anonymous actor, not `null`. The | ||
| existed; never add a second limiter entry point beside it. | ||
| - **The shared store is `postgresRateLimitStore({ executor })`, and it is what makes | ||
| `scope: 'shared'` satisfiable** (`As of 2026-08`). Before it, `assertRateLimitScope` refused | ||
| every store the framework shipped, so the declaration required by a chart with `replicas: 3` had no | ||
| answer. `PgExecutor` is declared STRUCTURALLY here, exactly as `@ultimat3/action`'s idempotency | ||
| store declares it: this package has no `@ultimat3/db` dependency, and taking one to type a single | ||
| method would put the database package in http's install graph. The refill expression is repeated | ||
| four times inside `on conflict do update` **on purpose** — only a direct `x_rate_limit.<column>` | ||
| reference reads the row as it is after the lock, so a CTE computing it once would compute from | ||
| the statement's own snapshot and lose a concurrent spend. `spent` is a stored column because the | ||
| token count alone cannot tell a take that landed at 0.5 from a refusal with 0.5 left, and the | ||
| invented answer would be "allowed". `purgeExpired(nowMs)` takes the CALLER's clock and never | ||
| `now()`: `last_ms` is written from the caller's, so measuring against the server's reads the | ||
| offset between the two as refill and deletes buckets a throttled caller is still sitting in. | ||
| - **A bucket a route names is a bucket something must register.** `meta.rateLimit` selects by | ||
@@ -341,2 +362,4 @@ name and `meta.rateLimitBucket` carries the numbers; `withRouteBuckets` (`rate-limit-buckets.ts`) | ||
| | `rate-limit.ts` | the token-bucket maths, the store interface, the memory driver and `toBucket` | | ||
| | `rate-limit-postgres.ts` | the SHARED store: one table, one `insert … on conflict` per take, over a structural `PgExecutor` | | ||
| | `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 | | ||
| | `correlation.ts` | the inbound request id and trace, read before the context and the span exist | | ||
@@ -343,0 +366,0 @@ | `forwarded.ts` | one hop-indexed reader for every header a trusted proxy writes | |
+5
-5
| { | ||
| "name": "@ultimat3/http", | ||
| "version": "7.0.0", | ||
| "version": "8.0.0", | ||
| "description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors", | ||
@@ -34,7 +34,7 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "7.0.0", | ||
| "@ultimat3/i18n": "7.0.0", | ||
| "@ultimat3/schema": "7.0.0", | ||
| "@ultimat3/time": "7.0.0" | ||
| "@ultimat3/core": "8.0.0", | ||
| "@ultimat3/i18n": "8.0.0", | ||
| "@ultimat3/schema": "8.0.0", | ||
| "@ultimat3/time": "8.0.0" | ||
| } | ||
| } |
+40
-2
@@ -100,5 +100,43 @@ # @ultimat3/http 🌐 | ||
| `rateLimitStore` feeds the `PipelineDeps.limiter` seam rather than sitting beside it: the bucket | ||
| maths stays in `createRateLimiter`, so every driver agrees on the numbers. **No shared store ships | ||
| yet, `As of 2026-08`** — `memoryRateLimitStore()` is the only implementation in the framework. | ||
| maths stays in `createRateLimiter`, so every driver agrees on the numbers. | ||
| **A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table | ||
| and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed, | ||
| `scope: 'shared'` was a declaration nothing in the framework could satisfy while `x new` scaffolded | ||
| `replicas: 2`. `executor` is a `PgExecutor` — anything speaking `query(text, values)`, which is one | ||
| line over the client the boot already opened; **never `Bun.sql`**, whose `.query` is `undefined`. | ||
| ```ts | ||
| import { db, type SqlFragment } from '@ultimat3/db'; | ||
| import { | ||
| createServer, | ||
| defineHttpConfig, | ||
| type PgExecutor, | ||
| postgresRateLimitStore, | ||
| type Route, | ||
| } from '@ultimat3/http'; | ||
| declare const routes: readonly Route[]; | ||
| // The client this process already opened, wrapped in one line. `@ultimat3/cli`'s `pgExecutorFor` | ||
| // is this exact function, and it is what the boot passes when it installs the store for you. | ||
| const client = db(); | ||
| const executor: PgExecutor = { | ||
| query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> => | ||
| client.query<R>({ text, values } satisfies SqlFragment), | ||
| }; | ||
| createServer({ | ||
| routes, | ||
| config: defineHttpConfig({ rateLimit: { scope: 'shared' } }), | ||
| rateLimitStore: postgresRateLimitStore({ executor }), | ||
| }); | ||
| ``` | ||
| The table bounds itself only when something asks it to: `store.purgeExpired(ctx.now().getTime())` | ||
| from a `task` drops every bucket that has refilled to capacity, which is the memory store's forget | ||
| rule. `nowMs` is required and must come from the same clock the takes use — measured against the | ||
| server's clock instead, the offset between the two reads as refill and deletes buckets a throttled | ||
| caller is still sitting in. | ||
| The maths reads an injected `Clock`, defaulting to `systemClock`: `createRateLimiter({ config, | ||
@@ -105,0 +143,0 @@ clock })`. **Breaking, `As of 2026-08-19`** — it took `now?: () => number` before and read |
+8
-0
@@ -55,2 +55,4 @@ // The one place a framework error code becomes an HTTP status. A table, not a | ||
| X_RATE_LIMIT_INVALID: 500, | ||
| // The shared store did not answer, so nothing decided. An operator's fault, never the caller's. | ||
| X_RATE_LIMIT_STORE_UNAVAILABLE: 500, | ||
| // The two the `admit` stage answers with, and the only 503s the pipeline produces. Both carry | ||
@@ -83,2 +85,8 @@ // `retry-after`: a shed request that does not say when to come back is a request that comes | ||
| X_IDEMPOTENCY_REPLAYED_FAILURE: 500, | ||
| // Same shape as the line above and 500 for the same reason: the store holds a record this | ||
| // build cannot turn into a result. Deliberately NOT 503 — a rolling deploy is the usual | ||
| // cause, so a retry may well reach a newer pod and succeed, but this code carries no | ||
| // `retry-after` and the two 503s above are the only ones that do. Telling a caller to come | ||
| // back without saying when is the load-shedding mistake, one layer up. | ||
| X_IDEMPOTENCY_STATUS_UNKNOWN: 500, | ||
| // @ultimat3/auth — every one of these is reachable from a request: the OAuth route descriptors | ||
@@ -85,0 +93,0 @@ // are mounted by the app, and `authenticate` throws the session codes inside the pipeline. Without |
+21
-143
@@ -31,2 +31,3 @@ // The HTTP layer's stable error codes. Every throw in this package goes through a | ||
| 'X_RATE_LIMIT_INVALID', | ||
| 'X_RATE_LIMIT_STORE_UNAVAILABLE', | ||
| 'X_TRUST_PROXY_UNSET', | ||
@@ -83,2 +84,3 @@ 'X_OVERLOADED', | ||
| X_RATE_LIMIT_INVALID: 'a declared rate limit computes to numbers the limiter cannot run on', | ||
| X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided', | ||
| X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front', | ||
@@ -196,21 +198,25 @@ X_OVERLOADED: 'in-flight requests are at the configured ceiling', | ||
| export const forbidden = (pathname: string, reason: string): HttpError => | ||
| new HttpError({ | ||
| code: 'X_FORBIDDEN', | ||
| cause: `${pathname} denied: ${reason}`, | ||
| fix: `x policy explain ${pathname} --json # shows which clause denied`, | ||
| }); | ||
| /** | ||
| * `x policy explain` resolves a policy SUBJECT — a permission, an action name or a query name. | ||
| * A route pathname is none of those, and the only callers of this factory (`stages.ts`' `authz`) | ||
| * had nothing but `ctx.url.pathname` to hand it: `x policy explain /settings` exits | ||
| * `X_DECLARATION_UNKNOWN`, so the one command a 403 told the reader to run was the one command | ||
| * that could not work. `route.meta.policy` is what the stage was evaluating and what the index | ||
| * can resolve, so that is the argument. | ||
| */ | ||
| const POLICY_SUBJECT = /^[a-z0-9_-]+:[a-z0-9_-]+$/; | ||
| /** | ||
| * The KEY never reaches the caller. `rateLimitKey` is `${routeName}|org:${orgId}` — or | ||
| * `actor:${actorId}` — so the old cause handed an anonymous caller promoted to an org bucket the | ||
| * internal org id, in a 429 anyone can provoke. It rides in `meta`, which the problem document | ||
| * does not render and the error reporter does. | ||
| * A composite policy renders `and(a:b, c:d)`, which is not a subject either — so anything that is | ||
| * not a bare `resource:verb` degrades to the route table, the shape `bodyInvalid` above uses. A | ||
| * fix that names the wrong thing is not a fix; a fix that resolves is. | ||
| */ | ||
| export const rateLimited = (key: string, retryAfterSeconds: number): HttpError => | ||
| export const forbidden = (pathname: string, reason: string, policy?: string): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMITED', | ||
| cause: `the rate limit for this caller is exhausted; it refills in ${retryAfterSeconds}s`, | ||
| fix: 'retry after the Retry-After header, or raise rateLimit.buckets in app.config.ts', | ||
| meta: { key, retryAfterSeconds }, | ||
| code: 'X_FORBIDDEN', | ||
| cause: `${pathname} denied: ${reason}`, | ||
| fix: | ||
| policy !== undefined && POLICY_SUBJECT.test(policy) | ||
| ? `x policy explain ${policy} --json # shows which clause denied` | ||
| : `x routes --json # find ${pathname}, then read the policy it declares`, | ||
| }); | ||
@@ -286,96 +292,2 @@ | ||
| /** | ||
| * At `createServer`/`createPipeline`, never on the request. `replicas: 3` behind one config means | ||
| * each process holds its own counters, so every configured number is enforced three times over — | ||
| * a green `x verify` and a limit that is not the limit. The declaration is the app's because the | ||
| * framework cannot see its replica count, and a framework that guessed would guess wrong. | ||
| */ | ||
| export const rateLimitNotShared = (found: 'process' | 'disabled'): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_NOT_SHARED', | ||
| cause: | ||
| found === 'disabled' | ||
| ? "http.rateLimit.scope is 'shared' but http.rateLimit.enabled is false, so the fleet-wide limit is enforced nowhere" | ||
| : "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", | ||
| 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", | ||
| }); | ||
| /** | ||
| * The numbers of one bucket, spelled structurally so `errors.ts` stays free of an import from | ||
| * `rate-limit.ts` — which imports this file. | ||
| */ | ||
| interface BucketNumbers { | ||
| readonly capacity: number; | ||
| readonly refillPerSecond: number; | ||
| } | ||
| const numbers = (bucket: BucketNumbers): string => `${bucket.capacity} / ${bucket.refillPerSecond}`; | ||
| /** | ||
| * Two declarations of one bucket, at `createServer`/`createPipeline`. Neither wins: an app that | ||
| * configures `rateLimit.buckets.<name>` and a route that declares its own numbers under that name | ||
| * disagree about what is enforced, and whichever a merge picked would leave the other a number | ||
| * someone read and nothing applies — the failure this seam exists to end. The message speaks | ||
| * capacity and refill rather than the `limit`/`windowMs` an action declares, because that is what | ||
| * the limiter runs on; `toBucket` (`rate-limit.ts`, this package) is the conversion between them — | ||
| * it lives here because http owns `Bucket` and the maths, and both tier-3 callers need it. | ||
| */ | ||
| export const rateLimitBucketConflict = (input: { | ||
| bucket: string; | ||
| /** `null` when the other declaration is `app.config.ts` rather than a second route. */ | ||
| otherRoute: string | null; | ||
| route: string; | ||
| other: BucketNumbers; | ||
| declared: BucketNumbers; | ||
| }): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_BUCKET_CONFLICT', | ||
| cause: `bucket "${input.bucket}" has two declarations: ${ | ||
| input.otherRoute === null | ||
| ? 'http.rateLimit.buckets in app.config.ts' | ||
| : `route "${input.otherRoute}"` | ||
| } says ${numbers(input.other)}, route "${input.route}" says ${numbers(input.declared)} (capacity / refill per second)${ | ||
| input.otherRoute === null | ||
| ? `; 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` | ||
| : '' | ||
| }`, | ||
| // One edit, named. Two joined by "or" leaves the reader to decide which declaration is | ||
| // authoritative — and the route is, always: it sits beside the handler and it is what the | ||
| // OpenAPI operation publishes, so a config entry duplicating it is the copy that goes stale. | ||
| fix: | ||
| input.otherRoute === null | ||
| ? `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` | ||
| : `rename the bucket route "${input.route}" declares — one name is one limit, and "${input.bucket}" is already route "${input.otherRoute}"'s`, | ||
| }); | ||
| /** | ||
| * A route declares its own bucket and the INSTALLED limiter cannot enforce it — at | ||
| * `createPipeline`, never on the request. `createRateLimiter` closes over the config it was built | ||
| * with, so a limiter constructed before the routes existed resolves the route's bucket name | ||
| * through `bucketFor`, misses, and falls through to `default`: measured at 120 burst and 21 of 21 | ||
| * requests allowed for a route declaring 5. Silent, and looser than what the author wrote. | ||
| * | ||
| * Refused rather than rebound, for two reasons. A `RateLimiter` is opaque — no store and no table | ||
| * are reachable through it — so "binding" it would mean discarding the caller's limiter and the | ||
| * store it carries, which is a different silent failure. And a caller who built their own limiter | ||
| * may have meant their own numbers; picking for them is the precedence mistake | ||
| * `X_RATE_LIMIT_BUCKET_CONFLICT` exists to refuse. | ||
| */ | ||
| export const rateLimitBucketUnbound = (input: { | ||
| bucket: string; | ||
| route: string; | ||
| declared: BucketNumbers; | ||
| /** What the limiter holds under that name, or `null` for "holds nothing / declares no table". */ | ||
| found: BucketNumbers | null; | ||
| }): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_BUCKET_UNBOUND', | ||
| cause: `route "${input.route}" declares bucket "${input.bucket}" as ${numbers(input.declared)} (capacity / refill per second) and the installed limiter ${ | ||
| input.found === null | ||
| ? 'does not hold that bucket, so the route would run on the default one' | ||
| : `holds ${numbers(input.found)} for it` | ||
| }`, | ||
| fix: 'pass the STORE and let the pipeline build the limiter — createServer({ routes, rateLimitStore }) — so the bucket table is the one the routes registered', | ||
| }); | ||
| export const routeConflict = (path: string, detail: string): HttpError => | ||
@@ -389,36 +301,2 @@ new HttpError({ | ||
| /** | ||
| * At `defineHttpConfig`, never on the request. `scope` used to DEFAULT to `'process'`, so an app | ||
| * that declared nothing enforced every configured number once per replica — three times over on | ||
| * the chart this repo ships — with a green `x verify` and nothing to read. The boot check that | ||
| * catches the other half (`assertRateLimitScope`) only fires for an app that said `'shared'`, so | ||
| * the silent case was exactly the one nobody declared. One process is still a legal answer; it is | ||
| * no longer an assumed one. | ||
| */ | ||
| export const rateLimitScopeUnset = (): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_SCOPE_UNSET', | ||
| cause: | ||
| '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', | ||
| 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", | ||
| }); | ||
| /** | ||
| * A `{ limit, windowMs }` pair the limiter cannot run on. Raised by `toBucket` (`rate-limit.ts`), | ||
| * which lives in this PACKAGE because http owns `Bucket` and the maths, and two tier-3 packages | ||
| * (`action`, `query`) need the same conversion without importing each other. | ||
| */ | ||
| export const rateLimitInvalid = (input: { | ||
| readonly owner: string; | ||
| readonly limit: number; | ||
| readonly windowMs: number; | ||
| readonly reason: string; | ||
| }): HttpError => | ||
| new HttpError({ | ||
| code: 'X_RATE_LIMIT_INVALID', | ||
| cause: `"${input.owner}" declares rateLimit { limit: ${input.limit}, windowMs: ${input.windowMs} }: ${input.reason}`, | ||
| 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`, | ||
| meta: { owner: input.owner, limit: input.limit, windowMs: input.windowMs }, | ||
| }); | ||
| /** | ||
| * At `defineHttpConfig`. `trustProxy` is a claim about the DEPLOYMENT — that something in front | ||
@@ -425,0 +303,0 @@ * rewrites `x-forwarded-for` — and the leftmost value in that header is whatever the client |
+22
-6
@@ -55,8 +55,2 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not | ||
| pipelineNoResponse, | ||
| rateLimitBucketConflict, | ||
| rateLimitBucketUnbound, | ||
| rateLimited, | ||
| rateLimitInvalid, | ||
| rateLimitNotShared, | ||
| rateLimitScopeUnset, | ||
| requestTimedOut, | ||
@@ -109,2 +103,3 @@ routeConflict, | ||
| memoryRateLimitStore, | ||
| rateLimitDecision, | ||
| rateLimitKey, | ||
@@ -115,2 +110,23 @@ resolveRateLimitConfig, | ||
| export { assertRouteBuckets, withRouteBuckets } from './rate-limit-buckets'; | ||
| export { | ||
| rateLimitBucketConflict, | ||
| rateLimitBucketUnbound, | ||
| rateLimited, | ||
| rateLimitInvalid, | ||
| rateLimitNotShared, | ||
| rateLimitScopeUnset, | ||
| rateLimitStoreUnavailable, | ||
| } from './rate-limit-errors'; | ||
| export type { | ||
| PgExecutor, | ||
| PostgresRateLimitStore, | ||
| PostgresRateLimitStoreOptions, | ||
| } from './rate-limit-postgres'; | ||
| export { | ||
| postgresRateLimitStore, | ||
| SQL_RATE_LIMIT_PURGE, | ||
| SQL_RATE_LIMIT_RESET, | ||
| SQL_RATE_LIMIT_TABLE, | ||
| SQL_RATE_LIMIT_TAKE, | ||
| } from './rate-limit-postgres'; | ||
| export { setRedirect, takeRedirect } from './redirect'; | ||
@@ -117,0 +133,0 @@ export type { QueryValues } from './request'; |
@@ -7,4 +7,4 @@ // Registration: a route that declares its own bucket puts it in the limiter's table. The bucket | ||
| import type { HttpConfig } from './config'; | ||
| import { rateLimitBucketConflict, rateLimitBucketUnbound } from './errors'; | ||
| import type { Bucket, RateLimiter } from './rate-limit'; | ||
| import { rateLimitBucketConflict, rateLimitBucketUnbound } from './rate-limit-errors'; | ||
| import type { Route } from './router'; | ||
@@ -11,0 +11,0 @@ |
+33
-11
@@ -6,3 +6,8 @@ // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in | ||
| import { type Clock, systemClock } from '@ultimat3/core'; | ||
| import { rateLimited, rateLimitInvalid, rateLimitNotShared, rateLimitScopeUnset } from './errors'; | ||
| import { | ||
| rateLimited, | ||
| rateLimitInvalid, | ||
| rateLimitNotShared, | ||
| rateLimitScopeUnset, | ||
| } from './rate-limit-errors'; | ||
@@ -152,15 +157,17 @@ /** | ||
| const decide = ( | ||
| state: BucketState, | ||
| /** | ||
| * The numbers a caller is owed, given what the bucket holds AFTER the take. Exported because a | ||
| * store that keeps its counters in Postgres does the refill and the spend in SQL and has nothing | ||
| * left to compute them with — and two drivers deriving `retryAfterSeconds` separately is two | ||
| * answers to "when may I come back", one of which is wrong. `allowed` is passed rather than | ||
| * inferred: `tokens` alone cannot tell a spend that landed at 0.5 from a refusal with 0.5 left. | ||
| */ | ||
| export const rateLimitDecision = ( | ||
| bucket: Bucket, | ||
| tokens: number, | ||
| cost: number, | ||
| allowed: boolean, | ||
| nowMs: number, | ||
| ): RateLimitDecision => { | ||
| const elapsedSeconds = Math.max(0, (nowMs - state.lastMs) / 1000); | ||
| const tokens = Math.min(bucket.capacity, state.tokens + elapsedSeconds * bucket.refillPerSecond); | ||
| state.lastMs = nowMs; | ||
| const allowed = tokens >= cost; | ||
| state.tokens = allowed ? tokens - cost : tokens; | ||
| state.forgetAtMs = forgetAt(state, bucket, nowMs); | ||
| const deficit = allowed ? bucket.capacity - state.tokens : cost - state.tokens; | ||
| const deficit = allowed ? bucket.capacity - tokens : cost - tokens; | ||
| // A bucket that never refills would give an infinite reset; clamp to a day so the | ||
@@ -173,3 +180,3 @@ // Retry-After header stays a number a client can act on. | ||
| limit: bucket.capacity, | ||
| remaining: Math.floor(state.tokens), | ||
| remaining: Math.floor(tokens), | ||
| resetAtMs: nowMs + Math.ceil(secondsToRefill * 1000), | ||
@@ -180,2 +187,17 @@ retryAfterSeconds: allowed ? 0 : Math.max(1, Math.ceil(secondsToRefill)), | ||
| const decide = ( | ||
| state: BucketState, | ||
| bucket: Bucket, | ||
| cost: number, | ||
| nowMs: number, | ||
| ): RateLimitDecision => { | ||
| const elapsedSeconds = Math.max(0, (nowMs - state.lastMs) / 1000); | ||
| const tokens = Math.min(bucket.capacity, state.tokens + elapsedSeconds * bucket.refillPerSecond); | ||
| state.lastMs = nowMs; | ||
| const allowed = tokens >= cost; | ||
| state.tokens = allowed ? tokens - cost : tokens; | ||
| state.forgetAtMs = forgetAt(state, bucket, nowMs); | ||
| return rateLimitDecision(bucket, state.tokens, cost, allowed, nowMs); | ||
| }; | ||
| /** | ||
@@ -182,0 +204,0 @@ * Hard bound on tracked keys. A key is `route|subject`, so one subject throttled on N routes is |
+7
-3
@@ -31,3 +31,2 @@ // One function per stage name: what each stage of the lifecycle DOES. The package's three-way | ||
| pathInvalid, | ||
| rateLimited, | ||
| routeNotFound, | ||
@@ -41,2 +40,3 @@ unauthenticated, | ||
| import { type RateLimiter, rateLimitKey } from './rate-limit'; | ||
| import { rateLimited } from './rate-limit-errors'; | ||
| import type { UltimateRequest } from './request'; | ||
@@ -271,7 +271,11 @@ import { addVary, applyCacheHeaders, problem, redirect } from './response'; | ||
| // here is exactly how a framework ends up with two authz systems. | ||
| throw forbidden(ctx.url.pathname, `no authorizer wired for policy ${route.meta.policy}`); | ||
| throw forbidden( | ||
| ctx.url.pathname, | ||
| `no authorizer wired for policy ${route.meta.policy}`, | ||
| route.meta.policy, | ||
| ); | ||
| } | ||
| const decision = await hooks.authorize(route, request, ctx); | ||
| ctx.authz = decision; | ||
| if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason); | ||
| if (!decision.allowed) throw forbidden(ctx.url.pathname, decision.reason, route.meta.policy); | ||
| return undefined; | ||
@@ -278,0 +282,0 @@ }, |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
279575
6.69%37
5.71%4737
6.12%214
21.59%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated