@ultimat3/http
Advanced tools
| // 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" | ||
| } | ||
| } |
+51
-5
@@ -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. |
+5
-2
@@ -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'; |
+8
-0
@@ -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 |
+2
-2
@@ -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'; |
+18
-1
@@ -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 |
+42
-193
| // 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; |
+8
-6
@@ -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 }, | ||
| }); |
+14
-7
@@ -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 { |
+1
-1
@@ -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'; |
+4
-0
@@ -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 |
+61
-5
@@ -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 @@ |
+1
-1
| // 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; |
+36
-14
@@ -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 @@ }, |
+24
-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. |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
343609
8.72%41
5.13%5569
6.32%270
20.54%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated