Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2022607
feat(contract)!: the marker carries OpenAPI requirements, not a boolean
btravers Aug 22, 2026
55bcbf6
feat(http): the principal is bare for one scheme and tagged for many
btravers Aug 22, 2026
3b95be2
fix(http): schemesOf is the union of scheme names, not their intersec…
btravers Aug 22, 2026
71e4ec2
feat(http)!: an authenticator names a scheme and the scopes it grants
btravers Aug 22, 2026
80ed7ff
feat(http)!: a record's requirements are the default, a procedure's r…
btravers Aug 22, 2026
c0f3af6
feat(http)!: defineHttp is the one door, and it infers its registry
btravers Aug 22, 2026
b6b37da
feat(http)!: a router declares one dep per scheme its contract names
btravers Aug 22, 2026
23c1b38
feat(http)!: requirements are tried in order, and a defect stops the …
btravers Aug 22, 2026
fe68220
feat(http)!: the sugar carries the authenticators, so an application …
btravers Aug 22, 2026
495dd1d
fix(http): a declared scope is enforced, and tagging counts schemes
btravers Aug 22, 2026
cec785c
feat(examples)!: the API contract names a scope and a second scheme
btravers Aug 22, 2026
5863df9
feat(examples)!: the API declares two schemes and a scope in one call
btravers Aug 22, 2026
1364d0d
fix(examples): the bearer token's scope segment survives its own deli…
btravers Aug 22, 2026
54de3fa
docs: named security schemes, and the sentence that admits scopes
btravers Aug 22, 2026
a4516c9
fix(http)!: a scoped grant is branded, and a requirement names one sc…
btravers Aug 22, 2026
d5bf033
docs: the sugar's expansion compiles, and no page imports a deleted e…
btravers Aug 22, 2026
ef171a8
fix(docs): the authenticator sample was ungated, and it had drifted
btravers Aug 22, 2026
74c13f1
test(contract): the registry is a WeakMap, and the cast now says so
btravers Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions .changeset/authenticated-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,26 @@
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<Identity>()` says what the principal is.**
**The contract says which schemes protect a route; the application says what
each one resolves to.**

`@btravstack/contract` is a new zero-dependency package holding the marker
itself: `authenticated(node)`, one export with no factory and no type
parameter, applied to a finished procedure or to a whole record of them. It
itself, 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
`WeakMap` off `globalThis` and a phantom type key — so a client can import a
marked contract without pulling in anything that implements it. `IsMarked<T>`
answers the yes/no at the type level, `isAuthenticated(node)` at runtime. An
answers the yes/no at the type level, `isAuthenticated(node)` reads the
requirements back at runtime. An
unmarked procedure is public; the marker makes the requirement legible in the
contract rather than detecting one that was forgotten.
contract rather than detecting one that was forgotten. Its full shape — the
curried `authenticated(...requirements)(node)`, scopes and per-procedure
overrides — is in the _named security schemes_ entry.

`@btravstack/http` resolves the principal through a new `Authenticator` port —
`HttpAuthenticator<P>()([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 carries the port as an
unmet need `start` refuses, and an authenticator minted on a different
identity than the router is refused at `HttpModule`. A marked procedure whose
`@btravstack/http` resolves the principal before dispatch, through an
authenticator per scheme. A contract that marks nothing needs none; a marked
router whose graph provides none carries that scheme's port as an
unmet need `start` refuses. A marked procedure whose
authenticator declines is answered `UNAUTHORIZED` before dispatch, with the
handler never running and no reason reaching the caller — `Unauthenticated`
carries none, so an authenticator logs why before returning.
Expand Down
7 changes: 4 additions & 3 deletions .changeset/http-controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
"@btravstack/http": minor
---

Add `HttpController(name, fragment)([deps], { sync })` and a keyed
Add `HttpController(name, fragment)({ name: Dep }, { sync })` and a keyed
`HttpRouter(contract)(controllers)` form, so a large API can be split into
slices that each own a contract fragment and its implementation.
slices that each own a contract fragment and its implementation. Both come off
`defineHttp` — see the _named security schemes_ entry.

A controller is an ordinary di provider on a port the factory mints and hands
back on `provider.port`. The root composes them by contract key, and a missing
slice, an undeclared key, a controller under the wrong key and a fragment that
has drifted from the contract are all compile errors. The positional
has drifted from the contract are all compile errors. The
`HttpRouter(contract)(deps, { sync })` form is unchanged and still right for a
small API.

Expand Down
86 changes: 86 additions & 0 deletions .changeset/named-security-schemes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
"@btravstack/contract": minor
"@btravstack/http": minor
---

Let a contract name **which security schemes** a procedure accepts and **which
scopes** each must grant, and let an application say what each scheme resolves
to — in one call.

`@btravstack/contract`'s marker carries OpenAPI's own requirement shape instead
of a boolean. `authenticated` is now **curried**:
`authenticated(...requirements)(node)`, where a `Requirement` is
`Readonly<Record<string, readonly string[]>>` — a scheme name mapped to the
scopes it must grant, and **exactly one scheme**: a second key does not
compile, because OpenAPI reads two keys in one requirement as AND while this
starter walks them as OR, so a requirement copied out of an OpenAPI document
would silently execute a weaker rule than the one it states. Several
requirements are **ORed**, tried in declaration order. Applied to a record it is the default for every procedure beneath it;
applied to a procedure it **replaces** that default for itself — nearest mark
wins, which is OpenAPI's rule. `isAuthenticated(node)` answers
`Requirements | undefined` rather than a boolean, `Authenticated<T, R>` and the
new `RequirementsOf<T>` carry the exact requirements at the type level, and the
registry is a `WeakMap` under `Symbol.for("@btravstack/contract/requirements")`
— a new key, so a mismatched copy of the package reads a node as _unmarked_ and
fails closed rather than calling `.has()` on it and getting an accidentally
correct answer.

`@btravstack/http` gains **`defineHttp`**, the one door:

```ts
export const api = defineHttp({
authenticators: { user: userAuth, service: serviceAuth },
});
```

It hands back `HttpController`, `HttpRouter` and `authenticators`, all typed by
a scheme registry **inferred from the authenticators** rather than declared a
second time. Declaring a scheme and implementing it are the same act, so a
scheme without an authenticator is not a state the API can reach. Hold the
result as **one binding and never destructure it**: each destructured member
expands to a type mentioning `@btravstack/contract`'s inaccessible
`unique symbol` (TS2527), while held whole it collapses to the nameable
`Http<A>` — so an application writes **no type annotation at all**, which is
what removed the three hand-written ones the previous shape required.

**The principal follows the requirements.** A leaf whose requirements name one
scheme gets the identity **bare** — byte-for-byte what handlers wrote before.
A leaf naming several gets `{ scheme, identity }`, narrowed with a `switch`
whose missing arm is a compile error. A public leaf gets `never`, so reading it
cannot compile.

**Scopes are declared in the contract and enforced before dispatch.**
`HttpAuthenticator<P, Scope>()` states a scheme's scope vocabulary, so a
credential reports what it actually granted through the new
**`granted(identity, scopes)`** (`Granted<P, Scope>` is `P` bare when there is
no vocabulary, and the branded `Grant<P, Scope>` when there is one) and the
starter compares it against what the endpoint declared: a valid credential lacking a required scope is **`403`**,
no valid credential at all is **`401`**, and neither carries a message. A
`Defect` from an authenticator short-circuits rather than falling through to
the next scheme — a broken verifier must not promote every caller. `granted()`
is **mandatory rather than advisory**: the type parameter is erased at
runtime, so the module-private symbol it stamps is the only sound way the
starter can tell a scoped answer from an identity that merely carries a
`scopes` field — the ordinary JWT-claims shape, which a structural test read
as the scoped answer and handed the handler `undefined`.

A router now declares **one di dependency per scheme its contract names**, on a
port whose id carries the scheme name (`HttpAuthenticator:user`), so a missing
authenticator is di's own unmet need naming that port. `HttpModule` wires the
authenticator providers itself, off the router that carries them.

**Breaking.** The top-level `HttpRouter` export is gone — it comes off
`defineHttp` now, because that is where the registry that types it is stated;
so do `HttpController` and `HttpAuthenticator`'s applied form. Also removed:
`httpAuth`, `HttpAuth`, `HttpControllerOf`, `HttpRouterOf`,
`HttpAuthenticatorOf`, `AuthenticatorPort`, `noAuthenticator`, the
`HttpModuleOptions.authenticator` option and the router/authenticator identity
comparison it carried. `authenticated(node)` must become
`authenticated({ scheme: [] })(node)`.

**Not modelled, deliberately.** AND within one requirement — a requirement
names one scheme, because requiring two credentials at once would put a record
rather than an identity on the handler; a composite scheme models it where it
is genuinely needed. And OpenAPI document metadata (`type: http`,
`bearerFormat`, an OAuth flow), which belongs beside the contract rather than
in this factory.
35 changes: 0 additions & 35 deletions .changeset/server-side-identity.md

This file was deleted.

58 changes: 38 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,14 @@ transport's hands.
"is there a principal, and what is it?" — is answerable before dispatch, and
is the only half the contract carries.

A **scope** is the exception that proves the rule, and it is admitted on the
same test: it is a property of the credential, answerable before dispatch,
which is exactly why authentication is in the contract already. What stays
out is resource-dependent authorization — the order's owner, the row's tenant
— which a scope was never going to answer. `@btravstack/http` checks a
credential's granted scopes against the endpoint's declared ones and answers
`403`, distinct from the `401` a caller with no valid credential gets.

## Public surface

Each package's surface is stated **once**, in that package's own `CLAUDE.md`,
Expand Down Expand Up @@ -474,24 +482,30 @@ 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 50 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 contract says WHETHER a route is protected; the
application's `httpAuth<Identity>()` 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
`rpcSliced` fixture, and the contract marker's runtime half — the per-scheme
authenticator ports and the one middleware they install — through
`rpcAuthed`. **The contract says WHICH SCHEMES protect a route, and which
scopes each must grant; the application's `defineHttp({ authenticators })`
says WHAT each scheme resolves to.**
`@btravstack/contract` names no identity type at all — `authenticated` takes
OpenAPI requirements and no type parameter — so nothing about a server's
view of a caller reaches a client, and a marked fragment reached through
anything but that one call 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.
`examples/order-api/src/auth.ts` is the one file per application that names
its identities, and there is no identity comparison left to make: declaring a
scheme and implementing it are the same act, so a scheme the contract names
with no authenticator behind it is di's own unmet need on
`HttpAuthenticator:<scheme>`. The one call's result is held as **one
binding and never destructured** — each destructured member expands to a type
mentioning `@btravstack/contract`'s inaccessible `unique symbol` (TS2527),
while held whole it collapses to the nameable `Http<A>`, which is why the
application writes no type annotation at all.
- **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
Expand Down Expand Up @@ -702,13 +716,14 @@ AuditSlice, observability()], … })`),
so there. That is what makes a slice directory readable on its own — which
ports come from outside, without naming who supplies them — and what keeps a
`needs` list one line per feature instead of one per hop. `@btravstack/http`'s
`HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's
port; the root composes every slice's controller into one router with the
keyed `HttpRouter(contract)(controllers)` form, exact against the contract
`api.HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's
port — `api` being the application's one `defineHttp(...)` binding; the root
composes every slice's controller into one router with the
keyed `api.HttpRouter(contract)(controllers)` form, exact against the contract
(see `packages/http/CLAUDE.md`). **A fragment is itself a valid contract**,
so a slice lifts out of the modulith into a process of its own without its
controller changing at all: the lifted root is
`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`,
`api.HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`,
declaring the very provider the modulith composed and handing back what it
built — a new composition root and one fewer import,
not a rewrite of the slice. That exact call is `controller.test-d.ts`'s fifth
Expand Down Expand Up @@ -762,7 +777,7 @@ AuditSlice, observability()], … })`),
visits 16 provider slots and di keeps 15, one `OrderDatabase` among them
(the same walk over the pre-split modules visited 22 for the same 15, and
the difference is the over-inclusion the split removed). The root composes them —
`orderRouter = HttpRouter(contract)({ orders: ordersController,
`orderRouter = api.HttpRouter(contract)({ orders: ordersController,
customers: customersController })`, the keyed form — and
**`HttpModule("OrderApi")({ router: orderRouter, imports: [OrdersSlice,
CustomersSlice, observability()], exports: [Logger] })`** is the whole
Expand All @@ -771,6 +786,8 @@ 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
two authenticators are **not** in that list: they ride the router, which is
what needs them, and `HttpModule` puts them in `provides` itself. The
**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
Expand Down Expand Up @@ -1188,8 +1205,9 @@ And a seventh, about the infrastructure a suite runs against:
`FindCustomer` against the real `contract` through the application's own
`src/auth.ts`, and a stub would have accepted every broken call — passing an
order id where a tenant goes was exactly the drift. It covers both
controllers, the keyed router, the `HttpModule` root with its authenticator,
the lifted single-slice root and the bare `HttpRouter(contract)(deps, arm)`
controllers, the keyed router, the `HttpModule` root whose authenticators
ride the router,
the lifted single-slice root and the bare `api.HttpRouter(contract)(deps, arm)`
form the three router-shaped pages share — `docs/index.md`,
`docs/reference/http.md` and `docs/how-to/serve-orpc-over-http.md`, none of
which puts a controller in between. Every deps record it compiles is
Expand Down
4 changes: 2 additions & 2 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ harness, the observability starter and the three transport starters on top of
`Attributes`, `Line`, `Sink`, `ObservabilityOptions`. The
`@btravstack/observability/pino` subpath carries `pinoSink` alone, so `pino`
stays an optional peer.
- **[`@btravstack/http`](/api/http/)** — `HttpModule`, `HttpRouter`, `http`,
- **[`@btravstack/http`](/api/http/)** — `HttpModule`, `defineHttp`, `http`,
the ports `HttpRuntime` and `HttpConfig`, and the types `HttpModuleOptions`,
`HttpOptions`, `HttpInfo`.
- **[`@btravstack/temporal`](/api/temporal/)** — `TemporalModule`,
Expand Down Expand Up @@ -68,7 +68,7 @@ because operations hang off the values by convention —
import { Module, Port, Provider } from "@btravstack/di";
import { Config, Env } from "@btravstack/config";
import { runMain, start } from "@btravstack/core";
import { HttpModule, HttpRouter } from "@btravstack/http";
import { HttpModule, defineHttp } from "@btravstack/http";
```

`Scope` is a **type-only** export of `di`, and `PortClass`,
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ two kinds of type test that keep the arrows pointing the right way.

### [Order API (HTTP)](/examples/order-api)

`HttpRouter(contract)` — every procedure a plain
`api.HttpRouter(contract)` — every procedure a plain
`Result`-returning function and one exhaustive `mapErrCases` where a domain
`Err` becomes a typed `ORPCError`; `HttpModule("OrderApi")` as the whole
composition root; `RequestModule` forked per request through
Expand Down
Loading
Loading