Skip to content

feat(http)!: named security schemes, OpenAPI's model - #89

Merged
btravers merged 18 commits into
mainfrom
feat/named-security-schemes
Aug 23, 2026
Merged

feat(http)!: named security schemes, OpenAPI's model#89
btravers merged 18 commits into
mainfrom
feat/named-security-schemes

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Replaces @btravstack/http's single-Identity authentication surface with
OpenAPI-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); the
decisions taken during implementation are summarised under Rulings below.

What an application writes now

// contract — names schemes and scopes, never an identity type
const ordersContract = authenticated({ user: [] })({
  place: oc.input(placeInput).output(orderView),
  export: authenticated({ user: ["orders:export"] }, { service: [] })(oc.output(csv)),
});

// server — one binding, no type annotation
export const api = defineHttp({ authenticators: { user: userAuth, service: serviceAuth } });
place:  ({ context }) => place.execute(context.principal.tenantId, )   // one scheme: bare
export: ({ context }) => {                                             // two: tagged
  switch (context.principal.scheme) {
    case "user":    return forUser(context.principal.identity);
    case "service": return forService(context.principal.identity);
  }
}
health: ({ context }) => context.principal   // never — reading it is a compile error

The three complaints this answers

  1. Three hand-written annotations in examples/order-api/src/auth.ts. The
    cause was never the contract's private unique symbol — it was that
    HttpAuth was not exported, so the inferred type had nothing nameable to
    collapse to. Measured: exporting it takes the count to zero, and
    destructuring the result puts it back.
  2. httpAuth was not the only door. HttpController and HttpRouter were
    also top-level. They are now reachable only through the factory's result.
  3. One deployment could hold one identity. It can now hold as many as it
    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.
Requirement is 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, the
RouterIdentity parameter and the identity comparison. All of them answered
"do these two agree?", which stops being a question when both come from one
registry. HttpModule wires the authenticators itself, so an application never
lists 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:

  • SchemesOf collapsed to never. keyof R[number] is the intersection
    of 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.
  • A declared scope was not enforced when the authenticator answered bare.
    Fails open. Fixed and pinned.
  • tagged counted requirements, not schemes, so one requirement naming two
    schemes typed the handler Tagged while the middleware injected bare —
    principal.scheme was undefined with no type error.
  • The example's bearer token could never grant its own scope: the scope name
    contains the token's field delimiter.

And one found by the final whole-branch review, in the fix for the first three:

  • The scoped answer was sniffed structurally. A scheme with no vocabulary
    whose identity happens to carry a scopes claim — JWT-shaped, the ordinary
    case — 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 with
    a 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); HasMark is unused inside its own package; one
test fixture is async for nothing. All triaged by the final review as
non-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/http carries 49 specs at 100% lines/functions; examples/order-api
carries 26, exercising both schemes, the scope, the 403 and the 401 through the
real composition root.

Changeset included — minor, all nine packages.

…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`.
Copilot AI lite review requested due to automatic review settings August 22, 2026 22:15

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

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 defineHttp and scheme-based authenticators (HttpAuthenticator:<scheme> ports), plus Principal typing that becomes bare for single-scheme and tagged for multi-scheme procedures.
  • Change @btravstack/contract auth marker to authenticated(...requirements)(node) with Requirements/RequirementsOf, backed by a WeakMap registry on globalThis.
  • Update example apps, tests, docs, and changesets to the new API (including 401 vs 403 behavior 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.

Comment thread packages/contract/src/auth.spec.ts Outdated
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.

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 58 changed files in this pull request and generated no new comments.

Comment thread examples/order-api/src/auth.ts
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.
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.

2 participants