You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Promised by the named-security-schemes design, dropped in implementation, and
raised independently by two reviews since. Split out of #89 rather than fixed
there, because it is a type-level feature rather than a defect in what shipped.
The hazard
Nothing ties a contract's declared scope strings to the vocabulary its
scheme's authenticator can actually grant.
// the authenticator grants exactly one scopeexportconstuserAuth=HttpAuthenticator<Identity,"orders:export">()({ … });exportconstapi=defineHttp({authenticators: {user: userAuth}});// the contract asks for one it can never grant — a typoexportconstcontract={orders: authenticated({user: ["order:export"]})({export: oc.output(csv)}),};
That compiles. It passes format, lint, typecheck, knip, test and build — all six. Then every caller on that route gets a permanent 403: principalMiddleware compares the required scope against what the credential
granted, "order:export" is never in the list, and the requirement can never be
satisfied. No diagnostic anywhere, at any point.
The failure direction is closed, which is why this is P1 and not urgent. But it
is a route that is unreachable by construction and says nothing about it.
Why it is absent
SchemesFrom keeps only the principal and drops the vocabulary
(packages/http/src/define-http.ts:11):
Authenticator<P, Scope, N>does carry scope (packages/http/src/auth.ts),
and it is kept deliberately for this — a ponytail pass flagged it as a phantom
field nothing reads, and it stays precisely because this check is the thing that
will read it.
Where the check belongs
routerFor<Schemes, Auth>(authenticators)(contract)
(packages/http/src/orpc.ts:143) is where the contract and the registry meet —
the same call that already refuses a scheme name the registry has no key for.
It needs the vocabulary alongside the principal, so SchemesFrom grows a second
projection (or Http<A> carries the registry whole and the router reads both).
The shape, roughly: for every requirement in the contract, every scope string it
names under scheme K must extend A[K]["scope"]. SchemesOf already
flattens requirements to scheme names; this is the sibling that flattens them to
scope strings, compared per scheme.
What it must not cost
A scheme with no vocabulary (HttpAuthenticator<Identity>(), Scope = never)
paired with a requirement declaring no scopes must stay exactly as cheap as
it is now. That is the common case and it currently pays nothing.
The diagnostic must name the offending scope, not just fail. The three
gates this package already ships name their port, and the one that does not —
di's arity gate — is the one this repo keeps having to explain.
Acceptance
A contract naming a scope outside its scheme's vocabulary is refused where the
router is minted, with the scope in the message.
A @ts-expect-error pinning that, alongside the existing scheme-name-typo arm.
A positive arm: the declared vocabulary, accepted.
packages/http/CLAUDE.md states the check and what it does not cover.
History, so it is not dropped a third time
The design spec promised it:
A contract declaring a scope for a scheme whose authenticator grants none is
a compile error where the router is minted, the same place a misspelled
scheme name fails.
The implementation did not build it.
The final whole-branch review of feat(http)!: named security schemes, OpenAPI's model #89 flagged it as a Minor: "either build the
check or record in packages/http/CLAUDE.md that it was dropped, so the next
reader does not go looking for it." Neither happened.
A later /code-review pass raised it again, independently, as medium.
Promised by the named-security-schemes design, dropped in implementation, and
raised independently by two reviews since. Split out of #89 rather than fixed
there, because it is a type-level feature rather than a defect in what shipped.
The hazard
Nothing ties a contract's declared scope strings to the vocabulary its
scheme's authenticator can actually grant.
That compiles. It passes
format,lint,typecheck,knip,testandbuild— all six. Then every caller on that route gets a permanent 403:principalMiddlewarecompares the required scope against what the credentialgranted,
"order:export"is never in the list, and the requirement can never besatisfied. No diagnostic anywhere, at any point.
The failure direction is closed, which is why this is P1 and not urgent. But it
is a route that is unreachable by construction and says nothing about it.
Why it is absent
SchemesFromkeeps only the principal and drops the vocabulary(
packages/http/src/define-http.ts:11):Authenticator<P, Scope, N>does carryscope(packages/http/src/auth.ts),and it is kept deliberately for this — a ponytail pass flagged it as a phantom
field nothing reads, and it stays precisely because this check is the thing that
will read it.
Where the check belongs
routerFor<Schemes, Auth>(authenticators)(contract)(
packages/http/src/orpc.ts:143) is where the contract and the registry meet —the same call that already refuses a scheme name the registry has no key for.
It needs the vocabulary alongside the principal, so
SchemesFromgrows a secondprojection (or
Http<A>carries the registry whole and the router reads both).The shape, roughly: for every requirement in the contract, every scope string it
names under scheme
Kmust extendA[K]["scope"].SchemesOfalreadyflattens requirements to scheme names; this is the sibling that flattens them to
scope strings, compared per scheme.
What it must not cost
HttpAuthenticator<Identity>(),Scope = never)paired with a requirement declaring no scopes must stay exactly as cheap as
it is now. That is the common case and it currently pays nothing.
gates this package already ships name their port, and the one that does not —
di's arity gate — is the one this repo keeps having to explain.
Acceptance
router is minted, with the scope in the message.
@ts-expect-errorpinning that, alongside the existing scheme-name-typo arm.packages/http/CLAUDE.mdstates the check and what it does not cover.History, so it is not dropped a third time
check or record in
packages/http/CLAUDE.mdthat it was dropped, so the nextreader does not go looking for it." Neither happened.
/code-reviewpass raised it again, independently, as medium.Three sightings, one issue.