@btravstack/http-server
Reference. A complete, structured description of the HTTP starter's public surface: every export of
@btravstack/http-server, its options and their defaults, and what the package decides about a request. 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
packages/http-server/src/index.ts exports exactly this:
| Export | Kind | What it is |
|---|---|---|
defineHttp | value | defineHttp({ 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 |
Http | type | Http<A, Units> — what defineHttp returns, held as one binding and never destructured; Units is the record auth.units<…>() binds, empty until that second call |
Authenticators | type | Readonly<Record<string, Authenticator<…>>> — the registry defineHttp takes, keyed by scheme name |
SchemesFrom | type | SchemesFrom<A> — the scheme-name → identity map read off the authenticators, so it is never declared twice |
HttpModule | value | HttpModule(name)({ router, fragments?, fragmentsPrefix?, fragmentsLogin?, 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 |
HttpModuleOptions | type | The options object HttpModule(name) takes |
HttpAuthenticator | value | HttpAuthenticator<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 |
Authenticator | type | Authenticator<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 |
granted | value | granted(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 |
Granted | type | Granted<P, Scope> — the identity bare when the scheme has no scope vocabulary, a Grant<P, Scope> when it has one |
Grant | type | Grant<P, Scope> — the branded { identity, scopes } granted() returns; unforgeable from outside the package |
AuthenticatorService | type | (headers: IncomingHttpHeaders) => AsyncResult<Granted<P, Scope>, Unauthenticated> — headers in, credential out |
authenticatorPort | value | authenticatorPort(scheme) — the di port whose id is `HttpAuthenticator:${scheme}`; a router declares one per scheme its contract names |
principalPort | value | principalPort(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 |
Principals | type | Principals<A> — the scheme → principal-port map, what auth.principals is |
Kinds | type | Kinds<A> — "anonymous" plus every declared scheme: every kind a unit may be opened under |
UnitsOf | type | UnitsOf<A> — Partial<Record<Kinds<A>, Module>>, the record auth.units<…>() takes |
Unauthenticated | value | a TaggedError with an empty payload — the refusal itself; the starter surfaces no reason to the client |
UnderScoped | value | TaggedError("UnderScoped") — a valid credential missing a declared scope, answered 403 |
resolvePrincipal | value | the protocol-neutral authentication walk, shared by every answerer |
Principal | type | Principal<S, Schemes> — what a leaf's handler reads: bare for one scheme, a tagged union for several, never for none |
SchemesOf | type | SchemesOf<R> — the union of scheme names a Requirements tuple mentions |
apiKeyAuthenticator | value | apiKeyAuthenticator<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 |
http | value | http({ prefix?, port?, hostname?, cors?, bodyLimit?, compression?, plugins?, securityHeaders?, csrf?, unit? }) — the starter module itself, needing the router port; what HttpModule imports |
httpServer | value | httpServer(options?) — the socket half: runtime, config, HttpUnit, and the empty answerer set. http() is this plus oRPC |
HttpOptions | type | http()'s options |
HttpRuntime | value | class HttpRuntime extends RuntimePort<Runtime<typeof HttpHandler, HttpInfo>> {} — the runtime's port; what http() provides and the module start boots must export. It resolves HttpHandler, so the root must export that too |
HttpHandler | value | class HttpHandler extends Port.many("HttpHandler")<HttpAnswerer> {} — the set port every protocol served in this process contributes one member to |
HttpAnswerer | type | one protocol's answer to HTTP — a mount prefix and the handle the runtime routes to; see several answerers |
HttpConfig | value | class HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string; bodyLimit: number; corsOrigin: string; compression: boolean }> {} — what the transport is bound and configured with, provided by http() from PORT / HOST / HTTP_BODY_LIMIT / HTTP_CORS_ORIGIN / HTTP_COMPRESSION |
HttpInfo | type | { readonly port: number } — what the runtime publishes on Serving.info once listening, read back through RunningApp.runtimeInfo() |
html | value | html`<tr>${value}</tr>` — a tagged template returning Html, escaping every interpolation by default |
raw | value | raw(markup) — the one way past html's escaping, a visible act at the call site |
Html | type | { readonly [HTML]: true; readonly value: string } — the output of html/raw, and nothing else |
ParamsOf | type | ParamsOf<Path> — the :name segments a path template names, e.g. ParamsOf<"/orders/:id/row"> is { readonly id: string } |
HtmxFragmentsPort | value | class HtmxFragmentsPort extends Port("HtmxFragments")<{ routes; authenticators }> {} — every route composed into one port; what htmx() answers from |
FragmentAnswer | type | what the composed port carries for one route — its declared unit record, and a handle taking the whole { principal, unit } context, both erased to unknown |
htmx | value | htmx({ prefix?, login? }) — the second answerer, one HttpHandler member serving fragments, mounted under prefix (default /), sending an unauthenticated caller to login when one is pinned |
HtmxOptions | type | htmx()'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, each behind an optional peer so a graph that never imports it installs nothing: @btravstack/http-server/jwt (jwtAuthenticator, DEFAULT_ALGORITHMS, and the Claims / JwtOptions 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/http-server/openapi (openApiDocument — @orpc/openapi). 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 appends httpServer(options) — the whole options record, csrf and unit included — to imports; when router is given it prepends router and the scheme authenticators it carries, plus orpc({ prefix, plugins, … }), to provides; when fragments is given it prepends fragments and its own authenticators, plus htmx({ prefix: fragmentsPrefix, login: fragmentsLogin }). A scheme both provide is deduplicated by reference before it reaches provides. It prepends HttpRuntime and HttpHandler to exports, 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.
| Option | Required | Default | What it is |
|---|---|---|---|
router | no* | — | 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 |
fragments | no* | — | the application's fragments provider — what api.HtmxFragments([...]) returns over an array of HtmxGet/HtmxPost pieces; likewise typed to its own port |
prefix | no | /rpc | where the RPC endpoint is mounted; typed `/${string}` |
fragmentsPrefix | no | / | where htmx fragments are mounted — htmx()'s own default, a separate field because one cannot carry two mount points with two different defaults |
fragmentsLogin | no | — | htmx()'s login — the login route an unauthenticated fragment caller is sent to; fragment-only, like fragmentsPrefix |
port | no | read from PORT | pins the port instead of reading it |
hostname | no | read from HOST | pins the host instead of reading it |
cors | no | read from HTTP_CORS_ORIGIN | pins the CORS policy — true for oRPC's defaults, or its options record; applies only when router is served |
bodyLimit | no | read from HTTP_BODY_LIMIT | pins the largest request body a procedure or a fragment POST reads, in bytes; false is unbounded |
compression | no | read from HTTP_COMPRESSION | pins response compression — true for oRPC's defaults, or its options record; applies only when router is served |
plugins | no | [] | any other oRPC handler plugin, forwarded to RPCHandler |
securityHeaders | no | true | response headers set on the raw listener, before dispatch — covers both answerers |
csrf | no | on when a scheme reads a cookie | refuses a cross-site state change carrying cookies, before dispatch. See csrf |
unit | no | none | kind → 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 |
imports | no | [] | the application's modules |
provides | no | [] | the application's own providers |
exports | no | [] | the application's own exports; HttpRuntime and HttpHandler are added |
* at least one of router/fragments is required.
The worked composition root, from examples/order-api/src/module.ts:
export const OrderApi = HttpModule("OrderApi")({
router: orderRouter,
fragments: orderFragments,
unit: { anonymous: RequestModule },
imports: [
OrdersSlice,
CustomersSlice,
cache({ adapter: redisCache() }),
observability(),
otel(),
],
provides: [sessionCodec()],
exports: [Logger, Tracer, Meter],
});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:
| Instrument | Kind | Dimensions |
|---|---|---|
btravstack.http.requests | counter | method, answerer, status |
btravstack.http.duration | histogram (ms) | the same three |
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.
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 /jwt and /session subpaths need Node ≥22.12 under CommonJS. jose is ESM-only, so the CJS build's require("jose") depends on require(esm), which Node enables by default from 22.12. ESM consumers are unaffected on any Node 22, and so is every consumer that never imports either subpath — which is why this is stated here rather than paid for by raising the package's own engines floor, a breaking change for the many to serve the few.
principal(claims) is yours — no standard claim carries a tenant — and answering undefined refuses the token. 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:
| Option | Required | Default | What it is |
|---|---|---|---|
principal | yes | — | (claims) => P | undefined; undefined refuses the token |
scopes | no | none (the scheme is unscoped) | the vocabulary, and the scheme's scope type; omit it entirely for a scheme with no scopes |
jwks | no | read from HTTP_JWT_JWKS_URI | pins the issuer's JWKS endpoint instead of reading it |
issuer | no | read from HTTP_JWT_ISSUER | pins the required iss |
audience | no | read from HTTP_JWT_AUDIENCE | pins the required aud |
algorithms | no | DEFAULT_ALGORITHMS | the accepted signature algorithms; asymmetric only, and none is not expressible |
clockToleranceSec | no | 0 | leeway on exp and nbf, in seconds |
header | no | authorization | which header carries the token, as Bearer <token> |
jwks, issuer and audience are pins, the same rule http({ 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. 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.
Those three variable names belong to the process, so one JWT scheme reads them. A second jwtAuthenticator in the same graph — a partner issuer beside the first — pins its own jwks, issuer and audience at the call; two schemes both reading the environment would both get the first issuer's.
The session cookie
@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.
| Option | Required | Default | What it is |
|---|---|---|---|
keys | no | read from HTTP_SESSION_KEYS | 32-byte base64url keys; the first seals, every one unseals |
ttlSec | no | 43_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 prepend, deploy, drop: the first key seals and every key unseals, so 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. There is no iss or aud in the sealed payload, so two deployments handed the same HTTP_SESSION_KEYS 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 prepend, deploy, 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:
void codec.transient.seal({ verifier: "v", state: "s", nonce: "n", returnTo: "/orders" });
void codec.transient.unseal(cookie); // the state back, or nothingIt 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? }) 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.
| Option | Required | Default | What it is |
|---|---|---|---|
scopes | no | none (the scheme is unscoped) | the vocabulary; the grant is its intersection with the session's own scopes |
principal | no | session.principal | what the session makes the caller; undefined refuses it, and the default refuses a session sealed with none |
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).
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,
}),
],
});| Option | Required | Default | What it is |
|---|---|---|---|
principal | yes | — | what the ID token's claims make the caller; undefined refuses the login |
issuer | no | read from HTTP_OIDC_ISSUER | the provider, as its discovery document names itself |
clientId | no | read from HTTP_OIDC_CLIENT_ID | this deployment's client |
clientSecret | no | read from HTTP_OIDC_CLIENT_SECRET | its secret — this is a confidential client |
redirectUri | no | read from HTTP_OIDC_REDIRECT_URI | the URI registered with the provider |
prefix | no | /auth | where the three routes are mounted |
scope | no | openid | what the authorization request asks for |
postLogout | no | / | where a logout lands when the provider advertises no end-session endpoint |
allowInsecureIssuer | no | false | talk to an http: issuer that is not on a loopback host |
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. It is a POST because it is a state change, and the CSRF check a composed session scheme turns on applies to it like any other cookie-bearing one.
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.
Logout sends no id_token_hint. The cookie holds a principal and no token, so there is none to send — which also means no post_logout_redirect_uri, since a provider is entitled to refuse that parameter without a hint (Ory Hydra does). Configure the provider's own post-logout URI instead.
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() is a single member provider 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.
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:
export const ordersRouter = api.OrpcRouter(contract.orders)({
inject: {},
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 only modeled failure is that cursor, the one field
// that came from outside.
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 },
}),
),
),
// `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 }:
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:
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>' 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. See 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:
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. See Split a router into controllers for the worked recipe.
api.OrpcController(contract, path)
const OrpcController: <
const C extends Record<string, RouterContract>,
const K extends ControllerKeyOf<C>,
>(
contract: C,
path: K,
) => <
const D extends Readonly<Record<string, AnyPort>>,
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>;
}) => Provider<
PortInstance<
`OrpcController:${K}`,
Implementation<FragmentAt<C, K>, Schemes>
>,
never,
InstanceType<D[keyof D]>
> & {
readonly port: PortClassOf<
`OrpcController:${K}`,
Implementation<FragmentAt<C, K>, Schemes>
>;
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 only: 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:
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. There is no name to give: the path is the port's name, minted as `OrpcController:${path}` — the same move AmqpHandler(contract, key) makes — and carried back on provider.port, the shape Config.provider("RelayConfig")(schema) already uses, so a slice's module exports controller.port rather than naming a port of its own:
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:
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 name | context.principal |
|---|---|
| one scheme | that 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:
({ context }) => {
switch (context.principal.scheme) {
case "user":
return context.principal.identity.userId;
case "service":
return context.principal.identity.appId;
}
};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:
| Arm | Shape | When |
|---|---|---|
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>.
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,
) => AsyncResult<Granted<P, Scope>, Unauthenticated>;Headers, not the request: an authenticator has no business reading a body, and the narrower argument is what keeps it testable without a socket. 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.
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. An authenticator that wants to record why logs it before returning. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default.
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
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:
Type '"HttpAuthenticator:user"' is not assignable to type '"@di/Scope"'(Not di's UNSATISFIED DEPENDENCIES dependency gate: that one guards Module.build/Module.scoped, and start types the need out on its parameter instead.)
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.
| Outcome | Answer |
|---|---|
| a requirement is satisfied | the handler runs, principal injected |
| no requirement accepted the caller | 401 UNAUTHORIZED |
| a credential was valid but lacked a scope the requirement named | 403 FORBIDDEN |
an authenticator returned a Defect | oRPC'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 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
An unmarked procedure 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.
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?)
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:
import { FindOrder } from "@btravstack/example-order-application";
import { html } from "@btravstack/http-server";
import { P } from "unthrown";
export const orderRowFragment = api.HtmxGet("/orders/:id/row", {
requires: [{ user: [] }],
})({
inject: {},
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:
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.
http(options)
const http: <Units extends Readonly<Record<string, AnyUnitModule>> | undefined = undefined>(
options?: Omit<HttpOptions, "unit"> & { readonly unit?: Units },
) => Module<
HttpRuntime | HttpConfig | HttpHandler | Observers,
ConfigInvalid,
Env | OrpcRouterPort | UnitsNeedsOf<Units>
>;The primitive HttpModule delegates to, for a composition root written by hand. HttpOptions:
| Option | Required | Default | What it is |
|---|---|---|---|
prefix | no | /rpc | where the RPC endpoint is mounted |
port | no | read from PORT | pins the port |
hostname | no | read from HOST | pins the host |
cors | no | HTTP_CORS_ORIGIN | boolean | CORSHandlerPluginOptions, oRPC's CORS plugin |
bodyLimit | no | HTTP_BODY_LIMIT | number | false, the largest body a procedure reads, in bytes |
compression | no | HTTP_COMPRESSION | boolean | ResponseCompressionHandlerPluginOptions |
plugins | no | [] | NodeHttpHandlerPlugin[], forwarded to oRPC's own RPCHandler |
securityHeaders | no | true | boolean | Record<string, string>, applied on the listener |
csrf | no | computed | boolean; unset is on exactly when a composed scheme reads a cookie |
unit | no | none | kind → module, un-gated here — see The unit |
The module provides HttpRuntime, HttpConfig and HttpUnit, exports HttpRuntime, HttpConfig, HttpHandler and Observers, and needs Env (the kernel discharges it), the starter's router port (OrpcRouterPort, the port api.OrpcRouter(contract)({ inject, unit?, sync }) provides on) and, for every kind unit binds, that module's own unmet needs — Scope excluded, since nothing can provide it, and the scheme's own principal port excluded too, since the fork seeds it — the runtime provider depends on the router through di, which is why a composition that imports http() without providing the router carries an unmet need start refuses (di's gate, not the kernel's). The router is not an option: there is no other port it could be on. The declared type is the same whether or not a field is pinned: Env and ConfigInvalid stay in the signature, and a pinned config 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:
export const OrderApi = HttpModule("OrderApi")({
router: orderRouter,
provides: [sessionCodec()],
imports: [OrdersSlice, CustomersSlice],
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:
| Variable | Default | What it is |
|---|---|---|
HTTP_BODY_LIMIT | 1048576 | the largest request body a procedure reads, in bytes; 0 is unbounded |
HTTP_CORS_ORIGIN | unset (off) | comma-separated allowed origins, or * |
HTTP_COMPRESSION | false | a 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 oRPC's own 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.
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 NodeHttpHandlerPlugin<DefaultInitialContext>[], from @orpc/server/node, 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 all three surfaces (http(), 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.
| Value | Effect |
|---|---|
true (default) | x-content-type-options: nosniff, x-frame-options: DENY, referrer-policy: no-referrer |
false | nothing 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.
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:
| Request | Answer |
|---|---|
GET, HEAD, OPTIONS — anything not state-changing | served by this check — but see the oRPC plugin below |
| a state change carrying no cookie | served: 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-site | served |
a state change with Sec-Fetch-Site: cross-site or none | 403, no body |
no fetch metadata, Origin host equal to the request's own | served |
no fetch metadata, Origin absent, unparseable, or another host | 403, 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)
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.
| Option | Required | Default | What it is |
|---|---|---|---|
prefix | no | / | where fragments are mounted |
login | no | — | 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 request | Answer | Header |
|---|---|---|
| a browser navigating | 303 | Location: /auth/login?return=%2Fprivate |
htmx's own (HX-Request: true) | 401 | HX-Redirect: /auth/login?return=%2Fprivate |
| under-scoped, whatever the sender | 403 | none |
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 — request.url as it arrived — 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: a route whose first segment is a parameter (api.HtmxGet("/:slug", { requires })) matches the crafted target /\evil.com, and new URL("/\\evil.com", base) resolves to https://evil.com/ — the WHATWG parser reads \ as / in relative-slash state. 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, corsOrigin, compression }, bound through Config.provider from the Env port the kernel provides. Each option pins its field: explicit > environment > default, per field, so http({ port: 0 }) still reads HOST — and still reads HTTP_BODY_LIMIT.
| Variable | Default | Parsed by | Notes |
|---|---|---|---|
PORT | 3000 | Config.port | 0 lets the OS pick; read the bound port back from RunningApp.runtimeInfo() |
HOST | 0.0.0.0 | Config.string | the deployment target is a pod; set 127.0.0.1 locally if the server must not be reachable off-host |
HTTP_BODY_LIMIT | 1048576 | Config.integer | bytes; 0 is unbounded. The one policy whose default is on — see above |
HTTP_CORS_ORIGIN | unset (off) | Config.string | comma-separated origins, or *; setting it is what turns CORS on |
HTTP_COMPRESSION | false | Config.boolean | true/false, 1/0, yes/no, on/off |
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.
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.
type HttpAnswerer = {
readonly prefix: `/${string}`;
readonly handle: (
request: IncomingMessage,
response: ServerResponse,
signal: AbortSignal,
host: UnitHost<never>,
) => PromiseLike<unknown>;
};| Request | Mounted answerers | Answers |
|---|---|---|
/rpc/orders | / and /rpc | /rpc — the longest match |
/orders/42 | / and /rpc | / — everything else |
/graphql | /graphql | /graphql — the mount point itself |
/rpcx | /rpc | the runtime's 404 — a mount point is a path segment, not a string prefix |
/orders/42 | /rpc and /graphql | the 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 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 an unmarked procedure 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
| Request | Answer | Decided by |
|---|---|---|
a procedure under prefix | the procedure's output, or the ORPCError its Result was mapped to | oRPC, the router |
a GET for a procedure whose output is an event iterator | the stream, as text/event-stream — the one GET the RPC handler admits | this package |
a defect thrown inside a procedure, or in unitScope's own fork | oRPC's own INTERNAL_SERVER_ERROR collapse | oRPC |
| a protected procedure no requirement accepted the caller for | 401 UNAUTHORIZED, the handler never entered | this package |
| a protected procedure whose caller lacked a required scope | 403 FORBIDDEN, the handler never entered | this package |
| a protected procedure whose authenticator defected | oRPC's INTERNAL_SERVER_ERROR collapse — a bug, not a rejected caller | oRPC |
a path under prefix naming no procedure | 404 {"error":"NotFound"} — oRPC declines it unwritten | this package |
any path outside prefix | 404 {"error":"NotFound"} — likewise | this package |
| the listener resolved without writing | 404 {"error":"NotFound"} | this package |
| the listener failed before headers were out | 500 {"error":"InternalError"} | this package |
| a failure with headers already on the wire | the socket is destroyed — a reset, not a hang | this 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 refuse 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.
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 field | Value |
|---|---|
id | randomUUID(), minted per request — never the route, which would give every request one trace id |
traceId | the trace id of an inbound traceparent; else the inbound x-request-id header when non-blank; otherwise absent, so it defaults to id. A malformed or all-zero traceparent falls through to x-request-id |
A blank header is ignored rather than adopted, because "" is not nullish and would otherwise win over the minted id.
unit, and who forks it
readonly unit?: Readonly<Record<string, AnyUnitModule>>;http(), httpServer() and HttpModule all 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> — not exported from the package, reached the same way OrpcRouterPort is, never by name. 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 is seeded with nothing, since there is no caller to name.
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:
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")({
needs: [auth.principals.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 names it in needs and injects it, and the seed lands on it once per unit — so a module naming 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.
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:
api.OrpcController(contract, "orders")({
inject: {},
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 answerers | Bindable kinds | Each value must be |
|---|---|---|
come from auth.units<…>() | exactly the kinds that call declared | the module type that kind declared |
come from a plain defineHttp() | anonymous plus every scheme the answerers serve | any 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.
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.
http() and httpServer() are un-gated, and structurally so: they take the router as a need, never as a value, so there is nothing to check against. Their unit keeps the wide record. A hand-rolled composition that wants the gate composes through HttpModule.
The drain
Serving.drain(signal) marks the server draining, retires every open response — an unsent header gets Connection: close, a sent text/event-stream response is destroyed on the spot, any other sent one 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.
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. jose is ESM-only, so a CJS consumer of /jwt or /session needs Node >=22.12 for require(esm); ESM is fine on any Node 22.
Deliberately not included
- Another router in oRPC's answerer. oRPC through
@orpc/server/node'sRPCHandleris how this package answers HTTP, and there is nohandleroption to swap it. A second protocol is a second answerer on theHttpHandlerset port under the same runtime. - A middleware slot for application logic. oRPC's own, inside the router's procedures.
principalMiddlewareis the one per-request hook the package installs, only on a leaf whose requirements say so.pluginsis 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'sResultis 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
apideployment 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 throughplugins— 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 indefineHttp. - HTTPS, HTTP/2.
node:httponly; terminate TLS at the ingress.
openApiDocument() — from @btravstack/http-server/openapi
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 is Partial<OpenApiDocument> 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.
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 optional peers behind the subpath, so an application that never asks for a document installs neither.
Nothing serves it
This package mounts no documentation route and ships no UI asset. A Swagger UI bundle inside a transport package would be a runtime dependency for every consumer, including the ones who never ask for a document — so an application serves the value from a route of its own. examples/order-api/src/openapi.ts is the whole recipe.