Skip to content

feat(http)!: a contract may name a scope only if its scheme can grant it - #91

Merged
btravers merged 2 commits into
mainfrom
feat/scope-vocabulary-gate
Aug 23, 2026
Merged

feat(http)!: a contract may name a scope only if its scheme can grant it#91
btravers merged 2 commits into
mainfrom
feat/scope-vocabulary-gate

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #90.

The gap

Nothing tied a contract's scope strings to the vocabulary its scheme's
authenticator was minted with:

export const userAuth = HttpAuthenticator<Identity, "orders:export">()({});
export const api = defineHttp({ authenticators: { user: userAuth } });

// a typo — compiled, passed all six gate commands, then 403'd forever
authenticated({ user: ["order:export"] })({ export: oc.output(csv) });

Two cases, both silent: a typo, and a scope asked of a scheme declared with no
vocabulary at all
(Scope = never, so everything is ungrantable). Both failed
closed, which is why the check survived two reviews as a Minor rather than a
blocker — but the route was unreachable by construction and said nothing.

The check

routerFor intersects ScopeGate<C, Vocab> onto its contract parameter — the
shape StartGate and di's NeedsGate already use: unknown when satisfied, an
object with one required property when not. That is what makes the diagnostic
end on the offending scope rather than restate the contract:

Property '"UNGRANTABLE SCOPE — its scheme's authenticator cannot grant it"' is
  missing in type 'Authenticated<…, [{ user: ["order:export"] }]>' but required
  in type '{ readonly "UNGRANTABLE SCOPE — …": "order:export"; }'

VocabFrom<A> reads the vocabulary off the same authenticators SchemesFrom<A>
reads the principals off — two projections, because they answer different
questions at different call sites: the principal types the handler, the
vocabulary checks the contract. This is what Authenticator.scope has been
carrying for; a ponytail pass flagged it as a phantom field nothing read, and it
stays because this is the thing that reads it.

It is the sibling of the scheme-name check, which di already performs by
leaving an unknown scheme's port unmet.

What it costs

Nothing, in the common case. A requirement naming no scopes contributes never
and the gate collapses to unknown. Both positives are pinned:
the declared vocabulary accepted, and a contract naming no scopes at all
accepted.

The gate found its own first instance

controller.test-d.ts's grouped contract named a scope its test api could
not grant. Fixed in the same commit — the fixture had exactly the bug.

One shape inside is load-bearing

ScopesIn asks K extends keyof R[I] before indexing. Indexing a
requirement that does not name K gives never, and inferring the element type
from never falls back to its constraintstring — so every scope looked
grantable the moment two requirements named different schemes. The first cut had
exactly that hole and the two-requirement case caught it. Measured, and the
comment says so.

Pinned

Four arms in packages/http/src/auth.test-d.ts: the declared vocabulary
accepted; no scopes at all accepted; a typo refused; a scope asked of a
vocabulary-less scheme refused.

Gate

All six green from the root: format --check, lint, typecheck (31/31),
knip, test (30/30), build (10/10), plus the documentation site.
Changeset included — minor, all nine.

Closes #90. Nothing tied a contract's scope STRINGS to the vocabulary its
scheme's authenticator was minted with, so a typo — or a scope asked of a scheme
declared with no vocabulary at all — compiled, passed all six gate commands, and
then refused every caller on that route with a permanent 403 and no diagnostic
anywhere. Fails closed, which is why it survived two reviews as a Minor.

routerFor intersects ScopeGate<C, Vocab> onto its contract parameter, the shape
StartGate and NeedsGate already use: unknown when satisfied, an object with one
required property when not, which is what makes the message end on the offending
scope rather than restate the contract. VocabFrom<A> reads the vocabulary off the
same authenticators SchemesFrom<A> reads the principals off — two projections
because they answer different questions at different call sites.

The gate found its own first instance: controller.test-d.ts's grouped contract
named a scope its test api could not grant.

One shape inside is load-bearing and measured. ScopesIn asks `K extends keyof
R[I]` BEFORE indexing, because indexing a requirement that does not name K gives
never, and inferring the element type from never falls back to its constraint —
string — so every scope looked grantable the moment two requirements named
different schemes. The first cut had exactly that hole.
Copilot AI lite review requested due to automatic review settings August 23, 2026 14:19

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 type-level gate in @btravstack/http so an authenticated contract can only name scope strings that are actually grantable by the authenticator vocabulary for the referenced scheme (closing #90), preventing “compiles but permanently 403s” routes.

Changes:

  • Intersects a new ScopeGate<C, Vocab> onto routerFor(...)(contract) and introduces supporting type helpers (ScopesIn, Ungrantable) to compute ungrantable scopes across the whole contract tree.
  • Adds VocabFrom<A> in define-http.ts and threads it into routerFor so the router can validate contract scopes against the authenticators’ declared vocabularies.
  • Pins the behavior via new *.test-d.ts arms, updates packages/http/CLAUDE.md, and includes a changeset.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/http/src/orpc.ts Adds ScopeGate and supporting type-level analysis to refuse ungrantable scopes at router mint time.
packages/http/src/define-http.ts Introduces VocabFrom and wires it into HttpRouter’s type so the router can validate contract scopes.
packages/http/src/auth.test-d.ts Adds type-level pins for allowed/forbidden scope declarations (typo + vocab-less scheme cases).
packages/http/CLAUDE.md Documents the new scope-vocabulary gate and its load-bearing ScopesIn shape.
.changeset/scope-vocabulary-gate.md Publishes the change as a minor bump across the fixed package group.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/http/src/orpc.ts
Ungrantable treated a scheme missing from the vocabulary registry as having an
empty one, so every scope it named came back ungrantable. A misspelled SCHEME
therefore surfaced as a scope complaint — the wrong diagnostic, and the earlier
one, since this gate sits on the router mint while the unmet port surfaces at the
composition root.

The gate now skips a scheme the registry does not know, and the two checks stop
overlapping: a misspelled scheme passes the mint and di refuses the composition
naming the port it cannot discharge, PortInstance<"HttpAuthenticator:usre", …>,
which is the diagnostic that says what is actually wrong.

Pinned by a new arm: the router mint accepts the misspelled contract, HttpModule
refuses it.
@btravers
btravers merged commit dd263ff into main Aug 23, 2026
13 checks passed
@btravers
btravers deleted the feat/scope-vocabulary-gate branch August 23, 2026 20:18
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.

A contract can declare a scope its scheme can never grant, and nothing says so

2 participants