Skip to content

HTTP server family ​

Reference. The HTTP runtime and its oRPC and htmx answerers live in @btravstack/http-server, @btravstack/orpc-server and @btravstack/htmx-server. This page covers their shared configuration, authentication and request behavior. For the task, see Serve an oRPC contract over HTTP; for the reasoning behind a starter, see Starters and The kernel maps nothing; for the worked example, Order API. Generated signatures are under API reference.

Exports ​

These symbols are exported across the three packages. defineHttp, HttpModule, http and oRPC controller types belong to @btravstack/orpc-server; html, raw and htmx route types belong to @btravstack/htmx-server; the runtime, ports and authenticators belong to @btravstack/http-server:

ExportKindWhat it is
defineHttpvaluedefineHttp({ authenticators }), or defineHttp() for a public API — the one door: it declares this deployment's security schemes and hands back OrpcController, OrpcRouter and authenticators typed by them
HttptypeHttp<A, Units, Namespace> — what defineHttp returns, held as one binding and never destructured; Units is the record auth.units<…>() binds, empty until that second call
AuthenticatorstypeReadonly<Record<string, Authenticator<…>>> — the registry defineHttp takes, keyed by scheme name
SchemesFromtypeSchemesFrom<A> — the scheme-name → identity map read off the authenticators, so it is never declared twice
HttpModulevalueHttpModule(name)({ router, fragments?, fragmentsPrefix?, fragmentsLogin?, openapi?, prefix?, port?, hostname?, cors?, bodyLimit?, compression?, plugins?, securityHeaders?, csrf?, unit?, imports?, provides?, exports?, needs? }) — a di Module(name)({...}) that also takes the router provider; the composition root of an HTTP deployment
HttpModuleOptionstypeThe options object HttpModule(name) takes
HttpAuthenticatorvalueHttpAuthenticator<P, Scope>()({ inject: { name: Dep }, sync }) — or make where building the scheme can fail, or ({ inject: {}, sync }) with no deps; the scheme's name is the key it sits under in defineHttp
AuthenticatortypeAuthenticator<P, Scope, N, E> — what HttpAuthenticator hands back: a description carrying its principal, its scope vocabulary, the ports it needs and the error its arm reports, which defineHttp binds to a port
grantedvaluegranted(identity, scopes) — mints the scoped answer, stamped with a module-private symbol so the starter can tell it from a bare identity that carries a scopes field
GrantedtypeGranted<P, Scope> — the identity bare when the scheme has no scope vocabulary, a Grant<P, Scope> when it has one
GranttypeGrant<P, Scope> — the branded { identity, scopes } granted() returns; unforgeable from outside the package
AuthenticatorServicetype(headers: IncomingHttpHeaders, query: URLSearchParams) => AsyncResult<Granted<P, Scope>, Unauthenticated | CredentialRefused | AuthenticatorUnavailable> — headers and the URL's query in, credential out
authenticatorPortvalueauthenticatorPort(scheme) — the di port whose id is `HttpAuthenticator:${scheme}`; a router declares one per scheme its contract names
principalPortvalueprincipalPort(scheme) — the di port whose id is `HttpPrincipal:${scheme}`, carrying that scheme's principal; a unit module names it in needs and the fork seeds it
PrincipalstypePrincipals<A> — the scheme → principal-port map, what auth.principals is
KindstypeKinds<A> — "anonymous" plus every declared scheme: every kind a unit may be opened under
UnitsOftypeUnitsOf<A> — Partial<Record<Kinds<A>, Module>>, the record auth.units<…>() takes
Unauthenticatedvaluea TaggedError with an empty payload — the refusal itself; the starter surfaces no reason to the client
UnderScopedvalueTaggedError("UnderScoped") — a valid credential missing a declared scope, answered 403
CredentialRefusedvalueTaggedError("CredentialRefused") — a credential the caller presented and the scheme refused, answered 403 as UnderScoped is
AuthenticatorUnavailablevalueTaggedError("AuthenticatorUnavailable") — an authenticator dependency outage, answered 503 and reported through Observers
resolvePrincipalvaluethe protocol-neutral authentication walk, shared by every answerer
PrincipaltypePrincipal<S, Schemes> — what a leaf's handler reads: bare for one scheme, a tagged union for several, never for none
SchemesOftypeSchemesOf<R> — the union of scheme names a Requirements tuple mentions
protectedResourcevalueprotectedResource({ resource?, authorizationServers?, scopes?, allowInsecure? }) — three providers, spread: the API as an OAuth 2.0 protected resource, serving its RFC 9728 metadata and challenging every caller with where it is; see The protected resource
ProtectedResourcevaluethe port protectedResource() provides — the resource identifier, its authorization servers and the metadata URL, as the boot checked them
protectedResourcePerHostvalueprotectedResourcePerHost({ path?, authorizationServers?, scopes?, allowInsecure? }) — three providers, spread: the same discovery on every host the application serves, the resource identifier derived from each request; see One resource per host
ProtectedResourcePerHostvaluethe port protectedResourcePerHost() provides — the resource path and its authorization servers, as the boot checked them
ServedHostsvalueclass ServedHosts extends Port("HttpServedHosts")<(host: string, signal: AbortSignal) => AsyncResult<boolean, never>> {} — the port the application provides for protectedResourcePerHost(), answering whether it serves a host
HttpChallengesvalueclass HttpChallenges extends Port.many("HttpChallenges")<HttpChallenge | HttpRequestChallenge> {} — the WWW-Authenticate challenges a 401 carries, each with the request target it answers or, for protectedResourcePerHost(), the path it resolves one on per request; the starter contributes an empty one
apiKeyAuthenticatorvalueapiKeyAuthenticator<P>()({ header?, keys }) — an API-key scheme with a constant-time compare over SHA-256 digests, no early return, and a missing header on the same path as a wrong key
HttpOptionstypethe transport options HttpModule and GraphqlModule take — see Transport options
HttpRuntimevalueclass HttpRuntime extends RuntimePort<Runtime<typeof HttpHandler, HttpInfo>> {} — the runtime's port; what the starter provides and HttpModule / GraphqlModule export. It resolves HttpHandler, so the root must export that too
HttpHandlervalueclass HttpHandler extends Port.many("HttpHandler")<HttpAnswerer> {} — the set port every protocol served in this process contributes one member to
HttpAnswerertypeone protocol's answer to HTTP — a mount prefix and the handle the runtime routes to; see several answerers
HttpConfigvalueclass HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string; bodyLimit: number; streamBodyLimit: number; corsOrigin: string; compression: boolean; headersTimeoutMs: number; requestTimeoutMs: number }> {} — what the transport is bound and configured with, provided by the starter from PORT / HOST / HTTP_BODY_LIMIT / HTTP_STREAM_BODY_LIMIT / HTTP_CORS_ORIGIN / HTTP_COMPRESSION / HTTP_HEADERS_TIMEOUT_MS / HTTP_REQUEST_TIMEOUT_MS
HttpInfotype{ readonly port: number } — what the runtime publishes on Serving.info once listening, read back through RunningApp.runtimeInfo()
htmlvaluehtml`<tr>${value}</tr>` — a tagged template returning Html, escaping every interpolation by default
rawvalueraw(markup) — the one way past html's escaping, a visible act at the call site
Htmltype{ readonly [HTML]: true; readonly value: string } — the output of html/raw, and nothing else
ParamsOftypeParamsOf<Path> — the :name segments a path template names, e.g. ParamsOf<"/orders/:id/row"> is { readonly id: string }
HtmxFragmentsPortvalueclass HtmxFragmentsPort extends Port("HtmxFragments")<{ routes; authenticators }> {} — every route composed into one port; what htmx() answers from
FragmentAnswertypewhat the composed port carries for one route — its declared unit record, and a handle taking the whole { principal, unit } context, both erased to unknown
htmxvaluehtmx({ prefix?, login? }) — the second answerer, one HttpHandler member serving fragments, mounted under prefix (default /), sending an unauthenticated caller to login when one is pinned
HtmxOptionstypehtmx()'s options

OrpcController/OrpcRouter and HtmxGet/HtmxPost/HtmxFragments are not top-level exports: all five come off defineHttp, because that is where the scheme registry that types them is stated. A marked contract or a marked route reached through anything else would type principal: never.

OrpcRouterPort (the starter's router port, Port("OrpcRouter")) and Implementation<C, Schemes> (the record type OrpcRouter's sync returns) exist in src/orpc.ts but are not exported from the package entry point: the first is reached as provider.port when a caller needs it, the second is inferred at the call. HttpHandler used to be a third — an internal seam, on the grounds that oRPC was the only way to answer HTTP here — and is exported now, since a second protocol's package has to name the set port it contributes to.

Four subpaths export more: @btravstack/http-server/jwt (jwtAuthenticator, jwtVerifier, JwtVerifier, DEFAULT_ALGORITHMS, and their options/service and Claims types — jose), @btravstack/http-server/session (sessionCodec, sessionAuthenticator, SessionCodec, SESSION_COOKIE, DEFAULT_TTL_SEC, TRANSIENT_TTL_SEC, and the Session / SessionCodecService / SessionOptions types — jose again), @btravstack/http-server/oidc (oidc, OidcUnreachable, and the OidcOptions type — openid-client), and @btravstack/orpc-server/openapi (openApiDocument, openApiDocumentRoute, openApiRoutes, and OpenApiRoutesOptions — @orpc/openapi and @orpc/json-schema). The first three sit behind an optional peer, so a graph that never imports one installs nothing; the OpenAPI peers are required, because HttpModule's openapi option serves the routes from the main entry. All four have sections of their own below.

HttpModule(name)({...}) ​

Everything Module(name)({...}) takes — imports, provides, exports — plus the starter's own fields. Supply router, fragments, or both; supplying neither is refused at this call, against a "SERVES NOTHING — supply a router, fragments, or both" marker, rather than booting a listener with nothing behind it. It imports the HTTP starter, built from the whole options record (csrf and unit included); when router is given it prepends router and the scheme authenticators and pieces it carries, plus orpc({ prefix, plugins, … }), to provides; when fragments is given it prepends fragments and its own authenticators and pieces, plus htmx({ prefix: fragmentsPrefix, login: fragmentsLogin }). A scheme both provide is deduplicated by reference before it reaches provides. A piece a slice already provides and exports is the same provider, which di keys by reference, so a root lists its controllers once — in the composing call — and the type counts a piece among the root's own providers only when no import exports its port, so the root never takes on a slice's needs. It prepends HttpRuntime and HttpHandler to exports, adds Logger and HealthChecks when imports export them or root providers supply them, and hands the augmented tuples to di's own Module(name), whose return type is the sugar's. The kernel and both gates see a plain module.

OptionRequiredDefaultWhat it is
routerno*—the application's router provider — a Provider<OrpcRouterPort, E, N>, what api.OrpcRouter(contract)({ inject?, unit?, sync }) returns; a provider on any other port fails at the call
fragmentsno*—the application's fragments provider — what api.HtmxFragments([...]) returns over an array of HtmxGet/HtmxPost pieces; likewise typed to its own port
prefixno/rpcwhere the RPC endpoint is mounted; typed `/${string}`
fragmentsPrefixno/where htmx fragments are mounted — htmx()'s own default, a separate field because one cannot carry two mount points with two different defaults
fragmentsLoginno—htmx()'s login — the login route an unauthenticated fragment caller is sent to; fragment-only, like fragmentsPrefix
openapinooffserves the router as OpenAPI routes too, through openApiRoutes(): true, or its options; cors and compression are this module's unless the record pins its own; needs a router
portnoread from PORTpins the port instead of reading it
headersTimeoutMsnoread from HTTP_HEADERS_TIMEOUT_MSpins how long a client may take to send its headers, in ms; past it the listener answers 408
requestTimeoutMsnoread from HTTP_REQUEST_TIMEOUT_MSpins how long a client may take to send a whole request, in ms; bounds what the client sends, never the response
hostnamenoread from HOSTpins the host instead of reading it
corsnoread from HTTP_CORS_ORIGINpins the CORS policy — true to reflect the request origin, or its options record; applies only when router is served
bodyLimitnoread from HTTP_BODY_LIMITpins the largest request body a procedure or a fragment POST reads, in bytes; false is unbounded
streamBodyLimitnoread from HTTP_STREAM_BODY_LIMITpins the largest body an OpenAPI route declaring requestBodyHint: "octet-stream" reads as a stream, in bytes; false is unbounded. See streamed bodies
compressionnoread from HTTP_COMPRESSIONpins response compression — true for oRPC's defaults, or its options record; applies only when router is served
pluginsno[]any other oRPC handler plugin, forwarded to RPCHandler
securityHeadersnotrueresponse headers set on the raw listener, before dispatch — covers both answerers
csrfnoon when a scheme reads a cookierefuses a cross-site state change carrying cookies, before dispatch. See csrf
unitnononekind → module — the module each answerer forks around a request it handles, chosen by the kind that authenticated it; gated against the kinds this root can open. See The unit
importsno[]the application's modules
providesno[]the application's own providers
exportsno[]the application's own exports; runtime ports and available Logger / HealthChecks are added

* at least one of router/fragments is required.

The worked composition root, from examples/order-api/src/module.ts:

ts
export const OrderApi = HttpModule("OrderApi")({
  router: orderRouter,
  fragments: orderFragments,
  fragmentsLogin: "/auth/login",
  unit: {
    anonymous: RequestModule,
    user: UserModule,
    service: ServiceModule,
    session: SessionModule,
  },
  imports: [
    OrdersSlice,
    CustomersSlice,
    OrderPersistenceModule,
    cache({ adapter: redisCache() }),
    observability(),
    otel(),
  ],
  provides: [sessionCodec(), ...oidc()({ principal, scope: "openid orders:export" })],
});

That composes both answerers: the router under /rpc, the fragments under / — fragmentsPrefix's own default. There is no authenticator option: the authenticators ride the router and the fragments provider — which is what needs them — and the sugar spreads them into provides itself, so an application never lists one and cannot list the wrong one. Their own dependencies (a JWT verifier, a key set) travel with them, so a root that satisfies none is refused at this call by di's NeedsGate, exactly as a hand-listed provider would be. A root serving router alone drops fragments; a fragments-only root drops router, and its prefix, cors, compression and plugins options — oRPC-only — go unused. observability() is a second starter, not this package's business: it brings the Logger the application writes to, bound from LOG_LEVEL, JSON per line on stdout, every line carrying the trace id of the unit this runtime opened.

RED metrics, reported always and collected when you ask ​

The runtime REPORTS rate, errors and duration at the unit seam — the one place a framework that owns the unit lifecycle gets them for free — and an observer is what turns a report into a measurement. Reporting always happens; collection happens when otel() is composed, and not before:

InstrumentKindDimensions
btravstack.http.operationscountermethod, answerer, status, aborted
btravstack.http.durationhistogram (s)the same four

instrumented is gone. Every unit is handed to Observers, and this module contributes a no-op member of its own — so a graph composing no observability owes nothing, and an operation costs one inert call per module that reads the port. Composing observability() writes the failures as lines; composing otel() beside it opens the spans and mints btravstack.<component>.operations and .duration.

The dimensions are chosen for cardinality, and what is absent matters more than what is present. The request path is not a dimension: /orders/42 would mint a time series per order, which is the classic way a metrics bill becomes the incident. answerer is a mount prefix, so the graph bounds it. Recording happens on the response's 'close', which is the one event that has seen the final status — the runtime's own 404 and 500 included, which no answerer ever sees.

status is what the runtime MEANT to send, and aborted is whether it arrived. ServerResponse.statusCode defaults to 200 and is never rewritten by a socket dying, so a client that walks away mid-body, and a stream the drain resets, would both count as a success. aborted is !response.writableFinished at 'close', and an aborted request settles error whatever its status says — filter on the dimension to tell a genuine 500 from a caller who left.

The authenticators that ship ​

Three, because these are the ones where writing it per application is how CVEs happen. All three are Authenticator values an application binds by name in defineHttp({ authenticators }), exactly like one it wrote itself — the same defineHttp call shown above, with a shipped authenticator under a key instead of a hand-written one.

apiKeyAuthenticator compares SHA-256 digests rather than strings (=== on a secret leaks its prefix through timing, and timingSafeEqual refuses two buffers of different lengths, which would leak the key's length instead), checks every configured key with no early return, and puts a missing header on the same path as a wrong one. Its vocabulary is the union of what its keys grant, inferred rather than declared twice. Keys come from the caller — a config field bound off Env, a secret store — because a key list in the image is a key list in the repository.

jwtAuthenticator, from @btravstack/http-server/jwt, with jose as an optional peer. It owns JWKS fetch, cache and rotation; an algorithm allowlist that is asymmetric-only, because a JWKS publishes public keys and accepting HS256 beside them is the algorithm-confusion attack; and iss, aud and exp required to be PRESENT — jose validates exp only when it is, so without that a signed token omitting it authenticates and never expires. nbf is honoured when present and not required, since real issuers often omit it. Clock tolerance defaults to zero. Every failure is the same refusal, so the endpoint is not an oracle for which check the attacker got wrong.

sessionAuthenticator, from @btravstack/http-server/session, is the cookie a browser sends back — see the session cookie below for the codec that seals it. It injects SessionCodec rather than holding keys, so the codec that reads a cookie is the one that sealed it, and a root composing the scheme without sessionCodec() is refused at the HttpModule call.

The package requires Node ≥22.12. Its CommonJS build loads ESM-only oRPC; /jwt and /session also load ESM-only jose. Node enables require(esm) by default from 22.12.

principal(claims, services) is yours — no standard claim carries a tenant — and answering undefined refuses the token. services are the ports inject names, resolved once as the graph is built, and each joins the scheme's needs, so a claim check that depends on the deployment reads its configuration rather than a constant — here, an allow-list of the subjects a delegated token may act for:

ts
class TrustedDelegates extends Port("TrustedDelegates")<{
  readonly subjects: readonly string[];
}> {}

const delegated = jwtAuthenticator<Identity>()({
  inject: { trusted: TrustedDelegates },
  principal: (claims, { trusted }) =>
    typeof claims.sub === "string" && trusted.subjects.includes(claims.sub)
      ? { tenantId: "acme", userId: claims.sub }
      : undefined,
});

// provided beside it, from the environment
const trustedDelegates = Config.provider(TrustedDelegates)(
  Config.object({ subjects: Config.list("TRUSTED_DELEGATES") }),
);

scopes is the vocabulary and the only place it is written: the scheme's scope type is inferred from it, so it cannot name a scope nothing grants. The grant is its intersection with the token's scope or scp claim, so a token claiming a scope the scheme does not know grants nothing extra. Nothing new checks them: the grant goes through granted() and the existing walk produces the 403.

Its other options:

OptionRequiredDefaultWhat it is
principalyes—(claims, services) => P | undefined; undefined refuses the token
injectnononethe ports principal reads, as a provider's inject; each joins the scheme's needs
scopesnonone (the scheme is unscoped)the vocabulary, and the scheme's scope type; omit it entirely for a scheme with no scopes
jwksnoread from HTTP_JWT_JWKS_URIpins the issuer's JWKS endpoint instead of reading it
issuernoread from HTTP_JWT_ISSUERpins the required iss
audiencenoread from HTTP_JWT_AUDIENCEpins the required aud
algorithmsnoDEFAULT_ALGORITHMSthe accepted signature algorithms; asymmetric only, and none is not expressible
clockToleranceSecno0leeway on exp and nbf, in seconds
headernoauthorizationwhich header carries the token, as Bearer <token>
allowInsecureJwksnofalsefetch the key set from an http: URL that is not on a loopback host
variablePrefixnoHTTP_JWTthe prefix of the three variables below, so a second scheme reads its own

jwks, issuer and audience are pins, the same rule HttpModule's port has against PORT: explicit beats environment, per field. Left unset, they bind from HTTP_JWT_JWKS_URI, HTTP_JWT_ISSUER and HTTP_JWT_AUDIENCE through Config.parse inside the scheme's make arm, so a variable nobody pinned and nobody set is a ConfigInvalid naming it at startup — exit 78 under runMain — rather than a 401 for every caller. jwks is a Config.url field: a malformed URI is refused with the variable named instead of defecting at the first request.

A second scheme names its own variables with variablePrefix: an API that accepts staff tokens from one issuer and customer tokens from another declares the second as jwtAuthenticator<Customer>()({ variablePrefix: "HTTP_JWT_CUSTOMER", principal }), which reads HTTP_JWT_CUSTOMER_JWKS_URI, HTTP_JWT_CUSTOMER_ISSUER and HTTP_JWT_CUSTOMER_AUDIENCE. A pin still beats its variable, and the ConfigInvalid and the cleartext refusal name the scheme's own variable. oidc() takes the same option. The scheme's needs name the prefixed variables too, so a boot's env is typed by them: each is required unless the call pins it, and not read when it does — the same for oidc(), sessionAuthenticator and sessionCodec. See The variables a graph reads.

A cleartext jwks is refused at boot too, on the same rule oidc() applies to its issuer and with the same loopback exception — localhost, 127.0.0.1 and [::1] are the dev loop. Config.url says a value PARSES, not that it is safe; a key set fetched over http: lets anything on the path publish its own signing key and mint tokens this process then accepts, and unlike the issuer case there is no secret an attacker had to steal first (RFC 8725 §3). Pin allowInsecureJwks: true to override it — an option and not a variable, for securityHeaders' own reason: a value whose silent change is a security regression stays in the composition root. The two schemes disagreed about this until it was one rule.

The scheme's Env need travels with it into the graph like any other, and HttpModule declares Env for the whole root, so a composition root writes no needs line for it.

Signed evidence for another recipient ​

jwtVerifier() provides JwtVerifier from the same /jwt subpath when the signed evidence is distinct from the credential authenticating the request. Its verify({ token, audience }) checks the expected audience on every call, while issuer trust and the JWKS cache are established once at startup. Derive that audience from authenticated recipient identity or trusted application policy; copying the evidence token's unverified aud would remove the check.

ts
const evidenceVerifier = jwtVerifier({ variablePrefix: "EVIDENCE_JWT" });

// Compose evidenceVerifier; request-scoped adapters inject JwtVerifier.
const verifiedClaims = injectedVerifier.verify({
  token: evidence,
  audience: authenticatedRecipientAudience,
});

jwks, issuer, variablePrefix, algorithms, clockToleranceSec and allowInsecureJwks have the JWT scheme's semantics below. Only <variablePrefix>_ISSUER and <variablePrefix>_JWKS_URI are read; no fixed _AUDIENCE is needed. The prefix defaults to HTTP_JWT. port optionally names an application-declared port carrying JwtVerifierService, so a second configured issuer has its own provider and cache.

Successful verification returns signed Claims; application claim-shape and authorization checks still follow. Invalid, expired, mismatched or malformed evidence returns CredentialRefused without credential details. So does evidence whose kid and alg select no key of the fetched key set: the sender chose them. An unknown kid triggers a refetch only when the cached set is older than jose's 30-second cooldown; within it the evidence is refused without a fetch, so evidence signed with a key published in those 30 seconds is refused until a later attempt. A key set that cannot be obtained or used (unreachable, timed out, non-200, malformed), or that publishes the key the evidence selects (by kid, kty and alg) with metadata no verification can use (key_ops, use, ext), returns AuthenticatorUnavailable and a bounded issuer observation, without recording the evidence token. Blank expected audiences are refused. jwtAuthenticator reuses the same verification implementation and retains its existing authentication-scheme refusal behavior.

@btravstack/http-server/session is the storage-free half of a session: the cookie is the session, encrypted, and nothing is kept server-side.

sessionCodec({ keys?, ttlSec? }) is the provider. seal turns a principal into a dir + A256GCM JWE stamped with its own lifetime; unseal turns a cookie back into a Session<unknown> — or into nothing.

OptionRequiredDefaultWhat it is
keysnoread from HTTP_SESSION_KEYS32-byte base64url keys; the first seals, every one unseals
ttlSecno43_200 (12 h)how long a session lasts, stamped by seal rather than accepted from its caller

HTTP_SESSION_KEYS is a comma-separated list of 32-byte base64url keys (A-Z a-z 0-9 - _, no padding — standard base64 is refused). Mint one with node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64url"))'. Rotation is append, promote, drop, one rollout each: old,new so every pod can open the new key before any pod seals with it, then new,old, then — once the old key's sessions have expired — new. The first key seals and every key unseals, so during a rolling update, with old and new pods serving side by side, no pod meets a cookie it cannot open; prepending in one step would let the first new pod seal cookies the old pods reject. A cookie sealed with a key that is gone is anonymous rather than an error — a browser holding a stale cookie logs in again, which is not a failed request. A key that is not 32 base64url bytes fails the boot with a ConfigInvalid naming the variable and the position it refused, never the value.

The service also publishes ttlSec — the number seal stamps — because a login has to write the same one into the cookie's Max-Age: a browser holding the cookie longer than the payload lives looks anonymous with a cookie still attached, and one holding it for less is a session cut short by the wrapper rather than by the policy.

Mint a key list per deployment. The sealed iss names a login, not a deployment, and there is no aud, so two deployments handed the same HTTP_SESSION_KEYS and the same issuer accept each other's sessions — a cookie minted by staging opens in production. That binding is deliberately not here: what it would guard against is an operator copying a secret between environments, which the same operator can undo by copying it back, so it would be advice rather than a boundary — where a key list per deployment IS one. Rotation being append, promote, drop is what makes minting a separate list cheap.

The sealed payload names what it is. seal writes a type marker into the plaintext and unseal requires it back, so something else sealed with these keys under this same algorithm — a login's short-lived transient, say — is not a session here. A cookie NAME could not do that job: __Host- is enforced by the browser on Set-Cookie and never on a request, so a client is free to replay any value it holds under any name.

Every failure to unseal is the same undefined — no cookie, a string that is not a JWE, a key that is gone, an edited ciphertext, another algorithm, another purpose, a payload that is not a session, one past its exp. Nothing outside learns which of them it got wrong.

codec.transient seals the login flow's own state, on the same service:

ts
void codec.transient.seal({ verifier: "v", state: "s", nonce: "n", returnTo: "/orders" });
void codec.transient.unseal(cookie); // the state back, or nothing

It takes a Record<string, string> — everything an OIDC redirect has to remember — and seals it with the SAME keys under its own purpose marker and a fixed lifetime of TRANSIENT_TTL_SEC, five minutes. unseal requires that marker back and answers the state with the codec's own stamps stripped, so the two refuse each other in both directions: a transient replayed under the session cookie's name is anonymous, and a session presented as login state is nothing. Five minutes is a constant rather than an option — a login that takes longer is a login to start again.

It is a pair on the codec rather than a provider of its own because the decoded keys live inside the codec: a second provider would be either a second sessionCodec() — a duplicate provider di refuses at build — or a second port re-reading HTTP_SESSION_KEYS, with its own rotation story for a list an operator rotates once.

sessionAuthenticator<P>()({ scopes?, principal?, issuer?, clientId?, variablePrefix? }) is the scheme over that codec, and it is an ordinary Authenticator: bind it in defineHttp({ authenticators }) and requires: [{ session: [] }] or authenticated({ session: [] }) work exactly as they do for the other two.

OptionRequiredDefaultWhat it is
scopesnonone (the scheme is unscoped)the vocabulary; the grant is its intersection with the session's own scopes
principalnosession.principalwhat the session makes the caller; undefined refuses it, and the default refuses a session sealed with none
issuernoread from HTTP_OIDC_ISSUERthe issuer whose login's sessions this scheme accepts
clientIdnoread from HTTP_OIDC_CLIENT_IDthat login's client id — a provider may host several logins
variablePrefixnoHTTP_OIDCthe prefix of that variable, <prefix>_ISSUER — the same prefix as the login it pairs with

It accepts only its own login's sessions. oidc() seals the issuer and the client id it is configured with into every session, and the scheme refuses one sealed by any other login — another issuer, another client of the same issuer, or none. Two logins share the cookie and the key list, so nothing else could tell their sessions apart: without this, a session from a customer login would authenticate on a route guarded by a staff login's scheme. A scheme pairs with its login by reading the same variables — HTTP_OIDC_ISSUER and HTTP_OIDC_CLIENT_ID by default, <variablePrefix>_ISSUER and _CLIENT_ID for a second login — so the two cannot disagree, and an unset one is a ConfigInvalid naming it at startup. A session an application seals itself through SessionCodec names its iss and clientId the same way.

The cookie is SESSION_COOKIE, __Host-session, and cannot be renamed.__Host- is a browser-enforced prefix — Secure, Path=/, no Domain — so a sibling host cannot write the cookie and plain HTTP cannot carry it, and there is no secure option because it would be an option for shipping a session cookie insecurely. There is no cookie option either: oidc() seals that name, and a scheme reading a different one is a deployment where every login succeeds into a cookie nothing reads — a redirect loop with no error anywhere. Both sides name the one exported constant.

The cookie header is parsed by name, exactly. __Host-session-theme is not __Host-session, a value carrying an = arrives whole, and the first of a repeated name wins — the order a browser sends them in, so a duplicate cannot shadow the session.

The lifetime is fixed and there is no sliding re-seal. A scheme is handed headers, not a response, so it has nowhere to put a Set-Cookie; ttlSec is the whole session, and logging in again is what issues the next one.

The trade that decision makes, stated plainly: a form submitted after the lifetime expires loses what was typed. A browser that sat on a page past ttlSec sends its POST with a cookie that is no longer a session, is refused, and is sent to log in — with nothing to re-seal and nothing holding the body. Twelve hours is the default because it is long enough that a working day does not cross it; shorten it deliberately, knowing that is what shortening it costs.

Password hashing and credential issuing are out of scope. All three schemes are on the verifying side: the credential is minted by whoever owns the identity, and this package reads what arrives — the session cookie included, since sessionCodec seals a principal somebody else already authenticated. Reach for argon2 directly at whatever mints your tokens.

The login answerer ​

@btravstack/http-server/oidc is the other half of the session: the codec seals a principal, and oidc() is what authenticates one. It is an answerer — one HttpHandler member beside orpc() and htmx(), mounted under a prefix of its own — serving three routes, and it needs openid-client (an optional peer, behind this subpath).

ts
const api = defineHttp({
  authenticators: { session: sessionAuthenticator<Identity>()({ scopes: ["orders:export"] }) },
});

const row = api.HtmxGet("/orders/:id/row", { requires: [{ session: [] }] })({
  inject: {},
  sync: () => (context) => OkAsync(html`<p>${context.principal.tenantId}</p>`),
});

export const BrowserApi = HttpModule("BrowserApi")({
  fragments: api.HtmxFragments([row]),
  fragmentsLogin: "/auth/login",
  provides: [
    row,
    sessionCodec(),
    ...oidc()({
      scope: "openid orders:export",
      principal: (claims) =>
        typeof claims["tenant"] === "string" && typeof claims.sub === "string"
          ? { tenantId: claims["tenant"], userId: claims.sub }
          : undefined,
    }),
  ],
});
OptionRequiredDefaultWhat it is
principalyes—(claims, services) — what the ID token's claims make the caller; undefined refuses the login
injectnononethe ports principal reads, as jwtAuthenticator's inject
issuernoread from HTTP_OIDC_ISSUERthe provider, as its discovery document names itself
clientIdnoread from HTTP_OIDC_CLIENT_IDthis deployment's client
clientSecretnoread from HTTP_OIDC_CLIENT_SECRETits secret — this is a confidential client
redirectUrinoread from HTTP_OIDC_REDIRECT_URIthe URI registered with the provider
postLogoutRedirectUrinoread from optional HTTP_OIDC_POST_LOGOUT_REDIRECT_URIprovider-registered absolute HTTP(S) return URI, without credentials or a fragment; requires tokens
tokensnononeserver-side token manager port, also passed to the session authenticator
prefixno/authwhere the three routes are mounted
scopenoopenidwhat the authorization request asks for
postLogoutno/where a logout lands when the provider advertises no end-session endpoint
allowInsecureIssuernofalsetalk to an http: issuer that is not on a loopback host
variablePrefixnoHTTP_OIDCthe prefix of the login's environment variables, so a second login reads its own

GET <prefix>/login?return=<path>&as=<hint> mints a PKCE verifier, a state and a nonce, seals them and return into the five-minute __Host-oidc cookie, and answers 303 to the provider's authorization endpoint. return is the seam htmx({ login }) writes when it sends an unauthenticated caller here; as rides through as login_hint, so a provider can prefill its own form.

GET <prefix>/callback checks the state that came back against that cookie, exchanges the code, and seals principal(claims) into __Host-session — clearing the transient in the same answer — then 303s to where the login was going. Session.scopes is written from the ID token's space-delimited scope claim and Session.sid from sid, each only when the provider sent one, which is what makes a scoped sessionAuthenticator grant anything at all.

POST <prefix>/logout clears the session cookie and 303s to the provider's end_session_endpoint, or to postLogout when it advertises none. With tokens, it deletes the retained credentials before redirecting. A store failure clears the cookie and answers 503 without a redirect. It is a POST because it is a state change; the answerer enables the same CSRF protection as other cookie-bearing operations.

Anything else under the mount is a 404 from this answerer: it owns every path below its prefix.

Every redirect is a 303, htmx()'s own ruling and for its reason: RFC 9110 §15.4.3 leaves a 302's POST-to-GET change a MAY, and §15.4.4's 303 specifies the retrieval request instead.

return is decoded exactly once, and kept only when it stays here. The query parser is that one decode; the value is then kept only if it starts with / and its second character is neither / nor \. A second decodeURIComponent would turn %255C back into \, and new URL("/\\evil.com", base) resolves to https://evil.com/ — the WHATWG parser reads \ as / in relative-slash state, so a protocol-relative URL is manufacturable out of a value that already passed the guard. Anything else lands on /. The check runs at /login, where the value is sealed, and again at the callback, where it is followed.

What a header accepts is the header's job, so the value goes through forLocation where it becomes a Location rather than being filtered by the guard. Node's header validator refuses control characters and every code point above U+00FF alike, so /订单/1 — an ordinary path — would otherwise pass every guard and then ERR_INVALID_CHAR the callback with the authorization code already spent. It goes out as /%E8%AE%A2%E5%8D%95/1, and a CR/LF one as /%0A…, unsplittable.

The code grant is checked against the REGISTERED redirect URI, never Host. currentUrl is redirectUri carrying this request's query string, so a forged Host header cannot move the check — and a deployment behind a proxy, or a test on an ephemeral port, needs no trust in that header either.

A cleartext issuer is refused at boot, unless it never leaves the machine.Config.url says a value parses, not that it is safe: an http: issuer sends the client secret, the authorization code and every token in the open, and the allowInsecureRequests this package then applies is the one check that would have refused to. An http: issuer on a loopback host — localhost, 127.0.0.1, [::1] — is accepted as it stands, because plaintext that never leaves the machine is the development loop. Any other is a ConfigInvalid naming HTTP_OIDC_ISSUER, unless allowInsecureIssuer: true is pinned on oidc(). It is an option and not a variable, for securityHeaders' reason: a posture whose silent change is a security regression belongs in the composition root, where changing it is a visible act.

Discovery runs ONCE, at boot. A provider that is not there is OidcUnreachable naming the issuer — a modeled startup failure runMain turns into an exit code — rather than a 500 on the first login; and the JWKS cache and the metadata are one per process rather than one per request. The configuration also has the ID token's signature check enabled explicitly: OIDC Core lets a client trust a token that arrived over TLS from the token endpoint, which is not a trust this package extends.

A registered logout return is opt-in. Set postLogoutRedirectUri, or <variablePrefix>_POST_LOGOUT_REDIRECT_URI, to the absolute HTTP(S) URI registered with the provider. Credentials and fragments are refused. Configuring a return without a tokens manager fails boot with ConfigInvalid.

When that setting and a retained ID token are present, the answerer obtains the issuer/client-bound hint while deleting the credentials, then sends id_token_hint and post_logout_redirect_uri to the discovered end-session endpoint. The cookie and principal remain token-free. Access/refresh tokens never enter redirects; neither Host nor request parameters choose a logout destination.

Without the setting, provider logout stays parameterless even when the token manager retained an ID token. A missing, invalid, expired, stateless or replayed session, and an older record without a hint, also use parameterless provider logout: no id_token_hint or post_logout_redirect_uri is sent. The provider then determines where the browser lands. When discovery advertises no end-session endpoint, the existing relative postLogout fallback still applies.

It injects SessionCodec rather than holding keys, so the codec that seals a session here is by construction the one sessionAuthenticator reads it back with, key rotation included — and a root composing oidc() without sessionCodec() is di's own unmet need naming the port, refused at the HttpModule call. The cookie it seals is SESSION_COOKIE, the same constant the scheme reads.

Each route is an operation reported to Observers, the way a cache read is. A refused login is the one refusal in this package that destroys information — the provider's reason must not reach the caller, and a 401 is not an error the runtime's RED metrics count, so a rotated client secret, a dead token endpoint and a genuinely bad code are otherwise one indistinguishable spike. So every refusal settles error carrying its own reason: transient_missing, state_mismatch, provider_refused, grant_failed or principal_refused. Those five are dimensions and are bounded; the provider's own error_description and the library error's message are caller-controlled, so they ride the cause — a line or a span, never an instrument — and the authorization code and the tokens ride nothing. It costs a root nothing: the starter contributes the no-op observer every reader of that set port owes, and exports the port too — oidc() answers providers rather than a module, so it has nowhere to put a no-op of its own, and the export is what keeps the set free to a root instead of making this one answerer charge for it. Composing observability() is what turns the line on; composing none leaves an inert call per route.

Every refusal clears the transient, not only the success: the flow state is spent the moment a callback has been seen. The consequence is worth knowing — two logins running at once in one browser share one transient, and the last /login wins; the other tab's callback finds a state that does not match and is refused. The cookie is the whole memory of the flow, and a browser has one of it.

The protected resource ​

A client that holds no token has to learn where to get one. An MCP client is the usual case: it calls the API, is refused, and must find the authorization server on its own. protectedResource() answers that question the way RFC 9728 specifies, and needs no peer:

ts
import { protectedResource } from "@btravstack/http-server";

// Spread into the root's `provides`, beside the router or the answerer it protects.
export const resource = protectedResource({ scopes: ["orders:read"] });
  • GET /.well-known/oauth-protected-resource<path> answers the metadata document: resource, authorization_servers and, when scopes is given, scopes_supported. bearer_methods_supported is left out: the scheme that reads the token decides where it is sent, not this document. <path> is the resource identifier's own, as RFC 9728 §3.1 places it: a resource at https://api.test/mcp is described at /.well-known/oauth-protected-resource/mcp, under the resource's own scheme, host and query alone. scopes must be OAuth scope tokens (RFC 6749 §3.3). Anything else below that mount is a 404.
  • A 401 for the resource's own URL carries WWW-Authenticate: Bearer resource_metadata="<that URL>", whichever answerer wrote it — oRPC, GraphQL, fragments — without any of them knowing. A challenge the answerer wrote itself, such as Bearer error="invalid_token", keeps its place, and discovery is appended to it (RFC 9728 §5.4). Any other URL — another scheme where the request line names one, another host, a path below it, the same path without its trailing slash, another query — is another resource, whose metadata a client must discard (RFC 9728 §3.3). A request's bare trailing ? reads as no query, since clients disagree on sending it — and for that reason a resource identifier ending in one is refused at boot. And a challenge on a served answer reads as "the metadata changed, fetch it again" (§5.2). The value rides HttpChallenges, a set port the starter reads the way it reads CookieSchemes, so a root composing no protected resource sends no header.

A verified Bearer credential missing a required scope gets a 403 with WWW-Authenticate: Bearer resource_metadata="<that URL>", error="insufficient_scope", scope="<required scopes>". The scope is the complete first unsatisfied alternative whose Bearer verifier accepted the token, in route declaration order. Alternatives are OR, so their scopes are never combined. This works for oRPC, htmx, GraphQL and MCP. Cookie/API-key scope refusals, business authorization refusals, plain 403 responses and another resource receive no OAuth scope error. An existing application challenge remains. jwtAuthenticator identifies its standard Authorization-header verifier as Bearer; a custom HttpAuthenticator opts in with bearer: true. A custom JWT header is outside RFC 6750's Authorization mechanism and does not opt in automatically. Invalid custom scope tokens omit the scope attribute rather than writing unsafe header bytes. The error itself remains insufficient_scope.

It reads two variables, each pinned by the option of the same name: HTTP_RESOURCE_URI, the resource identifier — the audience its JWT scheme requires, by RFC 8707, published as the URL parser serializes it — and HTTP_RESOURCE_AUTHORIZATION_SERVERS, a comma-separated list of the issuers it accepts tokens from. Both are checked by @btravstack/config's cleartext rule at boot: a client follows these URLs with its credentials, so an http: one that is not on a loopback host is a ConfigInvalid naming the variable unless allowInsecure: true is pinned at the call. A resource identifier carrying a fragment or credentials, or an authorization server that is not an issuer identifier (an absolute URL with no query or fragment, RFC 8414 §2), is a ConfigInvalid too, and one boot names every offending variable. The metadata URL keeps the identifier's path and query, dropping only the slash after the host (§3.1), and an empty scopes list is omitted from the document (§3.2).

It serves metadata and refusal challenges. Verifying the token stays the JWT scheme's, and exchanging it for a downstream token is @btravstack/oauth-client's onBehalfOf.

One resource per host ​

A deployment answering on one domain per tenant serves one protected resource per domain: https://tenant-a.example/mcp and https://tenant-b.example/mcp are two URLs, so two resources, and each document must name its own (§3.3). protectedResourcePerHost() derives the identifier from each request, for the hosts the application says it serves:

ts
import { ServedHosts, protectedResourcePerHost } from "@btravstack/http-server";

const verified = new Set(["tenant-a.example", "tenant-b.example"]);

// Spread into the root's `provides`, beside the answerer it protects.
export const resource = [
  ...protectedResourcePerHost({ path: "/mcp", scopes: ["orders:read"] }),
  Provider(ServedHosts)({ inject: {}, value: (host) => OkAsync(verified.has(host)) }),
];
  • The identifier is <scheme>//<host><path>. The host is the request's own — an absolute-form target's authority, else Host — as the URL parser spells it: lowercase, the scheme's default port dropped. The scheme is https:, since an origin-form request names none and a proxy terminating TLS is the usual front; on a loopback host, which a client reaches directly and the listener answers in cleartext, it is http:. An absolute-form target names its own, and an http: one off a loopback host is admitted only with allowInsecure: true.
  • ServedHosts vouches for the host, once per request to the resource's path or its metadata URL, and for no other request. It is the application's: verified domains live in its tenancy data, not in the environment, and change at run time. An unserved host gets a 404 for the metadata document and no challenge, so a forged Host never mints discovery for a domain the application did not vouch for. Its failure is never: a defect there is the runtime's 500. It is handed the request unit's AbortSignal as its second argument, aborted once the unit settles; a client that leaves while the lookup is pending settles the unit at once, without waiting for it, and the request is never dispatched.
  • Everything else is protectedResource()'s, per host: the document, the 401 challenge and the typed 403 scope refusal name that host's resource, and the matching rules hold unchanged — exact scheme, host, path and query, a bare ? read as none, no userinfo or fragment.

It reads HTTP_RESOURCE_PATH, the path every served host answers the resource at, and HTTP_RESOURCE_AUTHORIZATION_SERVERS, each pinned by the option of the same name. A path the URL parser would spell differently — relative, carrying a query or fragment, or with dot segments — is a ConfigInvalid at boot, and the authorization servers and scopes are checked as protectedResource() checks them.

Server-side OIDC tokens ​

@btravstack/http-server/oidc-tokens adds opt-in delegated API calls and retained logout hints to the existing login. Compose oidcTokens(), provide OidcTokenStore, and pass the manager's port as tokens to both oidc() and sessionAuthenticator(). The default port is OidcTokens; oidcTokens({ port: StaffTokens }) binds a second login to an application-declared port of OidcTokensService.

ts
const tokens = oidcTokens();
const browserAuth = defineAuth({
  authenticators: {
    session: sessionAuthenticator<Identity>()({ tokens: tokens.port }),
  },
});
const login = oidc<Identity>()({
  principal,
  tokens: tokens.port,
  postLogoutRedirectUri: "https://app.example/signed-out",
});
const BrowserSessions = Module("BrowserSessions")({
  provides: [
    sessionCodec(),
    Provider(OidcTokenStore)({ inject: {}, value: sharedStore }),
    tokens,
    ...login,
  ],
  exports: [tokens.port],
});

An application root imports this infrastructure and composes its answerer using browserAuth. As with ordinary login, it supplies the OIDC variables, HTTP_SESSION_KEYS, and the HTTP runtime's observer contributions. Register the sample's https://app.example/signed-out URI with the provider, or omit the pin and set HTTP_OIDC_POST_LOGOUT_REDIRECT_URI per deployment. Leave both unset to retain parameterless logout. oidcTokens reads <variablePrefix>_ISSUER, _CLIENT_ID and _CLIENT_SECRET, with HTTP_OIDC as the default prefix. Options pin those values. Discovery and the token manager are built once per provider; the login answerer separately discovers its own client. Their configured issuer and client ID must agree, or login construction fails with ConfigInvalid.

Store obligations ​

There is no process-local production adapter. The application supplies a shared OidcTokenStoreService with these operations:

  • create(reference, record) inserts a new reference and refuses a collision.
  • transact(reference, work) serializes every operation for that reference across replicas, including a missing row. It runs work with the latest record, atomically commits the returned replacement, and returns value. An undefined replacement deletes the row. A defect commits nothing.

Refresh runs while this ownership is held. All invalidation must use the same boundary. A lease-based implementation must fence expired owners, including writes after deletion, so a late refresh cannot resurrect a logged-out session. Protect credentials at rest and expire rows at sessionExpiresAt. Expired or missing records never authenticate; storage failures use OidcTokenStoreUnavailable, containing no driver details or credentials.

StoredOidcTokens.idToken is optional so existing records remain readable. Persist it with the access/refresh credentials when supplied; adapters that select stored fields explicitly must include it to support a hinted logout.

Request capabilities and lifetime ​

OidcTokensService.session(cookie) validates a raw __Host-session cookie and checks server-side revocation without refreshing. accessToken(cookie) resolves the bearer for a downstream call. invalidate(cookie) deletes retained credentials before returning success and keeps its AsyncResult<void, OidcTokenStoreUnavailable> result. logout(cookie) deletes in the same store transaction that obtains its hint, returning AsyncResult<OidcLogoutContext | undefined, OidcTokenStoreUnavailable>. OidcLogoutContext contains only { idToken: string }; absent or unusable sessions return undefined. These methods accept a raw cookie, never a reference from a URL or form. The service validates the cookie's signature, lifetime, issuer and client before using its opaque reference. A session from a different login is refused. Logout also checks the stored record's issuer/client before returning its hint or deleting it. Custom implementations of OidcTokensService must provide the new atomic logout operation; existing invalidate callers need no change. A stateless authenticator refuses a cookie carrying a server-side reference, so omitting tokens cannot bypass revocation.

The encrypted cookie retains the application principal and a random 256-bit tokenRef; it contains no access token, refresh token or ID token. The callback stores access and refresh credentials, an optional ID token, absolute lifetimes, issuer/client binding and the verified subject. OidcTokenResponse.id_token is optional; an older response or record without one still supports API calls and uses parameterless provider logout. A successful re-login invalidates the previous session. Logout clears the cookie and deletes the record. A deletion failure answers 503 instead of reporting a successful logout; a shared-store outage cannot silently fall back to trusting the cookie alone.

Access tokens are refreshed before expiry, with skew capped at half their lifetime. Refresh rotation is committed under the shared lock, and an omitted refresh token keeps the previous one. A refused refresh or an invalid replacement invalidates the retained session. A returned ID token replaces the stored hint only after signature validation and the same-subject check; an omitted ID token keeps the previous hint. Refresh never extends the absolute browser-session lifetime. An access token already handed to an in-flight caller cannot be recalled by local deletion; provider-wide token revocation is not performed.

createOidcTokens(configuration, codec, store, observers, clock, binding) exposes the same capability without an HTTP listener. A framework adapter supplies an already-discovered openid-client configuration with signature validation and its registered issuer/client binding, then passes the raw session cookie per request. Keep access/refresh tokens on the server: never return them from a page loader, serialize them with principal data, or put them in public configuration. Use the logout context's ID token only as the bound provider's id_token_hint in the dedicated logout redirect; do not expose it in ordinary page data, cookies or logs.

Operations use component: "oidc-tokens" and bounded names create, session, access, refresh, invalidate, logout. No reference, subject or credential is an observation attribute. Failures carry only their bounded Err/Defect reason, without the provider's raw body, message or cause. The original result still reaches the caller.

api.OrpcRouter(contract)({ inject: deps, sync }) ​

Contract-first: contract is an oRPC router record (Record<string, RouterContract> — a record, not a bare procedure), and the second call is di's Provider(port)({ inject: deps, sync }) on the starter's own router port with one difference — sync returns an implementation record shaped like the contract and the router is built from it. Only the sync arm exists: a router is built, not acquired.

Each leaf is the .result() handler @unthrown/orpc gives that procedure's implementer: (helpers, input) => AsyncResult<Output, ORPCError>, where input is the contract's parsed input, Output its declared output and helpers.errors its declared error map. A typo'd key, a missing procedure or a wrong output type is a compile error at the call. implement(contract), os.…, .result(...) and os.router(...) are what the call does for you.

There is no name to give: a process serves one router as it boots one runtime, so the port is the starter's — Port("OrpcRouter"), declared once, framework-owned like HttpConfig — and two router providers in one graph are di's duplicate-provider defect at build. Returns Provider<PortInstance<"OrpcRouter", Router<…>>, never, InstanceType<D[keyof D]>> & { readonly port: PortClassOf<"OrpcRouter", Router<…>> } — provider.port is the port class, for a hand-declared provider or a type test, and provider.authenticators carries the scheme providers defineHttp bound. The implementation below is the one in examples/order-api/src/slices/orders/controller.ts, served through the deps form — the example composes it as a controller instead (see the composing form), and a fragment is a contract, so the same sync reads either way. contract.orders is marked authenticated({ user: [] }), so api here is the application's own binding, from its src/auth.ts, and the tenant comes off context.principal rather than off the input — through the user kind's own module, which is where the use cases below were built over it:

ts
export const ordersRouter = api.OrpcRouter(contract.orders)({
  unit: { place: PlaceOrder, find: FindOrder, list: ListOrders },
  sync: () => ({
    place: ({ errors, context }, input) =>
      context.unit.place
        .execute(input.id, input.quantity)
        .map(view)
        .mapErrCases((matcher) =>
          matcher
            .with(P.tag("InvalidQuantity"), (error) =>
              errors.INVALID_QUANTITY({
                message: error.message,
                data: { id: error.id },
              }),
            )
            // A malformed id is the caller's mistake, so 400 — not the
            // 409 a duplicate gets.
            .with(P.tag("InvalidOrderId"), (error) =>
              errors.BAD_REQUEST({
                message: error.message,
                data: { id: error.id },
              }),
            )
            .with(P.tag("DuplicateOrder"), (error) =>
              errors.CONFLICT({
                message: error.message,
                data: { id: error.id },
              }),
            ),
        ),
    find: ({ errors, context }, input) =>
      context.unit.find
        .execute(input.id)
        .map(view)
        .mapErrCases((matcher) =>
          matcher.with(P.tag("OrderNotFound"), (error) =>
            errors.NOT_FOUND({
              message: error.message,
              data: { id: error.id },
            }),
          ),
        ),
    // A listing. The one translation is the cursor — the contract carries
    // `after` and `before` and refuses both, where the port makes them a
    // union — and the two modeled failures are both about that cursor, the
    // one field that came from outside: unreadable, or issued under a sort
    // this request no longer names.
    list: ({ errors, context }, { after, before, ...page }) =>
      context.unit.list
        .execute(
          before === undefined
            ? { ...page, ...(after === undefined ? {} : { after }) }
            : { ...page, before },
        )
        .map((found) => ({ ...found, items: found.items.map(view) }))
        .mapErrCases((matcher) =>
          matcher
            .with(P.tag("MalformedCursor"), (error) =>
              errors.BAD_REQUEST({
                message: "the cursor could not be read",
                data: { cursor: error.cursor, reason: "malformed" },
              }),
            )
            .with(P.tag("CursorSortMismatch"), (error) =>
              errors.BAD_REQUEST({
                message: "this cursor was issued under a different sort; start from the first page",
                data: { cursor: error.cursor, reason: "sort-mismatch" },
              }),
            ),
        ),
    // `export` names two schemes, so its principal is a tagged union — which
    // is what an authorization rule takes, since what a caller may export
    // depends on which of them it is.
    export: ({ errors, context }, input) =>
      context.unit.find
        .execute(input.id)
        .flatMap((order) => exportable(context.principal, order).toAsync())
        .map((authorized) => ({ csv: renderCsv(authorized) }))
        .mapErrCases((matcher) =>
          matcher
            .with(P.tag("OrderNotFound"), (error) =>
              errors.NOT_FOUND({
                message: error.message,
                data: { id: error.id },
              }),
            )
            .with(P.tag("Forbidden"), (error) =>
              errors.FORBIDDEN({
                message: error.message,
                data: { id: error.id, reason: error.reason },
              }),
            ),
        ),
  }),
});

An implementation key the contract does not declare is unreachable through the types; if one is smuggled past them it is dropped, not defected on.

The composing form: api.OrpcRouter(contract)([piece, …]) ​

For a contract shaped Record<string, RouterContract>, OrpcRouter also takes an array of pieces — each an OrpcController(contract, path) over one node of the contract tree, at any depth — instead of { inject, sync }:

ts
export const orderRouter = api.OrpcRouter(contract)([
  ordersController,
  customersController,
]);

Coverage is leaf-based: the pieces' paths must partition the contract's procedures, so any mix of depths composes — a piece per top-level fragment, one nested under "v1.orders", or one at a bare procedure path like "health", side by side. An uncovered procedure is refused against the last overload — declared last on purpose, so TypeScript reports its failure rather than degrading to di's own Qualification, which names nothing:

text
error TS2769: No overload matches this call.
  The last overload gave the following error.
    Type 'Minted<{ orders: { place: ContractBuilder<object>; }; users: { find: ContractBuilder<object>; }; billing: { pay: ContractBuilder<object>; }; }, "orders", SchemesFrom<...>, never, Record<...>>' is not assignable to type 'readonly ["UNCOVERED CONTROLLERS — the contract declares a procedure this array does not cover", "billing.pay" | "users.find"]'.

Read the last line: it is the only actionable part of the diagnostic, and it carries both halves — the marker, and every procedure no piece covers. That holds at any length, because the refusal is a tuple as long as the array you wrote: its head is your own elements, which match, and its last element is the marker paired with what is missing, so TypeScript lines the two up element by element and reports on the trailing one.

That output is TypeScript 7's. TypeScript 5.9 lists both overloads, and the { inject, sync } form comes first — Overload 1 of 2 … is missing the following properties …: inject, sync — which is not the mistake: skip to Overload 2 of 2, or search for UNCOVERED, and add a piece for the named procedure to the array. The full diagnostic under both compilers is in Read a wiring error.

A second gate rides the same overload: two pieces whose paths nest — "v1" and "v1.orders" — would implement the same procedures on two distinct port ids, which di cannot see conflicting (unlike two pieces at the same path, which share one id and are di's ordinary duplicate-provider defect), so overlap is refused explicitly:

text
error TS2769: No overload matches this call.
  The last overload gave the following error.
    Type 'Minted<{ v1: { orders: { place: ContractBuilder<object>; }; customers: { find: ContractBuilder<object>; }; }; health: ContractBuilder<object>; }, "health", SchemesFrom<...>, never>' is not assignable to type 'readonly ["OVERLAPPING CONTROLLERS — a piece sits inside another piece's fragment", "v1.orders"]'.

Both gates stand down when a piece's key is a union, which is what a piece whose own mint was refused looks like: OrpcController(contract, "billing") on a contract with no billing is a TS2345 listing every valid path, and the value TypeScript hands back is typed from the parameter it rejected — so its key reads as all of them at once, "v1" and "v1.orders" included, which used to make the router call report an OVERLAPPING that was not there. The mint's own error is the one to read.

The requirements fold down every path exactly as Implementation<C, Schemes> folds them: a contract marked at its root composes through this form too, and each piece inherits those requirements down to whatever depth it is minted at — unless it carries a mark of its own, in which case that one wins. Five compile-time gates are pinned by packages/http-server/src/controller.test-d.ts: every procedure must be covered (the marker above); a path the contract does not declare is refused at the mint, not at the router (see OrpcController below); a piece under the wrong path is impossible by construction, since the path rides the piece's own port id — what that would have meant is now an array leaving a procedure uncovered; a procedure a piece's own fragment does not declare is rejected inside the piece, before the router ever sees it; and a slice lifts into a process of its own with its piece untouched — api.OrpcRouter(contract.orders)({ inject: { implementation: ordersController.port }, sync: ({ implementation }) => implementation }) compiles — the property a slice's independent deployability rests on. The { inject, sync } form is unchanged and stays correct for a small API — an array is never a valid { inject?, unit?, sync } call, so Array.isArray alone tells the two arms apart, and there is nothing else left to discriminate. The composed provider carries its pieces on provider.pieces (empty for the { inject, sync } form), which HttpModule provides with it, as it does provider.authenticators. See Split a router into controllers for the worked recipe.

api.OrpcController(contract, path) ​

ts
const OrpcController: <
  const C extends Record<string, RouterContract>,
  const K extends ControllerKeyOf<C>,
>(
  contract: C,
  path: K,
) => <
  const D extends Readonly<Record<string, AnyPort>> = Record<never, never>,
  const U extends Readonly<Record<string, AnyPort>> = Record<never, never>,
>(options: {
  readonly inject?: D;
  readonly unit?: U;
  readonly sync: (services: {
    readonly [N in keyof D]: ServiceOf<InstanceType<D[N]>>;
  }) => Implementation<FragmentAt<C, K>, Schemes, never, Units, U>;
} & ([keyof D] extends [never] ? unknown : { readonly inject: D })) => Provider<
  PortInstance<
    Namespace extends "" ? `OrpcController:${K}` : `OrpcController[${Namespace}]:${K}`,
    Implementation<FragmentAt<C, K>, Schemes>
  >,
  never,
  InstanceType<D[keyof D]>
> & {
  readonly port: PortClassOf<
    Namespace extends "" ? `OrpcController:${K}` : `OrpcController[${Namespace}]:${K}`,
    Implementation<FragmentAt<C, K>, Schemes>
  >;
  readonly key: K;
  readonly unit: U;
};

One node of a contract, at any depth, as a provider over a port minted for it — the same two-call shape as api.OrpcRouter(contract)({ inject: { name: Dep }, sync }), aimed at one path into contract rather than the whole tree: a top-level fragment ("orders"), a nested one ("v1.orders"), or a bare procedure ("health"). contract is read for its type. path is checked against ControllerKeyOf<C> — every path into the contract tree — so a path the contract does not declare is refused at this call, with nothing to type the key by:

text
error TS2345: Argument of type '"billing"' is not assignable to parameter of type '"customers" | "customers.find" | "orders" | "orders.place"'.

path also shapes sync's return through FragmentAt<C, K>, so a procedure the node does not declare, or a handler whose input or output has drifted, is a compile error inside the piece. defineHttp({ namespace: "payments-api" }) gives the API's controllers literal identities such as OrpcController[payments-api]:payments. Unnamed APIs retain OrpcController:payments. The same namespace and path reuse one port across API bindings and equivalent contract objects, so two providers claiming that path still fail as duplicates. Separate APIs sharing resource names choose distinct namespaces. The namespace is preserved by Http<A, Units, Namespace> and ControllerPortOf<C, K, Schemes, Namespace>, so DI refuses a dependency supplied only by another namespace. A namespace must be one literal string without ]; widened strings and unions are refused. Router arrays accept their own namespace's controllers and reject duplicate paths at construction. RPC resource keys and OpenAPI HTTP paths stay unchanged. The contract path is carried separately on provider.key, independent of its DI identity. The port is carried back on provider.port, the shape Config.provider(name)(schema) has too, so a slice's module exports controller.port rather than naming a port of its own:

ts
export const OrdersSlice = Module("OrdersSlice")({
  needs: [Logger],
  imports: [OrderApplicationModule, OrderPersistenceModule],
  provides: [ordersController],
  exports: [ordersController],
});

Schemes is fixed by the defineHttp call the piece was minted from, which is what gives a marked fragment's handlers a readable context.principal. The piece does no oRPC work: it is a plain record, and OrpcRouter's own walk wraps each leaf in .result(...) when the composing form builds the router. A fragment is itself a valid contract, so a slice lifts out into a process of its own without its piece changing at all — the lifted root declares the piece's own port and hands back what it built:

ts
export const ordersRouter = api.OrpcRouter(contract.orders)({
  inject: { implementation: ordersController.port },
  sync: ({ implementation }) => implementation,
});

That property is marked do-not-break: it is what makes composing several slices into one router a starting point rather than a trap.

Authentication ​

A contract marked with @btravstack/contract's authenticated(...requirements) is what turns this on. Nothing here is a switch on the starter: the marker is a fact about the contract, and both halves of the package follow it.

A requirement is OpenAPI's own shape — a security scheme's name mapped to the scopes it must grant. Several requirements on one mark are ORed, tried in declaration order. A marked record is the default for every procedure beneath it; a procedure's own mark replaces that default for itself. Nearest mark wins.

In the types. Implementation<C, Schemes> branches on the marker. A marked leaf gets { readonly principal: Principal<SchemesOf<R>, Schemes> } in its implementer's injected context, so the handler reads opts.context.principal — oRPC's own context channel, not a second handler parameter this package invents and not a wrapper around .result(). An unmarked leaf's context is unchanged, which is what makes reading a principal there a compile error.

At runtime. OrpcRouter's walk carries the effective requirements down the contract exactly as the types do, and a protected leaf is built as node.use(principalMiddleware(requirements, authenticators)).result(fn) — .use before .result, which is the only order oRPC leaves available. The middleware reads the request off oRPC's initial context and tries the requirements in order, calling each scheme's authenticator with the request's headers, until one is satisfied.

Principal — what a handler actually reads ​

The leaf's requirements namecontext.principal
one schemethat scheme's identity, bare
several schemes{ scheme, identity }, a discriminated union
none (unmarked)absent — reading it is a compile error

The one-scheme case is byte-for-byte what a handler wrote before named schemes existed, so the common case pays nothing for the feature. The multi-scheme case is a union a handler narrows — with a switch whose missing arm leaves a path returning nothing, which the handler's own return type refuses, or by handing the whole union to something that decides about it, which is what the export fence above does:

ts
({ context }) => {
  switch (context.principal.scheme) {
    case "user":
      return context.principal.identity.userId;
    case "service":
      return context.principal.identity.appId;
  }
};

Authentication dependency outages ​

An application authenticator may return ErrAsync(new AuthenticatorUnavailable()) when its dependency cannot decide. Import the error from @btravstack/http-server. It stops the authentication walk and all HTTP answerers return 503, with no login redirect or credential details. The request reports an auth / authenticate operation with the scheme dimension and outcome: "error". Defects still answer 500; the JWT scheme's JWKS outage classification remains unchanged.

HttpAuthenticator<P, Scope>()({ inject: { name: Dep }, sync }) ​

How one scheme is implemented, and the primitive both shipped authenticators are built on. It hands back a description defineHttp binds to that scheme's port; the scheme's name is not stated here, because it is the key the authenticator sits under in defineHttp({ authenticators }) — written once.

Two arms, di's own pair, and naming both is refused:

ArmShapeWhen
sync(services) => AuthenticatorService<P, Scope>the scheme is built, not acquired — the common case
make(services) => AsyncResult<AuthenticatorService<P, Scope>, E>building it can fail, and that failure is startup's: the Err becomes the graph's own error channel

jwtAuthenticator is the worked make: { inject: { env: Env }, make } over Config.parse, so a misconfigured scheme fails the boot with the variable named, still typed, instead of refusing every caller at run time.

The description it answers is Authenticator<P, Scope, N, E> — four slots: the principal, the scope vocabulary, the ports it needs (InstanceType<D[keyof D]> over its inject record) and the error its arm reports. A sync scheme with an empty inject is never on the last two; jwtAuthenticator is Authenticator<P, Scope, Env, ConfigInvalid>.

ts
type Grant<P, Scope extends string> = {
  readonly identity: P;
  readonly scopes: readonly Scope[];
  readonly [GRANT]: true; // a module-private symbol; `granted()` is what stamps it
};

type Granted<P, Scope extends string> = [Scope] extends [never]
  ? P
  : Grant<P, Scope>;

const granted: <P, const Scope extends string = never>(
  identity: P,
  scopes: readonly Scope[],
) => Grant<P, Scope>;

type AuthenticatorService<P, Scope extends string = never> = ((
  headers: IncomingHttpHeaders,
  query: URLSearchParams,
) => AsyncResult<Granted<P, Scope>, Unauthenticated | CredentialRefused | AuthenticatorUnavailable>) & {
  readonly bearer?: true;
};

Headers and the query, not the request: an authenticator has no business reading a body, and the narrower arguments are what keep it testable without a socket. The query is where a capability URL carries its signed grant, since a browser following a plain link sends no header of the application's choosing — see A credential in the URL. deps are di's, so a JWT verifier or a user directory is injected the way any provider's dependencies are, and that need travels with the authenticator into the graph. Both type arguments are explicit rather than inferred from sync — inference through a returned function's AsyncResult is where a principal silently widens to unknown.

A scheme with no scope vocabulary returns the identity bare. One with a vocabulary reports what the credential actually granted through granted(identity, scopes), checked against the declared vocabulary at the authenticator rather than compared as loose strings at the endpoint. The helper is mandatory, not advisory: the type parameter is erased at runtime, so the brand it stamps is the only sound way the starter can tell the scoped answer from an identity that merely happens to carry a scopes field — an ordinary JWT-claims shape, which a structural test read as the scoped answer and handed the handler undefined.

ts
import { TenantId, TenantIdSchema } from "@btravstack/example-order-domain";
import { granted } from "@btravstack/http-server";
import { jwtAuthenticator, type Claims } from "@btravstack/http-server/jwt";

// A shipped scheme WITH a vocabulary. `jwtAuthenticator` is this primitive
// underneath: it calls `granted` itself, with the intersection of `scopes` and
// the token's own claim, so an application writes only what the claims mean.
export const userAuth = jwtAuthenticator<Identity>()({
  scopes: ["orders:export"],
  principal: (claims: Claims) => {
    const tenant = claims["tenant"];
    return typeof claims.sub === "string" &&
      claims.sub !== "" &&
      typeof tenant === "string" &&
      TenantIdSchema.safeParse(tenant).success
      ? { tenantId: TenantId(tenant), userId: claims.sub }
      : undefined;
  },
});

// A hand-written scheme over a source nothing shipped covers — the subject of
// the client certificate the ingress terminated, forwarded as a header — with
// a vocabulary of its own: the grant is `granted`'s, and both type arguments
// are stated because nothing here infers them.
export const partnerAuth = HttpAuthenticator<
  ServiceIdentity,
  "orders:export"
>()({
  inject: {},
  sync: () => (headers) => {
    const subject = headers["x-client-cert-subject"];
    const org = headers["x-client-cert-org"];
    return typeof subject === "string" &&
      subject !== "" &&
      typeof org === "string" &&
      TenantIdSchema.safeParse(org).success
      ? OkAsync(
          granted({ appId: subject, tenantId: TenantId(org) }, [
            "orders:export",
          ]),
        )
      : ErrAsync(new Unauthenticated());
  },
});

// The same source with no vocabulary answers the identity bare — no
// `granted`, and the handler reads the identity itself.
export const serviceAuth = HttpAuthenticator<ServiceIdentity>()({
  inject: {},
  sync: () => (headers) => {
    const subject = headers["x-client-cert-subject"];
    const org = headers["x-client-cert-org"];
    return typeof subject === "string" &&
      subject !== "" &&
      typeof org === "string" &&
      TenantIdSchema.safeParse(org).success
      ? OkAsync({ appId: subject, tenantId: TenantId(org) })
      : ErrAsync(new Unauthenticated());
  },
});

A hand-written scheme is the exception rather than the shape to copy: for a bearer token or an API key, reach for the two that ship — this one is what a scheme against a user directory, a session store or an ingress's mTLS headers looks like.

Unauthenticated is a TaggedError with an empty payload: the starter surfaces no reason, so a field would be write-only. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default.

CredentialRefused is its 403 sibling, equally empty: the caller presented a credential and the scheme refused it — a capability URL whose signature does not verify, or whose grant has expired. A login cannot help that caller, so the walk answers it as UnderScoped (with no scope hint) unless a later requirement accepts the caller, and htmx({ login }) never redirects it.

An authenticator that wants to record why reports it to Observers before returning, and jwtAuthenticator is the worked case — note what it records. A refusal the caller's own credential caused stays silent, because a line per unauthenticated request is how a scanner writes an application's logs for it. A refusal the scheme's own DEPENDENCY caused — a JWKS that times out, a key set that is not one — settles an error operation carrying a bounded reason, because it refused every caller at once and a bare 401 per request reads as the whole world suddenly sending bad credentials. A kid and alg that select no published key are the token's own fault and stay silent, unless the key set publishes the selected key with metadata no verification can use. An unknown kid is refetched only outside jose's 30-second cooldown, so a token signed with a key published within it is refused until a later attempt.

defineHttp({ authenticators }) — what each scheme resolves to ​

The contract says which schemes protect a route; this says what each one is. The contract names no identity type at all, so nothing about the server's view of a caller reaches a client — and this call is the only thing that gives a marked handler a readable context.principal. Declaring a scheme and implementing it are the same act, so a scheme without an authenticator is not a state this can reach:

src/auth.ts — one per application

ts
export type Identity = { readonly tenantId: TenantId; readonly userId: string };
export type ServiceIdentity = {
  readonly appId: string;
  readonly tenantId: TenantId;
};

export const auth = defineHttp({
  authenticators: { user: userAuth, service: serviceAuth },
});

// The second call binds the module each KIND forks — see
// [The kinds, and what a kind binds](#the-kinds-and-what-a-kind-binds) for why it cannot
// be one call.
export const api = auth.units<{
  anonymous: typeof RequestModule;
  user: typeof UserModule;
  service: typeof ServiceModule;
}>();

Every slice mints its controller from that one api, and its handlers see the right principal with no annotation of their own; nothing else about a controller changes.

Hold it whole — never destructure it

const { OrpcController } = defineHttp(...) is TS2527: each binding of a destructured member expands to a type mentioning @btravstack/contract's inaccessible unique symbol, which the file cannot emit. Held whole, the inferred type collapses to Http<A>, which is nameable — which is why the file above writes no type annotation at all.

defineHttp() with no argument is the public-API case: the registry is Record<never, never>, so a contract that marks anything leaves a scheme port unmet and the composition is refused. It is deliberately not Record<string, never> — an index signature would make every scheme's port look available, and the composition would type-check and then fail at build.

It is per application rather than per slice because a handler's parameter types are fixed where the arrow is written: a composition root cannot re-type a sync callback that lives in another module, so the registry has to be in scope where the handler is.

The gate: one dependency per scheme ​

For every scheme its contract names anywhere, OrpcRouter adds that scheme's port — `HttpAuthenticator:${scheme}` — to the router provider's deps record under a namespaced key (so it cannot collide with one you wrote), strips those keys back out before your own sync sees the record, and adds them to the provider's needs channel. A scheme with no authenticator behind it is therefore an ordinary unmet need at start, not a gate this package invented, and the diagnostic names the port:

text
{ readonly "UNSATISFIED DEPENDENCIES — nothing provides": "HttpAuthenticator:user"; }

(start's gate, in the same words di's Module.build/Module.scoped use.)

There is nothing left for a second gate to check. The registry that types the handlers and the providers that discharge those ports come from the samedefineHttp call, so they cannot disagree — which is why the identity comparison an earlier design performed at HttpModule is gone, along with the authenticator option it lived on.

401 and 403 ​

Requirements are tried in the order the contract declared them, and the first a caller satisfies wins.

OutcomeAnswer
a requirement is satisfiedthe handler runs, principal injected
no requirement accepted the caller401 UNAUTHORIZED
a credential was valid but lacked a scope the requirement named403 FORBIDDEN
a scheme refused the credential presented (CredentialRefused)403 FORBIDDEN
an authenticator returned a DefectoRPC's INTERNAL_SERVER_ERROR, walk stopped

Neither refusal carries a message: oRPC serializes message to the client, and a refusal has nothing a caller is entitled to. A root composing protectedResource() adds the one thing a refused caller needs: where to get a token, and one sufficient scope alternative for a verified Bearer credential that lacked scope. A requirement naming scopes is not satisfied by a credential reporting none — a scheme declared without a vocabulary answers bare, and admitting it there would admit the caller outright. An empty scope list still passes trivially. A Defect is a bug in the authenticator rather than a refusal, so it short-circuits: falling through would let a broken verifier silently promote every caller to the next scheme.

The marker is legibility, not enforcement ​

A procedure with no mark on it or above it is public, and nothing fails if the marker is forgotten — no compile error, no startup failure. There is no deny-by-default here; the contract makes a protected route visible to both sides, and that is all it claims. See Protect a procedure.

html and raw ​

A fragment's handler returns Html, not a string: html`…` escapes every interpolation by default, and raw(markup) is the one way past it.

ts
html`<tr id="order-${order.id}"><td>${order.quantity}</td></tr>`;

A nested Html splices as it is — composition needs no join — and an array of them concatenates with no separator, so a list of rows is as simple as html`${rows}` over an array built with .map.

WARNING

The escaping is context-blind: it protects element text and a quoted attribute value, and nothing else. An unquoted attribute, an attribute name, a URL scheme (href="${url}" does not vet javascript:), and <script>/<style> contents are the caller's own responsibility.

oxfmt and prettier treat a tagged template named html as embeddable markup and reflow it, inserting real whitespace into rendered output. This repo sets embeddedLanguageFormatting: "off"; a consuming application needs the same setting, or its rendered output drifts the moment a formatter runs.

api.HtmxGet(path, options?) and api.HtmxPost(path, options?) ​

ts
type RouteHandler<Path extends string, Input, Requires, Principal, Units, U> = (
  context: ([Requires] extends [never] ? object : { readonly principal: Principal }) & {
    readonly unit: UnitFor<U, Units, KindOf<Requires>>;
  },
  params: ParamsOf<Path>,
  input: Input, // GET: Readonly<Record<string, string>>, `{}` at runtime; POST: the schema's output, or the raw decoded form when `options.input` is omitted
) => AsyncResult<Html, never>;

A route as a provider on a port of its own, minted straight from its method and path — no contract in between, mirroring htmx's own hx-get/hx-post:

ts
import { FindOrder } from "@btravstack/example-order-application";
import { html } from "@btravstack/htmx-server";
import { P } from "unthrown";

export const orderRowFragment = api.HtmxGet("/orders/:id/row", {
  requires: [{ user: [] }],
})({
  unit: { find: FindOrder },
  sync: () => (context, params) =>
    context.unit.find
      .execute(params.id)
      .map(
        (order) =>
          html`<tr id="order-${order.id}">
            <td>${order.quantity}</td>
          </tr>`,
      )
      .recoverErrCases((matcher) =>
        matcher.with(
          P.tag("OrderNotFound"),
          () =>
            html`<tr>
              <td>not found</td>
            </tr>`,
        ),
      ),
});

options.requires marks the route exactly as authenticated(...) marks an oRPC procedure — the same resolvePrincipal walk runs, so a route gets the same 401/403 path and the same Principal<S, Schemes> typing on context.principal. Omit it and the route is public, with no principal on context at all — reading one is a compile error. A scope the scheme's own authenticator never grants fails the compile ending on "UNGRANTABLE SCOPE — its scheme's authenticator cannot grant it", naming the scope.

Both mint factories also take an optional unit: { name: Port } beside inject, exactly as api.OrpcController does, and the handler reads it as context.unit.name — typed by the kind this route's own requires selects. See What a leaf reads off the fork.

Only HtmxPost's options carry an input field — HtmxGet's options type has none, so passing one is a compile error naming the unknown property rather than a value refused at the call: unexpressible, not merely refused. input is any Standard Schema over the decoded form body — the same shape Config.provider accepts. ParamsOf<Path> extracts the :name segments a path template names, at the type level: ParamsOf<"/orders/:id/row"> is { readonly id: string }, and a template naming none is an empty record.

The route's key is `${method} ${path}`, minted as the port id `HtmxFragment:GET /orders/:id/row` — the same two-call shape as api.OrpcController(contract, path), with the path standing in for a contract key. The key space is flat, so two routes minted for one method and path are simply di's duplicate-provider defect, via the port id each carries — there is no unsliceable or overlapping path to refuse. See Serve htmx fragments for the worked recipe.

api.HtmxFragments([piece, …]) ​

Every route composed from an array of HtmxGet/HtmxPost pieces, mirroring the composing form of OrpcRouter minus the coverage it checks — there is no declared route set to leave uncovered:

ts
export const orderFragments = api.HtmxFragments([orderRowFragment]);

Routes are matched in this array's own order, first match wins — see Serve htmx fragments for why that ordering is a security property, not only a routing one. The returned provider carries readonly authenticators the same way the router does, so HttpModule can deduplicate a scheme the two share, by reference.

Transport options ​

HttpModule and GraphqlModule each import the HTTP starter for the root; neither the starter nor its oRPC half is exported, so one of the two is how a deployment root is written. The options below are the starter's, taken on either module. Env and ConfigInvalid stay in the module's type whether or not a field is pinned; a pinned field never produces the latter.

cors, bodyLimit, compression ​

boolean | CORSHandlerPluginOptions, number | false and boolean | ResponseCompressionHandlerPluginOptions — three oRPC plugins as named options, so the ordinary transport policy is configuration a reader sees at the composition root:

ts
export const OrderApi = HttpModule("OrderApi")({
  router: orderRouter,
  unit: { anonymous: RequestModule, user: UserModule, service: ServiceModule },
  provides: [sessionCodec()],
  imports: [OrdersSlice, CustomersSlice, OrderPersistenceModule, observability(), otel()],
  cors: { origin: "https://orders.example", credentials: true },
  bodyLimit: 5_000_000,
  compression: true,
});

true takes the underlying plugin's own defaults; a record is that plugin's own options type verbatim, never a Record<string, unknown> bag.

The scalar half of each is a field of HttpConfig, and the option pins it — exactly as port pins PORT:

VariableDefaultWhat it is
HTTP_BODY_LIMIT1048576the largest request body a procedure reads, in bytes; 0 is unbounded
HTTP_STREAM_BODY_LIMIT1073741824the largest body a route reads as a stream, in bytes; 0 is unbounded
HTTP_CORS_ORIGINunset (off)comma-separated allowed origins, or *
HTTP_COMPRESSIONfalsea flag — true/false, 1/0, yes/no, on/off

So a deployment admits a browser client by setting HTTP_CORS_ORIGIN, with no code change; a test pins cors instead. Explicit beats environment beats default, per field: a CORSHandlerPluginOptions record naming origin wins over HTTP_CORS_ORIGIN, which wins over the starter's default of reflecting the request's origin, and cors: false is off whatever the environment says.

The shapes stay composition-time — a record's allowed methods and headers, compression's encodings and threshold, plugins itself — because an environment carries no records. securityHeaders stays composition-time too, and deliberately: a deployment that can silently turn x-frame-options off is a footgun the other three are not.

bodyLimit defaults to 1 MiB because an unbounded body is a trust boundary rather than a convenience. cors and compression are policy — who may call, and how the bytes travel — and a framework guessing either is worse than one that stays quiet; a body nobody bounded is a request that can consume the process. Over the limit is oRPC's PAYLOAD_TOO_LARGE, decided on content-length when one is sent and while streaming otherwise. An application serving uploads raises it, and false — like HTTP_BODY_LIMIT=0 — turns it off. It bounds what an answerer reads, not the runtime: a file streamed through the process belongs to a route that reads its body as a stream, under streamBodyLimit, or to an answerer of its own, since oRPC otherwise reads a body that declares its length into memory whole.

Streamed bodies ​

An OpenAPI route whose openapi(...) metadata declares requestBodyHint: "octet-stream" gets its body as a ReadableStream while it arrives, and streamBodyLimit (HTTP_STREAM_BODY_LIMIT, 1 GiB by default) bounds it instead of bodyLimit. A declared content-length over the limit is PAYLOAD_TOO_LARGE before a byte is read; a body that declares none, or lies, errors its stream with PAYLOAD_TOO_LARGE once more bytes than the limit have passed, and the request answers 413 whatever the handler made of that error. The limit counts what the handler reads: a handler that answers without reading the body (a refusal decided on its headers) answers at once, and nothing reads the rest on its behalf. The route decides, never the client: a body whose kind oRPC infers from its headers stays under bodyLimit, and so does every body on the RPC wire. A procedure whose route declares requestBodyHint or responseBodyHint "octet-stream" is not served on the RPC wire at all: orpc() leaves it unmatched, so /rpc answers 404 before its handler runs, and an RPC answer that still carries a ReadableStream has its cancellation started and answers 500 at once (details). Request decompression, when plugins adds it, is measured inflated under both limits. The limit is finite by default because the body is never buffered: what it bounds is how long one client may hold a connection and whatever the handler streams the bytes into, not memory.

compression is the response half. Request decompression is a separate oRPC plugin (RequestCompressionHandlerPlugin), left to plugins because inflating a body before the limit measures it is a decision an application should make in the open.

CSRF is an option, csrf, and not a plugins line. It was a plugins line while nothing here read a cookie; sessionAuthenticator does, so the deferral closed. The reason it could not stay a plugin is that a plugin only sees what RPCHandler handles: htmx() takes no plugins and runs no oRPC handler, so no oRPC plugin ever sees a fragment request, and the fragment half is exactly the half a form POST reaches. So the check lives on the raw listener, upstream of both answerers, and oRPC's GetMethodCsrfProtectionHandlerPlugin — which covers the preflight-free GET an event-iterator procedure admits, a surface the listener's method set deliberately leaves alone — is composed from the same flag rather than by hand. plugins stays what it always was: every oRPC plugin the named options do not cover.

plugins ​

readonly StandardHandlerPlugin<DefaultInitialContext>[], from @orpc/server/standard (oRPC removed adapter plugins in 2.0.0-beta.37, so every plugin is a standard one), appended to the three configured above and forwarded straight to new RPCHandler(service, { plugins }) — any oRPC plugin the named options do not cover, imported from @orpc/server/plugins: plugins: [new BatchHandlerPlugin()].

plugins is an honest escape hatch, not a keyhole. oRPC's StandardHandlerPlugin.init transforms handler options — including StandardHandlerOptions.interceptors — so a plugin can wrap execution, and an application determined to see a procedure's outcome can get there. Nothing pretends otherwise. What the option buys is that the ordinary path is configuration a reader can see at the composition root, and reaching past it is a visible act rather than the default shape; an application middleware acting on the handler's Result is still what Deliberately not included refuses. It threads through HttpModule and the internal oRPC options as a plain optional field.

Note what a plugin does not cover: it only runs for a request oRPC matched, so the runtime's own 404 and 500 never reach one. That is why securityHeaders is not a plugin.

securityHeaders ​

boolean | Readonly<Record<string, string>>, default true. Applied by the package on the raw node listener, before dispatch — the first statement of the request handler — so it covers a served response, the runtime's 404, its 500 and a drained response alike.

ValueEffect
true (default)x-content-type-options: nosniff, x-frame-options: DENY, referrer-policy: no-referrer
falsenothing is set
Record<string, string>replaces the defaults outright — the record is the whole set

The set is resolved once per listen, not per request. It is deliberately small: a default that has to be right for every deployment cannot include a CSP, an HSTS max-age or a permissions policy, all of which are a deployment's own decision — pass a record when you have made those.

A record replaces the defaults; it does not add to them. A record holding only a content-security-policy serves no nosniff, no x-frame-options and no referrer-policy. The defaults are not exported, so a record that keeps them names all three beside its own:

ts
import type { HttpOptions } from "@btravstack/http-server";

export const securityHeaders: HttpOptions["securityHeaders"] = {
  "x-content-type-options": "nosniff",
  "x-frame-options": "DENY",
  "referrer-policy": "no-referrer",
  "content-security-policy": "default-src 'self'; frame-ancestors 'none'",
};

csrf ​

boolean, and unset is not a default value but a question about the graph: it is on exactly when a composed scheme reads a cookie. Each cookie-reading scheme contributes to a set port as it is declared, so composing sessionAuthenticator turns the check on and a root of bearer schemes alone never pays for it. csrf: true forces it on, csrf: false off.

The check is stateless — no token, no hidden field, nothing to store — and runs on the raw listener beside securityHeaders, before any answerer:

RequestAnswer
GET, HEAD, OPTIONS — anything not state-changingserved by this check — but see the oRPC plugin below
a state change carrying no cookieserved: a bearer caller is not a CSRF target, and a page cannot forge one
a state change with Sec-Fetch-Site: same-origin or same-siteserved
a state change with Sec-Fetch-Site: cross-site or none403, no body
no fetch metadata, Origin host equal to the request's ownserved
no fetch metadata, Origin absent, unparseable, or another host403, no body

The last row is the one worth stating plainly: a state-changing request that presents cookies while saying nothing at all about where it came from is refused rather than waved through. Every browser a session cookie can reach sends Sec-Fetch-Site; a client that sends neither that nor an Origin is not a browser, and is holding a credential it did not have to name.

oRPC's GetMethodCsrfProtectionHandlerPlugin rides the same flag, so the oRPC and htmx answerers are protected by one decision rather than two. It is what qualifies the table's first row: the listener check serves every GET, and the plugin then refuses a cross-site one that reaches an RPC mount, because an RPC GET is a procedure call and a <script src> or an <img> can make one. A GET at an htmx route is served, as the table says.

What the fallback assumes about your ingress. With no fetch metadata the check compares the Origin host against request.headers.host — the Host the process was given. X-Forwarded-Host is deliberately not consulted: it is a header any client can write, so trusting it would hand the attacker the comparison. The assumption is therefore that your ingress passes Host through unchanged. An ingress that rewrites it to an internal service name will 403 a cookie-bearing state change from any client that sends no fetch metadata — every current browser sends Sec-Fetch-Site, so the blast radius is old clients and hand-written ones, which is exactly the kind of intermittent failure nobody attributes to a CSRF check. Configure the ingress to preserve Host, or turn the check off and put it at the edge.

htmx(options) ​

ts
const htmx: (
  options?: HtmxOptions,
) => Provider<HttpHandler, never, HtmxFragmentsPort | HttpConfig | HttpUnit> & {
  readonly port: typeof HttpHandler;
};

The second answerer: fragments, mounted under prefix (default /). It matches a request against the composed fragments' routes by method and path, resolves the principal through resolvePrincipal when the route carries a requirement, reads and validates a POST body against the route's own schema, forks the unit module of the kind that authenticated the request — anonymous for a route with no requires, and anonymous again for a scheme that binds no module of its own — once all of that has succeeded and immediately before the handler runs, and writes the handler's Html with content-type: text/html; charset=utf-8. A request no route claims resolves unwritten, exactly like oRPC's answerer, so the runtime's own 404 answers it — and never forks, since the fork is the answerer's, for a request it handles and is about to hand to its own route.

OptionRequiredDefaultWhat it is
prefixno/where fragments are mounted
loginno—the login route an unauthenticated caller is sent to; unset, that caller gets a bare 401 as before

Only bodyLimit, off the same HttpConfig orpc() reads, applies to this answerer — cors and compression are oRPC plugins with no fragment equivalent.

login — where an unauthenticated caller is sent ​

Pin login and a route whose requires resolves Unauthenticated sends the caller there instead of answering a bare 401, carrying where they were going:

The requestAnswerHeader
a browser navigating303Location: /auth/login?return=%2Fprivate
htmx's own (HX-Request: true)401HX-Redirect: /auth/login?return=%2Fprivate
under-scoped, whatever the sender403none

It is the login route, not the prefix its answerer is mounted under. An oidc()({ prefix: "/auth" }) serves GET /auth/login; /auth itself answers nothing, so login: "/auth" would send every logged-out caller to a 404.

The htmx row is not a cosmetic difference. htmx follows a redirect inside the XHR and swaps the login page into whatever target the fragment named, so a request htmx made has to be told to navigate the window — which is what HX-Redirect does. The status stays 401: the request was refused, and only the browser's navigation is a redirect. HX-Request is the discriminator because htmx sets it on every request it makes.

303, not 302. requires is an option on HtmxPost as well as HtmxGet, and the csrf gate refuses only a cross-site request — so a same-origin, no-JS <form method="post"> behind requires, from a logged-out browser, reaches this branch. RFC 9110 §15.4.3 makes a 302's POST-to-GET change a MAY, which lets a strict client re-POST the form body at the login route; §15.4.4's 303 specifies the retrieval request instead.

UnderScoped is never redirected. A caller who is logged in and lacks the scope would come straight back to the same 403; only Unauthenticated is a caller a login can help.

return is the request's own path and query: the raw request.url for origin-form targets, or the parsed pathname plus query for absolute-form targets, with the authority discarded. It is percent-encoded once with encodeURIComponent, and only when it starts with / and its second character is neither / nor \; anything else is reported as /. Those two clauses are one shared returnTo, the same function oidc() applies when it seals the value and again when it follows it, and the value is DECODED EXACTLY ONCE on the way through — a second decodeURIComponent would turn %255C back into \. That guard is not belt-and-braces: new URL("/\\evil.com", base) resolves to https://evil.com/ — the WHATWG parser reads \ as / in relative-slash state. The shared pathname parser rejects that raw target before an htmx fragment route can mint a return; returnTo also guards a value handed through another path. Whether a header can CARRY the result is a separate question and not the guard's: the mount goes through forLocation where it becomes a Location, which leaves an already-encoded % alone, because Node's header validator refuses every code point above U+00FF as well as every control character. What stays the consumer's is the rest of the open-redirect question, at the point the value is about to be followed.

WARNING

Routes are matched in the composition root's own array order, first match wins — and that ordering is a security property, not only a routing one. An unmarked route declared before a marked route whose path can also match the same request answers it, and no authentication ever runs: two routes are two port ids, minted from their own method and path, so di has nothing to see collide, and there is deliberately no specificity rule to fall back on.

The POST body decodes through Object.fromEntries(new URLSearchParams(...)), which keeps only the last value for a repeated key. A <select multiple> or a checkbox group both collapse to their last selection rather than an array — a mainstream htmx shape a reader should meet here, not discover in production.

The decoding also assumes application/x-www-form-urlencoded and never checks content-type. A JSON body still passes through new URLSearchParams(...), which reads the whole payload as one garbage key with an empty value — form-urlencoded only, the same stated limitation as the repeated-key one above rather than a validated content type.

Every 200 carries Cache-Control: no-store, unconditional. A public route can still render a caller- or resource-scoped fragment off a path parameter alone, and this package has no way to know a route's output is safe for a shared cache to keep — so there is no cheaper signal than "never store" to key the header on.

A route always answers 200 on success, and cannot set a header or a status of its own. HX-Redirect, HX-Trigger, HX-Retarget and HX-Reswap — htmx's own response mechanics — are unreachable, and a route cannot answer its own 404 or 422: "not found" is rendered markup (see Serve htmx fragments's orderRow recipe), never a status. A defensible scope decision, not an oversight.

A refusal — 401/403/413/422 — carries no body, unlike the runtime's own 404/500 fallback (see What it decides about a request), which carries application/json: a refusal owes the caller nothing beyond the status.

HttpConfig, and the environment ​

HttpConfig is { port, hostname, bodyLimit, streamBodyLimit, corsOrigin, compression, headersTimeoutMs, requestTimeoutMs }, bound through Config.provider from the Env port the kernel provides. Each option pins its field: explicit > environment > default, per field, so port: 0 still reads HOST — and still reads HTTP_BODY_LIMIT.

VariableDefaultParsed byNotes
PORT3000Config.port0 lets the OS pick; read the bound port back from RunningApp.runtimeInfo()
HOST0.0.0.0Config.stringthe deployment target is a pod; set 127.0.0.1 locally if the server must not be reachable off-host
HTTP_BODY_LIMIT1048576Config.integerbytes; 0 is unbounded. The one policy whose default is on — see above
HTTP_STREAM_BODY_LIMIT1073741824Config.integerbytes; 0 is unbounded. Bounds only a body a route reads as a stream — see streamed bodies
HTTP_CORS_ORIGINunset (off)Config.stringcomma-separated origins, or *; setting it is what turns CORS on
HTTP_COMPRESSIONfalseConfig.booleantrue/false, 1/0, yes/no, on/off
HTTP_HEADERS_TIMEOUT_MS60000Config.integerms, at least 1; a client still sending headers past it gets 408 and a closed socket. Node's own default, stated rather than inherited
HTTP_REQUEST_TIMEOUT_MS300000Config.integerms, at least 1; the whole request, body included, never the response — so an event stream is not cut. Node's own default. A headers bound above it is held to it, since the headers are part of the request

An unset variable takes the default; a set-but-empty one, PORT=abc and PORT=70000 are each a ConfigInvalid — a startFailed event and exit 78 under runMain. Anything in the graph may depend on HttpConfig.

jwtAuthenticator binds three more — HTTP_JWT_JWKS_URI, HTTP_JWT_ISSUER and HTTP_JWT_AUDIENCE, each required unless the matching option pins it — but not through HttpConfig: a scheme is not a provider, so it parses its own in its make arm, and a graph composing no JWT scheme reads none of them. protectedResource() binds HTTP_RESOURCE_URI and HTTP_RESOURCE_AUTHORIZATION_SERVERS the same way.

HttpHandler, and several answerers ​

HttpHandler is a set port: every protocol served in this process contributes one HttpAnswerer, and the runtime routes each request to the one whose prefix matches longest.

ts
type HttpAnswerer = {
  readonly prefix: `/${string}`;
  readonly handle: (
    request: IncomingMessage,
    response: ServerResponse,
    signal: AbortSignal,
    host: UnitHost<never>,
  ) => PromiseLike<unknown>;
};
RequestMounted answerersAnswers
/rpc/orders/ and /rpc/rpc — the longest match
/orders/42/ and /rpc/ — everything else
/graphql/graphql/graphql — the mount point itself
/rpcx/rpcthe runtime's 404 — a mount point is a path segment, not a string prefix
/orders/42/rpc and /graphqlthe runtime's 404 — no mount covers it

A graph holds exactly one runtime, so several protocols cannot be several runtimes; they are several answerers under one. Nesting is expected rather than refused, which is why there is no ordering to configure: only a duplicate mount point is an error, and it is a RuntimeStartFailed at listen rather than a coin toss. A trailing slash is the same mount, so /rpc and /rpc/ collide.

The runtime reads no body. bodyLimit is applied by the answerers that read one: oRPC, fragments, GraphQL and MCP. A bare answerer gets request as it arrives and writes response as it goes, so it streams a body of any size in bounded memory (the runtime's spec puts 256 MiB through each way) and owes its own ceiling. requestTimeoutMs still bounds the whole request, body included. Stream a file through the process is the pattern.

signal is the unit's own. The unit closes on the response's 'close', not on handle returning, so a client that disconnects settles it while handle may still be running — and the kernel aborts a unit's signal once it settles, so the handler is told to stop. An outbound call that takes the signal stops with it; an ingress whose upstream timeout closes the connection cancels work the same way.

graphql(api, { schema, prefix?, requires?, units?, unit?, cors?, developerTools?, maxTokens?, plugins? }) from @btravstack/graphql-server contributes a Yoga answerer. schema is any GraphQLSchema (the example builds one with Pothos); prefix defaults to /graphql. requires gates the whole mount through RequiresGate and the shared principal resolver. Resolver context carries principal, unit, incoming, and signal — the exported GraphqlContext<Principal, Unit>. plugins are Yoga plugins, each either inline (typed by this call's context) or typed by the context it reads, Plugin<{ unit: … }>: Yoga's Plugin<C> is invariant in C, so each is checked against the context the call builds, and one reading what the call never binds is refused against "PLUGIN CONTEXT MISMATCH — …". developerTools turns GraphiQL, introspection and an error's "Did you mean …" suggestions on together; it pins GRAPHQL_DEVELOPER_TOOLS, default false, so a root providing graphql() declares needs: [Env]. maxTokens pins GRAPHQL_MAX_TOKENS, default 1000, the most tokens graphql-js parses in one document; over it the parser refuses the document before any validation rule runs, and false turns the limit off. There is no alias or directive cap: each alias or directive costs tokens, so the token limit already bounds them, and a finer budget — aliases, directives, depth or cost — is a Yoga plugin such as graphql-armor's, passed through plugins. The response remains one HTTP unit through completion and drain. GraphQL, Yoga and @btravstack/core are required peers of that package.

fieldResult(result) answers a resolver from an AsyncResult<T, GraphQLError> (or a Result): Ok is the field's value; Err is reported on the field's own path — an alias, a list index — with its message and extensions, while sibling fields resolve, and its originalError stays server-side; a defect is masked to Yoga's Unexpected error. whatever its cause, a GraphQLError included, and reported to Observers as a graphql / defect operation carrying the cause and the field's path. It answers a Promise, the one async API in the stack that does: a resolver reports a field's error by rejecting, so this is where a Result leaves for GraphQL. The resolver turns its E into GraphQLErrors itself, with an exhaustive mapErrCases (or flatMapErrCases, where one refusal is Ok(null)), so widening E fails every resolver that folds it. graphql() enables no @defer / @stream plugin, so no incremental response can drop a field's error. A schema's Subscription is served over SSE, as Yoga does by default, and each event's resolve answers through fieldResult too, so a refusal rides the event it belongs to. GraphQL over WebSocket is not served. See Serve GraphQL for the code-first example, SDL artifact, and error boundary.

The runtime reads the members through Runtime.resolves rather than through di, because a member contributed by a sibling module is not visible from inside the starter's own. That is why HttpRuntime resolves HttpHandler and the composition root must export it — HttpModule adds it for you, and start's UNSATISFIED RUNTIME PORTS names it when a hand-written root forgets.

WARNING

An answerer outside an oRPC contract carries its own authentication. @btravstack/contract's marker is what says which scheme protects a procedure, and a GraphQL operation or an HTML fragment has no such statement — so its routes are public unless the answerer brings authentication itself, exactly as a procedure with no mark on it or above it is public, and with the same absence of a gate for "you forgot".

HttpRuntime and HttpInfo ​

HttpRuntime is declared over the kernel's RuntimePort with service Runtime<typeof HttpHandler, HttpInfo>: the runtime resolves HttpHandler, and reads its members off RuntimeHost.ctx — the one thing it reads there, and the reason a composition root must export that port. It resolved nothing while the oRPC answerer was the only one and its provider could depend on the router directly; a sibling module's answerer is not visible that way. Once listening it publishes HttpInfo, { port }, on Serving.info; with PORT=0 that is the only way to learn the port that was actually bound.

What it decides about a request ​

RequestAnswerDecided by
a procedure under prefixthe procedure's output, or the ORPCError its Result was mapped tooRPC, the router
a GET for a procedure whose output is an event iteratorthe stream, as text/event-stream — the one GET the RPC handler admitsthis package
a defect thrown inside a procedure, or in unitScope's own forkoRPC's own INTERNAL_SERVER_ERROR collapseoRPC
a protected procedure no requirement accepted the caller for401 UNAUTHORIZED, the handler never enteredthis package
a protected procedure whose caller lacked a required scope403 FORBIDDEN, the handler never enteredthis package
a protected procedure whose authenticator defectedoRPC's INTERNAL_SERVER_ERROR collapse — a bug, not a rejected calleroRPC
a path under prefix naming no procedure404 {"error":"NotFound"} — oRPC declines it unwrittenthis package
any path outside prefix404 {"error":"NotFound"} — likewisethis package
the listener resolved without writing404 {"error":"NotFound"}this package
the listener failed before headers were out500 {"error":"InternalError"}this package
a failure with headers already on the wirethe socket is destroyed — a reset, not a hangthis package

The last three are the package's own fallbacks, guaranteeing that every request produces exactly one completed response. The two 500 shapes are unreachable over the oRPC surface, which collapses every defect itself — unitScope's own fork failure included, since a throw out of it is a middleware throw exactly like a throw out of the procedure it wraps — and they exist because the transport is proven against a bare listener. "Failed" covers only a rejected promise or a synchronous throw out of a hand-written HttpAnswerer.handle, never a promise this package's own answerers hand back: htmx()'s own fork failure writes its 500 directly, through the same send every other htmx refusal uses.

Result → HTTP status is deliberately not in the table: it is the router's .result() triage, at the one place that decides what a client sees.

Before oRPC collapses a defect, orpc() and the OpenAPI routes report it to Observers as an orpc / defect operation carrying the cause and the procedure's path (orpc.path). The client still sees only INTERNAL_SERVER_ERROR. Any error oRPC would answer with a 5xx is reported, unless the procedure's contract declares it: a declared code is the caller's answer even where it falls back to 500. Observers comes from the HTTP starter, so the answerer adds no wiring to a root.

The unit ​

One unit per request, kind: "http". Its lifetime is the response's: the unit's work resolves on the response's 'close' event (or at once if that already fired before the work ran), so there is no seam for a late write to land in.

UnitMeta fieldValue
idrandomUUID(), minted per request — never the route, which would give every request one trace id
traceIdthe trace id of an inbound traceparent; else the inbound x-request-id header when it matches /^[\w.-]{1,128}$/; otherwise absent, so it defaults to id. A malformed or all-zero traceparent falls through to x-request-id

An x-request-id that is not that bounded token — blank, a kilobyte of it, a newline — is ignored rather than adopted. The value lands on every log line and every span, so what a caller may put there is the same kind of question the traceparent parser already answers; and a blank one would win over the minted id outright, since "" is not nullish.

unit, and who forks it ​

ts
readonly unit?: Readonly<Record<string, AnyUnitModule>>;

HttpModule and GraphqlModule take a unit option: a record of kind → module, where a kind is anonymous — a request no leaf asked to authenticate — or the scheme that resolved the caller. The answerers fork the module of the kind a request opened under. Built as the answerer takes the request, torn down when the unit closes, after the response is flushed — every bound module's own unmet needs join the composition root's, exactly as any other needs does, since it is forked over the application context.

AnyUnitModule is Module<never, never, unknown> — @btravstack/core's, not re-exported from this package. never, not unknown, in the first position: Module's _exports channel is contravariant, so Module<unknown, …> is a bound no real module can ever satisfy, and unknown in the third (Needs) position is what lets a module with real needs infer against the bound at all — the same shape @btravstack/testing's TestRuntimeOptions.unit uses.

orpc() installs the fork as a middleware — unitScope — on every leaf, after the principal middleware where a leaf carries one, which is how the resolved scheme reaches it — principalMiddleware refuses without calling next(), so unitScope, nested inside it, never runs for a refused caller. htmx() forks at the same point in the request's life: after authentication succeeds and the body validates, immediately before the handler — never for a request either answerer refuses. The runtime's own 404 never forks: only an answerer that claims a request does, which is the behaviour change from the kernel forking a StartOptions.unit module around every unit — that option is gone, and the fork is now each answerer's own.

A scheme that binds no module of its own falls back to anonymous, and nothing is forked only when neither binds one. A scheme's module is how one kind is specialised, not how the others are switched off: binding { anonymous } alone keeps forking on every leaf, exactly as it did before kinds existed. The alternative — an unbound kind forks nothing — would make every existing unit: { anonymous } application silently lose its request scope on precisely its authenticated procedures, with no diagnostic anywhere.

The fork is seeded with the principal, on auth.principals[scheme], whenever a scheme resolved — whichever module ends up forked, so the anonymous fallback carries the seed too and an unread entry is the whole cost. A request no leaf authenticated has no principal seed, since there is no caller to name; its unit still receives HttpRequestMetadata. So only a scheme's own module is discharged of that scheme's principal: one the anonymous module, or another scheme's, names in needs is owed by the composition root like any other port, and start refuses a root that provides none.

The kinds, and what a kind binds ​

A unit module may inject the caller its unit was opened for, so defineHttp mints one principal port per declared scheme, and a second call binds a module per kind:

ts
export const auth = defineHttp({ authenticators: { user: userAuth } });

export class Tenant extends Port("Tenant")<TenantId> {}

// `auth.principals.user` carries `userAuth`'s own principal type.
export const User = Module("User")({
  provides: [
    Provider(Tenant)({
      inject: { principal: auth.principals.user },
      sync: ({ principal }) => principal.tenantId,
    }),
  ],
  exports: [Tenant],
});

export const api = auth.units<{ anonymous: typeof Anonymous; user: typeof User }>();

principals is a port per scheme, typed by the principal that scheme's authenticator declared. It is a port, not a value: a unit module's provider injects it, and the seed lands on it once per unit — so a module reading it owes the composition root nothing for it, while everything else it needs still surfaces at start's UNSATISFIED DEPENDENCIES.

units<…>() is a second call, and that is the whole design. A unit module names auth.principals.<scheme>, so its type depends on typeof auth; if auth in turn depended on the modules the kinds bind, the two would be mutually recursive and TypeScript reports TS7022 — auth implicitly any because it references itself. Splitting the call breaks the loop: typeof auth depends on the authenticators alone, and units hands back the same object under a narrower type. Nothing is rebuilt, and the factories on api are the ones defineHttp already built.

A key the authenticators never declared is refused at units<…>() itself, against the authenticator registry — the constraint requires never for every key outside "anonymous" | keyof A, which no real module satisfies, and the diagnostic names that key. With units<…>() never called, Units is the empty record and everything below degrades to the pre-kinds behaviour.

Non-authentication request metadata ​

HttpRequestMetadata, exported by @btravstack/http-server, is a port seeded into every request unit. Its value contains immutable headers, the incoming method and url (each possibly undefined, as on Node's request), and the runtime cancellation signal. Header arrays are copied and frozen too. A unit provider injects it to decode an application value such as a locale; no authentication scheme or ambient application store is needed. Its need is discharged for every unit kind, including anonymous, but never at application scope. Values remain untrusted input and grant no authorization.

RPC and OpenAPI controller handlers also receive typed context.request (Node's incoming request), context.host and context.signal, beside their principal and declared unit services. The context signal and metadata signal are the same runtime signal; a disconnected client aborts both.

What a leaf reads off the fork ​

A piece — or a fragment route — declares the unit-scoped ports its leaves may read once, beside inject, and every leaf reads them off context.unit. inject is optional, absent meaning {}: a piece whose every dependency comes off the fork — the usual case in a multi-tenant application — declares unit alone:

ts
api.OrpcController(contract, "orders")({
  unit: { place: PlaceOrder },
  sync: () => ({
    place: ({ context, input }) =>
      context.unit.place.execute(input.id, input.quantity),
  }),
});

The declared record is filtered per leaf by the kind that leaf's own requirements select — anonymous for a leaf nothing marks, else the schemes its mark names, read off requires for a fragment route. A name the kind's module does not export is not a property at all, so reading it is TypeScript's own Property 'tenant' does not exist, at the line that reads it rather than at the mint. A leaf accepting several schemes keeps only what every one of their modules exports, because the runtime forks exactly one of them and cannot know which in advance; the anonymous fallback is applied per scheme here too, so the type side and the runtime cannot part.

Entries are lazy getters over the forked context, neither writable nor configurable: a name UnitFor hid costs nothing, where eager resolution would defect on a port no leaf of that kind could have named.

The port a piece is minted on keeps a unit-free service type, which is what lets two pieces declaring different records compose under one contract and keeps the lifted-fragment property intact. The cost is that a lifted single-slice root injects that port, so a piece that declared unit: receives {} once lifted — a lifted root must restate unit: on the router arm.

The gate on the kinds a root binds ​

The anonymous fallback makes a typo silent: unit: { usre: M } would fork anonymous on every request and diagnose nothing. So HttpModule gates what a root binds, in two cases:

The answerersBindable kindsRequired kindsEach value must be
come from auth.units<…>()exactly the kinds that call declaredthe declared kinds a request here forksthe module type that kind declared
come from a plain defineHttp()anonymous plus every scheme the answerers servenoneany unit module

An undeclared kind is refused against an "UNDECLARED UNIT KIND — no request opens under it, so it would silently fall back to anonymous" marker, rather than by excess-property checking — which cannot see one, since the record's type is inferred from the value. A declared kind bound to the wrong module is ordinary assignability, and the diagnostic naming the kind and both modules is better than any marker.

A declared kind the root serves must be bound. Its leaves are typed by the module units<…>() named for it, so leaving it out — or omitting unit altogether — would fork anonymous's module, or nothing, under a leaf that reads a port only the declared one provides: a 500 on the first request. A missing kind is TypeScript's own Property 'user' is missing; a missing record is refused against "UNBOUND UNIT KINDS — units<…>() declared them, so bind each on unit", naming the kinds. A declared kind no request here forks under stays optional, so a lifted slice binds what it serves and no more: session under a router whose contract marks only user and service, and anonymous under one whose every leaf is marked. anonymous is required when a leaf is public, or when a served scheme declared no module of its own and so falls back to it.

The second case's set comes from the answerers' own needs channel — a router already owes one authenticator port per scheme its contract marks, and a fragments provider one per scheme its routes require — so no second phantom is needed. The first case reads the kinds off whichever answerer carries the units<…>() phantom, and the router and the fragments both carry it: a fragments-only root under a kinded api is gated against the declared modules exactly as a router-only one is. Supplying both answerers is an intersection of the two declarations — one api declares them once, and a router and fragments taken from two different apis must satisfy both. Two declarations naming different kinds cannot be satisfied by one record, and are refused against "DIVERGENT UNIT KINDS — the router and the fragments come from units<…>() calls declaring different kinds, so mint both from one".

The drain ​

Serving.drain(signal) marks the server draining, retires every open response — a queued text/event-stream response is destroyed on the spot, even before its headers flush. Other unsent headers get Connection: close, and any other sent response ends its socket on 'finish' — then calls server.close() and closeIdleConnections(). closeIdleConnections() alone reaches only connections idle at that instant, and node would keep serving keep-alive requests down a busy one for the whole drain window; retirement per response is what actually stops accepting. A stream is reset rather than ended cleanly because oRPC's client reads a clean end as the iterator finishing and never reconnects; on a reset both it and a browser's EventSource reconnect, carrying Last-Event-ID. The stream's unit closes on the response and is counted completed, so abandoned keeps meaning work that ignored the deadline. The check reads queued headers, through response.getHeader("content-type"), so an answerer that streams must set that header with response.setHeader(...) and not through writeHead alone: a response whose content type node has never queued is invisible here, and is retired as an ordinary sent response instead of reset. The deadline signal is noted and not otherwise used: closing a listener is instantaneous, so there is nothing to escalate to. Serving.stop() destroys whatever sockets are still open and resolves once the server has closed. Why the stream is ended here and not held is Draining, in three beats.

Startup failures ​

A bind failure (EADDRINUSE, and the synchronous ERR_SOCKET_BAD_PORT node throws for a port outside 0..65535) is Err(RuntimeStartFailed({ runtime: "http", cause })) — exit 1 under runMain. Once serving, the server keeps a permanent no-op 'error' listener so a transient accept fault cannot become an uncaughtException teardown.

Peer dependencies ​

@btravstack/core, @btravstack/config, @btravstack/di, @btravstack/contract, unthrown, @orpc/server, @orpc/contract, @unthrown/orpc. All peers, so an application holds one copy of each — @btravstack/contract most of all, since its marker is a unique symbol and two copies are two different symbols, so a contract marked against one would read as unmarked here. Node >=22.12.

Optional peers, each behind the subpath that needs it: jose (^6) for both /jwt and /session, openid-client (^6) for /oidc, @orpc/openapi and @orpc/json-schema for /openapi. A graph that imports none of those subpaths installs none of them — which is the whole reason they are subpaths.

Deliberately not included ​

  • Another router in oRPC's answerer. oRPC through @orpc/server/node's RPCHandler is how this package answers HTTP, and there is no handler option to swap it. A second protocol is a second answerer on the HttpHandler set port under the same runtime.
  • A middleware slot for application logic. oRPC's own, inside the router's procedures. principalMiddleware is the one per-request hook the package installs, only on a leaf whose requirements say so. plugins is an honest escape hatch rather than a keyhole — a plugin can reach the handler's interceptors — but the ordinary path is configuration visible at the composition root (cors, bodyLimit, compression, securityHeaders), and an application middleware acting on the handler's Result is what this package refuses.
  • Rate limiting, and it is a stated non-goal rather than a gap. A per-process counter is the wrong unit: an api deployment is N pods (one process, one runtime), so a per-process budget is N independent budgets and none of them is the limit anybody meant. Where a request is counted once is the ingress or the gateway, which is also where it can be refused before it costs a process anything. An application that wants one anyway writes an oRPC plugin and passes it through plugins, or a Yoga plugin through graphql()'s plugins — the escape hatch doing its job.
  • Result → HTTP status. The router's .result() triage owns it.
  • Resource-dependent authorization. A scope is checked here, because it is a property of the credential and answerable before dispatch. "Is this caller the order's owner?" is not, and stays in the handler.
  • AND within one requirement. A requirement names one scheme; requiring two credentials at once would put a record rather than an identity on the handler. A composite scheme models it where it is genuinely needed.
  • OpenAPI document metadata. A scheme's own definition — type: http, bearerFormat, an OAuth flow — belongs beside the contract, not in defineHttp.
  • Static files and an SPA fallback, a stated non-goal. The ingress or a CDN serves assets — the deployment model already puts an ingress in front of the pod, and one that routes the asset path to a bucket serves immutable files with caching, compression and range requests better than a Node process will. With htmx fragments there is no SPA to fall back to: the asset set is htmx and a stylesheet, which a CDN serves and a layout can vendor. The CSP for an HTML response is the deployment's: securityHeaders sets none by default, because htmx's hx-* attributes and any inline <script> meet a policy at once — pass a record carrying your own policy and the three defaults, since a record replaces them (the record shows one).
  • HTTPS, HTTP/2. node:http only; terminate TLS at the ingress.

openApiDocument() — from @btravstack/orpc-server/openapi ​

ts
const document = (
  await openApiDocument(contract, {
    base: { info: { title: "Order API", version: "1.0.0" } },
    securitySchemes: { user: { type: "http", scheme: "bearer" } },
  })
).get();

It returns AsyncResult<OpenApiDocument, never> — async, and cannot fail — so .get() is the extraction; a generator fault arrives as a defect, never a raw rejection. base takes the generator's own base-document type and securitySchemes is OpenApiSecuritySchemes (the document's own components.securitySchemes shape), so a key the generator would ignore is a type error rather than silently inert.

The contract as an OpenAPI document, with @btravstack/contract's marker folded into each operation's security.

A fold, not a translation. Requirement is Readonly<Record<string, readonly string[]>> and Requirements an array of them — byte-identical to OpenAPI's SecurityRequirementObject[], keys within one object AND, separate objects OR. The emitted security is the marker's own value; nothing is reinterpreted, which is why the OR rule the contract already enforces survives into the document intact.

A document from this stack therefore carries OR and never AND — not a limitation of the generator but of what a contract can say: @btravstack/contract refuses the multi-key requirement OpenAPI reads as AND, because this package would run it as OR.

Operations are matched by operationId, computed the way the generator computes it. The walk reads @orpc/openapi's own meta and falls back to the router segments joined by ., so a procedure that named an operationId of its own — through oc.meta(openapi({ operationId })), which is what a generated client's method is named after — keeps its security. It used to key on the contract path alone, and every such operation published with no security at all, which a reader of the document takes as public.

A procedure that replaces the whole operation with an openapi({ spec })owns its security too: an object spec IS the operation, so there is nothing for the fold to add to, and a function spec is handed the built operation and may drop what the fold wrote.

securitySchemes is yours to supply. The contract says WHICH schemes protect a route and deliberately never says what a scheme IS — the same split defineHttp({ authenticators }) makes, one layer out: the authenticators say what user resolves to for the server, this says what it looks like to a client. A scheme the contract names with no definition still appears in security, as a visible unresolvable reference rather than a silently dropped requirement.

@orpc/openapi and @orpc/json-schema are required peers: HttpModule's openapi option serves the routes from the package's main entry. The document generator stays behind the subpath, so the main entry never loads @orpc/json-schema.

Document publication is optional ​

This package mounts no documentation route by default and ships no UI asset. openApiRoutes() serves the contract's ordinary HTTP routes, but does not publish its document. An application can write the document to a build artifact or publish it through openApiDocumentRoute({ path, document }), or pass it to oRPC's optional reference plugin. Use this package's openApiDocument() as that plugin's spec source so the security marker fold is retained. examples/order-api/src/openapi.ts shows the document options, and the same document is the route list: List what a process serves.

openApiDocumentRoute() — from @btravstack/orpc-server/openapi ​

ts
import { openApiDocumentRoute, type OpenApiDocument } from "@btravstack/orpc-server/openapi";

declare const document: OpenApiDocument;

const documentRoute = openApiDocumentRoute({ path: "/v1/openapi.json", document });

Put documentRoute in the HTTP root's provides. It publishes the supplied document publicly, serialized once at boot. GET returns JSON and HEAD returns the same headers without a body. Only the exact path matches (query parameters are allowed); descendants and a trailing slash answer 404. Other methods answer 405 with Allow: GET, HEAD. No document route is automatic.

openApiRoutes() — from @btravstack/orpc-server/openapi ​

openApiRoutes({ prefix?, cors?, compression?, plugins? }) contributes one HttpHandler member under /api by default. It uses oRPC's OpenAPI codec and Node adapter over the same OrpcRouterPort as orpc(), so a contract can serve TypeScript RPC clients at /rpc and generic HTTP clients at /api in one process. HttpModule's openapi option composes it — openapi: true, or openapi: { prefix, cors, compression, plugins }. No second runtime or router implementation is needed. It is opt-in, and the RPC-only composition remains unchanged.

Methods and paths come from the contract's oRPC OpenAPI metadata. With no metadata, oRPC defaults to POST at the procedure path; the generator does not invent GET /resources/{id}. The output of openApiDocument() describes these OpenAPI routes, so the server must mount openApiRoutes() for that document to be a callable wire contract. The answerer shares orpc()'s policy builder and reads the same deployed HTTP_CORS_ORIGIN, HTTP_BODY_LIMIT, and HTTP_COMPRESSION values. Through the openapi option, HttpModule's cors and compression reach it too, unless the option's record pins its own, field by field; plugins are per answerer, and the module's stay with RPC. Set bodyLimit and csrf on HttpModule for both answerers. The HTTP runtime still owns cookie-based CSRF checks, the request unit, security headers, and drain. See Serve an oRPC contract as OpenAPI routes.

Custom error statuses ​

Declare status beside a named error's data or message in the procedure's .errors(...) map. The OpenAPI answerer and openApiDocument() read that declaration from each procedure; no application-wide status map is needed.

ts
import { oc } from "@orpc/contract";
import { openapi } from "@orpc/openapi";
import { z } from "zod";

const itemsContract = {
  find: oc
    .meta(openapi({ method: "GET", path: "/items/{id}" }))
    .input(z.object({ id: z.string() }))
    .output(z.object({ id: z.string() }))
    .errors({ ITEM_MISSING: { status: 404, data: z.object({ id: z.string() }) } }),
};

Returning ErrAsync(errors.ITEM_MISSING({ data: { id } })) from the handler answers HTTP 404 with the named code and typed data. Other procedures may give the same code a different status. Entries without an integer status in 400–599 keep oRPC's standard code mapping, or 500 for an unknown code. Only errors oRPC recognizes as declared use the local status: input validation, access failures and unexpected defects retain their normal responses. Defects stay masked and reported to Observers; declared refusals are not defects, including a refusal deliberately declared as 503.

This declaration applies to OpenAPI routes and their document. The /rpc answerer retains oRPC's standard status mapping.

Released under the MIT License.