feat: a contract declares which procedures need an authenticated caller - #73
Conversation
… bare mark fails closed
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.
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.
…hangeset 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.
`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.
…-goals 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.
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.
The keyed `HttpRouter(contract)(controllers)` overload never got the two changes the positional arm's `Implementation<C>` carries: `Exclude<keyof C, PrincipalKey>`, without which a root-marked contract demands a controller for the phantom key and can never be composed at all, and `Inherit<C[K], PrincipalOf<C>>`, 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.
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.
`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.
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.
There was a problem hiding this comment.
Pull request overview
Adds a contract-level authentication marker (authenticated) via a new @btravstack/contract package, and wires @btravstack/http to enforce marked procedures by requiring an authenticator provider and injecting a typed context.principal into handlers. This fits the codebase’s “contract-first + DI-verified wiring” approach by making authentication requirements visible in the shared contract artifact, while keeping transport policy as configuration.
Changes:
- Introduce
@btravstack/contract(zero-dependency) with an identity-based auth marker (auth<P>(),authenticated,isAuthenticated, marker types). - Extend
@btravstack/httpto (a) conditionally require an authenticator when any contract node is marked, (b) injectcontext.principalfor marked handlers, and (c) addpluginsandsecurityHeadersconfiguration. - Update examples and docs to demonstrate/describe marked fragments, authenticator wiring, and the “configuration not middleware” decisions.
Reviewed changes
Copilot reviewed 54 out of 55 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| turbo.json | Ensures docs build depends on the new contract package build. |
| pnpm-lock.yaml | Adds workspace linkage for @btravstack/contract and updates importers. |
| packages/temporal/CLAUDE.md | Documents why cross-cutting concerns don’t apply to Temporal transport. |
| packages/http/src/test-fixtures.ts | Adds fixtures covering auth marker runtime behavior and oRPC plugins. |
| packages/http/src/orpc.ts | Implements marker-aware router typing + runtime enforcement; adds plugin support. |
| packages/http/src/orpc.spec.ts | Adds spec proving configured oRPC plugins run. |
| packages/http/src/index.ts | Exposes authenticator API and ContractPrincipal from the HTTP package surface. |
| packages/http/src/http-runtime.ts | Adds plugins plumbing and securityHeaders on the raw listener. |
| packages/http/src/http-runtime.spec.ts | Adds specs for securityHeaders behavior (served + 404, disabled, custom). |
| packages/http/src/http-module.ts | Extends HttpModule options to wire authenticator/plugins/security headers. |
| packages/http/src/controller.test-d.ts | Ensures keyed router form still type-checks with marked fragments. |
| packages/http/src/auth.ts | Introduces authenticator port/provider + middleware to inject/refuse principals. |
| packages/http/src/auth.test-d.ts | Adds type-level gates: handler context typing + wiring/authenticator mismatch checks. |
| packages/http/src/auth.spec.ts | Adds runtime specs for refusal behavior, non-leakage, and root-marked protection. |
| packages/http/README.md | Documents how to protect a procedure and the non-goals around middleware/policy. |
| packages/http/package.json | Adds @btravstack/contract as dev + peer dependency. |
| packages/http/CLAUDE.md | Records the new surface + invariants for marker enforcement, plugins, headers. |
| packages/contract/vitest.config.ts | Adds vitest config with 100% coverage thresholds for the new package. |
| packages/contract/tsconfig.test-d.json | Adds type-test tsconfig for *.test-d.ts in contract package. |
| packages/contract/tsconfig.json | Adds package TS config aligning with workspace conventions. |
| packages/contract/src/test-fixtures.ts | Adds lazy fixtures for contract marker runtime specs. |
| packages/contract/src/index.ts | Exports the contract marker API. |
| packages/contract/src/auth.ts | Implements identity-based marker via shared registry + phantom key types. |
| packages/contract/src/auth.test-d.ts | Adds type-level checks around PrincipalKey exclusion and PrincipalOf. |
| packages/contract/src/auth.spec.ts | Adds runtime specs for marking semantics + registry location and behavior. |
| packages/contract/README.md | Documents marker usage and the multi-copy considerations. |
| packages/contract/package.json | Introduces new published package metadata and scripts. |
| packages/contract/LICENSE | Adds MIT license for the new package. |
| packages/contract/CLAUDE.md | Defines the contract package surface and load-bearing properties. |
| packages/amqp/CLAUDE.md | Documents why cross-cutting concerns don’t apply to AMQP transport. |
| examples/order-api/src/test-fixtures.ts | Updates API fixtures to supply credentials via headers for marked fragment calls. |
| examples/order-api/src/slices/orders/controller.ts | Switches tenant derivation to context.principal.tenantId for marked orders. |
| examples/order-api/src/needs-gate.test-d.ts | Pins both authenticator-related compile-time gates in the example. |
| examples/order-api/src/module.ts | Wires bearerAuthenticator into the example composition root. |
| examples/order-api/src/client.ts | Adds header injection capability for example client creation. |
| examples/order-api/src/authenticator.ts | Adds a stand-in bearer authenticator provider for the example. |
| examples/order-api/src/api.spec.ts | Updates example tests for marked/unmarked behavior and tenant isolation via principal. |
| examples/order-api/README.md | Documents authenticator wiring and marked-fragment client behavior. |
| examples/order-api-contract/src/test-fixtures.ts | Adjusts client-side stub expectations for tenant now implied by principal for orders. |
| examples/order-api-contract/src/index.ts | Exports the new Principal type from the contract package. |
| examples/order-api-contract/src/contract.ts | Marks orders as authenticated; introduces Principal type and removes tenant input from orders. |
| examples/order-api-contract/src/client.spec.ts | Updates contract client spec inputs for orders (no tenantId). |
| examples/order-api-contract/package.json | Adds @btravstack/contract dependency + peer to ensure single-copy behavior. |
| docs/typedoc.contract.json | Adds TypeDoc config for the new contract package API reference. |
| docs/scripts/build-api.ts | Includes contract in TypeDoc build list. |
| docs/reference/packages.md | Adds @btravstack/contract to the package list and dependency matrix. |
| docs/reference/http.md | Updates HTTP reference to include authenticator/plugins/securityHeaders and auth behavior table. |
| docs/reference/contract.md | Adds contract reference page describing marker API and behavior. |
| docs/how-to/test-an-application.md | Updates testing how-to to include required credentials for marked procedures. |
| docs/how-to/read-the-ambient-unit.md | Updates tenant-source table to reflect principal-based tenancy for marked HTTP fragment. |
| docs/how-to/protect-a-procedure.md | Adds dedicated how-to guide covering marker + authenticator + handler usage. |
| docs/.vitepress/config.ts | Adds contract pages + protect-procedure how-to, and excludes docs/superpowers/**. |
| CLAUDE.md | Updates root spec: ninth package, cross-cutting concerns decision, and docs tooling references. |
| .changeset/config.json | Adds @btravstack/contract to the fixed version group. |
| .changeset/authenticated-contracts.md | Adds changeset for the new marker package + HTTP authentication/plugins/headers changes. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
docs/reference/contract.md:139
- This warning still describes the marker as module-private and implies a second copy causes a marked node to read unmarked. The code now shares the marker registry via
globalThis+Symbol.for(...), so the more accurate statement is: two copies are primarily a type mismatch risk (PrincipalKeyis aunique symbol), while runtime marking is still shared. Updating this keeps the docs aligned with the safety properties the code now provides.
`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).
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
…or resolves more
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.
`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<P>()` and `PrincipalOf` are gone; `IsMarked<T>` answers yes/no in their place. BREAKING CHANGE: `auth<P>()` is replaced by a bare `authenticated` export, `PrincipalOf` is removed, and `Authenticated<T, P>` loses its principal parameter.
…tor's With no principal on the contract, `ContractPrincipal` becomes `HasMark<C>` — 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<A>()` refuses an authenticator from `httpAuth<B>()`. 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<Identity>()` for a handler to read a principal at all.
…entity `Principal` leaves `order-api-contract` entirely — the contract says only that `orders` is protected — and `httpAuth<Identity>()` in `order-api`'s `auth.ts` is the one place a principal type is stated.
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.
`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.
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.
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.
…tokens 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<T> 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.
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<Identity>,
and removing the directive names principal { sub } against principal Identity.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 58 out of 60 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (1)
packages/contract/src/auth.spec.ts:42
- This assertion uses optional chaining, which the repo’s test conventions call out as something that can make assertions silently weaker. Since the registry should either exist or the test should fail loudly, avoid
registry?.…and make the fallback explicit in the same single expect.
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.
Closes #58. Closes #57 — by decision rather than code; see below.
What this adds
An oRPC contract can declare that a procedure requires an authenticated
caller. The marker says whether; the application says who.
A marked procedure's handler is then typed to receive that identity, with no
annotation at its own call site, and cannot be written without it:
The principal is resolved by an
Authenticatorport the application provides,so JWT-vs-session-vs-mTLS is never the framework's opinion.
HttpModule({ router, authenticator })is the whole wiring.Nothing about identity reaches a client. The contract carries one phantom
mark and no shape: enriching what a deployment knows about its callers — roles,
an org tier, an internal id — is not a contract change and is invisible to
consumers.
Two gates, two places, both compile-time
AuthenticatorPortas a need, so acomposition that provides none is di's own
UNSATISFIED DEPENDENCIES, firingat
start(...).httpAuth<A>()will not accept anauthenticator minted by
httpAuth<B>(); the authenticator must resolve atleast what the handlers expect. Fires at the
HttpModule(...)call.A marked contract used with the top-level
HttpController— no factory — givesprincipal: never, so any read is a compile error. That is the "use thefactory" signal.
Also here
plugins— oRPC handler plugins forwarded toRPCHandler(CORS, bodylimits, compression). Transport policy as configuration.
securityHeaders— set on the node listener rather than as a plugin,because a plugin only runs for a matched request, so the runtime's own
404would go out bare.
The decisions (#58)
Recorded in
packages/http/CLAUDE.md, the README's "What it does not do", andthe documentation site.
refusal survives, narrowed: a plugin acts on the request/response envelope, an
application middleware would act on the handler's
Result, and only the secondis refused.
pluginsis stated as an honest escape hatch, not a keyhole — aplugin can reach
StandardHandlerOptions.interceptors.when an
apideployment is N pods; the ingress is the right home.forgotten. The contract makes it legible, not enforced — documented plainly
rather than overclaimed. Deny-by-default is a three-line follow-up (
public()),deliberately not built.
cannot be answered before the handler runs.
the connection is already authenticated. Per-message identity is a contract
field, as
tenantIdalready is.The transaction decision (#57)
Recorded in the root
CLAUDE.mdnext to thesis #2. A transaction is not puton the unit: commit boundaries are the adapter's, cross-store atomicity is the
outbox plus a saga. Three reasons — a unit-scoped transaction is an interactive
transaction on every request (Prisma's own last resort); the unit does not close
until the response is flushed, so a pooled connection would be pinned while bytes
go to the client; and a port does not say where its data lives.
#57's premise is stale and this says so.
prismaOrderRepository.savealreadycommits the order row and its outbox row in one
$tryTransaction, andremovedoes the same for its tombstone — verified against the code, not assumed. No code
changes.
Notable during review
Three bugs found and fixed, the first two the same class — the type half and the
runtime half of the marker disagreeing, in the unsafe direction:
protected, the authenticator required and resolved, and nothing installed —
served open. The runtime walk seeded
inherited: falseand never read theroot's own mark.
HttpRouterform never got the marker treatment the positionalone did, so a root-marked contract could not be composed through it. The
sibling of the first, left behind by its fix.
three pages claimed it was operator-only. Fixed in the code, not the docs: an
authenticator distinguishing "no such user" from "bad signature" would have
leaked that by default.
The marker registry is keyed off
globalThisunderSymbol.for("@btravstack/contract/marked"), so two copies of the package degradeto a compile error rather than an open route — with a module-private set, every
mark would silently evaporate and every protected route serve open.
The brand stays unforgeable:
PRINCIPALisdeclared and never exported asa value, because a nameable brand could be written onto a contract without the
matching registry entry — typed-protected, runtime-unmarked, no authenticator
required. The price is that
export const { HttpController, … } = httpAuth<I>()hits TS2527, so an application writes three annotated consts instead of one
destructure. Cheaper than the hole.
Ninth package
@btravstack/contractjoins thefixedversion group, the docs build, thesurface table,
build-api.ts, turbo's docs edges and the/api/sidebar. Zerodependencies and zero peers, which is what lets a client take a contract without
the server — enforced by
layering.test-d.ts.Known gaps, deliberately left
branch wraps its leaves. One fixture would close it.
packages/contract/src/auth.spec.tsasserts through an optional chain, whichthe test conventions call out. Verified it fails rather than skips.
@btravstack/httpspec count in the rootCLAUDE.mdhad already driftedbefore this branch; both files now say the measured number, but nothing gates
it.
Gate
format --check·lint·typecheck31/31 ·knip·test29/29 (Docker) ·build10/10 · docs build — all green. The docs build is green for the firsttime in a while: VitePress was compiling gitignored working files under
docs/superpowers/, now excluded.Changesets mark
@btravstack/contractand@btravstack/httpminor, taking thefixed group to
0.3.0.@btravstack/contract's surface changed twice on thisbranch and has never shipped, so the changesets describe where it landed rather
than the path.