New:Socket for Asana Is Now Available.Learn more
Sign In

@ultimat3/http

Package Overview
Dependencies
Maintainers
1
Versions
26
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
11.3.0
to
12.0.0
+146
src/app-config.ts
// The app's own HTTP declaration, and the layering that keeps a boot fact above it. One
// registration site, read once by whatever process starts the web role — the same seam
// `configureAuthenticator()` is, and for the same reason: `@ultimat3/core` is tier 0 and cannot
// hold this package's types, so an `http` block on `AppConfig` would be a second declaration of
// `HttpConfigInput` in a package that can never check it against this one.
import type { HttpConfigInput } from './config';
import type { RateLimitConfig } from './rate-limit';
/**
* The keys the BOOT owns, and the reason this type is an `Omit` rather than a hand-written list of
* what an app may say. Each of these is a fact about the PROCESS — the port it was told to bind,
* the build it serves, whether it is `x dev`, how many proxies the deployment puts in front of it,
* `auth.signInPath` from `app.config.ts` — so a value an app wrote for one of them would be
* overwritten at every boot: a switch with no wire, which is the defect this whole surface exists
* to remove. Refused at the type level, which is the build error that enforces it.
*/
export type BootOwnedHttpKey =
| 'port'
| 'hostname'
| 'dev'
| 'buildId'
| 'signInPath'
| 'trustProxy'
| 'trustedProxyHops';
/**
* What an app declares. `rateLimit.scope` is boot-owned for the same reason as the keys above:
* `startWeb` DERIVES it from the store it installed, so a literal here would be a second
* declaration quietly contradicting the object beside it — and `assertRateLimitScope` compares
* exactly those two halves.
*/
export type AppHttpConfig = Omit<HttpConfigInput, BootOwnedHttpKey | 'rateLimit'> & {
readonly rateLimit?: Omit<Partial<RateLimitConfig>, 'scope'> | undefined;
};
/**
* The app's declaration, if it made one. A single value and not a list, exactly as
* `configuredAuthenticator` is: two answers to "how does this server bind and what does it admit"
* is two configurations, and the one that ran first wins.
*
* Process-global for the reason that one is: the app has exactly one boot, and every host that
* starts a server (`x dev`, `apps/web/server.ts`) would otherwise need its own way to be handed
* the same values — which is what left the whole tuning surface unreachable, since the only
* shipped construction was a fixed literal inside the CLI.
*/
let declared: AppHttpConfig | undefined;
export const configureHttp = (config: AppHttpConfig): void => {
declared = config;
};
/** What the boot layers its own facts over. `undefined` means the locked defaults stand. */
export const configuredHttp = (): AppHttpConfig | undefined => declared;
/** Test seam. Production configures once at module scope and never unconfigures. */
export const resetHttpConfig = (): void => {
declared = undefined;
};
type SecurityInput = NonNullable<HttpConfigInput['security']>;
type CspInput = NonNullable<SecurityInput['csp']>;
type CspExtend = NonNullable<CspInput['extend']>;
/**
* Per directive, both lists. The app's `script-src` is a CDN it serves scripts from and the boot's
* is the sha256 of the hydration runtime this process emits inline — each is the whole answer for
* something, so a merge that let either win breaks a page: the CDN script, or every island.
*
* Built through a `Map`, never by assigning `out[directive]`: a directive named `__proto__` sets
* the PROTOTYPE rather than a key, which is a source silently dropped from the one header this
* package locks down hardest.
*/
const mergeCspExtend = (
app: CspExtend | undefined,
boot: CspExtend | undefined,
): CspExtend | undefined => {
if (app === undefined) return boot;
if (boot === undefined) return app;
const merged = new Map<string, readonly string[]>(Object.entries(app));
for (const [directive, sources] of Object.entries(boot)) {
merged.set(directive, [...(merged.get(directive) ?? []), ...sources]);
}
return Object.fromEntries(merged);
};
const mergeSecurity = (
app: SecurityInput | undefined,
boot: SecurityInput | undefined,
): SecurityInput | undefined => {
if (app === undefined) return boot;
if (boot === undefined) return app;
const extend = mergeCspExtend(app.csp?.extend, boot.csp?.extend);
const csp: CspInput = {
...app.csp,
...boot.csp,
...(extend === undefined ? {} : { extend }),
};
return { ...app, ...boot, csp };
};
/**
* The app's declaration with the boot's own facts laid OVER it — the one order that can be right.
* `buildId`, the port, the CSP hashes of what this process emits and the scope of the store it
* installed are all things the boot measured; an app can only have guessed at them. Everything
* else the app said survives, which is the whole point of it having said anything.
*
* Sections merge one level down rather than being replaced whole: `security: { csp: { extend } }`
* from the boot would otherwise delete an app's `hsts`, `frameAncestors` and its own extends, and
* `rateLimit: { scope }` would delete every bucket it declared.
*/
export const mergeHttpConfig = (
app: AppHttpConfig | undefined,
boot: HttpConfigInput,
): HttpConfigInput => {
if (app === undefined) return boot;
// `rateLimit` is lifted out of the spread rather than overwritten by it: `AppHttpConfig` types
// it WITHOUT `scope` and `HttpConfigInput` types it with, so under `exactOptionalPropertyTypes`
// the spread of the narrower optional is not assignable to the wider one. The merged value is
// computed below and put back.
const { rateLimit: appRateLimit, ...appRest } = app;
const cors =
app.cors === undefined && boot.cors === undefined ? undefined : { ...app.cors, ...boot.cors };
const csrf =
app.csrf === undefined && boot.csrf === undefined ? undefined : { ...app.csrf, ...boot.csrf };
const locale =
app.locale === undefined && boot.locale === undefined
? undefined
: { ...app.locale, ...boot.locale };
const tz = app.tz === undefined && boot.tz === undefined ? undefined : { ...app.tz, ...boot.tz };
const rateLimit: Partial<RateLimitConfig> | undefined =
appRateLimit === undefined && boot.rateLimit === undefined
? undefined
: { ...appRateLimit, ...boot.rateLimit };
const security = mergeSecurity(app.security, boot.security);
return {
...appRest,
...boot,
...(cors === undefined ? {} : { cors }),
...(csrf === undefined ? {} : { csrf }),
...(locale === undefined ? {} : { locale }),
...(tz === undefined ? {} : { tz }),
...(rateLimit === undefined ? {} : { rateLimit }),
...(security === undefined ? {} : { security }),
};
};
// Every RENDERING of a throwable the framework has: the normalised facts, the RFC-9457 problem
// document and the three lines the terminal and the overlay print. Split off `error-map.ts` at the
// 500-line ceiling — that file answers "what status is this code", one closed table, and this one
// answers "what does a reader see", which is three audiences and one opacity rule.
import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
import { declaredStatusFor, statusFor } from './error-map';
import { HTTP_ERROR_TITLES } from './errors';
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
export interface ErrorFacts {
readonly code: string;
readonly title: string;
readonly cause: string;
readonly fix: string;
readonly docs: string;
readonly status: number;
/** Present only when the process is in dev mode; never sent to a client in prod. */
readonly stack: string | undefined;
}
/**
* One string field off the throwable, through core's `stringField`. The read is a getter call —
* or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
* place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
* the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
* renderings and `handle()` rejected against its own contract.
*/
const str = (source: unknown, key: string): string | undefined => {
const value = stringField(source, key);
return value !== undefined && value.length > 0 ? value : undefined;
};
/**
* Normalises any throwable into the framework's error contract. Non-Ultimate
* throwables still get a code and a fix, because "errors are instructions" has to
* hold for the accidental `TypeError` too.
*/
export const factsOf = (error: unknown): ErrorFacts => {
const code = str(error, 'code') ?? 'X_INTERNAL';
// The error's own title first: every `UltimateError` resolves one from the code registry at
// construction, so this renders the OWNING package's title — including the codes http only
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
const title =
str(error, 'title') ??
// `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
// function off the prototype and put it in `title`, which is rendered into the problem
// document and the terminal.
(Object.hasOwn(HTTP_ERROR_TITLES, code)
? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
: undefined) ??
str(error, 'message') ??
'unhandled server error';
// The last fallback is the only one that touches the throwable whole, and every throwable a
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
// took the request down took the 500 renderer with it and the server had nothing left to send.
const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
return {
code,
title,
cause,
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
// `x errors explain` ships, and it is the command that answers "what is this code".
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
// resolved this at construction, so the fallback only fires for a throwable the framework did
// not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
// 404 on every problem document this package has ever rendered.
docs: str(error, 'docs') ?? ERROR_DOCS_URL,
status: statusFor(code),
stack: str(error, 'stack'),
};
};
/**
* `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
*
* The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
* `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
* HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
* same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
* both told the caller to come back and never said when — which is the shed-with-no-delay pattern
* the `admit` stage exists to avoid, one layer in.
*
* Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
* not build, and it is read in the frame that decides what the caller sees.
*/
export function retryAfterOf(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) return undefined;
try {
const meta: unknown = (error as Record<string, unknown>)['meta'];
if (typeof meta !== 'object' || meta === null) return undefined;
const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
// At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
// as "retry now", which is the stampede a Retry-After exists to spread.
return Math.max(1, Math.ceil(seconds));
} catch {
return undefined;
}
}
/**
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
* to read about it, and those stopped being the same string when `docs` became one wiki page for
* every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
* forbidden the same identifier, which is the one thing a `type` may not do.
*
* A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
* — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
* `code` carries the same string as a plain member for a reader that would rather not parse a URI.
*/
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
export interface ProblemDocument {
readonly type: string;
readonly title: string;
readonly status: number;
readonly detail: string;
readonly instance: string | undefined;
readonly code: string;
readonly cause: string;
readonly fix: string;
readonly docs: string;
readonly requestId: string | undefined;
}
/** The title a caller gets for a failure the framework cannot name. */
const INTERNAL_TITLE = 'unhandled server error';
/**
* The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
* falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
* absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
* out of exactly this and said so in its header; the two audiences then disagreed about one
* condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
* reports every 5xx to the error monitor, both keyed by the request id below.
*/
const INTERNAL_CAUSE =
'the server failed while handling this request; the details are in this process\u2019s logs and ' +
'error reports, under this request id';
/**
* A 5xx nobody declared a status for — not the framework's table, not the app's
* `registerErrorStatus` — or one whose code is `X_INTERNAL`. That is the discriminator, and not
* `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would
* take away the one instruction in it.
*
* `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's
* own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and
* core's `toError()` wraps a caught value into an `InternalError` whose cause is
* `renderCauseValue(value)` — the driver's message, verbatim. Nothing in an `X_INTERNAL` is
* actionable by the caller; the code and the request id are.
*/
const isUnclassifiedFailure = (code: string, status: number): boolean =>
status >= 500 && (code === 'X_INTERNAL' || declaredStatusFor(code) === undefined);
export const toProblem = (
error: unknown,
meta: { instance?: string; requestId?: string; dev?: boolean } = {},
): ProblemDocument => {
const facts = factsOf(error);
const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
return {
type: problemTypeFor(facts.code),
title: opaque ? INTERNAL_TITLE : facts.title,
status: facts.status,
detail: opaque ? INTERNAL_CAUSE : facts.cause,
instance: meta.instance,
code: facts.code,
cause: opaque ? INTERNAL_CAUSE : facts.cause,
fix: facts.fix,
docs: facts.docs,
requestId: meta.requestId,
};
};
/** The exact three lines the terminal prints, reused by the overlay and `--json`. */
export const renderErrorLines = (error: unknown): string => {
const facts = factsOf(error);
// The newlines here are the format's own. Every interpolated field goes through `singleLine`
// so a caller-controlled value cannot add a third one — this string is rendered into the dev
// overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
return [
`${singleLine(facts.code)}: ${singleLine(facts.title)}`,
` cause: ${singleLine(facts.cause)}`,
` fix: ${singleLine(facts.fix)}`,
].join('\n');
};
+57
-2

@@ -23,2 +23,23 @@ # @ultimat3/http

- Route `meta.auth` is required. Never default a route to public.
- **An app declares its half of `HttpConfig` through `configureHttp()`, and the boot lays its own
facts over it** (`As of 2026-08-24`). Until 12.0.0 the entire tuning surface was **unreachable
from a shipped app**: `AppConfig` has never had an `http` key, `RuntimeOverrides` carries none,
and the only construction any shipped process made was one fixed literal in
`packages/cli/src/dev-roles.ts` passing eight boot facts — so `DEFAULT_CORS.origins` was `[]` in
every deployment (an SPA on `app.example.com` calling `api.example.com` could not work, ever),
`bodyLimitBytes` was 1 MiB for a 4 MB CSV endpoint, `requestTimeoutMs` 30s for a five-minute
export, and `rateLimit.buckets` was 120 burst / 2 rps for a bank and a blog alike. Fourteen
`fix:` lines told the reader to edit `http.<key>` in `app.config.ts`, which has never held one.
It is a registration and not a config key for `configureAuthenticator`'s reason, stated in
`hooks.ts`: `@ultimat3/core` is tier 0 and cannot hold this package's types, so an `http` block
on `AppConfig` would be a **second declaration** of `HttpConfigInput` in a package that can
never check it against this one. `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>`,
**derived, never listed**: a key the boot always overwrites (`port`, `hostname`, `dev`,
`buildId`, `signInPath`, `trustProxy`, `trustedProxyHops`, `rateLimit.scope`) is a type error
where an app writes it, rather than a value silently discarded at every boot. `mergeHttpConfig`
merges one level down — `security.csp.extend` per DIRECTIVE, because the app's CDN source and
the boot's inline-script hash are each the whole answer for something, and either alone breaks a
page. `type-pins.ts` holds the other half: a key on `HttpConfig` and not on `HttpConfigInput` is
a build error, which `scripts/config-readers.ts` cannot see — that ratchet walks `AppConfig` and
asks whether a key is READ, and this is the mirror question.
- **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx`, and

@@ -64,2 +85,10 @@ `asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set

the timer's alone: it answers the SOCKET, and a caller that hung up has no socket to answer.
**And it leaves this process on the next hop's headers, `As of 2026-08-24`**:
`Deadline.deadlineAt` is published as core's `ctx.deadlineAt`, and `traceHeaders()` (tier 0, the
one thing both typed clients spread before the caller's own headers) sends what is LEFT as
`x-request-timeout-ms`. Before that the header had exactly one reader — `resolveTimeoutMs`, in
this file — and **zero writers anywhere in the tree**, so gateway → A (30s) → B meant a call made
at t=29 started B on a FRESH 30s: real work, holding a pool slot and a vendor connection, half a
minute after A's socket was answered `X_TIMEOUT`. A spent budget sends no header at all rather
than `0`, because `resolveTimeoutMs` ignores anything under 1ms and falls back to its own.
With `requestTimeoutMs: 0` the caller's signal is handed through as-is rather than the shared

@@ -247,2 +276,10 @@ never-aborted singleton, which every such request used to share — one `abort` listener per

cannot drift. Never assert either value as a copied string — import the constant.
- **`error-map.ts` answers the status; `error-facts.ts` renders the throwable** (`As of
2026-08-24`). One file did both and reached 501 lines, over the ceiling. The seam between
them is `declaredStatusFor(code)` — `number | undefined`, exported to this package only —
because the two questions are genuinely different: `statusFor` always answers a number, while
"did ANYBODY classify this code" is what decides whether a 5xx may carry the throwable's own
words back to the caller (`isUnclassifiedFailure`). Imports go one way, `error-facts.ts` →
`error-map.ts`; a status read from the facts file would be the second table this package
spent a release deleting.
- Statuses live in `error-map.ts` only. No other file writes a status number. The framework's

@@ -315,4 +352,20 @@ table (`ERROR_STATUS`) is closed; an app declares its own codes' statuses with

not one.
- **One request spends a LIST of rate-limit keys, and the tenant's is the second** (`As of
2026-08-24`). `rateLimitKey` picked ONE subject — actor > org > ip, exclusive — and `actorView`
answers `null` for anonymous, so `orgId` was consulted only for a caller with an org and no id:
**no authenticated request ever touched an org bucket**. A tenant with 8,000 seats whose
integration entered a retry loop therefore took 8,000 × the per-actor burst against one shared
pool, every bucket inside its own limit, and no number an operator could set would have refused
it — while `@ultimat3/jobs` has had `perTenant` since it shipped. `rateLimitSpends` answers the
caller's key **and** `tenant|org:<id>` when the app declared `rateLimit.tenantBucket`; the stage
spends them in order and stops at the first refusal, so a caller its own bucket already refused
costs its tenant nothing. The tenant key is deliberately NOT scoped to the route — a per-route
tenant bucket is the same number multiplied by the route table, which is not a cap. `null` is
the default because one tenant is a person and the next is five thousand seats (axiom 8), and a
name nothing declares is `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, never a
silent fall-through to `default`. The headers report the bucket **closest to refusing**: telling
a client `remaining: 99` off its own bucket while its tenant's holds 2 is a number that plans a
caller into a 429.
- **The memory rate-limit store is bounded, and the eviction order is part of the guarantee.**
The key falls back to the connection address (`rateLimitKey`), so a scan rotating through an
The key falls back to the connection address (`rateLimitSpends`), so a scan rotating through an
IPv6 /64 mints one entry per request — an unbounded map hands the flood the process. Every

@@ -409,3 +462,4 @@ entry carries `forgetAtMs`, the instant a refilled bucket becomes indistinguishable from a

| `router.ts` | trie matcher, precedence static > param > wildcard, `path-invalid` for a segment that will not decode |
| `error-map.ts` | code → status table + `factsOf()` |
| `error-map.ts` | the code → status table, closed, plus the app's half (`registerErrorStatus`) |
| `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document, the three terminal lines |
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |

@@ -429,2 +483,3 @@ | `type-pins.ts` | compile-time claims about `AuthzDecision`'s shape — source, because `tsc` never reads a `.test.ts` |

| `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |
| `app-config.ts` | the app's own HTTP declaration (`configureHttp`) and the layering that keeps a boot fact above it |

@@ -431,0 +486,0 @@ ## Commands

+5
-5
{
"name": "@ultimat3/http",
"version": "11.3.0",
"version": "12.0.0",
"description": "Owned request lifecycle over Bun.serve: router, ordered pipeline, problem+json errors",

@@ -34,7 +34,7 @@ "license": "MIT",

"dependencies": {
"@ultimat3/core": "11.3.0",
"@ultimat3/i18n": "11.3.0",
"@ultimat3/schema": "11.3.0",
"@ultimat3/time": "11.3.0"
"@ultimat3/core": "12.0.0",
"@ultimat3/i18n": "12.0.0",
"@ultimat3/schema": "12.0.0",
"@ultimat3/time": "12.0.0"
}
}

@@ -16,4 +16,6 @@ # @ultimat3/http 🌐

| response constructors + `problem()` | `response.ts` |
| code → status, `factsOf()` | `error-map.ts` |
| code → status, closed table | `error-map.ts` |
| `factsOf()`, the problem document, the terminal lines | `error-facts.ts` |
| token-bucket limiting, `toBucket` | `rate-limit.ts` |
| the app's own HTTP declaration, and the boot's facts over it | `app-config.ts` |
| CORS, CSP/HSTS | `cors.ts`, `security-headers.ts` |

@@ -26,2 +28,33 @@ | CSRF (origin proof for a credentialed write) | `csrf.ts` |

## What an app declares: `configureHttp()`
```ts
// apps/web/app/http.ts — module scope, imported by the app like any other module
import { configureHttp } from '@ultimat3/http';
configureHttp({
cors: { origins: ['https://app.example.com'], credentials: true },
bodyLimitBytes: 8 * 1024 * 1024, // this API takes a 4 MB CSV
requestTimeoutMs: 300_000, // and an export that really does take five minutes
rateLimit: {
tenantBucket: 'tenant',
buckets: { tenant: { capacity: 5_000, refillPerSecond: 100 } },
},
});
```
One registration, read once by whatever process starts the web role — the same seam
`configureAuthenticator()` is. **Breaking, `As of 2026-08-24`**: before it, the only `HttpConfig`
any shipped process built was a fixed literal inside `@ultimat3/cli`, so none of the four values
above could be set from an app at all — `cors.origins` was `[]` in every deployment, which refuses
every cross-origin browser call, permanently. `AppConfig` has never carried an `http` key and does
not gain one: `@ultimat3/core` is tier 0 and cannot hold this package's types.
`AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>` — `port`, `hostname`, `dev`,
`buildId`, `signInPath`, `trustProxy`, `trustedProxyHops` and `rateLimit.scope` are the boot's, and
writing one here is a **type error** rather than a value silently overwritten at the next boot.
`mergeHttpConfig(configuredHttp(), boot)` is the layering, and it merges `security.csp.extend` per
directive: the app's CDN source and the boot's inline-script hash are each the whole answer for
something.
## The pipeline is the guarantee

@@ -62,2 +95,3 @@

| a route's own bucket and a configured bucket of that name disagreeing | `X_RATE_LIMIT_BUCKET_CONFLICT` at `createServer`, because the loser would be a number someone read and nothing applied |
| a `rateLimit.tenantBucket` naming a bucket nothing declares | `X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN` at `defineHttpConfig`, because the name would fall through to `default` and a whole tenant's cap would silently be the 120-burst read bucket |
| an injected limiter that does not hold a bucket a route declares | `X_RATE_LIMIT_BUCKET_UNBOUND` at `createPipeline`, because the name would fall through to `default` — measured at 120 burst for a route declaring 5 |

@@ -108,2 +142,14 @@ | a config that never declared `rateLimit.scope` | `X_RATE_LIMIT_SCOPE_UNSET` at `defineHttpConfig`. **Breaking, `As of 2026-08`**: `'process'` used to be the default, so "nobody asked" and "the app said one replica" were the same value while the chart runs three |

**One request spends a LIST of keys, `As of 2026-08-24`** — the caller's (`actor`, else `org`,
else `ip`) and, when the app declared `rateLimit.tenantBucket`, that caller's tenant. The key
builder used to pick exactly ONE subject and consult `orgId` only when there was no actor id,
which no authenticated request ever satisfies: a tenant with 8,000 seats took 8,000 × the
per-actor burst against one shared pool, every bucket inside its own limit, and no number an
operator could set would have refused it. The tenant key is `tenant|org:<id>` and is deliberately
NOT scoped to the route — a per-route tenant bucket is the same allowance once per route. The
spend stops at the first refusal, so a caller its own bucket refused costs its tenant nothing, and
the `ratelimit-*` headers report the bucket closest to refusing. `tenantBucket` defaults to `null`:
one tenant is a person and the next is five thousand seats, so there is no allowance a framework
can pick for you.
**A shared store ships, `As of 2026-08`** — `postgresRateLimitStore({ executor })`, one table

@@ -224,5 +270,5 @@ and one `insert … on conflict` per take, so N replicas count against one bucket. Until it landed,

Tier 2. Imports `@ultimat3/core` and `@ultimat3/schema` only. Authentication and
policy evaluation arrive through `ServerHooks`, declared structurally, because
`@ultimat3/policy` is a sibling tier. There is no plugin API: `Middleware` wraps a
handler, the pipeline is everything else.
Tier 2. Imports `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n` and `@ultimat3/time` —
tiers 0 and 1, which is the whole rule. Authentication and policy evaluation arrive through
`ServerHooks`, declared structurally, because `@ultimat3/policy` is a sibling tier. There is no
plugin API: `Middleware` wraps a handler, the pipeline is everything else.

@@ -1,3 +0,6 @@

// The HTTP slice of `app.config.ts`. One resolver, so a value is either a locked
// default or an explicit override — never "whatever the first caller passed".
// The resolver every HTTP config goes through, so a value is either a locked default or an
// explicit override — never "whatever the first caller passed". It is NOT a slice of
// `app.config.ts`, which this file claimed for four majors while `AppConfig` has never carried an
// `http` key: an app declares its half through `configureHttp()` (`app-config.ts`) and the boot
// lays its own facts over it before calling this.
import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';

@@ -4,0 +7,0 @@ import { assertCorsConfig, type CorsConfig, DEFAULT_CORS } from './cors';

@@ -83,2 +83,7 @@ // The per-request context. It is created by the pipeline before any user code runs

readonly signal: AbortSignal;
/**
* The instant `signal` will fire at, or `null` with `requestTimeoutMs: 0`. Core's field: a
* signal can only say "already over", and an outbound hop has to say how much is LEFT.
*/
readonly deadlineAt: number | null;
readonly services: ServiceBag;

@@ -139,2 +144,4 @@

readonly signal?: AbortSignal;
/** `Deadline.deadlineAt` — epoch ms. Absent means this request has no budget. */
readonly deadlineAt?: number | null;
readonly services?: ServiceBag;

@@ -179,2 +186,3 @@ }

signal: init.signal ?? NEVER_ABORTED,
deadlineAt: init.deadlineAt ?? null,
// Frozen and explicit. `defineService` factories are NOT installed here: core does not

@@ -181,0 +189,0 @@ // export the installer, so the honest answer for a service nothing passed is

@@ -1,3 +0,3 @@

// CORS with a locked default: same-origin only. Cross-origin access is a decision
// the app makes in app.config.ts, never something a route can quietly opt into.
// CORS with a locked default: same-origin only. Cross-origin access is a decision the app makes
// once, in `configureHttp({ cors })`, never something a route can quietly opt into.

@@ -4,0 +4,0 @@ import { corsConfigInvalid } from './errors';

@@ -7,2 +7,3 @@ // The per-request deadline: the one thing that makes `ctx.signal` real. Nothing in this package

import { REQUEST_TIMEOUT_HEADER, systemClock } from '@ultimat3/core';
import type { HttpConfig } from './config';

@@ -14,4 +15,9 @@ import { requestTimedOut } from './errors';

* proxy, because the only thing it can buy an attacker is a faster 504 for their own request.
*
* The name is core's, re-exported rather than declared twice: this package READS the header and
* `@ultimat3/core`'s typed-client wire path WRITES it, and a second literal is a propagation that
* stops working the day one of the two strings is edited. Same shape as `logger.ts` re-exporting
* `REDACTED` — one definition, one public path.
*/
export const REQUEST_TIMEOUT_HEADER = 'x-request-timeout-ms';
export { REQUEST_TIMEOUT_HEADER };

@@ -21,2 +27,9 @@ export interface Deadline {

readonly signal: AbortSignal;
/**
* Epoch ms the budget runs out at, or `null` when there is none — `ctx.deadlineAt`, and what an
* outbound hop subtracts `now` from. Real monotonic time (`systemClock`), never an injected
* clock, for the reason the drain budget is: the timer beside it runs on `setTimeout`, so a
* frozen clock would publish an instant the abort will not honour.
*/
readonly deadlineAt: number | null;
/** Rejects with `X_TIMEOUT` at the deadline; `undefined` when there is no deadline. */

@@ -33,2 +46,3 @@ readonly expired: Promise<never> | undefined;

signal: NEVER_ABORTED,
deadlineAt: null,
expired: undefined,

@@ -76,2 +90,4 @@ timeoutMs: 0,

const controller = new AbortController();
// Read BEFORE the timer is armed, so the published instant is never later than the abort.
const deadlineAt = systemClock.now().getTime() + timeoutMs;
let fire: (() => void) | undefined;

@@ -91,2 +107,3 @@ const expired = new Promise<never>((_resolve, reject) => {

return {
deadlineAt,
// Both halves, or the doc on `ctx.signal` is half true — which it was: nothing in this package

@@ -93,0 +110,0 @@ // read the inbound signal, so a browser closing the tab left the request holding its pool slot

// The one place a framework error code becomes an HTTP status. A table, not a
// switch chain: adding a code elsewhere in the framework means adding a row here,
// and a missing row is a loud 500 rather than a silently wrong 200.
import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
import { errorStatusInvalid, HTTP_ERROR_TITLES } from './errors';
// Rendering a throwable for a reader is `error-facts.ts`; this file answers only the status.
import { errorStatusInvalid } from './errors';

@@ -51,4 +51,6 @@ /**

X_RATE_LIMIT_BUCKET_UNBOUND: 500,
// `defineHttpConfig` time, both of them: a declaration the deployment owes and did not make.
// `defineHttpConfig` time, all three: a declaration the deployment owes and did not make, or
// made against a bucket nothing declares.
X_RATE_LIMIT_SCOPE_UNSET: 500,
X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 500,
X_TRUST_PROXY_UNSET: 500,

@@ -142,2 +144,19 @@ // Raised by `toBucket` while a route or an action is being projected, never on the request.

X_TENANCY_CROSS_DENIED: 403,
// The three aggregate refusals, all 500 and all deliberately NOT a 4xx, for the reason
// `X_QUERY_NOT_PAGEABLE` below is one: nothing the caller sends changes the answer, and the fix
// is an edit to the read itself. They earn ROWS rather than a pin in `scripts/error-map-backlog.ts`
// because each carries an instruction the app's author needs and an unmapped 5xx is blanked —
// `toProblem` replaces an undeclared code's cause with `INTERNAL_CAUSE`, so pinning them would
// answer "the server failed while handling this request" for a fault whose own `fix:` names the
// exact call to write instead. A row costs no extra page: `stages.ts` reports every `status >= 500`
// either way.
//
// Reached with a request waiting, all three, which is why they are not in the backlog's entity
// group ("misuse a handler's author makes") — the mixed-currency and the ±2^53 refusals are
// decided by the ROWS, so a read that answered for two years starts failing on the day the data
// crosses the line, and `approximateCount()` on a chain whose predicates came from the caller's
// own optional filters is one query string away.
X_AGGREGATE_UNSUPPORTED: 500,
X_AGGREGATE_MIXED_CURRENCY: 500,
X_APPROXIMATE_COUNT_FILTERED: 500,
// @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique

@@ -221,2 +240,10 @@ // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the

X_MAIL_CREDENTIAL_MISSING: 500,
// @ultimat3/mcp — the one MCP code that is answered on a REQUEST rather than inside a JSON-RPC
// envelope, which is what the rest of that package's backlog group says about the others: the
// transport refused before dispatch, so there is no call to answer. 429 because
// `mcpHttpRoute` already builds that response by hand (`transport-http.ts`'s `throttled`), with
// `retry-after` beside it. The row is what keeps the two surfaces from disagreeing the day the
// MCP host is mounted inside this pipeline — a code that renders 429 on one and 500 on the other
// is exactly the split this table exists to prevent.
X_MCP_RATE_LIMITED: 429,
// @ultimat3/core

@@ -309,195 +336,17 @@ // The caller asked for a format the pipeline cannot produce (`?f=avif`): the request names an

// Framework table first: `registerErrorStatus` already refuses those codes, so the order is
// belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
// even if a future caller reaches the map some other way.
// `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
// prefer one for anything keyed by a value a caller chose.
export const statusFor = (code: string): number =>
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code) ?? DEFAULT_STATUS;
/** Everything a renderer (problem+json, overlay, terminal) needs from a throwable. */
export interface ErrorFacts {
readonly code: string;
readonly title: string;
readonly cause: string;
readonly fix: string;
readonly docs: string;
readonly status: number;
/** Present only when the process is in dev mode; never sent to a client in prod. */
readonly stack: string | undefined;
}
/**
* One string field off the throwable, through core's `stringField`. The read is a getter call —
* or a `Proxy`'s `get` trap — on a value the framework did not build, and it throws in the one
* place with nothing left to answer with: `factsOf` is called by the RECOVER stage, and again by
* the `problem()` that `recoverWith` degrades to, so a value that refuses to be read took both
* renderings and `handle()` rejected against its own contract.
*/
const str = (source: unknown, key: string): string | undefined => {
const value = stringField(source, key);
return value !== undefined && value.length > 0 ? value : undefined;
};
/**
* Normalises any throwable into the framework's error contract. Non-Ultimate
* throwables still get a code and a fix, because "errors are instructions" has to
* hold for the accidental `TypeError` too.
*/
export const factsOf = (error: unknown): ErrorFacts => {
const code = str(error, 'code') ?? 'X_INTERNAL';
// The error's own title first: every `UltimateError` resolves one from the code registry at
// construction, so this renders the OWNING package's title — including the codes http only
// borrows (`X_FORBIDDEN` is policy's, `X_UNAUTHENTICATED` is auth's) and so cannot title itself.
// Falling through to `message` here shipped the code twice: `X_FORBIDDEN: policy denied… — …`.
const title =
str(error, 'title') ??
// `Object.hasOwn` for `statusFor`'s reason, one table over: `code: 'toString'` read the
// function off the prototype and put it in `title`, which is rendered into the problem
// document and the terminal.
(Object.hasOwn(HTTP_ERROR_TITLES, code)
? HTTP_ERROR_TITLES[code as keyof typeof HTTP_ERROR_TITLES]
: undefined) ??
str(error, 'message') ??
'unhandled server error';
// The last fallback is the only one that touches the throwable whole, and every throwable a
// request produces reaches it. `String()` runs the value's own `toString`, so the value that
// took the request down took the 500 renderer with it and the server had nothing left to send.
const cause = str(error, 'cause') ?? str(error, 'message') ?? renderCauseValue(error);
return {
code,
title,
cause,
// `x logs tail` is in `PLANNED_COMMANDS` — it exits `X_NOT_IMPLEMENTED`. A fix line naming a
// command that throws is axiom 4 inverted: the one instruction the reader is given fails.
// `x errors explain` ships, and it is the command that answers "what is this code".
fix: str(error, 'fix') ?? `x errors explain ${code} --json # then fix the throwing call site`,
// Core's one constant, never a per-code URL: `wiki/` is the only public documentation surface
// and a code lives there in a table row, which has no anchor. An `UltimateError` already
// resolved this at construction, so the fallback only fires for a throwable the framework did
// not build — and it must not be the `https://ultimate.dev/errors/<code>` link that answered
// 404 on every problem document this package has ever rendered.
docs: str(error, 'docs') ?? ERROR_DOCS_URL,
status: statusFor(code),
stack: str(error, 'stack'),
};
};
/**
* `Retry-After`, in whole seconds, for a refusal that computed one — or `undefined`.
* The status SOMEBODY declared for a code — the framework or the app — or `undefined` when
* nobody did. The two questions `statusFor` used to answer at once are separate on purpose:
* "what do we answer" is always a number, and "did anyone classify this" is what `error-facts.ts`
* reads to decide whether a 5xx may carry the throwable's own words back to the caller.
*
* The contract it reads is already written down by the packages BELOW this one: `@ultimat3/auth`'s
* `kdfOverloaded` says "`retryAfterSeconds` rides in `meta` because this package cannot reach an
* HTTP header; the host reads it onto `Retry-After`", and `rateLimited` in this package carries the
* same field. Nothing was the host. So a 503 shed by the KDF gate and a 429 from an account lockout
* both told the caller to come back and never said when — which is the shed-with-no-delay pattern
* the `admit` stage exists to avoid, one layer in.
*
* Total, for `str`'s reason one function up: `meta` is a property read on a value this package did
* not build, and it is read in the frame that decides what the caller sees.
* Framework table first: `registerErrorStatus` already refuses those codes, so the order is
* belt-and-braces — but it is the belt that makes "the framework's statuses are fixed" true
* even if a future caller reaches the map some other way.
* `APP_ERROR_STATUS` is a `Map`, which is why its half never had `frameworkStatus`'s defect —
* prefer one for anything keyed by a value a caller chose.
*/
export function retryAfterOf(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null) return undefined;
try {
const meta: unknown = (error as Record<string, unknown>)['meta'];
if (typeof meta !== 'object' || meta === null) return undefined;
const seconds: unknown = (meta as Record<string, unknown>)['retryAfterSeconds'];
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
// At least one second, exactly as `RateLimitDecision.retryAfterSeconds` is clamped: `0` reads
// as "retry now", which is the stampede a Retry-After exists to spread.
return Math.max(1, Math.ceil(seconds));
} catch {
return undefined;
}
}
export const declaredStatusFor = (code: string): number | undefined =>
frameworkStatus(code) ?? APP_ERROR_STATUS.get(code);
/**
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY
* identifier for the problem KIND — a client switches on it — while `docs` is where a human goes
* to read about it, and those stopped being the same string when `docs` became one wiki page for
* every code. Collapsing `type` onto that page too would have given a 422 body-invalid and a 403
* forbidden the same identifier, which is the one thing a `type` may not do.
*
* A URN has no host to resolve, so it cannot rot the way `https://ultimate.dev/errors/<code>` did
* — it was never dereferenceable and never claimed to be, which RFC 9457 §3.1.1 explicitly allows.
* `code` carries the same string as a plain member for a reader that would rather not parse a URI.
*/
export const problemTypeFor = (code: string): string => `urn:ultimate:error:${singleLine(code)}`;
/** RFC-9457 problem document. `code`/`cause`/`fix`/`docs` are our extensions. */
export interface ProblemDocument {
readonly type: string;
readonly title: string;
readonly status: number;
readonly detail: string;
readonly instance: string | undefined;
readonly code: string;
readonly cause: string;
readonly fix: string;
readonly docs: string;
readonly requestId: string | undefined;
}
/** The title a caller gets for a failure the framework cannot name. */
const INTERNAL_TITLE = 'unhandled server error';
/**
* The cause a caller gets for one. An unclassified 5xx has no `cause` of its own, so `factsOf`
* falls through to the throwable's `message` — a driver's DSN, the row Postgres rejected, an
* absolute path — and `toProblem` handed it to whoever asked. `error-page.ts` locked the BROWSER
* out of exactly this and said so in its header; the two audiences then disagreed about one
* condition. The real text is not lost: the `error-map` stage logs it as a redactable FIELD and
* reports every 5xx to the error monitor, both keyed by the request id below.
*/
const INTERNAL_CAUSE =
'the server failed while handling this request; the details are in this process\u2019s logs and ' +
'error reports, under this request id';
/**
* A 5xx nobody declared a status for — not the framework's table, not the app's
* `registerErrorStatus` — or one whose code is `X_INTERNAL`. That is the discriminator, and not
* `status >= 500` alone: a declared code has an authored cause, and blanking `X_DRAINING`'s would
* take away the one instruction in it.
*
* `X_INTERNAL` is in the framework's table and still belongs here, because it is the framework's
* own word for "nobody classified this": `factsOf` mints it for a throwable carrying no code, and
* core's `toError()` wraps a caught value into an `InternalError` whose cause is
* `renderCauseValue(value)` — the driver's message, verbatim. Nothing in an `X_INTERNAL` is
* actionable by the caller; the code and the request id are.
*/
const isUnclassifiedFailure = (code: string, status: number): boolean =>
status >= 500 &&
(code === 'X_INTERNAL' || (frameworkStatus(code) === undefined && !APP_ERROR_STATUS.has(code)));
export const toProblem = (
error: unknown,
meta: { instance?: string; requestId?: string; dev?: boolean } = {},
): ProblemDocument => {
const facts = factsOf(error);
const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
return {
type: problemTypeFor(facts.code),
title: opaque ? INTERNAL_TITLE : facts.title,
status: facts.status,
detail: opaque ? INTERNAL_CAUSE : facts.cause,
instance: meta.instance,
code: facts.code,
cause: opaque ? INTERNAL_CAUSE : facts.cause,
fix: facts.fix,
docs: facts.docs,
requestId: meta.requestId,
};
};
/** The exact three lines the terminal prints, reused by the overlay and `--json`. */
export const renderErrorLines = (error: unknown): string => {
const facts = factsOf(error);
// The newlines here are the format's own. Every interpolated field goes through `singleLine`
// so a caller-controlled value cannot add a third one — this string is rendered into the dev
// overlay's `<pre>`, where HTML escaping does not help because a newline is not markup.
return [
`${singleLine(facts.code)}: ${singleLine(facts.title)}`,
` cause: ${singleLine(facts.cause)}`,
` fix: ${singleLine(facts.fix)}`,
].join('\n');
};
export const statusFor = (code: string): number => declaredStatusFor(code) ?? DEFAULT_STATUS;

@@ -33,2 +33,3 @@ // The HTTP layer's stable error codes. Every throw in this package goes through a

'X_RATE_LIMIT_STORE_UNAVAILABLE',
'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
'X_TRUST_PROXY_UNSET',

@@ -87,2 +88,3 @@ 'X_OVERLOADED',

X_RATE_LIMIT_STORE_UNAVAILABLE: 'the shared rate-limit store did not answer, so nothing decided',
X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN: 'the tenant allowance names a bucket nothing declares',
X_TRUST_PROXY_UNSET: 'proxy headers are trusted without saying how many proxies are in front',

@@ -294,3 +296,3 @@ X_OVERLOADED: 'in-flight requests are at the configured ceiling',

cause: `cors config rejected: ${reason}`,
fix: "in app.config.ts set http.cors.credentials: false, or replace http.cors.origins: ['*'] with the exact origins allowed to call this app",
fix: "call configureHttp({ cors: { credentials: false } }) at module scope in a file under apps/*/, or replace origins: ['*'] with the exact origins allowed to call this app",
});

@@ -309,3 +311,3 @@

cause: `${where} is not a csp token: ${JSON.stringify(value)}`,
fix: 'in app.config.ts write one http.security.csp.extend entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon',
fix: 'in the configureHttp({ security: { csp: { extend } } }) call write one entry per directive, each source its own array element — a directive name is [a-z][a-z0-9-]*, and no source may contain a space, a comma or a semicolon',
});

@@ -332,3 +334,3 @@

'http.trustProxy is true and http.trustedProxyHops is not set, so x-forwarded-for would be read from a position the client controls',
fix: 'in app.config.ts set http.trustedProxyHops to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — or set http.trustProxy: false when this process is reached directly',
fix: 'set TRUSTED_PROXY_HOPS in the deployment environment to the number of proxies that append to x-forwarded-for — 1 for a single ingress or ALB, 2 for a CDN in front of one — and leave it unset for a process that is reached directly; an embedder calling defineHttpConfig itself passes { trustProxy: true, trustedProxyHops: 1 }',
});

@@ -359,3 +361,3 @@

cause: `${inflight} requests are already in flight and http.maxInflight is ${ceiling}`,
fix: 'retry after the Retry-After header; to serve more at once raise http.maxInflight in app.config.ts, and add replicas to match',
fix: 'retry after the Retry-After header; to serve more at once call configureHttp({ maxInflight: 2000 }) at module scope in a file under apps/*/, and add replicas to match',
});

@@ -371,3 +373,3 @@

cause: `${pathname} refused a credentialed write: ${reason}`,
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to http.cors.origins in app.config.ts, or set http.csrf.mode: 'off' if this app has no cookie session at all",
fix: "call it with an Authorization header instead of the session cookie, add the calling origin to configureHttp({ cors: { origins } }), or configureHttp({ csrf: { mode: 'off' } }) if this app has no cookie session at all",
});

@@ -384,4 +386,4 @@

cause: `${method} ${pathname} did not finish within ${timeoutMs}ms`,
fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or raise http.requestTimeoutMs in app.config.ts',
fix: 'pass ctx.signal to every outbound call (fetch(url, { signal: ctx.signal })) and call throwIfAborted(ctx) before expensive work, or call configureHttp({ requestTimeoutMs: 60_000 }) at module scope in a file under apps/*/',
meta: { timeoutMs },
});

@@ -5,2 +5,4 @@ // The public surface of @ultimat3/http. Explicit, never `export *`: what is not

export type { RenderMode } from '@ultimat3/core';
export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';
export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';
export { NEXT_PARAM, nextAfterSignIn, signInRedirect } from './auth-redirect';

@@ -28,14 +30,16 @@ export type { HttpConfig, HttpConfigInput } from './config';

export { REQUEST_TIMEOUT_HEADER, resolveTimeoutMs, startDeadline } from './deadline';
export type { ErrorFacts, ProblemDocument } from './error-map';
export type { ErrorFacts, ProblemDocument } from './error-facts';
export {
factsOf,
problemTypeFor,
renderErrorLines,
retryAfterOf,
toProblem,
} from './error-facts';
export {
DEFAULT_STATUS,
ERROR_STATUS,
factsOf,
problemTypeFor,
registerErrorStatus,
renderErrorLines,
resetErrorStatus,
retryAfterOf,
statusFor,
toProblem,
} from './error-map';

@@ -112,2 +116,3 @@ export type {

RateLimitScope,
RateLimitSpend,
RateLimitStore,

@@ -122,4 +127,5 @@ } from './rate-limit';

rateLimitDecision,
rateLimitKey,
rateLimitSpends,
resolveRateLimitConfig,
TENANT_SCOPE,
toBucket,

@@ -136,2 +142,3 @@ } from './rate-limit';

rateLimitStoreUnavailable,
tenantBucketUnknown,
} from './rate-limit-errors';

@@ -138,0 +145,0 @@ export type {

@@ -5,3 +5,3 @@ // The dev error overlay. It renders the SAME facts object the terminal prints and

// error contract, not UI copy, so they are not routed through the i18n catalog.
import { factsOf, renderErrorLines, toProblem } from './error-map';
import { factsOf, renderErrorLines, toProblem } from './error-facts';
import { acceptsHtml, escapeHtml } from './html-render';

@@ -8,0 +8,0 @@ import { OVERLAY_STYLE } from './overlay-style';

@@ -240,2 +240,6 @@ // THE request lifecycle: which stages exist, in what ORDER, why — and the one loop that drives a

signal: deadline.signal,
// The number behind that signal. `traceHeaders()` in core reads it off the ambient
// context, so every outbound hop this request makes carries what is LEFT of the budget
// rather than letting the next service start a fresh one of its own.
deadlineAt: deadline.deadlineAt,
// The context is what app code reaches through core's ALS; without the inbound headers

@@ -242,0 +246,0 @@ // on it, a cookie the server itself set could never be read back on the next request,

@@ -17,3 +17,3 @@ // Every refusal a rate limit produces: the 429 a caller is answered with, and the six declaration

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',
fix: 'retry after the Retry-After header, or raise the bucket in configureHttp({ rateLimit: { buckets } }) at module scope in a file under apps/*/',
meta: { key, retryAfterSeconds },

@@ -35,3 +35,3 @@ });

: "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",
fix: "createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor: { query: (text, values) => db().query({ text, values }) } }) }) — or defineHttpConfig({ rateLimit: { scope: 'process' } }) to accept per-replica limits",
});

@@ -61,3 +61,3 @@

bucket: string;
/** `null` when the other declaration is `app.config.ts` rather than a second route. */
/** `null` when the other declaration is the app's `configureHttp()` rather than a second route. */
otherRoute: string | null;

@@ -72,7 +72,7 @@ route: string;

input.otherRoute === null
? 'http.rateLimit.buckets in app.config.ts'
? 'the rateLimit.buckets the app passed to configureHttp()'
: `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`
? `; if ${numbers(input.other)} is what this deployment means to enforce, then the route's declaration is the half that is wrong and configureHttp() is not where to say so`
: ''

@@ -85,3 +85,3 @@ }`,

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`
? `delete rateLimit.buckets.${input.bucket} from the app's configureHttp() call — 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`,

@@ -133,6 +133,22 @@ });

'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",
fix: "defineHttpConfig({ rateLimit: { scope: 'process' } }) if this app runs as ONE replica, or scope: 'shared' plus createServer({ routes, rateLimitStore: postgresRateLimitStore({ executor }) }) for a fleet-wide limit — a process booted by x dev or apps/web/server.ts derives it from the store it installed and never declares it",
});
/**
* At `defineHttpConfig`, never on the request, and the same shape as every other bucket-name
* refusal here: `bucketFor` resolves an unknown name to `default`, so a tenant allowance an author
* wrote as 5,000 would silently be the 120-burst read bucket — looser than the declaration, and
* visible nowhere. A whole tenant's cap is not a value to discover by watching a graph.
*/
export const tenantBucketUnknown = (name: string, declared: readonly string[]): HttpError =>
new HttpError({
code: 'X_RATE_LIMIT_TENANT_BUCKET_UNKNOWN',
cause: `rateLimit.tenantBucket names "${name}" and rateLimit.buckets declares ${
declared.length === 0 ? 'no buckets' : declared.join(', ')
}`,
fix: `add ${name} to the same rateLimit.buckets — configureHttp({ rateLimit: { tenantBucket: '${name}', buckets: { ${name}: { capacity: 5000, refillPerSecond: 100 } } } }) — or drop tenantBucket to leave this app with no per-tenant allowance`,
meta: { bucket: name },
});
/**
* The shared store ran its statement and answered nothing. An `insert … on conflict … returning`

@@ -139,0 +155,0 @@ * always yields one row, so this is a driver that is not running what it was handed — a wrapped

@@ -11,2 +11,3 @@ // Token-bucket rate limiting. The store is an interface so the same limiter runs in-memory in

rateLimitScopeUnset,
tenantBucketUnknown,
} from './rate-limit-errors';

@@ -47,2 +48,15 @@

/**
* The bucket a whole TENANT spends, beside — never instead of — the caller's own, or `null` for
* an app with no per-tenant allowance.
*
* `null` by default because no number is defensible without being told: one tenant is a person
* and the next is five thousand seats, so a framework-chosen allowance would throttle a real
* deployment on the day it installed the framework (axiom 8). It is still the one knob that
* answers the failure it exists for — `rateLimitKey` was `actor > org > ip`, EXCLUSIVE, so an
* authenticated request never touched an org bucket at all: a tenant with 8,000 seats whose
* integration entered a retry loop spent 8,000 per-actor bursts against one shared pool, every
* one of them under its own limit, and nothing an operator could set would have refused it.
*/
readonly tenantBucket: string | null;
/**
* What this deployment requires of the store. `'shared'` says these numbers are the whole

@@ -66,2 +80,3 @@ * fleet's allowance, and a per-process store then refuses to boot — because N replicas each

defaultBucket: 'default',
tenantBucket: null,
buckets: {

@@ -84,2 +99,8 @@ default: { capacity: 120, refillPerSecond: 2 },

const merged = { ...DEFAULT_RATE_LIMIT, ...input };
// Here and not at the first request, for `assertRateLimitScope`'s reason: an unknown name falls
// through `bucketFor` to `default`, so a tenant allowance somebody wrote as 5,000 would silently
// be the 120-burst read bucket — looser than what the author declared, and invisible.
if (merged.tenantBucket !== null && !Object.hasOwn(merged.buckets, merged.tenantBucket)) {
throw tenantBucketUnknown(merged.tenantBucket, Object.keys(merged.buckets));
}
if (input?.scope !== undefined) return { ...merged, scope: input.scope };

@@ -282,7 +303,36 @@ if (!merged.enabled) return { ...merged, scope: 'process' };

/**
* Key precedence: actor > org > ip. An authenticated actor gets its own bucket so
* one noisy user cannot exhaust a whole tenant's allowance, and an anonymous
* request falls back to the connection address.
* The namespace the tenant allowance is counted in. Deliberately NOT the route name the caller's
* own key carries: a per-route tenant bucket would give one org its whole allowance once per
* route, which is not a tenant cap at all — it is the same number multiplied by the route table.
*/
export const rateLimitKey = (parts: RateLimitKeyParts): string => {
export const TENANT_SCOPE = 'tenant';
/** One key and the bucket it is spent from. A request spends a LIST of these, never one. */
export interface RateLimitSpend {
readonly key: string;
/** A name resolved against `config.rateLimit.buckets` by the limiter, never a `Bucket`. */
readonly bucket: string;
}
/**
* Every bucket one request spends, in the order it spends them.
*
* The CALLER's key first — actor > org > ip, so an authenticated actor gets its own bucket and an
* anonymous request falls back to the connection address — and then, when the app declared a
* tenant bucket and this caller has an org, that org's own key.
*
* The second entry is the finding this function was rewritten for. The precedence used to be
* EXCLUSIVE: `orgId` was consulted only when `actorId` was null, which `actorView` makes
* unreachable for every authenticated request, so no request ever touched an org bucket. A tenant
* with 8,000 seats therefore had 8,000 × the per-actor burst against one shared connection pool,
* with every individual bucket comfortably inside its limit.
*
* The caller's key is spent FIRST so a single hostile actor is refused by its own allowance before
* it can spend its tenant's — and, because the stage stops at the first refusal, a throttled
* caller costs the tenant nothing.
*/
export const rateLimitSpends = (
parts: RateLimitKeyParts,
buckets: { readonly route: string; readonly tenant: string | null },
): readonly RateLimitSpend[] => {
const subject =

@@ -294,3 +344,9 @@ parts.actorId !== null

: `ip:${parts.ip ?? 'unknown'}`;
return `${parts.routeName}|${subject}`;
const spends: RateLimitSpend[] = [
{ key: `${parts.routeName}|${subject}`, bucket: buckets.route },
];
if (buckets.tenant !== null && parts.orgId !== null) {
spends.push({ key: `${TENANT_SCOPE}|org:${parts.orgId}`, bucket: buckets.tenant });
}
return spends;
};

@@ -297,0 +353,0 @@

// Response constructors. Every response in the framework is built here so that
// content types, charsets and cache semantics are decided once instead of per route.
import { TIMEZONE_HEADER } from '@ultimat3/time';
import { toProblem } from './error-map';
import { toProblem } from './error-facts';

@@ -6,0 +6,0 @@ type HeaderSource = { readonly headers?: HeadersInit | undefined } | undefined;

@@ -22,3 +22,3 @@ // One function per stage name: what each stage of the lifecycle DOES. The package's three-way

import { checkCsrf, selfOrigin } from './csrf';
import { factsOf, retryAfterOf } from './error-map';
import { factsOf, retryAfterOf } from './error-facts';
import { errorPageResponse } from './error-page';

@@ -41,3 +41,3 @@ import {

import { overlayResponse } from './overlay';
import { type RateLimiter, rateLimitKey } from './rate-limit';
import { type RateLimitDecision, type RateLimiter, rateLimitSpends } from './rate-limit';
import { rateLimited } from './rate-limit-errors';

@@ -216,19 +216,41 @@ import type { UltimateRequest } from './request';

const actor = actorView(ctx.actor);
const key = rateLimitKey({
actorId: actor?.id ?? null,
orgId: actor?.orgId ?? null,
ip: ctx.ip,
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
});
const decision = await limiter.check(
key,
ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
// A LIST, and the second entry is why: the key builder used to pick ONE subject —
// actor > org > ip, exclusive — so an authenticated request never touched a tenant bucket
// and one org's 8,000 seats each ran their own allowance against one shared pool.
const spends = rateLimitSpends(
{
actorId: actor?.id ?? null,
orgId: actor?.orgId ?? null,
ip: ctx.ip,
routeName: ctx.route?.meta.name ?? UNMATCHED_ROUTE,
},
{
route: ctx.route?.meta.rateLimit ?? config.rateLimit.defaultBucket,
tenant: config.rateLimit.tenantBucket,
},
);
let answer: RateLimitDecision | undefined;
let refusedKey: string | undefined;
for (const spend of spends) {
const decision = await limiter.check(spend.key, spend.bucket);
// The bucket closest to refusing is the one the caller has to plan against: reporting
// `remaining: 99` off a per-actor bucket while the tenant's holds 2 is a number that
// tells a client it may proceed and then refuses its next call.
if (answer === undefined || decision.remaining < answer.remaining) answer = decision;
if (!decision.allowed) {
// The first refusal ends the spend, so a caller its own bucket already refused costs
// its tenant nothing — one noisy actor may not drain the allowance it shares.
answer = decision;
refusedKey = spend.key;
break;
}
}
if (answer === undefined) return undefined;
// Recorded before the throw so the 429 can carry Retry-After and the
// RateLimit-* headers rather than making the client guess.
ctx.rateLimit = decision;
for (const [name, value] of Object.entries(limiter.headers(decision))) {
ctx.rateLimit = answer;
for (const [name, value] of Object.entries(limiter.headers(answer))) {
ctx.headers.set(name, value);
}
if (!decision.allowed) throw rateLimited(key, decision.retryAfterSeconds);
if (refusedKey !== undefined) throw rateLimited(refusedKey, answer.retryAfterSeconds);
return undefined;

@@ -235,0 +257,0 @@ },

@@ -6,2 +6,3 @@ // Compile-time pins for the shapes this package declares but never constructs. Source, not a

import type { HttpConfig, HttpConfigInput } from './config';
import type { AuthzDecision } from './hooks';

@@ -50,1 +51,24 @@

>;
/**
* Every key of the RESOLVED config is settable on the input, so nothing this package tunes is
* reachable only by editing this package.
*
* The whole HTTP tuning surface was unreachable from a shipped app until 12.0.0 — one fixed
* literal in `@ultimat3/cli` was its only construction — and the half of that defect a rule can
* see is this one: a key added to `HttpConfig` and forgotten on `HttpConfigInput` has a default
* nobody can override, silently, forever. `scripts/config-readers.ts` cannot see it either: that
* ratchet walks `AppConfig` and asks whether a key is READ, and this is the mirror question — can
* a key be WRITTEN. A build error naming the key beats both.
*/
type UnsettableHttpKey = Exclude<keyof HttpConfig, keyof HttpConfigInput>;
export type _EveryHttpConfigKeyIsSettable = Assert<
[UnsettableHttpKey] extends [never] ? true : false
>;
// There is deliberately NO second pin claiming "every settable key is app-declarable or
// boot-owned". `AppHttpConfig` is `Omit<HttpConfigInput, BootOwnedHttpKey>`, so that union is
// `keyof HttpConfigInput` by construction and the assertion is vacuously true whatever anyone
// edits — a claim that cannot fail is not a claim. The derivation IS the enforcement there; this
// file only pins what a derivation cannot say.