New:Socket for Asana Is Now Available.Learn more
Get Started

@ultimat3/http

Package Overview
Dependencies
Maintainers
1
Versions
27
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ultimat3/http - npm Package Compare versions

Comparing version
7.0.0
to
8.0.0
+163
src/rate-limit-errors.ts
// 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"
}
}

@@ -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

@@ -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

@@ -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

@@ -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 @@

@@ -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

@@ -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 @@ },