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..d4ad2ae 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -366,6 +366,38 @@ 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 — 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 diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 3683d04..bd8b6dd 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -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()({ + 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) }) }); + +// 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/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..169b314 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,66 @@ 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 = { + // 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>]; + +/** + * 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