diff --git a/CLAUDE.md b/CLAUDE.md index f469169..b03e23a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index e1c2efe..e199e3c 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -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) @@ -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"; @@ -65,7 +66,7 @@ const customersContract = { }; export const contract = { - orders: ordersContract, + orders: authenticated(ordersContract), customers: customersContract, }; ``` @@ -84,6 +85,103 @@ 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() — 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(); + +export const HttpController: HttpControllerOf = + identity.HttpController; +export const HttpRouter: HttpRouterOf = identity.HttpRouter; +export const HttpAuthenticator: HttpAuthenticatorOf = + 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` 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 }); + }, +}); +``` + +`Bearer :` 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//` — 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: @@ -91,7 +189,7 @@ 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 @@ -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 @@ -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) => @@ -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 @@ -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 @@ -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 @@ -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 :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` @@ -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: @@ -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")({ @@ -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). diff --git a/docs/explanation/the-kernel-maps-nothing.md b/docs/explanation/the-kernel-maps-nothing.md index 4dbf2ba..cdcb1a4 100644 --- a/docs/explanation/the-kernel-maps-nothing.md +++ b/docs/explanation/the-kernel-maps-nothing.md @@ -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 diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index 62c0a98..d7aaf2e 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -25,10 +25,12 @@ the real two-slice deployment this recipe scales into, see ## Recipe 1. Declare the contract with `@orpc/contract` — inputs, outputs and the - `.errors({...})` a client may branch on. + `.errors({...})` a client may branch on, marked `authenticated` where a + caller must be known. 2. Implement it with `HttpRouter(contract)(deps, { sync })`: a record shaped like the contract, each leaf a `Result`-returning function. -3. Compose with `HttpModule(name)({ router, imports, provides, exports })`. +3. Compose with + `HttpModule(name)({ router, authenticator, imports, provides, exports })`. 4. `await runMain(OrdersApi)` in `main.ts`. ## Step 1 — the contract @@ -37,6 +39,7 @@ The contract lives in its own package, because a client needs it and needs none of the server: ```ts +import { authenticated } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; @@ -46,7 +49,7 @@ export type OrderView = z.infer; const orderRef = z.object({ id: z.string() }); export type OrderRef = z.infer; -export const ordersContract = { +export const ordersContract = authenticated({ place: oc .input(z.object({ id: z.string(), quantity: z.number() })) .output(orderView) @@ -58,7 +61,7 @@ export const ordersContract = { .input(orderRef) .output(orderView) .errors({ NOT_FOUND: { data: orderRef } }), -}; +}); ``` The shapes are **schemas**, and the types are inferred from them rather than @@ -67,6 +70,13 @@ what the compiler believes cannot drift. oRPC's `type()` would declare the same types and validate nothing — `{ quantity: "abc" }` would reach `place` typed `number`. +[`authenticated`](/reference/contract) marks the whole record, so every +procedure under it needs a known caller — and neither input names a tenant, +because the caller's own identity is what establishes it. Drop the marker and +this is a public API; the rest of the page is unchanged either way, except that +the handlers then have no `context.principal` to read and the root needs no +authenticator. + ## Step 2 — the router, as a provider `HttpRouter(ordersContract)` is di's own `Provider(port)` on the starter's @@ -79,9 +89,10 @@ wildcard, so a new domain error is a compile error here: import { ordersContract, type OrderView } from "./contract.js"; import { FindOrder, PlaceOrder } from "@btravstack/example-order-application"; import type { Order } from "@btravstack/example-order-domain"; -import { HttpRouter } from "@btravstack/http"; import { P } from "unthrown"; +import { HttpRouter } from "./auth.js"; + const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quantity, @@ -91,9 +102,9 @@ export const ordersRouter = HttpRouter(ordersContract)( [PlaceOrder, FindOrder], { sync: (place, find) => ({ - 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 @@ -110,9 +121,9 @@ export const ordersRouter = HttpRouter(ordersContract)( }), ), ), - 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) => @@ -132,7 +143,17 @@ Each leaf is the `.result()` handler `@unthrown/orpc` gives an implementer: (the client sees `code: "CONFLICT"` as a value), and a `Defect` rethrows onto oRPC's own path, where it collapses to `INTERNAL_SERVER_ERROR`. `implement`, `os.…`, `.result(...)` and `os.router(...)` are what the call does for you. -oRPC's context stays empty: what a procedure needs, the provider declared. +oRPC's context carries **one** thing, and only under a marked procedure: the +`principal` the authenticator resolved. Everything else a procedure needs, the +provider declared. + +`HttpRouter` is imported from the application's own `auth.ts` — the file where +`httpAuth()` states what this deployment knows about a caller — which +is what gives `context.principal` a readable type here. The package's own +top-level `HttpRouter` names no identity and types it `never`, so every read is +a compile error: the signal to use the factory, not a fallback. See +[Protect a procedure](/how-to/protect-a-procedure) for that file and the +authenticator below. ## Step 3 — the composition root @@ -142,18 +163,20 @@ import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; +import { bearerAuthenticator } from "./authenticator.js"; import { ordersRouter } from "./router.js"; export const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, + authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], }); ``` `HttpModule` is `Module(name)({...})` plus `router`: it imports the starter -(`http()`), provides the router and exports `HttpRuntime`, and returns -exactly the module the hand-written form would: +(`http()`), provides the router and the authenticator, and exports +`HttpRuntime`, and returns exactly the module the hand-written form would: ```ts Module("OrdersApi")({ @@ -163,22 +186,31 @@ Module("OrdersApi")({ observability(), http(), ], - provides: [ordersRouter], + provides: [ordersRouter, bearerAuthenticator], exports: [HttpRuntime, Logger], }); ``` +The authenticator sits at the **root**, not beside the router: who a caller is +is one answer per process. It is required here because the contract marks the +fragment — a marked router carries `AuthenticatorPort` as a dependency, so +omitting the line is di's own `UNSATISFIED DEPENDENCIES` at `start`, and +supplying one minted on a different identity is a compile error at this very +call. + [`observability()`](/reference/observability) is the other starter here: it brings the `Logger` the use cases and the request scope write to, bound from `LOG_LEVEL`, one JSON object per line on stdout, every line carrying the trace id of the unit `http()` opened around the request. It is exported because the per-request `RequestModule` reads it. -Two gates hold at compile time. A root that forgets the starter exports no -runtime port and `start` fails on arity (`NO RUNTIME`). A root that imports -`http()` without providing the router carries an unmet need — the starter's -runtime provider depends on its router port through di — and `start` refuses -the module. +Three gates hold at compile time, now that the contract is marked. A root that +forgets the starter exports no runtime port and `start` fails on arity +(`NO RUNTIME`). A root that imports `http()` without providing the router +carries an unmet need — the starter's runtime provider depends on its router +port through di — and `start` refuses the module. And a root serving a **marked** +contract without an authenticator carries `AuthenticatorPort` as a second unmet +need, refused the same way; drop the marker and that third gate goes with it. ## Step 4 — `main.ts` @@ -213,12 +245,13 @@ stream rather than the default JSON on stderr; see `HttpModule(name)({...})` takes `imports`, `provides`, `exports` and: -| Option | Default | What it does | -| ---------- | ------- | ------------------------------------------------------- | -| `router` | — | the router **provider** `HttpRouter` returned; required | -| `prefix` | `/rpc` | where the RPC endpoint is mounted | -| `port` | `PORT` | pins the port instead of reading the variable | -| `hostname` | `HOST` | pins the host instead of reading the variable | +| Option | Default | What it does | +| --------------- | ------- | ---------------------------------------------------------------- | +| `router` | — | the router **provider** `HttpRouter` returned; required | +| `authenticator` | — | resolves the principal; required when the contract marks a route | +| `prefix` | `/rpc` | where the RPC endpoint is mounted | +| `port` | `PORT` | pins the port instead of reading the variable | +| `hostname` | `HOST` | pins the host instead of reading the variable | `http({ prefix?, port?, hostname? })` takes the last three; the router is not an option but the module's need, provided by the root. Pinning is per field — @@ -275,6 +308,8 @@ under the request already carries it — see ## See also - [`@btravstack/http`](/reference/http) — options, `HttpConfig`, `HttpInfo`, the guarantee. +- [Protect a procedure](/how-to/protect-a-procedure) — the marker, `auth.ts` + and the authenticator this page uses, in full. - [Order API (HTTP)](/examples/order-api) — the real deployment this recipe scales into, two slices composed through controllers, client half included. - [Open a per-request scope](/how-to/open-a-per-request-scope) — the `RequestModule` in `main.ts`. diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index 37dccd3..bd74661 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -25,6 +25,7 @@ positional form already takes, just smaller — and the root contract is a record of them: ```ts +import { authenticated } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; @@ -37,6 +38,11 @@ export type OrderRef = z.infer; const customerView = z.object({ id: z.string(), name: z.string() }); export type CustomerView = z.infer; +// The same shape as `orderRef` and deliberately its own schema: sharing that +// one would type a customer id as "which order it was about". +const customerRef = z.object({ id: z.string() }); +export type CustomerRef = z.infer; + const ordersContract = { place: oc .input(z.object({ id: z.string(), quantity: z.number() })) @@ -53,13 +59,13 @@ const ordersContract = { const customersContract = { find: oc - .input(z.object({ id: z.string() })) + .input(z.object({ tenantId: z.string(), id: z.string() })) .output(customerView) - .errors({ NOT_FOUND: { data: orderRef } }), + .errors({ NOT_FOUND: { data: customerRef } }), }; export const contract = { - orders: ordersContract, + orders: authenticated(ordersContract), customers: customersContract, }; ``` @@ -71,6 +77,13 @@ fragment is made of, not a bare `type()`: it validates what arrives at the slice, and inferring the view type from it keeps the checked shape and the compiled one from drifting apart. +The two fragments differ in one more way, and it is worth reading as part of +the split: `orders` is [`authenticated`](/reference/contract) and names no +tenant on its inputs, because a caller's own identity establishes it; the +unmarked `customers` names one, because "which tenant" is then part of what is +being asked. A marker is per fragment, so slicing a contract is also where a +public half and a protected one stop being one undifferentiated surface. + ## Step 2 — a controller per slice `HttpController(name, fragment)([deps], { sync })` is `HttpRouter`'s own @@ -80,14 +93,16 @@ so `sync`'s return is typed by the fragment at the call — a typo'd or missing procedure is a compile error inside the controller itself, not at the root: ```ts +import { HttpController } from "../../auth.js"; + export const ordersController = HttpController( "OrdersController", contract.orders, )([PlaceOrder, FindOrder], { sync: (place, find) => ({ - 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 @@ -104,9 +119,9 @@ export const ordersController = HttpController( }), ), ), - 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) => @@ -120,6 +135,14 @@ export const ordersController = HttpController( }); ``` +`HttpController` comes from the application's own `auth.ts`, not from +`@btravstack/http`: the marker on the fragment says the route is protected, and +`httpAuth()` in that one file is what says what a principal is, so +`context.principal` has a readable type here. Reached through the package's own +top-level `HttpController` it would be `never`, and every read a compile error. +The unmarked `customers` controller is unaffected either way — its context has +no `principal` at all. See [Protect a procedure](/how-to/protect-a-procedure). + The controller does no oRPC work of its own — it stores a plain record, and `HttpRouter` wraps each leaf in `.result(...)` when it composes the router. `HttpController` mints the port and carries it back on `.port`, which the @@ -167,6 +190,7 @@ owns: ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, + authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); @@ -174,7 +198,11 @@ export const OrderApi = HttpModule("OrderApi")({ `observability()` is here because every slice's layers write to its `Logger` and none of them owns it; `Logger` is exported because the per-request module -reads it. Nothing else about what a slice needs is spelled at the root. +reads it. The `authenticator` is here for the same kind of reason and a +stronger one: who a caller is is one answer per process, not a slice's +question. It is required because a marked fragment made it a dependency of the +router provider, so omitting it is di's own `UNSATISFIED DEPENDENCIES` at +`start`. Nothing else about what a slice needs is spelled at the root. This form is **exact**: a key the record above is missing, a key the contract does not declare, and a controller wired under the wrong key are all @@ -198,10 +226,15 @@ export const ordersRouter = HttpRouter(contract.orders)( export const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, + authenticator: bearerAuthenticator, imports: [OrdersSlice, observability()], }); ``` +`HttpRouter` here is `auth.ts`'s too — the lifted fragment carries its marker, +so the lifted root needs the same authenticator the modulith did, and that is +the only line about identity extraction adds. + `OrdersSlice` is the very module the modulith imported and `ordersController` the very provider it composed — not a copy, not a rewritten `sync`. Extraction is a new composition root and one fewer import, and the slice itself is @@ -215,5 +248,7 @@ composing slices into one router a starting point rather than a trap. positional form, and everything the starter itself decides. - [`@btravstack/http`](/reference/http) — `HttpController` and `HttpRouter`'s full signatures. +- [Protect a procedure](/how-to/protect-a-procedure) — `auth.ts`, the + authenticator, and what a marked fragment does to a controller. - [Order API (HTTP)](/examples/order-api) — the two-slice example these samples come from. diff --git a/docs/index.md b/docs/index.md index 35ff017..28c8073 100644 --- a/docs/index.md +++ b/docs/index.md @@ -32,19 +32,22 @@ features: ## At a glance `examples/order-api`'s **orders slice**, served on its own and condensed to one -procedure: a contract, a router that is a provider, a composition root, and one -call. The example itself composes that slice and a `customers` one into a -single router through +procedure: a contract, a router that is a provider, an authenticator, a +composition root, and one call. The example itself composes that slice and a +`customers` one into a single router through [controllers](/how-to/split-a-router-into-controllers). ```ts +import { authenticated } from "@btravstack/contract"; import { runMain } from "@btravstack/core"; -import { HttpModule, HttpRouter } from "@btravstack/http"; +import { HttpModule } from "@btravstack/http"; import { oc } from "@orpc/contract"; import { P } from "unthrown"; import { z } from "zod"; import { OrderApplicationModule, PlaceOrder } from "./application.js"; +import { HttpRouter } from "./auth.js"; +import { bearerAuthenticator } from "./authenticator.js"; import { OrderPersistenceModule } from "./persistence.js"; // The contract comes first; a client can take it without the server. @@ -52,7 +55,9 @@ import { OrderPersistenceModule } from "./persistence.js"; const orderView = z.object({ id: z.string(), quantity: z.number() }); const orderRef = z.object({ id: z.string() }); -const ordersContract = { +// `authenticated` marks the fragment. It names no tenant on the input: a +// caller does not get to pick the tenant it is served. +const ordersContract = authenticated({ place: oc .input(z.object({ id: z.string(), quantity: z.number() })) .output(orderView) @@ -60,15 +65,15 @@ const ordersContract = { INVALID_QUANTITY: { data: orderRef }, CONFLICT: { data: orderRef }, }), -}; +}); // The router is a provider: it declares the use case its procedure calls. // Every domain error is named here — the one place a Result becomes HTTP. const ordersRouter = HttpRouter(ordersContract)([PlaceOrder], { sync: (place) => ({ - place: ({ errors }, input) => + place: ({ errors, context }, input) => place - .execute(input.id, input.quantity) + .execute(context.principal.tenantId, input.id, input.quantity) .map((order) => ({ id: order.id, quantity: order.quantity })) .mapErrCases((matcher) => matcher @@ -91,6 +96,7 @@ const ordersRouter = HttpRouter(ordersContract)([PlaceOrder], { // The composition root. The runtime is a service of this module. const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, + authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule], }); @@ -105,6 +111,13 @@ builds the graph, resolves that port and drives what it finds. `PORT` and configuration provider — nothing in `main.ts` touches `process.env`, and a malformed value is a `startFailed` event and exit code `78`. +**The contract says _whether_ a route is protected; the application says _what_ +the principal is.** `authenticated` is the fact a client reads off the +contract; `httpAuth()` in `auth.ts` is what mints the `HttpRouter` +above, so `context.principal` is typed where the handler is written; and the +authenticator resolves it once per request, at the root. See +[Protect a procedure](/how-to/protect-a-procedure). + **SIGTERM drains in three beats.** Readiness flips false; the kernel waits for Kubernetes to stop routing to the pod _before_ telling the runtime to stop accepting; then in-flight requests get a deadline, and whatever is still open diff --git a/docs/reference/http.md b/docs/reference/http.md index 83406b2..e33bddf 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -115,16 +115,19 @@ di's duplicate-provider defect at build. Returns test. The implementation below is the one in `examples/order-api/src/slices/orders/controller.ts`, served through the positional form — the example composes it as a controller instead (see the -keyed form), and a fragment is a contract, so the same `sync` reads either way: +keyed form), and a fragment is a contract, so the same `sync` reads either way. +`contract.orders` is `authenticated`, so `HttpRouter` here is the application's +own — `httpAuth()`'s, from its `src/auth.ts` — and the tenant comes +off `context.principal` rather than off the input: ```ts export const ordersRouter = HttpRouter(contract.orders)( [PlaceOrder, FindOrder], { sync: (place, find) => ({ - 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 @@ -141,9 +144,9 @@ export const ordersRouter = HttpRouter(contract.orders)( }), ), ), - 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) => diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts new file mode 100644 index 0000000..ae11276 --- /dev/null +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -0,0 +1,204 @@ +// The HTTP code samples the documentation site ships for this deployment, +// compiled. A sample that stops compiling fails `pnpm typecheck`. Each section +// names the page it mirrors. +// +// This file exists because nothing gated those samples, and they drifted: six +// pages went on calling `PlaceOrder.execute(input.id, input.quantity)` after +// the use cases grew a leading `tenantId` and the `orders` fragment became +// `authenticated`, passing an order id where a tenant goes. Every call below +// is therefore made against the REAL `contract`, the REAL `PlaceOrder` / +// `FindOrder` / `FindCustomer` and the application's own `auth.ts` — a stub +// would have accepted every one of those broken calls, which is exactly how +// the drift survived. +// +// What it does NOT cover: the pages' own contract declarations. `zod` and +// `@btravstack/contract` are `@btravstack/example-order-api-contract`'s +// dependencies, not this workspace's, so the fragments are compiled where they +// live. The controllers below are typed by that contract, so a marker removed +// from it still fails here. + +import { Module } from "@btravstack/di"; +import { + contract, + type CustomerView, + type OrderView, +} from "@btravstack/example-order-api-contract"; +import { + CustomerApplicationModule, + FindCustomer, + FindOrder, + OrderApplicationModule, + PlaceOrder, +} from "@btravstack/example-order-application"; +import type { Customer, Order } from "@btravstack/example-order-domain"; +import { + CustomerPersistenceModule, + OrderPersistenceModule, +} from "@btravstack/example-order-infrastructure"; +import { HttpModule } from "@btravstack/http"; +import { Logger, observability } from "@btravstack/observability"; +import { P } from "unthrown"; + +import { HttpController, HttpRouter } from "./auth.js"; +import { bearerAuthenticator } from "./authenticator.js"; + +const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quantity }); + +const customerViewOf = (customer: Customer): CustomerView => ({ + id: customer.id, + name: customer.name, +}); + +// --------------------------------------------------------------------------- +// "Step 2 — a controller per slice" — docs/how-to/split-a-router-into-controllers.md; +// "The slices" — docs/examples/order-api.md; "The kernel maps nothing"'s +// `place` fragment — docs/explanation/the-kernel-maps-nothing.md. +// +// `HttpController` is `./auth.ts`'s, not `@btravstack/http`'s: reached through +// the package's own, a marked fragment types `principal: never` and every read +// below is a compile error. That substitution is half of what these pages +// were getting wrong, so it is pinned by the import rather than asserted. +// --------------------------------------------------------------------------- + +const ordersController = HttpController("DocsOrdersController", contract.orders)( + [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 + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ 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) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), + ), + ), + }), + }, +); + +// The unmarked half, and the contrast every page draws: no `principal` on the +// context at all, the tenant off the input instead. +const customersController = HttpController("DocsCustomersController", contract.customers)( + [FindCustomer], + { + sync: (find) => ({ + find: ({ errors }, input) => + find + .execute(input.tenantId, input.id) + .map(customerViewOf) + .mapErrCases((matcher) => + matcher.with(P.tag("CustomerNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), + ), + ), + }), + }, +); + +const DocsOrdersSlice = Module("DocsOrdersSlice")({ + imports: [OrderApplicationModule, OrderPersistenceModule], + provides: [ordersController], + exports: [ordersController], +}); + +const DocsCustomersSlice = Module("DocsCustomersSlice")({ + imports: [CustomerApplicationModule, CustomerPersistenceModule], + provides: [customersController], + exports: [customersController], +}); + +// --------------------------------------------------------------------------- +// "Step 3 — the keyed root" — docs/how-to/split-a-router-into-controllers.md; +// "The router" and "The composition root" — docs/examples/order-api.md. +// --------------------------------------------------------------------------- + +const docsRouter = HttpRouter(contract)({ + orders: ordersController, + customers: customersController, +}); + +const _DocsOrderApi = HttpModule("DocsOrderApi")({ + router: docsRouter, + authenticator: bearerAuthenticator, + imports: [DocsOrdersSlice, DocsCustomersSlice, observability()], + exports: [Logger], +}); + +// --------------------------------------------------------------------------- +// "Step 4 — lifting a slice into its own process" — +// docs/how-to/split-a-router-into-controllers.md; the same call quoted in +// docs/examples/order-api.md and docs/reference/http.md. +// +// The do-not-break property: the slice, its module and its controller are the +// very ones composed above — a new composition root and one fewer import, not +// a rewrite. The lifted fragment carries its marker, so the lifted root needs +// the same authenticator. +// --------------------------------------------------------------------------- + +const liftedOrdersRouter = HttpRouter(contract.orders)([ordersController.port], { + sync: (implementation) => implementation, +}); + +const _DocsOrdersApi = HttpModule("DocsOrdersApi")({ + router: liftedOrdersRouter, + authenticator: bearerAuthenticator, + imports: [DocsOrdersSlice, observability()], +}); + +// --------------------------------------------------------------------------- +// "Step 2 — the router, as a provider" — docs/how-to/serve-orpc-over-http.md; +// "`HttpRouter(contract)(deps, arm)`" — docs/reference/http.md; "At a glance" — +// docs/index.md. The positional form over the same marked fragment, with no +// controller layer: the three pages that show a router rather than a +// controller all reduce to this call. +// --------------------------------------------------------------------------- + +const positionalOrdersRouter = HttpRouter(contract.orders)([PlaceOrder, FindOrder], { + sync: (place, find) => ({ + place: ({ errors, context }, input) => + place + .execute(context.principal.tenantId, input.id, input.quantity) + .map(view) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ 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) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), + ), + ), + }), +}); + +const _DocsPositionalApi = HttpModule("DocsPositionalApi")({ + router: positionalOrdersRouter, + authenticator: bearerAuthenticator, + imports: [OrderApplicationModule, OrderPersistenceModule, observability()], + exports: [Logger], +});