From ee3c028539b8155e40fc6cc4e4807734a1c5dd76 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 01:29:43 +0200 Subject: [PATCH 01/38] feat(contract): a contract-level marker for an authenticated procedure --- packages/contract/CLAUDE.md | 71 ++++++++++++++++++++++++++ packages/contract/LICENSE | 21 ++++++++ packages/contract/README.md | 33 ++++++++++++ packages/contract/package.json | 62 ++++++++++++++++++++++ packages/contract/src/auth.spec.ts | 44 ++++++++++++++++ packages/contract/src/auth.test-d.ts | 39 ++++++++++++++ packages/contract/src/auth.ts | 54 ++++++++++++++++++++ packages/contract/src/index.ts | 7 +++ packages/contract/src/test-fixtures.ts | 17 ++++++ packages/contract/tsconfig.json | 11 ++++ packages/contract/tsconfig.test-d.json | 6 +++ packages/contract/vitest.config.ts | 14 +++++ pnpm-lock.yaml | 21 ++++++++ 13 files changed, 400 insertions(+) create mode 100644 packages/contract/CLAUDE.md create mode 100644 packages/contract/LICENSE create mode 100644 packages/contract/README.md create mode 100644 packages/contract/package.json create mode 100644 packages/contract/src/auth.spec.ts create mode 100644 packages/contract/src/auth.test-d.ts create mode 100644 packages/contract/src/auth.ts create mode 100644 packages/contract/src/index.ts create mode 100644 packages/contract/src/test-fixtures.ts create mode 100644 packages/contract/tsconfig.json create mode 100644 packages/contract/tsconfig.test-d.json create mode 100644 packages/contract/vitest.config.ts diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md new file mode 100644 index 0000000..c7d7ae1 --- /dev/null +++ b/packages/contract/CLAUDE.md @@ -0,0 +1,71 @@ +# 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 + +- **`auth

()`** (`auth.ts`) — `(): { readonly authenticated: (node: T) => Authenticated }`. Mints the combinator for one + contract's principal type `P`. Call it once per contract, destructure + `authenticated`, and apply it to a record of procedures (protects every + procedure beneath it) or to a single procedure (protects itself). +- **`Authenticated`** — `T & { readonly [PrincipalKey]: P }`. 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. +- **`PrincipalOf`** — `T extends { readonly [PrincipalKey]: infer P } ? P : +never`. Recovers the principal type a node was marked with, `never` when it + carries no marker. +- **`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. + +## 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 `auth()` combinator — 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 +module-private `WeakSet`, keyed by identity. + +**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, 4 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, and two +contracts' markers stay independent. `test-fixtures.ts` provides the +`authenticated` combinator and a one-key `fragment`, both as lazy fixtures. + +## Deferred, deliberately + +Nothing consumes this yet. A later package reads `PrincipalKey` / +`PrincipalOf` off a marked contract to type a handler's context with the +principal, and a starter maps a missing or invalid principal to a transport +error — neither exists here, and this package does not anticipate their +shape. 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..2cb1d97 --- /dev/null +++ b/packages/contract/README.md @@ -0,0 +1,33 @@ +# @btravstack/contract + +> 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. Zero dependencies, zero peers — a client can +> take a contract without the server, and any transport's contract can use +> the same combinator. + +```sh +pnpm add @btravstack/contract +``` + +Node `>=20`. Not yet published: this repository has not cut a release yet. + +## Usage + +```ts +export type Principal = { readonly userId: string }; +const { authenticated } = auth(); + +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. + +## 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..f4fef0c --- /dev/null +++ b/packages/contract/src/auth.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect } from "vitest"; + +import { isAuthenticated } from "./auth.js"; +import { it } from "./test-fixtures.js"; + +describe("authenticated", () => { + it("marks the node it is given", ({ authenticated, 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", ({ authenticated, 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("keeps two contracts' markers independent", ({ authenticated, 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..8a0efe4 --- /dev/null +++ b/packages/contract/src/auth.test-d.ts @@ -0,0 +1,39 @@ +import { describe, test } from "vitest"; + +import type { Authenticated, PrincipalKey, PrincipalOf } from "./auth.js"; + +type Fragment = { readonly place: { readonly kind: "procedure" } }; +type Principal = { readonly userId: string }; + +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("PrincipalOf recovers the principal a node was marked with", () => { + const principal = null as unknown as PrincipalOf>; + const recovered: Principal = principal; + void recovered; + }); + + test("PrincipalOf is never for a node carrying no marker", () => { + // @ts-expect-error PrincipalOf is `never`, nothing is assignable to it + const wrong: PrincipalOf = { userId: "x" } satisfies Principal; + void wrong; + }); + + 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..34eaa36 --- /dev/null +++ b/packages/contract/src/auth.ts @@ -0,0 +1,54 @@ +/** + * The phantom key the marker occupies. Declared, never defined: it exists only + * in the type system, so a marked node carries no runtime property and there is + * nothing for oRPC's `implement()` to walk as a procedure. + */ +declare const PRINCIPAL: unique symbol; + +/** A contract node whose procedures require an authenticated principal of type `P`. */ +export type Authenticated = T & { readonly [PRINCIPAL]: P }; + +/** The marker's key, so a consumer's mapped type can `Exclude` it from `keyof`. */ +export type PrincipalKey = typeof PRINCIPAL; + +/** The principal a node was marked with, or `never` when it carries no marker. */ +export type PrincipalOf = T extends { readonly [PRINCIPAL]: infer P } ? P : never; + +// Identity, not a property: a marked node must stay `===` what the contract +// declared, so `implement()` walks it unchanged and a consumer can still index +// the fragment out of the contract it lives in. +const marked = new WeakSet(); + +/** + * Mints the combinator for one contract's principal type. + * + * ```ts + * export type Principal = { readonly userId: string }; + * const { authenticated } = auth(); + * + * 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. Applied AFTER a builder chain is finished, never inside + * one, so nothing about oRPC's builders has to preserve it. + */ +export const auth =

(): { + readonly authenticated: (node: T) => Authenticated; +} => ({ + 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 and needs no +// redesign: mark the contract root and add `public(node)` that deletes it from +// the set. Add it the first time a route ships unprotected by accident. diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts new file mode 100644 index 0000000..cde1658 --- /dev/null +++ b/packages/contract/src/index.ts @@ -0,0 +1,7 @@ +export { + auth, + isAuthenticated, + type Authenticated, + type PrincipalKey, + type PrincipalOf, +} 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..d3212a6 --- /dev/null +++ b/packages/contract/src/test-fixtures.ts @@ -0,0 +1,17 @@ +import { test } from "vitest"; + +import { auth } from "./auth.js"; + +type Principal = { readonly userId: string }; + +export const it = test.extend<{ + readonly authenticated: ReturnType>["authenticated"]; + readonly fragment: { readonly place: { readonly kind: "procedure" } }; +}>({ + authenticated: async ({}, use) => { + await use(auth().authenticated); + }, + 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/pnpm-lock.yaml b/pnpm-lock.yaml index eea76e0..3d2ba8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -790,6 +790,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': From 92b18e34ac88c070792513c5b5423a967e913e17 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 01:49:53 +0200 Subject: [PATCH 02/38] chore: the ninth package joins the fixed group, the docs build and the surface table --- .changeset/config.json | 1 + CLAUDE.md | 33 ++++++++++++++++---------- docs/.vitepress/config.ts | 1 + docs/scripts/build-api.ts | 1 + docs/typedoc.contract.json | 8 +++++++ packages/contract/src/test-fixtures.ts | 2 ++ turbo.json | 1 + 7 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 docs/typedoc.contract.json 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/CLAUDE.md b/CLAUDE.md index ad28622..87df713 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 @@ -346,6 +350,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/` | @@ -681,7 +686,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 +713,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 +791,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 +809,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..7b3b170 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -202,6 +202,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/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/packages/contract/src/test-fixtures.ts b/packages/contract/src/test-fixtures.ts index d3212a6..a9fc69e 100644 --- a/packages/contract/src/test-fixtures.ts +++ b/packages/contract/src/test-fixtures.ts @@ -8,9 +8,11 @@ export const it = test.extend<{ readonly authenticated: ReturnType>["authenticated"]; 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 authenticated: async ({}, use) => { await use(auth().authenticated); }, + // oxlint-disable-next-line no-empty-pattern -- see above fragment: async ({}, use) => { await use({ place: { kind: "procedure" } }); }, 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", From 7727c3d3f071d637e52ee01a81408b0e0dd31f34 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 02:01:49 +0200 Subject: [PATCH 03/38] feat(http): a marked contract types its handler's principal --- packages/http/CLAUDE.md | 41 +++++++++++++++-- packages/http/package.json | 2 + packages/http/src/auth.test-d.ts | 64 ++++++++++++++++++++++++++ packages/http/src/controller.test-d.ts | 44 ++++++++++++++++++ packages/http/src/orpc.ts | 44 +++++++++++++++++- pnpm-lock.yaml | 3 ++ 6 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 packages/http/src/auth.test-d.ts diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index efab9bf..dc71c4a 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -40,8 +40,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 @@ -96,7 +96,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,6 +125,26 @@ 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. +- **`@btravstack/contract`'s marker, read by the types only (so far).** + `auth

()`'s `authenticated(node)` brands a contract node + `Authenticated` — an intersection with a `unique symbol` key, no + runtime property — and `Implementation` branches on it. A marked **leaf** + gets `{ readonly principal: P }` 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. `ContractPrincipal` (exported from `orpc.ts`, not from + `index.ts`) is the principal a contract declares **anywhere** in its tree, or + `never`. Pinned by `auth.test-d.ts`, mutation-checked. **Nothing injects the + principal at runtime yet** — `routerOf` still walks every leaf as + `node.result(fn)`; the middleware that makes the type true is a later task. + Do not describe the marker as enforced until it is. - **`http({ prefix?, port?, hostname? })` → `Module`** — the starter, and **the one way HTTP is answered here: oRPC, over its own @@ -187,8 +215,11 @@ HOST: "127.0.0.1" }` to `start`. `HttpInfo` is `{ port }`, published on `Result` → HTTP status, HTTPS, HTTP/2 — see the package README's _"What it does not do"_ for why each is a non-goal. - 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 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.test-d.ts b/packages/http/src/auth.test-d.ts new file mode 100644 index 0000000..110c9fc --- /dev/null +++ b/packages/http/src/auth.test-d.ts @@ -0,0 +1,64 @@ +// 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 { auth } from "@btravstack/contract"; +import { oc } from "@orpc/contract"; + +import type { ContractPrincipal, Implementation } from "./orpc.js"; + +type Principal = { readonly userId: string; readonly tenantId: string }; +const { authenticated } = auth(); + +const contract = { + orders: authenticated({ place: oc }), + health: { ping: oc }, + quote: authenticated(oc), +}; + +type Expect = T; +type ContextOf = H extends (opts: infer O, ...rest: never) => unknown + ? O extends { readonly context: infer Ctx } + ? Ctx + : never + : never; + +type OrdersImpl = Implementation<(typeof contract)["orders"]>; +type HealthImpl = Implementation<(typeof contract)["health"]>; +type QuoteImpl = Implementation<(typeof contract)["quote"]>; + +// 1. A marked RECORD pushes its marker onto every procedure beneath it, and the +// principal arrives on `opts.context` — oRPC's own channel, no second +// handler parameter added by this package. +declare const ordersContext: ContextOf; +const _inherited: Principal = ordersContext.principal; + +// 2. A marked PROCEDURE protects itself. +declare const quoteContext: ContextOf; +const _leaf: Principal = 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: ContextOf; +// @ts-expect-error — `principal` is not on an unmarked handler's context +const _none: Principal = healthContext.principal; + +// 5. The principal a contract declares is found through the nesting. +const _found: ContractPrincipal = { userId: "u", tenantId: "t" }; + +// 6. An all-public contract declares no principal at all. +// @ts-expect-error — `ContractPrincipal` of an unmarked tree is `never` +const _absent: ContractPrincipal<{ readonly health: { readonly ping: typeof oc } }> = {}; + +void _inherited; +void _leaf; +void _none; +void _found; +void _absent; diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index a7f9f89..dd0983c 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -1,5 +1,6 @@ // 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 { auth } from "@btravstack/contract"; import { Provider } from "@btravstack/di"; import { oc } from "@orpc/contract"; import { OkAsync } from "unthrown"; @@ -55,3 +56,46 @@ 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. +const { authenticated } = auth<{ readonly userId: string }>(); +const markedContract = { orders: authenticated(contract.orders), users: contract.users }; + +const markedOrders = HttpController("GateMarkedOrders", markedContract.orders)([], { + sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), +}); + +// 1. Every contract key must be covered. +// @ts-expect-error — `users` is missing from the record +void HttpRouter(markedContract)({ orders: markedOrders }); + +// 2. A key the contract does not declare is rejected. +// @ts-expect-error — `billing` is not in the contract +void HttpRouter(markedContract)({ orders: markedOrders, users, 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 HttpRouter(markedContract)({ orders: users, users: markedOrders }); + +// 4. A procedure the fragment does not declare is rejected inside the controller. +void HttpController("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 HttpRouter(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 HttpRouter(markedContract)({ orders: markedOrders, users }); +// @ts-expect-error — `markedOrders` needs a principal the unmarked contract declares nowhere +void HttpRouter(contract)({ orders: markedOrders, users }); diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index cc94b58..37b498c 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -1,3 +1,4 @@ +import type { Authenticated, PrincipalKey, PrincipalOf } from "@btravstack/contract"; import { Port, Provider, @@ -174,8 +175,47 @@ type ControllerFor = { */ export type Implementation = C extends ProcedureContract - ? Parameters["result"]>[0] - : { readonly [K in keyof C]: C[K] extends RouterContract ? Implementation : never }; + ? Parameters< + ProcedureImplementer, I, O, E>["result"] + >[0] + : { + readonly [K in Exclude]: C[K] extends RouterContract + ? Implementation>> + : never; + }; + +/** + * 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. `[X] extends [never]` rather than `X extends never`: a + * bare check distributes over a union and answers `never` for every arm. + */ +type ContextOf = [PrincipalOf] extends [never] + ? object + : { readonly principal: PrincipalOf }; + +/** + * 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 = [P] extends [never] ? T : Authenticated; + +/** + * The principal a contract declares anywhere in its tree, or `never` if none + * does — what a composition root reads to know which authenticator it owes. + */ +export type ContractPrincipal = [PrincipalOf] extends [never] + ? C extends ProcedureContract + ? never + : { + readonly [K in Exclude]: C[K] extends RouterContract + ? ContractPrincipal + : never; + }[Exclude] + : PrincipalOf; // Walks the implementation record next to the implementer: a function is a // procedure and becomes `implementer.result(fn)`, anything else is a nested diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d2ba8a..414e6ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: '@btravstack/config': specifier: workspace:* version: link:../config + '@btravstack/contract': + specifier: workspace:* + version: link:../contract '@btravstack/core': specifier: workspace:* version: link:../core From 58ac55daa3f5db90f44e239b47db2c35c3563359 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 02:15:57 +0200 Subject: [PATCH 04/38] feat(http): resolve a marked procedure's principal from an Authenticator port --- CLAUDE.md | 11 ++- packages/http/CLAUDE.md | 80 ++++++++++++++--- packages/http/src/auth.spec.ts | 76 ++++++++++++++++ packages/http/src/auth.ts | 91 +++++++++++++++++++ packages/http/src/index.ts | 3 + packages/http/src/orpc.ts | 119 ++++++++++++++++++++----- packages/http/src/test-fixtures.ts | 136 +++++++++++++++++++++++++++-- 7 files changed, 477 insertions(+), 39 deletions(-) create mode 100644 packages/http/src/auth.spec.ts create mode 100644 packages/http/src/auth.ts diff --git a/CLAUDE.md b/CLAUDE.md index 87df713..0905897 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -402,11 +402,14 @@ 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 32 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 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 diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index dc71c4a..1858f6c 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -125,7 +125,7 @@ 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. -- **`@btravstack/contract`'s marker, read by the types only (so far).** +- **`@btravstack/contract`'s marker, in the types and at runtime.** `auth

()`'s `authenticated(node)` brands a contract node `Authenticated` — an intersection with a `unique symbol` key, no runtime property — and `Implementation` branches on it. A marked **leaf** @@ -139,12 +139,62 @@ InstanceType> & { readonly port: PortClassOf> 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. `ContractPrincipal` (exported from `orpc.ts`, not from - `index.ts`) is the principal a contract declares **anywhere** in its tree, or - `never`. Pinned by `auth.test-d.ts`, mutation-checked. **Nothing injects the - principal at runtime yet** — `routerOf` still walks every leaf as - `node.result(fn)`; the middleware that makes the type true is a later task. - Do not describe the marker as enforced until it is. + signature. `ContractPrincipal` (exported from `orpc.ts` **and** from + `index.ts`, since a composition root reads it) is the principal a contract + declares **anywhere** in its tree, or `never`. 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` carrying + a `reason` — for the operator's log, not the client's body. +- **`principalMiddleware`** (`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. 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.** `routerOf` now 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. `hasMarked(contract)` is the runtime + half of the condition, walked once at composition, and only descends into + plain records — a `ProcedureContract` or a schema carries no marker of its + own and a recursive schema would not terminate. When it 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 + `[ContractPrincipal] extends [never] ? never : AuthenticatorPort` to the + needs channel plus `readonly principal: ContractPrincipal` to the result. + A marked router whose root provides no authenticator is therefore di's + existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Note + `oc.router(...)` **rebuilds** every node, so a marker applied inside a + builder chain is lost; `authenticated(...)` is applied to the finished node, + which is what `@btravstack/contract` already documents. - **`http({ prefix?, port?, hostname? })` → `Module`** — the starter, and **the one way HTTP is answered here: oRPC, over its own @@ -245,7 +295,7 @@ 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` +- **32 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 @@ -274,5 +324,15 @@ 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 6, through the `rpcAuthed`, `authedRouterDeps` and + `controllers` fixtures over `authedContract` — `{ orders: +authenticated({ whoami }), health: { ping } }`, one protected fragment and + one public one: the principal reaching the handler, a rejected token + answering `UNAUTHORIZED` with the handler never entered, an authenticator's + own defect collapsing to `INTERNAL_SERVER_ERROR` rather than a 401, an + unmarked procedure served with no credentials at all, and the two + composition-time facts — the authenticator appended **last** in both `build` + arms, and nothing appended at all when the contract marks nothing. + `controller.test-d.ts` is the package's own compile-time gate — see Public + surface. diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts new file mode 100644 index 0000000..cf3e2bf --- /dev/null +++ b/packages/http/src/auth.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect } from "vitest"; + +import { it } from "./test-fixtures.js"; + +describe("an authenticated procedure", () => { + it("hands the principal to the handler", async ({ rpcAuthed }) => { + // GIVEN a client presenting a token the authenticator accepts + const client = rpcAuthed.clientWith("good"); + + // WHEN a marked procedure is called + // THEN the handler saw the principal the authenticator resolved + await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); + }); + + it("answers 401 and never runs the handler when the token is rejected", async ({ rpcAuthed }) => { + // GIVEN a client presenting a token the authenticator rejects + 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 and the handler was not entered + await expect( + call.then((error) => ({ + code: (error as { code: string }).code, + ran: rpcAuthed.handlerRuns(), + })), + ).resolves.toEqual({ code: "UNAUTHORIZED", 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 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"]); + }); +}); diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts new file mode 100644 index 0000000..9cc6660 --- /dev/null +++ b/packages/http/src/auth.ts @@ -0,0 +1,91 @@ +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 { TaggedError, type AsyncResult } from "unthrown"; + +/** Why a caller was refused. The reason is for the operator's log, not the client's body. */ +export class Unauthenticated extends TaggedError("Unauthenticated")<{ + readonly reason: string; +}> {} + +/** + * 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; + +/** + * 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()) { + // 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", { message: resolved.error.reason }); + } + 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/index.ts b/packages/http/src/index.ts index 7577f0d..e5df6d1 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,6 +1,9 @@ +export { AuthenticatorPort, HttpAuthenticator, Unauthenticated } from "./auth.js"; +export type { AuthenticatorService } from "./auth.js"; export { HttpController } from "./controller.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 { ContractPrincipal } from "./orpc.js"; export type { HttpInfo, HttpOptions } from "./http-runtime.js"; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 37b498c..41b18f1 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -1,4 +1,9 @@ -import type { Authenticated, PrincipalKey, PrincipalOf } from "@btravstack/contract"; +import { + isAuthenticated, + type Authenticated, + type PrincipalKey, + type PrincipalOf, +} from "@btravstack/contract"; import { Port, Provider, @@ -17,6 +22,7 @@ import { import { RPCHandler } from "@orpc/server/node"; import "@unthrown/orpc/extensions/result"; +import { AuthenticatorPort, principalMiddleware, type AuthenticatorService } from "./auth.js"; import { HttpHandler } from "./handler.js"; export type OrpcOptions = { @@ -59,7 +65,9 @@ export const orpc = (options: OrpcOptions = {}) => { return Provider(HttpHandler)([HttpRouterPort], { sync: (service) => { const rpc = new RPCHandler(service); - return (request, response) => rpc.handle(request, response, { prefix }); + // 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 } }); }, }); }; @@ -120,42 +128,65 @@ export const HttpRouter = >(contract: C ): Provider< PortInstance<"HttpRouter", Router>>, never, - InstanceType + InstanceType | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) > & { readonly port: PortClassOf<"HttpRouter", Router>>; + readonly principal: ContractPrincipal; }; function build }>( controllers: M & { readonly [K in Exclude]: never }, ): Provider< PortInstance<"HttpRouter", Router>>, never, - InstanceType - > & { readonly port: PortClassOf<"HttpRouter", Router>> }; + | InstanceType + | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) + > & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly principal: ContractPrincipal; + }; 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, + ...services.slice(0, deps.length), ) as Record, + contract, + false, + guarded ? authenticatorOf(services) : undefined, ), ); - return Provider(HttpRouterPort)(depsOrControllers as readonly AnyPort[], { sync } as never); + 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, Object.fromEntries(entries.map(([key], index) => [key, services[index]]))), + routerOf( + os, + Object.fromEntries(entries.map(([key], index) => [key, services[index]])), + contract, + false, + guarded ? authenticatorOf(services) : undefined, + ), ); - return Provider(HttpRouterPort)( - entries.map(([, controller]) => controller.port), - { sync } as never, - ); + const ports = entries.map(([, controller]) => controller.port); + return Provider(HttpRouterPort)(guarded ? [...ports, AuthenticatorPort] : ports, { + sync, + } as never); } return build; @@ -217,22 +248,70 @@ export type ContractPrincipal = [PrincipalOf] exten }[Exclude] : PrincipalOf; -// 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. +/** + * 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 the + * `[ContractPrincipal] extends [never]` arm on both `build` overloads; these + * two must agree. + */ +const hasMarked = (node: unknown): boolean => { + if (typeof node !== "object" || node === null) return false; + if (isAuthenticated(node)) return true; + // Only a plain record is a contract router. Descending into a + // `ProcedureContract` or a schema finds no marker (`authenticated` is applied + // to the node itself) and a recursive schema would not terminate. + const proto: unknown = Object.getPrototypeOf(node); + if (proto !== Object.prototype && proto !== null) return false; + return Object.values(node as Record).some(hasMarked); +}; + +// 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") { + const target = + marked && authenticate !== undefined ? node.use(principalMiddleware(authenticate)) : 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..8d0a191 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -26,15 +26,17 @@ import { createServer } from "node:http"; import { connect, type Socket } from "node:net"; import type { ConfigInvalid, Environment } from "@btravstack/config"; +import { auth } 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 { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { test } from "vitest"; +import { HttpAuthenticator, Unauthenticated } from "./auth.js"; import { HttpController } from "./controller.js"; import { HttpHandler } from "./handler.js"; import { HttpModule } from "./http-module.js"; @@ -117,6 +119,78 @@ const rpcSlicedAppOf = () => ], }); +type AuthedPrincipal = { readonly userId: string }; + +const { authenticated } = auth(); + +/** 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()); +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 = HttpController("AuthedOrders", authedContract.orders)([], { + sync: () => ({ + whoami: ({ context }) => { + authedRuns += 1; + return OkAsync(context.principal); + }, + }), +}); + +const authedHealthController = HttpController("AuthedHealth", authedContract.health)([], { + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), +}); + +const authenticator = HttpAuthenticator()([], { + sync: () => (headers) => { + if (headers.authorization === "Bearer boom") { + return OkAsync().map((): AuthedPrincipal => { + // 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({ userId: "u-good" }) + : ErrAsync(new Unauthenticated({ reason: "not the good token" })); + }, +}); + +const authedRouter = HttpRouter(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 = HttpRouter(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", + provides: [authedOrdersController, authedHealthController, authenticator], + }); + +/** 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 }; +}>; + /** * The same implementation carrying a key the contract never declared — only * reachable past the types (the assertion is the bypass), which is what @@ -284,8 +358,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,6 +372,23 @@ 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. 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; + }; + /** What each `HttpRouter` arm declares as its dependencies over the same marked contract. */ + readonly authedRouterDeps: { + readonly keyed: readonly string[]; + readonly positional: readonly string[]; + }; }; export const it = test.extend({ @@ -469,7 +563,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 +581,33 @@ 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( + new RPCLink({ + origin, + url: "/rpc", + ...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }), + }), + ), + handlerRuns: () => authedRuns, + url: `${origin}/rpc`, + }); + }, + + // 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), + }); + }, }); From b516d10dd53c3a1496a7bbd5ab5ed96404e114c4 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 02:26:17 +0200 Subject: [PATCH 05/38] fix(http): a marked contract root installs the auth middleware, and a bare mark fails closed --- CLAUDE.md | 2 +- packages/http/CLAUDE.md | 70 ++++++++++++++++++++---------- packages/http/src/auth.spec.ts | 39 +++++++++++++++++ packages/http/src/auth.ts | 11 ++++- packages/http/src/orpc.ts | 35 +++++++++------ packages/http/src/test-fixtures.ts | 70 ++++++++++++++++++++++++++---- 6 files changed, 182 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0905897..87afede 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -402,7 +402,7 @@ 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 32 specs, across + owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 35 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 diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 1858f6c..66546f8 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -161,8 +161,8 @@ InstanceType> & { readonly port: PortClassOf> through a returned function's `AsyncResult` is exactly where a `Principal` silently widens to `unknown`. `Unauthenticated` is a `TaggedError` carrying a `reason` — for the operator's log, not the client's body. -- **`principalMiddleware`** (`auth.ts`, internal — **not** exported from - `index.ts`, like `HttpHandler`) — the one middleware this package installs, +- **`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 @@ -175,16 +175,32 @@ InstanceType> & { readonly port: PortClassOf> 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.** `routerOf` now 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 + 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. `hasMarked(contract)` is the runtime - half of the condition, walked once at composition, and only descends into - plain records — a `ProcedureContract` or a schema carries no marker of its - own and a recursive schema would not terminate. When it answers true, + 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 @@ -193,8 +209,12 @@ InstanceType> & { readonly port: PortClassOf> A marked router whose root provides no authenticator is therefore di's existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Note `oc.router(...)` **rebuilds** every node, so a marker applied inside a - builder chain is lost; `authenticated(...)` is applied to the finished node, - which is what `@btravstack/contract` already documents. + builder chain is lost — on **both** sides at once + (`AugmentedContractRouter` maps `[K in keyof T]` and answers `never` + for the phantom key, so `PrincipalOf` 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? })` → `Module`** — the starter, and **the one way HTTP is answered here: oRPC, over its own @@ -295,7 +315,7 @@ 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. -- **32 specs, 100% lines/functions.** Every app boots through the `boot` +- **35 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 @@ -325,14 +345,20 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over 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. `auth.spec.ts` - carries the last 6, through the `rpcAuthed`, `authedRouterDeps` and - `controllers` fixtures over `authedContract` — `{ orders: -authenticated({ whoami }), health: { ping } }`, one protected fragment and - one public one: the principal reaching the handler, a rejected token - answering `UNAUTHORIZED` with the handler never entered, an authenticator's - own defect collapsing to `INTERNAL_SERVER_ERROR` rather than a 401, an - unmarked procedure served with no credentials at all, and the two - composition-time facts — the authenticator appended **last** in both `build` - arms, and nothing appended at all when the contract marks nothing. + carries the last 9, through the `rpcAuthed`, `rpcRootMarked`, + `authedRouterDeps` and `controllers` fixtures. Four are over + `authedContract` — `{ orders: authenticated({ whoami }), health: { ping } }`, + one protected fragment and one public one: the principal reaching the + handler, 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/src/auth.spec.ts b/packages/http/src/auth.spec.ts index cf3e2bf..7ab0b50 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -1,5 +1,6 @@ import { describe, expect } from "vitest"; +import { Unauthenticated, noAuthenticator } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("an authenticated procedure", () => { @@ -54,6 +55,33 @@ describe("an authenticated procedure", () => { }); }); +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, @@ -74,3 +102,14 @@ describe("a router over a marked contract", () => { 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({ reason: "no authenticator", constructor: Unauthenticated }), + ); + }); +}); diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 9cc6660..c1dc302 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -9,7 +9,7 @@ import { type ServiceOf, } from "@btravstack/di"; import { ORPCError } from "@orpc/server"; -import { TaggedError, type AsyncResult } from "unthrown"; +import { ErrAsync, TaggedError, type AsyncResult } from "unthrown"; /** Why a caller was refused. The reason is for the operator's log, not the client's body. */ export class Unauthenticated extends TaggedError("Unauthenticated")<{ @@ -62,6 +62,15 @@ export const HttpAuthenticator = ): Provider> & { readonly principal: P } => Provider(AuthenticatorPort)(deps, options as never) as never; +/** + * What a marked leaf authenticates with when no authenticator reached the walk — + * a state the two halves of the condition agreeing should make unreachable, and + * the reason this exists is that a disagreement must fail **closed**: every + * caller refused, rather than the leaf served unprotected. + */ +export const noAuthenticator: AuthenticatorService = () => + ErrAsync(new Unauthenticated({ reason: "no authenticator" })); + /** * 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 diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 41b18f1..ed4bbdf 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -22,7 +22,12 @@ import { import { RPCHandler } from "@orpc/server/node"; import "@unthrown/orpc/extensions/result"; -import { AuthenticatorPort, principalMiddleware, type AuthenticatorService } from "./auth.js"; +import { + AuthenticatorPort, + noAuthenticator, + principalMiddleware, + type AuthenticatorService, +} from "./auth.js"; import { HttpHandler } from "./handler.js"; export type OrpcOptions = { @@ -163,7 +168,7 @@ export const HttpRouter = >(contract: C ...services.slice(0, deps.length), ) as Record, contract, - false, + isAuthenticated(contract), guarded ? authenticatorOf(services) : undefined, ), ); @@ -179,7 +184,7 @@ export const HttpRouter = >(contract: C os, Object.fromEntries(entries.map(([key], index) => [key, services[index]])), contract, - false, + isAuthenticated(contract), guarded ? authenticatorOf(services) : undefined, ), ); @@ -256,15 +261,16 @@ export type ContractPrincipal = [PrincipalOf] exten * `[ContractPrincipal] extends [never]` arm on both `build` overloads; these * two must agree. */ -const hasMarked = (node: unknown): boolean => { - if (typeof node !== "object" || node === null) return false; +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; - // Only a plain record is a contract router. Descending into a - // `ProcedureContract` or a schema finds no marker (`authenticated` is applied - // to the node itself) and a recursive schema would not terminate. - const proto: unknown = Object.getPrototypeOf(node); - if (proto !== Object.prototype && proto !== null) return false; - return Object.values(node as Record).some(hasMarked); + return Object.values(node as Record).some((child) => hasMarked(child, seen)); }; // Walks the implementation record next to the implementer and the contract: a @@ -297,8 +303,11 @@ const routerOf = ( const marked = inherited || (typeof child === "object" && child !== null && isAuthenticated(child)); if (typeof value === "function") { - const target = - marked && authenticate !== undefined ? node.use(principalMiddleware(authenticate)) : node; + // 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 [ diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 8d0a191..01474cc 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -185,12 +185,52 @@ const rpcAuthedAppOf = () => provides: [authedOrdersController, authedHealthController, authenticator], }); +/** + * 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 `PrincipalOf`. + */ +const rootMarkedContract = authenticated({ orders: { whoami } }); + +let rootMarkedRuns = 0; + +const rootMarkedRouter = HttpRouter(rootMarkedContract)([], { + sync: () => ({ + orders: { + whoami: ({ context }) => { + rootMarkedRuns += 1; + return OkAsync(context.principal); + }, + }, + }), +}); + +const rpcRootMarkedAppOf = () => + HttpModule("RpcRootMarkedApp")({ + router: rootMarkedRouter, + port: 0, + hostname: "127.0.0.1", + provides: [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 @@ -384,6 +424,14 @@ export type HttpFixtures = { 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[]; @@ -590,19 +638,25 @@ export const it = test.extend({ authedRuns = 0; await use({ - clientWith: (token) => - createORPCClient( - new RPCLink({ - origin, - url: "/rpc", - ...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }), - }), - ), + 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({ From 9c0e4e75934fbfb7a43fee8aaf6d0ac4875b52c2 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 02:40:42 +0200 Subject: [PATCH 06/38] feat(http): the authenticator a marked contract needs is an HttpModule option --- packages/http/CLAUDE.md | 27 ++++++++-- packages/http/src/auth.test-d.ts | 79 +++++++++++++++++++++++++++--- packages/http/src/http-module.ts | 53 +++++++++++++++++--- packages/http/src/test-fixtures.ts | 5 +- 4 files changed, 142 insertions(+), 22 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 66546f8..b947f95 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?, 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", @@ -207,7 +223,10 @@ InstanceType> & { readonly port: PortClassOf> `[ContractPrincipal] extends [never] ? never : AuthenticatorPort` to the needs channel plus `readonly principal: ContractPrincipal` to the result. A marked router whose root provides no authenticator is therefore di's - existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Note + existing `UNSATISFIED DEPENDENCIES` gate — no new gate. Whether the + authenticator resolves the contract's **principal** 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` diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 110c9fc..2dc1014 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -2,9 +2,13 @@ // 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 { auth } from "@btravstack/contract"; +import { start } from "@btravstack/core"; import { oc } from "@orpc/contract"; +import { OkAsync } from "unthrown"; -import type { ContractPrincipal, Implementation } from "./orpc.js"; +import { HttpAuthenticator } from "./auth.js"; +import { HttpModule } from "./http-module.js"; +import { HttpRouter, type ContractPrincipal, type Implementation } from "./orpc.js"; type Principal = { readonly userId: string; readonly tenantId: string }; const { authenticated } = auth(); @@ -16,7 +20,7 @@ const contract = { }; type Expect = T; -type ContextOf = H extends (opts: infer O, ...rest: never) => unknown +type HandlerContext = H extends (opts: infer O, ...rest: never) => unknown ? O extends { readonly context: infer Ctx } ? Ctx : never @@ -29,11 +33,11 @@ type QuoteImpl = Implementation<(typeof contract)["quote"]>; // 1. A marked RECORD pushes its marker onto every procedure beneath it, and the // principal arrives on `opts.context` — oRPC's own channel, no second // handler parameter added by this package. -declare const ordersContext: ContextOf; +declare const ordersContext: HandlerContext; const _inherited: Principal = ordersContext.principal; // 2. A marked PROCEDURE protects itself. -declare const quoteContext: ContextOf; +declare const quoteContext: HandlerContext; const _leaf: Principal = quoteContext.principal; // 3. The marker's phantom key never becomes a procedure key. @@ -46,12 +50,19 @@ type _OrdersKeys = Expect< >; // 4. An unmarked procedure's context carries no principal. -declare const healthContext: ContextOf; +declare const healthContext: HandlerContext; // @ts-expect-error — `principal` is not on an unmarked handler's context const _none: Principal = healthContext.principal; -// 5. The principal a contract declares is found through the nesting. -const _found: ContractPrincipal = { userId: "u", tenantId: "t" }; +// 5. The principal a contract declares is found through the nesting — pinned +// both ways, since assignability alone would also hold if it widened. +type _FoundPrincipal = Expect< + [ContractPrincipal] extends [Principal] + ? [Principal] extends [ContractPrincipal] + ? true + : false + : false +>; // 6. An all-public contract declares no principal at all. // @ts-expect-error — `ContractPrincipal` of an unmarked tree is `never` @@ -60,5 +71,57 @@ const _absent: ContractPrincipal<{ readonly health: { readonly ping: typeof oc } void _inherited; void _leaf; void _none; -void _found; void _absent; + +// The composition half: a marked contract needs an authenticator, and the +// composition root is where the router and the authenticator meet. Both gates +// are di's own `UNSATISFIED DEPENDENCIES`, which fires at `start` — the same +// arm `examples/order-api/src/needs-gate.test-d.ts` pins for the router. +const markedRouter = HttpRouter({ orders: contract.orders, health: contract.health })([], { + sync: () => ({ + orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, + health: { ping: () => OkAsync({ ok: true as const }) }, + }), +}); + +const matching = HttpAuthenticator()([], { + sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), +}); +const other = HttpAuthenticator<{ readonly sub: string }>()([], { + 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 whose principal is not the contract's 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 principals meet on `HttpModule`'s own options — +// `Principal` is inferred from the router — which is where it is caught. +// @ts-expect-error — the authenticator's principal is not the contract'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 = HttpRouter({ 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; diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index 331a0ce..08e4795 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -7,6 +7,7 @@ import { type Provider, } from "@btravstack/di"; +import type { AuthenticatorPort } from "./auth.js"; import { HttpRuntime, http, type HttpConfig } from "./http-runtime.js"; import type { HttpRouterPort } from "./orpc.js"; @@ -16,21 +17,46 @@ 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, + Principal, + 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 principal: Principal; + }; + /** + * 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 the contract's own principal is the one + * thing that need cannot say — the port's service type is erased — so + * `Principal`, read off `router`, is what checks it here. + */ + readonly authenticator?: Auth; /** Where the RPC endpoint is mounted. Default `/rpc`. */ readonly prefix?: `/${string}`; /** Pins for a test — otherwise `PORT`/`HOST` from the environment. */ @@ -67,13 +93,20 @@ export const HttpModule = < RouterError, RouterNeeds, + Principal, + const Auth extends + | (Provider & { + readonly principal: [Principal] extends [never] ? unknown : Principal; + }) + | 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 } = options; const imports = (options.imports ?? []) as I; const provides = (options.provides ?? []) as P; const exports = (options.exports ?? []) as X; @@ -86,7 +119,11 @@ export const HttpModule = // 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/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 01474cc..2da377a 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -182,7 +182,8 @@ const rpcAuthedAppOf = () => router: authedRouter, port: 0, hostname: "127.0.0.1", - provides: [authedOrdersController, authedHealthController, authenticator], + authenticator, + provides: [authedOrdersController, authedHealthController], }); /** @@ -210,7 +211,7 @@ const rpcRootMarkedAppOf = () => router: rootMarkedRouter, port: 0, hostname: "127.0.0.1", - provides: [authenticator], + authenticator, }); /** `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ From ca8864fb464785c20bc0662ccc3695b37c849630 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 02:44:52 +0200 Subject: [PATCH 07/38] docs(http): the auth type test's two gates are different gates, at different calls --- packages/http/src/auth.test-d.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 2dc1014..ce5812c 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -74,9 +74,15 @@ void _none; void _absent; // The composition half: a marked contract needs an authenticator, and the -// composition root is where the router and the authenticator meet. Both gates -// are di's own `UNSATISFIED DEPENDENCIES`, which fires at `start` — the same -// arm `examples/order-api/src/needs-gate.test-d.ts` pins for the router. +// 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 the contract's principal 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 principal, so only the options type +// can compare it. const markedRouter = HttpRouter({ orders: contract.orders, health: contract.health })([], { sync: () => ({ orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, From 1b8039b92dd98ea88d5bab5cc6cf5d7c5f0cff46 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 02:52:25 +0200 Subject: [PATCH 08/38] feat(http): forward oRPC handler plugins from http() and HttpModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transport policy — CORS, compression, body limits, CSRF — is already expressed by oRPC as handler plugins. Thread a typed plugins option through OrpcOptions, HttpOptions and HttpModuleOptions into RPCHandler's own constructor, so an application configures it rather than needing a middleware slot this package still declines to open. --- packages/http/CLAUDE.md | 38 +++++++++++++++++++++++------- packages/http/src/http-module.ts | 11 ++++++++- packages/http/src/http-runtime.ts | 18 ++++++++++++-- packages/http/src/orpc.spec.ts | 12 ++++++++++ packages/http/src/orpc.ts | 10 ++++++-- packages/http/src/test-fixtures.ts | 32 +++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 14 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index b947f95..bfdbdbe 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -8,7 +8,7 @@ the same commit, and with `README.md` — the package ships no ## Public surface -- **`HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, imports?, provides?, exports? })`** +- **`HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, 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 @@ -234,7 +234,7 @@ InstanceType> & { readonly port: PortClassOf> dropped protection rather than a bypass. `authenticated(...)` is applied to the finished node, which is what `@btravstack/contract` already documents. -- **`http({ prefix?, port?, hostname? })` → +- **`http({ prefix?, port?, hostname?, plugins? })` → `Module`** — the starter, and **the one way HTTP is answered here: oRPC, over its own node adapter**. The @@ -262,7 +262,21 @@ 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 this is configuration rather than a + middleware slot. 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 **not** the middleware door thesis #3 and the "Not included" + bullet below still refuse: a plugin configures the transport once, at + composition, with no access to a procedure's `Result` or its application + logic — `principalMiddleware` (below) is the one per-request hook this + package installs, and only on a marked leaf. - **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**, @@ -300,9 +314,11 @@ 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 not this: it is transport policy handed to oRPC's own + `RPCHandler`, not a hook an application's use case runs inside of. - Peer dependencies: `@btravstack/core`, `@btravstack/config`, `@btravstack/di`, `@btravstack/contract`, `unthrown`, `@orpc/server`, `@orpc/contract`, `@unthrown/orpc`. `@btravstack/contract` is a peer for the @@ -334,7 +350,7 @@ 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. -- **35 specs, 100% lines/functions.** Every app boots through the `boot` +- **36 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 @@ -349,13 +365,17 @@ 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). `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 diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index 08e4795..cbe4440 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -6,6 +6,8 @@ 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"; @@ -62,6 +64,12 @@ export type HttpModuleOptions< /** 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[]; readonly imports?: I; readonly provides?: P; /** The application's own exports; `HttpRuntime` is added, since `start` resolves it. */ @@ -106,7 +114,7 @@ export const HttpModule = >( options: HttpModuleOptions, ) => { - const { router, authenticator, prefix, port, hostname } = options; + const { router, authenticator, prefix, port, hostname, plugins } = options; const imports = (options.imports ?? []) as I; const provides = (options.provides ?? []) as P; const exports = (options.exports ?? []) as X; @@ -114,6 +122,7 @@ export const HttpModule = ...(prefix === undefined ? {} : { prefix }), ...(port === undefined ? {} : { port }), ...(hostname === undefined ? {} : { hostname }), + ...(plugins === undefined ? {} : { plugins }), }); // di's own `Module(name)({...})` over the augmented tuples: its return // type IS the sugar's — nothing spelled twice. diff --git a/packages/http/src/http-runtime.ts b/packages/http/src/http-runtime.ts index 2c0c5e7..f9a38cc 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,12 @@ 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[]; }; /** The runtime's port: what `http()` provides, and what the module `start` boots must export. */ @@ -114,8 +122,14 @@ 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 = ( 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 ed4bbdf..48fde29 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -19,7 +19,7 @@ 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 { @@ -33,6 +33,12 @@ 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[]; }; /** @@ -69,7 +75,7 @@ export const orpc = (options: OrpcOptions = {}) => { const prefix = options.prefix ?? "/rpc"; return Provider(HttpHandler)([HttpRouterPort], { sync: (service) => { - const rpc = new RPCHandler(service); + 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 } }); diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 2da377a..802b6c1 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -33,6 +33,7 @@ import { bootFixture, type Boot } from "@btravstack/testing"; import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/fetch"; 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"; @@ -254,6 +255,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 }> {} @@ -438,6 +461,8 @@ export type HttpFixtures = { 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({ @@ -665,4 +690,11 @@ export const it = test.extend({ 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}` }); + }, }); From a59371fa6fc21bd54e792eef32170e2fb3876915 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 08:56:43 +0200 Subject: [PATCH 09/38] feat(http): security headers on the listener, covering the runtime's own 404 --- packages/http/CLAUDE.md | 32 +++++++++++++++--- packages/http/src/http-module.ts | 9 ++++- packages/http/src/http-runtime.spec.ts | 40 ++++++++++++++++++++++ packages/http/src/http-runtime.ts | 47 +++++++++++++++++++++++--- packages/http/src/test-fixtures.ts | 27 ++++++++++++--- 5 files changed, 140 insertions(+), 15 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index bfdbdbe..1ee9e74 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -8,7 +8,7 @@ the same commit, and with `README.md` — the package ships no ## Public surface -- **`HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, 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 @@ -234,7 +234,7 @@ InstanceType> & { readonly port: PortClassOf> 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? })` → +- **`http({ prefix?, port?, hostname?, plugins?, securityHeaders? })` → `Module`** — the starter, and **the one way HTTP is answered here: oRPC, over its own node adapter**. The @@ -277,6 +277,24 @@ plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC composition, with no access to a procedure's `Result` or its application logic — `principalMiddleware` (below) is the one per-request hook this package 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**, @@ -350,12 +368,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. -- **36 specs, 100% lines/functions.** Every app boots through the `boot` +- **39 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 20, 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, @@ -365,7 +383,11 @@ 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 8 the starter proper answers + the graph bound), and three 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 — and + their absence when `securityHeaders: false` is pinned, `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: diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index cbe4440..3f752c0 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -70,6 +70,12 @@ export type HttpModuleOptions< * 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. */ @@ -114,7 +120,7 @@ export const HttpModule = >( options: HttpModuleOptions, ) => { - const { router, authenticator, prefix, port, hostname, plugins } = 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; @@ -123,6 +129,7 @@ export const HttpModule = ...(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. diff --git a/packages/http/src/http-runtime.spec.ts b/packages/http/src/http-runtime.spec.ts index 2d444ee..f65e345 100644 --- a/packages/http/src/http-runtime.spec.ts +++ b/packages/http/src/http-runtime.spec.ts @@ -266,6 +266,46 @@ 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("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 f9a38cc..19ca602 100644 --- a/packages/http/src/http-runtime.ts +++ b/packages/http/src/http-runtime.ts @@ -52,6 +52,25 @@ export type HttpOptions = { * 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. */ @@ -60,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), }); /** @@ -78,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 } }) @@ -95,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; @@ -136,9 +162,19 @@ 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 headers: Readonly> = + securityHeaders === false + ? {} + : securityHeaders === true || securityHeaders === undefined + ? DEFAULT_SECURITY_HEADERS + : securityHeaders; + // `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. @@ -166,6 +202,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 Object.entries(headers)) response.setHeader(name, value); open.add(response); response.once("close", () => open.delete(response)); if (draining) retire(response); diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 802b6c1..b3e6972 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -41,7 +41,13 @@ import { HttpAuthenticator, Unauthenticated } from "./auth.js"; import { HttpController } from "./controller.js"; import { HttpHandler } from "./handler.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; @@ -52,10 +58,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], }); @@ -352,6 +365,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` @@ -469,8 +483,11 @@ 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}` }; From 6b2d18c7782bd17aa5b705666314134878189a48 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 09:01:09 +0200 Subject: [PATCH 10/38] feat(example): the orders fragment declares an authenticated principal --- examples/order-api-contract/package.json | 1 + examples/order-api-contract/src/contract.ts | 24 ++++++++++++++++++++- examples/order-api-contract/src/index.ts | 1 + pnpm-lock.yaml | 3 +++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/examples/order-api-contract/package.json b/examples/order-api-contract/package.json index 72b6709..992ef77 100644 --- a/examples/order-api-contract/package.json +++ b/examples/order-api-contract/package.json @@ -15,6 +15,7 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { + "@btravstack/contract": "workspace:*", "@orpc/contract": "catalog:" }, "devDependencies": { diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index a76c44d..0880ae4 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -1,3 +1,4 @@ +import { auth } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; /** @@ -29,6 +30,19 @@ export type Tenanted = { readonly tenantId: string }; /** What a customer looks like on the wire. */ export type CustomerView = { readonly id: string; readonly name: string }; +/** + * Who the caller is, as this API models it. Named in the contract because that + * is what makes a protected route legible to a client: `authenticated` is one + * word in the shared artifact, visible in a diff and in the generated types. + * + * `tenantId` is on the principal AND on every input, deliberately: moving + * tenancy onto the caller's identity is a separate contract change, and this + * one is about the transport. + */ +export type Principal = { readonly userId: string; readonly tenantId: string }; + +const { authenticated } = auth(); + /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ const ordersContract = { place: oc @@ -65,5 +79,13 @@ 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. */ -export const contract = { orders: ordersContract, customers: customersContract }; +export const contract = { + orders: authenticated(ordersContract), + customers: customersContract, +}; diff --git a/examples/order-api-contract/src/index.ts b/examples/order-api-contract/src/index.ts index 2bc7d18..cc0896e 100644 --- a/examples/order-api-contract/src/index.ts +++ b/examples/order-api-contract/src/index.ts @@ -3,5 +3,6 @@ export { type CustomerView, type OrderRef, type OrderView, + type Principal, type Tenanted, } from "./contract.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 414e6ee..a82df3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -428,6 +428,9 @@ importers: examples/order-api-contract: dependencies: + '@btravstack/contract': + specifier: workspace:* + version: link:../../packages/contract '@orpc/contract': specifier: 'catalog:' version: 2.0.0-beta.28(@opentelemetry/api@1.9.1) From cd9a90accd618259bbfa92c3e0f223ff83c2d950 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 09:06:42 +0200 Subject: [PATCH 11/38] feat(example): order-api authenticates its callers through the starter's port --- examples/order-api/src/api.spec.ts | 74 +++++++++++++++++++ examples/order-api/src/authenticator.ts | 26 +++++++ examples/order-api/src/client.ts | 9 ++- examples/order-api/src/module.ts | 10 ++- examples/order-api/src/needs-gate.test-d.ts | 46 +++++++++++- .../order-api/src/slices/orders/controller.ts | 21 ++++-- examples/order-api/src/test-fixtures.ts | 37 +++++++++- 7 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 examples/order-api/src/authenticator.ts diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index bf9d012..e8d191e 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -325,6 +325,80 @@ 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 ({ + tenant, + 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({ tenantId: tenant, 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 the tenant the token names, not the one the input claims", 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 claimed = `${tenant}-claimed`; + const client = await clientFor(app); + const claimant = await clientWith(app, `Bearer ${claimed}:u-2`); + + // WHEN the first places an order whose input names the OTHER tenant, and + // that other tenant then looks it up + const found = await client.orders + .place({ tenantId: claimed, id: "o-1", quantity: 2 }) + .flatMap(() => claimant.orders.find({ tenantId: claimed, id: "o-1" })); + + // THEN the write landed under the authenticated tenant and not the claimed + // one: the input's `tenantId` is still on the wire and the handler reads + // `context.principal.tenantId` instead, so a caller cannot name the tenant + // it is served + 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, diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts new file mode 100644 index 0000000..f498b3c --- /dev/null +++ b/examples/order-api/src/authenticator.ts @@ -0,0 +1,26 @@ +import type { Principal } from "@btravstack/example-order-api-contract"; +import { HttpAuthenticator, Unauthenticated } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; + +/** + * 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 principal type is stated at the call rather than + * inferred, which is what makes a token resolving to the wrong shape a compile + * error at `HttpModule(...)` instead of an `unknown` reaching a handler. + */ +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({ reason: "no usable bearer token" })) + : 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..e1e152a 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -2,6 +2,7 @@ import { contract } from "@btravstack/example-order-api-contract"; import { HttpModule, HttpRouter } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; +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 +27,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 +48,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..276d909 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,44 @@ 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 the contract's own principal. `AuthenticatorPort`'s service type is +// erased to `unknown`, so di sees the need discharged and would let this +// through — `HttpModuleOptions` compares the two itself, at the +// `HttpModule(...)` call, which is why this directive sits on the option and +// not on a `start` below it. +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 contract's Principal. + authenticator: wrongAuthenticator, + imports: [OrdersSlice, CustomersSlice, observability()], + exports: [Logger], +}); + +void _missingAuthenticator; +void MismatchedApi; diff --git a/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index a670e1f..3ddb2f2 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -24,10 +24,15 @@ 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. `input.tenantId` is still declared by the + * contract and is deliberately NOT what these handlers use: a caller does not + * get to name the tenant it is served, and moving tenancy off the inputs + * altogether is a contract change of its own. The starter still knows nothing + * about tenancy — 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 @@ -38,9 +43,9 @@ export const ordersController = HttpController("OrdersController", contract.orde [PlaceOrder, FindOrder], { sync: (place, find) => ({ - place: ({ errors }, input) => + place: ({ errors, context }, input) => place - .execute(input.tenantId, input.id, input.quantity) + .execute(context.principal.tenantId, input.id, input.quantity) .map(view) .mapErrCases((matcher) => matcher @@ -51,9 +56,9 @@ export const ordersController = HttpController("OrdersController", contract.orde 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 From 782c2ea7b581bcb9ea73d8fb1a950e8231545f50 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 10:34:37 +0200 Subject: [PATCH 12/38] docs(example): the prose says what the marked contract actually does --- examples/order-api-contract/src/contract.ts | 10 ++++++--- examples/order-api/README.md | 23 +++++++++++++++++++-- examples/order-api/src/needs-gate.test-d.ts | 5 +---- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 0880ae4..d1117bd 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -21,9 +21,13 @@ export type OrderRef = { readonly id: string }; * 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. + * repository. + * + * `orders` is where that already moved: the fragment is marked + * `authenticated`, and its handlers serve `Principal.tenantId` rather than + * this field — a caller does not get to name the tenant it is served. The + * field stays declared on every input, `orders` included, because dropping it + * is a separate contract change and this one was about the transport. */ export type Tenanted = { readonly tenantId: string }; diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 65ca1b8..9faa288 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -93,11 +93,21 @@ 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 +that resolves a different principal 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. + 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,9 +173,11 @@ 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({ +const named = (await client.orders.place({ tenantId, id, quantity })).match({ ok: () => "placed", errCases: (matcher) => matcher.with( @@ -177,6 +189,13 @@ 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. The tenant on the input is still declared and still sent; +the server serves the token's, not this one. + 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`. diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 276d909..3a3c97f 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -97,13 +97,10 @@ const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], { sync: () => () => OkAsync({ sub: "s-1" }), }); -const MismatchedApi = HttpModule("MismatchedApi")({ +const _mismatchedApi = HttpModule("MismatchedApi")({ router: orderRouter, // @ts-expect-error — the authenticator resolves `{ sub }`, not the contract's Principal. authenticator: wrongAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); - -void _missingAuthenticator; -void MismatchedApi; From aeef13816574311843177cd0f80c73782a622c99 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 13:38:17 +0200 Subject: [PATCH 13/38] docs: the auth marker, the authenticator, plugins and security headers Adds a reference page for @btravstack/contract and a how-to for protecting a procedure, brings the HTTP reference up to date with authenticator, plugins and securityHeaders, and fixes the testing how-to, whose client called the now protected orders fragment with no credentials. srcExclude keeps VitePress out of docs/superpowers, which was failing the build. --- docs/.vitepress/config.ts | 6 + docs/how-to/protect-a-procedure.md | 244 ++++++++++++++++++++++++++++ docs/how-to/test-an-application.md | 56 +++++-- docs/reference/contract.md | 150 +++++++++++++++++ docs/reference/http.md | 248 +++++++++++++++++++++++------ docs/reference/packages.md | 15 +- 6 files changed, 653 insertions(+), 66 deletions(-) create mode 100644 docs/how-to/protect-a-procedure.md create mode 100644 docs/reference/contract.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 7b3b170..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-]+$/, /^\.\.\//], diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md new file mode 100644 index 0000000..958ceb3 --- /dev/null +++ b/docs/how-to/protect-a-procedure.md @@ -0,0 +1,244 @@ +--- +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 what the contract declared. + +## Recipe + +1. Declare the principal and mark the contract with + `auth()`'s `authenticated`. +2. Write the authenticator with `HttpAuthenticator()([deps], { sync })` + — 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 { auth } from "@btravstack/contract"; +import { oc, type } from "@orpc/contract"; + +/** Who the caller is, as this API models it. */ +export type Principal = { readonly userId: string; readonly tenantId: string }; + +const { authenticated } = auth(); + +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. + +## Step 2 — write the authenticator + +`HttpAuthenticator

()([deps], { sync })` is an ordinary di provider on the +starter's `AuthenticatorPort`. It resolves a principal 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 type { Principal } from "./contract.js"; +import { HttpAuthenticator, Unauthenticated } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; + +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({ reason: "no usable bearer token" })) + : OkAsync({ tenantId, userId }); + }, +}); +``` + +`Bearer :` is a stand-in, not a recommendation — what +matters is the shape. `[]` because this one needs no service; a JWT verifier, a +key set or a user directory 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 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 stating it is what makes a mismatch a compile +error at step 4 instead of an `unknown` reaching a handler. + +`Unauthenticated` carries a `reason` — for the operator's log, not the client's +body. A rejected caller gets an `UNAUTHORIZED` and nothing else. + +## Step 3 — read the principal + +A marked procedure's handler receives the principal on **oRPC's own context +channel**, `opts.context.principal`, typed with what the contract declared. No +second parameter, no wrapper: + +```ts +export const ordersController = HttpController( + "OrdersController", + contract.orders, +)([PlaceOrder, FindOrder], { + sync: (place, find) => ({ + place: ({ errors, context }, input) => + place + .execute(context.principal.tenantId, input.id, input.quantity) + .map(view) + .mapErrCases((matcher) => + matcher + .with(P.tag("InvalidQuantity"), (error) => + errors.INVALID_QUANTITY({ + message: error.message, + data: { id: error.id }, + }), + ) + .with(P.tag("DuplicateOrder"), (error) => + errors.CONFLICT({ + message: error.message, + data: { id: error.id }, + }), + ), + ), + find: ({ errors, context }, input) => + find + .execute(context.principal.tenantId, input.id) + .map(view) + .mapErrCases((matcher) => + matcher.with(P.tag("OrderNotFound"), (error) => + errors.NOT_FOUND({ + message: error.message, + data: { id: error.id }, + }), + ), + ), + }), +}); +``` + +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 that resolves a different principal** 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` checks the principal against the router's own. + +An **unmarked** router 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) — `auth`, `Authenticated`, + `PrincipalKey`, `PrincipalOf`, `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/test-an-application.md b/docs/how-to/test-an-application.md index 04ac596..c2e10be 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -51,18 +51,32 @@ 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 - await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ - id: "o-1", - quantity: 2, - }); + await expect( + client.orders.place({ tenantId, id: "o-1", quantity: 2 }), + ).toBeOkWith({ id: "o-1", quantity: 2 }); }); }); ``` +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 +127,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" }), @@ -124,6 +141,7 @@ const recordingApi = HttpModule("RecordingApi")({ }); it("runs each call in its own unit, with its own trace id", async ({ + tenant, serve, clientFor, }) => { @@ -132,8 +150,10 @@ it("runs each call in its own unit, with its own trace id", async ({ // WHEN two calls are served — chained, so neither `Result` is dropped const served = await client.orders - .place({ id: "o-1", quantity: 1 }) - .flatMap(() => client.orders.place({ id: "o-2", quantity: 1 })); + .place({ tenantId: tenant, id: "o-1", quantity: 1 }) + .flatMap(() => + client.orders.place({ tenantId: tenant, id: "o-2", quantity: 1 }), + ); // THEN four lines, two distinct trace ids, none written outside a unit const traced = served.map(() => ({ @@ -263,8 +283,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 +294,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 +307,13 @@ const recordingApi = () => { }; ``` +The tenant a handler serves is **not** `input.tenantId` any more: the `orders` +handlers read `context.principal.tenantId`, the value the authenticator +resolved from the request's headers. The contract still declares the input +field — dropping it is a separate contract change — and those handlers +deliberately do not use it, which is why a spec's token and its `tenantId` +argument name the same tenant. + `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..d8ed33d --- /dev/null +++ b/docs/reference/contract.md @@ -0,0 +1,150 @@ +--- +title: "@btravstack/contract" +description: The contract-level auth marker — auth(), Authenticated, PrincipalKey, PrincipalOf 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 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 marker over +`WeakSet` identity, transport-agnostic by construction. + +## Exports + +`packages/contract/src/index.ts` exports exactly this: + +| Export | Kind | What it is | +| --------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth` | value | `auth

(): { authenticated: (node: T) => Authenticated }` — mints the combinator for one contract's principal type `P` | +| `isAuthenticated` | value | `(node: object) => boolean` — whether **this exact node** was marked | +| `Authenticated` | type | `T & { readonly [PrincipalKey]: P }` — `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 | +| `PrincipalOf` | type | `T extends { readonly [PrincipalKey]: infer P } ? P : never` — the principal a node was marked with, `never` when it carries none | + +## `auth

()` + +Call it once per contract, destructure `authenticated`, and apply it to a +record of procedures (which protects every procedure beneath it) or to a +single procedure (which protects itself): + +```ts +import { auth } from "@btravstack/contract"; +import { oc, type } from "@orpc/contract"; + +export type Principal = { readonly userId: string; readonly tenantId: string }; + +const { authenticated } = auth(); + +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 }>()), + }, +}; +``` + +The type argument is the whole point of the two-call shape: `P` is stated once, +where the contract declares what a caller looks like, and every marked node +under that contract carries the same principal type. A second contract with a +different principal calls `auth` again. + +## `PrincipalOf` and `isAuthenticated` + +`PrincipalOf` recovers the principal off a node at the type level; +`isAuthenticated` answers the same question at runtime, for one node: + +```ts +import { auth, isAuthenticated, type PrincipalOf } from "@btravstack/contract"; +import { oc, type } from "@orpc/contract"; + +type Principal = { readonly userId: string }; +const { authenticated } = auth(); + +const quote = authenticated( + oc + .input(type<{ readonly id: string }>()) + .output(type<{ readonly total: number }>()), +); + +export type QuotePrincipal = PrincipalOf; // Principal +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. + +## 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 combinator: the marker 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 module-private `WeakSet`, keyed by identity. + +**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.** Turning a request into a principal is + `@btravstack/http`'s `HttpAuthenticator`, 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, or the marker reads as unmarked +`PrincipalKey` is a `unique symbol` and the mark is a module-private +`WeakSet` — two copies of this package are two different symbols and two +different sets, so a contract marked against one reads as unmarked in the +other. `@btravstack/http` peers on it for that reason; an application holds a +single copy. 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..2cc4893 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,22 @@ 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` | +| `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` carrying a `reason` — the refusal, for the operator's log rather than the client's body | +| `ContractPrincipal` | type | `ContractPrincipal` — the principal a contract declares anywhere in its tree, or `never` | +| `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 +45,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. @@ -236,6 +249,99 @@ 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: P }` 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. +`ContractPrincipal` is the principal a contract declares anywhere in its +tree, or `never`. + +**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`. `Unauthenticated` is a +`TaggedError` carrying a `reason`, for the operator's log rather than the +client's body. + +```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({ reason: "no usable bearer token" })) + : OkAsync({ tenantId, userId }); + }, +}); +``` + +### 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 **principal**: `AuthenticatorPort`'s service type is +erased to `unknown`, so any authenticator discharges that need. So +`HttpModule` checks the other half — `Principal` is inferred from the router's +own, and an authenticator resolving something else is a compile error at the +`HttpModule(...)` call. An **unmarked** router 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 +353,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 +372,44 @@ 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 this is **configuration**, not a middleware +slot — `plugins: [new CORSHandlerPlugin({ origin: () => "https://orders.example" })]` +on `HttpModule` or `http()`, with the plugin imported from +`@orpc/server/plugins`. + +A plugin configures the transport once, at composition, with no access to a +procedure's `Result` or to any application logic — which is why it is not the +door [Deliberately not included](#deliberately-not-included) still 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 +436,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 +497,21 @@ 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. `plugins` is not this — it is transport policy handed + to oRPC's `RPCHandler` at composition — and `principalMiddleware` is the one + per-request hook the package installs, only on a marked leaf. - **`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..f7aa34e 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` | `auth`, `isAuthenticated`, `Authenticated`, `PrincipalKey`, `PrincipalOf` — 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. From e8236b210bb948e83324a7ef8d28078008004704 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 13:44:33 +0200 Subject: [PATCH 14/38] docs: the cross-cutting-concerns and transaction decisions, and the changeset Transport policy is handler configuration, not a middleware slot: the refusal narrows to an application middleware acting on a handler's Result, which is the only one packages/http declines. Rate limiting is a stated non-goal, an unmarked procedure is public with nothing checking for a forgotten marker, and authorization stays out of the contract because a resource-dependent rule cannot be answered before the handler runs. The two worker packages say the question does not arise there: no origin, no browser, a connection the broker or Temporal already authenticated, and identity as a field on the contract the way tenantId already is. Transactions are recorded next to thesis #2 as something the unit record does not carry. The examples already commit their row and their outbox row in one $tryTransaction, so nothing there is hand-rolling a missing primitive. Also corrects the oRPC catalog pin (beta.28, not beta.23) and the order-api README paragraph that still had the orders controller reading input.tenantId. --- .changeset/authenticated-contracts.md | 33 ++++++++++++++ CLAUDE.md | 63 ++++++++++++++++++++++++++- examples/order-api/README.md | 19 +++++--- packages/amqp/CLAUDE.md | 12 +++++ packages/temporal/CLAUDE.md | 11 +++++ 5 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 .changeset/authenticated-contracts.md diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md new file mode 100644 index 0000000..50b4ac4 --- /dev/null +++ b/.changeset/authenticated-contracts.md @@ -0,0 +1,33 @@ +--- +"@btravstack/contract": minor +"@btravstack/http": minor +--- + +Let a contract declare that a procedure requires an authenticated principal, +and give `@btravstack/http` what it needs to satisfy that declaration. + +`@btravstack/contract` is a new zero-dependency package holding the marker +itself: `auth

()` mints an `authenticated` combinator for one contract's +principal type, applied to a finished procedure or to a whole record of them. +It returns the node unchanged — the marker lives in a `WeakSet` and a phantom +type key — so a client can import a marked contract without pulling in +anything that implements it. A handler under a marked key reads +`opts.context.principal` typed as `P`, and a controller that ignores it no +longer compiles under that key. 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 resolving the wrong +principal type is refused at `HttpModule`. A marked procedure whose +authenticator declines is answered `UNAUTHORIZED` before dispatch, with the +handler never running. + +`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. Both are transport configuration, not a +middleware slot for application logic. diff --git a/CLAUDE.md b/CLAUDE.md index 87afede..6b8f172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,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, @@ -338,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`, @@ -664,7 +725,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. diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 9faa288..ab5e733 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -281,8 +281,12 @@ const ordersContract = { }; ``` -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` instead — the input field is still declared by +the contract and deliberately goes unread there. 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. @@ -290,9 +294,14 @@ 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. +"who is asking": a deployment that authenticates its callers takes the tenant +from the caller's identity instead, which is what marking a fragment +`authenticated` does — a contract change, which is exactly the kind of change +that should be. `orders` has made it and `customers` has not, which is why the +two controllers read the tenant from different places. Dropping the now-unread +`tenantId` from the `orders` inputs would be a second contract change, and is +left undone on purpose: keeping both fragments' inputs the same shape is what +makes the one difference legible. 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 are source-only — no build step, `main` pointing straight at `src/` — so there 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/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 From 28c8557c217000baf6cff90599c88a2e9d61557b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 13:59:05 +0200 Subject: [PATCH 15/38] fix(http): a rejected caller's reason stays in the process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `principalMiddleware` forwarded `Unauthenticated.reason` into the `ORPCError`'s `message`, and oRPC serializes `message` to the client — so an authenticator distinguishing "no such user" from "bad signature" put that in a 401 body by default. `auth.ts` stated the opposite intent before any page documented it. `UNAUTHORIZED` is now thrown with no message derived from the refusal; the caller gets oRPC's own default. The reason is the application's, to log where it decides. --- packages/http/src/auth.spec.ts | 16 ++++++++++++---- packages/http/src/auth.ts | 12 ++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 7ab0b50..37d1ed3 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -13,20 +13,28 @@ describe("an authenticated procedure", () => { await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); }); - it("answers 401 and never runs the handler when the token is rejected", async ({ rpcAuthed }) => { - // GIVEN a client presenting a token the authenticator rejects + 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 and the handler was not entered + // 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", ran: 0 }); + ).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 }) => { diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index c1dc302..c81c745 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -11,7 +11,13 @@ import { import { ORPCError } from "@orpc/server"; import { ErrAsync, TaggedError, type AsyncResult } from "unthrown"; -/** Why a caller was refused. The reason is for the operator's log, not the client's body. */ +/** + * Why a caller was refused. The reason is the **application's own**: the starter + * does not surface it — a rejected caller gets an `UNAUTHORIZED` carrying oRPC's + * default message and nothing else — so an authenticator that wants the reason + * recorded logs it itself. Forwarding it would put "no such user" versus "bad + * signature" in a 401 body by default. + */ export class Unauthenticated extends TaggedError("Unauthenticated")<{ readonly reason: string; }> {} @@ -86,8 +92,10 @@ export const principalMiddleware = }): Promise => { const resolved = await authenticate(options.context.request.headers); if (resolved.isErr()) { + // The reason stays here: it is the application's, and oRPC serializes + // `message` to the client. // 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", { message: resolved.error.reason }); + throw new ORPCError("UNAUTHORIZED"); } if (resolved.isDefect()) { // A defect is a bug in the authenticator, not a refusal. Its own cause From 1f280ea7dd54a0780e9a8e9b25d8d63962318308 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 13:59:18 +0200 Subject: [PATCH 16/38] docs(http): plugins is an escape hatch, and the README states the non-goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections found in review. `plugins` was described as having no access to a procedure's `Result` — false: oRPC's `StandardHandlerPlugin.init` transforms handler options including the interceptors, so a plugin can wrap execution. The reference page and the package spec now say what the root spec already said: an honest escape hatch, whose value is that the ordinary path is visible configuration rather than a middleware slot for application logic. Three pages claimed a rejected caller never sees the authenticator's reason while the code forwarded it. The code is fixed; the pages now state the reason is the application's own and unsurfaced, so an authenticator that wants it recorded logs it itself. The package README gains the auth sample it never got — compiled before committing — and the "What it does not do" section the package spec already pointed at, covering middleware, rate limiting and authorization. Also drops a clause duplicated in examples/order-api's README. --- .changeset/authenticated-contracts.md | 7 +- docs/how-to/protect-a-procedure.md | 7 +- docs/reference/http.md | 39 ++++++--- examples/order-api/README.md | 2 +- packages/http/CLAUDE.md | 30 ++++--- packages/http/README.md | 109 ++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 27 deletions(-) diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md index 50b4ac4..ed9c3b4 100644 --- a/.changeset/authenticated-contracts.md +++ b/.changeset/authenticated-contracts.md @@ -24,10 +24,13 @@ authenticator; a marked router whose root provides none is di's existing `UNSATISFIED DEPENDENCIES` gate, and an authenticator resolving the wrong principal type is refused at `HttpModule`. A marked procedure whose authenticator declines is answered `UNAUTHORIZED` before dispatch, with the -handler never running. +handler never running and the `Unauthenticated`'s `reason` left in the process +— it is the application's, to log where it decides. `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. Both are transport configuration, not a +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/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 958ceb3..627f81f 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -104,8 +104,11 @@ through a returned function's `AsyncResult` is exactly where a principal silently widens to `unknown`, and stating it is what makes a mismatch a compile error at step 4 instead of an `unknown` reaching a handler. -`Unauthenticated` carries a `reason` — for the operator's log, not the client's -body. A rejected caller gets an `UNAUTHORIZED` and nothing else. +`Unauthenticated` carries a `reason`, and the reason is **yours**: the starter +does not surface it. A rejected caller gets an `UNAUTHORIZED` carrying oRPC's +default message and nothing derived from the refusal — so an authenticator that +wants the reason recorded logs it itself, which is one more argument for naming +a logger in `deps`. ## Step 3 — read the principal diff --git a/docs/reference/http.md b/docs/reference/http.md index 2cc4893..f8f2dba 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -27,7 +27,7 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `HttpAuthenticator` | value | `HttpAuthenticator

()([deps], { sync })` — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | | `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` carrying a `reason` — the refusal, for the operator's log rather than the client's body | +| `Unauthenticated` | value | a `TaggedError` carrying a `reason` — the refusal, the application's own; the starter does not surface it to the client | | `ContractPrincipal` | type | `ContractPrincipal` — the principal a contract declares anywhere in its tree, or `never` | | `http` | value | `http({ prefix?, port?, hostname?, plugins?, securityHeaders? })` — the starter module itself, needing the router port; what `HttpModule` imports | | `HttpOptions` | type | `http()`'s options | @@ -291,8 +291,11 @@ 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`. `Unauthenticated` is a -`TaggedError` carrying a `reason`, for the operator's log rather than the -client's body. +`TaggedError` carrying a `reason`, and the reason is the **application's own**: +the starter does not surface it. A rejected caller gets an `UNAUTHORIZED` +carrying oRPC's default message and nothing derived from the refusal, so an +authenticator that wants the reason recorded logs it itself — forwarding it +would put "no such user" versus "bad signature" in a 401 body by default. ```ts export const bearerAuthenticator = HttpAuthenticator()([], { @@ -377,16 +380,23 @@ latter. `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 this is **configuration**, not a middleware -slot — `plugins: [new CORSHandlerPlugin({ origin: () => "https://orders.example" })]` +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`. -A plugin configures the transport once, at composition, with no access to a -procedure's `Result` or to any application logic — which is why it is not the -door [Deliberately not included](#deliberately-not-included) still refuses. It -threads through all three surfaces (`http()`, `HttpModule` and the internal -oRPC options) as a plain optional field. +`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 @@ -510,8 +520,11 @@ read as unmarked here. Node `>=20`. `RPCHandler` is the one way HTTP is answered; there is no `handler` option and no listener port to provide. - **A middleware slot for application logic.** oRPC's own, inside the - router's procedures. `plugins` is not this — it is transport policy handed - to oRPC's `RPCHandler` at composition — and `principalMiddleware` is the one - per-request hook the package installs, only on a marked leaf. + 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/examples/order-api/README.md b/examples/order-api/README.md index ab5e733..e0afaf6 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -303,6 +303,6 @@ two controllers read the tenant from different places. Dropping the now-unread left undone on purpose: keeping both fragments' inputs the same shape is what makes the one difference legible. -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 +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/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 1ee9e74..727b69b 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -176,7 +176,10 @@ InstanceType> & { readonly port: PortClassOf> 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` carrying - a `reason` — for the operator's log, not the client's body. + a `reason`, and the reason is the **application's own**: the starter does + not surface it, so an authenticator that wants it recorded logs it itself. + Forwarding it would put "no such user" versus "bad signature" in a 401 body + by default — an information-disclosure footgun shipped as the default. - **`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 @@ -184,11 +187,15 @@ InstanceType> & { readonly port: PortClassOf> `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 + `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. A **defect** is rethrown as its own cause instead, so a bug in - the authenticator stays oRPC's `INTERNAL_SERVER_ERROR` collapse rather than + 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 @@ -272,11 +279,16 @@ plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC `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 **not** the middleware door thesis #3 and the "Not included" - bullet below still refuse: a plugin configures the transport once, at - composition, with no access to a procedure's `Result` or its application - logic — `principalMiddleware` (below) is the one per-request hook this - package installs, and only on a marked leaf. + `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 diff --git a/packages/http/README.md b/packages/http/README.md index 3587dff..24bf879 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -146,6 +146,85 @@ 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: + +```ts +import { auth } from "@btravstack/contract"; +import { + HttpAuthenticator, + HttpModule, + HttpRouter, + Unauthenticated, +} from "@btravstack/http"; +import { oc, type } from "@orpc/contract"; +import { ErrAsync, OkAsync, P } from "unthrown"; + +type Principal = { readonly userId: string; readonly tenantId: string }; + +const { authenticated } = auth(); + +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. The +// principal type is stated at the call rather than inferred from `sync` — +// inference through the returned function's AsyncResult is exactly where it +// would silently widen to `unknown`. +const bearerAuthenticator = HttpAuthenticator()([], { + sync: () => (headers) => { + const [tenantId, userId] = (headers.authorization ?? "") + .replace("Bearer ", "") + .split(":"); + return tenantId === undefined || userId === undefined + ? ErrAsync(new Unauthenticated({ reason: "no usable bearer token" })) + : OkAsync({ tenantId, userId }); + }, +}); + +// The principal arrives on oRPC's own context channel, typed by the contract. +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], +}); +``` + +A marked router carries the authenticator port as a **need**, so forgetting +`authenticator` is an unmet dependency `start` refuses, and supplying one that +resolves a different principal is a compile error at the `HttpModule(...)` call. +A marked record protects every procedure beneath it. `Unauthenticated` carries a +`reason` that is **yours**: the starter does not surface it — a rejected caller +gets an `UNAUTHORIZED` and nothing derived from the refusal — so an +authenticator that wants the reason recorded logs it itself. 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 +237,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 From 1b35a15d73dae9915ae9d24d65bd495dcb36ca81 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 14:03:55 +0200 Subject: [PATCH 17/38] docs(http): the non-goals bullet agrees with the plugins bullet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package spec corrected `plugins` in one place and left the claim standing in two others — "not a hook an application's use case runs inside of" under "Not included", and "so this is configuration rather than a middleware slot" in the bullet's own opening. Both now say what the corrected paragraph says: a plugin can reach the handler's interceptors, and what the option buys is that the ordinary path is visible configuration rather than a middleware slot for application logic. --- packages/http/CLAUDE.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 727b69b..2c03734 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -273,8 +273,8 @@ HOST: "127.0.0.1" }` to `start`. `HttpInfo` is `{ port }`, published on `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 this is configuration rather than a - middleware slot. It threads through all three surfaces on the same + 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 @@ -347,8 +347,11 @@ plugins })`: CORS, body limits, compression, CSRF are transport policy oRPC `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 not this: it is transport policy handed to oRPC's own - `RPCHandler`, not a hook an application's use case runs inside of. + `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`, `@btravstack/contract`, `unthrown`, `@orpc/server`, `@orpc/contract`, `@unthrown/orpc`. `@btravstack/contract` is a peer for the From 81f226f8da4ca2bd0c9bb618682426aa504a77f3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 16:58:29 +0200 Subject: [PATCH 18/38] fix(http): the keyed router form composes a root-marked contract The keyed `HttpRouter(contract)(controllers)` overload never got the two changes the positional arm's `Implementation` carries: `Exclude`, without which a root-marked contract demands a controller for the phantom key and can never be composed at all, and `Inherit>`, without which a controller under a root mark cannot type `context.principal`. Both fixtures mark a KEY, which is why the sibling fix in b516d10 left this behind unnoticed. `auth.test-d.ts`'s eleventh arm composes a root-marked contract through the keyed form; each half was mutation-checked against its own diagnostic. --- docs/reference/http.md | 15 ++++++++++----- packages/http/CLAUDE.md | 13 ++++++++++--- packages/http/src/auth.test-d.ts | 22 +++++++++++++++++++++- packages/http/src/orpc.ts | 10 ++++++++-- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/docs/reference/http.md b/docs/reference/http.md index f8f2dba..90c85c6 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -171,11 +171,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>> }`, 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 diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 2c03734..abd7c38 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -87,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>> }`, 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 diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index ce5812c..35e5d93 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -1,12 +1,13 @@ // 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 { auth } from "@btravstack/contract"; +import { auth, type Authenticated } from "@btravstack/contract"; import { start } from "@btravstack/core"; import { oc } from "@orpc/contract"; import { OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; +import { HttpController } from "./controller.js"; import { HttpModule } from "./http-module.js"; import { HttpRouter, type ContractPrincipal, type Implementation } from "./orpc.js"; @@ -131,3 +132,22 @@ 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 principal the root mark 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 }, Principal>; +const rootOrders = HttpController("RootOrders", ordersFragment)([], { + sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), +}); +const rootMarkedContract = authenticated({ orders: { whoami: oc } }); +const _rootKeyed = HttpModule("RootKeyed")({ + router: HttpRouter(rootMarkedContract)({ orders: rootOrders }), + authenticator: matching, +}); + +void _rootKeyed; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 48fde29..263ef96 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -144,8 +144,14 @@ export const HttpRouter = >(contract: C readonly port: PortClassOf<"HttpRouter", Router>>; readonly principal: ContractPrincipal; }; - function build }>( - controllers: M & { readonly [K in Exclude]: never }, + function build< + M extends { + readonly [K in Exclude]: ControllerFor>>; + }, + >( + controllers: M & { + readonly [K in Exclude>]: never; + }, ): Provider< PortInstance<"HttpRouter", Router>>, never, From 68614803afd3fc92e63992aaefa16148378deb86 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 17:00:39 +0200 Subject: [PATCH 19/38] fix(contract): one marker registry per install, not per copy The marker is WeakSet identity, so two copies of this package each hold their own registry: a contract marked by one reads unmarked to the other, `HttpRouter` declares no authenticator need and a protected route is served OPEN. The `unique symbol` catches it only when a handler actually reads `principal`. The registry now hangs off `globalThis` under `Symbol.for("@btravstack/contract/marked")`, so every copy shares one `WeakSet` and a stray second copy degrades to a compile error. A spec pins that the mark lands where another copy would look for it. `examples/order-api-contract` takes the package as a peer, the way `packages/http` does, and both docs say why. --- examples/order-api-contract/package.json | 5 ++++- packages/contract/CLAUDE.md | 13 ++++++++++++- packages/contract/README.md | 9 +++++++++ packages/contract/src/auth.spec.ts | 15 +++++++++++++++ packages/contract/src/auth.ts | 11 ++++++++++- pnpm-lock.yaml | 6 +++--- 6 files changed, 53 insertions(+), 6 deletions(-) diff --git a/examples/order-api-contract/package.json b/examples/order-api-contract/package.json index 992ef77..b081090 100644 --- a/examples/order-api-contract/package.json +++ b/examples/order-api-contract/package.json @@ -15,10 +15,10 @@ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "dependencies": { - "@btravstack/contract": "workspace:*", "@orpc/contract": "catalog:" }, "devDependencies": { + "@btravstack/contract": "workspace:*", "@btravstack/tsconfig": "catalog:", "@orpc/client": "catalog:", "@types/node": "catalog:", @@ -27,5 +27,8 @@ "typescript": "catalog:", "unthrown": "catalog:", "vitest": "catalog:" + }, + "peerDependencies": { + "@btravstack/contract": "workspace:^" } } diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index c7d7ae1..f869fdd 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -47,7 +47,18 @@ opinion about which transport reads it. 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 -module-private `WeakSet`, keyed by identity. +`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. **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 diff --git a/packages/contract/README.md b/packages/contract/README.md index 2cb1d97..ca6d2d3 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -28,6 +28,15 @@ 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 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 principal type, not an open route. + ## License [MIT](./LICENSE) © Benoit TRAVERS diff --git a/packages/contract/src/auth.spec.ts b/packages/contract/src/auth.spec.ts index f4fef0c..fd3b312 100644 --- a/packages/contract/src/auth.spec.ts +++ b/packages/contract/src/auth.spec.ts @@ -30,6 +30,21 @@ describe("authenticated", () => { expect(isAuthenticated(fragment)).toBe(false); }); + it("registers the mark where a second copy of this package would find it", ({ + authenticated, + 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 + expect(registry?.has(fragment)).toBe(true); + }); + it("keeps two contracts' markers independent", ({ authenticated, fragment }) => { // GIVEN two nodes, one marked const other = { find: { kind: "procedure" } as const }; diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index 34eaa36..9c23d69 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -17,7 +17,16 @@ export type PrincipalOf = T extends { readonly [PRINCIPAL]: infer P } ? P : n // Identity, not a property: a marked node must stay `===` what the contract // declared, so `implement()` walks it unchanged and a consumer can still index // the fragment out of the contract it lives in. -const marked = new WeakSet(); +// +// The registry hangs off `globalThis`, not off this module, because identity is +// what the marker IS: two copies of this package with a private set each read +// every node the other marked as unmarked, so `hasMarked` answers false, the +// router declares no authenticator and the route is served OPEN. One shared +// registry makes the second copy a compile error (the `unique symbol` differs) +// rather than a silent hole. +const registry: unique symbol = Symbol.for("@btravstack/contract/marked"); +const store = globalThis as unknown as { [registry]?: WeakSet }; +const marked = (store[registry] ??= new WeakSet()); /** * Mints the combinator for one contract's principal type. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a82df3a..823e399 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -428,13 +428,13 @@ importers: examples/order-api-contract: dependencies: - '@btravstack/contract': - specifier: workspace:* - version: link:../../packages/contract '@orpc/contract': 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 From 40699f5a75d2b0bfc725f8ee4d02691bbe169637 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 18:46:37 +0200 Subject: [PATCH 20/38] fix(example): a marked fragment names no tenant on its input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `contract.orders` is marked `authenticated` and its handlers serve `context.principal.tenantId`, while the fragment still REQUIRED an `input.tenantId` nobody read — a required, security-relevant field that lies, and the confused-deputy shape in contract form. The marked `orders` fragment drops it; the unmarked `customers` fragment keeps it, so the contrast is the lesson: where nothing authenticates the caller the tenant is an argument, and where something does it is who is asking. The spec that proved a claimed tenant was ignored is now two callers, each served its own token's tenant — a claim the contract no longer lets a caller make. Touched: the contract, the orders controller, the contract package's stub client, `api.spec.ts`, `examples/order-api/README.md`, the root spec and the two documentation-site pages that showed an `orders` call. --- CLAUDE.md | 10 ++- docs/how-to/read-the-ambient-unit.md | 10 +-- docs/how-to/test-an-application.md | 24 +++---- .../order-api-contract/src/client.spec.ts | 4 +- examples/order-api-contract/src/contract.ts | 36 +++++----- .../order-api-contract/src/test-fixtures.ts | 21 +++--- examples/order-api/README.md | 46 +++++++----- examples/order-api/src/api.spec.ts | 71 ++++++++----------- .../order-api/src/slices/orders/controller.ts | 13 ++-- 9 files changed, 114 insertions(+), 121 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6b8f172..257c401 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -701,9 +701,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 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 c2e10be..6eb17df 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -61,9 +61,10 @@ 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, id: "o-1", quantity: 2 }), - ).toBeOkWith({ id: "o-1", quantity: 2 }); + await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({ + id: "o-1", + quantity: 2, + }); }); }); ``` @@ -141,7 +142,6 @@ const recordingApi = HttpModule("RecordingApi")({ }); it("runs each call in its own unit, with its own trace id", async ({ - tenant, serve, clientFor, }) => { @@ -150,10 +150,8 @@ it("runs each call in its own unit, with its own trace id", async ({ // 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 four lines, two distinct trace ids, none written outside a unit const traced = served.map(() => ({ @@ -307,12 +305,12 @@ const recordingApi = () => { }; ``` -The tenant a handler serves is **not** `input.tenantId` any more: the `orders` +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. The contract still declares the input -field — dropping it is a separate contract change — and those handlers -deliberately do not use it, which is why a spec's token and its `tenantId` -argument name the same tenant. +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 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 d1117bd..830ecdf 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -13,21 +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. - * - * `orders` is where that already moved: the fragment is marked - * `authenticated`, and its handlers serve `Principal.tenantId` rather than - * this field — a caller does not get to name the tenant it is served. The - * field stays declared on every input, `orders` included, because dropping it - * is a separate contract change and this one was about the transport. + * `orders` is marked `authenticated` and therefore does **not** name it: its + * handlers serve `Principal.tenantId`, 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 }; @@ -39,9 +37,9 @@ export type CustomerView = { readonly id: string; readonly name: string }; * is what makes a protected route legible to a client: `authenticated` is one * word in the shared artifact, visible in a diff and in the generated types. * - * `tenantId` is on the principal AND on every input, deliberately: moving - * tenancy onto the caller's identity is a separate contract change, and this - * one is about the transport. + * `tenantId` is on the principal, and the marked fragment's inputs therefore + * do not carry one: for a protected procedure the caller's identity is what + * establishes the tenant. */ export type Principal = { readonly userId: string; readonly tenantId: string }; @@ -50,14 +48,14 @@ const { authenticated } = auth(); /** 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() } }), }; 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 e0afaf6..fa8f6e6 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -177,7 +177,7 @@ const client = createOrderApiClient("http://127.0.0.1:3000", "/rpc", { authorization: `Bearer ${tenantId}:${userId}`, }); -const named = (await client.orders.place({ tenantId, id, quantity })).match({ +const named = (await client.orders.place({ id, quantity })).match({ ok: () => "placed", errCases: (matcher) => matcher.with( @@ -193,8 +193,9 @@ 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. The tenant on the input is still declared and still sent; -the server serves the token's, not this one. +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 @@ -267,14 +268,22 @@ 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() } }), … @@ -284,24 +293,23 @@ const ordersContract = { 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` instead — the input field is still declared by -the contract and deliberately goes unread there. Either way `@btravstack/http` -knows +`context.principal.tenantId` — 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 takes the tenant -from the caller's identity instead, which is what marking a fragment -`authenticated` does — a contract change, which is exactly the kind of change -that should be. `orders` has made it and `customers` has not, which is why the -two controllers read the tenant from different places. Dropping the now-unread -`tenantId` from the `orders` inputs would be a second contract change, and is -left undone on purpose: keeping both fragments' inputs the same shape is what -makes the one difference legible. +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 diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index e8d191e..75056cd 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,8 +184,8 @@ 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 @@ -210,11 +202,11 @@ describe("order-api", () => { expect(traced).toBeOkWith({ lines: 4, 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 @@ -326,7 +315,6 @@ describe("order-api", () => { }); it("refuses a call to the marked fragment when the caller presents nothing", async ({ - tenant, serve, clientWith, api, @@ -335,7 +323,7 @@ describe("order-api", () => { const client = await clientWith(serve(api), undefined); // WHEN a procedure of the authenticated fragment is called - const refused = await client.orders.place({ tenantId: tenant, id: "o-1", quantity: 1 }); + 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 @@ -366,7 +354,7 @@ describe("order-api", () => { }); }); - it("serves the tenant the token names, not the one the input claims", async ({ + it("serves each caller the tenant its own token names", async ({ tenant, serve, clientFor, @@ -375,20 +363,18 @@ describe("order-api", () => { }) => { // GIVEN two callers on one app, each holding a token for its own tenant const app = serve(api); - const claimed = `${tenant}-claimed`; + const other = `${tenant}-other`; const client = await clientFor(app); - const claimant = await clientWith(app, `Bearer ${claimed}:u-2`); + const stranger = await clientWith(app, `Bearer ${other}:u-2`); - // WHEN the first places an order whose input names the OTHER tenant, and - // that other tenant then looks it up + // WHEN the first places an order and the second looks that id up const found = await client.orders - .place({ tenantId: claimed, id: "o-1", quantity: 2 }) - .flatMap(() => claimant.orders.find({ tenantId: claimed, id: "o-1" })); + .place({ id: "o-1", quantity: 2 }) + .flatMap(() => stranger.orders.find({ id: "o-1" })); - // THEN the write landed under the authenticated tenant and not the claimed - // one: the input's `tenantId` is still on the wire and the handler reads - // `context.principal.tenantId` instead, so a caller cannot name the tenant - // it is served + // 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, @@ -444,7 +430,6 @@ describe("order-api", () => { }); it("goes unready on drain while staying live", async ({ - tenant, serve, clientFor, probesFor, @@ -456,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/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index 3ddb2f2..f3c9ffb 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -27,12 +27,13 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * 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. `input.tenantId` is still declared by the - * contract and is deliberately NOT what these handlers use: a caller does not - * get to name the tenant it is served, and moving tenancy off the inputs - * altogether is a contract change of its own. The starter still knows nothing - * about tenancy — it resolved a principal this application defined, and what - * the fields on it mean is the application's business. + * misreads it does not compile. 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 From 8ec3bb6091bf1ed40dff9b7e739bf8074fdcbf23 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 18:47:34 +0200 Subject: [PATCH 21/38] test(http): a custom securityHeaders record is applied verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third arm of the ternary in `listen` had no test — line coverage cannot see a branch, so a record that was quietly ignored would have shipped green. One spec through `serve`'s third argument: the given headers are on the response and the defaults are not. --- packages/http/CLAUDE.md | 12 +++++++----- packages/http/src/http-runtime.spec.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index abd7c38..7df9577 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -390,12 +390,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. -- **39 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 20, + 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, @@ -405,10 +405,12 @@ 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), and three of them are `securityHeaders`: the defaults on a + 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 — and - their absence when `securityHeaders: false` is pinned, `serve`'s third + `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 diff --git a/packages/http/src/http-runtime.spec.ts b/packages/http/src/http-runtime.spec.ts index f65e345..ccf1476 100644 --- a/packages/http/src/http-runtime.spec.ts +++ b/packages/http/src/http-runtime.spec.ts @@ -306,6 +306,25 @@ describe("httpRuntime", () => { 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, From 49a834394013f0141bba47425bce6b14e33a33a3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 18:54:30 +0200 Subject: [PATCH 22/38] docs: the http spec count the branch left stale --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 257c401..d6144d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,7 +463,7 @@ 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 35 specs, across + 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 From a792fe863e8cd6ade4e91d71be065eca5da89446 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 20:22:46 +0200 Subject: [PATCH 23/38] fix(http): hoist the security-header entries out of the request path The record was resolved once at boot but Object.entries rebuilt the same pairs on every request, against the intent stated three lines above it. Also: docs/reference/contract.md still described the marker registry as module-private, the failure mode the globalThis registry removed. --- docs/reference/contract.md | 22 +++++++++++++++------- packages/http/src/http-runtime.ts | 7 +++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/reference/contract.md b/docs/reference/contract.md index d8ed33d..7fed0f1 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -106,7 +106,8 @@ reads 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 module-private `WeakSet`, keyed by identity. +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 @@ -131,12 +132,19 @@ a bypass. No oRPC builder has to know the marker exists. None, and no runtime dependencies either. `pnpm add @btravstack/contract`, and that is the whole install. Node `>=20`. -::: warning One copy, or the marker reads as unmarked -`PrincipalKey` is a `unique symbol` and the mark is a module-private -`WeakSet` — two copies of this package are two different symbols and two -different sets, so a contract marked against one reads as unmarked in the -other. `@btravstack/http` peers on it for that reason; an application holds a -single copy. See [Peer dependencies](/explanation/peer-dependencies). +::: 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. See +[Peer dependencies](/explanation/peer-dependencies). ::: ## See also diff --git a/packages/http/src/http-runtime.ts b/packages/http/src/http-runtime.ts index 19ca602..fdff946 100644 --- a/packages/http/src/http-runtime.ts +++ b/packages/http/src/http-runtime.ts @@ -168,12 +168,15 @@ const listen = ( 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 headers: Readonly> = + 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 @@ -204,7 +207,7 @@ 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 Object.entries(headers)) response.setHeader(name, value); + for (const [name, value] of headers) response.setHeader(name, value); open.add(response); response.once("close", () => open.delete(response)); if (draining) retire(response); From 69230c3c0b0e157aeefc4651ad2b92b728a9a559 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 22:43:38 +0200 Subject: [PATCH 24/38] refactor(example): the contract declares the minimum, the authenticator resolves more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Principal was { userId, tenantId } while nothing read userId — a field in the client-facing contract that existed only for the sample. It is { tenantId } now, and the authenticator resolves { tenantId, userId }: the gate is a subtype check, so enriching server-side identity is not a contract change. The limit is stated where the type is: a handler sees the contract's shape, not the authenticator's, so a field a handler needs is client-visible. --- docs/how-to/protect-a-procedure.md | 22 ++++++++++++--- docs/reference/contract.md | 20 +++++++++++++- examples/order-api-contract/src/contract.ts | 29 +++++++++++++++----- examples/order-api/README.md | 9 ++++++ examples/order-api/src/authenticator.ts | 19 +++++++++++-- examples/order-infrastructure/.migrate.db | Bin 0 -> 36864 bytes packages/contract/CLAUDE.md | 16 +++++++++++ 7 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 examples/order-infrastructure/.migrate.db diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 627f81f..664dc37 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -35,8 +35,8 @@ that a client should be able to read without taking the server: import { auth } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; -/** Who the caller is, as this API models it. */ -export type Principal = { readonly userId: string; readonly tenantId: string }; +/** The MINIMUM a caller's identity must carry for this API's semantics. */ +export type Principal = { readonly tenantId: string }; const { authenticated } = auth(); @@ -72,11 +72,13 @@ starter's `AuthenticatorPort`. It resolves a principal from the request's body, and the narrower argument is what keeps it testable without a socket. ```ts -import type { Principal } from "./contract.js"; import { HttpAuthenticator, Unauthenticated } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; -export const bearerAuthenticator = HttpAuthenticator()([], { +/** What the server knows — more than the contract asks for. */ +type Identity = { readonly tenantId: string; readonly userId: string }; + +export const bearerAuthenticator = HttpAuthenticator()([], { sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") @@ -93,6 +95,18 @@ export const bearerAuthenticator = HttpAuthenticator()([], { }); ``` +**Declare the minimum in the contract, and let the authenticator resolve +more.** The gate `HttpModule` applies is `Auth extends { principal: Principal }`, +so a _subtype_ discharges it: this authenticator resolves `{ tenantId, userId }` +against a contract asking only for `{ tenantId }`. Enriching what a deployment +knows about its callers — roles, an org tier, an internal id — is therefore not +a contract change, and none of it reaches a client. + +The limit worth knowing: a handler sees the **contract's** type, not the +authenticator's. A field a handler needs must be declared in the contract, and +is client-visible once it is. That is the price of the field, and the reason to +keep `Principal` as small as the API's semantics allow. + `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 diff --git a/docs/reference/contract.md b/docs/reference/contract.md index 7fed0f1..1611370 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -41,7 +41,8 @@ single procedure (which protects itself): import { auth } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; -export type Principal = { readonly userId: string; readonly tenantId: string }; +/** The minimum a caller's identity must carry for this API's own semantics. */ +export type Principal = { readonly tenantId: string }; const { authenticated } = auth(); @@ -94,6 +95,23 @@ 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. +## Declare the minimum, resolve more + +`P` is what a **client** learns about the identity your API expects, so it +belongs in the contract only to the extent the API's own semantics depend on +it. Everything else the server knows stays on the server. + +The starter's gate is `Auth extends { principal: P }`, so a **subtype** +discharges it: an authenticator resolving `{ tenantId, userId, roles }` +satisfies a contract that declares `{ tenantId }`. Enriching what a deployment +knows about its callers is therefore not a contract change, and none of it +reaches a client. + +The limit worth knowing: a handler sees `PrincipalOf` — the contract's +type, not the authenticator's richer one. A field a handler needs must be +declared here, and is client-visible once it is. That is the price of the +field, and the reason to keep `P` as small as the API allows. + ## Three load-bearing properties **Zero dependencies and zero peers.** Nothing here imports oRPC, `di`, `core` diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 830ecdf..92e8fbf 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -33,15 +33,30 @@ export type Tenanted = { readonly tenantId: string }; export type CustomerView = { readonly id: string; readonly name: string }; /** - * Who the caller is, as this API models it. Named in the contract because that - * is what makes a protected route legible to a client: `authenticated` is one - * word in the shared artifact, visible in a diff and in the generated types. + * The **minimum** a caller's identity must carry for this API's own semantics + * to work — not everything the server knows about them. Named in the contract + * because that is what makes a protected route legible to a client: + * `authenticated` is one word in the shared artifact, visible in a diff and in + * the generated types. * - * `tenantId` is on the principal, and the marked fragment's inputs therefore - * do not carry one: for a protected procedure the caller's identity is what - * establishes the tenant. + * `tenantId` is here because the marked fragment's inputs therefore do not + * carry one: for a protected procedure the caller's identity is what + * establishes the tenant. Nothing else is, on purpose. + * + * **The rule this file exists to demonstrate: declare the minimum here, and + * let the authenticator resolve more.** The starter's gate is + * `Auth extends { principal: Principal }`, so a *subtype* discharges it — + * `bearerAuthenticator` resolves `{ tenantId, userId }` and satisfies a + * contract asking only for `{ tenantId }`. Enriching what a deployment knows + * about its callers — roles, an org tier, an internal id — is therefore NOT a + * contract change, and none of it reaches a client. + * + * The limit worth knowing: a handler sees this type, not the authenticator's + * richer one. A field a handler needs must be declared here, and is then + * client-visible. That is the price of the field, and the reason to keep this + * type as small as the API's semantics allow. */ -export type Principal = { readonly userId: string; readonly tenantId: string }; +export type Principal = { readonly tenantId: string }; const { authenticated } = auth(); diff --git a/examples/order-api/README.md b/examples/order-api/README.md index fa8f6e6..7fa8c94 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -108,6 +108,15 @@ and it is an ordinary provider, so swapping this example's `Bearer :` stand-in for JWT verification changes nothing else. +It resolves **more** than the contract asks for, on purpose: `Principal` is +`{ tenantId }`, because that is all this API's semantics depend on, while the +authenticator returns `{ tenantId, userId }`. The gate is +`Auth extends { principal: Principal }`, so a subtype discharges it — adding +roles or an internal id to what a deployment knows about its callers is not a +contract change, and none of it reaches a client. The limit: a handler sees the +contract's type, so a field a handler needs has to be declared there and is +client-visible once it is. + 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: diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts index f498b3c..34c2bf2 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -1,7 +1,20 @@ -import type { Principal } from "@btravstack/example-order-api-contract"; import { HttpAuthenticator, Unauthenticated } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; +/** + * What this deployment knows about a caller, which is **more** than the + * contract asks for: `Principal` declares `{ tenantId }` alone, because that is + * all the API's own semantics depend on. `userId` is the server's business and + * never reaches a client. + * + * This is the layering the contract's own doc names. `HttpModule`'s gate is + * `Auth extends { principal: Principal }`, so a subtype discharges it — adding + * roles, an org tier or an internal id here is not a contract change. The + * limit: a handler sees the contract's type, not this one, so a field a + * handler needs has to be declared in the contract and becomes client-visible. + */ +type Identity = { readonly tenantId: string; readonly userId: string }; + /** * A stand-in, not a recommendation: `Bearer :`. What matters * for the example is the shape — an ordinary di provider on the starter's @@ -10,11 +23,11 @@ import { ErrAsync, OkAsync } from "unthrown"; * * `[]` 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 principal type is stated at the call rather than + * dependencies are. The identity type is stated at the call rather than * inferred, which is what makes a token resolving to the wrong shape a compile * error at `HttpModule(...)` instead of an `unknown` reaching a handler. */ -export const bearerAuthenticator = HttpAuthenticator()([], { +export const bearerAuthenticator = HttpAuthenticator()([], { sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; diff --git a/examples/order-infrastructure/.migrate.db b/examples/order-infrastructure/.migrate.db new file mode 100644 index 0000000000000000000000000000000000000000..edbc166198fafff1009da6327a2a69370b39a363 GIT binary patch literal 36864 zcmeI(&u-#I90zclB#^L6>80{1TCWdH&@L^uv5nPBY2vO>6OtvMZhCRY_Bcz!zhGP1 zRK1W^eU83CEA`m-s8&7oF?#G^OoAa>t+bbJ@*N3ddp!R>zcI)*oIF41hE(*2<30&R zmHUw6dG2#jJB{mYA*`HVV{m|H(6QP9X9-UV)f8R_R zYS+Iwuofy2ck?1kQrDAM>uu|q)e@UWZL!%oI1rCpjl+8DRQ$?173-b$QKQLfIkcMX zr?Wx}?Tc;e1uK=5O!5b$Po;EDzVvN2CwwaKlU(d`H1-(IaJrny#5;1gvaB3C1BrJt zjieH(bR})%gqk7t3w(Dbfl*D~DTiT67>zZ2Jx4x`*4%*^gr`2Miv8TBw?fT(yQhF3Q zy>9R_T|8epUe5|op75tJQE<^?7dv)9FCsc{>C)y#ifJixcN*lUtYm5Ocr7Og0)O>* z#y*W{9Gmr2d1)4}cjBFgIz6|N0bfL95O%}M)d`(1{rC-^6J(kH*^c{YkH+1gPwam8 zY)r!La1gBS$)tO~dO&KXOi!kz6rUN3r_$0{;=ZKrc@Xubbh+D3D8D<1LunH>6>ide zQX~M^qp@cvU~=GX750W_!D3AdhbEPchrJ&4Y=^k#i>)p-8H8jUvcuB-`j6Qf&*(vD zhuuD9h5MtVIWijcx~!rgq@%!ghtVJ&>HMRq(>!TBYg+rO)4!50?PapU*D}w=A1ui< zB~dtxx9!!y+4AbXHTFfbIsLLJuvg}T*IaSL7Dy0)00bZa0SG_<0uX=z1Rwx`_e@~& zQ6}G+6gKuaZevTAHC6K*rED4wDOW2Mz3k|^R@QY=b5uoEHKH19%`-HO>NU^tG*8h~ zpL&X8nuKby=2OE{tFof9rD`gsEIU4t^_nBQCUsR`;eUDf*AJ#rQS?ep))iG|&Dg~A zu5WI}2h=77KJJ$~?U!2dJ-;F=<*H1Ka#b_TG9d;n6QiccuEr{^nFck8>(GkpnYyeJ zO(#{aN=(D|4cVoh#`@$_N>oy-)M|$3P*bhyrmGoja4JpuB`5t-6-73yip}n?gmHf= zc^219@mH?+nk|qZ009U<00Izz00bZa0SG_<0ucB=2<)zP__w*6k=a@6yw&Z6>GOZ~ zqkkj_KmY;|fB*y_009U<00Izz00i!^0IvVwioI2tWV=5P$##AOHafKmY;|fWX}snEwACzyH7camENC009U<00Izz00bZa0SG|g zehc9F|NV|XMh^i9KmY;|fB*y_009U<00Q?z0MGyLN1QQA2tWV=5P$##AOHafKmY;| IxZeW*0)pxw@Bjb+ literal 0 HcmV?d00001 diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index f869fdd..d183425 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -34,6 +34,22 @@ never`. Recovers the principal type a node was marked with, `never` when it marked. Ancestry (a marked parent implying a marked child) is the caller's to carry; the package tracks nodes, not trees. +## Declare the minimum, resolve more + +`P` is what a **client** learns about the identity the API expects, so it +belongs in the contract only as far as the API's own semantics depend on it. +`@btravstack/http`'s gate is `Auth extends { principal: P }`, so a **subtype** +discharges it — an authenticator resolving `{ tenantId, userId, roles }` +satisfies a contract declaring `{ tenantId }`. Enriching what a deployment +knows about its callers is therefore not a contract change and reaches no +client. + +The limit: a handler sees `PrincipalOf`, the contract's type, not the +authenticator's. A field a handler needs must be declared in the contract and +is client-visible once it is. Keep `P` as small as the API allows. +`examples/order-api-contract` is the worked case — `{ tenantId }` in the +contract, `{ tenantId, userId }` out of the authenticator. + ## Three load-bearing properties **Zero dependencies and zero peers.** Nothing here imports oRPC, `di`, `core` From 3ddad2bff400a5e917b61949a19039327da87353 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 23:28:00 +0200 Subject: [PATCH 25/38] feat(http): httpAuth mints controller, router and authenticator on one identity --- packages/http/src/controller.ts | 18 ++- packages/http/src/http-auth.ts | 44 ++++++++ packages/http/src/index.ts | 1 + packages/http/src/orpc.ts | 187 ++++++++++++++++++-------------- 4 files changed, 165 insertions(+), 85 deletions(-) create mode 100644 packages/http/src/http-auth.ts diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 3753abf..4c670c8 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -26,22 +26,30 @@ 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 the principal the **contract** declares. `httpAuth()` + * is what mints the form whose handlers see the application's own. + */ +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..6db7532 --- /dev/null +++ b/packages/http/src/http-auth.ts @@ -0,0 +1,44 @@ +import { HttpAuthenticator } from "./auth.js"; +import { controllerFor } from "./controller.js"; +import { routerFor } from "./orpc.js"; + +/** + * Mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to **this + * deployment's** identity — the server-side mirror of `auth

()` on the + * contract side: + * + * ```ts + * type Identity = { readonly tenantId: string; readonly userId: string }; + * export const { HttpController, HttpRouter, HttpAuthenticator } = httpAuth(); + * ``` + * + * A contract declares the **client-visible minimum** — `{ tenantId }` — and + * says *whether* a route is protected. What the server actually resolved is + * usually more, and a handler could not see the extra fields: their type was + * read off the contract. `Identity` is where that is stated instead, once, and + * every slice's controller infers from it with no annotation of its own. The + * authenticator and the controllers cannot disagree, because both come from + * this call. + * + * `HttpModule`'s gate is unchanged and still checks the authenticator's + * principal satisfies the contract's — a subtype discharges it, which is + * exactly what an `Identity` richer than the contract's `Principal` is. + * + * Written once per application, and 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 `HttpAuthenticator` handed back is already applied — the type argument + * `HttpAuthenticator

()` exists to state is what this factory just fixed — + * so it is called `HttpAuthenticator([deps], { sync })`. + */ +export const httpAuth = (): { + readonly HttpController: ReturnType>; + readonly HttpRouter: ReturnType>; + readonly HttpAuthenticator: ReturnType>; +} => ({ + HttpController: controllerFor(), + HttpRouter: routerFor(), + HttpAuthenticator: HttpAuthenticator(), +}); diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index e5df6d1..7fd8f37 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,6 +1,7 @@ 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 { HttpModule } from "./http-module.js"; export type { HttpModuleOptions } from "./http-module.js"; export { HttpConfig, HttpRuntime, http } from "./http-runtime.js"; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 263ef96..21ee7f5 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -119,99 +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 | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - readonly principal: ContractPrincipal; - }; - function build< - M extends { - readonly [K in Exclude]: ControllerFor>>; - }, - >( - controllers: M & { - readonly [K in Exclude>]: never; - }, - ): Provider< - PortInstance<"HttpRouter", Router>>, - never, - | InstanceType - | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) - > & { - readonly port: PortClassOf<"HttpRouter", Router>>; - readonly principal: ContractPrincipal; - }; - 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; + function build( + deps: D, + options: { + readonly sync: ( + ...services: { [K in keyof D]: ServiceOf> } + ) => Implementation; + }, + ): Provider< + PortInstance<"HttpRouter", Router>>, + never, + InstanceType | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) + > & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly principal: ContractPrincipal; + }; + function build< + M extends { + readonly [K in Exclude]: ControllerFor< + Inherit>, + Identity + >; + }, + >( + controllers: M & { + readonly [K in Exclude>]: never; + }, + ): Provider< + PortInstance<"HttpRouter", Router>>, + never, + | InstanceType + | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) + > & { + readonly port: PortClassOf<"HttpRouter", Router>>; + readonly principal: ContractPrincipal; + }; + 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); + } - // `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 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.slice(0, deps.length), - ) as Record, + Object.fromEntries(entries.map(([key], index) => [key, services[index]])), contract, isAuthenticated(contract), guarded ? authenticatorOf(services) : undefined, ), ); - return Provider(HttpRouterPort)(guarded ? [...deps, AuthenticatorPort] : deps, { + 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]])), - contract, - isAuthenticated(contract), - guarded ? authenticatorOf(services) : undefined, - ), - ); - const ports = entries.map(([, controller]) => controller.port); - return Provider(HttpRouterPort)(guarded ? [...ports, AuthenticatorPort] : ports, { - sync, - } as never); - } + return build; + }; - return build; -}; +/** + * The router, with no server-side identity: a handler under a marked key sees + * the principal the **contract** declares. `httpAuth()` is what mints + * the form whose handlers see the application's own. + */ +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>; }; /** @@ -221,14 +235,20 @@ 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< - ProcedureImplementer, I, O, E>["result"] + ProcedureImplementer< + DefaultInitialContext & object, + ContextOf, + I, + O, + E + >["result"] >[0] : { readonly [K in Exclude]: C[K] extends RouterContract - ? Implementation>> + ? Implementation>, Identity> : never; }; @@ -239,10 +259,17 @@ export type Implementation = * type parameter, so this package adds no second handler parameter and wraps no * `.result()` handler. `[X] extends [never]` rather than `X extends never`: a * bare check distributes over a union and answers `never` for every arm. + * + * `Identity` is the server's own principal, from `httpAuth()`. It + * replaces the contract's declared one on a **marked** leaf and invents none on + * an unmarked one: the contract still says *whether* a route is protected, and + * the factory says *what* the caller is. `never` — what the top-level + * `HttpRouter` / `HttpController` pass — is "no factory", and leaves the + * contract's own type in place. */ -type ContextOf = [PrincipalOf] extends [never] +type ContextOf = [PrincipalOf] extends [never] ? object - : { readonly principal: PrincipalOf }; + : { readonly principal: [Identity] extends [never] ? PrincipalOf : Identity }; /** * Pushes a record's marker onto each of its children, so a marked fragment From 7ff784a1fa72aaf1e4ceae764a80232d0f56a1ef Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 23:30:16 +0200 Subject: [PATCH 26/38] test(http): the factory's identity, in the types and through a real request --- packages/http/src/auth.spec.ts | 13 ++++++ packages/http/src/auth.test-d.ts | 67 ++++++++++++++++++++++++++++++ packages/http/src/test-fixtures.ts | 57 +++++++++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 37d1ed3..4310131 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -121,3 +121,16 @@ describe("the fail-closed authenticator", () => { ); }); }); + +describe("a controller minted by httpAuth", () => { + it("sees the identity the authenticator resolved, not the contract's principal", async ({ + rpcIdentity, + }) => { + // GIVEN a client the deployment's authenticator accepts + const client = rpcIdentity.clientWith("good"); + + // WHEN a marked procedure reads a field the contract declares nowhere + // THEN the handler read it off the very object the authenticator resolved + await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); + }); +}); diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 35e5d93..8d18eae 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -8,6 +8,7 @@ import { OkAsync } from "unthrown"; import { HttpAuthenticator } from "./auth.js"; import { HttpController } from "./controller.js"; +import { httpAuth } from "./http-auth.js"; import { HttpModule } from "./http-module.js"; import { HttpRouter, type ContractPrincipal, type Implementation } from "./orpc.js"; @@ -151,3 +152,69 @@ const _rootKeyed = HttpModule("RootKeyed")({ }); void _rootKeyed; + +// The server-side factory. The contract declares the client-visible minimum — +// `{ tenantId }` — and says WHETHER a route is protected; `httpAuth()` +// says WHAT the principal is, server-side, and a handler minted from it sees +// that. Each arm below is spelled against `scopedContract`, whose principal is +// deliberately narrower than the identity the server resolves. +type Tenant = { readonly tenantId: string }; +const { authenticated: scoped } = auth(); +const scopedContract = { orders: scoped({ place: oc }), health: { ping: oc } }; + +type Identity = Tenant & { readonly userId: string }; +const { + HttpController: IdentityController, + HttpRouter: IdentityRouter, + HttpAuthenticator: IdentityAuthenticator, +} = httpAuth(); + +// 12. A factory-minted controller's MARKED handler sees the factory's identity, +// including a field the contract declares nowhere. +const scopedOrders = IdentityController("ScopedOrders", scopedContract.orders)([], { + sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), +}); + +// 13. The top-level `HttpController` is unchanged: the same fragment types the +// CONTRACT's principal, which has no `userId`. +void HttpController("ContractOrders", scopedContract.orders)([], { + // @ts-expect-error — `userId` is the server's identity, not the contract's principal + sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), +}); + +// 14. A factory invents no principal on an UNMARKED fragment: the identity +// replaces the contract's type where there is one, and adds none where +// there is not. +void IdentityController("ScopedHealth", scopedContract.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 still checks the authenticator against the CONTRACT's +// principal — which an identity richer than it satisfies as a subtype. +const scopedHealth = IdentityController("ScopedHealthOk", scopedContract.health)([], { + sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), +}); +const identityAuthenticator = IdentityAuthenticator([], { + sync: () => () => OkAsync({ tenantId: "t", userId: "u" }), +}); +const _scoped = HttpModule("Scoped")({ + router: IdentityRouter(scopedContract)({ orders: scopedOrders, health: scopedHealth }), + authenticator: identityAuthenticator, + provides: [scopedOrders, scopedHealth], +}); + +// 16. An authenticator whose identity does not satisfy the contract's principal +// is still refused, factory or not. +const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()([], { + sync: () => () => OkAsync({ sub: "s" }), +}); +const _strayScoped = HttpModule("StrayScoped")({ + router: IdentityRouter(scopedContract)({ orders: scopedOrders, health: scopedHealth }), + // @ts-expect-error — the authenticator's principal is not the contract's + authenticator: strayAuthenticator, +}); + +void _scoped; +void _strayScoped; diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index b3e6972..450baec 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -40,6 +40,7 @@ import { test } from "vitest"; import { HttpAuthenticator, 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, @@ -228,6 +229,47 @@ const rpcRootMarkedAppOf = () => authenticator, }); +/** + * The gap `httpAuth()` closes, end to end: the contract declares the + * client-visible minimum (`{ tenantId }`) while this deployment's + * authenticator resolves `{ tenantId, userId }`, and the handler — minted from + * the factory — reads the field the contract declares nowhere. + */ +type ScopedPrincipal = { readonly tenantId: string }; +type Identity = ScopedPrincipal & { readonly userId: string }; +const { authenticated: scoped } = auth(); +const { + HttpController: IdentityController, + HttpRouter: IdentityRouter, + HttpAuthenticator: IdentityAuthenticator, +} = httpAuth(); + +const identityContract = { orders: scoped({ whoami }) }; + +const identityController = IdentityController("IdentityOrders", identityContract.orders)([], { + sync: () => ({ whoami: ({ context }) => OkAsync({ userId: context.principal.userId }) }), +}); + +const identityAuthenticator = IdentityAuthenticator([], { + sync: () => (headers) => + headers.authorization === "Bearer good" + ? OkAsync({ tenantId: "t-good", userId: "u-good" }) + : ErrAsync(new Unauthenticated({ reason: "not the good token" })), +}); + +const rpcIdentityAppOf = () => + HttpModule("RpcIdentityApp")({ + router: IdentityRouter(identityContract)({ orders: identityController }), + port: 0, + hostname: "127.0.0.1", + authenticator: identityAuthenticator, + provides: [identityController], + }); + +type IdentityClient = RouterContractClient<{ + readonly orders: { readonly whoami: typeof whoami }; +}>; + /** `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ const linkOf = (origin: string, token: string | undefined) => new RPCLink({ @@ -470,6 +512,12 @@ export type HttpFixtures = { readonly clientWith: (token: string | undefined) => RootMarkedClient; readonly handlerRuns: () => number; }; + /** + * The starter over a router and a controller minted by `httpAuth()`, + * where the identity is richer than the contract's principal. Shut down by + * the fixture. + */ + readonly rpcIdentity: { readonly clientWith: (token: string) => IdentityClient }; /** What each `HttpRouter` arm declares as its dependencies over the same marked contract. */ readonly authedRouterDeps: { readonly keyed: readonly string[]; @@ -700,6 +748,15 @@ export const it = test.extend({ }); }, + rpcIdentity: async ({ boot }, use) => { + const app = boot(rpcIdentityAppOf()); + 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}`; + + await use({ clientWith: (token) => createORPCClient(linkOf(origin, token)) }); + }, + // oxlint-disable-next-line no-empty-pattern -- see above authedRouterDeps: async ({}, use) => { await use({ From 393556a2030cedfd522b0a3880a53eda8ea470da Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 23:33:39 +0200 Subject: [PATCH 27/38] refactor(example): the order API mints its controllers from httpAuth --- examples/order-api/src/api.spec.ts | 8 ++-- examples/order-api/src/auth.ts | 42 +++++++++++++++++++ examples/order-api/src/authenticator.ts | 24 +++-------- examples/order-api/src/module.ts | 3 +- .../src/slices/customers/controller.ts | 3 +- .../order-api/src/slices/orders/controller.ts | 23 ++++++---- packages/http/src/http-auth.ts | 23 +++++++--- packages/http/src/index.ts | 1 + 8 files changed, 91 insertions(+), 36 deletions(-) create mode 100644 examples/order-api/src/auth.ts diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index 75056cd..6f55ff9 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -187,9 +187,9 @@ describe("order-api", () => { .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()) @@ -199,7 +199,7 @@ 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 ({ serve, clientFor, gate }) => { diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts new file mode 100644 index 0000000..be7f3f1 --- /dev/null +++ b/examples/order-api/src/auth.ts @@ -0,0 +1,42 @@ +import { + httpAuth, + type HttpAuthenticatorOf, + type HttpControllerOf, + type HttpRouterOf, +} from "@btravstack/http"; + +/** + * What this deployment knows about a caller, which is **more** than the + * contract asks for: `Principal` declares `{ tenantId }` alone, because that is + * all the API's own semantics depend on. `userId` is the server's business and + * never reaches a client. + * + * This is the layering the contract's own doc names, and this file is the one + * place it is stated. The contract says **whether** a route is protected and + * what a client must know; `httpAuth()` says **what** the principal + * is, server-side — so a handler sees `Identity`, `userId` included, with no + * annotation at its own call site. `HttpModule`'s gate is unchanged and still + * checks the authenticator against the contract's `Principal`; a subtype + * discharges it, which is what `Identity` is. + */ +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. + * + * 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 index 34c2bf2..b90e07b 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -1,19 +1,7 @@ -import { HttpAuthenticator, Unauthenticated } from "@btravstack/http"; +import { Unauthenticated } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; -/** - * What this deployment knows about a caller, which is **more** than the - * contract asks for: `Principal` declares `{ tenantId }` alone, because that is - * all the API's own semantics depend on. `userId` is the server's business and - * never reaches a client. - * - * This is the layering the contract's own doc names. `HttpModule`'s gate is - * `Auth extends { principal: Principal }`, so a subtype discharges it — adding - * roles, an org tier or an internal id here is not a contract change. The - * limit: a handler sees the contract's type, not this one, so a field a - * handler needs has to be declared in the contract and becomes client-visible. - */ -type Identity = { readonly tenantId: string; readonly userId: string }; +import { HttpAuthenticator } from "./auth.js"; /** * A stand-in, not a recommendation: `Bearer :`. What matters @@ -23,11 +11,11 @@ type Identity = { readonly tenantId: string; readonly userId: string }; * * `[]` 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 type is stated at the call rather than - * inferred, which is what makes a token resolving to the wrong shape a compile - * error at `HttpModule(...)` instead of an `unknown` reaching a handler. + * 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()([], { +export const bearerAuthenticator = HttpAuthenticator([], { sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index e1e152a..ae76bda 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -1,7 +1,8 @@ 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"; 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 f3c9ffb..f474b7d 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 }); /** @@ -27,7 +29,12 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * 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. The fragment's inputs name **no** tenant: a + * misreads it does not compile. `HttpController` is `../../auth.ts`'s, minted + * by `httpAuth()`, which is why `userId` is readable at all: the + * contract declares `{ tenantId }` and the authenticator resolves more, and + * the factory is what puts the server's own type 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 @@ -41,11 +48,12 @@ 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, context }, input) => - place + 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) => @@ -56,7 +64,8 @@ export const ordersController = HttpController("OrdersController", contract.orde .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) diff --git a/packages/http/src/http-auth.ts b/packages/http/src/http-auth.ts index 6db7532..8fdedf5 100644 --- a/packages/http/src/http-auth.ts +++ b/packages/http/src/http-auth.ts @@ -33,12 +33,25 @@ import { routerFor } from "./orpc.js"; * `HttpAuthenticator

()` exists to state is what this factory just fixed — * so it is called `HttpAuthenticator([deps], { sync })`. */ -export const httpAuth = (): { - readonly HttpController: ReturnType>; - readonly HttpRouter: ReturnType>; - readonly HttpAuthenticator: ReturnType>; -} => ({ +export const httpAuth = (): HttpAuth => ({ HttpController: controllerFor(), HttpRouter: routerFor(), HttpAuthenticator: HttpAuthenticator(), }); + +/** + * The three, as one type. `Identity` reaches a `.d.ts` through these aliases + * rather than through the inferred type of the call: what a controller's port + * expands to carries `@btravstack/contract`'s phantom `unique symbol`, which no + * consumer can name (TS2527, measured on `examples/order-api`). A file that + * exports what `httpAuth` returns annotates with them. + */ +export type HttpAuth = { + readonly HttpController: HttpControllerOf; + readonly HttpRouter: HttpRouterOf; + readonly HttpAuthenticator: HttpAuthenticatorOf; +}; + +export type HttpControllerOf = ReturnType>; +export type HttpRouterOf = ReturnType>; +export type HttpAuthenticatorOf = ReturnType>; diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 7fd8f37..87c9798 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -2,6 +2,7 @@ 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 { HttpAuth, 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"; From 418890e3b1e8243e94362c7e8c272a93f007c478 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Wed, 19 Aug 2026 23:38:13 +0200 Subject: [PATCH 28/38] docs(http): the identity factory, and the rule it states --- .changeset/server-side-identity.md | 33 ++++++++++ CLAUDE.md | 9 ++- docs/how-to/protect-a-procedure.md | 99 ++++++++++++++++++++++-------- docs/reference/http.md | 48 +++++++++++++++ examples/order-api/README.md | 35 +++++++++-- packages/http/CLAUDE.md | 49 +++++++++++++-- packages/http/README.md | 42 +++++++++++++ 7 files changed, 278 insertions(+), 37 deletions(-) create mode 100644 .changeset/server-side-identity.md diff --git a/.changeset/server-side-identity.md b/.changeset/server-side-identity.md new file mode 100644 index 0000000..4541816 --- /dev/null +++ b/.changeset/server-side-identity.md @@ -0,0 +1,33 @@ +--- +"@btravstack/http": minor +--- + +Let a deployment state what its principal actually is, server-side, with +`httpAuth()`. + +A contract declares the client-visible **minimum** — often `{ tenantId }` — and +says _whether_ a route is protected. What an authenticator resolves is usually +more, and until now a handler could not see the extra fields: their type was +read off the contract, so the richer object that was there at runtime reached +the handler only through a cast. + +`httpAuth()` mints `HttpController`, `HttpRouter` and +`HttpAuthenticator` together, all fixed to that identity — the server-side +mirror of `auth

()` on the contract side. It is 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 })`). + +The contract still decides _whether_: an unmarked procedure's context carries no +principal, factory or not, and `HttpModule`'s gate is unchanged — it still +checks the authenticator against the contract's own `Principal`, which a richer +`Identity` discharges as a subtype. `HttpController` and `HttpRouter` imported +from the package behave exactly as before. + +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/CLAUDE.md b/CLAUDE.md index d6144d7..2a2f64a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,14 +463,19 @@ 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 40 specs, across + owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 41 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`, 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`. + `rpcAuthed`. The server's own principal is `httpAuth()`'s: + the contract declares the client-visible minimum and says _whether_ a route + is protected, the factory says _what_ the caller is, and a handler minted + from it sees `Identity` where the contract's `Principal` used to be the only + type available (`examples/order-api/src/auth.ts` is the one file per + application that names it). - **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 diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 664dc37..6a76894 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -21,8 +21,9 @@ authenticator port, and the marked procedures' handlers grow a 1. Declare the principal and mark the contract with `auth()`'s `authenticated`. -2. Write the authenticator with `HttpAuthenticator()([deps], { sync })` - — headers in, `AsyncResult` out. +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 })`. @@ -64,21 +65,55 @@ 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. -## Step 2 — write the authenticator +## Step 2 — state the identity, and write the authenticator -`HttpAuthenticator

()([deps], { sync })` is an ordinary di provider on the -starter's `AuthenticatorPort`. It resolves a principal 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. +The contract declares the **client-visible minimum** and says _whether_ a route +is protected. What the server resolves is usually more, and where that is +stated is `httpAuth()` — one file per application, which hands back +`HttpController`, `HttpRouter` and `HttpAuthenticator` all fixed to that +identity: ```ts -import { HttpAuthenticator, Unauthenticated } from "@btravstack/http"; -import { ErrAsync, OkAsync } from "unthrown"; +// src/auth.ts +import { + httpAuth, + type HttpAuthenticatorOf, + type HttpControllerOf, + type HttpRouterOf, +} from "@btravstack/http"; /** What the server knows — more than the contract asks for. */ -type Identity = { readonly tenantId: string; readonly userId: string }; +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. -export const bearerAuthenticator = HttpAuthenticator()([], { +```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 ") @@ -102,10 +137,12 @@ against a contract asking only for `{ tenantId }`. Enriching what a deployment knows about its callers — roles, an org tier, an internal id — is therefore not a contract change, and none of it reaches a client. -The limit worth knowing: a handler sees the **contract's** type, not the -authenticator's. A field a handler needs must be declared in the contract, and -is client-visible once it is. That is the price of the field, and the reason to -keep `Principal` as small as the API's semantics allow. +A handler minted from the factory sees `Identity`, so those extra fields are +readable where the work happens: the contract decides _whether_ a route is +protected, the factory decides _what_ the principal is. Neither invents one — +an unmarked procedure's context still has no `principal` at all. The top-level +`HttpController` and `HttpRouter` are unchanged for an application that states +no identity: their handlers see the contract's principal, as before. `Bearer :` is a stand-in, not a recommendation — what matters is the shape. `[]` because this one needs no service; a JWT verifier, a @@ -113,10 +150,12 @@ 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 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 stating it is what makes a mismatch a compile -error at step 4 instead of an `unknown` reaching a handler. +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 a `reason`, and the reason is **yours**: the starter does not surface it. A rejected caller gets an `UNAUTHORIZED` carrying oRPC's @@ -127,17 +166,24 @@ 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`, typed with what the contract declared. No -second parameter, no wrapper: +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` included, though the contract +declares only `tenantId`: ```ts +import { HttpController } from "../../auth.js"; + export const ordersController = HttpController( "OrdersController", contract.orders, -)([PlaceOrder, FindOrder], { - sync: (place, find) => ({ - place: ({ errors, context }, input) => - place +)([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) => @@ -154,7 +200,8 @@ export const ordersController = HttpController( data: { id: error.id }, }), ), - ), + ); + }, find: ({ errors, context }, input) => find .execute(context.principal.tenantId, input.id) diff --git a/docs/reference/http.md b/docs/reference/http.md index 90c85c6..c0fbf94 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -25,6 +25,11 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `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, so a marked handler sees `Identity` rather than the contract's 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` carrying a `reason` — the refusal, the application's own; the starter does not surface it to the client | @@ -320,6 +325,49 @@ export const bearerAuthenticator = HttpAuthenticator()([], { }); ``` +### `httpAuth()` — what the principal is, server-side + +A contract declares the **client-visible minimum** and says _whether_ a route +is protected. What the authenticator resolves is usually more — `{ tenantId }` +in the contract, `{ tenantId, userId }` in the deployment — and a handler could +not see the extra fields: their type was read off the contract. +`httpAuth()` states the server's own principal instead, 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 factory replaces the contract's type only on a **marked** node and invents +none on an unmarked one: the contract still decides _whether_, and the gates +below are unchanged — `HttpModule` still checks the authenticator against the +contract's `Principal`, which an `Identity` richer than it discharges as a +subtype. `HttpController` and `HttpRouter` imported from the package are the +`Identity = never` case: their handlers see the contract's principal, exactly +as before. + ### Two gates, and why they are two When the contract marks anything, `HttpRouter` appends `AuthenticatorPort` diff --git a/examples/order-api/README.md b/examples/order-api/README.md index 7fa8c94..d03f470 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 @@ -113,9 +115,31 @@ It resolves **more** than the contract asks for, on purpose: `Principal` is authenticator returns `{ tenantId, userId }`. The gate is `Auth extends { principal: Principal }`, so a subtype discharges it — adding roles or an internal id to what a deployment knows about its callers is not a -contract change, and none of it reaches a client. The limit: a handler sees the -contract's type, so a field a handler needs has to be declared there and is -client-visible once it is. +contract change, and none of it reaches a client. + +Where that extra field 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 the client-visible minimum and **whether** a route is +protected; `httpAuth()` says **what** the principal is, server-side. +Both slices import `HttpController` from there instead of from +`@btravstack/http`, and the orders controller reads +`context.principal.userId` — a field the contract declares nowhere — 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. Nothing else about either controller changed, which is the point of the +factory: it is written once, and every slice infers from it. The root is a list of **slices**. Each one imports the vertical it needs — `OrderApplicationModule`, whose repository is an unmet need, and @@ -302,7 +326,8 @@ const ordersContract = { 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` — and its inputs name none: a required field the +`context.principal.tenantId` — the server's `Identity`, not merely the +contract's `Principal` — 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 diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 7df9577..3fbec60 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -187,6 +187,45 @@ InstanceType> & { readonly port: PortClassOf> not surface it, so an authenticator that wants it recorded logs it itself. Forwarding it would put "no such user" versus "bad signature" in a 401 body by default — an information-disclosure footgun shipped as the default. +- **`httpAuth()` → `{ HttpController, HttpRouter, HttpAuthenticator }`, + plus `HttpAuth` / `HttpControllerOf` / + `HttpRouterOf` / `HttpAuthenticatorOf`** + (`http-auth.ts`) — the server-side mirror of the contract's `auth

()`. + A contract declares the **client-visible minimum** and 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] extends [never] ? PrincipalOf : +Identity`, an **unmarked** one is still `object`. `Identity = never` is + therefore "no factory", and the top-level `HttpController` / + `HttpRouter` are `controllerFor()` / `routerFor()` — today's + behaviour, unchanged, which is the backward compatibility this default + exists for. `controllerFor` and `routerFor` are exported from their own + files for this factory alone, not from `index.ts`. + The bug it fixes: the contract declared `{ tenantId }` while the + authenticator resolved `{ tenantId, userId }`, the richer object was on + `context.principal` at runtime, and a handler could reach the extra field + only through a cast. + 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 is untouched and still compares the authenticator's + principal to the **contract's**, which an `Identity` richer than it + discharges as a subtype. Pinned by `auth.test-d.ts`'s arms 12–16 (the + identity on a marked leaf, the top-level form unchanged on the same + fragment, no principal invented on an unmarked one, the keyed compose, and a + stray authenticator still refused) and at runtime by `auth.spec.ts`'s + `rpcIdentity` fixture, whose handler returns a `userId` its contract + declares nowhere. - **`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 @@ -390,7 +429,7 @@ 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. -- **40 specs, 100% lines/functions.** Every app boots through the `boot` +- **41 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 @@ -430,8 +469,8 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over 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. `auth.spec.ts` - carries the last 9, through the `rpcAuthed`, `rpcRootMarked`, - `authedRouterDeps` and `controllers` fixtures. Four are over + carries the last 10, through the `rpcAuthed`, `rpcRootMarked`, + `authedRouterDeps`, `rpcIdentity` and `controllers` fixtures. Four are over `authedContract` — `{ orders: authenticated({ whoami }), health: { ping } }`, one protected fragment and one public one: the principal reaching the handler, a rejected token answering `UNAUTHORIZED` with the handler never @@ -444,6 +483,8 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over 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. + every caller. The tenth is the identity factory: a controller minted by + `httpAuth()` answers with a field its contract declares nowhere, + read off the object the authenticator resolved. `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 24bf879..519fc6f 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -216,6 +216,48 @@ const OrdersApi = HttpModule("OrdersApi")({ }); ``` +### When the server knows more than the contract says + +A contract declares the **client-visible minimum** — often `{ tenantId }` — and +says _whether_ a route is protected. What the authenticator resolves is usually +more, and a handler could not see the extra fields: their type was read off the +contract. `httpAuth()` states the server's own principal instead, once +per application, and hands back the three pieces fixed to it: + +```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; +``` + +Every slice imports `HttpController` from there instead of from this package, +and its handlers see `Identity` on `context.principal` — `userId` included — +with no annotation of their own. Nothing else about a controller changes. The +authenticator drops its type argument (`HttpAuthenticator([deps], { sync })`), +because this call already fixed it, which is also why the authenticator and the +controllers cannot disagree. + +An unmarked procedure still gets no principal: the contract decides _whether_, +the factory decides _what_. `HttpModule`'s gate is unchanged and still checks +the authenticator against the contract's own `Principal` — an `Identity` richer +than it discharges that as a subtype. 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. + A marked router carries the authenticator port as a **need**, so forgetting `authenticator` is an unmet dependency `start` refuses, and supplying one that resolves a different principal is a compile error at the `HttpModule(...)` call. From 3a1b4902f9a5c1f1397da462bed0fe5ddb997a5a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 00:39:34 +0200 Subject: [PATCH 29/38] feat(contract)!: the marker says whether, not who `authenticated` is one export with no factory and no type parameter: a contract declares that a route is protected and names no identity type at all. `auth

()` and `PrincipalOf` are gone; `IsMarked` answers yes/no in their place. BREAKING CHANGE: `auth

()` is replaced by a bare `authenticated` export, `PrincipalOf` is removed, and `Authenticated` loses its principal parameter. --- packages/contract/CLAUDE.md | 75 +++++++++++++++----------- packages/contract/README.md | 18 ++++--- packages/contract/src/auth.spec.ts | 13 ++--- packages/contract/src/auth.test-d.ts | 39 +++++++++----- packages/contract/src/auth.ts | 36 +++++++------ packages/contract/src/index.ts | 4 +- packages/contract/src/test-fixtures.ts | 9 ---- 7 files changed, 105 insertions(+), 89 deletions(-) diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index d183425..592b83b 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -16,46 +16,44 @@ marker over `WeakSet` identity, transport-agnostic by construction. ## Public surface -- **`auth

()`** (`auth.ts`) — `(): { readonly authenticated: (node: T) => Authenticated }`. Mints the combinator for one - contract's principal type `P`. Call it once per contract, destructure - `authenticated`, and apply it to a record of procedures (protects every - procedure beneath it) or to a single procedure (protects itself). -- **`Authenticated`** — `T & { readonly [PrincipalKey]: P }`. The typed +- **`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. -- **`PrincipalOf`** — `T extends { readonly [PrincipalKey]: infer P } ? P : -never`. Recovers the principal type a node was marked with, `never` when it - carries no marker. +- **`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. -## Declare the minimum, resolve more +## The contract says whether; the application says what -`P` is what a **client** learns about the identity the API expects, so it -belongs in the contract only as far as the API's own semantics depend on it. -`@btravstack/http`'s gate is `Auth extends { principal: P }`, so a **subtype** -discharges it — an authenticator resolving `{ tenantId, userId, roles }` -satisfies a contract declaring `{ tenantId }`. Enriching what a deployment -knows about its callers is therefore not a contract change and reaches no -client. +**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. -The limit: a handler sees `PrincipalOf`, the contract's type, not the -authenticator's. A field a handler needs must be declared in the contract and -is client-visible once it is. Keep `P` as small as the API allows. -`examples/order-api-contract` is the worked case — `{ tenantId }` in the -contract, `{ tenantId, userId }` out of the authenticator. +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 `auth()` combinator — the marker has no +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.** @@ -76,6 +74,14 @@ 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. A nameable brand could be hand-written onto a contract node without +the corresponding `WeakSet` entry: typed as protected, unmarked at runtime — +so no authenticator is demanded and a handler reads a principal nothing ever +injected. The TS2527 wart a consumer hits when re-exporting an inferred +controller type is the price, and 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 @@ -83,16 +89,21 @@ builder has to know the marker exists or preserve it through its own chain. ## Specs -`vitest run --coverage`, 100% lines/functions, 4 tests in one file, +`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, and two -contracts' markers stay independent. `test-fixtures.ts` provides the -`authenticated` combinator and a one-key `fragment`, both as lazy fixtures. +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 -Nothing consumes this yet. A later package reads `PrincipalKey` / -`PrincipalOf` off a marked contract to type a handler's context with the -principal, and a starter maps a missing or invalid principal to a transport -error — neither exists here, and this package does not anticipate their -shape. +**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/README.md b/packages/contract/README.md index ca6d2d3..991f1d6 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -1,10 +1,10 @@ # @btravstack/contract > 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. Zero dependencies, zero peers — a client can -> take a contract without the server, and any transport's contract can use -> the same combinator. +> 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 @@ -15,8 +15,7 @@ Node `>=20`. Not yet published: this repository has not cut a release yet. ## Usage ```ts -export type Principal = { readonly userId: string }; -const { authenticated } = auth(); +import { authenticated } from "@btravstack/contract"; export const contract = { orders: authenticated({ place, find }), @@ -28,6 +27,11 @@ 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, @@ -35,7 +39,7 @@ 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 principal type, not an open route. +mismatched marker symbol, not an open route. ## License diff --git a/packages/contract/src/auth.spec.ts b/packages/contract/src/auth.spec.ts index fd3b312..a64a1a7 100644 --- a/packages/contract/src/auth.spec.ts +++ b/packages/contract/src/auth.spec.ts @@ -1,10 +1,10 @@ import { describe, expect } from "vitest"; -import { isAuthenticated } from "./auth.js"; +import { authenticated, isAuthenticated } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("authenticated", () => { - it("marks the node it is given", ({ authenticated, fragment }) => { + it("marks the node it is given", ({ fragment }) => { // GIVEN an unmarked contract fragment // WHEN it is marked const marked = authenticated(fragment); @@ -15,7 +15,7 @@ describe("authenticated", () => { }); }); - it("adds no enumerable key", ({ authenticated, fragment }) => { + it("adds no enumerable key", ({ fragment }) => { // GIVEN a fragment with exactly one key // WHEN it is marked const marked = authenticated(fragment); @@ -30,10 +30,7 @@ describe("authenticated", () => { expect(isAuthenticated(fragment)).toBe(false); }); - it("registers the mark where a second copy of this package would find it", ({ - authenticated, - fragment, - }) => { + 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") @@ -45,7 +42,7 @@ describe("authenticated", () => { expect(registry?.has(fragment)).toBe(true); }); - it("keeps two contracts' markers independent", ({ authenticated, fragment }) => { + 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 diff --git a/packages/contract/src/auth.test-d.ts b/packages/contract/src/auth.test-d.ts index 8a0efe4..dd6f01d 100644 --- a/packages/contract/src/auth.test-d.ts +++ b/packages/contract/src/auth.test-d.ts @@ -1,31 +1,42 @@ import { describe, test } from "vitest"; -import type { Authenticated, PrincipalKey, PrincipalOf } from "./auth.js"; +import type { Authenticated, IsMarked, PrincipalKey } from "./auth.js"; type Fragment = { readonly place: { readonly kind: "procedure" } }; -type Principal = { readonly userId: string }; +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>; + test("Exclude, PrincipalKey> is exactly keyof T", () => { + const same = null as unknown as Exclude, PrincipalKey>; const fragmentKey: keyof Fragment = same; void fragmentKey; }); - test("PrincipalOf recovers the principal a node was marked with", () => { - const principal = null as unknown as PrincipalOf>; - const recovered: Principal = principal; - void recovered; + 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("PrincipalOf is never for a node carrying no marker", () => { - // @ts-expect-error PrincipalOf is `never`, nothing is assignable to it - const wrong: PrincipalOf = { userId: "x" } satisfies Principal; - void wrong; + 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 marked = null as unknown as Authenticated; const plain: Fragment = marked; void plain; }); @@ -33,7 +44,7 @@ describe("Authenticated carries the contract's own keys plus the phantom one", ( 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; + const marked: Authenticated = plain; void marked; }); }); diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index 9c23d69..89e3054 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -1,18 +1,22 @@ /** * The phantom key the marker occupies. Declared, never defined: it exists only * in the type system, so a marked node carries no runtime property and there is - * nothing for oRPC's `implement()` to walk as a procedure. + * nothing for oRPC's `implement()` to walk as a procedure. It is never exported + * as a value either — a nameable brand could be hand-written onto a contract + * without the corresponding registry entry, which types as protected and runs + * unmarked: no authenticator demanded, and a handler reading a principal + * nothing ever injected. */ declare const PRINCIPAL: unique symbol; -/** A contract node whose procedures require an authenticated principal of type `P`. */ -export type Authenticated = T & { readonly [PRINCIPAL]: P }; +/** A contract node whose procedures require an authenticated principal. */ +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; -/** The principal a node was marked with, or `never` when it carries no marker. */ -export type PrincipalOf = T extends { readonly [PRINCIPAL]: infer P } ? P : never; +/** Whether this exact node carries the marker — a yes/no, not a type. */ +export type IsMarked = T extends { readonly [PRINCIPAL]: true } ? true : false; // Identity, not a property: a marked node must stay `===` what the contract // declared, so `implement()` walks it unchanged and a consumer can still index @@ -29,12 +33,9 @@ const store = globalThis as unknown as { [registry]?: WeakSet }; const marked = (store[registry] ??= new WeakSet()); /** - * Mints the combinator for one contract's principal type. + * Marks a contract node as requiring an authenticated caller. * * ```ts - * export type Principal = { readonly userId: string }; - * const { authenticated } = auth(); - * * export const contract = { * orders: authenticated({ place, find }), * customers: { find, quote: authenticated(oc.input(…).output(…)) }, @@ -44,15 +45,16 @@ const marked = (store[registry] ??= new WeakSet()); * A marked record protects every procedure beneath it; a marked procedure * protects itself. Applied AFTER a builder chain is finished, never inside * one, so nothing about oRPC's builders has to preserve it. + * + * The contract says **whether** a route is protected and nothing about who the + * caller is: no principal type is named here, so nothing about the server's + * identity reaches a client. What the principal actually is, is the + * application's `httpAuth()` to say. */ -export const auth =

(): { - readonly authenticated: (node: T) => Authenticated; -} => ({ - authenticated: (node: T): Authenticated => { - marked.add(node); - return node as Authenticated; - }, -}); +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); diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index cde1658..b12a8f3 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -1,7 +1,7 @@ export { - auth, + authenticated, isAuthenticated, type Authenticated, + type IsMarked, type PrincipalKey, - type PrincipalOf, } from "./auth.js"; diff --git a/packages/contract/src/test-fixtures.ts b/packages/contract/src/test-fixtures.ts index a9fc69e..19b2a68 100644 --- a/packages/contract/src/test-fixtures.ts +++ b/packages/contract/src/test-fixtures.ts @@ -1,18 +1,9 @@ import { test } from "vitest"; -import { auth } from "./auth.js"; - -type Principal = { readonly userId: string }; - export const it = test.extend<{ - readonly authenticated: ReturnType>["authenticated"]; 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 - authenticated: async ({}, use) => { - await use(auth().authenticated); - }, - // oxlint-disable-next-line no-empty-pattern -- see above fragment: async ({}, use) => { await use({ place: { kind: "procedure" } }); }, From 29fcc9a8a241a891272d2964c21c585a49dbf7e0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 00:44:44 +0200 Subject: [PATCH 30/38] feat(http)!: the gate pairs the router's identity with the authenticator's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no principal on the contract, `ContractPrincipal` becomes `HasMark` — a yes/no driving the conditional authenticator dependency — and the router provider carries `readonly identity` instead of `readonly principal`. `HttpModule` compares the two identities, so a router from `httpAuth()` refuses an authenticator from `httpAuth()`. A marked leaf reached without the factory types `principal: never`: any read is a compile error, which is the "use the factory" signal. BREAKING CHANGE: `ContractPrincipal` is replaced by `HasMark`, and a marked contract now requires `httpAuth()` for a handler to read a principal at all. --- packages/http/src/auth.spec.ts | 20 +--- packages/http/src/auth.test-d.ts | 149 +++++++++++++------------ packages/http/src/controller.test-d.ts | 34 ++++-- packages/http/src/controller.ts | 5 +- packages/http/src/http-auth.ts | 23 ++-- packages/http/src/http-module.ts | 24 ++-- packages/http/src/index.ts | 2 +- packages/http/src/orpc.ts | 71 ++++++------ packages/http/src/test-fixtures.ts | 104 +++++------------ 9 files changed, 196 insertions(+), 236 deletions(-) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 4310131..787ec2e 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -4,12 +4,13 @@ import { Unauthenticated, noAuthenticator } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("an authenticated procedure", () => { - it("hands the principal to the handler", async ({ rpcAuthed }) => { + 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 is called - // THEN the handler saw the principal the authenticator resolved + // 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" }); }); @@ -121,16 +122,3 @@ describe("the fail-closed authenticator", () => { ); }); }); - -describe("a controller minted by httpAuth", () => { - it("sees the identity the authenticator resolved, not the contract's principal", async ({ - rpcIdentity, - }) => { - // GIVEN a client the deployment's authenticator accepts - const client = rpcIdentity.clientWith("good"); - - // WHEN a marked procedure reads a field the contract declares nowhere - // THEN the handler read it off the very object the authenticator resolved - await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); - }); -}); diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 8d18eae..74c6f20 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -1,7 +1,7 @@ // 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 { auth, type Authenticated } from "@btravstack/contract"; +import { authenticated, type Authenticated } from "@btravstack/contract"; import { start } from "@btravstack/core"; import { oc } from "@orpc/contract"; import { OkAsync } from "unthrown"; @@ -10,10 +10,9 @@ import { HttpAuthenticator } from "./auth.js"; import { HttpController } from "./controller.js"; import { httpAuth } from "./http-auth.js"; import { HttpModule } from "./http-module.js"; -import { HttpRouter, type ContractPrincipal, type Implementation } from "./orpc.js"; +import { HttpRouter, type HasMark, type Implementation } from "./orpc.js"; -type Principal = { readonly userId: string; readonly tenantId: string }; -const { authenticated } = auth(); +type Identity = { readonly userId: string; readonly tenantId: string }; const contract = { orders: authenticated({ place: oc }), @@ -21,6 +20,12 @@ const contract = { 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 } @@ -28,19 +33,19 @@ type HandlerContext = H extends (opts: infer O, ...rest: never) => unknown : never : never; -type OrdersImpl = Implementation<(typeof contract)["orders"]>; -type HealthImpl = Implementation<(typeof contract)["health"]>; -type QuoteImpl = Implementation<(typeof contract)["quote"]>; +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 -// principal arrives on `opts.context` — oRPC's own channel, no second -// handler parameter added by this package. +// 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: Principal = ordersContext.principal; +const _inherited: Identity = ordersContext.principal; // 2. A marked PROCEDURE protects itself. declare const quoteContext: HandlerContext; -const _leaf: Principal = quoteContext.principal; +const _leaf: Identity = quoteContext.principal; // 3. The marker's phantom key never becomes a procedure key. type _OrdersKeys = Expect< @@ -54,48 +59,53 @@ type _OrdersKeys = Expect< // 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: Principal = healthContext.principal; +const _none: Identity = healthContext.principal; -// 5. The principal a contract declares is found through the nesting — pinned -// both ways, since assignability alone would also hold if it widened. -type _FoundPrincipal = Expect< - [ContractPrincipal] extends [Principal] - ? [Principal] extends [ContractPrincipal] +// 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 declares no principal at all. -// @ts-expect-error — `ContractPrincipal` of an unmarked tree is `never` -const _absent: ContractPrincipal<{ readonly health: { readonly ping: typeof oc } }> = {}; +// 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; -void _absent; // 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 the contract's principal is this +// 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 principal, so only the options type -// can compare it. -const markedRouter = HttpRouter({ orders: contract.orders, health: contract.health })([], { +// 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 = HttpAuthenticator()([], { +const matching = IdentityAuthenticator([], { sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), }); -const other = HttpAuthenticator<{ readonly sub: string }>()([], { +const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator([], { sync: () => () => OkAsync({ sub: "s" }), }); @@ -107,12 +117,12 @@ 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 whose principal is not the contract's is refused. Unlike -// 7, this one is NOT di's gate and does not wait for `start`: the +// 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 principals meet on `HttpModule`'s own options — -// `Principal` is inferred from the router — which is where it is caught. -// @ts-expect-error — the authenticator's principal is not the contract's. +// 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. @@ -121,7 +131,7 @@ 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 = HttpRouter({ health: contract.health })([], { +const publicRouter = IdentityRouter({ health: contract.health })([], { sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), }); const _public = start( @@ -135,84 +145,75 @@ void _wired; void _public; // 11. A ROOT-marked contract composes through the KEYED form, and a controller -// under it reads the principal the root mark declares. The keyed overload +// 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 }, Principal>; -const rootOrders = HttpController("RootOrders", ordersFragment)([], { +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: HttpRouter(rootMarkedContract)({ orders: rootOrders }), + router: IdentityRouter(rootMarkedContract)({ orders: rootOrders }), authenticator: matching, }); void _rootKeyed; -// The server-side factory. The contract declares the client-visible minimum — -// `{ tenantId }` — and says WHETHER a route is protected; `httpAuth()` -// says WHAT the principal is, server-side, and a handler minted from it sees -// that. Each arm below is spelled against `scopedContract`, whose principal is -// deliberately narrower than the identity the server resolves. -type Tenant = { readonly tenantId: string }; -const { authenticated: scoped } = auth(); -const scopedContract = { orders: scoped({ place: oc }), health: { ping: oc } }; - -type Identity = Tenant & { readonly userId: string }; -const { - HttpController: IdentityController, - HttpRouter: IdentityRouter, - HttpAuthenticator: IdentityAuthenticator, -} = httpAuth(); +// 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, -// including a field the contract declares nowhere. -const scopedOrders = IdentityController("ScopedOrders", scopedContract.orders)([], { - sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), +// a type the contract declares nowhere. +const scopedOrders = IdentityController("ScopedOrders", contract.orders)([], { + sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), }); -// 13. The top-level `HttpController` is unchanged: the same fragment types the -// CONTRACT's principal, which has no `userId`. -void HttpController("ContractOrders", scopedContract.orders)([], { - // @ts-expect-error — `userId` is the server's identity, not the contract's principal +// 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 -// replaces the contract's type where there is one, and adds none where -// there is not. -void IdentityController("ScopedHealth", scopedContract.health)([], { +// 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 still checks the authenticator against the CONTRACT's -// principal — which an identity richer than it satisfies as a subtype. -const scopedHealth = IdentityController("ScopedHealthOk", scopedContract.health)([], { +// `HttpModule` gate checks the authenticator against the ROUTER's identity. +const scopedHealth = IdentityController("ScopedHealthOk", contract.health)([], { sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), }); -const identityAuthenticator = IdentityAuthenticator([], { - sync: () => () => OkAsync({ tenantId: "t", userId: "u" }), -}); const _scoped = HttpModule("Scoped")({ - router: IdentityRouter(scopedContract)({ orders: scopedOrders, health: scopedHealth }), - authenticator: identityAuthenticator, + router: IdentityRouter({ orders: contract.orders, health: contract.health })({ + orders: scopedOrders, + health: scopedHealth, + }), + authenticator: matching, provides: [scopedOrders, scopedHealth], }); -// 16. An authenticator whose identity does not satisfy the contract's principal -// is still refused, factory or not. +// 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(scopedContract)({ orders: scopedOrders, health: scopedHealth }), - // @ts-expect-error — the authenticator's principal is not the contract's + 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, }); diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index dd0983c..6421a65 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -1,11 +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 { auth } from "@btravstack/contract"; +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 } }; @@ -60,34 +61,45 @@ type _ComposedNeedsAreDeclared = Expect<[NeedsOf] extends [neve // 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. -const { authenticated } = auth<{ readonly userId: string }>(); +// 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 markedOrders = HttpController("GateMarkedOrders", markedContract.orders)([], { +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 HttpRouter(markedContract)({ orders: markedOrders }); +void IdentityRouter(markedContract)({ orders: markedOrders }); // 2. A key the contract does not declare is rejected. // @ts-expect-error — `billing` is not in the contract -void HttpRouter(markedContract)({ orders: markedOrders, users, billing: markedOrders }); +void IdentityRouter(markedContract)({ + orders: markedOrders, + users: markedUsers, + 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 HttpRouter(markedContract)({ orders: users, users: markedOrders }); +void IdentityRouter(markedContract)({ orders: markedUsers, users: markedOrders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void HttpController("GateMarkedTypo", markedContract.orders)([], { +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 HttpRouter(markedContract.orders)([markedOrders.port], { +void IdentityRouter(markedContract.orders)([markedOrders.port], { sync: (implementation) => implementation, }); @@ -96,6 +108,6 @@ void HttpRouter(markedContract.orders)([markedOrders.port], { // 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 HttpRouter(markedContract)({ orders: markedOrders, users }); +void IdentityRouter(markedContract)({ orders: markedOrders, users: markedUsers }); // @ts-expect-error — `markedOrders` needs a principal the unmarked contract declares nowhere -void HttpRouter(contract)({ orders: markedOrders, users }); +void IdentityRouter(contract)({ orders: markedOrders, users: markedUsers }); diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 4c670c8..39f39e7 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -49,7 +49,8 @@ export const controllerFor = /** * The controller, with no server-side identity: a handler under a marked - * fragment sees the principal the **contract** declares. `httpAuth()` - * is what mints the form whose handlers see the application's own. + * 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 index 8fdedf5..fc6ee53 100644 --- a/packages/http/src/http-auth.ts +++ b/packages/http/src/http-auth.ts @@ -4,25 +4,24 @@ import { routerFor } from "./orpc.js"; /** * Mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to **this - * deployment's** identity — the server-side mirror of `auth

()` on the - * contract side: + * deployment's** identity: * * ```ts * type Identity = { readonly tenantId: string; readonly userId: string }; * export const { HttpController, HttpRouter, HttpAuthenticator } = httpAuth(); * ``` * - * A contract declares the **client-visible minimum** — `{ tenantId }` — and - * says *whether* a route is protected. What the server actually resolved is - * usually more, and a handler could not see the extra fields: their type was - * read off the contract. `Identity` is where that is stated instead, once, and - * every slice's controller infers from it with no annotation of its own. The - * authenticator and the controllers cannot disagree, because both come from - * this call. + * **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 own view of a caller reaches a client, and `Identity` is stated + * here once — every slice's controller infers from it with no annotation of + * its own, and the authenticator and the controllers cannot disagree, because + * both come from this call. * - * `HttpModule`'s gate is unchanged and still checks the authenticator's - * principal satisfies the contract's — a subtype discharges it, which is - * exactly what an `Identity` richer than the contract's `Principal` is. + * That is also what `HttpModule`'s gate now compares: the **router's** identity + * against the **authenticator's**, so a router from `httpAuth()` refuses an + * authenticator from `httpAuth()`. The authenticator must resolve at least + * what the handlers read, so a subtype discharges it. * * Written once per application, and per application rather than per slice * because a handler's parameter types are fixed where the arrow is written: a diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index 3f752c0..54affd5 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -39,7 +39,7 @@ type Provides< export type HttpModuleOptions< RouterError, RouterNeeds, - Principal, + RouterIdentity, Auth extends AnyProvider | undefined, I extends readonly AnyModule[], P extends readonly AnyProvider[], @@ -47,16 +47,20 @@ export type HttpModuleOptions< > = { /** The application's oRPC router — `HttpRouter(contract)(deps, arm)`, the provider that builds it from the services its procedures call. */ readonly router: Provider & { - readonly principal: Principal; + 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 + * `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 the contract's own principal is the one - * thing that need cannot say — the port's service type is erased — so - * `Principal`, read off `router`, is what checks it here. + * 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`. */ @@ -107,10 +111,10 @@ export const HttpModule = < RouterError, RouterNeeds, - Principal, + RouterIdentity, const Auth extends | (Provider & { - readonly principal: [Principal] extends [never] ? unknown : Principal; + readonly principal: [RouterIdentity] extends [never] ? unknown : RouterIdentity; }) | undefined = undefined, const I extends readonly AnyModule[] = [], @@ -118,7 +122,7 @@ export const HttpModule = const X extends readonly Exportable, Provides>[] = [], >( - options: HttpModuleOptions, + options: HttpModuleOptions, ) => { const { router, authenticator, prefix, port, hostname, plugins, securityHeaders } = options; const imports = (options.imports ?? []) as I; diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 87c9798..a385f00 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -7,5 +7,5 @@ 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 { ContractPrincipal } from "./orpc.js"; +export type { HasMark } from "./orpc.js"; export type { HttpInfo, HttpOptions } from "./http-runtime.js"; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 21ee7f5..d28af71 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -1,8 +1,8 @@ import { isAuthenticated, type Authenticated, + type IsMarked, type PrincipalKey, - type PrincipalOf, } from "@btravstack/contract"; import { Port, @@ -141,15 +141,15 @@ export const routerFor = ): Provider< PortInstance<"HttpRouter", Router>>, never, - InstanceType | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) + InstanceType | (HasMark extends true ? AuthenticatorPort : never) > & { readonly port: PortClassOf<"HttpRouter", Router>>; - readonly principal: ContractPrincipal; + readonly identity: Identity; }; function build< M extends { readonly [K in Exclude]: ControllerFor< - Inherit>, + Inherit>, Identity >; }, @@ -160,11 +160,10 @@ export const routerFor = ): Provider< PortInstance<"HttpRouter", Router>>, never, - | InstanceType - | ([ContractPrincipal] extends [never] ? never : AuthenticatorPort) + InstanceType | (HasMark extends true ? AuthenticatorPort : never) > & { readonly port: PortClassOf<"HttpRouter", Router>>; - readonly principal: ContractPrincipal; + readonly identity: Identity; }; function build(depsOrControllers: unknown, options?: unknown): unknown { // The authenticator is appended LAST to the dependency array so every @@ -218,8 +217,9 @@ export const routerFor = /** * The router, with no server-side identity: a handler under a marked key sees - * the principal the **contract** declares. `httpAuth()` is what mints - * the form whose handlers see the application's own. + * `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(); @@ -248,7 +248,7 @@ export type Implementation = >[0] : { readonly [K in Exclude]: C[K] extends RouterContract - ? Implementation>, Identity> + ? Implementation>, Identity> : never; }; @@ -257,48 +257,47 @@ export type Implementation = * 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. `[X] extends [never]` rather than `X extends never`: a - * bare check distributes over a union and answers `never` for every arm. + * `.result()` handler. * - * `Identity` is the server's own principal, from `httpAuth()`. It - * replaces the contract's declared one on a **marked** leaf and invents none on - * an unmarked one: the contract still says *whether* a route is protected, and - * the factory says *what* the caller is. `never` — what the top-level - * `HttpRouter` / `HttpController` pass — is "no factory", and leaves the - * contract's own type in place. + * 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 = [PrincipalOf] extends [never] - ? object - : { readonly principal: [Identity] extends [never] ? PrincipalOf : Identity }; +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 = [P] extends [never] ? T : Authenticated; +type Inherit = Marked extends true ? Authenticated : T; /** - * The principal a contract declares anywhere in its tree, or `never` if none - * does — what a composition root reads to know which authenticator it owes. + * 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 ContractPrincipal = [PrincipalOf] extends [never] - ? C extends ProcedureContract - ? never - : { - readonly [K in Exclude]: C[K] extends RouterContract - ? ContractPrincipal - : never; - }[Exclude] - : PrincipalOf; +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 the - * `[ContractPrincipal] extends [never]` arm on both `build` overloads; these - * two must agree. + * 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; diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 450baec..5ff8e5f 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -26,7 +26,7 @@ import { createServer } from "node:http"; import { connect, type Socket } from "node:net"; import type { ConfigInvalid, Environment } from "@btravstack/config"; -import { auth } from "@btravstack/contract"; +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"; @@ -37,7 +37,7 @@ import { CORSHandlerPlugin } from "@orpc/server/plugins"; import { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { test } from "vitest"; -import { HttpAuthenticator, Unauthenticated } from "./auth.js"; +import { Unauthenticated } from "./auth.js"; import { HttpController } from "./controller.js"; import { HttpHandler } from "./handler.js"; import { httpAuth } from "./http-auth.js"; @@ -134,46 +134,57 @@ const rpcSlicedAppOf = () => ], }); -type AuthedPrincipal = { readonly userId: string }; +/** + * 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 { authenticated } = auth(); +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()); +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 = HttpController("AuthedOrders", authedContract.orders)([], { +const authedOrdersController = AuthedController("AuthedOrders", authedContract.orders)([], { sync: () => ({ whoami: ({ context }) => { authedRuns += 1; - return OkAsync(context.principal); + return OkAsync({ userId: context.principal.userId }); }, }), }); -const authedHealthController = HttpController("AuthedHealth", authedContract.health)([], { +const authedHealthController = AuthedController("AuthedHealth", authedContract.health)([], { sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), }); -const authenticator = HttpAuthenticator()([], { +const authenticator = AuthedAuthenticator([], { sync: () => (headers) => { if (headers.authorization === "Bearer boom") { - return OkAsync().map((): AuthedPrincipal => { + 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({ userId: "u-good" }) + ? OkAsync({ tenantId: "t-good", userId: "u-good" }) : ErrAsync(new Unauthenticated({ reason: "not the good token" })); }, }); -const authedRouter = HttpRouter(authedContract)({ +const authedRouter = AuthedRouter(authedContract)({ orders: authedOrdersController, health: authedHealthController, }); @@ -182,7 +193,7 @@ const authedRouter = HttpRouter(authedContract)({ * The same marked contract through the positional form, so where the * authenticator lands in `deps` is pinned for both arms of `build`. */ -const authedPositionalRouter = HttpRouter(authedContract)([Greeter], { +const authedPositionalRouter = AuthedRouter(authedContract)([Greeter], { sync: (greeter) => ({ orders: { whoami: ({ context }) => OkAsync({ userId: greeter.greet(context.principal.userId) }), @@ -204,18 +215,18 @@ const rpcAuthedAppOf = () => /** * 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 `PrincipalOf`. + * record arm inherits `IsMarked`. */ const rootMarkedContract = authenticated({ orders: { whoami } }); let rootMarkedRuns = 0; -const rootMarkedRouter = HttpRouter(rootMarkedContract)([], { +const rootMarkedRouter = AuthedRouter(rootMarkedContract)([], { sync: () => ({ orders: { whoami: ({ context }) => { rootMarkedRuns += 1; - return OkAsync(context.principal); + return OkAsync({ userId: context.principal.userId }); }, }, }), @@ -229,47 +240,6 @@ const rpcRootMarkedAppOf = () => authenticator, }); -/** - * The gap `httpAuth()` closes, end to end: the contract declares the - * client-visible minimum (`{ tenantId }`) while this deployment's - * authenticator resolves `{ tenantId, userId }`, and the handler — minted from - * the factory — reads the field the contract declares nowhere. - */ -type ScopedPrincipal = { readonly tenantId: string }; -type Identity = ScopedPrincipal & { readonly userId: string }; -const { authenticated: scoped } = auth(); -const { - HttpController: IdentityController, - HttpRouter: IdentityRouter, - HttpAuthenticator: IdentityAuthenticator, -} = httpAuth(); - -const identityContract = { orders: scoped({ whoami }) }; - -const identityController = IdentityController("IdentityOrders", identityContract.orders)([], { - sync: () => ({ whoami: ({ context }) => OkAsync({ userId: context.principal.userId }) }), -}); - -const identityAuthenticator = IdentityAuthenticator([], { - sync: () => (headers) => - headers.authorization === "Bearer good" - ? OkAsync({ tenantId: "t-good", userId: "u-good" }) - : ErrAsync(new Unauthenticated({ reason: "not the good token" })), -}); - -const rpcIdentityAppOf = () => - HttpModule("RpcIdentityApp")({ - router: IdentityRouter(identityContract)({ orders: identityController }), - port: 0, - hostname: "127.0.0.1", - authenticator: identityAuthenticator, - provides: [identityController], - }); - -type IdentityClient = RouterContractClient<{ - readonly orders: { readonly whoami: typeof whoami }; -}>; - /** `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ const linkOf = (origin: string, token: string | undefined) => new RPCLink({ @@ -494,8 +464,9 @@ export type HttpFixtures = { }>; /** * The starter over a contract whose `orders` fragment is `authenticated(...)`, - * with an authenticator that accepts exactly one token. Shut down by the - * fixture; the handler's run count is reset before the test body. + * 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`. */ @@ -512,12 +483,6 @@ export type HttpFixtures = { readonly clientWith: (token: string | undefined) => RootMarkedClient; readonly handlerRuns: () => number; }; - /** - * The starter over a router and a controller minted by `httpAuth()`, - * where the identity is richer than the contract's principal. Shut down by - * the fixture. - */ - readonly rpcIdentity: { readonly clientWith: (token: string) => IdentityClient }; /** What each `HttpRouter` arm declares as its dependencies over the same marked contract. */ readonly authedRouterDeps: { readonly keyed: readonly string[]; @@ -748,15 +713,6 @@ export const it = test.extend({ }); }, - rpcIdentity: async ({ boot }, use) => { - const app = boot(rpcIdentityAppOf()); - 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}`; - - await use({ clientWith: (token) => createORPCClient(linkOf(origin, token)) }); - }, - // oxlint-disable-next-line no-empty-pattern -- see above authedRouterDeps: async ({}, use) => { await use({ From 77c2299c6e32c108192bada952ca03ab393758a4 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 00:46:35 +0200 Subject: [PATCH 31/38] refactor(example): the contract marks the route, auth.ts names the identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Principal` leaves `order-api-contract` entirely — the contract says only that `orders` is protected — and `httpAuth()` in `order-api`'s `auth.ts` is the one place a principal type is stated. --- examples/order-api-contract/src/contract.ts | 46 +++++-------------- examples/order-api-contract/src/index.ts | 1 - examples/order-api/README.md | 39 ++++++++-------- examples/order-api/src/auth.ts | 23 +++++----- examples/order-api/src/needs-gate.test-d.ts | 11 +++-- .../order-api/src/slices/orders/controller.ts | 6 +-- 6 files changed, 51 insertions(+), 75 deletions(-) diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 92e8fbf..5d05c08 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -1,4 +1,4 @@ -import { auth } from "@btravstack/contract"; +import { authenticated } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; /** @@ -21,45 +21,17 @@ export type OrderRef = { readonly id: string }; * the application's. * * `orders` is marked `authenticated` and therefore does **not** name it: its - * handlers serve `Principal.tenantId`, 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. + * 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 }; /** What a customer looks like on the wire. */ export type CustomerView = { readonly id: string; readonly name: string }; -/** - * The **minimum** a caller's identity must carry for this API's own semantics - * to work — not everything the server knows about them. Named in the contract - * because that is what makes a protected route legible to a client: - * `authenticated` is one word in the shared artifact, visible in a diff and in - * the generated types. - * - * `tenantId` is here because the marked fragment's inputs therefore do not - * carry one: for a protected procedure the caller's identity is what - * establishes the tenant. Nothing else is, on purpose. - * - * **The rule this file exists to demonstrate: declare the minimum here, and - * let the authenticator resolve more.** The starter's gate is - * `Auth extends { principal: Principal }`, so a *subtype* discharges it — - * `bearerAuthenticator` resolves `{ tenantId, userId }` and satisfies a - * contract asking only for `{ tenantId }`. Enriching what a deployment knows - * about its callers — roles, an org tier, an internal id — is therefore NOT a - * contract change, and none of it reaches a client. - * - * The limit worth knowing: a handler sees this type, not the authenticator's - * richer one. A field a handler needs must be declared here, and is then - * client-visible. That is the price of the field, and the reason to keep this - * type as small as the API's semantics allow. - */ -export type Principal = { readonly tenantId: string }; - -const { authenticated } = auth(); - /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ const ordersContract = { place: oc @@ -101,6 +73,12 @@ const customersContract = { * 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: authenticated(ordersContract), diff --git a/examples/order-api-contract/src/index.ts b/examples/order-api-contract/src/index.ts index cc0896e..2bc7d18 100644 --- a/examples/order-api-contract/src/index.ts +++ b/examples/order-api-contract/src/index.ts @@ -3,6 +3,5 @@ export { type CustomerView, type OrderRef, type OrderView, - type Principal, type Tenanted, } from "./contract.js"; diff --git a/examples/order-api/README.md b/examples/order-api/README.md index d03f470..6eed193 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -104,20 +104,13 @@ export const OrderApi = HttpModule("OrderApi")({ `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 -that resolves a different principal is a compile error at this call. It sits at +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. -It resolves **more** than the contract asks for, on purpose: `Principal` is -`{ tenantId }`, because that is all this API's semantics depend on, while the -authenticator returns `{ tenantId, userId }`. The gate is -`Auth extends { principal: Principal }`, so a subtype discharges it — adding -roles or an internal id to what a deployment knows about its callers is not a -contract change, and none of it reaches a client. - -Where that extra field is **stated** is `src/auth.ts`, the whole of it: +Where the identity is **stated** is `src/auth.ts`, the whole of it: ```ts export type Identity = { readonly tenantId: string; readonly userId: string }; @@ -131,15 +124,21 @@ export const HttpAuthenticator: HttpAuthenticatorOf = identity.HttpAuthenticator; ``` -The contract says the client-visible minimum and **whether** a route is -protected; `httpAuth()` says **what** the principal is, server-side. -Both slices import `HttpController` from there instead of from -`@btravstack/http`, and the orders controller reads -`context.principal.userId` — a field the contract declares nowhere — 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. Nothing else about either controller changed, which is the point of the -factory: it is written once, and every slice infers from it. +**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 @@ -326,8 +325,8 @@ const ordersContract = { 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` — the server's `Identity`, not merely the -contract's `Principal` — and its inputs name none: a required field the +`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 diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts index be7f3f1..ab03181 100644 --- a/examples/order-api/src/auth.ts +++ b/examples/order-api/src/auth.ts @@ -6,18 +6,15 @@ import { } from "@btravstack/http"; /** - * What this deployment knows about a caller, which is **more** than the - * contract asks for: `Principal` declares `{ tenantId }` alone, because that is - * all the API's own semantics depend on. `userId` is the server's business and - * never reaches a client. + * What this deployment knows about a caller — and the one place it is stated. * - * This is the layering the contract's own doc names, and this file is the one - * place it is stated. The contract says **whether** a route is protected and - * what a client must know; `httpAuth()` says **what** the principal - * is, server-side — so a handler sees `Identity`, `userId` included, with no - * annotation at its own call site. `HttpModule`'s gate is unchanged and still - * checks the authenticator against the contract's `Principal`; a subtype - * discharges it, which is what `Identity` is. + * **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 }; @@ -28,7 +25,9 @@ export type Identity = { readonly tenantId: string; readonly userId: string }; * 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. + * 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 diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 3a3c97f..43361e3 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -88,18 +88,19 @@ const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({ const _missingAuthenticator = start(UnauthenticatedApi, options); // The OTHER authenticator gate, and a different one: whether the authenticator -// resolves the contract's own principal. `AuthenticatorPort`'s service type is +// 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 two itself, at the -// `HttpModule(...)` call, which is why this directive sits on the option and -// not on a `start` below it. +// 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 contract's Principal. + // @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/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index f474b7d..e004fe1 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -30,9 +30,9 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * 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 `userId` is readable at all: the - * contract declares `{ tenantId }` and the authenticator resolves more, and - * the factory is what puts the server's own type in scope where the handler is + * 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 From 3f5d015fc153b7548c71668c604b2918f29de298 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 00:49:40 +0200 Subject: [PATCH 32/38] docs: the contract says whether, the factory says what Replaces the "declare the minimum, resolve more" doctrine in both packages' CLAUDE.md and README with the one rule that survives it, and corrects the http spec count (40) after the redundant identity fixture went away. --- packages/http/CLAUDE.md | 110 +++++++++++++++++++++------------------- packages/http/README.md | 106 ++++++++++++++++++-------------------- 2 files changed, 105 insertions(+), 111 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 3fbec60..b935f16 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -88,7 +88,7 @@ PortInstance<…> }`) rather than the class's own type because a class 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 Exclude]: ControllerFor>> }`, and the `controllers` +IsMarked>, 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 @@ -149,23 +149,24 @@ InstanceType> & { readonly port: PortClassOf> 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. - **`@btravstack/contract`'s marker, in the types and at runtime.** - `auth

()`'s `authenticated(node)` brands a contract node - `Authenticated` — an intersection with a `unique symbol` key, no - runtime property — and `Implementation` branches on it. A marked **leaf** - gets `{ readonly principal: P }` 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. `ContractPrincipal` (exported from `orpc.ts` **and** from - `index.ts`, since a composition root reads it) is the principal a contract - declares **anywhere** in its tree, or `never`. Pinned by `auth.test-d.ts`, - mutation-checked. What makes the type true at runtime is + `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 @@ -190,21 +191,23 @@ InstanceType> & { readonly port: PortClassOf> - **`httpAuth()` → `{ HttpController, HttpRouter, HttpAuthenticator }`, plus `HttpAuth` / `HttpControllerOf` / `HttpRouterOf` / `HttpAuthenticatorOf`** - (`http-auth.ts`) — the server-side mirror of the contract's `auth

()`. - A contract declares the **client-visible minimum** and 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] extends [never] ? PrincipalOf : -Identity`, an **unmarked** one is still `object`. `Identity = never` is - therefore "no factory", and the top-level `HttpController` / - `HttpRouter` are `controllerFor()` / `routerFor()` — today's - behaviour, unchanged, which is the backward compatibility this default - exists for. `controllerFor` and `routerFor` are exported from their own - files for this factory alone, not from `index.ts`. - The bug it fixes: the contract declared `{ tenantId }` while the - authenticator resolved `{ tenantId, userId }`, the richer object was on - `context.principal` at runtime, and a handler could reach the extra field - only through a cast. + (`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 @@ -218,14 +221,14 @@ Identity`, an **unmarked** one is still `object`. `Identity = never` is 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 is untouched and still compares the authenticator's - principal to the **contract's**, which an `Identity` richer than it - discharges as a subtype. Pinned by `auth.test-d.ts`'s arms 12–16 (the - identity on a marked leaf, the top-level form unchanged on the same + `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 still refused) and at runtime by `auth.spec.ts`'s - `rpcIdentity` fixture, whose handler returns a `userId` its contract - declares nowhere. + 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 @@ -273,17 +276,17 @@ Identity`, an **unmarked** one is still `object`. `Identity = never` is `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 - `[ContractPrincipal] extends [never] ? never : AuthenticatorPort` to the - needs channel plus `readonly principal: ContractPrincipal` to the result. + `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 the contract's **principal** is the one thing that + 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 `PrincipalOf` loses it too), which makes it a + 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. @@ -429,7 +432,7 @@ 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. -- **41 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 @@ -469,12 +472,15 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over 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. `auth.spec.ts` - carries the last 10, through the `rpcAuthed`, `rpcRootMarked`, - `authedRouterDeps`, `rpcIdentity` and `controllers` fixtures. Four are over + 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 principal reaching the - handler, a rejected token answering `UNAUTHORIZED` with the handler never - entered, an authenticator's own defect collapsing to + 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 @@ -483,8 +489,6 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over 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. The tenth is the identity factory: a controller minted by - `httpAuth()` answers with a field its contract declares nowhere, - read off the object the authenticator resolved. + 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 519fc6f..e4b0657 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -149,22 +149,45 @@ declaring the very provider the modulith composed. See ## 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: +`@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 -import { auth } from "@btravstack/contract"; +// src/auth.ts — the one file that names the identity import { - HttpAuthenticator, - HttpModule, - HttpRouter, - Unauthenticated, + 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"; -type Principal = { readonly userId: string; readonly tenantId: string }; - -const { authenticated } = auth(); +import { HttpAuthenticator, HttpRouter } from "./auth.js"; const ordersContract = authenticated({ find: oc @@ -174,11 +197,10 @@ const ordersContract = authenticated({ }); // 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. The -// principal type is stated at the call rather than inferred from `sync` — -// inference through the returned function's AsyncResult is exactly where it -// would silently widen to `unknown`. -const bearerAuthenticator = HttpAuthenticator()([], { +// 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 [tenantId, userId] = (headers.authorization ?? "") .replace("Bearer ", "") @@ -189,7 +211,7 @@ const bearerAuthenticator = HttpAuthenticator()([], { }, }); -// The principal arrives on oRPC's own context channel, typed by the contract. +// The principal arrives on oRPC's own context channel, typed by `Identity`. const ordersRouter = HttpRouter({ orders: ordersContract })([FindOrder], { sync: (find) => ({ orders: { @@ -216,51 +238,19 @@ const OrdersApi = HttpModule("OrdersApi")({ }); ``` -### When the server knows more than the contract says - -A contract declares the **client-visible minimum** — often `{ tenantId }` — and -says _whether_ a route is protected. What the authenticator resolves is usually -more, and a handler could not see the extra fields: their type was read off the -contract. `httpAuth()` states the server's own principal instead, once -per application, and hands back the three pieces fixed to it: - -```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; -``` - -Every slice imports `HttpController` from there instead of from this package, -and its handlers see `Identity` on `context.principal` — `userId` included — -with no annotation of their own. Nothing else about a controller changes. The -authenticator drops its type argument (`HttpAuthenticator([deps], { sync })`), -because this call already fixed it, which is also why the authenticator and the -controllers cannot disagree. - -An unmarked procedure still gets no principal: the contract decides _whether_, -the factory decides _what_. `HttpModule`'s gate is unchanged and still checks -the authenticator against the contract's own `Principal` — an `Identity` richer -than it discharges that as a subtype. 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. +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 that -resolves a different principal is a compile error at the `HttpModule(...)` call. +`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 a `reason` that is **yours**: the starter does not surface it — a rejected caller gets an `UNAUTHORIZED` and nothing derived from the refusal — so an From f6e6d06ad677cfbce5c5c36a56442dcedadac93a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 00:55:36 +0200 Subject: [PATCH 33/38] docs: the site and the changesets on the collapsed marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/reference/contract.md`, `docs/reference/http.md`, `docs/how-to/protect-a-procedure.md`, `docs/reference/packages.md` and the root spec drop the "declare the minimum, resolve more" doctrine: with no identity type in the contract there is nothing to keep minimal and nothing to leak. The two pending changesets are rewritten rather than contradicted — none of what they describe has been released. --- .changeset/authenticated-contracts.md | 29 ++++--- .changeset/server-side-identity.md | 42 +++++----- CLAUDE.md | 19 +++-- docs/how-to/protect-a-procedure.md | 71 ++++++++-------- docs/reference/contract.md | 107 +++++++++++++------------ docs/reference/http.md | 68 ++++++++-------- docs/reference/packages.md | 2 +- packages/http/src/controller.test-d.ts | 2 +- 8 files changed, 180 insertions(+), 160 deletions(-) diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md index ed9c3b4..f19bfbf 100644 --- a/.changeset/authenticated-contracts.md +++ b/.changeset/authenticated-contracts.md @@ -3,26 +3,29 @@ "@btravstack/http": minor --- -Let a contract declare that a procedure requires an authenticated principal, -and give `@btravstack/http` what it needs to satisfy that declaration. +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: `auth

()` mints an `authenticated` combinator for one contract's -principal type, applied to a finished procedure or to a whole record of them. -It returns the node unchanged — the marker lives in a `WeakSet` and a phantom -type key — so a client can import a marked contract without pulling in -anything that implements it. A handler under a marked key reads -`opts.context.principal` typed as `P`, and a controller that ignores it no -longer compiles under that key. An unmarked procedure is public; the marker -makes the requirement legible in the contract rather than detecting one that -was forgotten. +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 resolving the wrong -principal type is refused at `HttpModule`. A marked procedure whose +`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 the `Unauthenticated`'s `reason` left in the process — it is the application's, to log where it decides. diff --git a/.changeset/server-side-identity.md b/.changeset/server-side-identity.md index 4541816..35c0c23 100644 --- a/.changeset/server-side-identity.md +++ b/.changeset/server-side-identity.md @@ -5,28 +5,30 @@ Let a deployment state what its principal actually is, server-side, with `httpAuth()`. -A contract declares the client-visible **minimum** — often `{ tenantId }` — and -says _whether_ a route is protected. What an authenticator resolves is usually -more, and until now a handler could not see the extra fields: their type was -read off the contract, so the richer object that was there at runtime reached -the handler only through a cast. +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 })`). -`httpAuth()` mints `HttpController`, `HttpRouter` and -`HttpAuthenticator` together, all fixed to that identity — the server-side -mirror of `auth

()` on the contract side. It is 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. -The contract still decides _whether_: an unmarked procedure's context carries no -principal, factory or not, and `HttpModule`'s gate is unchanged — it still -checks the authenticator against the contract's own `Principal`, which a richer -`Identity` discharges as a subtype. `HttpController` and `HttpRouter` imported -from the package behave exactly as before. +`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 diff --git a/CLAUDE.md b/CLAUDE.md index 2a2f64a..f469169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -463,19 +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 41 specs, across + 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`, 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 server's own principal is `httpAuth()`'s: - the contract declares the client-visible minimum and says _whether_ a route - is protected, the factory says _what_ the caller is, and a handler minted - from it sees `Identity` where the contract's `Principal` used to be the only - type available (`examples/order-api/src/auth.ts` is the one file per - application that names it). + `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 diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 6a76894..33817d0 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -15,12 +15,15 @@ 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 what the contract declared. +`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. Declare the principal and mark the contract with - `auth()`'s `authenticated`. +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. @@ -33,14 +36,9 @@ 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 { auth } from "@btravstack/contract"; +import { authenticated } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; -/** The MINIMUM a caller's identity must carry for this API's semantics. */ -export type Principal = { readonly tenantId: string }; - -const { authenticated } = auth(); - const ordersContract = { place: oc .input(type<{ readonly id: string; readonly quantity: number }>()) @@ -65,13 +63,14 @@ 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 -The contract declares the **client-visible minimum** and says _whether_ a route -is protected. What the server resolves is usually more, and where that is -stated is `httpAuth()` — one file per application, which hands back -`HttpController`, `HttpRouter` and `HttpAuthenticator` all fixed to that -identity: +`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 @@ -82,7 +81,7 @@ import { type HttpRouterOf, } from "@btravstack/http"; -/** What the server knows — more than the contract asks for. */ +/** What this deployment knows about a caller. The contract names none. */ export type Identity = { readonly tenantId: string; readonly userId: string }; const identity = httpAuth(); @@ -130,19 +129,16 @@ export const bearerAuthenticator = HttpAuthenticator([], { }); ``` -**Declare the minimum in the contract, and let the authenticator resolve -more.** The gate `HttpModule` applies is `Auth extends { principal: Principal }`, -so a _subtype_ discharges it: this authenticator resolves `{ tenantId, userId }` -against a contract asking only for `{ tenantId }`. Enriching what a deployment -knows about its callers — roles, an org tier, an internal id — is therefore not -a contract change, and none of it reaches a client. +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. -A handler minted from the factory sees `Identity`, so those extra fields are -readable where the work happens: the contract decides _whether_ a route is -protected, the factory decides _what_ the principal is. Neither invents one — -an unmarked procedure's context still has no `principal` at all. The top-level -`HttpController` and `HttpRouter` are unchanged for an application that states -no identity: their handlers see the contract's principal, as before. +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 @@ -168,8 +164,8 @@ a logger in `deps`. 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` included, though the contract -declares only `tenantId`: +`context.principal` is the `Identity` — `userId` and `tenantId` both, neither +of which the contract names: ```ts import { HttpController } from "../../auth.js"; @@ -244,13 +240,18 @@ Two things are checked here, and they are different gates: 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 that resolves a different principal** is a compile error at +- **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` checks the principal against the router's own. + — 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. -An **unmarked** router accepts any authenticator, including none: a provider -nothing needs is di's business and not an error to invent. +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 @@ -298,8 +299,8 @@ handler, where the use case is. ## See also -- [`@btravstack/contract`](/reference/contract) — `auth`, `Authenticated`, - `PrincipalKey`, `PrincipalOf`, `isAuthenticated`. +- [`@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) — diff --git a/docs/reference/contract.md b/docs/reference/contract.md index 1611370..9a3e6ea 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -1,6 +1,6 @@ --- title: "@btravstack/contract" -description: The contract-level auth marker — auth(), Authenticated, PrincipalKey, PrincipalOf and isAuthenticated — what it puts on a contract node, and what it deliberately does not. +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 @@ -14,38 +14,38 @@ description: The contract-level auth marker — auth(), Authenticated, Principal > [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 principal"_, readable by +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 | -| --------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth` | value | `auth

(): { authenticated: (node: T) => Authenticated }` — mints the combinator for one contract's principal type `P` | -| `isAuthenticated` | value | `(node: object) => boolean` — whether **this exact node** was marked | -| `Authenticated` | type | `T & { readonly [PrincipalKey]: P }` — `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 | -| `PrincipalOf` | type | `T extends { readonly [PrincipalKey]: infer P } ? P : never` — the principal a node was marked with, `never` when it carries none | +| 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 | -## `auth

()` +## `authenticated(node)` -Call it once per contract, destructure `authenticated`, and apply it to a -record of procedures (which protects every procedure beneath it) or to a -single procedure (which protects itself): +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 { auth } from "@btravstack/contract"; +import { authenticated } from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; -/** The minimum a caller's identity must carry for this API's own semantics. */ -export type Principal = { readonly tenantId: string }; - -const { authenticated } = auth(); - const ordersContract = { place: oc .input(type<{ readonly id: string; readonly quantity: number }>()) @@ -62,30 +62,30 @@ export const contract = { }; ``` -The type argument is the whole point of the two-call shape: `P` is stated once, -where the contract declares what a caller looks like, and every marked node -under that contract carries the same principal type. A second contract with a -different principal calls `auth` again. +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. -## `PrincipalOf` and `isAuthenticated` +## `IsMarked` and `isAuthenticated` -`PrincipalOf` recovers the principal off a node at the type level; -`isAuthenticated` answers the same question at runtime, for one node: +`IsMarked` answers the question at the type level; `isAuthenticated` answers +the same question at runtime, for one node: ```ts -import { auth, isAuthenticated, type PrincipalOf } from "@btravstack/contract"; +import { + authenticated, + isAuthenticated, + type IsMarked, +} from "@btravstack/contract"; import { oc, type } from "@orpc/contract"; -type Principal = { readonly userId: string }; -const { authenticated } = auth(); - const quote = authenticated( oc .input(type<{ readonly id: string }>()) .output(type<{ readonly total: number }>()), ); -export type QuotePrincipal = PrincipalOf; // Principal +export type QuoteIsMarked = IsMarked; // true export const isProtected: boolean = isAuthenticated(quote); // true ``` @@ -95,30 +95,30 @@ 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. -## Declare the minimum, resolve more +## The contract says whether; the application says what -`P` is what a **client** learns about the identity your API expects, so it -belongs in the contract only to the extent the API's own semantics depend on -it. Everything else the server knows stays on the server. +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. -The starter's gate is `Auth extends { principal: P }`, so a **subtype** -discharges it: an authenticator resolving `{ tenantId, userId, roles }` -satisfies a contract that declares `{ tenantId }`. Enriching what a deployment -knows about its callers is therefore not a contract change, and none of it -reaches a client. +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. -The limit worth knowing: a handler sees `PrincipalOf` — the contract's -type, not the authenticator's richer one. A field a handler needs must be -declared here, and is client-visible once it is. That is the price of the -field, and the reason to keep `P` as small as the API allows. +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 combinator: the marker has no opinion about which transport -reads it. +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 @@ -140,9 +140,10 @@ a bypass. No oRPC builder has to know the marker exists. 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.** Turning a request into a principal is - `@btravstack/http`'s `HttpAuthenticator`, and what a token means is the - application's. +- **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 @@ -161,7 +162,13 @@ 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. See +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). ::: diff --git a/docs/reference/http.md b/docs/reference/http.md index c0fbf94..9a5f463 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -25,7 +25,7 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `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, so a marked handler sees `Identity` rather than the contract's principal | +| `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 | @@ -33,7 +33,7 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `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` carrying a `reason` — the refusal, the application's own; the starter does not surface it to the client | -| `ContractPrincipal` | type | `ContractPrincipal` — the principal a contract declares anywhere in its tree, or `never` | +| `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 | @@ -177,7 +177,7 @@ export const orderRouter = HttpRouter(contract)({ Each value is what [`HttpController`](#httpcontrollername-fragment) returns. The call is **exact**: `M` is constrained to `{ readonly [K in Exclude]: ControllerFor>> }`, and the `controllers` +IsMarked>, 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 @@ -266,15 +266,15 @@ A contract marked with [`@btravstack/contract`](/reference/contract)'s 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: P }` 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. -`ContractPrincipal` is the principal a contract declares anywhere in its -tree, or `never`. +**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 @@ -300,7 +300,9 @@ 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`. `Unauthenticated` is a +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` carrying a `reason`, and the reason is the **application's own**: the starter does not surface it. A rejected caller gets an `UNAUTHORIZED` carrying oRPC's default message and nothing derived from the refusal, so an @@ -308,7 +310,7 @@ authenticator that wants the reason recorded logs it itself — forwarding it would put "no such user" versus "bad signature" in a 401 body by default. ```ts -export const bearerAuthenticator = HttpAuthenticator()([], { +export const bearerAuthenticator = HttpAuthenticator([], { sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") @@ -327,12 +329,11 @@ export const bearerAuthenticator = HttpAuthenticator()([], { ### `httpAuth()` — what the principal is, server-side -A contract declares the **client-visible minimum** and says _whether_ a route -is protected. What the authenticator resolves is usually more — `{ tenantId }` -in the contract, `{ tenantId, userId }` in the deployment — and a handler could -not see the extra fields: their type was read off the contract. -`httpAuth()` states the server's own principal instead, and hands -back the three pieces fixed to it: +**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 @@ -360,13 +361,11 @@ 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 factory replaces the contract's type only on a **marked** node and invents -none on an unmarked one: the contract still decides _whether_, and the gates -below are unchanged — `HttpModule` still checks the authenticator against the -contract's `Principal`, which an `Identity` richer than it discharges as a -subtype. `HttpController` and `HttpRouter` imported from the package are the -`Identity = never` case: their handlers see the contract's principal, exactly -as before. +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 @@ -377,13 +376,16 @@ 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 **principal**: `AuthenticatorPort`'s service type is +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 — `Principal` is inferred from the router's -own, and an authenticator resolving something else is a compile error at the -`HttpModule(...)` call. An **unmarked** router accepts any authenticator, -including none: a provider nothing needs is di's business and not an error to -invent. +`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 diff --git a/docs/reference/packages.md b/docs/reference/packages.md index f7aa34e..75a60e8 100644 --- a/docs/reference/packages.md +++ b/docs/reference/packages.md @@ -134,7 +134,7 @@ 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` | `auth`, `isAuthenticated`, `Authenticated`, `PrincipalKey`, `PrincipalOf` — see [@btravstack/contract](/reference/contract) | +| `@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 | diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index 6421a65..9902a85 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -81,10 +81,10 @@ const markedUsers = IdentityController("GateMarkedUsers", markedContract.users)( void IdentityRouter(markedContract)({ orders: markedOrders }); // 2. A key the contract does not declare is rejected. -// @ts-expect-error — `billing` is not in the contract void IdentityRouter(markedContract)({ orders: markedOrders, users: markedUsers, + // @ts-expect-error — `billing` is not in the contract billing: markedOrders, }); From 75737763028209db06456597de43cd2185e7a8bb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 00:57:46 +0200 Subject: [PATCH 34/38] chore: untrack the SQLite shadow database committed by accident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/order-infrastructure/.migrate.db is a Prisma artifact from this example's SQLite era. Nothing reads it — the suites run against the shared PostgreSQL container — and it entered the branch through a careless add. --- .gitignore | 5 +++++ examples/order-infrastructure/.migrate.db | Bin 36864 -> 0 bytes 2 files changed, 5 insertions(+) delete mode 100644 examples/order-infrastructure/.migrate.db 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/examples/order-infrastructure/.migrate.db b/examples/order-infrastructure/.migrate.db deleted file mode 100644 index edbc166198fafff1009da6327a2a69370b39a363..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36864 zcmeI(&u-#I90zclB#^L6>80{1TCWdH&@L^uv5nPBY2vO>6OtvMZhCRY_Bcz!zhGP1 zRK1W^eU83CEA`m-s8&7oF?#G^OoAa>t+bbJ@*N3ddp!R>zcI)*oIF41hE(*2<30&R zmHUw6dG2#jJB{mYA*`HVV{m|H(6QP9X9-UV)f8R_R zYS+Iwuofy2ck?1kQrDAM>uu|q)e@UWZL!%oI1rCpjl+8DRQ$?173-b$QKQLfIkcMX zr?Wx}?Tc;e1uK=5O!5b$Po;EDzVvN2CwwaKlU(d`H1-(IaJrny#5;1gvaB3C1BrJt zjieH(bR})%gqk7t3w(Dbfl*D~DTiT67>zZ2Jx4x`*4%*^gr`2Miv8TBw?fT(yQhF3Q zy>9R_T|8epUe5|op75tJQE<^?7dv)9FCsc{>C)y#ifJixcN*lUtYm5Ocr7Og0)O>* z#y*W{9Gmr2d1)4}cjBFgIz6|N0bfL95O%}M)d`(1{rC-^6J(kH*^c{YkH+1gPwam8 zY)r!La1gBS$)tO~dO&KXOi!kz6rUN3r_$0{;=ZKrc@Xubbh+D3D8D<1LunH>6>ide zQX~M^qp@cvU~=GX750W_!D3AdhbEPchrJ&4Y=^k#i>)p-8H8jUvcuB-`j6Qf&*(vD zhuuD9h5MtVIWijcx~!rgq@%!ghtVJ&>HMRq(>!TBYg+rO)4!50?PapU*D}w=A1ui< zB~dtxx9!!y+4AbXHTFfbIsLLJuvg}T*IaSL7Dy0)00bZa0SG_<0uX=z1Rwx`_e@~& zQ6}G+6gKuaZevTAHC6K*rED4wDOW2Mz3k|^R@QY=b5uoEHKH19%`-HO>NU^tG*8h~ zpL&X8nuKby=2OE{tFof9rD`gsEIU4t^_nBQCUsR`;eUDf*AJ#rQS?ep))iG|&Dg~A zu5WI}2h=77KJJ$~?U!2dJ-;F=<*H1Ka#b_TG9d;n6QiccuEr{^nFck8>(GkpnYyeJ zO(#{aN=(D|4cVoh#`@$_N>oy-)M|$3P*bhyrmGoja4JpuB`5t-6-73yip}n?gmHf= zc^219@mH?+nk|qZ009U<00Izz00bZa0SG_<0ucB=2<)zP__w*6k=a@6yw&Z6>GOZ~ zqkkj_KmY;|fB*y_009U<00Izz00i!^0IvVwioI2tWV=5P$##AOHafKmY;|fWX}snEwACzyH7camENC009U<00Izz00bZa0SG|g zehc9F|NV|XMh^i9KmY;|fB*y_009U<00Q?z0MGyLN1QQA2tWV=5P$##AOHafKmY;| IxZeW*0)pxw@Bjb+ From 76d607f10e954087cd1727ed359cd58715b5ae8c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 01:29:08 +0200 Subject: [PATCH 35/38] refactor: cut what the marker and the factory did not need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unauthenticated carried a reason nothing read: three sites constructed it and the middleware discarded it once the 401 stopped forwarding a message. The error is payload-free now, and an authenticator that wants to record why logs it before returning. HttpAuth was exported and named by nobody — the aliases are what a consumer annotates with. Two TSDoc blocks restating packages/*/CLAUDE.md are cut to what guards the line beneath them, per this repo's own comment-density rule. noAuthenticator stays: two lines of fail-closed insurance on a seam that has failed twice. It now says it is unreachable rather than implying otherwise. --- examples/order-api/src/authenticator.ts | 2 +- packages/contract/src/auth.ts | 53 ++++++++----------------- packages/http/CLAUDE.md | 15 +++---- packages/http/src/auth.spec.ts | 2 +- packages/http/src/auth.ts | 30 +++++++------- packages/http/src/http-auth.ts | 48 ++++++---------------- packages/http/src/index.ts | 2 +- packages/http/src/test-fixtures.ts | 2 +- 8 files changed, 55 insertions(+), 99 deletions(-) diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts index b90e07b..7655115 100644 --- a/examples/order-api/src/authenticator.ts +++ b/examples/order-api/src/authenticator.ts @@ -21,7 +21,7 @@ export const bearerAuthenticator = HttpAuthenticator([], { const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; const [tenantId, userId] = token.split(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" - ? ErrAsync(new Unauthenticated({ reason: "no usable bearer token" })) + ? ErrAsync(new Unauthenticated()) : OkAsync({ tenantId, userId }); }, }); diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index 89e3054..7d9473b 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -1,39 +1,27 @@ -/** - * The phantom key the marker occupies. Declared, never defined: it exists only - * in the type system, so a marked node carries no runtime property and there is - * nothing for oRPC's `implement()` to walk as a procedure. It is never exported - * as a value either — a nameable brand could be hand-written onto a contract - * without the corresponding registry entry, which types as protected and runs - * unmarked: no authenticator demanded, and a handler reading a principal - * nothing ever injected. - */ +// Never exported as a value: a nameable brand could be hand-written onto a +// contract without the matching registry entry, which types as protected and +// runs unmarked — no authenticator demanded, and a handler reading a principal +// nothing injected. declare const PRINCIPAL: unique symbol; -/** A contract node whose procedures require an authenticated principal. */ +/** 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 — a yes/no, not a type. */ +/** Whether this exact node carries the marker. */ export type IsMarked = T extends { readonly [PRINCIPAL]: true } ? true : false; -// Identity, not a property: a marked node must stay `===` what the contract -// declared, so `implement()` walks it unchanged and a consumer can still index -// the fragment out of the contract it lives in. -// -// The registry hangs off `globalThis`, not off this module, because identity is -// what the marker IS: two copies of this package with a private set each read -// every node the other marked as unmarked, so `hasMarked` answers false, the -// router declares no authenticator and the route is served OPEN. One shared -// registry makes the second copy a compile error (the `unique symbol` differs) -// rather than a silent hole. -const registry: unique symbol = Symbol.for("@btravstack/contract/marked"); -const store = globalThis as unknown as { [registry]?: WeakSet }; -const marked = (store[registry] ??= new WeakSet()); +// 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. + * Marks a contract node as requiring an authenticated caller — a record + * protects every procedure beneath it, a procedure protects itself. * * ```ts * export const contract = { @@ -42,14 +30,8 @@ const marked = (store[registry] ??= new WeakSet()); * }; * ``` * - * A marked record protects every procedure beneath it; a marked procedure - * protects itself. Applied AFTER a builder chain is finished, never inside - * one, so nothing about oRPC's builders has to preserve it. - * - * The contract says **whether** a route is protected and nothing about who the - * caller is: no principal type is named here, so nothing about the server's - * identity reaches a client. What the principal actually is, is the - * application's `httpAuth()` to say. + * 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); @@ -60,6 +42,5 @@ export const authenticated = (node: T): Authenticated => { 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 and needs no -// redesign: mark the contract root and add `public(node)` that deletes it from -// the set. Add it the first time a route ships unprotected by accident. +// 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/http/CLAUDE.md b/packages/http/CLAUDE.md index b935f16..c2bef5b 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -183,14 +183,15 @@ InstanceType> & { readonly port: PortClassOf> `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` carrying - a `reason`, and the reason is the **application's own**: the starter does - not surface it, so an authenticator that wants it recorded logs it itself. - Forwarding it would put "no such user" versus "bad signature" in a 401 body - by default — an information-disclosure footgun shipped as the default. + 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 `HttpAuth` / `HttpControllerOf` / - `HttpRouterOf` / `HttpAuthenticatorOf`** + 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` diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 787ec2e..b09956a 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -118,7 +118,7 @@ describe("the fail-closed authenticator", () => { // 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({ reason: "no authenticator", constructor: Unauthenticated }), + expect.objectContaining({ constructor: Unauthenticated }), ); }); }); diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index c81c745..074a9e7 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -12,15 +12,12 @@ import { ORPCError } from "@orpc/server"; import { ErrAsync, TaggedError, type AsyncResult } from "unthrown"; /** - * Why a caller was refused. The reason is the **application's own**: the starter - * does not surface it — a rejected caller gets an `UNAUTHORIZED` carrying oRPC's - * default message and nothing else — so an authenticator that wants the reason - * recorded logs it itself. Forwarding it would put "no such user" versus "bad - * signature" in a 401 body by default. + * 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")<{ - readonly reason: string; -}> {} +export class Unauthenticated extends TaggedError("Unauthenticated") {} /** * What an application provides so a marked procedure can name its caller. @@ -69,13 +66,14 @@ export const HttpAuthenticator = Provider(AuthenticatorPort)(deps, options as never) as never; /** - * What a marked leaf authenticates with when no authenticator reached the walk — - * a state the two halves of the condition agreeing should make unreachable, and - * the reason this exists is that a disagreement must fail **closed**: every - * caller refused, rather than the leaf served unprotected. + * 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({ reason: "no authenticator" })); +export const noAuthenticator: AuthenticatorService = () => ErrAsync(new Unauthenticated()); /** * The one middleware this package installs, and only on a marked leaf. It reads @@ -92,8 +90,8 @@ export const principalMiddleware = }): Promise => { const resolved = await authenticate(options.context.request.headers); if (resolved.isErr()) { - // The reason stays here: it is the application's, and oRPC serializes - // `message` to the client. + // 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"); } diff --git a/packages/http/src/http-auth.ts b/packages/http/src/http-auth.ts index fc6ee53..f481e94 100644 --- a/packages/http/src/http-auth.ts +++ b/packages/http/src/http-auth.ts @@ -3,34 +3,11 @@ import { controllerFor } from "./controller.js"; import { routerFor } from "./orpc.js"; /** - * Mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to **this - * deployment's** identity: - * - * ```ts - * type Identity = { readonly tenantId: string; readonly userId: string }; - * export const { HttpController, HttpRouter, HttpAuthenticator } = httpAuth(); - * ``` - * - * **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 own view of a caller reaches a client, and `Identity` is stated - * here once — every slice's controller infers from it with no annotation of - * its own, and the authenticator and the controllers cannot disagree, because - * both come from this call. - * - * That is also what `HttpModule`'s gate now compares: the **router's** identity - * against the **authenticator's**, so a router from `httpAuth()` refuses an - * authenticator from `httpAuth()`. The authenticator must resolve at least - * what the handlers read, so a subtype discharges it. - * - * Written once per application, and 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 `HttpAuthenticator` handed back is already applied — the type argument - * `HttpAuthenticator

()` exists to state is what this factory just fixed — - * so it is called `HttpAuthenticator([deps], { sync })`. + * 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(), @@ -38,19 +15,18 @@ export const httpAuth = (): HttpAuth => ({ HttpAuthenticator: HttpAuthenticator(), }); -/** - * The three, as one type. `Identity` reaches a `.d.ts` through these aliases - * rather than through the inferred type of the call: what a controller's port - * expands to carries `@btravstack/contract`'s phantom `unique symbol`, which no - * consumer can name (TS2527, measured on `examples/order-api`). A file that - * exports what `httpAuth` returns annotates with them. - */ -export type HttpAuth = { +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/index.ts b/packages/http/src/index.ts index a385f00..74b6b37 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -2,7 +2,7 @@ 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 { HttpAuth, HttpAuthenticatorOf, HttpControllerOf, HttpRouterOf } 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"; diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 5ff8e5f..18046b8 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -180,7 +180,7 @@ const authenticator = AuthedAuthenticator([], { } return headers.authorization === "Bearer good" ? OkAsync({ tenantId: "t-good", userId: "u-good" }) - : ErrAsync(new Unauthenticated({ reason: "not the good token" })); + : ErrAsync(new Unauthenticated()); }, }); From dfe501471dee3bc6e6a0496752fd3f7216b497ff Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 01:50:33 +0200 Subject: [PATCH 36/38] fix(docs): the samples compile, and the README stops admitting empty tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making Unauthenticated payload-free fixed the four construction sites in source and none of the published ones, so the how-to, the reference page and the README all shipped a sample that no longer compiles — the first code a reader writes when adopting this. The README's authenticator also guarded only undefined, and 'Authorization: :' splits into two defined empty strings: any caller was admitted as tenant '', which on the marked fragment below it is a real scope. It now guards empty too, matching the example and both site samples. And the unforgeability claim was too strong. Authenticated is exported, so a deliberate double cast types as protected with an empty registry. Not exporting the symbol buys a cast, not impossibility; both the comment and the package spec now say so. --- .changeset/authenticated-contracts.md | 4 ++-- docs/how-to/protect-a-procedure.md | 11 +++++------ docs/reference/http.md | 14 +++++++------- packages/contract/CLAUDE.md | 18 ++++++++++++------ packages/contract/src/auth.ts | 10 ++++++---- packages/http/README.md | 25 ++++++++++++++++--------- 6 files changed, 48 insertions(+), 34 deletions(-) diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md index f19bfbf..5be3199 100644 --- a/.changeset/authenticated-contracts.md +++ b/.changeset/authenticated-contracts.md @@ -27,8 +27,8 @@ 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 the `Unauthenticated`'s `reason` left in the process -— it is the application's, to log where it decides. +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 diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 33817d0..8af5230 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -123,7 +123,7 @@ export const bearerAuthenticator = HttpAuthenticator([], { tenantId === "" || userId === undefined || userId === "" - ? ErrAsync(new Unauthenticated({ reason: "no usable bearer token" })) + ? ErrAsync(new Unauthenticated()) : OkAsync({ tenantId, userId }); }, }); @@ -153,11 +153,10 @@ 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 a `reason`, and the reason is **yours**: the starter -does not surface it. A rejected caller gets an `UNAUTHORIZED` carrying oRPC's -default message and nothing derived from the refusal — so an authenticator that -wants the reason recorded logs it itself, which is one more argument for naming -a logger in `deps`. +`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 diff --git a/docs/reference/http.md b/docs/reference/http.md index 9a5f463..83406b2 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -32,7 +32,7 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu | `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` carrying a `reason` — the refusal, the application's own; the starter does not surface it to the client | +| `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 | @@ -303,11 +303,11 @@ inferred from `sync` — inference through a returned function's `AsyncResult` i 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` carrying a `reason`, and the reason is the **application's own**: -the starter does not surface it. A rejected caller gets an `UNAUTHORIZED` -carrying oRPC's default message and nothing derived from the refusal, so an -authenticator that wants the reason recorded logs it itself — forwarding it -would put "no such user" versus "bad signature" in a 401 body by default. +`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([], { @@ -321,7 +321,7 @@ export const bearerAuthenticator = HttpAuthenticator([], { tenantId === "" || userId === undefined || userId === "" - ? ErrAsync(new Unauthenticated({ reason: "no usable bearer token" })) + ? ErrAsync(new Unauthenticated()) : OkAsync({ tenantId, userId }); }, }); diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index 592b83b..793661f 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -75,12 +75,18 @@ 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. A nameable brand could be hand-written onto a contract node without -the corresponding `WeakSet` entry: typed as protected, unmarked at runtime — -so no authenticator is demanded and a handler reads a principal nothing ever -injected. The TS2527 wart a consumer hits when re-exporting an inferred -controller type is the price, and the aliases `@btravstack/http` exports -(`HttpControllerOf` and friends) are how it is paid. +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 diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index 7d9473b..fd4ba39 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -1,7 +1,9 @@ -// Never exported as a value: a nameable brand could be hand-written onto a -// contract without the matching registry entry, which types as protected and -// runs unmarked — no authenticator demanded, and a handler reading a principal -// nothing injected. +// 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. */ diff --git a/packages/http/README.md b/packages/http/README.md index e4b0657..a95cba7 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -202,11 +202,18 @@ const ordersContract = authenticated({ // why the authenticator and the controllers cannot disagree. const bearerAuthenticator = HttpAuthenticator([], { sync: () => (headers) => { - const [tenantId, userId] = (headers.authorization ?? "") - .replace("Bearer ", "") - .split(":"); - return tenantId === undefined || userId === undefined - ? ErrAsync(new Unauthenticated({ reason: "no usable bearer token" })) + 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 }); }, }); @@ -251,10 +258,10 @@ A marked router carries the authenticator port as a **need**, so forgetting 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 a -`reason` that is **yours**: the starter does not surface it — a rejected caller -gets an `UNAUTHORIZED` and nothing derived from the refusal — so an -authenticator that wants the reason recorded logs it itself. See +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 From a031e9f4c24eaecd0b831885f0dc0636333b5ecd Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:05:20 +0200 Subject: [PATCH 37/38] test(http): the authenticator shape every adopter writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every authenticator on this branch declares no dependencies, so the documented form — a JWT verifier, a key set, a user directory injected by di — was checked by a reviewer's scratch file and by nothing that runs. Two arms: one pins that declared deps discharge the HttpModule gate and that sync's injected service still constrains the resolved identity, the other that declaring deps is no way around the identity check. Both mutation-verified — dropping a field from the resolved shape fails on AuthenticatorService, and removing the directive names principal { sub } against principal Identity. --- packages/http/src/auth.test-d.ts | 52 ++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 74c6f20..8303c88 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -3,10 +3,11 @@ // `@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 { OkAsync } from "unthrown"; +import { ErrAsync, OkAsync } from "unthrown"; -import { HttpAuthenticator } from "./auth.js"; +import { HttpAuthenticator, Unauthenticated } from "./auth.js"; import { HttpController } from "./controller.js"; import { httpAuth } from "./http-auth.js"; import { HttpModule } from "./http-module.js"; @@ -217,5 +218,52 @@ const _strayScoped = HttpModule("StrayScoped")({ 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; From 6eea429ee63a633a267e42f82cfde228f62cebbc Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 02:17:11 +0200 Subject: [PATCH 38/38] test(contract): the registry assertion names which half failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reviewers flagged the optional chain against this repo's test conventions. It could not pass silently — expect(undefined).toBe(true) fails — so the fix buys diagnosis, not safety: registry?.has() reads undefined for a MISSING registry and for one not holding the node alike, and an absent registry is the failure the test exists to pin. Mutation-checked against a wrong registry key, which now reports registered: false rather than undefined. --- packages/contract/src/auth.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/contract/src/auth.spec.ts b/packages/contract/src/auth.spec.ts index a64a1a7..e4101d5 100644 --- a/packages/contract/src/auth.spec.ts +++ b/packages/contract/src/auth.spec.ts @@ -38,8 +38,14 @@ describe("authenticated", () => { // 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 - expect(registry?.has(fragment)).toBe(true); + // 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 }) => {