Skip to content

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

Description

@btravers

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 scope
export const userAuth = HttpAuthenticator<Identity, "orders:export">()({});
export const api = defineHttp({ authenticators: { user: userAuth } });

// the contract asks for one it can never grant — a typo
export const contract = {
  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):

export type SchemesFrom<A extends Authenticators> = { readonly [K in keyof A]: A[K]["principal"] };

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.

Three sightings, one issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Should land before 1.0 — real DX cost, or a decision blocking other work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions