Skip to content
Merged
32 changes: 25 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1091,16 +1091,34 @@ And a seventh, about the infrastructure a suite runs against:
the three transport starters — and the auto-instrumentation constraint that
will not go away are in `packages/observability/CLAUDE.md`. Never describe
them as shipped.
- **A `docs-examples.test-d.ts` for `@btravstack/http`, `@btravstack/temporal`,
`@btravstack/amqp` and `@btravstack/observability`.** `packages/core`'s exists precisely so its README and
the kernel-only pages of the documentation site cannot drift from
`runtime.ts` / `drain.ts` without failing `pnpm typecheck`; the four
other packages' README and site samples have no such gate — they were
- **A `docs-examples.test-d.ts` for `@btravstack/temporal`, `@btravstack/amqp`
and `@btravstack/observability`.** `packages/core`'s exists precisely so its
README and the kernel-only pages of the documentation site cannot drift from
`runtime.ts` / `drain.ts` without failing `pnpm typecheck`; those three
packages' README and site samples have no such gate — they were
compiled by hand in a scratch file inside the matching example workspace
when written, and by nothing since. Deliberately not built — four
packages' worth of samples still did not justify the harness. Add it the
when written, and by nothing since. Deliberately not built — three
packages' worth of samples still do not justify the harness. Add one the
next time one of those samples is found to have drifted, the same way this
gap itself was found.

**The HTTP half is no longer deferred**, because that trigger fired twice in
two days (issues #74 and #75, six pages describing `examples/order-api` as it
was before it had authentication). `examples/order-api/src/docs-examples.test-d.ts`
is the gate, and it lives in the **example** rather than in
`packages/http`: the samples call the real `PlaceOrder` / `FindOrder` /
`FindCustomer` against the real `contract` through the application's own
`src/auth.ts`, and a stub would have accepted every broken call — passing an
order id where a tenant goes was exactly the drift. It covers both
controllers, the keyed router, the `HttpModule` root with its authenticator,
the lifted single-slice root and the positional form the three
router-shaped pages share. It does **not** cover the pages' own contract
declarations: `zod` and `@btravstack/contract` are
`examples/order-api-contract`'s dependencies, not `examples/order-api`'s, so
a fragment is compiled where it lives — though a marker removed from it
still fails this file, since the controllers are typed by it. No config
change was needed; the workspace already wires `test:types`.

- ~~Bringing `packages/core`'s 13 spec files under the Test conventions.~~
**Closed by decision, not by doing it** (three of the 13 — `test-runtime`,
`fake-clock`, `with-app` — have since moved to `packages/testing`, on the
Expand Down
208 changes: 192 additions & 16 deletions docs/examples/order-api.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Order API example
description: The HTTP deployment — two slices, orders and customers, each its own contract fragment, HttpController and full vertical down to Prisma, composed by the keyed HttpRouter form into one HttpModule root, RequestModule forked per request, a main.ts that is one runMain call with the kernel's events on the application's own logger, and the three compile-time gates pinned by needs-gate.test-d.ts.
description: The HTTP deployment — two slices, orders and customers, one marked authenticated and one public, each its own contract fragment, HttpController and full vertical down to Prisma, an auth.ts stating what a principal is and an authenticator resolving it, composed by the keyed HttpRouter form into one HttpModule root, RequestModule forked per request, a main.ts that is one runMain call with the kernel's events on the application's own logger, and the five compile-time gates pinned by needs-gate.test-d.ts.
---

# Order API (HTTP)
Expand All @@ -19,9 +19,10 @@ 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:
`RouterContract` in its own right — and one of them is marked:

```ts
import { authenticated } from "@btravstack/contract";
import { oc } from "@orpc/contract";
import { z } from "zod";

Expand Down Expand Up @@ -65,7 +66,7 @@ const customersContract = {
};

export const contract = {
orders: ordersContract,
orders: authenticated(ordersContract),
customers: customersContract,
};
```
Expand All @@ -84,14 +85,111 @@ 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`](/reference/contract) on `orders` is a type-level fact about
the fragment, so a client reads which half of this API needs credentials off
the contract itself, and a server that serves the marked half without an
authenticator does not compile. 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

Two files, both at the root of `src/`, and neither belongs to a slice:

```
src/auth.ts httpAuth<Identity>() — states the principal, mints HttpController/HttpRouter/HttpAuthenticator on it
src/authenticator.ts bearerAuthenticator — the provider that resolves an Identity from the request's headers
```

`auth.ts` is where `Identity` is stated, once, and the three pieces the slices
and the root import come back fixed to it:

```ts
import {
httpAuth,
type HttpAuthenticatorOf,
type HttpControllerOf,
type HttpRouterOf,
} from "@btravstack/http";

export type Identity = {
readonly tenantId: string;
readonly userId: string;
};

const identity = httpAuth<Identity>();

export const HttpController: HttpControllerOf<Identity> =
identity.HttpController;
export const HttpRouter: HttpRouterOf<Identity> = identity.HttpRouter;
export const HttpAuthenticator: HttpAuthenticatorOf<Identity> =
identity.HttpAuthenticator;
```

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 identity
has to be in scope there. That is also what makes the authenticator and the
controllers unable to disagree — both come from this one call. The three
`…Of<Identity>` aliases are annotations rather than ceremony: a controller's
port expands to a type carrying the marker's phantom `unique symbol`, which
this file cannot name in its own declaration emit.

It is the **only** way a handler gets a readable principal. A marked fragment
reached through `@btravstack/http`'s own top-level `HttpController` types
`principal: never`, so every read of it is a compile error — the signal to use
the factory, not a fallback.

`authenticator.ts` is then an ordinary di provider, with no type argument left
to state:

```ts
import { Unauthenticated } from "@btravstack/http";
import { ErrAsync, OkAsync } from "unthrown";

import { HttpAuthenticator } from "./auth.js";

export const bearerAuthenticator = HttpAuthenticator([], {
sync: () => (headers) => {
const header = headers.authorization ?? "";
const token = header.startsWith("Bearer ")
? header.slice("Bearer ".length)
: "";
const [tenantId, userId] = token.split(":");
return tenantId === undefined ||
tenantId === "" ||
userId === undefined ||
userId === ""
? ErrAsync(new Unauthenticated())
: OkAsync({ tenantId, userId });
},
});
Comment thread
Copilot marked this conversation as resolved.
```

`Bearer <tenantId>:<userId>` is a stand-in, not a recommendation — what matters
is the shape. `[]` because this one needs no service; a JWT verifier, a key set
or a user directory would be named there and injected the way any provider's
dependencies are, so swapping the stand-in for real verification changes
nothing else in the composition. See
[Protect a procedure](/how-to/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`](/examples/order-application), and the
entities and Prisma adapters behind it.

```
src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder], { sync })
src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder, Logger], { sync })
src/slices/orders/module.ts OrdersSlice — imports the vertical, provides the controller, exports only it
src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)([FindCustomer], { sync })
src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice
Expand All @@ -102,14 +200,19 @@ this slice where a domain error becomes something else — `slices/customers/con
below does the same for its own slice:

```ts
import { HttpController } from "../../auth.js";

export const ordersController = HttpController(
"OrdersController",
contract.orders,
)([PlaceOrder, FindOrder], {
sync: (place, find) => ({
place: ({ errors }, input) =>
place
.execute(input.id, input.quantity)
)([PlaceOrder, FindOrder, Logger], {
sync: (place, find, logger) => ({
place: ({ errors, context }, input) => {
logger.info("order placement requested", {
userId: context.principal.userId,
});
return place
.execute(context.principal.tenantId, input.id, input.quantity)
.map(view)
.mapErrCases((matcher) =>
matcher
Expand All @@ -125,10 +228,11 @@ export const ordersController = HttpController(
data: { id: error.id },
}),
),
),
find: ({ errors }, input) =>
);
},
find: ({ errors, context }, input) =>
find
.execute(input.id)
.execute(context.principal.tenantId, input.id)
.map(view)
.mapErrCases((matcher) =>
matcher.with(P.tag("OrderNotFound"), (error) =>
Expand Down Expand Up @@ -158,9 +262,22 @@ 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 `bearerAuthenticator`
resolved from this request's headers — and it is the only thing on oRPC's
context channel. `HttpController` is `auth.ts`'s, which is why `principal` has
a readable type here at all with no annotation at this call site. 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.

`slices/customers/controller.ts` is the same shape over one procedure, built
from `FindCustomer` and mapping `CustomerNotFound` to the fragment's own
`NOT_FOUND`. It has its own `view` too, because its use case answers with the
`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. 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
Expand All @@ -173,12 +290,18 @@ a record of controllers, one per top-level contract key, instead of one
`sync`:

```ts
import { HttpRouter } from "./auth.js";

export const orderRouter = HttpRouter(contract)({
orders: ordersController,
customers: customersController,
});
```

`HttpRouter` is `auth.ts`'s here too: the marker on `contract.orders` rides
through the keyed form, so the router carries the identity its controllers were
minted with, and the root below checks the authenticator against it.

This form is exact: a slice missing from the record, a key the contract does
not declare, and a controller wired under the wrong key are all compile
errors at this call — see
Expand All @@ -198,11 +321,19 @@ composition root and one fewer import, not a rewrite.
```ts
export const OrderApi = HttpModule("OrderApi")({
router: orderRouter,
authenticator: bearerAuthenticator,
imports: [OrdersSlice, CustomersSlice, observability()],
exports: [Logger],
});
```

The `authenticator` is here and nowhere else, because who a caller is is one
answer per process rather than a slice's question — and it is _required_ here
because the contract marks `orders`: `HttpRouter` gave the router provider a
dependency on the starter's `AuthenticatorPort`, so dropping the line is an
unmet need `start` refuses, and supplying one that resolves a different
principal is a compile error at this very call.

Each slice imports its own vertical — `OrderApplicationModule`, whose
repository is an unmet need, and `OrderPersistenceModule`, which provides it —
so the root names what the process serves rather than everything every slice
Expand Down Expand Up @@ -325,7 +456,12 @@ test ends; `serve` adds the per-request `RequestModule`, 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. Where a spec needs
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 <tenant>:u-1`, 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. 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`
Expand Down Expand Up @@ -356,7 +492,7 @@ 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 keyed
router actually mounted both controllers rather than one.

## Three gates, pinned at compile time
## Five gates, pinned at compile time

`needs-gate.test-d.ts` is type-checked, never executed. It pins the two
directions of `start`'s own gate and di's, side by side:
Expand All @@ -368,7 +504,9 @@ const _missingRuntime = start(RuntimelessApi, options);

`RuntimelessApi` is the same list of slices without `http(...)`: `start`'s phantom
rest tuple becomes a required argument naming the absence, and the call fails
on arity.
on arity. It provides `bearerAuthenticator` even so, deliberately: the contract
marks `orders`, so a graph carrying the router without an authenticator has an
unmet need too, and an arm that could fail either way pins neither gate.

```ts
const RouterlessApi = Module("RouterlessApi")({
Expand Down Expand Up @@ -398,8 +536,46 @@ and `UnloggedApi` — runtime and router present, `observability()` imported so
the port exists in the graph, `Logger` simply not exported — is
rejected by the unit arm alone.

The last two are the authenticator's, and they are different gates on purpose:

```ts
const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({
router: orderRouter,
imports: [OrdersSlice, CustomersSlice, observability()],
exports: [Logger],
});

// @ts-expect-error — the composition needs the authenticator port and nothing provides it.
const _missingAuthenticator = start(UnauthenticatedApi, options);
```

That is **di's** gate again, at `start` and not at `HttpModule(...)` — which is
why the module above builds without complaint. The other one cannot be di's at
all: `AuthenticatorPort`'s service type is erased to `unknown`, so any
authenticator discharges the need. `HttpModule` compares the router's identity
against the authenticator's itself, at the option:

```ts
const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], {
sync: () => () => OkAsync({ sub: "s-1" }),
});

const _mismatchedApi = HttpModule("MismatchedApi")({
router: orderRouter,
// @ts-expect-error — the authenticator resolves `{ sub }`, not the router's Identity.
authenticator: wrongAuthenticator,
imports: [OrdersSlice, CustomersSlice, observability()],
exports: [Logger],
});
```

The directive sits on the option rather than on a `start` below it, because
that is where the failure is. The contract declares no principal to compare
against; `auth.ts` is what declares one.

## Where to go next

- The same `DuplicateOrder`, orchestrated: [Order Temporal worker](/examples/order-temporal-worker).
- The marker, `auth.ts` and the authenticator as a recipe: [Protect a procedure](/how-to/protect-a-procedure).
- The package behind the transport: [`@btravstack/http`](/reference/http).
- Why the kernel appears in none of this: [The kernel maps nothing](/explanation/the-kernel-maps-nothing).
7 changes: 4 additions & 3 deletions docs/explanation/the-kernel-maps-nothing.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,13 @@ them answers it in the kernel.
oRPC router and declines to map anything itself. The router's procedures are
`Result`-returning functions typed by the contract, and the one place a domain
error becomes a status is the `mapErrCases` in each procedure. From
[`examples/order-api`](/examples/order-api):
[`examples/order-api`](/examples/order-api), whose `orders` fragment is
`authenticated`, so the tenant comes off the principal rather than the input:

```ts
place: ({ errors }, input) =>
place: ({ errors, context }, input) =>
place
.execute(input.id, input.quantity)
.execute(context.principal.tenantId, input.id, input.quantity)
.map(view)
.mapErrCases((matcher) =>
matcher
Expand Down
Loading
Loading