Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/scope-vocabulary-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
"@btravstack/contract": minor
"@btravstack/di": minor
"@btravstack/config": minor
"@btravstack/core": minor
"@btravstack/testing": minor
"@btravstack/observability": minor
"@btravstack/http": minor
"@btravstack/temporal": minor
"@btravstack/amqp": minor
---

A contract may name a scope only if its scheme can grant it

`HttpRouter(contract)` now refuses a contract declaring a scope outside the
vocabulary its scheme's authenticator was minted with, and the diagnostic ends
on the offending scope:

```
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"; }'
```

Before this, nothing tied a contract's scope **strings** to what a scheme could
actually grant. A typo — or a scope asked of a scheme declared with no
vocabulary at all — compiled, passed every check, and then refused every caller
on that route with a permanent `403` and no diagnostic anywhere.

A requirement naming no scopes costs nothing, which is the common case. The
check is the sibling of the scheme-**name** check di already performs by leaving
an unknown scheme's port unmet.
32 changes: 32 additions & 0 deletions packages/http/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,38 @@ InstanceType<D[keyof D]>> & { readonly port: PortClassOf<Name, Implementation<C,
`noAuthenticator` — the fail-closed stand-in the single-scheme design needed
— is gone: there is no "marked but unwired" state left for it to cover.

- **A contract may name a scope only if the scheme's authenticator can grant
it.** `routerFor` intersects `ScopeGate<C, Vocab>` onto its `contract`
parameter — `unknown` when satisfied, an object with one required property
when not, which is what makes the diagnostic end on the offending scope
(measured: `… "UNGRANTABLE SCOPE — its scheme's authenticator cannot grant
it": "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.

Two cases it catches, and both used to be silent (#90): a typo, and a scope
asked of a scheme declared with no vocabulary at all — `Scope = never`, so
everything is ungrantable. Both compiled, passed all six gate commands, and
then refused every caller on that route with a permanent 403 and no
diagnostic anywhere. It is the sibling of the scheme-NAME check, which di
performs already by leaving an unknown scheme's port unmet — and the two do
NOT overlap: a scheme the registry does not know is skipped by this gate
entirely, so a misspelled scheme naming scopes reports the port it cannot
discharge (`PortInstance<"HttpAuthenticator:usre", …>`) rather than a scope
complaint. Treating an unknown scheme as an empty vocabulary made every scope
it named ungrantable, which was the wrong diagnostic AND the earlier one,
since this gate sits on the router mint and the unmet port on the composition
root.

A requirement naming no scopes contributes `never` and costs nothing, which
is the common case. One shape inside is load-bearing: `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. Measured; do
not "simplify" it back to `R[I][K & keyof R[I]]`.

- **The scheme dependencies are read off the contract, and the two halves must
agree — a disagreement is an auth bypass.** `routerOf` walks the
**contract** alongside the implementer, carrying an `inherited` requirements
Expand Down
50 changes: 50 additions & 0 deletions packages/http/src/auth.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,53 @@ HttpAuthenticator<{ readonly userId: string }, "orders:export">()({

expectTypeOf(plain.principal).toEqualTypeOf<{ readonly userId: string }>();
expectTypeOf(scoped.scope).toEqualTypeOf<"orders:export">();

// ---------------------------------------------------------------------------
// The scope-vocabulary gate (#90). A contract may name a scope only if the
// scheme's own authenticator can grant it — otherwise the route compiles, passes
// every gate command, and then 403s every caller forever with no diagnostic.
// ---------------------------------------------------------------------------

const scopedApi = defineHttp({
authenticators: {
user: HttpAuthenticator<Identity, "orders:export">()({
sync: () => () => OkAsync(granted({ userId: "u", tenantId: "t" }, ["orders:export"])),
}),
// No vocabulary at all: this scheme can grant nothing.
service: HttpAuthenticator<ServiceIdentity>()({
sync: () => () => OkAsync({ appId: "a" }),
}),
},
});

// Positive: the declared vocabulary is accepted.
void scopedApi.HttpRouter(authenticated({ user: ["orders:export"] })({ csv: oc }))({
sync: () => ({ csv: () => OkAsync(undefined) }),
});

// Positive, and the case that must stay free: no scopes named at all.
void scopedApi.HttpRouter(authenticated({ user: [] })({ csv: oc }))({
sync: () => ({ csv: () => OkAsync(undefined) }),
});

// Negative: a typo. `"order:export"` is not in the vocabulary.
void scopedApi.HttpRouter(
// @ts-expect-error — UNGRANTABLE SCOPE: "order:export" is not one `user` can grant
authenticated({ user: ["order:export"] })({ csv: oc }),
)({ sync: () => ({ csv: () => OkAsync(undefined) }) });

// Negative: a scope named for a scheme whose authenticator declares no vocabulary.
void scopedApi.HttpRouter(
// @ts-expect-error — UNGRANTABLE SCOPE: `service` grants nothing
authenticated({ service: ["reports:read"] })({ csv: oc }),
)({ sync: () => ({ csv: () => OkAsync(undefined) }) });

// A misspelled SCHEME naming scopes is not this gate's to report. The router
// mint accepts it — di refuses the composition, naming the port it cannot
// discharge, which is the diagnostic that says what is actually wrong.
const misspelledScheme = scopedApi.HttpRouter(
authenticated({ usre: ["orders:export"] })({ csv: oc }),
)({ sync: () => ({ csv: () => OkAsync(undefined) }) });

// @ts-expect-error — UNDECLARED NEEDS: nothing discharges `HttpAuthenticator:usre`
void HttpModule("Misspelled")({ needs: [Env], router: misspelledScheme });
13 changes: 11 additions & 2 deletions packages/http/src/define-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export type Authenticators = Readonly<Record<string, Authenticator<unknown, stri
/** The scheme registry, read off the authenticators rather than declared twice. */
export type SchemesFrom<A extends Authenticators> = { readonly [K in keyof A]: A[K]["principal"] };

/**
* What each scheme can grant, read off the same authenticators. Separate from
* `SchemesFrom` because they answer different questions at different call
* sites: the principal types the handler, the vocabulary checks the contract.
*/
export type VocabFrom<A extends Authenticators> = { readonly [K in keyof A]: A[K]["scope"] };

/**
* One di provider per scheme, on the port whose id carries that scheme's name,
* and carrying that authenticator's own dependencies in its needs channel — so
Expand All @@ -33,7 +40,9 @@ type SchemeProviders<A extends Authenticators> = {
*/
export type Http<A extends Authenticators> = {
readonly HttpController: ReturnType<typeof controllerFor<SchemesFrom<A>>>;
readonly HttpRouter: ReturnType<typeof routerFor<SchemesFrom<A>, SchemeProviders<A>>>;
readonly HttpRouter: ReturnType<
typeof routerFor<SchemesFrom<A>, SchemeProviders<A>, VocabFrom<A>>
>;
readonly authenticators: A;
};

Expand Down Expand Up @@ -65,7 +74,7 @@ export const defineHttp = <const A extends Authenticators = Record<never, never>
);
return {
HttpController: controllerFor<SchemesFrom<A>>(),
HttpRouter: routerFor<SchemesFrom<A>, SchemeProviders<A>>(providers as never),
HttpRouter: routerFor<SchemesFrom<A>, SchemeProviders<A>, VocabFrom<A>>(providers as never),
authenticators: declared as A,
};
};
Expand Down
66 changes: 64 additions & 2 deletions packages/http/src/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,10 @@ type Built<Auth, N> = Provider<
};

export const routerFor =
<Schemes, Auth extends AnyProvider = never>(authenticators: readonly Auth[]) =>
<C extends Record<string, RouterContract>>(contract: C) => {
<Schemes, Auth extends AnyProvider = never, Vocab = Record<never, never>>(
authenticators: readonly Auth[],
) =>
<C extends Record<string, RouterContract>>(contract: C & ScopeGate<C, Vocab>) => {
// The implementer is walked untyped: `Implementation<C>` above is the
// whole check — a key the contract does not declare is a compile error
// there, and `routerOf` skips one anyway rather than reading `.result` off
Expand Down Expand Up @@ -354,6 +356,66 @@ type AllRequirementsOf<C> =
/** Distributes `SchemesOf` over the union of requirement tuples the walk collected. */
type SchemesIn<R> = R extends Requirements ? SchemesOf<R> : never;

/**
* Every scope string the contract names for scheme `K`, across every
* requirement the walk collected. A requirement that names no scopes
* contributes `never`, so the common case reaches the gate below with nothing
* to check and costs it nothing.
*/
type ScopesIn<R, K extends string> = R extends Requirements
? {
// `K extends keyof R[I]` first, and not `R[I][K & keyof R[I]]`: indexing a
// requirement that does not name `K` gives `never`, and inferring `S`
// from `never` falls back to its CONSTRAINT — `string` — so every scope
// looked grantable the moment two requirements named different schemes
// (measured).
[I in keyof R]: K extends keyof R[I]
? R[I][K] extends readonly (infer S extends string)[]
? S
: never
: never;
}[number]
: never;

/**
* A scope the contract names that its scheme's authenticator cannot grant —
* a typo, or a scope asked of a scheme declared with no vocabulary at all
* (`Scope = never`, so everything is ungrantable).
*/
type Ungrantable<C, Vocab> = {
// A scheme the registry does not know is NOT this gate's to report: it is
// already di's, which leaves `HttpAuthenticator:<scheme>` unmet and names the
// port. Treating an unknown scheme as a `never` vocabulary made every scope it
// named ungrantable, so a misspelled SCHEME surfaced as a scope complaint —
// the wrong diagnostic, and earlier than the right one, since this gate sits
// on the router mint and the unmet port on the composition root.
[K in SchemesIn<AllRequirementsOf<C>>]: K extends keyof Vocab
? Exclude<ScopesIn<AllRequirementsOf<C>, K>, Vocab[K]>
: never;
}[SchemesIn<AllRequirementsOf<C>>];

/**
* The scope half of what `routerFor` checks, and the sibling of the scheme-name
* check di already performs by leaving an unknown scheme's port unmet. Nothing
* ties a contract's scope STRINGS to a scheme's vocabulary otherwise: the route
* compiles, passes every gate command, and then refuses every caller with a
* permanent 403 and no diagnostic anywhere (#90).
*
* It rides an intersection on the `contract` parameter — `unknown` when
* satisfied, so the parameter type is untouched — and its failure branch is an
* object with one required property, because that is what makes the diagnostic
* name the offending scope rather than restate the contract (the same shape
* di's `NeedsGate` uses, and for the same measured reason).
*/
type ScopeGate<C, Vocab> = [Ungrantable<C, Vocab>] extends [never]
? unknown
: {
readonly "UNGRANTABLE SCOPE — its scheme's authenticator cannot grant it": Ungrantable<
C,
Vocab
>;
};

/**
* One port instance per scheme the contract names, as the router's needs
* channel. The naked `S` distributes, so two schemes are two distinct port
Expand Down
Loading