diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md new file mode 100644 index 0000000..5be3199 --- /dev/null +++ b/.changeset/authenticated-contracts.md @@ -0,0 +1,39 @@ +--- +"@btravstack/contract": minor +"@btravstack/http": minor +--- + +Let a contract declare that a procedure requires an authenticated caller, and +give `@btravstack/http` what it needs to satisfy that declaration. + +**The contract says whether a route is protected; the application's +`httpAuth()` says what the principal is.** + +`@btravstack/contract` is a new zero-dependency package holding the marker +itself: `authenticated(node)`, one export with no factory and no type +parameter, applied to a finished procedure or to a whole record of them. It +names no identity type at all, so nothing about a server's view of a caller +reaches a client. It returns the node unchanged — the marker lives in a +`WeakSet` and a phantom type key set to `true` — so a client can import a +marked contract without pulling in anything that implements it. `IsMarked` +answers the yes/no at the type level, `isAuthenticated(node)` at runtime. An +unmarked procedure is public; the marker makes the requirement legible in the +contract rather than detecting one that was forgotten. + +`@btravstack/http` resolves the principal through a new `Authenticator` port — +`HttpAuthenticator

()([deps], { sync })`, an ordinary di provider, wired on +`HttpModule`'s `authenticator` option. A contract that marks nothing needs no +authenticator; a marked router whose root provides none is di's existing +`UNSATISFIED DEPENDENCIES` gate, and an authenticator minted on a different +identity than the router is refused at `HttpModule`. A marked procedure whose +authenticator declines is answered `UNAUTHORIZED` before dispatch, with the +handler never running and no reason reaching the caller — `Unauthenticated` +carries none, so an authenticator logs why before returning. + +`http()` and `HttpModule` also gain `plugins`, forwarding oRPC handler plugins +(CORS, body limits, compression, CSRF) straight to `RPCHandler`, and +`securityHeaders`, applied on the node listener rather than as a plugin so the +runtime's own `404` is covered too. `plugins` is an honest escape hatch rather +than a keyhole — an oRPC plugin's `init` can reach the handler's interceptors — +but the ordinary path is configuration visible at the composition root, not a +middleware slot for application logic. diff --git a/.changeset/config.json b/.changeset/config.json index 9365261..19ac914 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -8,6 +8,7 @@ "ignore": [], "fixed": [ [ + "@btravstack/contract", "@btravstack/di", "@btravstack/config", "@btravstack/core", diff --git a/.changeset/server-side-identity.md b/.changeset/server-side-identity.md new file mode 100644 index 0000000..35c0c23 --- /dev/null +++ b/.changeset/server-side-identity.md @@ -0,0 +1,35 @@ +--- +"@btravstack/http": minor +--- + +Let a deployment state what its principal actually is, server-side, with +`httpAuth()`. + +The contract says **whether** a route is protected and names no identity type +at all. `httpAuth()` is what says **what** the principal is: it mints +`HttpController`, `HttpRouter` and `HttpAuthenticator` together, all fixed to +that identity. Written once per application, because a handler's parameter +types are fixed where the arrow is written and a composition root cannot +re-type a `sync` callback living in another module; every slice then imports +`HttpController` from that one file and its marked handlers see `Identity` on +`context.principal` with no annotation of their own. The authenticator and the +controllers cannot disagree, since both come from the same call, and it is +handed back already applied (`HttpAuthenticator([deps], { sync })`). + +It is also the only way a handler gets a readable principal: `HttpController` +and `HttpRouter` imported from the package itself name no identity, so a marked +fragment reached through them types `principal: never` and every read is a +compile error — the signal to use the factory, not a fallback. The contract +still decides _whether_: an unmarked procedure's context carries no principal, +factory or not. + +`HttpModule`'s gate compares the **router's** identity against the +**authenticator's** — `AuthIdentity extends RouterIdentity`, so an +authenticator resolving more than the handlers read discharges it, while one +minted by a different `httpAuth` call does not. `ContractPrincipal` is replaced +by `HasMark`, exactly `true` or `false`, which is all the conditional +authenticator dependency ever needed. + +Also exported: `HttpAuth` and the three `HttpControllerOf` / +`HttpRouterOf` / `HttpAuthenticatorOf` aliases, which a file exporting what the +factory returns needs to annotate with. diff --git a/.gitignore b/.gitignore index ed0d730..a82d823 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,8 @@ docs/superpowers/ docs/.vitepress/cache/ docs/.vitepress/dist/ docs/api/*/ + +# A leftover from Prisma's SQLite era: `prisma migrate` writes a shadow +# database next to the schema, and this example's was committed by accident. +# The suites run against the shared PostgreSQL container, so nothing reads it. +examples/order-infrastructure/.migrate.db diff --git a/CLAUDE.md b/CLAUDE.md index ad28622..f469169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,18 +19,22 @@ already-proven graph is constructed and torn down, and nothing more. Nothing throws to callers: every fallible operation returns an [`unthrown`](https://github.com/btravstack/unthrown) `Result`. -pnpm workspace + turbo monorepo. `packages/` holds eight published packages, -`di` (the container), `config` (configuration from the environment, as +pnpm workspace + turbo monorepo. `packages/` holds nine published packages, +`contract` (contract-level markers shared by a client and the server that +implements it — zero dependencies, zero peers), `di` (the container), `config` +(configuration from the environment, as providers), `core` (the kernel), `testing` (the test harness — `bootFixture`, `tapped`, the in-memory runtime, the fake clock; peers on `core`), `observability` (the logging starter — a `Logger` port correlated with the ambient unit, a JSON sink, the kernel's events as lines), `http` (the HTTP starter — oRPC), `temporal` (the Temporal starter) and `amqp` (the AMQP starter). `di` was its own repository until it was merged here -**with its history**; it is the one package that depends on nothing else in +**with its history**; it and `contract` are the two packages that depend on +nothing else in this workspace, and the dependencies run `core` → `config` → `di`, never back, with `testing`, `observability` and the three transport starters on -`core`. Its own spec is `packages/di/CLAUDE.md`; the harness's is +`core`. Its own spec is `packages/di/CLAUDE.md`; `contract`'s is +`packages/contract/CLAUDE.md`; the harness's is `packages/testing/CLAUDE.md`; the logging starter's is `packages/observability/CLAUDE.md`. `examples/` holds ten private ones — a clean-architecture application @@ -64,9 +68,9 @@ pnpm build # tsdown dual CJS/ESM + d.ts Commits follow Conventional Commits (commitlint via a lefthook `commit-msg` hook). User-facing changes need a changeset. -## Versioning: all eight packages move as one +## Versioning: all nine packages move as one -The eight published packages share **one version number**, enforced by a +The nine published packages share **one version number**, enforced by a `fixed` group in `.changeset/config.json`. A release bumps every one of them, whether or not it changed — Spring Boot's model, and the reason is the same: an application installs a kernel and two or three starters together, and @@ -176,6 +180,24 @@ major. convention this stack has not established — so today it is a documented convention with no enforcement. Do not describe it as enforced. + **A transaction is not on the record either, and that is the decision, not + an omission.** Commit boundaries belong to the **adapter**, spelled + explicitly at the call — `examples/order-infrastructure`'s + `prismaOrderRepository` already does exactly this: `save` writes the order + row and its outbox row inside one `db.$tryTransaction`, and `remove` does + the same for the tombstone, with `@unthrown/prisma` supplying the + primitive. Nothing is hand-rolling a missing framework feature there. + Cross-store atomicity is the **outbox** plus a **saga**, which is what the + three examples are built on. Three reasons a unit-scoped transaction is + the wrong shape: it makes every request an **interactive** transaction, + which Prisma's own documentation says to reach for last; the unit does not + close until the response is **flushed** (the first contract a runtime + owes), so a pooled connection would stay pinned while bytes go to the + client; and a **port does not say where its data lives**, so a boundary + drawn around a unit spans stores the framework cannot see inside — a + promise it has no way to keep. Nested and joined transactions follow from + this: not supported, and not a framework concept. + 3. **The kernel never maps an outcome to a transport.** `Result` → HTTP status belongs to the router an application hands `@btravstack/http` (oRPC's `.result()` triage) — the package itself declines that mapping, @@ -334,6 +356,49 @@ its work (a response's `'close'`) must first check whether it already fired: found by a client hanging up during a slow per-request acquire and leaving a unit open for the process lifetime. +## Cross-cutting concerns: configuration, not a middleware slot + +CORS, body limits, compression, CSRF, security headers and authentication all +arrive at the same door, and the answer is the same for all of them: **they are +handler configuration, not a middleware slot.** Thesis #3's refusal survives +intact, narrowed to what it was always about. An oRPC plugin and the starter's +own `principalMiddleware` act on the **request/response envelope** — bytes, +headers, a principal resolved before dispatch. An application middleware would +act on the handler's **`Result`**, and that is the only one `@btravstack/http` +refuses, because it is the one that would put a use case's outcome in the +transport's hands. + +- **`plugins` is an honest escape hatch, not a keyhole.** It forwards straight + to `new RPCHandler(service, { plugins })`, and an oRPC plugin can reach + oRPC's interceptors — so an application determined to see a procedure's + outcome can get there. Nothing pretends otherwise. What the option buys is + that the ordinary path is configuration a reader can see at the composition + root, and reaching past it is a visible act rather than the default shape. +- **Security headers are set on the listener, not as a plugin.** A plugin only + runs for a request oRPC **matched**, so the runtime's own `404` would go out + bare — the opposite of what helmet-style headers are for. +- **Rate limiting is a stated non-goal.** A per-process counter is the wrong + unit: an `api` deployment is N pods (thesis #1), so a per-process budget is + N independent budgets and none of them is the limit anybody meant. The + ingress or gateway is where a request count is counted once. An application + that wants one anyway writes a plugin and passes it through `plugins` — + which is the escape hatch doing its job, not a gap. +- **An unmarked procedure is public, and nothing fails if the marker is + forgotten.** `@btravstack/contract`'s marker makes the requirement + **legible** in the contract and makes the principal's type reach the + handler; it does not detect a procedure that should have been marked. There + is no gate for "you forgot", and there cannot be one — the contract is the + only statement of intent there is. Do not describe an unmarked procedure as + checked. +- **Authorization is deliberately not in the contract.** "May this caller do + this?" often depends on the resource — the order's owner, its state, the + row's tenant — which cannot be answered before the handler has run and + fetched it. Putting the caller-shaped half in the contract and leaving the + resource-shaped half in the handler splits one rule across two files, and + the half in the contract is the half that looks complete. Authentication — + "is there a principal, and what is it?" — is answerable before dispatch, and + is the only half the contract carries. + ## Public surface Each package's surface is stated **once**, in that package's own `CLAUDE.md`, @@ -346,6 +411,7 @@ the copy with no gate is the one that lies. | Package | Surface lives in | Reference page | | --------------------------- | ---------------------------------- | -------------------------- | +| `@btravstack/contract` | `packages/contract/CLAUDE.md` | `/reference/contract` | | `@btravstack/di` | `packages/di/CLAUDE.md` | `/reference/di/` | | `@btravstack/config` | `packages/config/CLAUDE.md` | `/reference/config` | | `@btravstack/core` | `packages/core/CLAUDE.md` | `/reference/core/` | @@ -397,11 +463,24 @@ type checker already verifies. `tsconfig.test-d.json` or `test:types` script, before it. `packages/http/src/controller.test-d.ts` pins the five compile-time gates the keyed `HttpRouter(contract)(controllers)` form - owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 26 specs, across - `http-runtime.spec.ts`, `orpc.spec.ts` and `controller.spec.ts`, drive the + owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 40 specs, across + `http-runtime.spec.ts`, `orpc.spec.ts`, `controller.spec.ts` and + `auth.spec.ts`, drive the transport through the internal `httpModule` with a bare listener, the - starter proper through `HttpModule`, and the keyed router form through the - `rpcSliced` fixture. + starter proper through `HttpModule`, the keyed router form through the + `rpcSliced` fixture, and the contract marker's runtime half — the + authenticator port and the one middleware it installs — through + `rpcAuthed`. **The contract says WHETHER a route is protected; the + application's `httpAuth()` says WHAT the principal is.** + `@btravstack/contract` names no identity type at all — `authenticated` is one + export with no factory and no type parameter — so nothing about a server's + view of a caller reaches a client, and a marked fragment reached through the + top-level `HttpController` types `principal: never`, which makes every read a + compile error and is the signal to use the factory. + `examples/order-api/src/auth.ts` is the one file per application that names an + identity, and `HttpModule`'s gate pairs the **router's** identity with the + **authenticator's** — both from that one call — since there is no + contract-side principal left to compare against. - **The whole gate runs on THREE containers, shared, and `internal/test-infra` owns them.** One `postgres:18.1`, one `rabbitmq:4.2.1-management-alpine` and one `temporalio/auto-setup:1.29.1`, started once per machine and reused by @@ -632,9 +711,13 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `HttpRouterPort` and exports `HttpRuntime`: `OrderApi` is a constant, `PORT`/`HOST` and `DATABASE_URL` come from the environment inside the graph, and the router is mounted under `/rpc`. The - contract declares `tenantId` on every input, so a procedure hands it to the - use case and the use case to the repository — the transport reads nothing - about it. + **unmarked** `customers` fragment declares `tenantId` on its input, so a + procedure hands it to the use case and the use case to the repository; the + **marked** `orders` fragment declares none and its handlers read + `context.principal.tenantId` instead — a caller does not name the tenant it + is served, and a required field the handler ignores would be a confused + deputy in contract form. Either way the transport reads nothing about + tenancy. `observability()` is what provides the `Logger` the interactors and the request scope write to, and `Logger` is in `exports` because `RequestModule` reads it out of the application scope. `RequestModule` rides @@ -656,7 +739,7 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole it without providing `orderRouter` fails di's own gate at `start`, since the starter's runtime provider depends on its router port. - **oRPC is pinned to an exact beta.** `@orpc/{client,contract,server}` sit at - `2.0.0-beta.23` in the catalog because oRPC v2's `latest` dist-tag is still + `2.0.0-beta.28` in the catalog because oRPC v2's `latest` dist-tag is still the **1.x** line, while `@unthrown/orpc` peers on `^2.0.0-beta`: an unpinned range resolves 1.x and fails `strictPeerDependencies`. The exact beta is the contract until v2 goes stable; raise it deliberately, not on a bot bump. @@ -681,7 +764,8 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole a hardcoded `^0.1.0` until the versions went lockstep; a literal range in a peer field is a pin that goes stale silently the first time the dependency is bumped. `di` itself peers on - `unthrown` and depends on nothing; `config` peers on `di` and `unthrown`; + `unthrown` and depends on nothing; `contract` depends on nothing at all, not + even `unthrown`; `config` peers on `di` and `unthrown`; `core` peers on all three; `testing` peers on all four (and not on `vitest` — `bootFixture` is a plain function in vitest's fixture shape); `observability` peers on all four too and has **no runtime dependency of its @@ -707,14 +791,14 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `@btravstack/core#typecheck` an explicit edge on `@btravstack/testing#build`; `knip.json` ignores the dependency for `packages/core`. Four places; a change to one is a change to all. -- `declarationMap: false` on all eight published packages — the published +- `declarationMap: false` on all nine published packages — the published tarball has no `src/`, so maps would be dead ends. - **Relative imports carry `.js`.** `moduleResolution: NodeNext` plus `verbatimModuleSyntax`, both inherited from `@btravstack/tsconfig/base.json` — an external package under `node_modules`, so this is the one convention here the repo itself cannot show you. `import { x } from "./units"` fails `pnpm typecheck` with TS2835. -- All eight published packages claim `engines: { node: ">=20" }` while the root +- All nine published packages claim `engines: { node: ">=20" }` while the root claims `>=22.19`. The divergence is **deliberate**: the root floor is the dev toolchain's, a package's is a compatibility promise to consumers. Do not align them for tidiness — raising a published floor is a breaking change. @@ -785,8 +869,9 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `packages/config/CLAUDE.md`, `packages/testing/CLAUDE.md`, `packages/http/CLAUDE.md`, `packages/temporal/CLAUDE.md` or `packages/amqp/CLAUDE.md`, whichever is where that package's public - surface lives — or `packages/di/CLAUDE.md` for the container. There are - **nine** `CLAUDE.md` files; naming the wrong one is how the last drift + surface lives — or `packages/di/CLAUDE.md` for the container, or + `packages/contract/CLAUDE.md` for the auth marker. There are + **ten** `CLAUDE.md` files; naming the wrong one is how the last drift happened. ## Documentation site @@ -802,12 +887,12 @@ was folded in here when the container was merged; nothing under - **TypeDoc runs from `docs/`, not from the packages** — it needs its own TypeScript (`catalog:typedoc` pins 6.0.3; 7.x is the native port and ships - no `typescript.js`). One `typedoc..json` per package — eight — points + no `typescript.js`). One `typedoc..json` per package — nine — points at that package's `src/index.ts` (core's one entry point; the doubles are `typedoc.testing.json`'s, and `typedoc.observability.json` names two entry points, `src/index.ts` and `src/pino.ts`) and writes straight into `api//` (gitignored; `docs/api/index.md` is the one committed file - there); `scripts/build-api.ts` runs the eight concurrently. + there); `scripts/build-api.ts` runs the nine concurrently. The package list is repeated in four places that must stay in sync: the configs, `build-api.ts`, `@btravstack/docs#build`'s `dependsOn` in `turbo.json` (explicit `#build` edges — the site does not _depend_ on diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 6fddd09..fc5147a 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -25,6 +25,7 @@ const GUIDE_SIDEBAR = [ { text: "Log and correlate", link: "/how-to/log-and-correlate" }, { text: "Serve an oRPC contract over HTTP", link: "/how-to/serve-orpc-over-http" }, { text: "Split a router into controllers", link: "/how-to/split-a-router-into-controllers" }, + { text: "Protect a procedure", link: "/how-to/protect-a-procedure" }, { text: "Split a worker into slices", link: "/how-to/split-a-worker-into-slices" }, { text: "Run a Temporal worker", link: "/how-to/run-a-temporal-worker" }, { text: "Consume AMQP messages", link: "/how-to/consume-amqp-messages" }, @@ -43,6 +44,7 @@ const GUIDE_SIDEBAR = [ text: "Reference", items: [ { text: "Packages and install", link: "/reference/packages" }, + { text: "@btravstack/contract", link: "/reference/contract" }, { text: "@btravstack/di", collapsed: false, @@ -118,6 +120,10 @@ export default defineConfig({ lang: "en-US", cleanUrls: true, + // `docs/superpowers/` holds gitignored working files (plans, specs). VitePress + // scans the whole of `docs/`, so without this it compiles them as pages. + srcExclude: ["superpowers/**"], + // The API reference under /api/ is generated by TypeDoc and copied in at build // time; its cross-references use relative links TypeDoc resolves itself. ignoreDeadLinks: [/^\/api\//, /^\.\/index$/, /^\.\/[a-z-]+$/, /^\.\.\//], @@ -202,6 +208,7 @@ export default defineConfig({ text: "API Reference", items: [ { text: "Overview", link: "/api/" }, + { text: "@btravstack/contract", link: "/api/contract/" }, { text: "@btravstack/di", link: "/api/di/" }, { text: "@btravstack/config", link: "/api/config/" }, { text: "@btravstack/core", link: "/api/core/" }, diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md new file mode 100644 index 0000000..8af5230 --- /dev/null +++ b/docs/how-to/protect-a-procedure.md @@ -0,0 +1,308 @@ +--- +title: Protect a procedure +description: Mark a contract fragment or a procedure with authenticated(), write the Authenticator that resolves a principal from the request headers, and hand it to HttpModule. +--- + +# Protect a procedure + +> **How-to.** Declare in the contract that a procedure needs an authenticated +> caller, resolve that caller once per request, and read it in the handler. For +> the marker's surface, see [`@btravstack/contract`](/reference/contract); for +> the starter's, [`@btravstack/http`](/reference/http); for the worked +> deployment, [Order API (HTTP)](/examples/order-api). + +Three moves, in this order: **mark** the contract, **write** the +`Authenticator`, **pass** it to `HttpModule`. The marker is what makes the +other two type-checked — the router provider grows a dependency on the +authenticator port, and the marked procedures' handlers grow a +`context.principal` typed with the identity the application stated. + +**The contract says _whether_ a route is protected; `httpAuth()` says +_what_ the principal is.** No identity type is named in the contract at all, so +nothing about the server's view of a caller reaches a client. + +## Recipe + +1. Mark the contract with `authenticated`. +2. State the server's own identity once with `httpAuth()`, and write + the authenticator it hands back — headers in, + `AsyncResult` out. +3. Read `opts.context.principal` in the handlers of the marked procedures. +4. Pass the provider as `HttpModule(name)({ router, authenticator })`. + +## Step 1 — mark the contract + +The marker goes in the contract package, because it is a fact about the API +that a client should be able to read without taking the server: + +```ts +import { authenticated } from "@btravstack/contract"; +import { oc, type } from "@orpc/contract"; + +const ordersContract = { + place: oc + .input(type<{ readonly id: string; readonly quantity: number }>()) + .output(type<{ readonly id: string }>()), +}; + +const customersContract = { + find: oc + .input(type<{ readonly id: string }>()) + .output(type<{ readonly name: string }>()), +}; + +export const contract = { + orders: authenticated(ordersContract), // every procedure beneath it + customers: customersContract, // public +}; +``` + +A marked **record** protects every procedure beneath it; a marked **procedure** +protects itself, so `{ find, quote: authenticated(quoteProcedure) }` is a +fragment with one of each. Apply `authenticated` to a **finished** node — the +last call in a builder chain, or a whole record of finished nodes. Applied +mid-chain it is silently dropped, because `oc.router(...)` rebuilds every node. + +The contract stops here. It names no principal, so there is nothing in it to +keep minimal and nothing in it to leak. + +## Step 2 — state the identity, and write the authenticator + +`httpAuth()` is where the principal's type is stated — one file per +application, which hands back `HttpController`, `HttpRouter` and +`HttpAuthenticator` all fixed to that identity: + +```ts +// src/auth.ts +import { + httpAuth, + type HttpAuthenticatorOf, + type HttpControllerOf, + type HttpRouterOf, +} from "@btravstack/http"; + +/** What this deployment knows about a caller. The contract names none. */ +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; +``` + +Written once per application, because a handler's parameter types are fixed +where the arrow is written: a composition root cannot re-type a `sync` callback +that lives in a slice's module, so the identity has to be in scope where the +handler is. The three aliases are annotations rather than ceremony — a +controller's port expands to a type carrying the marker's phantom +`unique symbol`, which the file's own `.d.ts` cannot name. + +`HttpAuthenticator([deps], { sync })` is then an ordinary di provider on the +starter's `AuthenticatorPort`, with no type argument left to state. It resolves +the identity from the request's **headers** — not the request: an authenticator +has no business reading a body, and the narrower argument is what keeps it +testable without a socket. + +```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 }); + }, +}); +``` + +Enriching what a deployment knows about its callers — roles, an org tier, an +internal id — is a change to this file alone: not a contract change, and none +of it reaches a client. + +The factory is also the **only** way a handler gets a readable principal. +`@btravstack/http`'s own top-level `HttpController` and `HttpRouter` name no +identity, so a marked fragment reached through them types `principal: never` +and every read of it is a compile error — the signal to use the factory, not a +fallback. Neither form invents one: an unmarked procedure's context still has +no `principal` at all. + +`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 is 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. + +The identity is **stated**, never inferred from `sync`: inference through a +returned function's `AsyncResult` is exactly where a principal silently widens +to `unknown`, and stating it once is what makes a mismatch a compile error at +step 4 instead of an `unknown` reaching a handler. It also means the +authenticator and the controllers cannot disagree — both come from the same +`httpAuth` call. + +`Unauthenticated` carries **nothing**: the starter surfaces no reason — a +rejected caller gets an `UNAUTHORIZED` and oRPC's default message — so a payload +would be write-only. An authenticator that wants to record why logs it before +returning, which is one more argument for naming a logger in `deps`. + +## Step 3 — read the principal + +A marked procedure's handler receives the principal on **oRPC's own context +channel**, `opts.context.principal`. No second parameter, no wrapper. +`HttpController` is imported from the application's own `auth.ts`, so +`context.principal` is the `Identity` — `userId` and `tenantId` both, neither +of which the contract names: + +```ts +import { HttpController } from "../../auth.js"; + +export const ordersController = HttpController( + "OrdersController", + 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 }, + }), + ), + ), + }), +}); +``` + +An **unmarked** procedure's `context` has no `principal`, so reading one there +is a compile error — and a controller whose handler reads `context.principal` +cannot be mounted under an unmarked contract key, where nothing would inject +one. The reverse is fine: an unmarked controller under a marked key is a +handler that ignores its caller's identity. + +## Step 4 — pass it to `HttpModule` + +The authenticator sits at the **root**, not in a slice: who a caller is is one +answer per process. + +```ts +export const OrderApi = HttpModule("OrderApi")({ + router: orderRouter, + authenticator: bearerAuthenticator, + imports: [OrdersSlice, CustomersSlice, observability()], + exports: [Logger], +}); +``` + +Two things are checked here, and they are different gates: + +- **Omitting the line** is di's own `UNSATISFIED DEPENDENCIES` at `start`. When + the contract marks anything, `HttpRouter` appends `AuthenticatorPort` to the + router provider's dependencies, so the need is real and unmet — no new gate, + and nothing this package invents. +- **Supplying one minted on a different identity** is a compile error at + the `HttpModule(...)` call itself. di cannot see it — `AuthenticatorPort`'s + service type is erased to `unknown`, so any authenticator discharges the need + — so `HttpModule` compares the **router's** identity against the + **authenticator's**, both of which came from the same `httpAuth` call in an + application that has one. The direction is + `AuthIdentity extends RouterIdentity`: the authenticator must resolve at + least what the handlers read, so a subtype discharges it. + +A router minted by the package's own top-level `HttpRouter` carries no identity +and accepts any authenticator, including none: a provider nothing needs is di's +business and not an error to invent. + +## What a rejected caller gets + +| Situation | Answer | +| ---------------------------------------------- | ----------------------------------------------------- | +| the authenticator returns `Unauthenticated` | `401 UNAUTHORIZED`, the handler never entered | +| the authenticator defects | oRPC's `INTERNAL_SERVER_ERROR` collapse — not a `401` | +| a marked route with no authenticator behind it | `401` — the starter's fail-closed fallback | +| an unmarked procedure, no credentials | served | + +A defect is a bug in the authenticator, not a rejected caller, and reporting it +as one would tell an operator the opposite of what happened. The third row is +unreachable while the types and the runtime walk agree — which is exactly why +it is there. + +On the client, `UNAUTHORIZED` is an error the contract does **not** declare, so +it is not inferable: it lands in `defect`, not in `errCases`. A client for a +marked fragment sends its credentials up front: + +```ts +const client = createOrderApiClient("http://127.0.0.1:3000", "/rpc", { + authorization: `Bearer ${tenantId}:${userId}`, +}); +``` + +## The marker is legibility, not enforcement + +**An unmarked procedure is public, and nothing fails if the marker is +forgotten.** There is no deny-by-default: a new procedure added to an unmarked +record is served to anyone, no compile error, no startup failure, no warning. +What the contract buys is that a protected route is _visible_ — one word in the +artifact both sides read, in the diff, in the generated types, and in the +handler's own signature. + +So the marker is a declaration, not a policy. If you need deny-by-default, it +is the contract's job to say so — mark the root and unmark what is public — +and today that is something an application writes, not something this package +offers. + +Two further non-goals worth stating plainly: the marker does not +**authenticate** (that is your `Authenticator`, and what a token means is +yours), and it does not model **authorization** — it says who a caller is, and +nothing about what they may do. Per-procedure permissions belong in the +handler, where the use case is. + +## See also + +- [`@btravstack/contract`](/reference/contract) — `authenticated`, + `Authenticated`, `PrincipalKey`, `IsMarked`, `isAuthenticated`. +- [`@btravstack/http`](/reference/http) — `HttpAuthenticator`, + `AuthenticatorPort`, `Unauthenticated`, and the request table. +- [Split a router into controllers](/how-to/split-a-router-into-controllers) — + where the handler in step 3 lives once an API has slices. +- [Order API (HTTP)](/examples/order-api) — one marked fragment, one public + one, end to end. diff --git a/docs/how-to/read-the-ambient-unit.md b/docs/how-to/read-the-ambient-unit.md index 7123147..5a52f7d 100644 --- a/docs/how-to/read-the-ambient-unit.md +++ b/docs/how-to/read-the-ambient-unit.md @@ -76,11 +76,11 @@ export class OrderRepository extends Port("OrderRepository")<{ Each transport then supplies it from its own contract, which is where a client already has to say what it wants: -| Deployment | Where the tenant comes from | -| ----------------------- | ---------------------------------------------- | -| `order-api` | an input field on every procedure (`Tenanted`) | -| `order-amqp-worker` | a field on the broadcast envelope | -| `order-temporal-worker` | a field on every workflow and activity input | +| Deployment | Where the tenant comes from | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| `order-api` | an input field on a public procedure (`Tenanted`), the authenticated caller's principal on a marked one | +| `order-amqp-worker` | a field on the broadcast envelope | +| `order-temporal-worker` | a field on every workflow and activity input | Two consequences are the point rather than the price. A use case that forgot its tenant **does not compile**, where an ambient one would have failed at diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 04ac596..6eb17df 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -51,7 +51,13 @@ describe("order-api", () => { // GIVEN the real composition root, bound to a loopback port the OS picks const app = boot(OrderApi, { unit: RequestModule }); const info = (await app.runtimeInfo()).get(); - const client = createOrderApiClient(`http://127.0.0.1:${info?.port}`); + const client = createOrderApiClient( + `http://127.0.0.1:${info?.port}`, + "/rpc", + { + authorization: `Bearer ${tenantId}:u-1`, + }, + ); // WHEN a call goes over the wire // THEN it reached the use case behind the transport @@ -63,6 +69,15 @@ describe("order-api", () => { }); ``` +The credentials are not optional: the contract marks its `orders` fragment +[`authenticated`](/reference/contract), so the same call without an +`authorization` header is refused before any procedure runs — and +`UNAUTHORIZED` is not an error the contract declares, so it arrives as a +`Defect` rather than in `errCases`. What the token establishes here is the +tenant; the example's fixtures wrap this up as `clientFor`, which is why every +spec below takes that fixture rather than building a client by hand. See +[Protect a procedure](/how-to/protect-a-procedure). + `runtimeInfo()` is whatever the runtime published on `Serving.info` — the HTTP starter publishes `{ port }` — and `probePort()` the probe port that bound. Both carry `E = never`, so `.get()` is the whole read. The teardown @@ -113,9 +128,12 @@ const lines: Line[] = []; const recordingApi = HttpModule("RecordingApi")({ router: orderRouter, + // The same authenticator as the real root: the contract marks `orders`, so + // every composition serving that router owes one. + authenticator: bearerAuthenticator, imports: [ - OrderApplicationModule, - OrderPersistenceModule, + OrdersSlice, + CustomersSlice, // Pinned rather than bound: the fixture's `LOG_LEVEL` silences the real // root, and this root exists to be read. observability({ sink: (line) => lines.push(line), level: "trace" }), @@ -263,8 +281,10 @@ export const it = test.extend({ `serve` is `boot` with `RequestModule` forked around every request, so its shutdown is still the fixture's; `clientFor` builds the oRPC client from -`runtimeInfo()`; and `recording` is the real root's composition with a -recording sink in place of stdout: +`runtimeInfo()` **and gives it credentials for this test's tenant** +(`Bearer ${tenant}:u-1`), since the contract marks the `orders` fragment and an +anonymous call to it never reaches a use case; and `recording` is the real +root's composition with a recording sink in place of stdout: ```ts const recordingApi = () => { @@ -272,9 +292,10 @@ const recordingApi = () => { return { api: HttpModule("RecordingApi")({ router: orderRouter, + authenticator: bearerAuthenticator, imports: [ - OrderApplicationModule, - OrderPersistenceModule, + OrdersSlice, + CustomersSlice, observability({ sink: recorder.sink, level: "trace" }), ], exports: [Logger], @@ -284,6 +305,13 @@ const recordingApi = () => { }; ``` +The tenant a handler serves is **not** an input field any more: the `orders` +handlers read `context.principal.tenantId`, the value the authenticator +resolved from the request's headers, and the marked fragment's inputs declare +no tenant at all. So a spec's tenant reaches the server through the **token** +`clientFor` mints and nowhere else. The unmarked `customers` fragment still +names its tenant on the input, which is why its calls still pass one. + `api.spec.ts` then swaps the repository for a stub that holds a request open to prove `completed: 1` and `abandoned: 1` against the real HTTP runtime (see [Swap an adapter for tests](/how-to/swap-an-adapter)). The other two examples diff --git a/docs/reference/contract.md b/docs/reference/contract.md new file mode 100644 index 0000000..9a3e6ea --- /dev/null +++ b/docs/reference/contract.md @@ -0,0 +1,183 @@ +--- +title: "@btravstack/contract" +description: The contract-level auth marker — authenticated(), Authenticated, PrincipalKey, IsMarked and isAuthenticated — what it puts on a contract node, and what it deliberately does not. +--- + +# @btravstack/contract + +> **Reference.** A complete, structured description of the contract marker's +> public surface: every export of `@btravstack/contract`, what a marked node +> carries and what reads it. For the task, see +> [Protect a procedure](/how-to/protect-a-procedure); for how the HTTP starter +> turns the marker into a typed `opts.context.principal`, see +> [`@btravstack/http`](/reference/http). Generated signatures are under +> [API reference](/api/contract/). + +A marker a contract puts on a node — a record of procedures, or a single +procedure — to say _"this requires an authenticated caller"_, readable by +both the client that imports the contract and the server that implements it. +Nothing here talks to oRPC, HTTP, AMQP or Temporal: it is a plain marker over +`WeakSet` identity, transport-agnostic by construction. + +**The contract says _whether_ a route is protected; the application's +`httpAuth()` says _what_ the principal is.** No identity type is +named here at all, so nothing about the server's view of a caller reaches a +client. + +## Exports + +`packages/contract/src/index.ts` exports exactly this: + +| Export | Kind | What it is | +| ------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `authenticated` | value | `(node: T) => Authenticated` — marks a contract node as requiring an authenticated caller | +| `isAuthenticated` | value | `(node: object) => boolean` — whether **this exact node** was marked | +| `Authenticated` | type | `T & { readonly [PrincipalKey]: true }` — `T`'s own keys plus one phantom key that exists only for the type checker | +| `PrincipalKey` | type | `typeof PRINCIPAL`, the marker's key — exported so a consumer's mapped type can `Exclude` and land on the contract's own keys | +| `IsMarked` | type | `T extends { readonly [PrincipalKey]: true } ? true : false` — whether **this exact node** carries the marker, as a yes/no rather than a type | + +## `authenticated(node)` + +One export, no factory and no type parameter. Apply it to a record of +procedures (which protects every procedure beneath it) or to a single +procedure (which protects itself): + +```ts +import { authenticated } from "@btravstack/contract"; +import { oc, type } from "@orpc/contract"; + +const ordersContract = { + place: oc + .input(type<{ readonly id: string; readonly quantity: number }>()) + .output(type<{ readonly id: string }>()), +}; + +export const contract = { + orders: authenticated(ordersContract), + customers: { + find: oc + .input(type<{ readonly id: string }>()) + .output(type<{ readonly name: string }>()), + }, +}; +``` + +There is nothing to state twice, and nothing about identity to keep in step +between two contracts: the marker carries no principal, so `orders` above says +only that a caller must be authenticated. + +## `IsMarked` and `isAuthenticated` + +`IsMarked` answers the question at the type level; `isAuthenticated` answers +the same question at runtime, for one node: + +```ts +import { + authenticated, + isAuthenticated, + type IsMarked, +} from "@btravstack/contract"; +import { oc, type } from "@orpc/contract"; + +const quote = authenticated( + oc + .input(type<{ readonly id: string }>()) + .output(type<{ readonly total: number }>()), +); + +export type QuoteIsMarked = IsMarked; // true +export const isProtected: boolean = isAuthenticated(quote); // true +``` + +`isAuthenticated` answers for **one node only**. Ancestry — a marked parent +implying a marked child — is the caller's to carry: this package tracks nodes, +not trees. `@btravstack/http`'s router walk carries an `inherited` flag for +exactly that, mirroring what the types do when a marked record pushes its +marker onto each child. + +## The contract says whether; the application says what + +Nothing here names an identity type, so there is nothing in the contract to +keep minimal and nothing in it to leak. What a principal actually **is** is +stated once, server-side, by `@btravstack/http`'s +[`httpAuth()`](/reference/http) — and a handler minted from that +factory sees it with no annotation of its own. + +Two things follow. Enriching what a deployment knows about its callers — +roles, an org tier, an internal id — is never a contract change and reaches no +client. And the gate pairing a router with an authenticator compares the +**router's** identity against the **authenticator's**, both of which come from +the same `httpAuth` call, rather than either against the contract. + +A marked fragment reached through the top-level `HttpController` — no factory — +types `principal: never`, so every read of it is a compile error. That is the +signal to use the factory, not a fallback. + +## Three load-bearing properties + +**Zero dependencies and zero peers.** Nothing here imports oRPC, `di`, `core` +or `unthrown`. That is what lets a client take a contract without pulling in +the server that implements it, and what would let an AMQP or Temporal contract +reuse the same marker: it has no opinion about which transport reads it. + +**The combinator returns the node unchanged and sets no property on it.** +`authenticated(node) === node`, with nothing added — `PRINCIPAL` is `declare`d +and never assigned, so it exists only in the type system. There is no key for +oRPC's `implement()` to walk as a procedure and nothing for its builders to +strip; the mark lives in a `WeakSet` keyed by identity, shared across copies of +this package (see the warning below). + +**Applied after a builder chain is finished, never inside one.** +`authenticated` wraps a finished node — the last call in a chain, or a whole +record of finished nodes. Applied mid-chain it is lost, because +`oc.router(...)` rebuilds every node: lost on **both** sides at once, the type +and the runtime mark together, which makes it a dropped protection rather than +a bypass. No oRPC builder has to know the marker exists. + +## What it does not do + +- **It does not enforce anything.** An unmarked node is public, and forgetting + the marker fails nothing — the contract makes a protected route _legible_, + not mandatory. Opt-in by construction; see + [Protect a procedure](/how-to/protect-a-procedure). +- **It does not authenticate, and it does not name a principal.** Turning a + request into a principal is `@btravstack/http`'s `HttpAuthenticator`, what + that principal's type is, is `httpAuth()`, and what a token means + is the application's. +- **It does not model authorization.** Who a caller is, not what they may do. + +## Peer dependencies + +None, and no runtime dependencies either. `pnpm add @btravstack/contract`, and +that is the whole install. Node `>=20`. + +::: warning One copy — and a second one is a compile error, not an open route +`PrincipalKey` is a `unique symbol`, so two copies of this package mint two +different brands: a contract marked against one does not type as marked in the +other. The **runtime** registry does not split that way — it hangs off +`globalThis` under `Symbol.for("@btravstack/contract/marked")`, so every copy +reads and writes one `WeakSet`. + +That asymmetry is deliberate. A module-private set would make a second copy +silent: `isAuthenticated` false everywhere, no authenticator required, and a +marked route **served open**. Sharing the registry makes the two halves fail +together, and the type half fails loudly. `@btravstack/http` peers on this +package so an application holds a single copy in the first place. + +`PRINCIPAL` is also never exported as a value, and must stay that way: a +nameable brand could be written onto a contract node by hand without the +matching `WeakSet` entry — typed as protected, unmarked at runtime, so no +authenticator is demanded and a handler reads a principal nothing injected. +See +[Peer dependencies](/explanation/peer-dependencies). +::: + +## See also + +- [Protect a procedure](/how-to/protect-a-procedure) — mark, authenticate, + compose. +- [`@btravstack/http`](/reference/http) — `HttpAuthenticator`, + `AuthenticatorPort`, `Unauthenticated`, and what a marked leaf's handler + receives. +- [Order API (HTTP)](/examples/order-api) — a contract with one marked + fragment and one public one. diff --git a/docs/reference/http.md b/docs/reference/http.md index 85c33d0..83406b2 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -1,6 +1,6 @@ --- title: "@btravstack/http" -description: The HTTP starter — HttpModule, HttpRouter, HttpController, http(), HttpRuntime, HttpConfig and HttpInfo, what each request is answered with, and how the drain retires a keep-alive connection. +description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAuthenticator, http(), HttpRuntime, HttpConfig and HttpInfo, plugins and securityHeaders, what each request is answered with, and how the drain retires a keep-alive connection. --- # @btravstack/http @@ -18,17 +18,27 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, http() `packages/http/src/index.ts` exports exactly this: -| Export | Kind | What it is | -| ------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `HttpModule` | value | `HttpModule(name)({ router, prefix?, port?, hostname?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the router provider; the composition root of an HTTP deployment | -| `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | -| `HttpRouter` | value | `HttpRouter(contract)(deps, { sync })`, or `HttpRouter(contract)(controllers)` — the router as a provider on the starter's own router port, contract-first, either from one `sync` or from a keyed record of controllers | -| `HttpController` | value | `HttpController(name, fragment)([deps], { sync })` — one slice of a contract, as a provider on a port minted for it | -| `http` | value | `http({ prefix?, port?, hostname? })` — the starter module itself, needing the router port; what `HttpModule` imports | -| `HttpOptions` | type | `http()`'s options | -| `HttpRuntime` | value | `class HttpRuntime extends RuntimePort> {}` — the runtime's port; what `http()` provides and the module `start` boots must export | -| `HttpConfig` | value | `class HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string }> {}` — what the socket is bound with, provided by `http()` from `PORT` / `HOST` | -| `HttpInfo` | type | `{ readonly port: number }` — what the runtime publishes on `Serving.info` once listening, read back through `RunningApp.runtimeInfo()` | +| Export | Kind | What it is | +| ---------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HttpModule` | value | `HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports? })` — a di `Module(name)({...})` that also takes the router provider; the composition root of an HTTP deployment | +| `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | +| `HttpRouter` | value | `HttpRouter(contract)(deps, { sync })`, or `HttpRouter(contract)(controllers)` — the router as a provider on the starter's own router port, contract-first, either from one `sync` or from a keyed record of controllers | +| `HttpController` | value | `HttpController(name, fragment)([deps], { sync })` — one slice of a contract, as a provider on a port minted for it | +| `HttpAuthenticator` | value | `HttpAuthenticator

()([deps], { sync })` — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | +| `httpAuth` | value | `httpAuth()` — mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to the server's own identity; the only thing that gives a marked handler a readable `context.principal` | +| `HttpAuth` | type | what `httpAuth()` returns — the three, as one type | +| `HttpControllerOf` | type | `HttpControllerOf` — the annotation a file exporting the factory's `HttpController` needs | +| `HttpRouterOf` | type | `HttpRouterOf` — the same, for the router | +| `HttpAuthenticatorOf` | type | `HttpAuthenticatorOf` — the same, for the authenticator | +| `AuthenticatorPort` | value | `Port("HttpAuthenticator")` over `AuthenticatorService` — the port a marked contract's router depends on | +| `AuthenticatorService` | type | `(headers: IncomingHttpHeaders) => AsyncResult` — headers in, principal out | +| `Unauthenticated` | value | a `TaggedError` with an empty payload — the refusal itself; the starter surfaces no reason to the client | +| `HasMark` | type | `HasMark` — exactly `true` or `false`: whether the contract marks anything, anywhere in its tree | +| `http` | value | `http({ prefix?, port?, hostname?, plugins?, securityHeaders? })` — the starter module itself, needing the router port; what `HttpModule` imports | +| `HttpOptions` | type | `http()`'s options | +| `HttpRuntime` | value | `class HttpRuntime extends RuntimePort> {}` — the runtime's port; what `http()` provides and the module `start` boots must export | +| `HttpConfig` | value | `class HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string }> {}` — what the socket is bound with, provided by `http()` from `PORT` / `HOST` | +| `HttpInfo` | type | `{ readonly port: number }` — what the runtime publishes on `Serving.info` once listening, read back through `RunningApp.runtimeInfo()` | `HttpRouterPort` (the starter's router port, `Port("HttpRouter")`), `Implementation` (the record type `HttpRouter`'s `sync` returns) and @@ -40,34 +50,42 @@ inferred at the call, the third is an internal seam. ## `HttpModule(name)({...})` Everything `Module(name)({...})` takes — `imports`, `provides`, `exports` — -plus the starter's own fields. It appends `http({ prefix, port, hostname })` -to `imports`, prepends `router` to `provides`, prepends `HttpRuntime` to -`exports`, and hands the augmented tuples to di's own `Module(name)`, whose -return type is the sugar's. The kernel and both gates see a plain module. - -| Option | Required | Default | What it is | -| ---------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `router` | yes | — | the application's router **provider** — a `Provider`, what `HttpRouter(contract)(deps, arm)` returns; a provider on any other port fails at the call | -| `prefix` | no | `/rpc` | where the RPC endpoint is mounted; typed `` `/${string}` `` | -| `port` | no | read from `PORT` | pins the port instead of reading it | -| `hostname` | no | read from `HOST` | pins the host instead of reading it | -| `imports` | no | `[]` | the application's modules | -| `provides` | no | `[]` | the application's own providers | -| `exports` | no | `[]` | the application's own exports; `HttpRuntime` is added | +plus the starter's own fields. It appends +`http({ prefix, port, hostname, plugins, securityHeaders })` to `imports`, +prepends `router` (and `authenticator`, when one is given) to `provides`, +prepends `HttpRuntime` to `exports`, and hands the augmented tuples to di's own +`Module(name)`, whose return type is the sugar's. The kernel and both gates see +a plain module. + +| Option | Required | Default | What it is | +| ----------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `router` | yes | — | the application's router **provider** — a `Provider`, what `HttpRouter(contract)(deps, arm)` returns; a provider on any other port fails at the call | +| `authenticator` | no\* | — | what `HttpAuthenticator

()([deps], { sync })` returns; \*owed whenever the contract marks anything (see [Authentication](#authentication)) | +| `prefix` | no | `/rpc` | where the RPC endpoint is mounted; typed `` `/${string}` `` | +| `port` | no | read from `PORT` | pins the port instead of reading it | +| `hostname` | no | read from `HOST` | pins the host instead of reading it | +| `plugins` | no | `[]` | oRPC handler plugins, forwarded to `RPCHandler` — CORS, body limits, compression, CSRF | +| `securityHeaders` | no | `true` | response headers set on the raw listener, before dispatch | +| `imports` | no | `[]` | the application's modules | +| `provides` | no | `[]` | the application's own providers | +| `exports` | no | `[]` | the application's own exports; `HttpRuntime` is added | The worked composition root, from `examples/order-api/src/module.ts`: ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - imports: [OrderApplicationModule, OrderPersistenceModule, observability()], + authenticator: bearerAuthenticator, + imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); ``` That is exactly the module -`Module("OrderApi")({ imports: [OrderApplicationModule, OrderPersistenceModule, observability(), http()], provides: [orderRouter], exports: [HttpRuntime, Logger] })` -would have declared. [`observability()`](/reference/observability) is a second +`Module("OrderApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], provides: [orderRouter, bearerAuthenticator], exports: [HttpRuntime, Logger] })` +would have declared. `authenticator` is a plain optional field: present, it +joins `provides`, which is all discharging di's need takes. +[`observability()`](/reference/observability) is a second starter, not this package's business: it brings the `Logger` the application writes to, bound from `LOG_LEVEL`, JSON per line on stdout, every line carrying the trace id of the unit this runtime opened. @@ -158,11 +176,16 @@ export const orderRouter = HttpRouter(contract)({ Each value is what [`HttpController`](#httpcontrollername-fragment) returns. The call is **exact**: `M` is constrained to -`{ readonly [K in keyof C]: ControllerFor }`, and the `controllers` -**parameter** itself is typed `M & { readonly [K in Exclude]: never }` — the exactness intersection sits on the parameter, not on `M`, -so a key `C` does not declare is typed `never` there without collapsing `M` -(and with it the needs channel di orders the controllers by) to `never` too. +`{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` +**parameter** itself is typed `M & { readonly [K in Exclude>]: never }` — the exactness intersection sits on +the parameter, not on `M`, so a key `C` does not declare is typed `never` there +without collapsing `M` (and with it the needs channel di orders the controllers +by) to `never` too. The `Exclude`/`Inherit` pair is the same one +[`Implementation`](#authentication) carries: a contract marked at its +**root** composes through this form too, and each fragment inherits that mark, +so a controller under it types `context.principal`. Five gates are pinned by `packages/http/src/controller.test-d.ts`: every contract key must be covered; a key the contract does not declare is rejected; a controller wired under the @@ -236,6 +259,147 @@ export const ordersRouter = HttpRouter(contract.orders)( That property is marked do-not-break: it is what makes composing several slices into one router a starting point rather than a trap. +## Authentication + +A contract marked with [`@btravstack/contract`](/reference/contract)'s +`authenticated` is what turns this on. Nothing here is a switch on the +starter: the marker is a fact about the contract, and both halves of the +package follow it. + +**In the types.** `Implementation` branches on the marker. A +marked **leaf** gets `{ readonly principal: Identity }` in its implementer's +injected context, so the handler reads `opts.context.principal` — oRPC's own +context channel, not a second handler parameter this package invents and not a +wrapper around `.result()`. A marked **record** pushes its marker onto each +child, so a marked fragment protects every procedure beneath it. An unmarked +leaf's context is unchanged, which is what makes reading a principal there a +compile error. `HasMark` is whether the contract marks anything anywhere in +its tree — a yes/no, since the contract names no principal to recover. + +**At runtime.** `HttpRouter`'s walk carries the mark down the contract exactly +as the types do, and a marked leaf is built as +`node.use(principalMiddleware(authenticate)).result(fn)` — `.use` before +`.result`, which is the only order oRPC leaves available. The middleware reads +the request off oRPC's initial context, calls the authenticator with its +headers, and either injects `{ context: { principal } }` or terminates the +request. + +### `HttpAuthenticator

()([deps], { sync })` + +An ordinary di provider on `AuthenticatorPort`, whose service is +`AuthenticatorService

`: + +```ts +type AuthenticatorService

= ( + headers: IncomingHttpHeaders, +) => AsyncResult; +``` + +**Headers, not the request**: an authenticator has no business reading a body, +and the narrower argument is what keeps it testable without a socket. `deps` +are di's, so a JWT verifier or a user directory is injected the way any +provider's dependencies are. The type argument is **explicit** rather than +inferred from `sync` — inference through a returned function's `AsyncResult` is +where a principal silently widens to `unknown` — though in an application that +has an `src/auth.ts` the argument is already fixed by `httpAuth()` +and the call is `HttpAuthenticator([deps], { sync })`. `Unauthenticated` is a +`TaggedError` with an **empty payload**: the starter surfaces no reason, so a +field would be write-only. A rejected caller gets an `UNAUTHORIZED` and oRPC's +default message; an authenticator that wants to record why logs it before +returning. Forwarding a reason would put "no such user" versus "bad signature" +in a 401 body by default. + +```ts +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 }); + }, +}); +``` + +### `httpAuth()` — what the principal is, server-side + +**The contract says _whether_ a route is protected; this says _what_ the +principal is.** The contract names no identity type at all, so nothing about +the server's view of a caller reaches a client — and this factory is the only +thing that gives a marked handler a readable `context.principal`. It states the +identity once and hands back the three pieces fixed to it: + +```ts +// src/auth.ts — one per application +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; +``` + +Every slice imports `HttpController` from there, and its marked handlers see +`Identity` on `context.principal` with no annotation of their own; nothing else +about a controller changes. The `HttpAuthenticator` handed back is already +applied, so it is called `HttpAuthenticator([deps], { sync })` — which is also +why the authenticator and the controllers cannot disagree about the identity. + +It is per application rather than per slice because a handler's parameter types +are fixed **where the arrow is written**: a composition root cannot re-type a +`sync` callback that lives in another module, so the identity has to be in +scope where the handler is. 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 a consumer's own `.d.ts` cannot name. + +The identity reaches a **marked** node only, and none is invented on an +unmarked one: the contract still decides _whether_. `HttpController` and +`HttpRouter` imported from the package itself are the `Identity = never` case — +a marked fragment reached through them types `principal: never`, so every read +is a compile error. That is the signal to use the factory, not a fallback. + +### Two gates, and why they are two + +When the contract marks anything, `HttpRouter` appends `AuthenticatorPort` +**last** to the router provider's dependency array — so every existing +positional service keeps its index — and adds it to the provider's needs +channel. Which makes a marked router with no authenticator behind it di's +existing `UNSATISFIED DEPENDENCIES` gate at `start`, not a gate this package +invented. + +What di cannot see is the **identity**: `AuthenticatorPort`'s service type is +erased to `unknown`, so any authenticator discharges that need. So +`HttpModule` checks the other half — the **router's** identity, inferred from +`router.identity`, against the **authenticator's**. A router minted by +`httpAuth()` refuses an authenticator minted by `httpAuth()`, at the +`HttpModule(...)` call. The direction is `AuthIdentity extends RouterIdentity`: +the authenticator must resolve **at least** what the handlers read, so a +subtype discharges it. A router from the package's own top-level `HttpRouter` +carries no identity and accepts any authenticator, including none — a provider +nothing needs is di's business and not an error to invent. + +A mark with no authenticator behind it still **fails closed**: an internal +`noAuthenticator` refuses every caller, so such a leaf answers `401` rather +than serving unprotected. It is unreachable while the types and the walk +agree, which is exactly why it is there. + +### The marker is legibility, not enforcement + +An unmarked procedure is public, and **nothing fails if the marker is +forgotten** — no compile error, no startup failure. There is no +deny-by-default here; the contract makes a protected route visible to both +sides, and that is all it claims. See +[Protect a procedure](/how-to/protect-a-procedure). + ## `http(options)` ```ts @@ -247,11 +411,13 @@ const http: ( The primitive `HttpModule` delegates to, for a composition root written by hand. `HttpOptions`: -| Option | Required | Default | What it is | -| ---------- | -------- | ---------------- | --------------------------------- | -| `prefix` | no | `/rpc` | where the RPC endpoint is mounted | -| `port` | no | read from `PORT` | pins the port | -| `hostname` | no | read from `HOST` | pins the host | +| Option | Required | Default | What it is | +| ----------------- | -------- | ---------------- | --------------------------------------------------------------- | +| `prefix` | no | `/rpc` | where the RPC endpoint is mounted | +| `port` | no | read from `PORT` | pins the port | +| `hostname` | no | read from `HOST` | pins the host | +| `plugins` | no | `[]` | `NodeHttpHandlerPlugin[]`, forwarded to oRPC's own `RPCHandler` | +| `securityHeaders` | no | `true` | `boolean \| Record`, applied on the listener | The module **provides** `HttpRuntime` and `HttpConfig`, exports both, and **needs** `Env` (the kernel discharges it) and the starter's router port @@ -264,6 +430,51 @@ declared type is the same whether or not a field is pinned: `Env` and `ConfigInvalid` stay in the signature, and a pinned config never produces the latter. +### `plugins` + +`readonly NodeHttpHandlerPlugin[]`, from +`@orpc/server/node`, forwarded straight to `new RPCHandler(service, { plugins })`. +CORS, body limits, compression and CSRF are transport policy oRPC already +expresses as handler plugins, so the ordinary use is **configuration** rather +than a middleware slot for application logic — +`plugins: [new CORSHandlerPlugin({ origin: () => "https://orders.example" })]` +on `HttpModule` or `http()`, with the plugin imported from +`@orpc/server/plugins`. + +`plugins` is an **honest escape hatch, not a keyhole**. oRPC's +`StandardHandlerPlugin.init` transforms handler options — including +`StandardHandlerOptions.interceptors` — so a plugin can wrap execution, and an +application determined to see a procedure's outcome can get there. Nothing +pretends otherwise. What the option buys is that the ordinary path is +configuration a reader can see at the composition root, and reaching past it is +a visible act rather than the default shape; an application middleware acting on +the handler's `Result` is still what +[Deliberately not included](#deliberately-not-included) refuses. It threads +through all three surfaces (`http()`, `HttpModule` and the internal oRPC +options) as a plain optional field. + +Note what a plugin does **not** cover: it only runs for a request oRPC +**matched**, so the runtime's own `404` and `500` never reach one. That is why +`securityHeaders` is not a plugin. + +### `securityHeaders` + +`boolean | Readonly>`, default `true`. Applied by the +package on the **raw node listener**, before dispatch — the first statement of +the request handler — so it covers a served response, the runtime's `404`, its +`500` and a drained response alike. + +| Value | Effect | +| ------------------------ | ------------------------------------------------------------------------------------------ | +| `true` (default) | `x-content-type-options: nosniff`, `x-frame-options: DENY`, `referrer-policy: no-referrer` | +| `false` | nothing is set | +| `Record` | replaces the defaults outright — the record is the whole set | + +The set is resolved once per `listen`, not per request. It is deliberately +small: a default that has to be right for every deployment cannot include a +CSP, an HSTS max-age or a permissions policy, all of which are a deployment's +own decision — pass a record when you have made those. + ## `HttpConfig`, and the environment `HttpConfig` is `{ port, hostname }`, bound through @@ -290,15 +501,17 @@ that is the only way to learn the port that was actually bound. ## What it decides about a request -| Request | Answer | Decided by | -| ------------------------------------------- | --------------------------------------------------------------------- | ---------------- | -| a procedure under `prefix` | the procedure's output, or the `ORPCError` its `Result` was mapped to | oRPC, the router | -| a defect thrown inside a procedure | oRPC's own `INTERNAL_SERVER_ERROR` collapse | oRPC | -| a path under `prefix` naming no procedure | `404 {"error":"NotFound"}` — oRPC declines it unwritten | this package | -| any path outside `prefix` | `404 {"error":"NotFound"}` — likewise | this package | -| the listener resolved without writing | `404 {"error":"NotFound"}` | this package | -| the listener failed before headers were out | `500 {"error":"InternalError"}` | this package | -| a failure with headers already on the wire | the socket is destroyed — a reset, not a hang | this package | +| Request | Answer | Decided by | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------- | +| a procedure under `prefix` | the procedure's output, or the `ORPCError` its `Result` was mapped to | oRPC, the router | +| a defect thrown inside a procedure | oRPC's own `INTERNAL_SERVER_ERROR` collapse | oRPC | +| a marked procedure whose authenticator returned `Unauthenticated` | `401 UNAUTHORIZED`, the handler never entered | this package | +| a marked procedure whose authenticator defected | oRPC's `INTERNAL_SERVER_ERROR` collapse — a bug, not a rejected caller | oRPC | +| a path under `prefix` naming no procedure | `404 {"error":"NotFound"}` — oRPC declines it unwritten | this package | +| any path outside `prefix` | `404 {"error":"NotFound"}` — likewise | this package | +| the listener resolved without writing | `404 {"error":"NotFound"}` | this package | +| the listener failed before headers were out | `500 {"error":"InternalError"}` | this package | +| a failure with headers already on the wire | the socket is destroyed — a reset, not a hang | this package | The last three are the package's own fallbacks, guaranteeing that every request produces exactly one completed response. The two `500` shapes are @@ -349,15 +562,24 @@ so a transient accept fault cannot become an `uncaughtException` teardown. ## Peer dependencies -`@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `unthrown`, -`@orpc/server`, `@orpc/contract`, `@unthrown/orpc`. All peers, so an -application holds one copy of each. Node `>=20`. +`@btravstack/core`, `@btravstack/config`, `@btravstack/di`, +`@btravstack/contract`, `unthrown`, `@orpc/server`, `@orpc/contract`, +`@unthrown/orpc`. All peers, so an application holds one copy of each — +`@btravstack/contract` most of all, since its marker is a `unique symbol` and +two copies are two different symbols, so a contract marked against one would +read as unmarked here. Node `>=20`. ## Deliberately not included - **Any other router or handler.** oRPC through `@orpc/server/node`'s `RPCHandler` is the one way HTTP is answered; there is no `handler` option and no listener port to provide. -- **Middleware.** oRPC's own, inside the router's procedures. +- **A middleware slot for application logic.** oRPC's own, inside the + router's procedures. `principalMiddleware` is the one per-request hook the + package installs, only on a marked leaf. [`plugins`](#plugins) is an honest + escape hatch rather than a keyhole — a plugin can reach the handler's + interceptors — but the ordinary path is configuration visible at the + composition root, and an application middleware acting on the handler's + `Result` is what this package refuses. - **`Result` → HTTP status.** The router's `.result()` triage owns it. - **HTTPS, HTTP/2.** `node:http` only; terminate TLS at the ingress. diff --git a/docs/reference/packages.md b/docs/reference/packages.md index af25114..75a60e8 100644 --- a/docs/reference/packages.md +++ b/docs/reference/packages.md @@ -1,11 +1,11 @@ --- title: Packages and install -description: The eight published packages, who peers on what, and one install command per kind of deployment. +description: The nine published packages, who peers on what, and one install command per kind of deployment. --- # Packages and install -> **Reference.** The eight published packages, their peer-dependency matrix and the +> **Reference.** The nine published packages, their peer-dependency matrix and the > install command for each kind of deployment. For _why_ everything is a peer > dependency, see [Peer dependencies](/explanation/peer-dependencies); for what > a starter is, see [Starters](/explanation/starters). @@ -14,6 +14,7 @@ description: The eight published packages, who peers on what, and one install co | Package | What it is | Reference | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@btravstack/contract` | Contract-level markers a client and a server share: `authenticated` says a procedure needs a principal, and the handler's type carries it. Depends on nothing. | [@btravstack/contract](/reference/contract) | | `@btravstack/di` | The container: ports as the vocabulary, providers bound at one edge, modules that declare their imports and exports. Depends on nothing. | [Ports](/reference/di/ports), [Providers](/reference/di/providers), [Modules](/reference/di/modules), [Entry points](/reference/di/entry-points), [Wiring defects](/reference/di/wiring-defects) | | `@btravstack/config` | Configuration the twelve-factor way: `Env` as a port, typed fields bound from it through a schema, `ConfigInvalid` naming every fault. | [@btravstack/config](/reference/config) | | `@btravstack/core` | The kernel: boot a module into a running process with one runtime, drain on SIGTERM, close the scope on every path, decide the exit code. | [start](/reference/core/start), [RunningApp](/reference/core/running-app), [Runtime](/reference/core/runtime), [Exit codes](/reference/core/exit-codes) | @@ -50,7 +51,8 @@ single copy. | `@btravstack/config` | `@btravstack/di`, `unthrown` | | `@btravstack/core` | `@btravstack/config`, `@btravstack/di`, `unthrown` | | `@btravstack/observability` | `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `unthrown` — and `pino`, the family's one **optional** peer, needed only by the `@btravstack/observability/pino` subpath | -| `@btravstack/http` | `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `unthrown`, `@orpc/server`, `@orpc/contract`, `@unthrown/orpc` | +| `@btravstack/contract` | nothing — zero peers and zero dependencies, so a client can take a contract without the server | +| `@btravstack/http` | `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `@btravstack/contract`, `unthrown`, `@orpc/server`, `@orpc/contract`, `@unthrown/orpc` | | `@btravstack/temporal` | `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `unthrown`, `@temporalio/worker`, `@temporalio/activity`, `@temporalio/common`, `@temporal-contract/worker`, `@temporal-contract/contract` | | `@btravstack/amqp` | `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `unthrown`, `@amqp-contract/worker`, `@opentelemetry/api` | | `@btravstack/testing` | `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `unthrown` — and **not** `vitest`: `bootFixture` is a plain `(ctx, use) => Promise`, vitest's fixture protocol met without the import | @@ -80,8 +82,8 @@ does, the first package alone suffices. ::: code-group ```sh [HTTP API] -pnpm add @btravstack/http @btravstack/core @btravstack/config @btravstack/di unthrown \ - @orpc/server @orpc/contract @unthrown/orpc +pnpm add @btravstack/http @btravstack/core @btravstack/config @btravstack/di \ + @btravstack/contract unthrown @orpc/server @orpc/contract @unthrown/orpc ``` ```sh [Temporal worker] @@ -132,9 +134,10 @@ yet. The commands above are what they will be once it has. | `@btravstack/testing` | `bootFixture`, `tapped`, `testRuntime`, `TestRuntimePort`, `createFakeClock` and the types — a package of its own, so a production bundle never pulls the fakes in; see [@btravstack/testing](/reference/testing) | | `@btravstack/config` | `Env`, `Config`, `ConfigInvalid`, `ConfigFieldInvalid` and the types — see [@btravstack/config](/reference/config) | | `@btravstack/di` | `Port`, `Provider`, `Module`, `Context` and the types — see [Ports](/reference/di/ports) | +| `@btravstack/contract` | `authenticated`, `isAuthenticated`, `Authenticated`, `PrincipalKey`, `IsMarked` — see [@btravstack/contract](/reference/contract) | | `@btravstack/observability` | `Logger`, `createLogger`, `jsonSink`, `observability`, `LoggerConfig`, `logLevel`, `kernelEvents`, `LEVELS` and the types — see [@btravstack/observability](/reference/observability) | | `@btravstack/observability/pino` | `pinoSink` alone, so `pino` stays an optional peer a consumer that never imports this never installs | -All eight packages ship dual CJS/ESM builds with `.d.ts` files and no source +All nine packages ship dual CJS/ESM builds with `.d.ts` files and no source maps (the tarball carries no `src/`, so a map would be a dead end). `@btravstack/observability` is the only one with a second entry point. diff --git a/docs/scripts/build-api.ts b/docs/scripts/build-api.ts index 817fe86..c9d0537 100644 --- a/docs/scripts/build-api.ts +++ b/docs/scripts/build-api.ts @@ -25,6 +25,7 @@ const TYPEDOC = join( // `@btravstack/docs#build`'s `dependsOn` in the root `turbo.json`, and with the // `/api/` sidebar in `.vitepress/config.ts`. const packages: readonly string[] = [ + "contract", "di", "config", "core", diff --git a/docs/typedoc.contract.json b/docs/typedoc.contract.json new file mode 100644 index 0000000..a5b5194 --- /dev/null +++ b/docs/typedoc.contract.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/typedoc/base.json", + "name": "@btravstack/contract", + "entryPoints": ["../packages/contract/src/index.ts"], + "tsconfig": "../packages/contract/tsconfig.json", + "out": "api/contract", + "intentionallyNotExported": [] +} diff --git a/examples/order-api-contract/package.json b/examples/order-api-contract/package.json index 72b6709..b081090 100644 --- a/examples/order-api-contract/package.json +++ b/examples/order-api-contract/package.json @@ -18,6 +18,7 @@ "@orpc/contract": "catalog:" }, "devDependencies": { + "@btravstack/contract": "workspace:*", "@btravstack/tsconfig": "catalog:", "@orpc/client": "catalog:", "@types/node": "catalog:", @@ -26,5 +27,8 @@ "typescript": "catalog:", "unthrown": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "@btravstack/contract": "workspace:^" } } diff --git a/examples/order-api-contract/src/client.spec.ts b/examples/order-api-contract/src/client.spec.ts index 7b01392..2866e31 100644 --- a/examples/order-api-contract/src/client.spec.ts +++ b/examples/order-api-contract/src/client.spec.ts @@ -12,7 +12,7 @@ describe("contract", () => { // WHEN a procedure the contract declares is called // THEN the contract's own output shape comes back as a value - await expect(client.orders.place({ tenantId: "acme", id: "o-1", quantity: 2 })).toBeOkWith({ + await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ id: "o-1", quantity: 2, }); @@ -22,7 +22,7 @@ describe("contract", () => { // GIVEN the same client, and an order the stub does not hold // WHEN it is looked up - const missing = await client.orders.find({ tenantId: "acme", id: "o-404" }); + const missing = await client.orders.find({ id: "o-404" }); // THEN the code and payload the contract declares arrive on the error // channel, inferable — the half of contract-first design a client is diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index a76c44d..5d05c08 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -1,3 +1,4 @@ +import { authenticated } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; /** @@ -12,17 +13,19 @@ export type OrderView = { readonly id: string; readonly quantity: number }; export type OrderRef = { readonly id: string }; /** - * Every input names its tenant, because this API serves several from one - * database and "which tenant" is part of what is being asked. + * An **unauthenticated** input names its tenant, because this API serves + * several from one database and "which tenant" is then part of what is being + * asked. It is an argument, not a header the transport reads: + * `@btravstack/http` has no tenancy concept and should not grow one — context + * is the application's to own, and naming it in the contract is what makes it + * the application's. * - * It is an **argument**, not a header the transport reads: `@btravstack/http` - * has no tenancy concept and should not grow one — context is the - * application's to own. Naming it in the contract is what makes it the - * application's: a client cannot forget it, the router cannot invent one, and - * the use case it reaches takes it as a parameter all the way to the - * repository. A deployment that later authenticates its callers would put the - * tenant on the caller's identity instead and drop it from here; that is a - * contract change, which is exactly the kind of change it should be. + * `orders` is marked `authenticated` and therefore does **not** name it: its + * handlers serve the tenant the caller's own identity establishes, so a caller + * does not get to name the tenant it is served, and a field the handlers ignore + * would be a lie in the contract. `customers` is unmarked and keeps it. The + * contrast is the lesson — where a caller's identity establishes the tenant, + * the input has nothing to say about it. */ export type Tenanted = { readonly tenantId: string }; @@ -32,14 +35,14 @@ export type CustomerView = { readonly id: string; readonly name: string }; /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ const ordersContract = { place: oc - .input(type()) + .input(type<{ readonly id: string; readonly quantity: number }>()) .output(type()) .errors({ INVALID_QUANTITY: { data: type() }, CONFLICT: { data: type() }, }), find: oc - .input(type()) + .input(type()) .output(type()) .errors({ NOT_FOUND: { data: type() } }), }; @@ -65,5 +68,19 @@ const customersContract = { * Each code is one arm of the exhaustive `mapErrCases` in a slice's own * controller. Adding a domain error without adding a code here stops that * file compiling. + * + * `orders` is `authenticated(...)`, `customers` is not: the marker 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. + * + * **The contract says WHETHER a route is protected, and nothing about who the + * caller is.** No principal type is named here, so nothing about what this + * deployment knows about a caller — a user id, roles, an org tier — reaches a + * client, and enriching it is never a contract change. What the principal + * actually is, is `examples/order-api`'s `httpAuth()` to say. */ -export const contract = { orders: ordersContract, customers: customersContract }; +export const contract = { + orders: authenticated(ordersContract), + customers: customersContract, +}; diff --git a/examples/order-api-contract/src/test-fixtures.ts b/examples/order-api-contract/src/test-fixtures.ts index 04507dd..1a2de88 100644 --- a/examples/order-api-contract/src/test-fixtures.ts +++ b/examples/order-api-contract/src/test-fixtures.ts @@ -48,24 +48,23 @@ const stubServer = (): StubFetch => { const stored = new Map(); return async (_url, init, _options, path) => { - // Keyed by tenant AND id, the way the real schema is: a stub that ignored - // the tenant would let a contract test pass against an API that leaks - // between them. `tenantId` is an INPUT here and never an output — the - // views the contract declares carry no tenant, because a caller that - // named one does not need telling. + // Keyed by id alone: `orders` is the MARKED fragment, so its inputs name + // no tenant — a protected procedure is served the tenant its caller's + // principal carries, which is a server's business and not something this + // client-side stub has an identity to model. The unmarked `customers` + // fragment is where an input still names one. if (path.join(".") === "orders.place") { - const { tenantId, id, quantity } = inputOf<{ - readonly tenantId: string; + const { id, quantity } = inputOf<{ readonly id: string; readonly quantity: number; }>(init); - if (stored.has(`${tenantId}/${id}`)) return declared(409, "CONFLICT", id); - stored.set(`${tenantId}/${id}`, { id, quantity }); + if (stored.has(id)) return declared(409, "CONFLICT", id); + stored.set(id, { id, quantity }); return rpc(200, { id, quantity }); } - const { tenantId, id } = inputOf<{ readonly tenantId: string; readonly id: string }>(init); - const found = stored.get(`${tenantId}/${id}`); + const { id } = inputOf<{ readonly id: string }>(init); + const found = stored.get(id); return found === undefined ? declared(404, "NOT_FOUND", id) : rpc(200, found); }; }; diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 65ca1b8..6eed193 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -9,7 +9,9 @@ socket, and the router itself is a di-provided service. The contract lives in its own package, because a client needs it and needs none of this. ``` -src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder], { sync }) — where the orders slice's own domain error becomes an ORPCError +src/auth.ts Identity, and the HttpController / HttpRouter / HttpAuthenticator httpAuth() mints from it +src/authenticator.ts bearerAuthenticator — headers in, Identity out, on the starter's port +src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)([PlaceOrder, FindOrder, Logger], { sync }) — where the orders slice's own domain error becomes an ORPCError src/slices/orders/module.ts OrdersSlice — provides the controller, exports only it src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)([FindCustomer], { sync }) — same shape, for the customers slice's own domain error src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice @@ -93,11 +95,51 @@ root that is a `Module(...)` which also knows about it: ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, + authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); ``` +`authenticator` is owed because the contract marks its `orders` fragment +`authenticated`: the router provider carries `AuthenticatorPort` as a need, so +omitting the line is an unmet dependency `start` refuses, and supplying one +minted on a different identity is a compile error at this call. It sits at +the root rather than in a slice — who a caller is is one answer per process — +and it is an ordinary provider, so swapping this example's +`Bearer :` stand-in for JWT verification changes nothing +else. + +Where the identity is **stated** is `src/auth.ts`, the whole of it: + +```ts +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; +``` + +**The contract says whether a route is protected; `httpAuth()` says +what the principal is.** The contract names no identity type at all, so nothing +here reaches a client and enriching it — roles, an org tier, an internal id — +is never a contract change. Both slices import `HttpController` from there +instead of from `@btravstack/http`, and the orders controller reads +`context.principal.userId` to log who asked for a placement. Who placed an +order is a transport-boundary fact, so it is logged there rather than pushed +through a use case that has no business with it. + +It is also the only way to read a principal at all: a marked fragment reached +through `@btravstack/http`'s own top-level `HttpController` types +`principal: never`, so every read of it is a compile error. And it is written +once per application rather than per slice — a handler's parameter types are +fixed where the arrow is written, so the composition root cannot re-type a +`sync` callback living in a slice's module. + The root is a list of **slices**. Each one imports the vertical it needs — `OrderApplicationModule`, whose repository is an unmet need, and `OrderPersistenceModule`, which provides it — and exports only its controller: @@ -163,7 +205,9 @@ and no handler code manages any of it. ## The client half ```ts -const client = createOrderApiClient("http://127.0.0.1:3000"); +const client = createOrderApiClient("http://127.0.0.1:3000", "/rpc", { + authorization: `Bearer ${tenantId}:${userId}`, +}); const named = (await client.orders.place({ id, quantity })).match({ ok: () => "placed", @@ -177,6 +221,14 @@ const named = (await client.orders.place({ id, quantity })).match({ }); ``` +The header is not optional here: `orders` is the marked half of the contract, +so the same call without it is refused before any procedure runs — as an +`UNAUTHORIZED` the contract does not declare, which means it is not inferable +and lands in `defect` rather than `errCases`. `customers` is unmarked and +answers either way — and names its tenant on the input, which `orders` does +not: the tenant a marked procedure serves is the token's, so there is nothing +for the caller to say about it. + The error channel is the raw `ORPCError` union discriminated by `code` — not re-wrapped into a second error concept — so the client's match is the mirror of the server's `mapErrCases`. @@ -248,33 +300,50 @@ this package validates, prints or exits. ## Multi-tenant by design, not by framework The API serves several tenants from one database, and the tenant is declared -in **its own contract**: +in **its own contract** — on the unmarked fragment, where the caller is the +only one who can say which tenant is meant: ```ts export type Tenanted = { readonly tenantId: string }; +const customersContract = { + find: oc + .input(type()) + .output(type()) + .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), +}; + const ordersContract = { place: oc - .input(type()) + .input(type<{ readonly id: string; readonly quantity: number }>()) .output(type()) .errors({ INVALID_QUANTITY: { data: type() }, CONFLICT: { data: type() } }), … }; ``` -The controller hands `input.tenantId` straight to the use case, which hands it -to the repository, which puts it in the `WHERE`. `@btravstack/http` knows +The `customers` controller hands `input.tenantId` straight to the use case, +which hands it to the repository, which puts it in the `WHERE`. The `orders` +fragment is marked `authenticated`, so its controller takes the tenant from +`context.principal.tenantId` — this deployment's `Identity`, which the +contract never names — and its inputs name none: a required field the +handler ignores is a field that lies, and a caller that could name a tenant it +is not served is a confused deputy waiting to happen. Either way +`@btravstack/http` knows nothing about tenants and has no hook for them — context is the application's to own, and a starter that read a tenant off a header would be deciding a system's authentication model on its behalf. -An argument rather than a header, then, and the trade is worth naming. A -client cannot forget it (the contract refuses), the router cannot invent one, -and the path from wire to `WHERE` is visible in three files. What it is not is -"who is asking": a deployment that authenticates its callers would take the -tenant from the caller's identity and drop it from the contract — a contract -change, which is exactly the kind of change that should be. - -It is typechecked by the gate rather than executed by it:It is typechecked by the gate rather than executed by it: the example packages +The contrast between the two fragments is the lesson. Where nothing +authenticates the caller, the tenant is an **argument**: the client cannot +forget it (the contract refuses), the router cannot invent one, and the path +from wire to `WHERE` is visible in three files. Where the caller is +authenticated, the tenant is **who is asking**, and it comes off the principal +— which is a contract change, exactly the kind of change that should be one. +`orders` has made it and `customers` has not, which is why the two controllers +read the tenant from different places and why only one of the two inputs +mentions it. + +It is typechecked by the gate rather than executed by it: the example packages are source-only — no build step, `main` pointing straight at `src/` — so there is no compiled entry for `node` to run, and every spec drives `start` directly. diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index bf9d012..6f55ff9 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -6,7 +6,6 @@ import { it } from "./test-fixtures.js"; describe("order-api", () => { it("carries a real oRPC call through to the DI-wired use case", async ({ - tenant, serve, clientFor, api, @@ -16,14 +15,13 @@ describe("order-api", () => { // WHEN a call goes over the wire // THEN it reached the use case behind the transport - await expect(client.orders.place({ tenantId: tenant, id: "o-1", quantity: 2 })).toBeOkWith({ + await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ id: "o-1", quantity: 2, }); }); it("serves every call from the one application scope, so a write outlives its request", async ({ - tenant, serve, clientFor, api, @@ -35,8 +33,8 @@ describe("order-api", () => { // a second unit — and the same database, because the application scope is // opened once by the kernel and only the request scope is forked per call. const found = await client.orders - .place({ tenantId: tenant, id: "o-1", quantity: 2 }) - .flatMap(() => client.orders.find({ tenantId: tenant, id: "o-1" })); + .place({ id: "o-1", quantity: 2 }) + .flatMap(() => client.orders.find({ id: "o-1" })); // THEN the write is visible to the read expect(found).toBeOkWith({ id: "o-1", quantity: 2 }); @@ -54,7 +52,6 @@ describe("order-api", () => { }); it("turns a domain Err into a typed, inferable CONFLICT — a value, not a thrown 500", async ({ - tenant, serve, clientFor, api, @@ -65,8 +62,8 @@ describe("order-api", () => { // WHEN the same id is placed again — chained, so the first call's `Result` // is consumed and a failure there cannot be mistaken for the conflict const conflict = await client.orders - .place({ tenantId: tenant, id: "o-1", quantity: 1 }) - .flatMap(() => client.orders.place({ tenantId: tenant, id: "o-1", quantity: 1 })); + .place({ id: "o-1", quantity: 1 }) + .flatMap(() => client.orders.place({ id: "o-1", quantity: 1 })); // THEN the `Err` channel, not the defect one: the client got a value back. // `constructor` is read through the prototype chain, so the one assertion @@ -85,7 +82,6 @@ describe("order-api", () => { }); it("turns a rejected invariant into a typed, inferable INVALID_QUANTITY", async ({ - tenant, serve, clientFor, api, @@ -94,7 +90,7 @@ describe("order-api", () => { const client = await clientFor(serve(api)); // WHEN a quantity the domain rejects is placed - const invalid = await client.orders.place({ tenantId: tenant, id: "o-2", quantity: 0 }); + const invalid = await client.orders.place({ id: "o-2", quantity: 0 }); // THEN the second declared code crosses the wire the same way expect(invalid).toBeErrWith( @@ -108,14 +104,13 @@ describe("order-api", () => { }); it("lets the client match that error channel exhaustively, by code", async ({ - tenant, serve, clientFor, api, }) => { // GIVEN an error the contract declares const client = await clientFor(serve(api)); - const invalid = await client.orders.place({ tenantId: tenant, id: "o-2", quantity: 0 }); + const invalid = await client.orders.place({ id: "o-2", quantity: 0 }); // WHEN the channel is folded — the mirror of the `mapErrCases` that // produced it, with no wildcard to fall back on. Both codes are named and @@ -133,7 +128,6 @@ describe("order-api", () => { }); it("collapses a Defect to INTERNAL_SERVER_ERROR without leaking the cause", async ({ - tenant, serve, clientFor, unmodelled, @@ -142,7 +136,7 @@ describe("order-api", () => { const client = await clientFor(serve(unmodelled)); // WHEN a call reaches it - const result = await client.orders.find({ tenantId: tenant, id: "o-1" }); + const result = await client.orders.find({ id: "o-1" }); // THEN the raw cause does NOT leak over the wire; oRPC collapses it, and // the non-inferable result lands back in the defect channel. `inferable` @@ -159,7 +153,6 @@ describe("order-api", () => { }); it("keeps serving after a defect, because a defect is not a crash", async ({ - tenant, serve, clientFor, unmodelled, @@ -172,9 +165,9 @@ describe("order-api", () => { // here rather than asserted — it is the subject of the test above; what // this one asks is what the process does next. const served = await client.orders - .find({ tenantId: tenant, id: "o-1" }) + .find({ id: "o-1" }) .recoverDefect(() => Ok("defected" as const)) - .flatMap(() => client.orders.place({ tenantId: tenant, id: "o-1", quantity: 1 })) + .flatMap(() => client.orders.place({ id: "o-1", quantity: 1 })) .map(() => app.phase()); // THEN the next call was served, by a process still in the serving phase @@ -182,7 +175,6 @@ describe("order-api", () => { }); it("runs each call in its own unit, with its own trace id", async ({ - tenant, serve, clientFor, recording, @@ -192,12 +184,12 @@ describe("order-api", () => { // WHEN two calls are served — chained, so neither `Result` is dropped const served = await client.orders - .place({ tenantId: tenant, id: "o-1", quantity: 1 }) - .flatMap(() => client.orders.place({ tenantId: tenant, id: "o-2", quantity: 1 })); + .place({ id: "o-1", quantity: 1 }) + .flatMap(() => client.orders.place({ id: "o-2", quantity: 1 })); - // THEN two calls, two interactor lines plus two request-scope teardown - // lines, carrying two distinct trace ids and never one written outside a - // unit — read off the line's own `unit` field, which is what the logger + // THEN two calls, each writing a controller line, an interactor line and a + // request-scope teardown line, carrying two distinct trace ids and never + // one written outside a unit — read off the line's own `unit` field, which is what the logger // stamps from `currentUnit()` per call const traced = served .map(() => recording.lines()) @@ -207,14 +199,14 @@ describe("order-api", () => { outOfUnit: lines.filter((line) => line.unit === undefined).length, })); - expect(traced).toBeOkWith({ lines: 4, distinct: 2, outOfUnit: 0 }); + expect(traced).toBeOkWith({ lines: 6, distinct: 2, outOfUnit: 0 }); }); - it("lets an in-flight call finish while draining", async ({ tenant, serve, clientFor, gate }) => { + it("lets an in-flight call finish while draining", async ({ serve, clientFor, gate }) => { // GIVEN a call held open inside the repository const app = serve(gate.api); const client = await clientFor(app); - const inFlight = client.orders.find({ tenantId: tenant, id: "o-1" }); + const inFlight = client.orders.find({ id: "o-1" }); await gate.arrived; // WHEN the drain starts and the call is released only once the phase moved. @@ -230,7 +222,6 @@ describe("order-api", () => { }); it("counts the finished call as completed in the drain report", async ({ - tenant, serve, clientFor, gate, @@ -238,7 +229,7 @@ describe("order-api", () => { // GIVEN a call held open inside the repository const app = serve(gate.api); const client = await clientFor(app); - const inFlight = client.orders.find({ tenantId: tenant, id: "o-1" }); + const inFlight = client.orders.find({ id: "o-1" }); await gate.arrived; // WHEN the drain starts and the call is released only once the phase moved @@ -257,7 +248,6 @@ describe("order-api", () => { }); it("reports a call still hung at the deadline as abandoned", async ({ - tenant, serve, clientFor, gate, @@ -265,7 +255,7 @@ describe("order-api", () => { // GIVEN a call held open, and a drain with no time to give it const app = serve(gate.api, { drainTimeoutMs: 0 }); const client = await clientFor(app); - const hung = client.orders.find({ tenantId: tenant, id: "o-1" }); + const hung = client.orders.find({ id: "o-1" }); await gate.arrived; // WHEN the drain starts and the call is never released @@ -283,7 +273,6 @@ describe("order-api", () => { }); it("surfaces the socket destroyed under an abandoned call as a defect", async ({ - tenant, serve, clientFor, gate, @@ -291,7 +280,7 @@ describe("order-api", () => { // GIVEN a call held open, and a drain with no time to give it const app = serve(gate.api, { drainTimeoutMs: 0 }); const client = await clientFor(app); - const hung = client.orders.find({ tenantId: tenant, id: "o-1" }); + const hung = client.orders.find({ id: "o-1" }); await gate.arrived; // WHEN the drain starts and the call is never released @@ -325,6 +314,77 @@ describe("order-api", () => { expect(probed).toEqual({ livez: 200, readyz: 200, ready: true }); }); + it("refuses a call to the marked fragment when the caller presents nothing", async ({ + serve, + clientWith, + api, + }) => { + // GIVEN the real composition root and a caller with no credentials + const client = await clientWith(serve(api), undefined); + + // WHEN a procedure of the authenticated fragment is called + const refused = await client.orders.place({ id: "o-1", quantity: 1 }); + + // THEN it was refused before the use case was reached. `UNAUTHORIZED` is + // not a code the contract declares, so oRPC does not mark it inferable and + // the client hands it back on the defect channel — the same treatment a + // collapsed 500 gets, and the reason a refusal is not something a caller + // has to match on + expect(refused).toBeDefectWith( + expect.objectContaining({ constructor: ORPCError, code: "UNAUTHORIZED", inferable: false }), + ); + }); + + it("serves the unmarked fragment to a caller presenting nothing", async ({ + tenant, + serve, + clientWith, + stubbed, + }) => { + // GIVEN the same absent credentials, and a customer registered behind the + // slice whose fragment carries no marker + const client = await clientWith(serve(stubbed), undefined); + + // WHEN a procedure of that fragment is called + // THEN it answers: the marker is per-fragment, so protecting `orders` did + // not quietly close the rest of the API + await expect(client.customers.find({ tenantId: tenant, id: "c-1" })).toBeOkWith({ + id: "c-1", + name: "Ada", + }); + }); + + it("serves each caller the tenant its own token names", async ({ + tenant, + serve, + clientFor, + clientWith, + api, + }) => { + // GIVEN two callers on one app, each holding a token for its own tenant + const app = serve(api); + const other = `${tenant}-other`; + const client = await clientFor(app); + const stranger = await clientWith(app, `Bearer ${other}:u-2`); + + // WHEN the first places an order and the second looks that id up + const found = await client.orders + .place({ id: "o-1", quantity: 2 }) + .flatMap(() => stranger.orders.find({ id: "o-1" })); + + // THEN the second sees nothing: the tenant a marked handler serves is + // `context.principal.tenantId`, and the fragment's inputs name no tenant + // for a caller to ask for another one with + expect(found).toBeErrWith( + expect.objectContaining({ + constructor: ORPCError, + code: "NOT_FOUND", + data: { id: "o-1" }, + inferable: true, + }), + ); + }); + it("serves the customers slice alongside the orders slice", async ({ tenant, serve, @@ -370,7 +430,6 @@ describe("order-api", () => { }); it("goes unready on drain while staying live", async ({ - tenant, serve, clientFor, probesFor, @@ -382,7 +441,7 @@ describe("order-api", () => { const app = serve(gate.api, { probes: { port: 0 } }); const probes = await probesFor(app); const client = await clientFor(app); - const inFlight = client.orders.find({ tenantId: tenant, id: "o-1" }); + const inFlight = client.orders.find({ id: "o-1" }); await gate.arrived; // WHEN the drain starts. The TRANSITION is awaited through `ready()`, which diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts new file mode 100644 index 0000000..ab03181 --- /dev/null +++ b/examples/order-api/src/auth.ts @@ -0,0 +1,41 @@ +import { + httpAuth, + type HttpAuthenticatorOf, + type HttpControllerOf, + type HttpRouterOf, +} from "@btravstack/http"; + +/** + * What this deployment knows about a caller — and the one place it is stated. + * + * **The contract says whether a route is protected; this says what the + * principal is.** `@btravstack/example-order-api-contract` names no identity + * type at all, so none of this reaches a client and enriching it — roles, an + * org tier, an internal id — is never a contract change. A handler minted + * below sees `Identity` with no annotation at its own call site, and + * `HttpModule`'s gate compares the router's identity against the + * authenticator's, both of which come from the one call here. + */ +export type Identity = { readonly tenantId: string; readonly userId: string }; + +/** + * The three the factory mints, together — imported by the slices instead of + * `@btravstack/http`'s own. Written once per application, because a handler's + * parameter types are fixed where the arrow is written: the composition root + * cannot re-type a `sync` callback that lives in a slice's module. + * + * The authenticator and the controllers cannot disagree about the identity, + * since both come from this call — and there is no other way to read a + * principal: a marked fragment reached through `@btravstack/http`'s own + * top-level `HttpController` types `principal: never`. + * + * Each is annotated rather than left to inference: a controller's port expands + * to a type carrying `@btravstack/contract`'s phantom `unique symbol`, which + * this file cannot name in its own declaration emit (TS2527). The aliases the + * starter exports are what it names instead. + */ +const identity = httpAuth(); + +export const HttpController: HttpControllerOf = identity.HttpController; +export const HttpRouter: HttpRouterOf = identity.HttpRouter; +export const HttpAuthenticator: HttpAuthenticatorOf = identity.HttpAuthenticator; diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts new file mode 100644 index 0000000..7655115 --- /dev/null +++ b/examples/order-api/src/authenticator.ts @@ -0,0 +1,27 @@ +import { Unauthenticated } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; + +import { HttpAuthenticator } from "./auth.js"; + +/** + * A stand-in, not a recommendation: `Bearer :`. What matters + * for the example is the shape — an ordinary di provider on the starter's + * port, so a real deployment swaps in JWT verification by composing a + * different provider and changes nothing else. + * + * `[]` because this one needs no service; a verifier, a key set or a user + * directory would be named there and injected the way any provider's + * dependencies are. The identity is `./auth.ts`'s — the same call the slices' + * controllers are minted from — so a token resolving to the wrong shape is a + * compile error here, and the handlers cannot be reading a different one. + */ +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 }); + }, +}); diff --git a/examples/order-api/src/client.ts b/examples/order-api/src/client.ts index 825c886..2cbec82 100644 --- a/examples/order-api/src/client.ts +++ b/examples/order-api/src/client.ts @@ -16,8 +16,15 @@ type Wire = RouterContractClient; */ export type OrderApiClient = ResultClient; +/** + * `headers` is where a caller's credentials go: the contract marks its `orders` + * fragment `authenticated`, so a call to that half without an `authorization` + * header is refused before any procedure runs. The `customers` fragment is + * unmarked and answers either way. + */ export const createOrderApiClient = ( origin: string, prefix: `/${string}` = "/rpc", + headers: Readonly> = {}, ): OrderApiClient => - createResultClient(createORPCClient(new RPCLink({ origin, url: prefix }))); + createResultClient(createORPCClient(new RPCLink({ origin, url: prefix, headers }))); diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index 5da86db..ae76bda 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -1,7 +1,9 @@ import { contract } from "@btravstack/example-order-api-contract"; -import { HttpModule, HttpRouter } from "@btravstack/http"; +import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; +import { HttpRouter } from "./auth.js"; +import { bearerAuthenticator } from "./authenticator.js"; import { customersController } from "./slices/customers/controller.js"; import { CustomersSlice } from "./slices/customers/module.js"; import { ordersController } from "./slices/orders/controller.js"; @@ -26,7 +28,13 @@ export const orderRouter = HttpRouter(contract)({ * * What is left here is what no slice owns: `observability()`, whose `Logger` * every layer writes to and which is exported because the per-request - * `RequestModule` reads it out of the application scope. Importing the router + * `RequestModule` reads it out of the application scope, and the + * `authenticator` — one per process, because who a caller is is not a slice's + * question. It is required here and nowhere else because the contract marks + * `orders`: the router provider carries `AuthenticatorPort` as a need, so + * dropping this line is an unmet dependency `start` refuses, and supplying one + * that resolves a different principal is a compile error at this very call. + * Importing the router * and the starter is what closes di's arity gate (a composition without the * router provider does not compile — the starter's provider depends on it), * and `HttpRuntime`, which the sugar exports, is what closes the kernel's. @@ -41,6 +49,7 @@ export const orderRouter = HttpRouter(contract)({ */ export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, + authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 6507fc0..43361e3 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -10,9 +10,11 @@ import { start } from "@btravstack/core"; * this package's `test:types` script, never executed. */ import { Module } from "@btravstack/di"; -import { HttpRuntime, http } from "@btravstack/http"; +import { HttpAuthenticator, HttpModule, HttpRuntime, http } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; +import { OkAsync } from "unthrown"; +import { bearerAuthenticator } from "./authenticator.js"; import { OrderApi, orderRouter } from "./module.js"; import { RequestModule } from "./request-scope.js"; import { CustomersSlice } from "./slices/customers/module.js"; @@ -28,7 +30,10 @@ const _wired = start(OrderApi, options); // exported, so there is no runtime for `start` to resolve. const RuntimelessApi = Module("RuntimelessApi")({ imports: [OrdersSlice, CustomersSlice, observability()], - provides: [orderRouter], + // The authenticator is here so this arm fails on arity ALONE: the contract + // marks `orders`, so a graph carrying the router without one has an unmet + // need too, and an arm that could fail either way pins neither gate. + provides: [orderRouter, bearerAuthenticator], exports: [Logger], }); @@ -61,9 +66,42 @@ const _withUnit = start(OrderApi, { ...options, unit: RequestModule }); // half of the gate can be what rejects the call. const UnloggedApi = Module("UnloggedApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], - provides: [orderRouter], + provides: [orderRouter, bearerAuthenticator], exports: [HttpRuntime], }); // @ts-expect-error — UNSATISFIED UNIT NEEDS: the module does not export Logger for RequestModule to read. const _unitUnmet = start(UnloggedApi, { ...options, unit: RequestModule }); + +// The real root minus its authenticator. `contract.orders` is marked +// `authenticated`, so `HttpRouter` gave the router provider a dependency on +// the starter's `AuthenticatorPort` and nothing here discharges it. Same gate +// as `_missingRouter` above — di's, at `start`, not at `HttpModule(...)`, +// which is why the module below builds without complaint. +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); + +// The OTHER authenticator gate, and a different one: whether the authenticator +// resolves what the handlers read. `AuthenticatorPort`'s service type is +// erased to `unknown`, so di sees the need discharged and would let this +// through — `HttpModuleOptions` compares the ROUTER's identity against the +// authenticator's itself, at the `HttpModule(...)` call, which is why this +// directive sits on the option and not on a `start` below it. The contract +// declares no principal to compare against; `./auth.ts` is what declares one. +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], +}); diff --git a/examples/order-api/src/slices/customers/controller.ts b/examples/order-api/src/slices/customers/controller.ts index 818da37..9251282 100644 --- a/examples/order-api/src/slices/customers/controller.ts +++ b/examples/order-api/src/slices/customers/controller.ts @@ -1,9 +1,10 @@ import { contract, type CustomerView } from "@btravstack/example-order-api-contract"; import { FindCustomer } from "@btravstack/example-order-application"; import type { Customer } from "@btravstack/example-order-domain"; -import { HttpController } from "@btravstack/http"; import { P } from "unthrown"; +import { HttpController } from "../../auth.js"; + const view = (customer: Customer): CustomerView => ({ id: customer.id, name: customer.name }); /** diff --git a/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index a670e1f..e004fe1 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -1,9 +1,11 @@ import { contract, type OrderView } from "@btravstack/example-order-api-contract"; import { FindOrder, PlaceOrder } from "@btravstack/example-order-application"; import type { Order } from "@btravstack/example-order-domain"; -import { HttpController } from "@btravstack/http"; +import { Logger } from "@btravstack/observability"; import { P } from "unthrown"; +import { HttpController } from "../../auth.js"; + const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quantity }); /** @@ -24,10 +26,21 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * matcher has no wildcard to fall back on. A new domain error is a compile * error here, at the one place that has to decide what the client sees. * - * `input.tenantId` is the tenant the caller named, handed straight to the use - * case. The transport reads nothing about it and the starter knows nothing - * about it — tenancy is this application's design, declared in its own - * contract, and `@btravstack/http` has no concept of one. + * The tenant comes off `context.principal`, the value this application's own + * authenticator resolved from the request's headers — `contract.orders` is + * marked `authenticated`, so the principal is typed here and a handler that + * misreads it does not compile. `HttpController` is `../../auth.ts`'s, minted + * by `httpAuth()`, which is why the principal has a readable type at + * all: the contract says only that the route is protected, and the factory is + * what puts this deployment's own identity in scope where the handler is + * written. Who placed an order is a transport-boundary fact, so it is logged + * here rather than pushed through a use case that has no business with it. The fragment's inputs name **no** tenant: a + * caller does not get to name the tenant it is served, and a required field + * these handlers ignore would be a lie in the contract. The unmarked + * `customers` fragment still names one, which is where that contrast is + * legible. 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. * * The use cases arrive as arguments, not through oRPC's context: di injects * them into the provider — `HttpController(name, contract)` is di's own @@ -35,12 +48,13 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * provider like any other in the graph. */ export const ordersController = HttpController("OrdersController", contract.orders)( - [PlaceOrder, FindOrder], + [PlaceOrder, FindOrder, Logger], { - sync: (place, find) => ({ - place: ({ errors }, input) => - place - .execute(input.tenantId, input.id, input.quantity) + 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 @@ -50,10 +64,11 @@ export const ordersController = HttpController("OrdersController", contract.orde .with(P.tag("DuplicateOrder"), (error) => errors.CONFLICT({ message: error.message, data: { id: error.id } }), ), - ), - find: ({ errors }, input) => + ); + }, + find: ({ errors, context }, input) => find - .execute(input.tenantId, 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/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index 90a7c49..4fc3bff 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -23,6 +23,7 @@ import { bootFixture, type Boot } from "@btravstack/testing"; import { ErrAsync, fromSafePromise, OkAsync } from "unthrown"; import { inject, test } from "vitest"; +import { bearerAuthenticator } from "./authenticator.js"; import { createOrderApiClient, type OrderApiClient } from "./client.js"; import { OrderApi, orderRouter } from "./module.js"; import { RequestModule } from "./request-scope.js"; @@ -79,6 +80,11 @@ const recorderOf = () => { const apiWith = (repository: ServiceOf, sink: Sink = () => {}) => HttpModule("StubApi")({ router: orderRouter, + // The same authenticator as the real root: the contract marks `orders`, so + // every composition serving that router owes one. Swapping it out is how a + // spec would test a different identity story — not something the transport + // can be asked to skip. + authenticator: bearerAuthenticator, imports: [ OrderApplicationModule, CustomerApplicationModule, @@ -107,6 +113,7 @@ const recordingApi = () => { return { api: HttpModule("RecordingApi")({ router: orderRouter, + authenticator: bearerAuthenticator, // `level` pinned rather than bound: `boot`'s `LOG_LEVEL` silences the // real root, and this root exists to be read. imports: [ @@ -213,7 +220,23 @@ export type ApiFixtures = { module: Module, options?: Pick, ) => RunningApp; + /** + * A client already carrying credentials for this test's tenant — the shape + * every spec here wants, since the contract marks the orders fragment and an + * anonymous call to it never reaches a use case. `u-1` is a user id and + * nothing reads it; what the token establishes that a test cares about is + * the tenant. + */ readonly clientFor: (app: RunningApp) => Promise; + /** + * The same client with the `authorization` header stated verbatim, and + * absent when the token is `undefined` — what a spec about the refusal + * itself needs, rather than one about what a caller is then allowed to do. + */ + readonly clientWith: ( + app: RunningApp, + token: string | undefined, + ) => Promise; readonly probesFor: (app: RunningApp) => Promise; readonly statusOf: (url: string) => Promise; /** The real composition root. */ @@ -257,8 +280,18 @@ export const it = test.extend({ }, // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture - clientFor: async ({}, use) => { - await use(async (app) => createOrderApiClient(await originOf(app))); + clientWith: async ({}, use) => { + await use(async (app, token) => + createOrderApiClient( + await originOf(app), + "/rpc", + token === undefined ? {} : { authorization: token }, + ), + ); + }, + + clientFor: async ({ tenant, clientWith }, use) => { + await use(async (app) => clientWith(app, `Bearer ${tenant}:u-1`)); }, // oxlint-disable-next-line no-empty-pattern -- see above diff --git a/packages/amqp/CLAUDE.md b/packages/amqp/CLAUDE.md index c47d7dc..f016a4c 100644 --- a/packages/amqp/CLAUDE.md +++ b/packages/amqp/CLAUDE.md @@ -256,6 +256,18 @@ null })` **raced against `signal`**, and `stop()` reuses whatever deadline transport. `retry: { mode: "ttl-backoff", maxRetries: 3 }` also means **four** total attempts (first plus three retries), not the same count as Temporal's `maximumAttempts: 3`. +- **Cross-cutting concerns: the question does not arise here.** There is no + origin, no preflight and no browser, so CORS and security headers are + meaningless on this transport, and the connection is already authenticated — + by the broker, at `url` / `connectionOptions`, before a delivery exists. + Per-message identity is a **field on the contract's own envelope**, the way + `tenantId` already is: the same argument-not-ambient trade + `examples/order-amqp-worker` makes, and nothing this package reads. Limiting + throughput is **prefetch**, reachable today through + `defaultConsumerOptions` — issue #25's bag, a different question from this + one. `@btravstack/contract` is dependency-free, so its marker combinator + _would_ work over an AMQP contract; it is deliberately not wired, because + there is nothing here to authenticate **from**. - **The suite needs Docker** (`@amqp-contract/testing` boots one RabbitMQ per run) and carries **10 specs**: **8** in `amqp-runtime.spec.ts` — one the published info, one the unreachable broker, three the unit boundary (_"opens diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md new file mode 100644 index 0000000..793661f --- /dev/null +++ b/packages/contract/CLAUDE.md @@ -0,0 +1,115 @@ +# packages/contract + +The contract package's public surface. The root `CLAUDE.md` is the +authoritative spec for the kernel and the conventions; this file holds what +only matters when you are working under `packages/contract/`. Keep it in +sync with the code in the same commit, and with `README.md` — the package +ships no `docs-examples.test-d.ts`, so nothing else compiles these claims. + +## What this is + +A marker a contract puts on a node — a record of procedures or a single +procedure — to say "this requires an authenticated principal", readable by +both the client that imports the contract and the server that implements it. +Nothing here talks to oRPC, HTTP, AMQP or Temporal; it is a plain object +marker over `WeakSet` identity, transport-agnostic by construction. + +## Public surface + +- **`authenticated(node)`** (`auth.ts`) — `(node: T) => +Authenticated`. One export, no factory and no type parameter: apply it to + a record of procedures (protects every procedure beneath it) or to a single + procedure (protects itself). +- **`Authenticated`** — `T & { readonly [PrincipalKey]: true }`. The typed + shape a marked node carries — `T`'s own keys plus one phantom key that + exists only for the type checker. +- **`PrincipalKey`** — `typeof PRINCIPAL`, the marker's key. Exported so a + consumer's own mapped type can `Exclude` and land on + exactly the contract's own keys. +- **`IsMarked`** — `T extends { readonly [PrincipalKey]: true } ? true : +false`. Whether this exact node carries the marker. A **yes/no**, not a type: + a consumer reads it to decide whether to inject a principal, never to learn + what one is. +- **`isAuthenticated(node: object): boolean`** — whether this exact node was + marked. Ancestry (a marked parent implying a marked child) is the caller's + to carry; the package tracks nodes, not trees. + +## The contract says whether; the application says what + +**The contract names no identity type at all.** A marked node says a caller +must be authenticated and stops there; `@btravstack/http`'s +`httpAuth()` is what says what a principal is, server-side, and a +handler minted from it sees that type. So nothing about the server's own view +of a caller — roles, an org tier, an internal id — reaches a client, and +enriching it is never a contract change and never a client-visible field. + +There is therefore nothing here to keep minimal and nothing here to leak. The +gate that used to compare a contract's principal against an authenticator's +now compares the **router's** identity against the authenticator's, inside +`@btravstack/http`, where both come from the same `httpAuth` call. + +## Three load-bearing properties + +**Zero dependencies and zero peers.** Nothing here imports oRPC, `di`, `core` +or `unthrown`. That is what lets a client take a contract without pulling in +the server that implements it, and what would let an AMQP or Temporal +contract reuse the exact same `authenticated` marker — the marker has no +opinion about which transport reads it. + +**The combinator returns the node unchanged and sets no property on it.** +`authenticated(node)` returns the same reference (`=== `) with nothing added +to it — `PRINCIPAL` is `declare`d, never assigned, so it exists only in the +type system. There is no key for oRPC's `implement()` to walk as a +procedure, and nothing for its builders to strip. The marker lives in a +`WeakSet`, keyed by identity. + +Identity is exactly why a consumer takes this package as a **peer** rather +than an ordinary dependency — `@btravstack/http` and +`examples/order-api-contract` both do. Two copies in one install would each +hold their own registry, a contract marked by one would read unmarked to the +other, `HttpRouter` would declare no authenticator need and the protected +route would be served **open**. So the registry is copy-proof: it hangs off +`globalThis` under `Symbol.for("@btravstack/contract/marked")`, and every copy +shares the one `WeakSet`. A stray second copy then degrades to a compile +error — the two copies' `PRINCIPAL` symbols are different `unique symbol`s — +rather than to a silently unprotected route. + +`PRINCIPAL` is `declare`d and **never exported as a value**, and must stay +that way — but be precise about what that buys. It stops the brand being +applied by accident or written literally; it does **not** make it unforgeable. +`Authenticated` is exported, because `@btravstack/http`'s `Inherit` needs +it, so a deliberate `node as unknown as Authenticated` types as +protected while the registry stays empty: `HasMark` answers `true` and +`HttpModule` demands an authenticator, `hasMarked` answers `false` and +`routerOf` installs no middleware, and the leaf serves unauthenticated. It +takes a double cast to reach, which is the whole of the protection. Exporting +the symbol would remove even that, which is why the TS2527 wart a consumer +hits when re-exporting an inferred controller type is worth paying — the +aliases `@btravstack/http` exports (`HttpControllerOf` and friends) +are how it is paid. + +**Applied after a builder chain is finished, never inside one.** `authenticated` +wraps a finished contract node — the last call in a chain, or a whole record +of finished nodes — never a step in the middle of building one. No oRPC +builder has to know the marker exists or preserve it through its own chain. + +## Specs + +`vitest run --coverage`, 100% lines/functions, 5 tests in one file, +`auth.spec.ts`: marking returns the same reference and a readable marker, no +enumerable key is added, an unmarked node reads as unmarked, the mark lands in +the `globalThis` registry a second copy would read, and two contracts' markers +stay independent. `test-fixtures.ts` provides a one-key `fragment` as a lazy +fixture. `auth.test-d.ts` pins the type side: the phantom key excludes cleanly +out of `keyof`, `IsMarked` is **exactly** `true` / `false` (asserted both +directions — a `boolean` result would satisfy assignability to either), a +marked node still satisfies the plain shape, and a plain one does not satisfy +the marked shape. + +## Deferred, deliberately + +**A transport other than HTTP reading the marker.** `@btravstack/http` is the +only consumer today. Nothing here is HTTP-shaped — an AMQP or Temporal +contract could mark a node with the same `authenticated` and its starter read +`isAuthenticated` — but neither does, and this package does not anticipate +what a broker's or a workflow's authenticator would look like. diff --git a/packages/contract/LICENSE b/packages/contract/LICENSE new file mode 100644 index 0000000..e389328 --- /dev/null +++ b/packages/contract/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Benoit TRAVERS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/contract/README.md b/packages/contract/README.md new file mode 100644 index 0000000..991f1d6 --- /dev/null +++ b/packages/contract/README.md @@ -0,0 +1,46 @@ +# @btravstack/contract + +> Contract-level markers shared by a client and the server that implements +> it: declare **whether** a procedure requires an authenticated caller, and +> nothing about who that caller is. Zero dependencies, zero peers — a client +> can take a contract without the server, and any transport's contract can use +> the same marker. + +```sh +pnpm add @btravstack/contract +``` + +Node `>=20`. Not yet published: this repository has not cut a release yet. + +## Usage + +```ts +import { authenticated } from "@btravstack/contract"; + +export const contract = { + orders: authenticated({ place, find }), + customers: { find, quote: authenticated(oc.input(…).output(…)) }, +}; +``` + +A marked record protects every procedure beneath it; a marked procedure +protects itself. Apply `authenticated` after a builder chain is finished, +never inside one. + +**The contract says whether a route is protected; the application's +`httpAuth()` says what the principal is.** No identity type is named +here, so nothing about the server's own view of a caller reaches a client, and +enriching it is never a contract change. + +The marker is **identity-based** — a `WeakSet`, no property on the node — which +is why a package shipping a marked contract takes this one as a **peer** +dependency rather than an ordinary one: two copies would mean two registries, +and a contract marked by one reading unmarked to the other is a protected route +served open. The registry is copy-proof against that anyway (it hangs off +`globalThis` under `Symbol.for("@btravstack/contract/marked")`, so every copy +shares one `WeakSet`), so a stray second copy costs a compile error on the +mismatched marker symbol, not an open route. + +## License + +[MIT](./LICENSE) © Benoit TRAVERS diff --git a/packages/contract/package.json b/packages/contract/package.json new file mode 100644 index 0000000..a72b74d --- /dev/null +++ b/packages/contract/package.json @@ -0,0 +1,62 @@ +{ + "name": "@btravstack/contract", + "version": "0.2.0", + "description": "Contract-level markers shared by a client and the server that implements it: declare that a procedure requires an authenticated principal, and let the handler's type carry it", + "keywords": [ + "authentication", + "contract", + "orpc", + "rpc", + "typescript" + ], + "homepage": "https://github.com/btravstack/start#readme", + "bugs": { + "url": "https://github.com/btravstack/start/issues" + }, + "license": "MIT", + "author": "Benoit TRAVERS ", + "repository": { + "type": "git", + "url": "https://github.com/btravstack/start.git", + "directory": "packages/contract" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsdown src/index.ts --format cjs,esm --dts --clean", + "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", + "test": "vitest run --coverage", + "test:types": "tsc --noEmit -p tsconfig.test-d.json", + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/contract/src/auth.spec.ts b/packages/contract/src/auth.spec.ts new file mode 100644 index 0000000..e4101d5 --- /dev/null +++ b/packages/contract/src/auth.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect } from "vitest"; + +import { authenticated, isAuthenticated } from "./auth.js"; +import { it } from "./test-fixtures.js"; + +describe("authenticated", () => { + it("marks the node it is given", ({ fragment }) => { + // GIVEN an unmarked contract fragment + // WHEN it is marked + const marked = authenticated(fragment); + // THEN the marker is readable, and the value came back unchanged + expect({ marked: isAuthenticated(marked), same: marked === fragment }).toEqual({ + marked: true, + same: true, + }); + }); + + it("adds no enumerable key", ({ fragment }) => { + // GIVEN a fragment with exactly one key + // WHEN it is marked + const marked = authenticated(fragment); + // THEN nothing was added for `implement()` to walk as a procedure + expect(Reflect.ownKeys(marked)).toEqual(["place"]); + }); + + it("leaves an unmarked node unmarked", ({ fragment }) => { + // GIVEN a fragment nobody marked + // WHEN it is asked + // THEN it is not authenticated + expect(isAuthenticated(fragment)).toBe(false); + }); + + it("registers the mark where a second copy of this package would find it", ({ fragment }) => { + // GIVEN the registry as any other copy of this package would reach it + const registry = (globalThis as Record | undefined>)[ + Symbol.for("@btravstack/contract/marked") + ]; + // WHEN a node is marked + authenticated(fragment); + // THEN that shared registry is the one holding it — a module-private set + // here would read unmarked to a second copy, and serve the route open. + // Projected rather than optional-chained: `registry?.has(...)` reads + // `undefined` for a MISSING registry and for one that does not hold the + // node alike, and an absent registry is the failure this pins. + expect({ registered: registry !== undefined, holds: registry?.has(fragment) }).toEqual({ + registered: true, + holds: true, + }); + }); + + it("keeps two contracts' markers independent", ({ fragment }) => { + // GIVEN two nodes, one marked + const other = { find: { kind: "procedure" } as const }; + // WHEN only the first is marked + authenticated(fragment); + // THEN the second is untouched + expect({ first: isAuthenticated(fragment), second: isAuthenticated(other) }).toEqual({ + first: true, + second: false, + }); + }); +}); diff --git a/packages/contract/src/auth.test-d.ts b/packages/contract/src/auth.test-d.ts new file mode 100644 index 0000000..dd6f01d --- /dev/null +++ b/packages/contract/src/auth.test-d.ts @@ -0,0 +1,50 @@ +import { describe, test } from "vitest"; + +import type { Authenticated, IsMarked, PrincipalKey } from "./auth.js"; + +type Fragment = { readonly place: { readonly kind: "procedure" } }; +type Expect = T; + +describe("Authenticated carries the contract's own keys plus the phantom one", () => { + test("Exclude, PrincipalKey> is exactly keyof T", () => { + const same = null as unknown as Exclude, PrincipalKey>; + const fragmentKey: keyof Fragment = same; + void fragmentKey; + }); + + test("IsMarked is exactly true for a marked node", () => { + // Both directions: `boolean` would satisfy assignability to `true` alone. + const exact = null as unknown as Expect< + [IsMarked>] extends [true] + ? [true] extends [IsMarked>] + ? true + : false + : false + >; + void exact; + }); + + test("IsMarked is exactly false for a node carrying no marker", () => { + const exact = null as unknown as Expect< + [IsMarked] extends [false] + ? [false] extends [IsMarked] + ? true + : false + : false + >; + void exact; + }); + + test("a marked node still satisfies the plain contract shape", () => { + const marked = null as unknown as Authenticated; + const plain: Fragment = marked; + void plain; + }); + + test("a plain node does not satisfy the marked shape", () => { + const plain = null as unknown as Fragment; + // @ts-expect-error a plain node carries no [PrincipalKey] + const marked: Authenticated = plain; + void marked; + }); +}); diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts new file mode 100644 index 0000000..fd4ba39 --- /dev/null +++ b/packages/contract/src/auth.ts @@ -0,0 +1,48 @@ +// Never exported as a value, so the brand cannot be applied by accident and +// cannot be written literally. It is not unforgeable: `Authenticated` is +// exported (`@btravstack/http` needs it), so a deliberate +// `node as unknown as Authenticated` types as protected while the +// registry stays empty — no middleware installed, and a handler reading a +// principal nothing injected. Exporting the symbol would drop the cast. +declare const PRINCIPAL: unique symbol; + +/** A contract node whose procedures require an authenticated caller. */ +export type Authenticated = T & { readonly [PRINCIPAL]: true }; + +/** The marker's key, so a consumer's mapped type can `Exclude` it from `keyof`. */ +export type PrincipalKey = typeof PRINCIPAL; + +/** Whether this exact node carries the marker. */ +export type IsMarked = T extends { readonly [PRINCIPAL]: true } ? true : false; + +// On `globalThis`, not module-private: two copies each with their own set read +// every node the other marked as unmarked, and a protected route serves open. +const KEY: unique symbol = Symbol.for("@btravstack/contract/marked"); +const store = globalThis as unknown as { [KEY]?: WeakSet }; +const marked = (store[KEY] ??= new WeakSet()); + +/** + * Marks a contract node as requiring an authenticated caller — a record + * protects every procedure beneath it, a procedure protects itself. + * + * ```ts + * export const contract = { + * orders: authenticated({ place, find }), + * customers: { find, quote: authenticated(oc.input(…).output(…)) }, + * }; + * ``` + * + * Returns the node unchanged and applies after a builder chain, never inside + * one. See `packages/contract/CLAUDE.md`. + */ +export const authenticated = (node: T): Authenticated => { + marked.add(node); + return node as Authenticated; +}; + +/** Whether this exact node was marked. Ancestry is the caller's to carry. */ +export const isAuthenticated = (node: object): boolean => marked.has(node); + +// ponytail: opt-in by construction — an unmarked node is public, and forgetting +// the marker fails nothing. Deny-by-default is three lines away: mark the root +// and add `public(node)` that deletes it from the set. diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts new file mode 100644 index 0000000..b12a8f3 --- /dev/null +++ b/packages/contract/src/index.ts @@ -0,0 +1,7 @@ +export { + authenticated, + isAuthenticated, + type Authenticated, + type IsMarked, + type PrincipalKey, +} from "./auth.js"; diff --git a/packages/contract/src/test-fixtures.ts b/packages/contract/src/test-fixtures.ts new file mode 100644 index 0000000..19b2a68 --- /dev/null +++ b/packages/contract/src/test-fixtures.ts @@ -0,0 +1,10 @@ +import { test } from "vitest"; + +export const it = test.extend<{ + readonly fragment: { readonly place: { readonly kind: "procedure" } }; +}>({ + // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture + fragment: async ({}, use) => { + await use({ place: { kind: "procedure" } }); + }, +}); diff --git a/packages/contract/tsconfig.json b/packages/contract/tsconfig.json new file mode 100644 index 0000000..92efc4b --- /dev/null +++ b/packages/contract/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationMap": false, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/contract/tsconfig.test-d.json b/packages/contract/tsconfig.test-d.json new file mode 100644 index 0000000..c4a0f1c --- /dev/null +++ b/packages/contract/tsconfig.test-d.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noUnusedLocals": false, "noUnusedParameters": false }, + "include": ["src/**/*.test-d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/contract/vitest.config.ts b/packages/contract/vitest.config.ts new file mode 100644 index 0000000..03f7234 --- /dev/null +++ b/packages/contract/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.spec.ts", "src/**/*.test-d.ts", "src/test-fixtures.ts"], + thresholds: { lines: 100, functions: 100 }, + }, + }, +}); diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index efab9bf..c2bef5b 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -8,25 +8,41 @@ the same commit, and with `README.md` — the package ships no ## Public surface -- **`HttpModule(name)({ router, prefix?, port?, hostname?, imports?, provides?, exports? })`** +- **`HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports? })`** (`http-module.ts`) — THE way an application declares an HTTP deployment: `Module(name)({...})` plus the router **provider**. It appends `http({ prefix?, port?, hostname? })` to `imports`, prepends the provider to `provides` and `HttpRuntime` to `exports`, and hands the augmented tuples — - `Imports` / `Provides`, readonly and exact + `Imports` / `Provides`, readonly and exact — to di's own `Module(name)({...})`, whose return type IS the sugar's: nothing spelled twice. di exports `AnyModule`, `AnyProvider` and `Exportable` for exactly that (constraining the tuples the way `Module(name)` does); its other module-typing pieces stay internal. (Spelling the return through a named generic alias was tried and removed: declaration emit keeps such an alias unreduced and cannot name imported - modules' internal ports — TS2883, measured.) `router` is a plain + modules' internal ports — TS2883, measured.) `router` is `Provider` — a provider on the starter's own router port, which is what `HttpRouter(contract)(deps, arm)` returns — so a provider of anything else fails at the call, and there is no port to read off it: the starter needs `HttpRouterPort`, and the sugar's job is to provide it. Covered by the package's own `rpc` fixture, which composes `RpcApp` through it. Options `port`/`hostname` pin as for `http()`. + **`authenticator`** is what a marked contract needs — + `HttpAuthenticator

()([deps], { sync })` — and it is a plain optional + field: present, it joins `provides`, which is all discharging di's need + takes. `Auth` is inferred from it and `Provides` spreads + `[Auth] extends [undefined] ? [] : [NonNullable]`, so an **omitted** + authenticator contributes no element and a marked router's need survives + to `start` — di's `UNSATISFIED DEPENDENCIES`, no gate of this package's. + What di cannot see is the **principal**: `AuthenticatorPort`'s service type + is erased to `unknown`, so any authenticator discharges the need whatever it + resolves. That half is checked here instead — `Principal` is inferred from + `router`'s own `readonly principal`, and `Auth`'s constraint requires + `readonly principal: [Principal] extends [never] ? unknown : Principal` — so + a mismatch fails at the `HttpModule(...)` call, while an **unmarked** + router (`Principal` is `never`) accepts any authenticator, since a provider + nothing needs is di's business and not an error to invent. Both are pinned by + `auth.test-d.ts`, on the two different lines they fire at. - **`HttpRouterPort`** (`orpc.ts`, exported from the file for the package's own tests, **not** from `index.ts`) — the router's port, one id, the starter's own: `Port("HttpRouter")` cast to di's `PortClassOf<"HttpRouter", @@ -40,8 +56,8 @@ Router>>`, with the matching `PortInstance` alias. A - **`HttpRouter(contract)(deps, { sync })`** (`orpc.ts`) — contract-first router provider. `Implementation` is the record type: recursing the contract's shape, each `ProcedureContract` becomes - `Parameters["result"]>[0]` — the `.result()` handler `@unthrown/orpc` gives that + `Parameters, +I, O, E>["result"]>[0]` — the `.result()` handler `@unthrown/orpc` gives that procedure's implementer (`import "@unthrown/orpc/extensions/result"` here; `@orpc/contract` and `@unthrown/orpc` are peers for it) — so `sync`'s return is typed by the contract at the call. At runtime `implement(contract)` is @@ -71,9 +87,16 @@ PortInstance<…> }`) rather than the class's own type because a class second overload of `build`) — for `contract: Record`, a record keyed by the contract's own top-level keys, one `HttpController` per key, instead of `(deps, { sync })`. `M` is constrained - `{ readonly [K in keyof C]: ControllerFor }`, and the `controllers` - **parameter** is typed `M & { readonly [K in Exclude]: -never }` — the exactness intersection is on the parameter, not on `M`: a key + `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` + **parameter** is typed `M & { readonly [K in Exclude>]: never }` — the same `Exclude` and the same `Inherit` the + positional arm's `Implementation` carries, so a **root-marked** contract + composes here at all (the phantom key is not a controller to supply) and each + fragment inherits the root's mark (a controller under it types + `context.principal`). Both were missing until `auth.test-d.ts`'s eleventh arm + went in; the marked fixtures in `controller.test-d.ts` mark a **key**, which + is why neither showed there. The exactness intersection is on the parameter, not on `M`: a key `M` has that `C` does not declare types as `never` there, so the call fails to compile rather than silently dropping the key, without the intersection leaking into `M` and collapsing @@ -96,7 +119,15 @@ never }` — the exactness intersection is on the parameter, not on `M`: a key composed and hands back what it built. The gate names the controller deliberately — a fresh `sync` literal over the fragment would pin only that a fragment is a valid contract, the weaker half, which says nothing about - the controller surviving the lift. + the controller surviving the lift. All five are pinned **twice**: once + against a plain contract and once against one whose `orders` fragment is + `authenticated(...)`, so the marker's phantom key cannot quietly break any of + them — the fifth least of all. The same block pins the one direction that + must be refused: a controller whose handler reads `opts.context.principal` + cannot be mounted under an **unmarked** contract key, where nothing would + inject one. The reverse is accepted and correctly so — an unmarked + controller under a marked key is a handler that ignores the principal, which + is contravariantly fine. Covered at runtime by the `rpcSliced` fixture, composing `helloController` and `echoesController` over `slicedContract`'s two fragments. @@ -117,7 +148,150 @@ InstanceType> & { readonly port: PortClassOf> `Config.provider("RelayConfig")(schema)` already uses in this repo. Covered by `controller.spec.ts`'s `controllers` fixture (the port and declared deps a controller carries) and by every gate in `controller.test-d.ts` above. -- **`http({ prefix?, port?, hostname? })` → +- **`@btravstack/contract`'s marker, in the types and at runtime.** + `authenticated(node)` brands a contract node `Authenticated` — an + intersection with a `unique symbol` key set to `true`, no runtime property + and **no principal type** — and `Implementation` branches on + `IsMarked`. A marked **leaf** gets `{ readonly principal: Identity }` in + `ProcedureImplementer`'s **second** type parameter (`TInjectedContext`), so + the principal arrives on `opts.context.principal`: **oRPC's own context + channel**, not a second handler parameter this package invents and not a + wrapper around `.result()`. A marked **record** pushes its marker onto each + child (`Inherit`), so a marked fragment protects every procedure + beneath it, and the record arm walks `Exclude` so the + phantom key never becomes a procedure key. An unmarked leaf keeps today's + spelling, `object`, exactly — which is what makes the negative gate + meaningful, since `DefaultInitialContext` is an empty interface rather than + an index signature. `HasMark` (exported from `orpc.ts` **and** from + `index.ts`) is **whether** a contract marks anything anywhere in its tree — + exactly `true` or exactly `false`, asserted both directions in + `auth.test-d.ts` because a `boolean` result would satisfy either. Pinned by + `auth.test-d.ts`, mutation-checked. What makes the type true at runtime is + `principalMiddleware`, below. +- **`HttpAuthenticator

()([deps], { sync })`, `AuthenticatorPort`, + `Unauthenticated`, `AuthenticatorService

`** (`auth.ts`) — what an + application provides so a marked procedure can name its caller. + `AuthenticatorService

` is + `(headers: IncomingHttpHeaders) => AsyncResult` — + **headers, not the request**: an authenticator has no business reading a + body, and the narrower argument is what keeps it testable without a socket. + `AuthenticatorPort` is `Port("HttpAuthenticator")` cast to + `PortClassOf<"HttpAuthenticator", AuthenticatorService>`, the same + spelling and for the same reason as `HttpRouterPort`; its service type is + **erased to `unknown`** because the principal's type is carried by the + provider instead — `HttpAuthenticator

()` returns + `Provider & { readonly principal: P }`. The + type argument is explicit rather than inferred from `sync`: inference + through a returned function's `AsyncResult` is exactly where a `Principal` + silently widens to `unknown`. `Unauthenticated` is a `TaggedError` with an + **empty payload**: the starter surfaces no reason — a refused caller gets an + `UNAUTHORIZED` and oRPC's default message — so a field here would be + write-only. An authenticator that wants to record why logs it before + returning. Forwarding a reason would put "no such user" versus "bad + signature" in a 401 body by default. +- **`httpAuth()` → `{ HttpController, HttpRouter, HttpAuthenticator }`, + plus `HttpControllerOf` / `HttpRouterOf` / + `HttpAuthenticatorOf`** + (`http-auth.ts`) — **the** place a principal type is stated. + **The contract says whether a route is protected; this says what the + principal is.** `Implementation` and `ContextOf` + carry it: a **marked** leaf's `opts.context.principal` is `Identity`, an + **unmarked** one is still `object`. `Identity = never` is "no factory", and + the top-level `HttpController` / `HttpRouter` are `controllerFor()` / + `routerFor()` — so a marked fragment reached through them types + `principal: never` and **any read of it is a compile error** (measured: + TS2339 on a property of `never`). That is the "use the factory" signal, and + it is the only thing the top-level form can honestly say now that the + contract carries no principal to fall back on. `controllerFor` and + `routerFor` are exported from their own files for this factory alone, not + from `index.ts`. + What it replaced: a principal type named in the contract, which put the + server's own view of a caller — a user id, roles — in the artifact a client + imports, and left a handler unable to see anything the contract had not + published. + It is **per application, not per slice**, and that is forced rather than + chosen: a handler's parameter types are fixed where the arrow is written, so + a composition root cannot retroactively re-type a `sync` callback in another + module. The identity must be in scope where the handler is, and the factory + is how it gets there with no per-call-site annotation. + The three `…Of` aliases exist because a file **exporting** what the + factory returns cannot infer it: a controller's port expands to a type + carrying `@btravstack/contract`'s phantom `unique symbol` (TS2527, measured + on `examples/order-api`, and the same reason `HttpController` / + `HttpRouter` themselves are annotated `ReturnType>` + here). `HttpAuthenticator` is handed back **already applied** — the type + argument it exists to state is what the factory just fixed — so it is called + `HttpAuthenticator([deps], { sync })`. + `HttpModule`'s gate compares the authenticator's principal to the + **router's** identity, both of which come from the same `httpAuth` call in an + ordinary application. Pinned by `auth.test-d.ts`'s arms 12–16 (the identity + on a marked leaf, the top-level form typing `principal: never` on the same + fragment, no principal invented on an unmarked one, the keyed compose, and a + stray authenticator refused) and at runtime by `auth.spec.ts`'s `rpcAuthed` + fixture, whose contract names no identity at all and whose handler reads a + `userId` only the factory typed. +- **`principalMiddleware` and `noAuthenticator`** (`auth.ts`, internal — + **not** exported from `index.ts`, like `HttpHandler`) — the one middleware this package installs, + and only on a marked leaf. It reads the request off oRPC's **initial + context** (`orpc()` now passes `context: { request }` to + `RPCHandler.handle`, which is what initial context is for), calls the + authenticator with its headers, and either injects + `{ context: { principal } }` through `next` or terminates the request. An + `Unauthenticated` becomes `throw new ORPCError("UNAUTHORIZED")` — oRPC's + middleware protocol has no returned-error arm, which is the one place in + this package a `throw` is right, carried by an `unthrown/no-throw` disable + naming why. **No message is derived from the refusal**: oRPC serializes + `message` to the client, so the caller gets oRPC's default `"Unauthorized"` + and the `reason` never leaves the process. Pinned by `auth.spec.ts`'s + _"answers 401 without the authenticator's reason"_, mutation-verified. A + **defect** is rethrown as its own cause instead, so a bug in the + authenticator stays oRPC's `INTERNAL_SERVER_ERROR` collapse rather than + being reported as a rejected caller. +- **The authenticator dependency is conditional, and the two halves must + agree — a disagreement is an auth bypass.** `routerOf` walks the + **contract** alongside the implementer, carrying an `inherited` flag — + `isAuthenticated(node)` answers for one node only, so a marked record's mark + is pushed down by the walk exactly as `Inherit` pushes it in the types + — and a marked leaf becomes + `node.use(principalMiddleware(authenticate)).result(fn)`. **`.use` before + `.result`, never the reverse**: `.result` returns an `ImplementedProcedure` + whose own `.use` has no `.result` left. Three things keep the two halves + from parting, each of which was a live bypass before it was fixed: + - **The walk is seeded with `isAuthenticated(contract)`, not `false`.** The + root node has no `contract[key]` to be read from, so a marked **root** — + `HttpRouter(authenticated(contract))` — would otherwise wrap nothing at + all while `Implementation`'s record arm typed every leaf with a + principal that never arrived. Pinned by `auth.spec.ts`'s + `rpcRootMarked` fixture, mutation-verified. + - **`hasMarked` enters every object, not only plain records**, cycle-guarded + by a `WeakSet` (a schema is free to be recursive). Anything it declines to + enter is a mark it can miss and the walk cannot, and missing one is the + unsafe direction; over-approximating only ever declares an authenticator + nothing uses. + - **A mark with no authenticator behind it fails closed**, through + `auth.ts`'s `noAuthenticator` — an `AuthenticatorService` that refuses + every caller, so the leaf answers `401` instead of serving unprotected. + Unreachable while the two halves agree, which is exactly why it is there. + + When `hasMarked(contract)` answers true, + `AuthenticatorPort` is appended **last** to the provider's dependency array + (so every existing positional service keeps its index; `sync` is called with + the leading slice) and both `build` overloads add + `HasMark extends true ? AuthenticatorPort : never` to the needs channel + plus `readonly identity: Identity` to the result. + A marked router whose root provides no authenticator is therefore di's + existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Whether the + authenticator resolves what the handlers read is the one thing that + gate cannot see, and `HttpModule`'s `authenticator` option is where it is + checked (see the first bullet). Note + `oc.router(...)` **rebuilds** every node, so a marker applied inside a + builder chain is lost — on **both** sides at once + (`AugmentedContractRouter` maps `[K in keyof T]` and answers `never` + for the phantom key, so `IsMarked` loses it too), which makes it a + dropped protection rather than a bypass. `authenticated(...)` is applied to + the finished node, which is what `@btravstack/contract` already documents. + +- **`http({ prefix?, port?, hostname?, plugins?, securityHeaders? })` → `Module`** — the starter, and **the one way HTTP is answered here: oRPC, over its own node adapter**. The @@ -145,7 +319,44 @@ http()], provides: [orderRouter], exports: [HttpRuntime] })` + `runMain(OrderApi)`; a test passes `env: { PORT: "0", HOST: "127.0.0.1" }` to `start`. `HttpInfo` is `{ port }`, published on `Serving.info` once bound; `0` lets the OS pick, read back via - `runtimeInfo()`. + `runtimeInfo()`. **`plugins`** — + `readonly NodeHttpHandlerPlugin[]`, from + `@orpc/server/node` — forwards straight to `new RPCHandler(service, { +plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC + already expresses as handler plugins, so the ordinary use is configuration + rather than a middleware slot for application logic. It threads through all three surfaces on the same + `...(x === undefined ? {} : { x })` spread every other option here uses — + `OrpcOptions.plugins` (`orpc.ts`) → `HttpOptions.plugins` (`http-runtime.ts`) + → `HttpModuleOptions.plugins` (`http-module.ts`) — and needs no generic + parameter on any of the three, since it is a plain optional field like + `prefix`. It is an **honest escape hatch, not a keyhole**: oRPC's + `StandardHandlerPlugin.init` transforms handler options **including + `StandardHandlerOptions.interceptors`**, so a plugin can wrap execution and + an application determined to see a procedure's outcome can get there. What + the option buys is that the ordinary path is visible configuration at the + composition root rather than a middleware slot for application logic — + which is the one thing thesis #3 and the "Not included" bullet below still + refuse, and reaching past it is a visible act rather than the default shape. + `principalMiddleware` (below) is the one per-request hook this package + itself installs, and only on a marked leaf. +- **`securityHeaders`** — `boolean | Readonly>`, + default `true`. **Not** routed through `orpc()`: it stays on `HttpOptions` + after `prefix` and `plugins` are destructured out of `http()`'s options, so + it lands in `socket` — the rest handed to `httpModule` — and is applied by + `http-runtime.ts`'s `listen`, on the raw node listener, **before** + dispatch. That placement, not an oRPC plugin, is deliberate: a plugin only + runs for a request oRPC **matched**, so the runtime's own `404` and `500` + would go out bare — the opposite of what helmet-style headers are for. + `true` applies the package's small default set + (`x-content-type-options: nosniff`, `x-frame-options: DENY`, + `referrer-policy: no-referrer`); `false` disables the feature; a record + replaces the defaults outright. Resolved once per `listen` call, outside + the per-request `createServer` callback, and set as its **first** + statement — before `open.add(response)` — so it covers a served response, + the runtime's `404`, its `500`, and a drained/retired response alike. + `HttpModuleOptions.securityHeaders` (`http-module.ts`) forwards it to + `http()` on the same `...(x === undefined ? {} : { x })` spread every + other option here uses. - **Two gates, both compile-time.** `start`'s phantom rest tuple turns a composition exporting no `HttpRuntime` into an arity error (`NO RUNTIME`); and because the runtime provider depends on the router port **through di**, @@ -183,12 +394,20 @@ HOST: "127.0.0.1" }` to `start`. `HttpInfo` is `{ port }`, published on miss a response with a request in flight; that is why retirement is tracked per-response rather than left to it. - **Not included, deliberately**: any other router or handler (there is no - `handler` option and no listener port to provide — one way), middleware, - `Result` → HTTP status, HTTPS, HTTP/2 — see the package README's _"What it - does not do"_ for why each is a non-goal. + `handler` option and no listener port to provide — one way), a middleware + slot for application logic, `Result` → HTTP status, HTTPS, HTTP/2 — see the + package README's _"What it does not do"_ for why each is a non-goal. + `plugins` (above) is an honest escape hatch rather than a keyhole — a plugin + can reach `StandardHandlerOptions.interceptors` and therefore a procedure's + execution — but the ordinary path is visible configuration at the + composition root, and an application middleware acting on the handler's + `Result` is what stays refused. - Peer dependencies: `@btravstack/core`, `@btravstack/config`, - `@btravstack/di`, `unthrown`, `@orpc/server`, `@orpc/contract`, - `@unthrown/orpc`. Hono and `@hono/node-server` were peers until the second + `@btravstack/di`, `@btravstack/contract`, `unthrown`, `@orpc/server`, + `@orpc/contract`, `@unthrown/orpc`. `@btravstack/contract` is a peer for the + same dual-copy reason as the rest: its marker is a `unique symbol` and two + copies of the package are two different symbols, so a contract marked + against one would read as unmarked here. Hono and `@hono/node-server` were peers until the second code review of PR #40: Hono routed exactly one pattern (`${prefix}/*`) to oRPC's fetch adapter and 404'd the rest, which `@orpc/server/node`'s `RPCHandler.handle(req, res, { prefix })` plus the runtime's own `404` do @@ -214,12 +433,12 @@ prefix })`, unmatched → resolves unwritten), and the `HttpRuntime` provider de `httpModule(socket, orpc({ prefix }))`; the package's own transport specs hand it a bare listener instead. It exists for that second reason only. `httpRuntime`, the runtime value's factory, is internal too. -- **26 specs, 100% lines/functions.** Every app boots through the `boot` +- **40 specs, 100% lines/functions.** Every app boots through the `boot` fixture — `@btravstack/testing`'s `bootFixture()`, which `serve`, `rpc`, `configured` and `appOnPort` depend on — so it is stopped when the test ends, on every exit path, and the teardown is Defect-only: a startup failure (`configured`'s `ConfigInvalid`, `occupied`'s port in use) is the - test's to assert on `app.exited`. `http-runtime.spec.ts` carries 17, + test's to assert on `app.exited`. `http-runtime.spec.ts` carries 21, through `test-fixtures.ts`'s `appOf` — `httpModule({ port: 0, hostname: "127.0.0.1" }, Provider(HttpHandler)({ value: handler }))` — so the guarantees (`404`/`500` fallbacks, the unit open until `'close'`, the drain, @@ -229,13 +448,23 @@ prefix })`, unmatched → resolves unwritten), and the `HttpRuntime` provider de pinned"_, _"pins what it is given and reads the rest from the environment"_, _"fails startup with ConfigInvalid for HttpConfig when PORT is not a port"_, through the `configured` fixture, whose `BoundConfig` provider captures what - the graph bound). `orpc.spec.ts` carries 7 the starter proper answers + the graph bound), and four of them are `securityHeaders`: the defaults on a + served response through `serve`, the same defaults on the runtime's own + `404` through `rpc` — the path a handler plugin would never reach — their + absence when `securityHeaders: false` is pinned, and a **custom record** + applied verbatim (the given headers on the response and the defaults gone, + since the record replaces them rather than extending them), `serve`'s third + argument threading straight into `appOf`. `orpc.spec.ts` carries 8 the starter proper answers for, through the `rpc` fixture — `HttpModule("RpcApp")({ router: greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over a router provider that declares a `Greeter`, with a typed `RPCLink` client: dependencies injected, a nested procedure, a stray implementation key dropped, `prefix` honoured, the runtime's 404 outside and under the prefix, - and oRPC's `INTERNAL_SERVER_ERROR` collapse. `controller.spec.ts` carries the + and oRPC's `INTERNAL_SERVER_ERROR` collapse — plus one through `rpcWithCors`, + a `greet`-only router configured with oRPC's own `CORSHandlerPlugin`, proving + `plugins` reaches `RPCHandler` rather than being silently accepted and + dropped: the plugin, not this package, decided the response's + `access-control-allow-origin`. `controller.spec.ts` carries the remaining 2, through the `controllers` and `rpcSliced` fixtures: a `HttpController` carries the port it was minted under and the deps it declared, and `HttpRouter(contract)({...})` serves a router composed from @@ -243,5 +472,24 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over own fragment of `slicedContract` — with a procedure from each answering through one client, proving every controller's slice was mounted under its own contract key. A process still serves one router (thesis #1); the keyed - form changes how many providers build it, not that fact. `controller.test-d.ts` - is the package's own compile-time gate — see Public surface. + form changes how many providers build it, not that fact. `auth.spec.ts` + carries the last 9, through the `rpcAuthed`, `rpcRootMarked`, + `authedRouterDeps` and `controllers` fixtures — every one of them over a + router, controllers and authenticator minted by ONE `httpAuth()`, + since a contract naming no principal leaves the factory as the only way a + handler gets a readable one. Four are over + `authedContract` — `{ orders: authenticated({ whoami }), health: { ping } }`, + one protected fragment and one public one: the handler reading a `userId` + only the factory typed, a rejected token answering `UNAUTHORIZED` with the + handler never entered, an authenticator's own defect collapsing to + `INTERNAL_SERVER_ERROR` rather than a 401, and an unmarked procedure served + with no credentials at all. Two are over `rootMarkedContract` — + `authenticated({ orders: { whoami } })`, the mark on the **root**, where + there is no `contract[key]` to read it from: a rejected token still gets a + 401 with the handler never entered, and an accepted one still reaches the + handler with its principal. Two are composition-time — the authenticator + appended **last** in both `build` arms, and nothing appended at all when the + contract marks nothing. The ninth is `noAuthenticator` itself, refusing + every caller. + `controller.test-d.ts` is the package's own compile-time gate — see Public + surface. diff --git a/packages/http/README.md b/packages/http/README.md index 3587dff..a95cba7 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -146,6 +146,124 @@ can be served alone, its controller unchanged: the lifted root is declaring the very provider the modulith composed. See [Split a router into controllers](https://btravstack.github.io/start/how-to/split-a-router-into-controllers). +## Protecting a procedure + +A contract can say a procedure needs an authenticated caller. The marker is +`@btravstack/contract`'s, so it lives in the artifact a client holds too — and +it says **whether**, not who: no identity type is named there, so nothing about +the server's view of a caller reaches a client. + +`httpAuth()` is what says **what** the principal is. It is written +once per application and hands back `HttpController`, `HttpRouter` and +`HttpAuthenticator` fixed to that identity: + +```ts +// src/auth.ts — the one file that names the identity +import { + httpAuth, + type HttpControllerOf, + type HttpRouterOf, + type HttpAuthenticatorOf, +} 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; +``` + +The three aliases are annotations, not ceremony: a controller's port expands to +a type carrying the marker's phantom `unique symbol`, which a consumer's +`.d.ts` cannot name. + +```ts +import { authenticated } from "@btravstack/contract"; +import { HttpModule, Unauthenticated } from "@btravstack/http"; +import { oc, type } from "@orpc/contract"; +import { ErrAsync, OkAsync, P } from "unthrown"; + +import { HttpAuthenticator, HttpRouter } from "./auth.js"; + +const ordersContract = authenticated({ + find: oc + .input(type<{ readonly id: string }>()) + .output(type()) + .errors({ NOT_FOUND: { data: type() } }), +}); + +// An ordinary di provider on the starter's port: `deps` are di's, so a JWT +// verifier or a user directory is injected the way any provider's are. It +// takes no type argument — `httpAuth()` already fixed one, which is +// why the authenticator and the controllers cannot disagree. +const bearerAuthenticator = HttpAuthenticator([], { + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId] = token.split(":"); + // Empty is not absent: `Authorization: :` splits into two defined strings, + // and admitting them is admitting an anonymous caller as tenant "". + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ tenantId, userId }); + }, +}); + +// The principal arrives on oRPC's own context channel, typed by `Identity`. +const ordersRouter = HttpRouter({ orders: ordersContract })([FindOrder], { + sync: (find) => ({ + orders: { + find: ({ context, errors }, 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 OrdersApi = HttpModule("OrdersApi")({ + router: ordersRouter, + authenticator: bearerAuthenticator, + imports: [Application, Persistence], +}); +``` + +Every slice imports `HttpController` from `src/auth.ts` instead of from this +package, and its handlers see `Identity` on `context.principal` with no +annotation of their own. It is the **only** way to read one: the top-level +`HttpController` and `HttpRouter` name no identity, so a marked fragment +reached through them types `principal: never` and every read is a compile +error — the signal to use the factory. An unmarked procedure still gets no +principal at all: the contract decides _whether_, the factory decides _what_. + +A marked router carries the authenticator port as a **need**, so forgetting +`authenticator` is an unmet dependency `start` refuses, and supplying one +minted on a different identity is a compile error at the `HttpModule(...)` +call — the router's identity against the authenticator's, both from the same +`httpAuth` call. +A marked record protects every procedure beneath it. `Unauthenticated` carries +**nothing**: the starter surfaces no reason — a rejected caller gets an +`UNAUTHORIZED` and oRPC's default message — so an authenticator that wants to +record why logs it before returning. See +[Protect a procedure](https://btravstack.github.io/start/how-to/protect-a-procedure). + ## What it guarantees Every request produces exactly one completed response, and its unit stays open @@ -158,6 +276,36 @@ nothing. The drain retires busy keep-alive connections; a client's `x-request-id` becomes the unit's `traceId`. The rest is on the [documentation site](https://btravstack.github.io/start/reference/http). +## What it does not do + +- **Any other router or handler.** oRPC through `@orpc/server/node`'s + `RPCHandler` is the one way HTTP is answered here; there is no `handler` + option and no listener port to provide. +- **A middleware slot for application logic.** oRPC's own middleware, inside + the router's procedures, is where that belongs. The one the package installs + itself is `principalMiddleware`, on a marked leaf only. `plugins` is an + honest escape hatch rather than a keyhole — an oRPC plugin's `init` + transforms handler options **including interceptors**, so an application + determined to see a procedure's outcome can get there. What the option buys + is that the ordinary path — CORS, body limits, compression, CSRF — is + configuration a reader can see at the composition root. An application + middleware acting on the handler's `Result` is still what this package + refuses, because it is the one that puts a use case's outcome in the + transport's hands. +- **`Result` → HTTP status.** The router's `.result()` triage owns it, in the + application. +- **Rate limiting.** A per-process counter is the wrong unit: an `api` + deployment is N pods, so a per-process budget is N independent budgets and + none of them is the limit anybody meant. The ingress or gateway is where a + request count is counted once — and an application that wants one anyway + writes an oRPC plugin and passes it through `plugins`. +- **Authorization.** "May this caller do this?" usually depends on the + resource — the order's owner, its state, the row's tenant — which cannot be + answered before the handler has run and fetched it. Authentication, "is + there a principal and what is it?", is answerable before dispatch, and is + the only half the contract carries. +- **HTTPS, HTTP/2.** `node:http` only; terminate TLS at the ingress. + ## License [MIT](./LICENSE) © Benoit TRAVERS diff --git a/packages/http/package.json b/packages/http/package.json index e0be239..373b5db 100644 --- a/packages/http/package.json +++ b/packages/http/package.json @@ -53,6 +53,7 @@ }, "devDependencies": { "@btravstack/config": "workspace:*", + "@btravstack/contract": "workspace:*", "@btravstack/core": "workspace:*", "@btravstack/di": "workspace:*", "@btravstack/testing": "workspace:*", @@ -71,6 +72,7 @@ }, "peerDependencies": { "@btravstack/config": "workspace:^", + "@btravstack/contract": "workspace:^", "@btravstack/core": "workspace:^", "@btravstack/di": "workspace:^", "@orpc/contract": "^2.0.0-beta", diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts new file mode 100644 index 0000000..b09956a --- /dev/null +++ b/packages/http/src/auth.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect } from "vitest"; + +import { Unauthenticated, noAuthenticator } from "./auth.js"; +import { it } from "./test-fixtures.js"; + +describe("an authenticated procedure", () => { + it("hands the handler the identity its factory typed", async ({ rpcAuthed }) => { + // GIVEN a client presenting a token the authenticator accepts + const client = rpcAuthed.clientWith("good"); + + // WHEN a marked procedure reads a field the contract declares nowhere — + // the contract names no identity type, so `httpAuth()` is the + // only thing that could have typed it + await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); + }); + + it("answers 401 without the authenticator's reason, and never runs the handler", async ({ + rpcAuthed, + }) => { + // GIVEN a client presenting a token the authenticator rejects with a reason + const client = rpcAuthed.clientWith("bad"); + + // WHEN a marked procedure is called + const call = client.orders.whoami({ id: "o-1" }).catch((cause: unknown) => cause); + + // THEN the request was refused, the reason stayed in the process, and the + // handler was not entered + await expect( + call.then((error) => ({ + code: (error as { code: string }).code, + message: (error as { message: string }).message, + ran: rpcAuthed.handlerRuns(), + })), + ).resolves.toEqual({ + code: "UNAUTHORIZED", + message: expect.not.stringContaining("not the good token"), + ran: 0, + }); + }); + + it("collapses an authenticator's own defect to a 500, not a 401", async ({ rpcAuthed }) => { + // GIVEN a client presenting the token the authenticator blows up on + const client = rpcAuthed.clientWith("boom"); + + // WHEN a marked procedure is called + const call = client.orders.whoami({ id: "o-1" }).catch((cause: unknown) => cause); + + // THEN the bug is reported as a server error and the handler was not entered + await expect( + call.then((error) => ({ + code: (error as { code: string }).code, + ran: rpcAuthed.handlerRuns(), + })), + ).resolves.toEqual({ code: "INTERNAL_SERVER_ERROR", ran: 0 }); + }); + + it("serves an unmarked procedure with no credentials at all", async ({ rpcAuthed }) => { + // GIVEN a client presenting nothing + const client = rpcAuthed.clientWith(undefined); + + // WHEN an unmarked procedure is called + // THEN it answers + await expect(client.health.ping()).resolves.toEqual({ ok: true }); + }); +}); + +describe("a contract marked at its root", () => { + it("protects every leaf beneath it", async ({ rpcRootMarked }) => { + // GIVEN a client presenting a token the authenticator rejects + const client = rpcRootMarked.clientWith("bad"); + + // WHEN the only procedure — marked by the root alone — is called + const call = client.orders.whoami({ id: "o-1" }).catch((cause: unknown) => cause); + + // THEN the authenticator ran, refused, and the handler was never entered + await expect( + call.then((error) => ({ + code: (error as { code: string }).code, + ran: rpcRootMarked.handlerRuns(), + })), + ).resolves.toEqual({ code: "UNAUTHORIZED", ran: 0 }); + }); + + it("hands the principal to a leaf the root alone marked", async ({ rpcRootMarked }) => { + // GIVEN a client presenting a token the authenticator accepts + const client = rpcRootMarked.clientWith("good"); + + // WHEN the only procedure is called + // THEN the principal the authenticator resolved reached the handler + await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); + }); +}); + +describe("a router over a marked contract", () => { + it("appends the authenticator after the dependencies it already declared", ({ + authedRouterDeps, + }) => { + // GIVEN the same marked contract composed through both arms of HttpRouter + // WHEN each provider's declared dependencies are read + // THEN the authenticator is last in both, so every existing service keeps its index + expect(authedRouterDeps).toEqual({ + keyed: ["AuthedOrders", "AuthedHealth", "HttpAuthenticator"], + positional: ["Greeter", "HttpAuthenticator"], + }); + }); + + it("declares no authenticator when the contract marks nothing", ({ controllers }) => { + // GIVEN a router composed from a controller over an unmarked contract + // WHEN its declared dependencies are read + // THEN nothing was appended — an application with no protected route provides nothing + expect(controllers.unmarkedRouterDeps).toEqual(["HelloController", "EchoesController"]); + }); +}); + +describe("the fail-closed authenticator", () => { + it("refuses every caller, so a mark with nothing behind it is a 401", async () => { + // GIVEN the stand-in a marked leaf gets when no authenticator reached the walk + // WHEN it is asked to name a caller + // THEN it refuses — the safe direction for a disagreement between the two halves + await expect(noAuthenticator({})).resolves.toBeErrWith( + expect.objectContaining({ constructor: Unauthenticated }), + ); + }); +}); diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts new file mode 100644 index 0000000..8303c88 --- /dev/null +++ b/packages/http/src/auth.test-d.ts @@ -0,0 +1,269 @@ +// The type half of the auth marker: a marked contract node types its handler's +// principal on oRPC's own context channel, and an unmarked one does not. Each +// `@ts-expect-error` is an assertion: if one stops erroring, the gate is gone. +import { authenticated, type Authenticated } from "@btravstack/contract"; +import { start } from "@btravstack/core"; +import { Module, Port, Provider } from "@btravstack/di"; +import { oc } from "@orpc/contract"; +import { ErrAsync, OkAsync } from "unthrown"; + +import { HttpAuthenticator, Unauthenticated } from "./auth.js"; +import { HttpController } from "./controller.js"; +import { httpAuth } from "./http-auth.js"; +import { HttpModule } from "./http-module.js"; +import { HttpRouter, type HasMark, type Implementation } from "./orpc.js"; + +type Identity = { readonly userId: string; readonly tenantId: string }; + +const contract = { + orders: authenticated({ place: oc }), + health: { ping: oc }, + quote: authenticated(oc), +}; + +const { + HttpController: IdentityController, + HttpRouter: IdentityRouter, + HttpAuthenticator: IdentityAuthenticator, +} = httpAuth(); + +type Expect = T; +type HandlerContext = H extends (opts: infer O, ...rest: never) => unknown + ? O extends { readonly context: infer Ctx } + ? Ctx + : never + : never; + +type OrdersImpl = Implementation<(typeof contract)["orders"], Identity>; +type HealthImpl = Implementation<(typeof contract)["health"], Identity>; +type QuoteImpl = Implementation<(typeof contract)["quote"], Identity>; + +// 1. A marked RECORD pushes its marker onto every procedure beneath it, and the +// factory's identity arrives on `opts.context` — oRPC's own channel, no +// second handler parameter added by this package. +declare const ordersContext: HandlerContext; +const _inherited: Identity = ordersContext.principal; + +// 2. A marked PROCEDURE protects itself. +declare const quoteContext: HandlerContext; +const _leaf: Identity = quoteContext.principal; + +// 3. The marker's phantom key never becomes a procedure key. +type _OrdersKeys = Expect< + [keyof OrdersImpl] extends ["place"] + ? ["place"] extends [keyof OrdersImpl] + ? true + : false + : false +>; + +// 4. An unmarked procedure's context carries no principal. +declare const healthContext: HandlerContext; +// @ts-expect-error — `principal` is not on an unmarked handler's context +const _none: Identity = healthContext.principal; + +// 5. `HasMark` finds a mark through the nesting, and is EXACTLY `true` — a +// `boolean` result would satisfy both this assertion and its opposite. +type _Marked = Expect< + [HasMark] extends [true] + ? [true] extends [HasMark] + ? true + : false + : false +>; + +// 6. An all-public contract is exactly `false`, on the same footing. +type _Unmarked = Expect< + [HasMark<{ readonly health: { readonly ping: typeof oc } }>] extends [false] + ? [false] extends [HasMark<{ readonly health: { readonly ping: typeof oc } }>] + ? true + : false + : false +>; + +void _inherited; +void _leaf; +void _none; + +// The composition half: a marked contract needs an authenticator, and the +// composition root is where the router and the authenticator meet. The two +// gates below are DIFFERENT gates, and fire at different calls. Whether an +// authenticator is there at all is di's own `UNSATISFIED DEPENDENCIES` at +// `start` (7) — the same arm `examples/order-api/src/needs-gate.test-d.ts` +// pins for the router. Whether it resolves what the handlers read is this +// package's own options check at the `HttpModule(...)` call (8), because +// `AuthenticatorPort`'s service type is erased to `AuthenticatorService< +// unknown>`: the need cannot carry the identity, so only the options type +// can compare it — the ROUTER's identity against the AUTHENTICATOR's, since +// the contract declares none. +const markedRouter = IdentityRouter({ orders: contract.orders, health: contract.health })([], { + sync: () => ({ + orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, + health: { ping: () => OkAsync({ ok: true as const }) }, + }), +}); + +const matching = IdentityAuthenticator([], { + sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), +}); +const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator([], { + sync: () => () => OkAsync({ sub: "s" }), +}); + +const options = { signals: false, probes: false } as const; + +// 7. A marked router with no authenticator supplied carries the port as an +// unmet need — the module builds, `start` refuses it. +const MissingApi = HttpModule("Missing")({ router: markedRouter }); +// @ts-expect-error — UNSATISFIED DEPENDENCIES: nothing provides the authenticator port the marked router needs. +const _missing = start(MissingApi, options); + +// 8. An authenticator minted on a DIFFERENT identity is refused. Unlike 7, +// this one is NOT di's gate and does not wait for `start`: the +// authenticator port's service type is erased to `unknown`, so di sees the +// need discharged. The two identities meet on `HttpModule`'s own options — +// `RouterIdentity` is inferred from the router — which is where it is caught. +// @ts-expect-error — the authenticator's identity is not the router's. +const MismatchedApi = HttpModule("Mismatched")({ router: markedRouter, authenticator: other }); + +// 9. The matching pair compiles. +const WiredApi = HttpModule("Wired")({ router: markedRouter, authenticator: matching }); +const _wired = start(WiredApi, options); + +// 10. An unmarked router with an authenticator supplied is not this package's +// error to raise: di decides, and a provider nothing needs is no defect. +const publicRouter = IdentityRouter({ health: contract.health })([], { + sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), +}); +const _public = start( + HttpModule("Public")({ router: publicRouter, authenticator: matching }), + options, +); + +void _missing; +void MismatchedApi; +void _wired; +void _public; + +// 11. A ROOT-marked contract composes through the KEYED form, and a controller +// under it reads the identity the factory declares. The keyed overload +// must therefore `Exclude` the phantom key from the keys it demands (or the +// record can never be complete) and `Inherit` the root's mark down to each +// fragment (or no controller under it could type `context.principal`) — +// both of which the positional arm already did. `contract.orders` above +// marks a KEY, so neither omission showed there. +declare const ordersFragment: Authenticated<{ readonly whoami: typeof oc }>; +const rootOrders = IdentityController("RootOrders", ordersFragment)([], { + sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), +}); +const rootMarkedContract = authenticated({ orders: { whoami: oc } }); +const _rootKeyed = HttpModule("RootKeyed")({ + router: IdentityRouter(rootMarkedContract)({ orders: rootOrders }), + authenticator: matching, +}); + +void _rootKeyed; + +// The contract says WHETHER a route is protected; the factory says WHAT the +// principal is. The arms below are what makes that division checkable: an +// identity a contract could never have named, and the top-level form — which +// names none — refusing to invent one. + +// 12. A factory-minted controller's MARKED handler sees the factory's identity, +// a type the contract declares nowhere. +const scopedOrders = IdentityController("ScopedOrders", contract.orders)([], { + sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), +}); + +// 13. The top-level `HttpController` mints no identity, so the same marked +// fragment types `principal: never` — the "use the factory" signal, since +// any read of it is a compile error. +void HttpController("ContractOrders", contract.orders)([], { + // @ts-expect-error — no factory, so there is no principal type to read + sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), +}); + +// 14. A factory invents no principal on an UNMARKED fragment: the identity +// reaches a marked leaf and no other. +void IdentityController("ScopedHealth", contract.health)([], { + // @ts-expect-error — `principal` is not on an unmarked handler's context + sync: () => ({ ping: ({ context }) => OkAsync(context.principal.tenantId) }), +}); + +// 15. A factory-minted router composes factory-minted controllers, and the +// `HttpModule` gate checks the authenticator against the ROUTER's identity. +const scopedHealth = IdentityController("ScopedHealthOk", contract.health)([], { + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), +}); +const _scoped = HttpModule("Scoped")({ + router: IdentityRouter({ orders: contract.orders, health: contract.health })({ + orders: scopedOrders, + health: scopedHealth, + }), + authenticator: matching, + provides: [scopedOrders, scopedHealth], +}); + +// 16. An authenticator minted on another identity is still refused, and a +// hand-written `HttpAuthenticator

()` is no way around it. +const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], { + sync: () => () => OkAsync({ sub: "s" }), +}); +const _strayScoped = HttpModule("StrayScoped")({ + router: IdentityRouter({ orders: contract.orders, health: contract.health })({ + orders: scopedOrders, + health: scopedHealth, + }), + // @ts-expect-error — the authenticator's identity is not the router's + authenticator: strayAuthenticator, +}); + +// 17. An authenticator that DECLARES DEPENDENCIES is the documented shape — a +// JWT verifier, a key set, a user directory — and it discharges the gate +// like any other. Pinned because nothing else covers it: every other +// authenticator on this branch takes `[]`, so the one form every adopter +// actually writes was checked by a reviewer's scratch file and by nothing +// that runs. `deps` are di's, so the services arrive positionally and +// `sync` closes over them; what reaches `HttpModule` is still a provider +// on the same identity. +class Verifier extends Port("Verifier")<(token: string) => Identity | undefined> {} + +const verifiedAuthenticator = IdentityAuthenticator([Verifier], { + sync: (verify) => (headers) => { + const claimed = verify(headers.authorization ?? ""); + return claimed === undefined ? ErrAsync(new Unauthenticated()) : OkAsync(claimed); + }, +}); + +const _verified = HttpModule("Verified")({ + router: IdentityRouter({ orders: contract.orders, health: contract.health })({ + orders: scopedOrders, + health: scopedHealth, + }), + authenticator: verifiedAuthenticator, + imports: [ + Module("Verifying")({ + provides: [Provider(Verifier)({ value: () => undefined })], + exports: [Verifier], + }), + ], +}); + +// 18. The dependency does not loosen the identity check: the same declared +// deps with a foreign identity are still refused at the same call. +const verifiedStray = HttpAuthenticator<{ readonly sub: string }>()([Verifier], { + sync: () => () => OkAsync({ sub: "s" }), +}); +const _verifiedStray = HttpModule("VerifiedStray")({ + router: IdentityRouter({ orders: contract.orders, health: contract.health })({ + orders: scopedOrders, + health: scopedHealth, + }), + // @ts-expect-error — declaring deps is no way around the identity gate + authenticator: verifiedStray, +}); + +void _scoped; +void _strayScoped; +void _verified; +void _verifiedStray; diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts new file mode 100644 index 0000000..074a9e7 --- /dev/null +++ b/packages/http/src/auth.ts @@ -0,0 +1,106 @@ +import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; + +import { + Port, + Provider, + type AnyPort, + type PortClassOf, + type PortInstance, + type ServiceOf, +} from "@btravstack/di"; +import { ORPCError } from "@orpc/server"; +import { ErrAsync, TaggedError, type AsyncResult } from "unthrown"; + +/** + * A caller was refused. Carries nothing: the starter surfaces no reason — a + * rejected caller gets an `UNAUTHORIZED` and oRPC's default message — so a + * payload here would be write-only. An authenticator that wants to record why + * logs it before returning this. + */ +export class Unauthenticated extends TaggedError("Unauthenticated") {} + +/** + * What an application provides so a marked procedure can name its caller. + * Headers, not the request: an authenticator has no business reading a body, + * and the narrower argument is what keeps it testable without a socket. + */ +export type AuthenticatorService

= ( + headers: IncomingHttpHeaders, +) => AsyncResult; + +/** + * The authenticator's port — one id, the starter's own, like `HttpRouterPort`. + * The service type is erased to `unknown` because di identifies a port by id; + * the principal's type is carried by the provider `HttpAuthenticator` returns + * and checked where the router and the authenticator meet. + */ +export const AuthenticatorPort = Port("HttpAuthenticator") as PortClassOf< + "HttpAuthenticator", + AuthenticatorService +>; +export type AuthenticatorPort = PortInstance<"HttpAuthenticator", AuthenticatorService>; + +/** + * The authenticator as a provider, with its principal type stated at the call: + * + * ```ts + * export const jwtAuthenticator = HttpAuthenticator()([JwtVerifier], { + * sync: (verify) => (headers) => verify(headers.authorization), + * }); + * ``` + * + * The type argument is explicit rather than inferred from `sync`: inference + * through a returned function's `AsyncResult` is exactly where a `Principal` + * silently widens to `unknown`, and the whole point is that it cannot. + */ +export const HttpAuthenticator = +

() => + ( + deps: D, + options: { + readonly sync: ( + ...services: { [K in keyof D]: ServiceOf> } + ) => AuthenticatorService

; + }, + ): Provider> & { readonly principal: P } => + Provider(AuthenticatorPort)(deps, options as never) as never; + +/** + * Unreachable today, and kept anyway. `routerOf` falls back to this when a + * marked leaf has no authenticator behind it — which `HasMark` and + * `hasMarked` agreeing makes impossible, since a mark anywhere requires one. + * It is two lines of insurance on a seam that has already failed twice, and it + * fails **closed**: every caller refused, never a leaf served unprotected. + * `auth.spec.ts` exercises it directly, because no router can reach it. + */ +export const noAuthenticator: AuthenticatorService = () => ErrAsync(new Unauthenticated()); + +/** + * The one middleware this package installs, and only on a marked leaf. It reads + * the request from oRPC's initial context — which is what initial context is + * for — and either injects the principal or refuses. + */ +export const principalMiddleware = + (authenticate: AuthenticatorService) => + async (options: { + readonly context: { readonly request: IncomingMessage }; + readonly next: (injected: { + readonly context: { readonly principal: unknown }; + }) => Promise; + }): Promise => { + const resolved = await authenticate(options.context.request.headers); + if (resolved.isErr()) { + // No message: oRPC serializes `message` to the client, and a refusal + // has nothing a caller is entitled to. + // oxlint-disable-next-line unthrown/no-throw -- oRPC terminates a request by throwing an ORPCError; its middleware protocol has no returned-error arm to use instead + throw new ORPCError("UNAUTHORIZED"); + } + if (resolved.isDefect()) { + // A defect is a bug in the authenticator, not a refusal. Its own cause + // goes up unchanged so oRPC's INTERNAL_SERVER_ERROR collapse answers it — + // folding it into the 401 above would report a bug as a rejected caller. + // oxlint-disable-next-line unthrown/no-throw -- the only way to hand a defect back to oRPC, whose middleware protocol has no returned-error arm + throw resolved.cause; + } + return options.next({ context: { principal: resolved.value } }); + }; diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index a7f9f89..9902a85 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -1,10 +1,12 @@ // The five compile gates the keyed router form exists to provide. Each // `@ts-expect-error` is an assertion: if one stops erroring, the gate is gone. +import { authenticated } from "@btravstack/contract"; import { Provider } from "@btravstack/di"; import { oc } from "@orpc/contract"; import { OkAsync } from "unthrown"; import { HttpController } from "./controller.js"; +import { httpAuth } from "./http-auth.js"; import { HttpRouter } from "./orpc.js"; const contract = { orders: { place: oc }, users: { find: oc } }; @@ -55,3 +57,57 @@ void HttpRouter(contract)([], { type NeedsOf = T extends Provider ? N : never; type Expect = T; type _ComposedNeedsAreDeclared = Expect<[NeedsOf] extends [never] ? false : true>; + +// All five again, against a contract whose `orders` fragment is MARKED. The +// marker is a phantom key on the fragment, so every gate above has to survive +// it — the fifth especially: a marked slice must still lift out of the composed +// router with its controller unchanged. The contract names no principal, so the +// controllers here come from `httpAuth()`, which is what types one. +const markedContract = { orders: authenticated(contract.orders), users: contract.users }; + +const { HttpController: IdentityController, HttpRouter: IdentityRouter } = httpAuth<{ + readonly userId: string; +}>(); + +const markedOrders = IdentityController("GateMarkedOrders", markedContract.orders)([], { + sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), +}); +const markedUsers = IdentityController("GateMarkedUsers", markedContract.users)([], { + sync: () => ({ find: () => OkAsync("found") }), +}); + +// 1. Every contract key must be covered. +// @ts-expect-error — `users` is missing from the record +void IdentityRouter(markedContract)({ orders: markedOrders }); + +// 2. A key the contract does not declare is rejected. +void IdentityRouter(markedContract)({ + orders: markedOrders, + users: markedUsers, + // @ts-expect-error — `billing` is not in the contract + billing: markedOrders, +}); + +// 3. A controller wired under the wrong key is rejected. +// @ts-expect-error — `users`'s fragment is not the marked `orders`'s +void IdentityRouter(markedContract)({ orders: markedUsers, users: markedOrders }); + +// 4. A procedure the fragment does not declare is rejected inside the controller. +void IdentityController("GateMarkedTypo", markedContract.orders)([], { + // @ts-expect-error — the fragment declares `place`, not `plce` + sync: () => ({ plce: () => OkAsync("placed") }), +}); + +// 5. The do-not-break lift, for a marked fragment. +void IdentityRouter(markedContract.orders)([markedOrders.port], { + sync: (implementation) => implementation, +}); + +// The correct composition still compiles. The other direction is what has to +// be refused: a controller whose handler READS a principal cannot be mounted +// under an unmarked contract key, where nothing would inject one. (The reverse +// — an unmarked controller under a marked key — is accepted, and correctly so: +// a handler that ignores `opts.context.principal` is contravariantly fine.) +void IdentityRouter(markedContract)({ orders: markedOrders, users: markedUsers }); +// @ts-expect-error — `markedOrders` needs a principal the unmarked contract declares nowhere +void IdentityRouter(contract)({ orders: markedOrders, users: markedUsers }); diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 3753abf..39f39e7 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -26,22 +26,31 @@ import type { Implementation } from "./orpc.js"; * and a consumer that exports the provider cannot emit its declaration (TS4023, * measured on `examples/order-api`). */ -export const HttpController = +export const controllerFor = + () => (name: Name, contract: C) => ( deps: D, options: { readonly sync: ( ...services: { [K in keyof D]: ServiceOf> } - ) => Implementation; + ) => Implementation; }, - ): Provider>, never, InstanceType> & { - readonly port: PortClassOf>; + ): Provider>, never, InstanceType> & { + readonly port: PortClassOf>; } => { // The parameter is named, not `_`-prefixed, so it reads as `contract` in the // published `.d.ts` and in an editor hint; nothing needs its value. void contract; // oxlint-disable-next-line typescript/no-extraneous-class -- a port is a phantom token; only a class expression carries the construct signature `PortClassOf` describes - const port = class extends Port(name)> {}; + const port = class extends Port(name)> {}; return Provider(port as never)(deps, options as never) as never; }; + +/** + * The controller, with no server-side identity: a handler under a marked + * fragment sees `principal: never`, so any read of it is a compile error — + * the "use the factory" signal. `httpAuth()` mints the form whose + * handlers see the application's own principal. + */ +export const HttpController: ReturnType> = controllerFor(); diff --git a/packages/http/src/http-auth.ts b/packages/http/src/http-auth.ts new file mode 100644 index 0000000..f481e94 --- /dev/null +++ b/packages/http/src/http-auth.ts @@ -0,0 +1,32 @@ +import { HttpAuthenticator } from "./auth.js"; +import { controllerFor } from "./controller.js"; +import { routerFor } from "./orpc.js"; + +/** + * Mints `HttpController`, `HttpRouter` and `HttpAuthenticator` on one identity — + * the contract says whether a route is protected, this says what the principal + * is. Written once per application, because a handler's parameter types are + * fixed where the arrow is written: a composition root cannot re-type a `sync` + * callback living in a slice's module. See `packages/http/CLAUDE.md`. + */ +export const httpAuth = (): HttpAuth => ({ + HttpController: controllerFor(), + HttpRouter: routerFor(), + HttpAuthenticator: HttpAuthenticator(), +}); + +type HttpAuth = { + readonly HttpController: HttpControllerOf; + readonly HttpRouter: HttpRouterOf; + readonly HttpAuthenticator: HttpAuthenticatorOf; +}; + +/** + * What a consumer annotates with. `Identity` cannot reach a `.d.ts` through the + * inferred type of the call: a controller's port expands to a type carrying + * `@btravstack/contract`'s phantom `unique symbol`, which no consumer can name + * (TS2527, measured on `examples/order-api`). + */ +export type HttpControllerOf = ReturnType>; +export type HttpRouterOf = ReturnType>; +export type HttpAuthenticatorOf = ReturnType>; diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index 331a0ce..54affd5 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -6,7 +6,10 @@ import { type Exportable, type Provider, } from "@btravstack/di"; +import type { DefaultInitialContext } from "@orpc/server"; +import type { NodeHttpHandlerPlugin } from "@orpc/server/node"; +import type { AuthenticatorPort } from "./auth.js"; import { HttpRuntime, http, type HttpConfig } from "./http-runtime.js"; import type { HttpRouterPort } from "./orpc.js"; @@ -16,26 +19,67 @@ type HttpStarter = Module = readonly [...I, HttpStarter]; -/** The router provider plus the application's own — the tuple `Module(name)` is handed. */ -type Provides

= readonly [ +/** + * The router provider, the authenticator when there is one, and the + * application's own — the tuple `Module(name)` is handed. `Auth` is inferred + * from the option, so an omitted authenticator contributes no element and the + * marked router's need for one stays unmet: di's gate, at `start`. + */ +type Provides< + P extends readonly AnyProvider[], + RouterError, + RouterNeeds, + Auth extends AnyProvider | undefined, +> = readonly [ Provider, + ...([Auth] extends [undefined] ? [] : [NonNullable]), ...P, ]; export type HttpModuleOptions< RouterError, RouterNeeds, + RouterIdentity, + Auth extends AnyProvider | undefined, I extends readonly AnyModule[], P extends readonly AnyProvider[], - X extends readonly Exportable, Provides>[], + X extends readonly Exportable, Provides>[], > = { /** The application's oRPC router — `HttpRouter(contract)(deps, arm)`, the provider that builds it from the services its procedures call. */ - readonly router: Provider; + readonly router: Provider & { + readonly identity: RouterIdentity; + }; + /** + * Resolves the principal a marked procedure's handler receives — + * `HttpAuthenticator()([deps], { sync })`. Required exactly when + * the router's contract marks something: a marked router declares + * `AuthenticatorPort` as a need, and di refuses a graph that does not + * discharge it. Whether it resolves what the handlers actually read is the + * one thing that need cannot say — the port's service type is erased — so + * `RouterIdentity`, read off `router`, is what checks it here: the + * authenticator must resolve **at least** the identity the router was minted + * with, so a router from `httpAuth()` refuses an authenticator from + * `httpAuth()`. A router minted by the top-level `HttpRouter` carries no + * identity (`never`), and there is then nothing to compare. + */ + readonly authenticator?: Auth; /** Where the RPC endpoint is mounted. Default `/rpc`. */ readonly prefix?: `/${string}`; /** Pins for a test — otherwise `PORT`/`HOST` from the environment. */ readonly port?: number; readonly hostname?: string; + /** + * oRPC handler plugins — CORS, body limits, compression, CSRF. Transport + * policy configuring the transport; this is NOT a middleware slot for + * application logic, which the package still declines. + */ + readonly plugins?: readonly NodeHttpHandlerPlugin[]; + /** + * Headers set on every response, before dispatch — a listener concern, not + * oRPC's. `true` (default) applies the package's small helmet-style + * default set; `false` disables it; a record replaces it outright. + */ + readonly securityHeaders?: boolean | Readonly>; readonly imports?: I; readonly provides?: P; /** The application's own exports; `HttpRuntime` is added, since `start` resolves it. */ @@ -67,13 +111,20 @@ export const HttpModule = < RouterError, RouterNeeds, + RouterIdentity, + const Auth extends + | (Provider & { + readonly principal: [RouterIdentity] extends [never] ? unknown : RouterIdentity; + }) + | undefined = undefined, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], - const X extends readonly Exportable, Provides>[] = [], + const X extends readonly Exportable, Provides>[] = + [], >( - options: HttpModuleOptions, + options: HttpModuleOptions, ) => { - const { router, prefix, port, hostname } = options; + const { router, authenticator, prefix, port, hostname, plugins, securityHeaders } = options; const imports = (options.imports ?? []) as I; const provides = (options.provides ?? []) as P; const exports = (options.exports ?? []) as X; @@ -81,12 +132,18 @@ export const HttpModule = ...(prefix === undefined ? {} : { prefix }), ...(port === undefined ? {} : { port }), ...(hostname === undefined ? {} : { hostname }), + ...(plugins === undefined ? {} : { plugins }), + ...(securityHeaders === undefined ? {} : { securityHeaders }), }); // di's own `Module(name)({...})` over the augmented tuples: its return // type IS the sugar's — nothing spelled twice. return Module(name)({ imports: [...imports, starter] as Imports, - provides: [router, ...provides] as Provides, + provides: [ + router, + ...(authenticator === undefined ? [] : [authenticator]), + ...provides, + ] as unknown as Provides, exports: [HttpRuntime, ...exports] as readonly [typeof HttpRuntime, ...X], }); }; diff --git a/packages/http/src/http-runtime.spec.ts b/packages/http/src/http-runtime.spec.ts index 2d444ee..ccf1476 100644 --- a/packages/http/src/http-runtime.spec.ts +++ b/packages/http/src/http-runtime.spec.ts @@ -266,6 +266,65 @@ describe("httpRuntime", () => { await expect(held.head()).resolves.toContain("Connection: close"); }); + it("sets the security headers on a served response", async ({ serve }) => { + // GIVEN an app with the default security headers + const { origin } = await serve(); + + // WHEN a request is answered + const response = await fetch(`${origin}/anything`); + + // THEN the defaults are on the response + expect({ + nosniff: response.headers.get("x-content-type-options"), + frame: response.headers.get("x-frame-options"), + referrer: response.headers.get("referrer-policy"), + }).toEqual({ nosniff: "nosniff", frame: "DENY", referrer: "no-referrer" }); + }); + + it("sets them on the runtime's own 404 too", async ({ rpc }) => { + // GIVEN an app whose oRPC handler declines a path + const { origin } = await rpc(); + + // WHEN an unmatched path is requested + const response = await fetch(`${origin}/not-a-procedure`); + + // THEN the headers are there, on the path a handler plugin would never reach + expect({ + status: response.status, + nosniff: response.headers.get("x-content-type-options"), + }).toEqual({ status: 404, nosniff: "nosniff" }); + }); + + it("omits the security headers when securityHeaders is false", async ({ serve }) => { + // GIVEN an app with the feature explicitly disabled + const { origin } = await serve(undefined, undefined, false); + + // WHEN a request is answered + const response = await fetch(origin); + + // THEN none of the default headers are present + expect(response.headers.get("x-content-type-options")).toBeNull(); + }); + + it("applies a custom securityHeaders record verbatim", async ({ serve }) => { + // GIVEN an app whose starter was handed a record of its own + const { origin } = await serve(undefined, undefined, { + "permissions-policy": "geolocation=()", + "x-frame-options": "SAMEORIGIN", + }); + + // WHEN a request is answered + const response = await fetch(origin); + + // THEN exactly that record is on the response — the given value replaces + // the defaults rather than extending them + expect({ + policy: response.headers.get("permissions-policy"), + frame: response.headers.get("x-frame-options"), + nosniff: response.headers.get("x-content-type-options"), + }).toEqual({ policy: "geolocation=()", frame: "SAMEORIGIN", nosniff: null }); + }); + it("ends the socket after a response whose headers were already on the wire when the drain began", async ({ serve, streamedGate, diff --git a/packages/http/src/http-runtime.ts b/packages/http/src/http-runtime.ts index 2c0c5e7..fdff946 100644 --- a/packages/http/src/http-runtime.ts +++ b/packages/http/src/http-runtime.ts @@ -12,6 +12,8 @@ import { type UnitMeta, } from "@btravstack/core"; import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; +import type { DefaultInitialContext } from "@orpc/server"; +import type { NodeHttpHandlerPlugin } from "@orpc/server/node"; import { Err, Ok, OkAsync, fromSafePromise, type AsyncResult, type Result } from "unthrown"; import { HttpHandler } from "./handler.js"; @@ -44,6 +46,31 @@ export type HttpOptions = { readonly prefix?: `/${string}`; readonly port?: number; readonly hostname?: string; + /** + * oRPC handler plugins — CORS, body limits, compression, CSRF. Transport + * policy configuring the transport; this is NOT a middleware slot for + * application logic, which the package still declines. + */ + readonly plugins?: readonly NodeHttpHandlerPlugin[]; + /** + * Headers set on every response, before dispatch — the listener's own + * concern, not oRPC's: a handler plugin only runs for a request oRPC + * matched, so the runtime's own `404`/`500` would go out bare otherwise. + * `true` (default) applies {@link DEFAULT_SECURITY_HEADERS}; `false` + * disables the feature entirely; a record replaces the defaults outright. + */ + readonly securityHeaders?: boolean | Readonly>; +}; + +/** + * Set before dispatch, so they also cover the runtime's own `404` and `500` — + * which is why they are here and not an oRPC plugin: a plugin runs only for a + * request oRPC matched. + */ +const DEFAULT_SECURITY_HEADERS: Readonly> = { + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "no-referrer", }; /** The runtime's port: what `http()` provides, and what the module `start` boots must export. */ @@ -52,10 +79,11 @@ export class HttpRuntime extends RuntimePort> {} const httpRuntime = ( config: ServiceOf, handler: ServiceOf, + securityHeaders: HttpOptions["securityHeaders"], ): Runtime => ({ name: "http", needs: [], - start: (host) => listen(host, config, handler), + start: (host) => listen(host, config, handler, securityHeaders), }); /** @@ -70,10 +98,14 @@ const httpRuntime = ( * overload pair to keep in step. */ export const httpModule = ( - options: { readonly port?: number; readonly hostname?: string }, + options: { + readonly port?: number; + readonly hostname?: string; + readonly securityHeaders?: HttpOptions["securityHeaders"]; + }, handler: Provider, ): Module => { - const { port, hostname } = options; + const { port, hostname, securityHeaders } = options; const config = port !== undefined && hostname !== undefined ? Provider(HttpConfig)({ value: { port, hostname } }) @@ -87,7 +119,9 @@ export const httpModule = ( provides: [ config, handler, - Provider(HttpRuntime)([HttpConfig, HttpHandler], { sync: (c, h) => httpRuntime(c, h) }), + Provider(HttpRuntime)([HttpConfig, HttpHandler], { + sync: (c, h) => httpRuntime(c, h, securityHeaders), + }), ], exports: [HttpRuntime, HttpConfig], }) as unknown as Module; @@ -114,17 +148,36 @@ export const httpModule = ( export const http = ( options: HttpOptions = {}, ): Module => { - const { prefix, ...socket } = options; - return httpModule(socket, orpc(prefix === undefined ? {} : { prefix })); + const { prefix, plugins, ...socket } = options; + return httpModule( + socket, + orpc({ + ...(prefix === undefined ? {} : { prefix }), + ...(plugins === undefined ? {} : { plugins }), + }), + ); }; const listen = ( host: RuntimeHost, options: ServiceOf, handler: ServiceOf, + securityHeaders: HttpOptions["securityHeaders"], ): AsyncResult, RuntimeStartFailed> => fromSafePromise( new Promise, RuntimeStartFailed>>((resolve) => { + // Resolved once, outside the per-request callback: a request answers + // no faster for re-deriving the same record every time. + const headerRecord: Readonly> = + securityHeaders === false + ? {} + : securityHeaders === true || securityHeaders === undefined + ? DEFAULT_SECURITY_HEADERS + : securityHeaders; + // Entries too, not just the record: the loop below runs per request, and + // `Object.entries` would rebuild the same pairs every time. + const headers = Object.entries(headerRecord); + // `close()` waits for every connection to end, and a keep-alive client // holds one open long after its response. Tracking sockets is what lets // `stop` destroy them instead of hanging. @@ -152,6 +205,9 @@ const listen = ( }; const server: Server = createServer((request, response) => { + // FIRST, before dispatch: covers the runtime's own 404/500 and a + // drained/retired response alike, not only what oRPC matched. + for (const [name, value] of headers) response.setHeader(name, value); open.add(response); response.once("close", () => open.delete(response)); if (draining) retire(response); diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 7577f0d..74b6b37 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,6 +1,11 @@ +export { AuthenticatorPort, HttpAuthenticator, Unauthenticated } from "./auth.js"; +export type { AuthenticatorService } from "./auth.js"; export { HttpController } from "./controller.js"; +export { httpAuth } from "./http-auth.js"; +export type { HttpAuthenticatorOf, HttpControllerOf, HttpRouterOf } from "./http-auth.js"; export { HttpModule } from "./http-module.js"; export type { HttpModuleOptions } from "./http-module.js"; export { HttpConfig, HttpRuntime, http } from "./http-runtime.js"; export { HttpRouter } from "./orpc.js"; +export type { HasMark } from "./orpc.js"; export type { HttpInfo, HttpOptions } from "./http-runtime.js"; diff --git a/packages/http/src/orpc.spec.ts b/packages/http/src/orpc.spec.ts index d9b72a3..c2f3f10 100644 --- a/packages/http/src/orpc.spec.ts +++ b/packages/http/src/orpc.spec.ts @@ -80,4 +80,16 @@ describe("http, over a router", () => { // THEN the client sees oRPC's own collapse, not a reset await expect(client.boom()).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" }); }); + + it("runs a plugin it was handed", async ({ rpcWithCors }) => { + // GIVEN an app configured with oRPC's CORS plugin + // WHEN a procedure is called from an origin + const response = await fetch(`${rpcWithCors.url}/rpc/greet`, { + method: "POST", + headers: { "content-type": "application/json", origin: "https://example.test" }, + body: JSON.stringify({ json: { name: "world" } }), + }); + // THEN the plugin decided the response's CORS header + expect(response.headers.get("access-control-allow-origin")).toBe("https://example.test"); + }); }); diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index cc94b58..d28af71 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -1,3 +1,9 @@ +import { + isAuthenticated, + type Authenticated, + type IsMarked, + type PrincipalKey, +} from "@btravstack/contract"; import { Port, Provider, @@ -13,14 +19,26 @@ import { type ProcedureImplementer, type Router, } from "@orpc/server"; -import { RPCHandler } from "@orpc/server/node"; +import { RPCHandler, type NodeHttpHandlerPlugin } from "@orpc/server/node"; import "@unthrown/orpc/extensions/result"; +import { + AuthenticatorPort, + noAuthenticator, + principalMiddleware, + type AuthenticatorService, +} from "./auth.js"; import { HttpHandler } from "./handler.js"; export type OrpcOptions = { /** Where the RPC endpoint is mounted. Default `/rpc`. */ readonly prefix?: `/${string}`; + /** + * oRPC handler plugins — CORS, body limits, compression, CSRF. Transport + * policy configuring the transport; this is NOT a middleware slot for + * application logic, which the package still declines. + */ + readonly plugins?: readonly NodeHttpHandlerPlugin[]; }; /** @@ -57,8 +75,10 @@ export const orpc = (options: OrpcOptions = {}) => { const prefix = options.prefix ?? "/rpc"; return Provider(HttpHandler)([HttpRouterPort], { sync: (service) => { - const rpc = new RPCHandler(service); - return (request, response) => rpc.handle(request, response, { prefix }); + const rpc = new RPCHandler(service, { plugins: [...(options.plugins ?? [])] }); + // The request rides oRPC's initial context so `principalMiddleware` can + // read its headers; nothing else in this package reads it. + return (request, response) => rpc.handle(request, response, { prefix, context: { request } }); }, }); }; @@ -99,70 +119,113 @@ export const orpc = (options: OrpcOptions = {}) => { * fragment is composed as-is rather than re-implemented, and every key of the * contract must be covered — a missing or extra key is a compile error. */ -export const HttpRouter = >(contract: C) => { - // The implementer is walked untyped: `Implementation` above is the - // whole check — a key the contract does not declare is a compile error - // there, and `routerOf` skips one anyway rather than reading `.result` off - // `undefined` — and `implement(contract)`'s own type is a per-contract - // intersection this generic body cannot index into. - const os = implement(contract) as unknown as Record & { - readonly router: (record: Record) => Router>; - }; +export const routerFor = + () => + >(contract: C) => { + // The implementer is walked untyped: `Implementation` above is the + // whole check — a key the contract does not declare is a compile error + // there, and `routerOf` skips one anyway rather than reading `.result` off + // `undefined` — and `implement(contract)`'s own type is a per-contract + // intersection this generic body cannot index into. + const os = implement(contract) as unknown as Record & { + readonly router: (record: Record) => Router>; + }; - function build( - deps: D, - options: { - readonly sync: ( - ...services: { [K in keyof D]: ServiceOf> } - ) => Implementation; - }, - ): Provider< - PortInstance<"HttpRouter", Router>>, - never, - InstanceType - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - }; - function build }>( - controllers: M & { readonly [K in Exclude]: never }, - ): Provider< - PortInstance<"HttpRouter", Router>>, - never, - InstanceType - > & { readonly port: PortClassOf<"HttpRouter", Router>> }; - function build(depsOrControllers: unknown, options?: unknown): unknown { - // `Array.isArray` discriminates the two forms, the same way - // `Provider(port)(depsOrOptions, …)` discriminates its own. - if (Array.isArray(depsOrControllers)) { + function build( + deps: D, + options: { + readonly sync: ( + ...services: { [K in keyof D]: ServiceOf> } + ) => Implementation; + }, + ): Provider< + PortInstance<"HttpRouter", Router>>, + never, + InstanceType | (HasMark extends true ? AuthenticatorPort : never) + > & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly identity: Identity; + }; + function build< + M extends { + readonly [K in Exclude]: ControllerFor< + Inherit>, + Identity + >; + }, + >( + controllers: M & { + readonly [K in Exclude>]: never; + }, + ): Provider< + PortInstance<"HttpRouter", Router>>, + never, + InstanceType | (HasMark extends true ? AuthenticatorPort : never) + > & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly identity: Identity; + }; + function build(depsOrControllers: unknown, options?: unknown): unknown { + // The authenticator is appended LAST to the dependency array so every + // existing positional service keeps the index `sync` already reads it at. + const guarded = hasMarked(contract); + const authenticatorOf = (services: readonly unknown[]): AuthenticatorService => + services.at(-1) as AuthenticatorService; + + // `Array.isArray` discriminates the two forms, the same way + // `Provider(port)(depsOrOptions, …)` discriminates its own. + if (Array.isArray(depsOrControllers)) { + const deps = depsOrControllers as readonly AnyPort[]; + const sync = (...services: readonly unknown[]): Router> => + os.router( + routerOf( + os, + (options as { readonly sync: (...s: readonly unknown[]) => unknown }).sync( + ...services.slice(0, deps.length), + ) as Record, + contract, + isAuthenticated(contract), + guarded ? authenticatorOf(services) : undefined, + ), + ); + return Provider(HttpRouterPort)(guarded ? [...deps, AuthenticatorPort] : deps, { + sync, + } as never); + } + + const entries = Object.entries( + depsOrControllers as Record, + ); const sync = (...services: readonly unknown[]): Router> => os.router( routerOf( os, - (options as { readonly sync: (...s: readonly unknown[]) => unknown }).sync( - ...services, - ) as Record, + Object.fromEntries(entries.map(([key], index) => [key, services[index]])), + contract, + isAuthenticated(contract), + guarded ? authenticatorOf(services) : undefined, ), ); - return Provider(HttpRouterPort)(depsOrControllers as readonly AnyPort[], { sync } as never); + const ports = entries.map(([, controller]) => controller.port); + return Provider(HttpRouterPort)(guarded ? [...ports, AuthenticatorPort] : ports, { + sync, + } as never); } - const entries = Object.entries(depsOrControllers as Record); - const sync = (...services: readonly unknown[]): Router> => - os.router( - routerOf(os, Object.fromEntries(entries.map(([key], index) => [key, services[index]]))), - ); - return Provider(HttpRouterPort)( - entries.map(([, controller]) => controller.port), - { sync } as never, - ); - } + return build; + }; - return build; -}; +/** + * The router, with no server-side identity: a handler under a marked key sees + * `principal: never`, so any read of it is a compile error. That is the + * "use the factory" signal — `httpAuth()` is what mints the form + * whose handlers see the application's own principal. + */ +export const HttpRouter: ReturnType> = routerFor(); /** A controller for one fragment — what `HttpController` returns, as the keyed form consumes it. */ -type ControllerFor = { - readonly port: PortClassOf>; +type ControllerFor = { + readonly port: PortClassOf>; }; /** @@ -172,27 +235,130 @@ type ControllerFor = { * the input is the contract's parsed input, the output its declared output * and the `errors` helpers its declared error map. */ -export type Implementation = +export type Implementation = C extends ProcedureContract - ? Parameters["result"]>[0] - : { readonly [K in keyof C]: C[K] extends RouterContract ? Implementation : never }; + ? Parameters< + ProcedureImplementer< + DefaultInitialContext & object, + ContextOf, + I, + O, + E + >["result"] + >[0] + : { + readonly [K in Exclude]: C[K] extends RouterContract + ? Implementation>, Identity> + : never; + }; -// Walks the implementation record next to the implementer: a function is a -// procedure and becomes `implementer.result(fn)`, anything else is a nested -// router. The types above are the whole check; the walk trusts them, and -// drops a key the implementer has no node for rather than defecting on it. +/** + * What a leaf's handler gets on `opts.context`: the principal when the leaf is + * marked, and `object` — today's spelling, unchanged — when it is not. It rides + * oRPC's own context channel, injected into `ProcedureImplementer`'s second + * type parameter, so this package adds no second handler parameter and wraps no + * `.result()` handler. + * + * The contract says only **whether** a leaf is protected; `Identity` — from + * `httpAuth()` — says **what** the principal is. The top-level + * `HttpRouter` / `HttpController` pass `never`, so a marked leaf reached + * without the factory types `principal: never` and any read of it is a compile + * error: the "use the factory" signal, rather than a principal invented from a + * type the contract no longer carries. + */ +type ContextOf = IsMarked extends true ? { readonly principal: Identity } : object; + +/** + * Pushes a record's marker onto each of its children, so a marked fragment + * protects every procedure beneath it. The runtime walk in `routerOf` carries + * the same fact as an argument; these two must agree. + */ +type Inherit = Marked extends true ? Authenticated : T; + +/** + * Whether the contract marks anything, anywhere — a yes/no, not a type, since + * the contract names no principal. It is what makes the authenticator + * dependency conditional on both `build` overloads, and the type side of the + * `hasMarked` walk below; these two must agree. + */ +export type HasMark = + IsMarked extends true + ? true + : C extends ProcedureContract + ? false + : true extends { + readonly [K in Exclude]: HasMark; + }[Exclude] + ? true + : false; + +/** + * Whether the contract marks anything, anywhere. Walked once, at composition, + * because it is what makes the authenticator dependency conditional: a router + * with no marked leaf declares no such need, so an application with no + * protected route provides nothing. The type side of the same condition is + * `HasMark` on both `build` overloads; these two must agree. + */ +const hasMarked = (node: unknown, seen: WeakSet = new WeakSet()): boolean => { + if (typeof node !== "object" || node === null || seen.has(node)) return false; + // Every object, not only a plain record: `routerOf` reaches a mark through + // whatever `contract[key]` holds, so anything this walk declines to enter is + // a mark it can miss and the walk cannot — and missing one is the unsafe + // direction. `seen` is what makes entering everything terminate, since a + // schema is free to be recursive. + seen.add(node); + if (isAuthenticated(node)) return true; + return Object.values(node as Record).some((child) => hasMarked(child, seen)); +}; + +// Walks the implementation record next to the implementer and the contract: a +// function is a procedure and becomes `implementer.result(fn)`, anything else +// is a nested router. The types above are the whole check; the walk trusts +// them, and drops a key the implementer has no node for rather than defecting +// on it. `inherited` carries a marked record's mark down to its procedures — +// `isAuthenticated` answers for one node only — the same way `Inherit` +// carries it in the types. `.use` must come BEFORE `.result`: `.result` +// returns an `ImplementedProcedure`, whose own `.use` has no `.result` left. const routerOf = ( implementer: Record, implementation: Record, + contract: Record, + inherited: boolean, + authenticate: AuthenticatorService | undefined, ): Record => Object.fromEntries( Object.entries(implementation).flatMap(([key, value]) => { const node = implementer[key] as - | (Record & { readonly result: (fn: unknown) => unknown }) + | (Record & { + readonly result: (fn: unknown) => unknown; + readonly use: (middleware: unknown) => Record & { + readonly result: (fn: unknown) => unknown; + }; + }) | undefined; if (node === undefined) return []; - return typeof value === "function" - ? [[key, node.result(value)]] - : [[key, routerOf(node, value as Record)]]; + const child = contract[key]; + const marked = + inherited || (typeof child === "object" && child !== null && isAuthenticated(child)); + if (typeof value === "function") { + // Fail closed: a mark with no authenticator behind it refuses every + // caller rather than serving the leaf unprotected. + const target = marked + ? node.use(principalMiddleware(authenticate ?? noAuthenticator)) + : node; + return [[key, target.result(value)]]; + } + return [ + [ + key, + routerOf( + node, + value as Record, + (child ?? {}) as Record, + marked, + authenticate, + ), + ], + ]; }), ); diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 402c4b1..18046b8 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -26,19 +26,29 @@ import { createServer } from "node:http"; import { connect, type Socket } from "node:net"; import type { ConfigInvalid, Environment } from "@btravstack/config"; +import { authenticated } from "@btravstack/contract"; import { currentUnit, type RunningApp } from "@btravstack/core"; import { Module, Port, Provider, type ServiceOf } from "@btravstack/di"; import { bootFixture, type Boot } from "@btravstack/testing"; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; -import { oc, type RouterContractClient } from "@orpc/contract"; -import { OkAsync, fromSafePromise } from "unthrown"; +import { oc, type as ocType, type RouterContractClient } from "@orpc/contract"; +import { CORSHandlerPlugin } from "@orpc/server/plugins"; +import { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { test } from "vitest"; +import { Unauthenticated } from "./auth.js"; import { HttpController } from "./controller.js"; import { HttpHandler } from "./handler.js"; +import { httpAuth } from "./http-auth.js"; import { HttpModule } from "./http-module.js"; -import { HttpConfig, HttpRuntime, httpModule, type HttpInfo } from "./http-runtime.js"; +import { + HttpConfig, + HttpRuntime, + httpModule, + type HttpInfo, + type HttpOptions, +} from "./http-runtime.js"; import { HttpRouter } from "./orpc.js"; type Handler = ServiceOf; @@ -49,10 +59,17 @@ type Handler = ServiceOf; * (`404`/`500`, the unit open until `'close'`, the drain) are exercised without * a router in the way. Loopback and an ephemeral port unless told otherwise. */ -const appOf = (handler: Handler, port = 0) => +const appOf = (handler: Handler, port = 0, securityHeaders?: HttpOptions["securityHeaders"]) => Module("App")({ imports: [ - httpModule({ port, hostname: "127.0.0.1" }, Provider(HttpHandler)({ value: handler })), + httpModule( + { + port, + hostname: "127.0.0.1", + ...(securityHeaders === undefined ? {} : { securityHeaders }), + }, + Provider(HttpHandler)({ value: handler }), + ), ], exports: [HttpRuntime], }); @@ -117,6 +134,130 @@ const rpcSlicedAppOf = () => ], }); +/** + * What this deployment knows about a caller. The contract names no identity + * type at all, so the factory is the only place one is stated — and the only + * route by which a handler gets a readable `context.principal`. + */ +type Identity = { readonly tenantId: string; readonly userId: string }; + +const { + HttpController: AuthedController, + HttpRouter: AuthedRouter, + HttpAuthenticator: AuthedAuthenticator, +} = httpAuth(); + +/** One protected fragment and one public one — the marker's runtime half, end to end. */ +const whoami = oc + .input(ocType<{ readonly id: string }>()) + .output(ocType<{ readonly userId: string }>()); +const ping = oc.output(ocType<{ readonly ok: true }>()); +const authedContract = { orders: authenticated({ whoami }), health: { ping } }; + +/** Counted so a test can assert the handler was never entered on a refusal. */ +let authedRuns = 0; + +const authedOrdersController = AuthedController("AuthedOrders", authedContract.orders)([], { + sync: () => ({ + whoami: ({ context }) => { + authedRuns += 1; + return OkAsync({ userId: context.principal.userId }); + }, + }), +}); + +const authedHealthController = AuthedController("AuthedHealth", authedContract.health)([], { + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), +}); + +const authenticator = AuthedAuthenticator([], { + sync: () => (headers) => { + if (headers.authorization === "Bearer boom") { + return OkAsync().map((): Identity => { + // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect + throw new Error("authenticator bug"); + }); + } + return headers.authorization === "Bearer good" + ? OkAsync({ tenantId: "t-good", userId: "u-good" }) + : ErrAsync(new Unauthenticated()); + }, +}); + +const authedRouter = AuthedRouter(authedContract)({ + orders: authedOrdersController, + health: authedHealthController, +}); + +/** + * The same marked contract through the positional form, so where the + * authenticator lands in `deps` is pinned for both arms of `build`. + */ +const authedPositionalRouter = AuthedRouter(authedContract)([Greeter], { + sync: (greeter) => ({ + orders: { + whoami: ({ context }) => OkAsync({ userId: greeter.greet(context.principal.userId) }), + }, + health: { ping: () => OkAsync({ ok: true as const }) }, + }), +}); + +/** `HttpModule` over the protected router, with the authenticator the router now needs. */ +const rpcAuthedAppOf = () => + HttpModule("RpcAuthedApp")({ + router: authedRouter, + port: 0, + hostname: "127.0.0.1", + authenticator, + provides: [authedOrdersController, authedHealthController], + }); + +/** + * The marker on the contract's ROOT, where the walk has no `contract[key]` to + * read it from: every leaf inherits it, the same way `Implementation`'s + * record arm inherits `IsMarked`. + */ +const rootMarkedContract = authenticated({ orders: { whoami } }); + +let rootMarkedRuns = 0; + +const rootMarkedRouter = AuthedRouter(rootMarkedContract)([], { + sync: () => ({ + orders: { + whoami: ({ context }) => { + rootMarkedRuns += 1; + return OkAsync({ userId: context.principal.userId }); + }, + }, + }), +}); + +const rpcRootMarkedAppOf = () => + HttpModule("RpcRootMarkedApp")({ + router: rootMarkedRouter, + port: 0, + hostname: "127.0.0.1", + authenticator, + }); + +/** `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ +const linkOf = (origin: string, token: string | undefined) => + new RPCLink({ + origin, + url: "/rpc", + ...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }), + }); + +/** The marker is erased from the client's view — it is a phantom key, never a procedure. */ +type AuthedClient = RouterContractClient<{ + readonly orders: { readonly whoami: typeof whoami }; + readonly health: { readonly ping: typeof ping }; +}>; + +type RootMarkedClient = RouterContractClient<{ + readonly orders: { readonly whoami: typeof whoami }; +}>; + /** * The same implementation carrying a key the contract never declared — only * reachable past the types (the assertion is the bypass), which is what @@ -139,6 +280,28 @@ const rpcAppOf = (prefix?: `/${string}`, stray = false) => provides: [Provider(Greeter)({ value: { greet: (name) => `hello ${name}` } })], }); +/** + * A one-procedure `greet` contract, named for the plugin test's own request + * path — `greetingContract`'s `hello` would not match `/rpc/greet` and the + * CORS plugin only decorates a MATCHED response. + */ +const corsContract = oc.router({ + greet: oc.input(ocType<{ readonly name: string }>()).output(ocType()), +}); + +const corsRouter = HttpRouter(corsContract)([], { + sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), +}); + +/** The same starter shape as `rpcAppOf`, with oRPC's CORS plugin configured. */ +const rpcWithCorsAppOf = () => + HttpModule("RpcWithCorsApp")({ + router: corsRouter, + port: 0, + hostname: "127.0.0.1", + plugins: [new CORSHandlerPlugin({ origin: () => "https://example.test" })], + }); + /** Whatever `HttpConfig` the graph bound, captured by a provider that depends on it. */ class BoundConfig extends Port("BoundConfig")<{ readonly value: ServiceOf }> {} @@ -214,6 +377,7 @@ export type HttpFixtures = { readonly serve: ( handler?: Handler, unit?: SlowUnit["module"], + securityHeaders?: HttpOptions["securityHeaders"], ) => Promise<{ readonly app: App; readonly origin: string }>; /** * An app whose starter binds `HttpConfig` from `env` (plus whatever `options` @@ -284,8 +448,11 @@ export type HttpFixtures = { }>; readonly stoppedAccepting: (origin: string) => Promise; }; - /** The controllers the keyed router form composes. */ - readonly controllers: { readonly controller: typeof helloController }; + /** The controllers the keyed router form composes, and what the unmarked router they build declares. */ + readonly controllers: { + readonly controller: typeof helloController; + readonly unmarkedRouterDeps: readonly string[]; + }; /** * The starter over a router composed from several controllers, keyed by * the contract — the same shape as `rpc`, but built from `slicedRouter`. @@ -295,14 +462,45 @@ export type HttpFixtures = { readonly origin: string; readonly client: RouterContractClient; }>; + /** + * The starter over a contract whose `orders` fragment is `authenticated(...)`, + * with an authenticator that accepts exactly one token — router, controllers + * and authenticator all minted by one `httpAuth()`. Shut down by + * the fixture; the handler's run count is reset before the test body. + */ + readonly rpcAuthed: { + /** A typed client presenting `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ + readonly clientWith: (token: string | undefined) => AuthedClient; + /** How many times the protected handler has been entered. */ + readonly handlerRuns: () => number; + readonly url: string; + }; + /** + * The starter over a contract whose **root** is `authenticated(...)` — the + * case no `contract[key]` lookup can see. Shut down by the fixture. + */ + readonly rpcRootMarked: { + readonly clientWith: (token: string | undefined) => RootMarkedClient; + readonly handlerRuns: () => number; + }; + /** What each `HttpRouter` arm declares as its dependencies over the same marked contract. */ + readonly authedRouterDeps: { + readonly keyed: readonly string[]; + readonly positional: readonly string[]; + }; + /** The starter over a router with oRPC's CORS plugin configured. Shut down by the fixture. */ + readonly rpcWithCors: { readonly url: string }; }; export const it = test.extend({ boot: bootFixture(), serve: async ({ boot }, use) => { - await use(async (handler = noop, unit) => { - const app = boot(appOf(handler), unit === undefined ? {} : { unit }); + await use(async (handler = noop, unit, securityHeaders) => { + const app = boot( + appOf(handler, undefined, securityHeaders), + unit === undefined ? {} : { unit }, + ); const info = (await app.runtimeInfo()).get(); assert.ok(info !== undefined, "the runtime published no Serving.info"); return { app, origin: `http://127.0.0.1:${info.port}` }; @@ -469,7 +667,10 @@ export const it = test.extend({ // oxlint-disable-next-line no-empty-pattern -- Vitest fixtures require a destructuring pattern; this one depends on no other fixture controllers: async ({}, use) => { - await use({ controller: helloController }); + await use({ + controller: helloController, + unmarkedRouterDeps: slicedRouter.deps.map((dep) => dep.portId), + }); }, rpcSliced: async ({ boot }, use) => { @@ -484,4 +685,46 @@ export const it = test.extend({ return { origin, client }; }); }, + + rpcAuthed: async ({ boot }, use) => { + const app = boot(rpcAuthedAppOf()); + const info = (await app.runtimeInfo()).get(); + assert.ok(info !== undefined, "the runtime published no Serving.info"); + const origin = `http://127.0.0.1:${info.port}`; + authedRuns = 0; + + await use({ + clientWith: (token) => createORPCClient(linkOf(origin, token)), + handlerRuns: () => authedRuns, + url: `${origin}/rpc`, + }); + }, + + rpcRootMarked: async ({ boot }, use) => { + const app = boot(rpcRootMarkedAppOf()); + const info = (await app.runtimeInfo()).get(); + assert.ok(info !== undefined, "the runtime published no Serving.info"); + const origin = `http://127.0.0.1:${info.port}`; + rootMarkedRuns = 0; + + await use({ + clientWith: (token) => createORPCClient(linkOf(origin, token)), + handlerRuns: () => rootMarkedRuns, + }); + }, + + // oxlint-disable-next-line no-empty-pattern -- see above + authedRouterDeps: async ({}, use) => { + await use({ + keyed: authedRouter.deps.map((dep) => dep.portId), + positional: authedPositionalRouter.deps.map((dep) => dep.portId), + }); + }, + + rpcWithCors: async ({ boot }, use) => { + const app = boot(rpcWithCorsAppOf()); + const info = (await app.runtimeInfo()).get(); + assert.ok(info !== undefined, "the runtime published no Serving.info"); + await use({ url: `http://127.0.0.1:${info.port}` }); + }, }); diff --git a/packages/temporal/CLAUDE.md b/packages/temporal/CLAUDE.md index eae694f..1150429 100644 --- a/packages/temporal/CLAUDE.md +++ b/packages/temporal/CLAUDE.md @@ -215,6 +215,17 @@ TemporalConfig, TemporalActivitiesPort as ActivitiesPortOf], { sync })` — - **Not included, deliberately**: `Result` → activity failure, which `declareActivitiesHandler` already owns. Doing it twice is what the removal of the raw-worker path was about. +- **Cross-cutting concerns: the question does not arise here.** There is no + origin, no preflight and no browser, so CORS and security headers are + meaningless on this transport, and the connection is already authenticated — + by Temporal itself, at `TEMPORAL_ADDRESS` and its namespace, before a task is + polled. Per-activity identity is a **field on the contract's own input**, the + way `tenantId` already is on every workflow and activity input in + `examples/order-temporal-worker`, and nothing this package reads. Limiting + throughput is the Worker's own concurrency options, not a policy slot. + `@btravstack/contract` is dependency-free, so its marker combinator _would_ + work over a Temporal contract; it is deliberately not wired, because there is + nothing here to authenticate **from**. - **`temporal-runtime.spec.ts` carries 13 specs, and `workflow-activities.spec.ts` 2 more — 15 in the package.** One is the published info (_"publishes the task queue and namespace it polls"_), four the starter's configuration (_"binds TEMPORAL_ADDRESS and TEMPORAL_NAMESPACE from the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eea76e0..823e399 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -432,6 +432,9 @@ importers: specifier: 'catalog:' version: 2.0.0-beta.28(@opentelemetry/api@1.9.1) devDependencies: + '@btravstack/contract': + specifier: workspace:* + version: link:../../packages/contract '@btravstack/tsconfig': specifier: 'catalog:' version: 0.2.0 @@ -790,6 +793,27 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) + packages/contract: + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.2.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.10(vitest@4.1.10) + tsdown: + specifier: 'catalog:' + version: 0.22.14(oxc-resolver@11.24.2)(tsx@4.23.12)(typescript@7.0.2) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.12)(yaml@2.9.0) + packages/core: devDependencies: '@btravstack/config': @@ -855,6 +879,9 @@ importers: '@btravstack/config': specifier: workspace:* version: link:../config + '@btravstack/contract': + specifier: workspace:* + version: link:../contract '@btravstack/core': specifier: workspace:* version: link:../core diff --git a/turbo.json b/turbo.json index bdab011..9848ade 100644 --- a/turbo.json +++ b/turbo.json @@ -32,6 +32,7 @@ }, "@btravstack/docs#build": { "dependsOn": [ + "@btravstack/contract#build", "@btravstack/di#build", "@btravstack/config#build", "@btravstack/core#build",