feat(http)!: named security schemes, OpenAPI's model - #89
Merged
Conversation
…heme
Two defects the final review found, both silent.
`principalMiddleware` told the scoped answer from the bare one with
`"scopes" in granted`. The type parameter is erased at runtime, so no
structural test can be sound: a scheme declared WITHOUT a vocabulary whose
identity carries a `scopes` field — an ordinary JWT-claims shape, and what
the design spec advises — was read as the scoped answer, and its absent
`identity` was injected. The handler is typed as the identity, receives
`undefined`, and in this stack the principal is where the tenant lives, so it
is a 500 on every request to that route.
`granted(identity, scopes)` now mints the scoped answer, stamping a
module-private symbol, and `Granted`'s scoped arm IS that branded result — so
the helper is mandatory rather than advisory and a hand-built
`{ identity, scopes }` no longer type-checks. The middleware tests for the
brand. `[Scope] extends [never]` still means the identity bare, unchanged.
`Requirement` allowed several keys, which OpenAPI reads as AND and this
starter runs as OR — an author copying a requirement out of an OpenAPI
document got a weaker rule than the one written. Four documents said "a
requirement names one scheme" and nothing enforced it; `authenticated`'s
constraint does now.
…xport Three defects the final review found outside the diff the last sweep covered. The tutorial's step 4 imported `HttpRouter` from `@btravstack/http`, an export this branch deleted — a `TS2305` on the sample's first line. It goes through `defineHttp()`, the no-argument public-API call, like every other page. Compiled in a scratch file under `packages/http/src` before it landed. `serve-orpc-over-http.md` and `reference/http.md` spelled the "exactly the module the sugar builds" equivalence as `provides: [ordersRouter, userAuth]`. Those are `Authenticator<…>` descriptions, not providers; the expansion is `...router.authenticators`, which `examples/order-api.md` and the needs-gate type test already used. The branch stated it three ways and got it right once. `serve-orpc-over-http.md` also named a `userAuth` binding it never introduced, which the same edit removes. `open-a-per-request-scope.md`'s unit-gate arm claimed to isolate that gate while carrying a marked router with no authenticators, so it would have failed for two reasons. Spread them, as `needs-gate.test-d.ts` already does. Plus the ride-alongs: `tenant.ts` named `bearerAuthenticator`, deleted on this branch, and seven pages spelled the router and controller helpers bare where they now hang off `api`.
There was a problem hiding this comment.
Pull request overview
This PR overhauls @btravstack/http authentication from a single deployment-wide Identity to OpenAPI-shaped named security schemes with per-procedure requirements and scopes, enforced before dispatch. It introduces a new defineHttp({ authenticators }) factory as the single entry point that types routers/controllers and wires per-scheme authenticators into the DI graph, while updating @btravstack/contract’s authenticated marker to carry requirements (scheme + scopes) rather than a boolean.
Changes:
- Add
defineHttpand scheme-based authenticators (HttpAuthenticator:<scheme>ports), plusPrincipaltyping that becomes bare for single-scheme and tagged for multi-scheme procedures. - Change
@btravstack/contractauth marker toauthenticated(...requirements)(node)withRequirements/RequirementsOf, backed by aWeakMapregistry onglobalThis. - Update example apps, tests, docs, and changesets to the new API (including
401vs403behavior based on scope grants).
Reviewed changes
Copilot reviewed 58 out of 58 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/http/src/test-fixtures.ts | Migrates HTTP fixtures to defineHttp() and adds a verified-authenticator fixture path. |
| packages/http/src/principal.ts | Adds SchemesOf/Principal typing for bare vs tagged principals. |
| packages/http/src/principal.test-d.ts | Type-level tests for Principal and SchemesOf. |
| packages/http/src/index.ts | Re-exports new auth surface (defineHttp, granted, Principal, etc.) and removes httpAuth/top-level router exports. |
| packages/http/src/http-module.ts | Removes authenticator option; wires router-carried per-scheme authenticators into provides. |
| packages/http/src/http-auth.ts | Removes the old httpAuth factory and its exported aliases. |
| packages/http/src/define-http.ts | Introduces defineHttp factory and binding of scheme authenticators onto scheme-named ports. |
| packages/http/src/define-http.test-d.ts | Type tests for defineHttp inference and authenticator dependency propagation. |
| packages/http/src/controller.ts | Generalizes controller typing from single identity to scheme registry. |
| packages/http/src/controller.test-d.ts | Updates keyed-router compile-time gates and adds scheme/requirement inheritance checks. |
| packages/http/src/auth.ts | Implements per-scheme ports, scoped grants via branded granted(), and requirement-walking principal middleware with 401/403 split. |
| packages/http/src/auth.spec.ts | Adds runtime coverage for requirement ordering, scope enforcement, multi-scheme tagging, and defect behavior. |
| packages/http/README.md | Updates public docs to defineHttp, schemes, scopes, and router-carried authenticators. |
| packages/contract/src/index.ts | Exports new requirement-related types. |
| packages/contract/src/auth.ts | Changes marker to carry Requirements (scheme+scopes), adds RequirementsOf, and stores requirements in a WeakMap. |
| packages/contract/src/auth.test-d.ts | Type tests for requirements-carrying marker and one-scheme constraint. |
| packages/contract/src/auth.spec.ts | Runtime tests updated for requirements-based marker and global registry shape. |
| packages/contract/README.md | Documents curried authenticated(...requirements)(node) and scheme/scope semantics. |
| packages/contract/CLAUDE.md | Updates package spec to requirements/scopes model and revised invariants. |
| examples/order-domain/src/tenant.ts | Updates commentary to reflect renamed stand-in authenticator. |
| examples/order-api/src/test-fixtures.ts | Removes explicit authenticator wiring and adds service-scheme client fixture. |
| examples/order-api/src/slices/orders/controller.ts | Switches controllers to api.HttpController and adds multi-scheme export handler. |
| examples/order-api/src/slices/customers/controller.ts | Switches controller minting to api.HttpController. |
| examples/order-api/src/needs-gate.test-d.ts | Updates needs-gate type tests to scheme-port model and router-carried authenticators. |
| examples/order-api/src/module.ts | Updates composition root to api.HttpRouter and drops authenticator option. |
| examples/order-api/src/docs-examples.test-d.ts | Updates compiled docs examples to new auth API and multi-scheme handler. |
| examples/order-api/src/authenticator.ts | Removes obsolete standalone bearerAuthenticator provider. |
| examples/order-api/src/auth.ts | Introduces user + service schemes, scope granting via granted(), and exports api. |
| examples/order-api/src/api.spec.ts | Adds integration coverage for service scheme, scope success, and 403 under-scoped behavior. |
| examples/order-api/README.md | Updates example documentation to scheme-based auth and router-carried authenticators. |
| examples/order-api-contract/src/contract.ts | Updates contract to curried authenticated({ user: [] }) plus per-procedure override for export. |
| docs/tutorial/getting-started.md | Updates tutorial router example to use defineHttp()/api.HttpRouter. |
| docs/reference/di/providers.md | Updates references from HttpRouter to api.HttpRouter. |
| docs/reference/di/ports.md | Updates example references to api.HttpRouter. |
| docs/reference/di/modules.md | Updates example references to api.HttpController. |
| docs/reference/contract.md | Updates contract reference for requirements/scopes and new exports. |
| docs/index.md | Updates landing-page sample to curried authenticated and api.HttpRouter, removing explicit authenticator wiring. |
| docs/how-to/test-an-application.md | Updates testing recipe to rely on router-carried authenticators. |
| docs/how-to/split-a-worker-into-slices.md | Updates HTTP router reference to api.HttpRouter. |
| docs/how-to/split-a-router-into-controllers.md | Updates how-to to api.HttpController/api.HttpRouter and removes authenticator option. |
| docs/how-to/serve-orpc-over-http.md | Updates recipe steps and examples for defineHttp, scheme ports, and removed authenticator option. |
| docs/how-to/open-a-per-request-scope.md | Updates module example to include ...orderRouter.authenticators in provides. |
| docs/explanation/starters.md | Updates starter overview to api.HttpRouter and scheme-typed principals. |
| docs/explanation/design-decisions.md | Updates design discussion references to api.HttpRouter. |
| docs/explanation/compile-time-wiring.md | Updates keyed-router gate reference to api.HttpRouter. |
| docs/examples/index.md | Updates examples index snippet to api.HttpRouter. |
| docs/api/index.md | Updates API index references from HttpRouter to defineHttp. |
| CLAUDE.md | Updates repo-level guidance around scopes, schemes, and defineHttp usage. |
| .changeset/server-side-identity.md | Removes obsolete changeset describing the old single-identity surface. |
| .changeset/named-security-schemes.md | Adds changeset documenting the new schemes/scopes model across contract+http. |
| .changeset/http-controllers.md | Updates controllers changeset to note they come from defineHttp. |
| .changeset/authenticated-contracts.md | Updates earlier changeset to reference the new schemes/scopes model and defers to the new changeset. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Two pages still built a scoped answer as a plain `{ identity, scopes }` object
after `granted()` became mandatory. It does not type-check — auth.test-d.ts pins
that exact literal as a negative — and casting past it is worse than the compile
error: principalMiddleware reads an unbranded object as a BARE identity, so
scopesGranted is empty and every caller on a scoped route is refused forever.
One of the pages also shadowed `granted` with a local of the same name.
The reason both drifted is that docs-examples.test-d.ts covered the controllers,
the router and the module root but never the authenticator, so nothing compiled
the half that changed. It does now, which is this repo's own stated trigger:
add the gate the next time a sample is found to have drifted.
Also: the prose on protect-a-procedure still described the old hand-built shape,
and the spec count reached 50 when the fail-open fix added a test.
btravers
commented
Aug 23, 2026
The spec reached the shared registry through a `Map<object, unknown>` cast while the implementation stores a `WeakMap`. It passed because both carry `.get`, but a cast that misdescribes what it points at is a trap for the next edit — and this one sits on the fail-closed property two copies of the package depend on.
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces
@btravstack/http's single-Identityauthentication surface withOpenAPI-shaped named security schemes: several identities in one deployment,
scopes, and a factory that infers its own registry.
Spec and plan are in
docs/superpowers/(gitignored working files); thedecisions taken during implementation are summarised under Rulings below.
What an application writes now
The three complaints this answers
examples/order-api/src/auth.ts. Thecause was never the contract's private
unique symbol— it was thatHttpAuthwas not exported, so the inferred type had nothing nameable tocollapse to. Measured: exporting it takes the count to zero, and
destructuring the result puts it back.
httpAuthwas not the only door.HttpControllerandHttpRouterwerealso top-level. They are now reachable only through the factory's result.
declares, and a procedure names which of them it accepts.
From OpenAPI
Named schemes, per-operation requirements with a group-level default
(nearest mark wins), and scopes — declared in the contract, enforced
before dispatch, giving 403 for a valid credential lacking a scope against
401 for no valid credential. Neither refusal carries a message.
AND within one requirement is the one exclusion, on handler-DX cost rather
than principle: it would make the principal a record and leave an OR over such
requirements with no single string to narrow on. A composite scheme models it.
Requirementis constrained to one key, so a two-key object — OpenAPI's AND —does not type-check rather than silently weakening to OR.
The authorization thesis gains a sentence, not a rewrite
A scope is a property of the credential, answerable before dispatch — the
same test that already puts authentication in the contract. Resource-dependent
authorization is untouched and still the handler's.
What this deletes
httpAuth,HttpControllerOf,HttpRouterOf,HttpAuthenticatorOf,AuthenticatorPort,noAuthenticator,HttpModuleOptions.authenticator, theRouterIdentityparameter and the identity comparison. All of them answered"do these two agree?", which stops being a question when both come from one
registry.
HttpModulewires the authenticators itself, so an application neverlists them in
provides.Rulings, and the defects they answer
Four defects were found in the plan's own code and are worth naming, since
they are the reason the review loop earned its keep:
SchemesOfcollapsed tonever.keyof R[number]is the intersectionof the requirements' keys, so the multi-scheme case — the one the feature
exists for — silently computed nothing. The plan's own test passed against a
deliberately-broken stub. Fixed to a mapped-tuple form; the test is now
bidirectional, which is the half that bites.
Fails open. Fixed and pinned.
taggedcounted requirements, not schemes, so one requirement naming twoschemes typed the handler
Taggedwhile the middleware injected bare —principal.schemewasundefinedwith no type error.contains the token's field delimiter.
And one found by the final whole-branch review, in the fix for the first three:
whose identity happens to carry a
scopesclaim — JWT-shaped, the ordinarycase — was read as the scoped answer and the principal became
undefined.The type parameter is erased at run time, so no structural test is sound: a
scoped grant is now built by a package-minted
granted()helper stamped witha symbol, and the type makes the helper mandatory.
Shipping as-is, deliberately
Every declared authenticator is provided to every router (an over-declared
obligation, loud at build);
HasMarkis unused inside its own package; onetest fixture is
asyncfor nothing. All triaged by the final review asnon-blocking.
Gate
All six green from the root:
format --check,lint,typecheck(31/31),knip,test(30/30, 366 tests),build(10/10), plus the documentation site.@btravstack/httpcarries 49 specs at 100% lines/functions;examples/order-apicarries 26, exercising both schemes, the scope, the 403 and the 401 through the
real composition root.
Changeset included —
minor, all nine packages.