Order API (HTTP)
examples/order-api — the first deployment: the order application answering callers over oRPC, served by @btravstack/http-server.
pnpm turbo run test --filter=@btravstack/example-order-apiThe specs run a real node:http server and a real oRPC client over it, on an ephemeral port; nothing else is needed.
Two slices, each its own fragment and controller
The contract splits into two fragments, orders and customers, each a RouterContract in its own right — and one of them is marked:
import { authenticated } from "@btravstack/contract";
import { oc } from "@orpc/contract";
import { z } from "zod";
const orderView = z.object({ id: z.uuidv7(), quantity: z.number() });
export type OrderView = z.infer<typeof orderView>;
const orderRef = z.object({ id: z.uuidv7() });
export type OrderRef = z.infer<typeof orderRef>;
// The one ref whose `id` is a bare string. It names the id **as received**,
// which is exactly the value that is not a UUIDv7 — validating it against
// `z.uuidv7()` would reject the only payload `BAD_REQUEST` ever carries.
const malformedRef = z.object({ id: z.string() });
// The unmarked fragment names its tenant on the input; the marked one does not,
// because a caller's identity establishes it there.
const tenanted = z.object({ tenantId: z.uuidv7() });
const customerView = z.object({ id: z.uuidv7(), name: z.string() });
export type CustomerView = z.infer<typeof customerView>;
// Same shape as `orderRef`, deliberately not the same schema: reusing it would
// type a customer id as "which order it was about".
const customerRef = z.object({ id: z.uuidv7() });
export type CustomerRef = z.infer<typeof customerRef>;
// The group default: every procedure beneath needs the `user` scheme.
const ordersContract = authenticated({ user: [] })({
place: oc
.input(z.object({ id: z.uuidv7(), quantity: z.number() }))
.output(orderView)
.errors({
INVALID_QUANTITY: { data: orderRef },
BAD_REQUEST: { data: malformedRef },
CONFLICT: { data: orderRef },
}),
find: oc
.input(orderRef)
.output(orderView)
.errors({ NOT_FOUND: { data: orderRef } }),
// Overrides the group default for itself: a service token may export too,
// and a user token needs the scope. `FORBIDDEN` is the one code here the
// STARTER also answers — an under-scoped caller gets a bare 403, where this
// one carries `data` and is inferable.
export: authenticated(
{ user: ["orders:export"] },
{ service: [] },
)(
oc
.input(z.object({ id: z.uuidv7() }))
.output(z.object({ csv: z.string() }))
.errors({
NOT_FOUND: { data: orderRef },
FORBIDDEN: { data: orderRef.extend({ reason: z.string() }) },
}),
),
});
const customersContract = {
find: oc
.input(tenanted.extend({ id: z.uuidv7() }))
.output(customerView)
.errors({ NOT_FOUND: { data: customerRef } }),
};
export const contract = {
orders: ordersContract,
customers: customersContract,
};The wire shapes are zod schemas, with the view types inferred from them rather than declared beside them. They are not the entities: Order's fields are branded (OrderId, Quantity) and a brand is a compile-time fiction that does not survive serialization, so the transport speaks its own shape and each slice's controller is the one place the two are converted. oRPC's type<T>() would say the same thing to the compiler and check nothing at runtime, which is how { quantity: "abc" } reaches a use case typed number; a schema is what makes the boundary real, and inferring the type from it is what keeps the checked shape and the compiled one from drifting.
The two fragments are module-private; contract and the view types are the package's exports, and every consumer reaches a fragment through it — contract.orders, contract.customers.
authenticated({ user: [] }) on orders is a type-level fact about the fragment, so a client reads which half of this API needs credentials — and under which scheme — off the contract itself, and a server that serves the marked half without an authenticator for that scheme does not compile. orders.export overrides that group default for itself, which is how one contract exercises a per-procedure override, a scope and a second scheme all at once: a user token granting orders:export, or a service key needing no scope. It is also why the two fragments' inputs differ: customers.find names its tenantId, because "which tenant" is part of what an anonymous caller is asking; orders.place and orders.find name none, because the caller's own identity establishes it, and a required field the handlers ignore would be a lie in the contract.
The contract says nothing about who the caller is. No principal type appears anywhere in the contract package, so nothing about this deployment's view of a caller — a user id, roles, an org tier — reaches a client, and enriching it is never a contract change.
What a caller is, and the one file that says so
One file, at the root of src/, belonging to no slice:
src/auth.ts the bearer, API-key and session schemes, and the one defineHttp call that declares themauth.ts is where each scheme's identity is stated and its authenticator written, and where the one defineHttp call the application makes lives:
import { TenantId, TenantIdSchema } from "@btravstack/example-order-domain";
import { apiKeyAuthenticator, defineHttp } from "@btravstack/http-server";
import { jwtAuthenticator, type Claims } from "@btravstack/http-server/jwt";
import { sessionAuthenticator } from "@btravstack/http-server/session";
import type { IDToken } from "openid-client";
/** What this deployment knows about a caller under the `user` and `session` schemes. */
export type Identity = {
readonly tenantId: TenantId;
readonly userId: string;
};
/** What the `service` scheme resolves to: which machine, and the tenant its key was cut for. */
export type ServiceIdentity = {
readonly appId: string;
readonly tenantId: TenantId;
};
/**
* What a verified token or a sealed session means here, and the one place
* this deployment's claim spelling is written: `sub` is the user, `tenant`
* is the tenant. Answering `undefined` refuses the token or the login. Two
* doors read it — the bearer scheme's token and the login answerer's ID
* token — which is why it takes either claim set.
*/
const principal = (claims: Claims | IDToken): Identity | undefined => {
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;
};
/** Nothing pinned: the three `HTTP_JWT_*` variables are the deployment's. */
export const userAuth = jwtAuthenticator<Identity>()({
principal,
scopes: ["orders:export"],
});
/**
* The third scheme: the session cookie the login answerer seals. The same
* `Identity` as `user`, since a browser that logged in IS a user.
*/
export const browserAuth = sessionAuthenticator<Identity>()({
scopes: ["orders:export"],
});
/** The issued keys, and the one place a key's tenant is written. */
export const serviceKeys = [
{
key: "reporting",
principal: {
appId: "reporting",
tenantId: TenantId("0199a1e0-0000-7000-8000-0000000000f1"),
},
},
] as const;
/** The second scheme: an API key, no scopes — what a reporting job presents. */
export const serviceAuth = apiKeyAuthenticator<ServiceIdentity>()({
keys: serviceKeys,
});
export const api = defineHttp({
authenticators: { user: userAuth, service: serviceAuth, session: browserAuth },
});Once per application rather than once per slice, because a handler's parameter types are fixed where the arrow is written: the composition root cannot re-type a sync callback that lives inside slices/orders/, so the registry has to be in scope there. Declaring a scheme and implementing it are the same act, which is why there is no registry to keep in step with the contract and no authenticator for the root to list.
Held whole — never destructured
const { OrpcController } = defineHttp(...) is TS2527: each binding of a destructured member expands to a type mentioning the marker's inaccessible unique symbol, which this file could not emit. Held whole, the inferred type collapses to Http<A>, which is nameable — which is why this file, unlike the one it replaced, carries no type annotation at all.
It is the only way a handler gets a readable principal. A marked fragment reached through any other defineHttp call types principal: never, so every read of it is a compile error — the signal to use the factory, not a fallback.
principal is where a verified claim becomes a tenant — and the one place this deployment names the claim it reads it from, since no standard claim carries one. TenantId is the domain's branded string, so the identity carries the brand from here and no handler on this path casts anything — UserModule hands the identity's tenantId straight to its Tenant provider. The constructor is a cast, and principal is where it is earned: a claim is the issuer's string rather than a contract-validated input, so TenantIdSchema.safeParse runs first and a claim it cannot read authenticates as nobody. A brand is a compile-time fiction, and what it buys is that customers.find(tenantId, id) — the one port left here that names a tenant — cannot be called with its two arguments the other way round. The scope vocabulary is declared at the call (scopes: ["orders:export"]), so the granted list is the intersection of it with the token's own scope claim rather than a string compared at the endpoint. A scheme that needs a service of its own — a user directory, a key store — names it in an inject record and gets it the way any provider's dependencies arrive, and that need travels with the authenticator into the graph, so a root satisfying none is refused at the HttpModule(...) call. See Protect a procedure for the recipe in full.
The slices: a controller and a module each
Each slice lives under slices/<name>/ — a controller.ts implementing that slice's fragment, and a module.ts exporting only that controller. Both are one file deep, because both are backed by the same three-package vertical: use cases in order-application, and the entities and Prisma adapters behind it. The orders slice reaches that vertical off context.unit rather than through inject, because its use cases are built per request over the caller's own tenant — see A request scope over the application scope below.
src/slices/orders/controller.ts api.OrpcController(contract, "orders")({ inject: { logger: Logger }, unit: { place: PlaceOrder, find: FindOrder, list: ListOrders }, sync })
src/slices/orders/authorize.ts exportable(caller, order) — the authorization rule, and renderCsv, which nothing but its answer reaches
src/slices/orders/module.ts OrdersSlice — provides the controller and the fragment, exports only them
src/slices/customers/controller.ts api.OrpcController(contract, "customers")({ inject: { find: FindCustomer }, sync })
src/slices/customers/module.ts CustomersSlice — same shape as OrdersSliceslices/orders/controller.ts is the transport boundary and the only place in this slice where a domain error becomes something else — slices/customers/controller.ts below does the same for its own slice:
import { api } from "../../auth.js";
export const ordersController = api.OrpcController(
contract,
"orders",
)({
inject: { logger: Logger },
unit: { place: PlaceOrder, find: FindOrder },
sync: ({ logger }) => ({
place: ({ errors, context }, input) => {
logger.info("order placement requested", {
userId: context.principal.userId,
});
return 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 },
}),
),
),
// Two schemes, so the principal is a discriminated union — and it is what
// the authorization rule takes, since what a caller may export depends on
// which of them it is. `renderCsv` cannot be called without the rule's
// answer.
export: ({ errors, context }, input) => {
logger.info("order export requested", {
id: input.id,
scheme: context.principal.scheme,
});
return 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 },
}),
),
);
},
}),
});Each leaf is the .result() handler @unthrown/orpc gives that procedure's implementer, typed by the contract at the call — the input is the fragment's parsed input, errors its declared error map, and a typo'd or missing procedure is a compile error inside the controller, not at the root. In it, Ok is the output, an Err holding an ORPCError is returned (so oRPC marks it inferable and the client gets it typed), and a Defect rethrows its cause onto oRPC's own defect path, where it collapses to INTERNAL_SERVER_ERROR.
The mapErrCases in between is the triage. Every case of the use case's error type is named — this repository bans P._, and the matcher has no .otherwise() — so a new domain error is a compile error here, at the one place that has to decide what a client sees. A Defect is never named: it was never modeled, and collapsing it to a 500 is the correct treatment rather than a fallback.
The tenant comes off context.principal — the value the user scheme's authenticator resolved from this request's headers — and it is the only thing on oRPC's context channel. ordersController is minted from auth.ts's api, which is why principal has a readable type here at all with no annotation at this call site. place and find name one scheme, so they read the identity bare — byte-for-byte what this file held before named schemes existed; export names two, so its principal is tagged and the compiler checks that every scheme the contract named is answered for. That contrast is the whole design: the common case pays nothing. The starter knows nothing about tenancy either way: it resolved a principal this application defined, and what the fields on it mean is the application's business. Who placed an order is a transport-boundary fact, so it is logged here, on the request's own trace id, rather than pushed through a use case that has no business with it.
Three layers of authorization, and only the third is written by hand
orders.export is where all three meet, each checked by the layer that can check it — the position itself, with the floor underneath it, is Authorize a request:
| Layer | Where it is stated | What it can decide |
|---|---|---|
| scope | the contract, authenticated(…) | may this KIND of caller reach the procedure |
| tenant | the unit kind, Tenant | which rows exist at all for this caller |
| policy | the handler, exportable(…) | may THIS caller do this to THIS resource |
The first two are refusals the caller never reaches a handler for: an under-scoped token is the starter's bare 403, and context.unit.find cannot see another tenant's order however the handler is written. What is left is a decision about one order, and it lives in slices/orders/authorize.ts:
The file is compiled whole on Authorize a request: a brand only the rule mints, a Forbidden the application owns, and renderCsv typed on the witness.
AUTHORIZED is not exported, so renderCsv(order) on a plain order does not compile and no other module can mint the witness by construction. What that buys is precise: a forgotten rule is a compile error. A caller determined to skip it can still write order as Authorized<Order>, because the type is exported — but that is a lie in one line, and one a reviewer greps for: rg 'as Authorized' is the review, and the rule's own lines are the ones it is allowed to find. Forbidden is the application's own tagged error, folded by the same exhaustive mapErrCases as every domain error — there is no framework Policy port, no registry, and nothing to register. Caller is exported from auth.ts as an alias of what the declared schemes resolve to, so a third scheme is a compile error inside the rule that must decide about it.
The rule is a quantity ceiling rather than ownership because this domain records no owner — a worker places orders with nobody behind them — and the shape is the same either way once yours does.
slices/customers/controller.ts is the same shape over one procedure, built from FindCustomer and mapping CustomerNotFound to the fragment's own NOT_FOUND. Its fragment is unmarked, so its context has no principal at all — reading one there is a compile error — and it takes its tenant from input.tenantId instead. The contrast is the lesson: where a caller's identity establishes the tenant, the input has nothing to say about it. That is the one TenantId(input.tenantId) in the application: the fragment validated the field as a UUIDv7, and the brand is claimed once, where the wire's string becomes the application's vocabulary. It has its own view too, because its use case answers with the branded Customer entity and CustomerView is the wire's shape — a slice is defined by owning its fragment, its controller and its triage, not by owning a private adapter. The throwaway in-memory directory this replaced declared its port over CustomerView itself, which pointed the dependency arrow outwards.
The router: composed from controllers, each carrying its own path
module.ts's orderRouter is api.OrpcRouter(contract)'s composing form — an array of pieces, each already carrying the path it was minted from, instead of one sync:
import { api } from "./auth.js";
export const orderRouter = api.OrpcRouter(contract)([
ordersController,
customersController,
]);OrpcRouter comes off the same api as the controllers: the marks on contract.orders ride through the composing form, so the router declares one dependency per scheme the contract names — HttpAuthenticator:user and HttpAuthenticator:service — and carries the providers that discharge them, from that same call.
This form is exact: a slice's path missing from the array, a path the contract does not declare — refused at the piece's own mint, not here — and a piece under the wrong path — impossible by construction, since the path rides the piece's own port id — are all compile errors, the last two at api.OrpcController(contract, path) itself. See Split a router into controllers for the recipe, and packages/http-server/src/controller.test-d.ts for the six gates that pin these errors and the lift below. Because a fragment is itself a valid contract, ordersController serves contract.orders alone unchanged: the lifted root is api.OrpcRouter(contract.orders)({ inject: { implementation: ordersController.port }, sync: ({ implementation }) => implementation }) over OrdersSlice, so extracting a slice out of this modulith is a new composition root and one fewer import, not a rewrite.
One htmx route, off the caller's own tenant
Alongside the router, this deployment serves one htmx route — not an oRPC contract fragment like orders/customers above; see Serve htmx fragments for that distinction in full. slices/orders/fragment.ts's orderRowFragment is minted straight from its method and path, with api.HtmxGet — no contract in between:
import { html } from "@btravstack/http-server";
export const orderRowFragment = api.HtmxGet("/orders/:id/row", {
requires: [{ session: [] }],
})({
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>`,
),
),
});requires: [{ session: [] }] marks the route exactly as contract.orders marks ordersController — so it opens the session unit rather than user, and its FindOrder is the one SessionModule's fork built over the browser's own tenant, off the cookie the login answerer sealed. The route's path names only id, so a caller's credential is what scopes the row, never the path. .recoverErrCases is this piece's own triage, at the place mapErrCases sits for the router: there is no declared error union for a client to branch on, so OrderNotFound becomes a rendered row here or not at all. See Log a browser in for the scheme, the kind and the login answerer end to end.
module.ts composes it the same way it composes the router, over an array of its own routes, and the composition root below passes the result alongside router:
export const orderFragments = api.HtmxFragments([orderRowFragment]);fragments mounts at htmx()'s own default, / — a separate mount from the router's /rpc, since one option cannot carry two defaults. fragments.spec.ts proves the tenant scoping end to end, over the real root and a real credential: a cross-tenant request for an order id another tenant placed renders the slice's own not-found row rather than the owner's order.
The composition root, and the process
module.ts is a list of slices, plus what no slice owns:
export const OrderApi = HttpModule("OrderApi")({
router: orderRouter,
fragments: orderFragments,
fragmentsLogin: "/auth/login",
// One module per kind a request can open under. `UserModule` is where the
// principal's tenant becomes a `Tenant` and the orders vertical is composed
// over it; `ServiceModule` adds nothing, because a machine caller has none;
// `SessionModule` is `UserModule`'s shape over the browser's principal.
unit: {
anonymous: RequestModule,
user: UserModule,
service: ServiceModule,
session: SessionModule,
},
imports: [
OrdersSlice,
CustomersSlice,
OrderPersistenceModule,
cache({ adapter: redisCache() }),
observability(),
otel(),
],
// The session cookie's codec and the login answerer that seals it.
provides: [sessionCodec(), oidc({ principal, scope: "openid orders:export" })],
// Everything a forked kind reads out of the application scope.
exports: [Logger, Tracer, Meter, OrderDatabase],
});fragments rides alongside router — supplying one, the other, or both is the same call; HttpModule mounts each under its own default and deduplicates a scheme the two share by reference. The authenticators are not listed, and that is the point: who a caller is is one answer per process rather than a slice's question, so they were declared once in auth.ts, and they ride the router and the fragments provider — which are what need them. HttpModule puts them in provides itself, so a scheme cannot be forgotten here and cannot be wired to the wrong router. What is still checked is di's own gate: a scheme the contract names with no authenticator behind it leaves HttpAuthenticator:<scheme> in the root's needs, which start refuses, naming the port.
A slice imports whatever its OWN providers close over. The orders one imports nothing at all, because its controller and fragment reach their use cases off context.unit — built per request, in the user kind's module, over the caller's own tenant:
export const OrdersSlice = Module("OrdersSlice")({
// The controller writes a line itself, so `Logger` is this slice's own
// provider's need. The use cases are not: a leaf reaches them off
// `context.unit`, never through `inject`.
needs: [Logger],
provides: [ordersController, orderRowFragment],
exports: [ordersController, orderRowFragment],
});orderRowFragment rides with ordersController — the providers, not their .ports: OrpcController and HtmxGet each mint the port for you, so there is no class to name. A real slice carries its own htmx route rather than leaving it for the root to provide separately.
The customers slice DOES import a vertical — CustomerApplicationModule and CustomerPersistenceModule — and the asymmetry is the tenancy showing through the composition: its procedures are unmarked, so they open an anonymous unit with no principal to take a tenant from, and FindCustomer takes its tenant as an argument off the input instead. A repository that needs no request to be built stays in the application scope.
Where the two meet is one level below: both persistence modules import the same OrderDatabaseModule, which owns the connection. That is a diamond, not duplication: di flattens the module tree into a Set keyed by provider reference, so the graph builds one database — and the root exports OrderDatabase so the user fork can build its per-request repository over that one client. exports takes the provider rather than ordersController.port — OrpcController minted that port, so there is no class to spell back off it.
HttpModule imports the starter (http() — HttpRuntime, HttpConfig bound from PORT / HOST, the router mounted under /rpc, needing the router the root provides), provides orderRouter and exports HttpRuntime, and returns exactly the module Module("OrderApi")({...}) would have. observability() brings the Logger the interactors and the request scope write to — bound from LOG_LEVEL, one JSON object per line on stdout, every line carrying the unit's trace id — and Logger is exported for the request scope below. It is a constant: configuration is read inside the graph from the Env port the kernel provides, so nothing is passed in from main.ts, and a spec boots this very module with env: { PORT: "0", HOST: "127.0.0.1" }.
main.ts is one statement:
await runMain(OrderApi, {
onEvent: kernelEvents(createLogger(jsonSink())),
});The process reads PORT (default 3000), HOST (default 0.0.0.0), LOG_LEVEL (default info) and PROBE_PORT (default 9000) — inside the graph — and a malformed one is a startFailed event and exit 78. kernelEvents puts the kernel's nine lifecycle events in the same stream and the same shape as the application's own lines, instead of the default JSON on stderr; the logger there is built by hand because building is emitted while the graph still is, so a sink taken out of the context it is watching would have nothing to write the two events that matter most with. See Log and correlate.
A request scope over the application scope
The application scope is opened once, by the kernel, and holds the database; opening another per request would give every request its own empty in-memory database. So request-scope.ts declares what lives for one request, and each answerer forks it around the request it is handling:
export class RequestSpan extends Port("RequestSpan")<{
readonly finish: () => void;
}> {}
export const RequestModule = Module("Request")({
needs: [Logger],
imports: [UnitSpanModule],
provides: [
Provider(RequestSpan)({
inject: { logger: Logger },
// No histogram here: `@btravstack/http-server` records
// `btravstack.http.duration` at the unit seam, dimensioned by answerer
// and status. What is left is the LINE.
sync: ({ logger }) => {
const startedAt = Date.now();
return {
finish: () =>
logger.info("request finished", {
durationMs: Date.now() - startedAt,
}),
};
},
onStop: (span) => span.finish(),
}),
],
exports: [RequestSpan],
});Bound as HttpModule's anonymous kind, it is built as the request opens and torn down as it closes, reading Logger out of the parent without rebuilding it. onStop runs while the unit is still open, which is what gives its line the request's own trace id — and no handler code manages the fork.
UserModule is the same shape with the tenancy on top: it imports RequestModule, provides Tenant from auth.principals.user — the principal the fork is seeded with — and composes OrderTenantPersistence and OrderApplicationModule over it, so a marked leaf reads PlaceOrder, FindOrder and ListOrders already bound. SessionModule is the identical shape over auth.principals.session instead — a browser that logged in is a user, so it exports the same three use cases. ServiceModule does the same over the tenant the caller's API key was cut for — Tenant from auth.principals.service, and the tenant is a field of ServiceIdentity stated per key in auth.ts's serviceKeys, so a second key states its own rather than inheriting the first's rows — but exports only FindOrder. That asymmetry is what makes context.unit.place and context.unit.list unreadable from export, the one leaf both schemes serve: the record a leaf is given is the intersection of what its kinds export. See Open a per-request scope and Log a browser in for SessionModule in full.
The spec: booting the real module on PORT=0
test-fixtures.ts starts from @btravstack/testing's bootFixture and wraps it in serve, where every spec starts, real composition root included:
export const it = test.extend<ApiFixtures>({
// One issuer per spec file: a served JWKS and a matching signer, so the
// `user` scheme does a real fetch and a real verify.
issuer: [localIssuerFixture, { scope: "file" }],
env: async ({ issuer }, use) => {
await use({
PORT: "0",
HOST: "127.0.0.1",
LOG_LEVEL: "fatal",
// Nothing is pinned on `userAuth`, so these three are what the scheme
// binds itself from — the same three a deployment sets.
HTTP_JWT_JWKS_URI: issuer.jwks,
HTTP_JWT_ISSUER: issuer.issuer,
HTTP_JWT_AUDIENCE: issuer.audience,
// …DATABASE_URL and REDIS_URL, from the shared containers
});
},
boot: async ({ env }, use) => {
await bootFixture({ env })({}, use);
},
serve: async ({ boot }, use) => {
await use((module, options) => boot(module, options));
},
// …
});boot brings a test's defaults (signals: false, probes: false, preDrainDelayMs: 0, a silent sink) and stops every app it started when the test ends; serve has nothing more to add — RequestModule is forked by the answerers themselves, per OrderApi's own unit option, not by anything a fixture supplies — and LOG_LEVEL: "fatal" keeps the real root — whose sink is the production jsonSink() on stdout — out of the runner's own output. The port comes back from Serving.info through app.runtimeInfo() — the kernel's own channel for it — and the client is built from the contract alone. What it carries on top of that is one header: clientFor sends authorization: Bearer <token> for a token the file's own localIssuer signed — a real key, a real JWKS fetch and jose's own verify, not a header the scheme is told to trust — since the orders fragment is marked and an anonymous call to it never reaches a use case, while clientWith states the token verbatim — or omits it — for the specs about the refusal itself, and tokenFor overrides a claim at a time for the specs about a token from another issuer, for another audience, or without the scope. The tenant is a UUID per test, which is what lets every spec share one database. Where a spec needs the lines the running graph wrote, the seam is observability({ sink }): the recording fixture composes the root's shape with a sink that keeps every Line, so an assertion reads line.unit.traceId as a field rather than parsing a prefix out of a string. The suite then pins what matters: a DuplicateOrder arrives as an Err holding an inferable CONFLICT, a value the client matches by code, not a thrown 500:
expect(conflict).toBeErrWith(
expect.objectContaining({
constructor: ORPCError,
code: "CONFLICT",
data: { id: "0199a1e0-0000-7000-8000-000000000001" },
inferable: true,
}),
);An unmodeled repository failure collapses to INTERNAL_SERVER_ERROR without leaking its message, and the process keeps serving afterwards; each call runs in its own unit with its own trace id (two calls, four log lines, two distinct line.unit.traceIds, none written outside a unit); a call held open in the repository finishes during a drain and is counted completed, one still hung at a zero deadline is counted abandoned; /livez and /readyz answer while serving, and readiness goes false before liveness during the drain; and the customers slice answers over the same client and the same running root — a CustomerView on the way out of a stub-backed root, a typed NOT_FOUND out of the real one — proving the composed router actually mounted both controllers rather than one.
Three gates, pinned at compile time
needs-gate.test-d.ts is type-checked, never executed. It pins start's own marker gate, the Needs-channel refusal that is neither the marker nor di's declaration gate, and the unit needs-propagation gate, side by side:
// @ts-expect-error — NO RUNTIME: the module exports no port declared over RuntimePort.
const _missingRuntime = start(RuntimelessApi, options);RuntimelessApi is the same list of slices without http(...): start's phantom marker becomes the sentence "NO RUNTIME — the module exports no port declared over RuntimePort", and the module argument fails to match its parameter type — the sentence is the error's last line. It provides orderRouter and ...orderRouter.authenticators even so, deliberately: the contract marks orders, so a graph carrying the router without them has an unmet need too, and an arm that could fail either way pins neither gate. That spread is exactly what HttpModule does for a root that uses the sugar.
const RouterlessApi = Module("RouterlessApi")({
imports: [OrdersSlice, CustomersSlice, observability(), http()],
exports: [HttpRuntime, Logger],
});
// @ts-expect-error — the composition needs the router port and nothing provides it.
const _missingRouter = start(RouterlessApi, options);This one is the Needs channel, not the kernel's marker and not di's declaration gate either: the port is owed by http(), an import, and an import's needs travel without the importer re-declaring them. start — whose module parameter accepts only Scope | Env outstanding — is what refuses it, and the diagnostic names the port: Type 'OrpcRouterPort' is not assignable to type 'Env | Scope', down to Type '"OrpcRouter"' is not assignable to type '"@di/Scope"'. It is not di's UNSATISFIED DEPENDENCIES dependency gate, which guards Module.build and Module.scoped; conflating the two is easy and the distinction is the point of having both pinned here. There is no UNSATISFIED RUNTIME PORTS arm, because the shipped runtime resolves nothing.
const _unloggedUnit = HttpModule("WithUnitUnmet")({
router: orderRouter,
unit: { anonymous: HttpUnitModule },
imports: [OrdersSlice, CustomersSlice, observability(), cache({ adapter: memoryCache() })],
exports: [Logger],
});
// @ts-expect-error — UNSATISFIED DEPENDENCIES: nothing provides `HttpUnitDep`, which `HttpUnitModule` needs
const _withUnitUnmet = start(_unloggedUnit, options);The third gate is HttpModule's own needs-propagation one, the same shape the two workers' needs-gate.test-d.ts files pin: a bound unit.anonymous module's own unmet needs join HttpModule's own Needs channel, so the gate that refuses them is start's ordinary UNSATISFIED DEPENDENCIES, never a marker of the kernel's — there is no StartOptions.unit any more for the kernel to gate. WithUnitSatisfied provides the trivial HttpUnitDep the bound HttpUnitModule needs and compiles as an ordinary call; WithUnitUnmet leaves it out and is rejected on that need alone.
What is no longer here, and why. This file used to carry two more arms about the authenticator — a root that forgot to pass one, and a root that passed one resolving the wrong identity. Neither is reachable any more. The authenticators come from the same defineHttp call that types the handlers and ride the router into provides, so there is nothing to forget and no pair to compare — a scheme with nobody behind it is di's own unmet need on HttpAuthenticator:<scheme>, which the router-port arm above already pins the shape of.
Where to go next
- The same
DuplicateOrder, orchestrated: Order Temporal worker. - The marker,
auth.ts, scopes and the 401/403 split as a recipe: Protect a procedure. orderRowFragment's own recipe, and the answerer's stated limits: Serve htmx fragments.- The package behind the transport:
@btravstack/http-server. - Why the kernel appears in none of this: The kernel maps nothing.