feat(http)!: a contract may name a scope only if its scheme can grant it - #91
Merged
Conversation
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.
There was a problem hiding this comment.
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>ontorouterFor(...)(contract)and introduces supporting type helpers (ScopesIn,Ungrantable) to compute ungrantable scopes across the whole contract tree. - Adds
VocabFrom<A>indefine-http.tsand threads it intorouterForso the router can validate contract scopes against the authenticators’ declared vocabularies. - Pins the behavior via new
*.test-d.tsarms, updatespackages/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.
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.
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.
Closes #90.
The gap
Nothing tied a contract's scope strings to the vocabulary its scheme's
authenticator was minted with:
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 failedclosed, 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
routerForintersectsScopeGate<C, Vocab>onto itscontractparameter — theshape
StartGateand di'sNeedsGatealready use:unknownwhen satisfied, anobject with one required property when not. That is what makes the diagnostic
end on the offending scope rather than restate the contract:
VocabFrom<A>reads the vocabulary off the same authenticatorsSchemesFrom<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.scopehas beencarrying 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
neverand 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'sgroupedcontract named a scope its testapicouldnot grant. Fixed in the same commit — the fixture had exactly the bug.
One shape inside is load-bearing
ScopesInasksK extends keyof R[I]before indexing. Indexing arequirement that does not name
Kgivesnever, and inferring the element typefrom
neverfalls back to its constraint —string— so every scope lookedgrantable 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 vocabularyaccepted; 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.