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

@ultimat3/http

Package Overview
Dependencies
Maintainers
1
Versions
24
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
12.0.0
to
13.0.0
+133
src/webhook-verify.ts
// The inbound half of the framework's webhook mechanism: prove a request was signed by the holder
// of a shared secret, recently, over the bytes it actually carries. It is a plain function and not
// a pipeline stage because a receiver is an ordinary `api/` route — the secret is per sender, and
// only the route knows which one applies.
//
// THE FORMAT IS `@ultimat3/core`'s (`webhook-signature.ts`) and is not re-declared here. That
// module is at the tier both halves can reach: `@ultimat3/jobs` (tier 3) signs a delivery and its
// boundary forbids this package, and this package (tier 2) may not reach tier 3. What stays here
// is the POLICY — what counts as fresh, how large a body may be, and which refusal a receiver
// answers with.
import type { Clock } from '@ultimat3/core';
import {
isCanonicalWebhookField,
parseWebhookSignatureHeader,
readWithinLimit,
systemClock,
timingSafeEqual,
WEBHOOK_FIELD_MAX,
WEBHOOK_ID_HEADER,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_TOPIC_HEADER,
webhookMac,
} from '@ultimat3/core';
import { bodyInvalid, webhookSignatureInvalid, webhookSignatureStale } from './errors';
/**
* How far a delivery's timestamp may sit from this clock, either way. Five minutes is the window
* every sender in the wild already assumes, and it is a REPLAY BOUND, not a latency allowance: a
* captured request stops being usable after it, which is the only thing that keeps an intercepted
* delivery from being replayable forever.
*/
export const DEFAULT_WEBHOOK_TOLERANCE_MS = 300_000;
/**
* Restated rather than read from `HttpConfig.bodyLimitBytes` (same number, `config.ts`): this
* function runs inside a route handler with a raw `Request` and no pipeline config in scope, and a
* receiver that must hold a 4 MB payload says so here rather than by widening every route's cap.
*/
export const DEFAULT_WEBHOOK_BODY_LIMIT = 1_048_576;
export interface WebhookVerifyOptions {
/** The shared secret for THIS sender. Never logged, never rendered into a refusal. */
readonly secret: string;
/** Defaults to `DEFAULT_WEBHOOK_TOLERANCE_MS`. */
readonly toleranceMs?: number;
/** Defaults to `DEFAULT_WEBHOOK_BODY_LIMIT`. Enforced while the body streams. */
readonly maxBytes?: number;
/** Defaults to `systemClock`. A window no test can freeze is a window no test pins. */
readonly clock?: Clock;
}
export interface VerifiedWebhook {
/**
* The sender's id for this event, signed and therefore unforgeable. It is the DEDUPE key: a
* delivery replayed inside the tolerance window verifies again by design, and this is what lets
* a receiver notice. The seen-set is the app's table — the framework has nowhere to keep one.
*/
readonly eventId: string;
/** The sender's routing label. Carried and signed, never interpreted (axiom 8). */
readonly topic: string;
/**
* The exact text the signature covers. Parse THIS, never `request.json()` — the body stream is
* spent, and a re-serialisation would not be the bytes that were signed.
*/
readonly body: string;
readonly signedAtMs: number;
}
/**
* Prove the request came from the holder of `secret`, inside the tolerance window, over the bytes
* it carries — and answer what was signed.
*
* The order is deliberate: the mac is checked BEFORE the window, so `X_WEBHOOK_SIGNATURE_STALE`
* means "authentic and old" and never "unreadable and old". An operator reading it goes to a clock
* or a replay, which is what that code is for.
*/
export async function verifyWebhookSignature(
request: Request,
options: WebhookVerifyOptions,
): Promise<VerifiedWebhook> {
const pathname = new URL(request.url).pathname;
const signature = parseWebhookSignatureHeader(request.headers.get(WEBHOOK_SIGNATURE_HEADER));
if (signature === undefined) {
throw webhookSignatureInvalid(
pathname,
`no readable ${WEBHOOK_SIGNATURE_HEADER} on the request`,
);
}
const eventId = request.headers.get(WEBHOOK_ID_HEADER) ?? '';
const topic = request.headers.get(WEBHOOK_TOPIC_HEADER) ?? '';
if (!isCanonicalWebhookField(eventId) || !isCanonicalWebhookField(topic)) {
throw webhookSignatureInvalid(
pathname,
`${WEBHOOK_ID_HEADER} and ${WEBHOOK_TOPIC_HEADER} must each be 1-${WEBHOOK_FIELD_MAX} characters and carry no ":"`,
);
}
const maxBytes = options.maxBytes ?? DEFAULT_WEBHOOK_BODY_LIMIT;
// Through core's counting reader, the same one `UltimateRequest.#read` uses: a sender that
// announces no length must not be able to make this handler hold an unbounded payload before the
// signature it was never going to pass is even computed.
const read = await readWithinLimit(request.body, maxBytes);
if ('over' in read) {
throw bodyInvalid(pathname, [`body is at least ${read.over} bytes, limit is ${maxBytes}`]);
}
// The mac is core's, over the RAW bytes: an HMAC is over a byte stream, so hashing the prefix
// and then the body is identical to hashing one string — and it never round-trips a body that is
// not valid UTF-8 through a decoder before the mac is taken over it.
const expected = webhookMac({
secret: options.secret,
timestampText: signature.timestampText,
eventId,
topic,
body: read.bytes,
});
// `timingSafeEqual`, never `===`: this is a mac comparison, and where the two first differ is
// exactly what a timing oracle needs to forge one byte at a time.
if (!timingSafeEqual(expected, signature.mac)) {
throw webhookSignatureInvalid(pathname, 'the signature does not match the body that arrived');
}
const signedAtMs = signature.timestampSeconds * 1_000;
const toleranceMs = options.toleranceMs ?? DEFAULT_WEBHOOK_TOLERANCE_MS;
const skewMs = Math.abs((options.clock ?? systemClock).now().getTime() - signedAtMs);
// Both directions: a sender whose clock runs ahead is the same replay window pointed the other
// way, and accepting the future half doubles it.
if (skewMs > toleranceMs) throw webhookSignatureStale(pathname, skewMs, toleranceMs);
return { eventId, topic, body: new TextDecoder().decode(read.bytes), signedAtMs };
}
+103
-3

@@ -44,3 +44,3 @@ # @ultimat3/http

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
- **`asCtx` is a WIDENING the compiler checks, never a cast.** `RequestContext extends Ctx` and
`asCtx` is the identity function. It used to be `ctx as unknown as Ctx` over an object that set

@@ -55,2 +55,42 @@ none of `clock`, `now`, `logger`, `signal` or `services` — so `ctx.now()` threw

`ctx.clientBuildId`, read only by `assertBuild()`.
**`RequestContext extends Ctx` again, and this file no longer BUILDS a context — it composes
one** (`As of 2026-08-24`). `Ctx extends CtxServices`, and `CtxServices` is the seam an app
augments (`declare module '@ultimat3/core'`) to declare `ctx.posts` — so in an APP's program
every service it declared became a REQUIRED member of `createRequestContext`'s object literal,
and this file failed to compile inside `examples/dummy` with `TS2739: missing posts, orgs` while
the framework's own gate, which augments nothing, stayed green. The framework cannot set members
only the app's boot knows about.
`createRequestContext` now spreads `createContext()`'s result. Those members arrive WITH the
base, so the literal is checked in full and there is **no assertion left in this file** —
`withServices`, which existed for one release, is gone. `packages/core/src/context.ts` keeps the
framework's one irreducible `as Ctx` and its header states, with the four measured alternatives,
why it cannot be removed below a major.
Composing is also one constructor for one shape instead of two. This file used to re-derive
`clock`, `now`, the logger child, `signal`, `deadlineAt` and the service bag, so core could fix
any of them and this surface would keep the old answer — which is exactly what happened to the
bag (see the bullet below). `defineService` factories now install here too, for the same reason:
they are `createContext`'s, and this is `createContext`.
`type-pins.ts` carries `_RequestContextIsACtx`, because `asCtx` is a function body and a future
edit answering a failure there with a cast would delete the enforcement and leave the comment.
The reverse direction is FALSE by design — core's `Ctx` carries no `requestHeaders`, which is
what `assertInRequest` proves one way at runtime.
- **`defineService` used to be a job-and-CLI feature, and nothing said so** (`As of 2026-08-24`).
Two halves, and together they made services unreachable on the surface an app spends its life
on. This file built its own service bag from `RequestContextInit.services` alone and never
called core's `installedServices()`; `pipeline.ts:224` passes NO `services` at all. So a
`defineService('posts', …)` an app registered at boot was installed for a job, a task and a CLI
command and for nothing else — over HTTP `ctx.services` was `{}` and `useService('posts')` threw
`X_SERVICE_MISSING`. And the bag, even when one was passed, was never spread ONTO the context,
so `ctx.posts` — the spelling `docs/architecture/15-adding-a-feature.md` writes in its worked
example — read `undefined` beside a populated `ctx.services`.
Composing `createContext` fixes both at once, which is the argument for composing: the installer
is core's and so is the spread. Services go on FIRST so a service an app named `actor` or
`logger` loses to the request's own field and stays reachable as `ctx.services['actor']`; the
context's meaning never depends on what an app named a service. `context.test.ts` pins the
factory install, the spread and the collision order.
- **The two inbound ids are read BEFORE the context and the span, in `correlation.ts`.** `startSpan`

@@ -129,2 +169,34 @@ resolves its parent from `currentSpanContext()`, which reads `ctx.traceId`, so a `traceparent`

both keyed by the request id the caller was given.
- **The problem document carries the ISSUE LIST, and the opacity rule applies to it**
(`As of 2026-08-24`). `ProblemDocument.issues` is a top-level extension member — RFC 9457 §3.2
puts extension members at the document root and every Ultimate extension already is one
(`code`, `cause`, `fix`, `docs`, `requestId`); there is no bag. `@ultimat3/action` has attached
the list to `meta.issues` since `InputInvalidError` grew its third parameter and **nothing
carried it**, so every app in this framework recovered per-field form errors by splitting
`cause` on `'; '` — guesswork the moment a message contains the separator.
Four rules, and the third is the one most likely to be dropped by a later edit.
`issuesOf` is TOTAL and module-private, written in `retryAfterOf`'s shape and for its reason:
`meta` is a property read on a value this package did not build, in the frame that decides what
the caller sees. It is **all-or-nothing** — a client that finds `issues` uses it INSTEAD of
`cause`, so one unreadable entry drops the whole list back to the prose line rather than
shipping a subset, which would be a rejection the user never sees and a form reporting itself
valid. The member is **absent** when there is none, never `undefined` and never `[]`:
`JSON.stringify` drops an `undefined`, but this interface is read directly by `error-page.ts`
and by tests, and `[]` claims "validated clean" about a request that was just refused — which
the first implementation of this reader emitted, because `Array.isArray([])` is true and the
loop simply does not run. A list past `MAX_PROBLEM_ISSUES` (100) is dropped WHOLE for the same
all-or-nothing reason, and because `@ultimat3/action`'s `issuesFromWire` bounds it identically
on arrival: sending it is a body that costs the wire and answers nothing. That package is tier 3,
so the number is restated here and pinned on this side. And it is **dropped under exactly the condition
that blanks `title`/`detail`/`cause`** — an issue list on an unclassified 5xx is precisely the
internal detail `INTERNAL_CAUSE` exists to withhold, because it names the fields and the
expectations of something the caller was never meant to see inside. `X_INPUT_INVALID` is a
declared 4xx and so is never opaque.
`received` is forced to `''` and every entry is rebuilt member by member, never spread. Not
redundancy with `toValidationIssues`, which forces the same thing: this is the boundary where
the value LEAVES the process, a conforming library's own issue object is first-class here and
routinely carries the rejected value, and `@ultimat3/schema`'s `describeValue` exists because a
password-strength rule once wrote mistyped passwords into the log index.
- **A rejected value is a log FIELD, never part of the message.** `logger.emit()` redacts `bound`,

@@ -437,2 +509,29 @@ `contextFields` and `fields` — and never `msg` — so `logger.error(\`${code}: ${cause}\`)` in the

is refused too — what cannot be shown to hold is not assumed to hold.
- **`verifyWebhookSignature` is a FUNCTION, never a pipeline stage** (`As of 2026-08-24`). The
secret is per SENDER, and only the route knows which sender it is serving; a stage would need one
secret for the whole app or a table this package has no business holding. It reads the body
through core's `readWithinLimit` — the same counting reader `UltimateRequest.#read` uses — so it
composes with the cap rather than defeating it, and it answers the raw text so the caller parses
the bytes that were signed rather than a re-serialisation of them.
The order inside it is load-bearing: the mac is checked BEFORE the freshness window, so
`X_WEBHOOK_SIGNATURE_STALE` means *authentic and old* and never *unreadable and old* — an
operator reading it goes to a clock or a replay, which is the only reason the second code exists.
The window is `Math.abs`, both directions: a sender whose clock runs ahead is the same replay
window pointed the other way, and accepting the future half doubles it. The timestamp is parsed
digits-only because `Number('nope')` is `NaN` and `NaN > toleranceMs` is FALSE — the one guard
whose failure mode is "the check does not run". `:` is refused in the id and the topic because
one mac over `v1:t:evt:01HZ:orders.paid:<body>` would otherwise authenticate two different
id/topic splits. The comparison is `timingSafeEqual` and may never become `===`; `bun run
secret-compare` is the mechanical half and `mac`/`signature` are names it reads.
**The FORMAT is `@ultimat3/core`'s and is not re-declared here** (`As of 2026-08-24`).
`packages/core/src/webhook-signature.ts` owns the canonical string, the mac and the parse;
this file owns the POLICY — what counts as fresh, how large a body may be, and which refusal a
receiver answers with. It shipped for one release as two implementations, here and in
`@ultimat3/jobs`, held together by a hex literal asserted in two test files: this package is
tier 2 and may not reach tier 3, that one's boundary forbids `http`, so the one copy lives at
the tier both can reach — the argument `timing-safe-equal.ts` makes for itself. The two literal
vectors stay until `scripts/webhook-round-trip.test.ts` replaces them. **Never re-declare the
canonical string here.**
- Never throw a bare `Error` — use a factory from `errors.ts`.

@@ -463,3 +562,3 @@ - No `any`. Validation goes through Standard Schema (`validate.ts`), not a vendor API.

| `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 |
| `error-facts.ts` | every RENDERING of a throwable: `factsOf()`, the problem document (including the issue list and the opacity rule over it), the three terminal lines |
| `hooks.ts` | the seams: `authenticate`, `authorize`, `devNotices` + the app's `configureAuthenticator()` |

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

| `overlay-style.ts` | the overlay's one stylesheet, split out so `security-headers.ts` hashes it |
| `context.ts` | `RequestContext` + the single `Ctx` adapter (`asCtx`) + the inbound-header readers |
| `context.ts` | `RequestContext` (core's `Ctx` plus the request's own), composed from `createContext`, the single `Ctx` adapter (`asCtx`) and the inbound-header readers |
| `redirect.ts` | the intent slot a handler that cannot return a `Response` fills |

@@ -482,2 +581,3 @@ | `auth-redirect.ts` | where an unauthenticated browser goes, and where it comes back to |

| `csrf.ts` | the origin proof an unsafe method from a credentialed browser must carry |
| `webhook-verify.ts` | the INBOUND webhook: the canonical string, the constant-time mac check and the replay window. The outbound half is `webhook()` in `@ultimat3/jobs`, which this package can never import |
| `locale.ts` | WHERE the request's locale and zone are read from — header and cookie NAMES only, plus `readCookie`. It negotiates nothing |

@@ -484,0 +584,0 @@ | `rate-limit-buckets.ts` | the one point routes and config meet: a route's own bucket, registered or refused |

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

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

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

@@ -116,2 +116,12 @@ # @ultimat3/http 🌐

```ts
import {
createServer,
defineHttpConfig,
type RateLimitStore,
type Route,
} from '@ultimat3/http';
declare const routes: readonly Route[];
declare const myStore: RateLimitStore; // postgresRateLimitStore({ executor }), say
createServer({

@@ -238,2 +248,4 @@ routes,

```ts
import { createServer, defineHttpConfig, json } from '@ultimat3/http';
const handle = createServer({

@@ -250,2 +262,46 @@ routes: [{ method: 'GET', path: '/posts/:id', meta: { name: 'posts.show', auth: 'public' },

## Inbound webhooks
`verifyWebhookSignature(request, { secret })` is the receiving half of the framework's webhook
mechanism. It is a plain function and not a pipeline stage, because the secret is **per sender**
and only the route knows which one applies.
```ts
// apps/web/api/webhooks/partner/route.ts
import { verifyWebhookSignature } from '@ultimat3/http';
declare const env: { readonly PARTNER_WEBHOOK_SECRET: string }; // the app's defineEnv() result
// The seen-set and the dispatch are the app's — this package has nowhere to keep either.
declare function alreadyHandled(eventId: string): Promise<boolean>;
declare function handle(topic: string, payload: unknown, eventId: string): Promise<void>;
export async function POST(request: Request): Promise<Response> {
const { eventId, topic, body } = await verifyWebhookSignature(request, {
secret: env.PARTNER_WEBHOOK_SECRET,
});
// Parse the bytes that were SIGNED. Never `request.json()` — the stream is spent, and a
// re-serialisation would not be the bytes the mac covers.
const payload: unknown = JSON.parse(body);
if (await alreadyHandled(eventId)) return new Response(null, { status: 200 });
await handle(topic, payload, eventId);
return new Response(null, { status: 202 });
}
```
| Property | How |
|---|---|
| constant time | the mac is compared with `@ultimat3/core`'s `timingSafeEqual`, never `===` — where two macs first differ is exactly what a timing oracle forges one byte at a time |
| a replay expires | the signature's timestamp is checked against `toleranceMs` (5 minutes by default), **both** directions — a sender whose clock runs ahead is the same window pointed the other way |
| a replay is detectable | `eventId` is signed and returned, so it cannot be moved in transit; the seen-set is your table, because the framework has nowhere to keep one |
| moving the timestamp breaks it | the timestamp is inside the canonical string, so editing `t=` on a captured request invalidates the mac |
| the raw bytes are what is verified | the body is read through core's counting reader — the same one `UltimateRequest` uses — so a sender that declares no length cannot make this handler hold an unbounded payload |
| the mac is checked BEFORE the window | `X_WEBHOOK_SIGNATURE_STALE` means *authentic and old*, never *unreadable and old*, so an operator reading it goes to a clock or a replay |
`X_WEBHOOK_SIGNATURE_INVALID` and `X_WEBHOOK_SIGNATURE_STALE` are both **401**: the request is
well formed and carried a credential, and the credential is what failed. Neither triggers the
sign-in redirect, which keys on `X_UNAUTHENTICATED` alone.
The sending half is `webhook()` in `@ultimat3/jobs`. Neither package may import the other, so the
canonical string is stated in both and pinned by one literal vector asserted in both test files.
## Errors

@@ -255,3 +311,4 @@

· `X_FORBIDDEN` · `X_RATE_LIMITED` · `X_BUILD_SKEW` · `X_ROUTE_CONFLICT`
· `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED`
· `X_CORS_CONFIG_INVALID` · `X_RATE_LIMIT_NOT_SHARED` · `X_WEBHOOK_SIGNATURE_INVALID`
· `X_WEBHOOK_SIGNATURE_STALE`

@@ -258,0 +315,0 @@ One `factsOf()` feeds three renderings — terminal, `application/problem+json`, dev

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

type Ctx,
createContext,
isAnonymous,

@@ -14,3 +15,2 @@ type Logger,

type Role,
logger as rootLogger,
type ServiceBag,

@@ -33,11 +33,19 @@ systemClock,

/**
* The per-request context, and — through `asCtx` — core's `Ctx` itself. Every member `Ctx`
* declares is declared here and SET by `createRequestContext`, because `asCtx` used to be
* `as unknown as Ctx` over an object missing five of them (`clock`, `now`, `logger`, `signal`,
* `services`). The assertion type-checked and every reader threw at runtime: `ctx.now()` in
* `@ultimat3/action`'s audit trail, `useService()`, `throwIfAborted()`. The cast is gone, so
* a member core adds is a build error in this file until it is set. The `extends` is what makes
* that true rather than aspirational — and it carries `CtxServices`' index signature, which is
* what an app augments for `ctx.posts`; `noPropertyAccessFromIndexSignature` keeps `ctx.typo` a
* build error all the same.
* The per-request context, and — through `asCtx` — core's `Ctx` itself.
*
* `extends Ctx` again, `As of 2026-08-24`, and it is safe again for one reason: this file no
* longer BUILDS a `Ctx`, it composes one. `Ctx extends CtxServices`, an app augments
* `CtxServices` with `declare module` to declare `ctx.posts`, and every service it declared then
* became a required member of every context literal in the framework — this file failed to
* compile inside `examples/dummy` with `TS2739: missing posts, orgs` while the framework's own
* gate, which augments nothing, stayed green. `createRequestContext` now spreads
* `createContext()`'s result, so the members only an app's boot can supply arrive with it and the
* literal below is checked in full.
*
* The `extends` is therefore back to doing what it was always claimed to do: a member core adds
* to `Ctx` is set here — by `base` — or this file does not compile. `asCtx` is the identity
* function and never an assertion; `as unknown as Ctx` is what it used to be, over an object
* missing `clock`, `now`, `logger`, `signal` and `services`, and every reader threw at runtime.
* `CtxServices`' index signature is what an app augments; `noPropertyAccessFromIndexSignature`
* keeps `ctx.typo` a build error all the same.
*/

@@ -151,8 +159,2 @@ export interface RequestContext extends Ctx {

/**
* One signal for every context built without one, so "no cancellation here" costs no allocation
* and `ctx.signal.aborted` is still a read rather than a `TypeError`. The same shape core uses.
*/
const NEVER_ABORTED: AbortSignal = new AbortController().signal;
export const createRequestContext = (init: RequestContextInit): RequestContext => {

@@ -165,3 +167,43 @@ const clock = init.clock ?? systemClock;

const traceId = init.traceId ?? newTraceId();
// COMPOSED from core's constructor rather than built beside it, and that is what deletes the
// last cast in this file. `createContext` returns a `Ctx` that already carries the app's
// `CtxServices` augmentation, so spreading it hands this literal the members only the app's boot
// could supply — and the return below is checked in full, with nothing asserted anywhere.
//
// It is also one constructor for one shape instead of two. This file used to re-derive `clock`,
// `now`, the logger child, `signal`, `deadlineAt` and the service bag itself, so core could fix
// any of them and the HTTP surface would keep the old answer — which is exactly what happened to
// the bag: core has spread services ONTO the context since it shipped and this file never did,
// so an app declaring `ctx.posts` the documented way read `undefined` over HTTP while
// `ctx.services.posts` beside it was populated. Composing makes that class of drift unwritable.
//
// `defineService` factories now install on this surface too, for the same reason: they are
// `createContext`'s and this is `createContext`.
const base = createContext({
requestId,
traceId,
role: init.role,
// The build this PROCESS serves. The client's claim goes to `clientBuildId` below, where only
// `assertBuild()` reads it — the two shared this name until `asCtx` was checked.
buildId: init.config.buildId ?? 'dev',
// What the request gets before the `locale` stage runs, and what it keeps if the stage is
// never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
locale: localeConfig().fallback,
tz: timeConfig().defaultZone,
clock,
...(init.logger === undefined ? {} : { logger: init.logger }),
// Absent means a request nothing can cancel, which is core's `neverAborted` — the same shape
// this file kept its own singleton for.
...(init.signal === undefined ? {} : { signal: init.signal }),
// `null` and "not set" are one fact to core, whose own field is `number | null`.
...(init.deadlineAt === undefined || init.deadlineAt === null
? {}
: { deadlineAt: init.deadlineAt }),
...(init.services === undefined ? {} : { services: init.services }),
});
return {
...base,
// Everything below is either this package's own or a core member the PIPELINE rewrites: the
// mutable slots are re-declared here so a stage can write them, and they must therefore be
// this object's own properties rather than the frozen base's.
requestId,

@@ -173,3 +215,2 @@ traceId,

method: init.method.toUpperCase(),
role: init.role,
config: init.config,

@@ -181,22 +222,5 @@ ip: init.ip ?? null,

requestHeaders: new Headers(init.requestHeaders),
// The build this PROCESS serves, resolved the way core resolves it. The client's claim goes
// to `clientBuildId` below, where only `assertBuild()` reads it.
buildId: init.config.buildId ?? 'dev',
clock,
now: () => clock.now(),
// A child, so `ctx.logger` carries the ids even where core's ALS injector cannot see the
// context — a callback that outlived the request scope, a logger passed to a driver.
logger: (init.logger ?? rootLogger).child({ requestId, traceId }),
signal: init.signal ?? NEVER_ABORTED,
deadlineAt: init.deadlineAt ?? null,
// Frozen and explicit. `defineService` factories are NOT installed here: core does not
// export the installer, so the honest answer for a service nothing passed is
// `X_SERVICE_MISSING` from `useService()` — which is what it exists to raise — rather than
// the `TypeError: undefined is not an object` a missing bag produced.
services: Object.freeze({ ...(init.services ?? {}) }),
params: {},
route: undefined,
actor: anonymousActor(),
// What the request gets before the `locale` stage runs, and what it keeps if the stage is
// never reached (a refusal in `admit`). The owners' configured fallbacks, never a third one.
locale: localeConfig().fallback,

@@ -203,0 +227,0 @@ tz: timeConfig().defaultZone,

@@ -6,2 +6,3 @@ // Every RENDERING of a throwable the framework has: the normalised facts, the RFC-9457 problem

import { ERROR_DOCS_URL, renderCauseValue, singleLine, stringField } from '@ultimat3/core';
import type { ValidationIssue } from '@ultimat3/schema';
import { declaredStatusFor, statusFor } from './error-map';

@@ -107,2 +108,78 @@ import { HTTP_ERROR_TITLES } from './errors';

/**
* A list this long is not a form's worth of rejections; it is a body meant to be expensive. The
* same bound `@ultimat3/action`'s `issuesFromWire` applies on arrival, restated because that
* package is tier 3 and this one is tier 2 — `error-facts.test.ts` pins the number on this side.
*/
const MAX_PROBLEM_ISSUES = 100;
/**
* The rejections a validation failure carried, addressed by path — or `undefined`.
*
* `@ultimat3/action` attaches the list to `meta.issues` (`InputInvalidError`'s third parameter),
* and until this reader existed nothing carried it across the wire: a client rendering a form
* recovered per-field errors by splitting `cause` on `'; '`, which is guesswork the moment a
* message contains the separator.
*
* Total, for `retryAfterOf`'s reason directly above: `meta` is a property read on a value this
* package did not build, in the frame that decides what the caller sees.
*
* ALL-OR-NOTHING, and that is the load-bearing rule. A client that finds `issues` uses it INSTEAD
* of `cause`, so a partly-read list is a rejection the user never sees and a form that reports
* itself valid when it is not. One unreadable entry drops the whole list back to the prose line.
*
* Every entry is rebuilt MEMBER BY MEMBER and `received` is forced empty — never a spread. Not
* redundancy with `toValidationIssues`, which forces the same thing today: this is the boundary
* where the value leaves the process, and a future producer of `meta.issues` need not have gone
* through that helper. A conforming library's own issue object is first-class in this framework
* and routinely carries the rejected VALUE; `packages/schema/src/describe-value.ts` exists because
* a password-strength rule once wrote mistyped passwords into the log index.
*
* Module-private, unlike `retryAfterOf`: that one has a second caller (`stages.ts` writes it onto
* the header) and this one has exactly one, `toProblem`. An exported reader nobody outside calls
* is a public API that promises support it has never been asked for.
*/
function issuesOf(error: unknown): readonly ValidationIssue[] | 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 raw: unknown = (meta as Record<string, unknown>)['issues'];
// EMPTY is `undefined`, never `[]`. `Array.isArray([])` is true and the loop below would
// simply not run, so an empty list reached the document as `issues: []` — which tells a client
// "we validated and found nothing wrong" about a request that was just refused.
//
// TOO LONG is `undefined` too, and dropped WHOLE rather than truncated: a subset is the
// silent-drop this reader refuses everywhere else. The typed client bounds the same list at
// `MAX_WIRE_ISSUES` and would refuse it on arrival anyway (`packages/action/src/wire-issues.ts`),
// so sending it is a body that costs the wire and answers nothing — and that package is tier 3,
// so the number is restated here rather than imported.
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_PROBLEM_ISSUES) {
return undefined;
}
const issues: ValidationIssue[] = [];
for (const entry of raw as readonly unknown[]) {
if (typeof entry !== 'object' || entry === null) return undefined;
const fields = entry as Record<string, unknown>;
const path: unknown = fields['path'];
const message: unknown = fields['message'];
const expected: unknown = fields['expected'];
// `path` and `message` are what a form binding addresses a control by and what it renders;
// an entry missing either is not usable, and a usable subset beside an unusable one is the
// silent-drop this list refuses.
if (typeof path !== 'string' || typeof message !== 'string') return undefined;
issues.push({
path,
expected: typeof expected === 'string' ? expected : message,
// Forced, never copied. See the paragraph above.
received: '',
message,
});
}
return issues;
} catch {
return undefined;
}
}
/**
* RFC-9457 `type`, per code. A URN, and deliberately not a URL: `type` is the document's PRIMARY

@@ -132,2 +209,12 @@ * identifier for the problem KIND — a client switches on it — while `docs` is where a human goes

readonly requestId: string | undefined;
/**
* The rejections, addressed by path, for a failure that produced them. TOP-LEVEL and not an
* extension bag: RFC 9457 §3.2 puts extension members at the document root, and every Ultimate
* extension already is one (`code`, `cause`, `fix`, `docs`, `requestId`).
*
* ABSENT when there are none — never `undefined`, never `[]`. `JSON.stringify` drops an
* `undefined` member, but this interface is read directly by `error-page.ts` and by tests, and
* `[]` says "validated clean", which is a different and false claim.
*/
readonly issues?: readonly ValidationIssue[] | undefined;
}

@@ -171,2 +258,7 @@

const opaque = meta.dev !== true && isUnclassifiedFailure(facts.code, facts.status);
// Dropped under EXACTLY the condition that blanks `title`, `detail` and `cause`. An issue list
// on a failure nobody classified is precisely the internal detail `INTERNAL_CAUSE` exists to
// withhold — it names the fields and the expectations of something the caller was never meant to
// see the inside of. `X_INPUT_INVALID` is a declared 4xx, so it is never opaque.
const issues = opaque ? undefined : issuesOf(error);
return {

@@ -183,2 +275,3 @@ type: problemTypeFor(facts.code),

requestId: meta.requestId,
...(issues === undefined ? {} : { issues }),
};

@@ -185,0 +278,0 @@ };

@@ -72,2 +72,11 @@ // The one place a framework error code becomes an HTTP status. A table, not a

X_CSRF_BLOCKED: 403,
// 401 for both, and never 400: an inbound webhook is well formed and carries a CREDENTIAL — a
// timestamped hmac over its own bytes — so what failed is authentication, not the request. Never
// 403 either, which means an authenticated caller was refused, and there is no authenticated
// caller here. Two codes rather than one because the repairs differ and a sender's dashboard
// shows the status: `INVALID` is the wrong secret or a rewritten body, `STALE` is a skewed clock
// or a delivery being replayed off a capture. Neither triggers `signInRedirect`, which keys on
// `X_UNAUTHENTICATED` alone — a webhook sender is not a browser and has no session to go get.
X_WEBHOOK_SIGNATURE_INVALID: 401,
X_WEBHOOK_SIGNATURE_STALE: 401,
// @ultimat3/action — the code every primitive throws when the CALLER's input fails the schema

@@ -161,2 +170,25 @@ // the primitive declared. 400 because that is what the published OpenAPI operation promises for

X_APPROXIMATE_COUNT_FILTERED: 500,
// The two search refusals, 500 for the reason the three above are: nothing the caller sends
// changes either answer. An entity with no searchable column needs a `searchable()` on one, and
// a driver that cannot answer a full-text match needs the Postgres one — both are edits to the
// app, and both carry a `fix:` that an unmapped 5xx would blank (`toProblem` replaces an
// undeclared code's cause with `INTERNAL_CAUSE`).
X_SEARCH_UNDECLARED: 500,
X_SEARCH_IN_MEMORY: 500,
// The three state-machine refusals, and they are deliberately THREE statuses rather than one:
// the machine says the transition does not exist, the row says it is somewhere else, or the
// column says there is no machine at all — three different readers and three different repairs.
//
// 422 and not 400: the request is well formed and its schema passed. The transition the caller
// named is not one this machine has, which is the same shape as `X_INVARIANT_VIOLATED` above and
// takes its status. Refused before any statement opens a connection, so nothing was written.
X_STATE_TRANSITION_ILLEGAL: 422,
// 409, the lost update caught. The row moved between the read the caller decided on and the
// write it asked for — nothing is wrong with either, and the repair is re-read and retry, which
// is precisely what a 409 tells a client to do. A 422 would say "your request is unusable",
// which is false: the identical request succeeds a moment later.
X_STATE_CONFLICT: 409,
// 500, the same shelf as `X_SEARCH_UNDECLARED`: a column with no machine is a declaration the
// app has not written, and no request changes that.
X_STATE_UNDECLARED: 500,
// @ultimat3/db — the constraints a request trips, both 409. db's own `fix:` for the unique

@@ -181,2 +213,42 @@ // violation says "answer 409, which is what a raced signup is", and `X_ENTITY_DUPLICATE` — the

X_ACTION_JOB_UNBRIDGED: 500,
// Every `X_WEBHOOK_*` below is OUTBOUND and is thrown inside a worker: `ROLE=worker` opens no
// HTTP port, so none of them ever answers a request. The rows exist for the reason
// `X_ACTION_JOB_UNBRIDGED`'s does — this table is the closed one, and a code with no row is a
// 500 anyway. The INBOUND pair (`X_WEBHOOK_SIGNATURE_*`, 401) is @ultimat3/http's and sits with
// the rest of this package's codes above; these are the ones a delivery ends on.
X_WEBHOOK_ENDPOINT_UNKNOWN: 500,
X_WEBHOOK_ENDPOINT_INVALID: 500,
X_WEBHOOK_ENDPOINT_DISABLED: 500,
X_WEBHOOK_EVENT_UNKNOWN: 500,
X_WEBHOOK_EVENT_INVALID: 500,
X_WEBHOOK_DELIVERY_FAILED: 500,
X_WEBHOOK_DELIVERY_THROTTLED: 500,
X_WEBHOOK_DELIVERY_REJECTED: 500,
// Same class again: an export pass runs in a worker, and both codes refuse the DECLARATION —
// a `row()` that answers columns nobody declared, and a page too big to hold. Neither is
// anything a caller sent.
X_EXPORT_ROW_INVALID: 500,
X_EXPORT_PART_TOO_LARGE: 500,
// @ultimat3/notify — five 500s and one 502, and the split is who failed.
//
// The five are the app's own declaration: a notifier with no channels, one channel named twice,
// a digest window on a bulk channel, a store nothing installed, and a fan-out past the per-run
// ceiling. Every `fix:` on those five names a code edit or a boot call, so nothing a caller
// sends changes any of them — `X_NOTIFY_FANOUT_TOO_WIDE` is the only one a request can even
// INFLUENCE (an action that notifies a whole org), and the repair is still `bulkChannel()` or a
// paged `backfill()`, never the request.
X_NOTIFY_CHANNELS_EMPTY: 500,
X_NOTIFY_CHANNEL_DUPLICATE: 500,
X_NOTIFY_FANOUT_TOO_WIDE: 500,
X_NOTIFY_STORE_MISSING: 500,
X_NOTIFY_DIGEST_UNSUPPORTED: 500,
// 502, and it is the one row on this table that answers for somebody else's server. This code
// WRAPS a provider rejection — `NotifyDeliveryFailedError` takes the caught value and renders it
// — so the thing that failed is the channel's upstream, not this process. It is thrown inside a
// job step today (`x jobs show <notifier> --json` is its own `fix:`), so nothing reaches a
// request and the number is unobservable either way; the row is chosen for the day that stops
// being true, and the asymmetry decides it. A wrong 502 costs nothing. A wrong 500 pages the
// on-call for an email provider's outage, because `stages.ts` reports every `status >= 500` to
// the error monitor — which is the failure this whole table exists to stop.
X_NOTIFY_DELIVERY_FAILED: 502,
// @ultimat3/policy

@@ -235,2 +307,18 @@ X_POLICY_MISSING: 500,

X_STORAGE_ORG_MISMATCH: 404,
// @ultimat3/ui — a form control whose `name` is not a usable field path. The owning slice argued
// for NO ROW, on the grounds that this is a render-time developer error that can never reach
// HTTP, and the argument is right about the code and wrong about the table.
//
// `scripts/error-map-backlog.ts` is the only "no row" this table has, and its own header says
// what an entry there means: "NOT a claim that the code can never cross HTTP … a claim that
// nobody has decided yet", with the ratchet promising only that the undecided set never grows.
// This code HAS been decided, so a pin would record the opposite of what is known and grow the
// one list that may not grow.
//
// So it takes the answer every other decided-and-unreachable code takes — `X_CORS_CONFIG_INVALID`,
// `X_ACTION_JOB_UNBRIDGED`, `X_RATE_LIMIT_NOT_SHARED`. The row is NOT a claim that it reaches a
// request. A code with no row already answers 500 (`DEFAULT_STATUS`); the row changes nothing at
// runtime and makes that answer a reviewed one instead of an accident, which is the whole reason
// this table is closed.
X_UI_FORM_PATH_INVALID: 500,
// @ultimat3/mail

@@ -237,0 +325,0 @@ // The deployment configured no transport. It reaches a caller only through an inline

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

'X_CSRF_BLOCKED',
'X_WEBHOOK_SIGNATURE_INVALID',
'X_WEBHOOK_SIGNATURE_STALE',
] as const;

@@ -92,2 +94,4 @@

X_CSRF_BLOCKED: 'a credentialed write arrived from an origin that is not allowed to make it',
X_WEBHOOK_SIGNATURE_INVALID: 'the inbound webhook is not signed by the holder of this secret',
X_WEBHOOK_SIGNATURE_STALE: 'the inbound webhook is signed correctly and is too old to accept',
};

@@ -384,1 +388,35 @@

});
/**
* The inbound delivery is not signed by the holder of this route's secret — a wrong secret, a
* body something rewrote in transit, or a header this format does not define.
*
* 401 rather than 400: the request is well formed and carried a CREDENTIAL, and the credential is
* what failed. Rather than 403, which means an authenticated caller was refused, and there is no
* authenticated caller here. `reason` names only what the framework chose — never the signature
* that arrived, never the secret, and never the body — because a `cause` reaches both the caller
* and the log store, and a credential in either is a leak wearing a diagnostic's clothes.
*/
export const webhookSignatureInvalid = (pathname: string, reason: string): HttpError =>
new HttpError({
code: 'X_WEBHOOK_SIGNATURE_INVALID',
cause: `${pathname} refused an inbound webhook: ${reason}`,
fix: 'sign the delivery with the secret this endpoint was registered under, or re-read the secret from your sender dashboard and pass it as verifyWebhookSignature(request, { secret })',
});
/**
* Signed correctly, and outside the replay window. Its own code because the repair is a different
* one: a sender's clock, or a delivery being replayed off a capture. Same 401 — the credential is
* a TIMESTAMPED one, and this is the expiry half of it.
*/
export const webhookSignatureStale = (
pathname: string,
skewMs: number,
toleranceMs: number,
): HttpError =>
new HttpError({
code: 'X_WEBHOOK_SIGNATURE_STALE',
cause: `${pathname} received a valid signature ${skewMs}ms from this clock, and the window is ${toleranceMs}ms`,
fix: 'sync the sending host clock with NTP, or widen the window with verifyWebhookSignature(request, { secret, toleranceMs: 600_000 }) if the sender queues deliveries for longer than that',
meta: { skewMs, toleranceMs },
});

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

export type { RenderMode } from '@ultimat3/core';
// The wire format is `@ultimat3/core`'s and is RE-EXPORTED, never re-declared: it is one module at
// the tier both halves can reach, because `@ultimat3/jobs` signs a delivery, this package verifies
// one, and neither may import the other. Re-exported here so a receiver route needs one import.
export {
isCanonicalWebhookField,
WEBHOOK_ID_HEADER,
WEBHOOK_SIGNATURE_HEADER,
WEBHOOK_SIGNATURE_VERSION,
WEBHOOK_TOPIC_HEADER,
} from '@ultimat3/core';
export type { AppHttpConfig, BootOwnedHttpKey } from './app-config';

@@ -81,2 +91,4 @@ export { configuredHttp, configureHttp, mergeHttpConfig, resetHttpConfig } from './app-config';

unauthenticated,
webhookSignatureInvalid,
webhookSignatureStale,
} from './errors';

@@ -197,1 +209,7 @@ export type { ForwardedInput, ForwardedSplit } from './forwarded';

export { formatIssue, validate, validateSync } from './validate';
export type { VerifiedWebhook, WebhookVerifyOptions } from './webhook-verify';
export {
DEFAULT_WEBHOOK_BODY_LIMIT,
DEFAULT_WEBHOOK_TOLERANCE_MS,
verifyWebhookSignature,
} from './webhook-verify';

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

import type { Ctx } from '@ultimat3/core';
import type { HttpConfig, HttpConfigInput } from './config';
import type { RequestContext } from './context';
import type { AuthzDecision } from './hooks';

@@ -74,1 +76,14 @@

// file only pins what a derivation cannot say.
/**
* `RequestContext` IS a `Ctx`, so `asCtx` stays a checked widening rather than an assertion.
*
* `asCtx` already carries this claim at its own call site and this pin is not a duplicate of it:
* `asCtx` is a function body, and a future edit answering a failure there with a cast would delete
* the enforcement and leave the comment. A pin has nothing to cast.
*
* The direction that matters is this one and not the reverse — `Ctx extends RequestContext` is
* FALSE by design, because core's `Ctx` carries no `requestHeaders`, which is precisely what
* `assertInRequest` exists to prove one way at runtime.
*/
export type _RequestContextIsACtx = Assert<RequestContext extends Ctx ? true : false>;