Skip to content

feat: a contract declares which procedures need an authenticated caller - #73

Merged
btravers merged 38 commits into
mainfrom
feat/cross-cutting-concerns
Aug 20, 2026
Merged

feat: a contract declares which procedures need an authenticated caller#73
btravers merged 38 commits into
mainfrom
feat/cross-cutting-concerns

Conversation

@btravers

@btravers btravers commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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.

// the contract — one import, no type parameter, nothing about identity
import { authenticated } from "@btravstack/contract";

export const contract = {
  orders: authenticated({ place, find }),   // protected
  customers: { find },                      // public
};
// the application, once — this is where identity is named
export type Identity = { readonly tenantId: string; readonly userId: string };

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

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:

place: ({ errors, context }, input) =>
  place.execute(context.principal.tenantId, input.id, input.quantity)
//                        ^ Identity — userId included, and the contract
//                          never mentions it

The principal is resolved by an Authenticator port 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

  • Presence — a marked router declares AuthenticatorPort as a need, so a
    composition that provides none is di's own UNSATISFIED DEPENDENCIES, firing
    at start(...).
  • Agreement — a router minted by httpAuth<A>() will not accept an
    authenticator minted by httpAuth<B>(); the authenticator must resolve at
    least what the handlers expect. Fires at the HttpModule(...) call.

A marked contract used with the top-level HttpController — no factory — gives
principal: never, so any read is a compile error. That is the "use the
factory" signal.

Also here

  • plugins — oRPC handler plugins forwarded to RPCHandler (CORS, body
    limits, 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 404
    would go out bare.

The decisions (#58)

Recorded in packages/http/CLAUDE.md, the README's "What it does not do", and
the documentation site.

  • Transport policy is handler configuration, not middleware. The middleware
    refusal survives, narrowed: a plugin acts on the request/response envelope, an
    application middleware would act on the handler's Result, and only the second
    is refused. plugins is stated as an honest escape hatch, not a keyhole — a
    plugin can reach StandardHandlerOptions.interceptors.
  • Rate limiting is a stated non-goal. A per-process counter is the wrong unit
    when an api deployment is N pods; the ingress is the right home.
  • An unmarked procedure is public and nothing fails if the marker is
    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.
  • Authorization is not in the contract. A rule depending on the resource
    cannot be answered before the handler runs.
  • AMQP and Temporal: the question does not arise — no origin, no browser, and
    the connection is already authenticated. Per-message identity is a contract
    field, as tenantId already is.

The transaction decision (#57)

Recorded in the root CLAUDE.md next to thesis #2. A transaction is not put
on 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.save already
commits the order row and its outbox row in one $tryTransaction, and remove
does 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:

  • A marked contract root wrapped no leaf at all. Every handler typed
    protected, the authenticator required and resolved, and nothing installed —
    served open. The runtime walk seeded inherited: false and never read the
    root's own mark.
  • The keyed HttpRouter form never got the marker treatment the positional
    one did, so a root-marked contract could not be composed through it. The
    sibling of the first, left behind by its fix.
  • A rejected caller's failure reason was serialised into the 401 body while
    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 globalThis under
Symbol.for("@btravstack/contract/marked"), so two copies of the package degrade
to 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: PRINCIPAL is declared and never exported as
a 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/contract joins the fixed version group, the docs build, the
surface table, build-api.ts, turbo's docs edges and the /api/ sidebar. Zero
dependencies 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

  • Root-marked × keyed router is pinned by types only; no runtime spec proves that
    branch wraps its leaves. One fixture would close it.
  • packages/contract/src/auth.spec.ts asserts through an optional chain, which
    the test conventions call out. Verified it fails rather than skips.
  • The @btravstack/http spec count in the root CLAUDE.md had already drifted
    before this branch; both files now say the measured number, but nothing gates
    it.

Gate

format --check · lint · typecheck 31/31 · knip · test 29/29 (Docker) ·
build 10/10 · docs build — all green. The docs build is green for the first
time in a while: VitePress was compiling gitignored working files under
docs/superpowers/, now excluded.

Changesets mark @btravstack/contract and @btravstack/http minor, taking the
fixed group to 0.3.0. @btravstack/contract's surface changed twice on this
branch and has never shipped, so the changesets describe where it landed rather
than the path.

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.
Copilot AI lite review requested due to automatic review settings August 19, 2026 17:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/http to (a) conditionally require an authenticator when any contract node is marked, (b) inject context.principal for marked handlers, and (c) add plugins and securityHeaders configuration.
  • 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 (PrincipalKey is a unique 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.

Comment thread docs/reference/contract.md Outdated
Comment thread packages/http/src/http-runtime.ts Outdated
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.
Comment thread .changeset/config.json
Comment thread docs/how-to/protect-a-procedure.md Outdated
Comment thread docs/reference/contract.md
…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.
@btravers btravers changed the title feat: a contract declares which procedures need an authenticated principal feat: a contract declares which procedures need an authenticated caller Aug 19, 2026
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@btravers
btravers merged commit ecfbb2e into main Aug 20, 2026
13 checks passed
@btravers
btravers deleted the feat/cross-cutting-concerns branch August 20, 2026 00:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide where cross-cutting concerns live: auth, CORS, security headers, rate limiting No first-party transaction / unit-of-work boundary

2 participants