From 3bf403638a03dec89e15bc845644469969824a65 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 16:19:19 +0200 Subject: [PATCH 1/2] feat(http)!: a contract may name a scope only if its scheme can grant it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 reads the vocabulary off the same authenticators SchemesFrom 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. --- .changeset/scope-vocabulary-gate.md | 32 +++++++++++++++ packages/http/CLAUDE.md | 25 ++++++++++++ packages/http/src/auth.test-d.ts | 40 +++++++++++++++++++ packages/http/src/define-http.ts | 13 +++++- packages/http/src/orpc.ts | 61 ++++++++++++++++++++++++++++- 5 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 .changeset/scope-vocabulary-gate.md diff --git a/.changeset/scope-vocabulary-gate.md b/.changeset/scope-vocabulary-gate.md new file mode 100644 index 0000000..c27bd99 --- /dev/null +++ b/.changeset/scope-vocabulary-gate.md @@ -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. diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 70147e1..8564601 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -366,6 +366,31 @@ InstanceType> & { readonly port: PortClassOf` 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` reads the vocabulary off the same + authenticators `SchemesFrom` 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. + + 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 diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 3683d04..fca7748 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -257,3 +257,43 @@ 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()({ + sync: () => () => OkAsync(granted({ userId: "u", tenantId: "t" }, ["orders:export"])), + }), + // No vocabulary at all: this scheme can grant nothing. + service: HttpAuthenticator()({ + 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) }) }); diff --git a/packages/http/src/define-http.ts b/packages/http/src/define-http.ts index d0efde2..f93d6c8 100644 --- a/packages/http/src/define-http.ts +++ b/packages/http/src/define-http.ts @@ -10,6 +10,13 @@ export type Authenticators = Readonly = { 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 = { 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 @@ -33,7 +40,9 @@ type SchemeProviders = { */ export type Http = { readonly HttpController: ReturnType>>; - readonly HttpRouter: ReturnType, SchemeProviders>>; + readonly HttpRouter: ReturnType< + typeof routerFor, SchemeProviders, VocabFrom> + >; readonly authenticators: A; }; @@ -65,7 +74,7 @@ export const defineHttp = ); return { HttpController: controllerFor>(), - HttpRouter: routerFor, SchemeProviders>(providers as never), + HttpRouter: routerFor, SchemeProviders, VocabFrom>(providers as never), authenticators: declared as A, }; }; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 3a6a96a..7e930e6 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -141,8 +141,10 @@ type Built = Provider< }; export const routerFor = - (authenticators: readonly Auth[]) => - >(contract: C) => { + >( + authenticators: readonly Auth[], + ) => + >(contract: C & ScopeGate) => { // The implementer is walked untyped: `Implementation` 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 @@ -354,6 +356,61 @@ type AllRequirementsOf = /** Distributes `SchemesOf` over the union of requirement tuples the walk collected. */ type SchemesIn = R extends Requirements ? SchemesOf : 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 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 = { + [K in SchemesIn>]: Exclude< + ScopesIn, K>, + K extends keyof Vocab ? Vocab[K] : never + >; +}[SchemesIn>]; + +/** + * 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 = [Ungrantable] 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 From c4ccf71db133997852bb5f76ffdb0430b0a0cc7c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 22:16:43 +0200 Subject: [PATCH 2/2] fix(http): an unknown scheme is di's to report, not the scope gate's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/http/CLAUDE.md | 9 ++++++++- packages/http/src/auth.test-d.ts | 10 ++++++++++ packages/http/src/orpc.ts | 13 +++++++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 8564601..d4ad2ae 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -381,7 +381,14 @@ it": "order:export"`). `VocabFrom` reads the vocabulary off the same 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. + 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 diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index fca7748..bd8b6dd 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -297,3 +297,13 @@ 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 }); diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 7e930e6..169b314 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -383,10 +383,15 @@ type ScopesIn = R extends Requirements * (`Scope = never`, so everything is ungrantable). */ type Ungrantable = { - [K in SchemesIn>]: Exclude< - ScopesIn, K>, - K extends keyof Vocab ? Vocab[K] : never - >; + // A scheme the registry does not know is NOT this gate's to report: it is + // already di's, which leaves `HttpAuthenticator:` 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>]: K extends keyof Vocab + ? Exclude, K>, Vocab[K]> + : never; }[SchemesIn>]; /**