From 202260706525e9e8f843647ef3e6e10c142c6e74 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:17:59 +0200 Subject: [PATCH 01/18] feat(contract)!: the marker carries OpenAPI requirements, not a boolean --- packages/contract/CLAUDE.md | 103 +++++++++++++++++---------- packages/contract/src/auth.spec.ts | 58 +++++++-------- packages/contract/src/auth.test-d.ts | 29 ++++++-- packages/contract/src/auth.ts | 81 ++++++++++++++------- packages/contract/src/index.ts | 3 + 5 files changed, 171 insertions(+), 103 deletions(-) diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index 793661f0..03249f4c 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -9,30 +9,50 @@ ships no `docs-examples.test-d.ts`, so nothing else compiles these claims. ## What this is A marker a contract puts on a node — a record of procedures or a single -procedure — to say "this requires an authenticated principal", readable by -both the client that imports the contract and the server that implements it. -Nothing here talks to oRPC, HTTP, AMQP or Temporal; it is a plain object -marker over `WeakSet` identity, transport-agnostic by construction. +procedure — to say "this requires an authenticated principal, satisfying one +of these requirements", readable by both the client that imports the contract +and the server that implements it. A requirement is OpenAPI's own shape: a +security scheme name and the scopes it must grant. Nothing here talks to +oRPC, HTTP, AMQP or Temporal; it is a plain object marker over `WeakMap` +identity, transport-agnostic by construction. ## Public surface -- **`authenticated(node)`** (`auth.ts`) — `(node: T) => -Authenticated`. One export, no factory and no type parameter: apply it to - a record of procedures (protects every procedure beneath it) or to a single - procedure (protects itself). -- **`Authenticated`** — `T & { readonly [PrincipalKey]: true }`. The typed - shape a marked node carries — `T`'s own keys plus one phantom key that - exists only for the type checker. +- **`authenticated(...requirements)(node)`** (`auth.ts`) — curried: + `(...requirements: R) => (node: T) => +Authenticated`. Call it with one or more `Requirement`s to get back a + function that marks a node with them, in the order given. Apply it to a + record of procedures (the **default** for every procedure beneath it) or to + a single procedure (which **replaces** that default for itself — nearest + mark wins). +- **`Requirement`** — `Readonly>`, e.g. + `{ user: ["orders:export"] }`: one security scheme's name mapped to the + scopes it must grant. Names exactly one scheme deliberately — + AND-within-a-requirement is not modelled, because that would put a record + rather than a single identity on the handler, and a handler wants to know + which scheme authenticated the caller, not juggle several at once. +- **`Requirements`** — `readonly Requirement[]`. Several requirements on one + mark are **ORed**, tried in declaration order: the first the caller + satisfies wins. +- **`Authenticated`** — `T & { readonly [PrincipalKey]: R }`. The typed + shape a marked node carries — `T`'s own keys plus one phantom key, holding + the exact `Requirements` it was marked with, that exists only for the type + checker. - **`PrincipalKey`** — `typeof PRINCIPAL`, the marker's key. Exported so a consumer's own mapped type can `Exclude` and land on exactly the contract's own keys. -- **`IsMarked`** — `T extends { readonly [PrincipalKey]: true } ? true : -false`. Whether this exact node carries the marker. A **yes/no**, not a type: - a consumer reads it to decide whether to inject a principal, never to learn - what one is. -- **`isAuthenticated(node: object): boolean`** — whether this exact node was - marked. Ancestry (a marked parent implying a marked child) is the caller's - to carry; the package tracks nodes, not trees. +- **`IsMarked`** — `T extends { readonly [PrincipalKey]: Requirements } ? +true : false`. Whether this exact node carries the marker. A **yes/no**, not + a type: a consumer reads it to decide whether to inject a principal, never + to learn what one is. +- **`RequirementsOf`** — `T extends { readonly [PrincipalKey]: infer R +extends Requirements } ? R : never`. What this exact node's mark requires, at + the type level — `never` for an unmarked node. +- **`isAuthenticated(node: object): Requirements | undefined`** — what this + exact node requires, or `undefined` when nobody marked it. `undefined`, not + an empty array, so a caller cannot confuse "public" with "protected by + nothing satisfiable". Ancestry (a marked parent implying a marked child) is + the caller's to carry; the package tracks nodes, not trees. ## The contract says whether; the application says what @@ -57,11 +77,12 @@ contract reuse the exact same `authenticated` marker — the marker has no opinion about which transport reads it. **The combinator returns the node unchanged and sets no property on it.** -`authenticated(node)` returns the same reference (`=== `) with nothing added -to it — `PRINCIPAL` is `declare`d, never assigned, so it exists only in the -type system. There is no key for oRPC's `implement()` to walk as a -procedure, and nothing for its builders to strip. The marker lives in a -`WeakSet`, keyed by identity. +`authenticated(...requirements)(node)` returns the same reference (`===`) +with nothing added to it — `PRINCIPAL` is `declare`d, never assigned, so it +exists only in the type system. There is no key for oRPC's `implement()` to +walk as a procedure, and nothing for its builders to strip. The marker lives +in a `WeakMap`, keyed by identity, mapping each node to the `Requirements` it +was marked with. Identity is exactly why a consumer takes this package as a **peer** rather than an ordinary dependency — `@btravstack/http` and @@ -69,16 +90,21 @@ than an ordinary dependency — `@btravstack/http` and hold their own registry, a contract marked by one would read unmarked to the other, `HttpRouter` would declare no authenticator need and the protected route would be served **open**. So the registry is copy-proof: it hangs off -`globalThis` under `Symbol.for("@btravstack/contract/marked")`, and every copy -shares the one `WeakSet`. A stray second copy then degrades to a compile -error — the two copies' `PRINCIPAL` symbols are different `unique symbol`s — -rather than to a silently unprotected route. +`globalThis` under `Symbol.for("@btravstack/contract/requirements")`, and +every copy shares the one `WeakMap`. The key changed from the earlier +`.../marked` — it named a `WeakSet` of marked nodes; naming it `requirements` +prevents a stale copy expecting a `WeakSet` from calling `.has()` on the new +`WeakMap` and getting an accidentally-correct `true` back, which would have +masked the version mismatch instead of failing closed. A stray second copy +now degrades to a compile error — the two copies' `PRINCIPAL` symbols are +different `unique symbol`s — rather than to a silently unprotected route. `PRINCIPAL` is `declare`d and **never exported as a value**, and must stay -that way — but be precise about what that buys. It stops the brand being +that way — but be precise about what that buys. It stops the mark being applied by accident or written literally; it does **not** make it unforgeable. -`Authenticated` is exported, because `@btravstack/http`'s `Inherit` needs -it, so a deliberate `node as unknown as Authenticated` types as +`Authenticated` is exported, because `@btravstack/http`'s `Inherit` +needs it, so a deliberate +`node as unknown as Authenticated` types as protected while the registry stays empty: `HasMark` answers `true` and `HttpModule` demands an authenticator, `hasMarked` answers `false` and `routerOf` installs no middleware, and the leaf serves unauthenticated. It @@ -96,15 +122,16 @@ builder has to know the marker exists or preserve it through its own chain. ## Specs `vitest run --coverage`, 100% lines/functions, 5 tests in one file, -`auth.spec.ts`: marking returns the same reference and a readable marker, no -enumerable key is added, an unmarked node reads as unmarked, the mark lands in -the `globalThis` registry a second copy would read, and two contracts' markers -stay independent. `test-fixtures.ts` provides a one-key `fragment` as a lazy -fixture. `auth.test-d.ts` pins the type side: the phantom key excludes cleanly -out of `keyof`, `IsMarked` is **exactly** `true` / `false` (asserted both +`auth.spec.ts`: marking returns the same reference and readable requirements, +several requirements survive in the order given, no enumerable key is added, +an unmarked node reads as `undefined`, and the mark lands in the `globalThis` +registry a second copy would read. `test-fixtures.ts` provides a one-key +`fragment` as a lazy fixture. `auth.test-d.ts` pins the type side: the phantom +key excludes cleanly out of `keyof`, `IsMarked` is **exactly** `true` / `false` (asserted both directions — a `boolean` result would satisfy assignability to either), a -marked node still satisfies the plain shape, and a plain one does not satisfy -the marked shape. +marked node still satisfies the plain shape, a plain one does not satisfy the +marked shape, and `RequirementsOf` reads the exact requirements back for a +marked node and is `never` for an unmarked one. ## Deferred, deliberately diff --git a/packages/contract/src/auth.spec.ts b/packages/contract/src/auth.spec.ts index e4101d5c..0fcb860c 100644 --- a/packages/contract/src/auth.spec.ts +++ b/packages/contract/src/auth.spec.ts @@ -4,59 +4,53 @@ import { authenticated, isAuthenticated } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("authenticated", () => { - it("marks the node it is given", ({ fragment }) => { + it("returns the requirements it was marked with", ({ fragment }) => { // GIVEN an unmarked contract fragment - // WHEN it is marked - const marked = authenticated(fragment); - // THEN the marker is readable, and the value came back unchanged - expect({ marked: isAuthenticated(marked), same: marked === fragment }).toEqual({ - marked: true, + // WHEN it is marked with one requirement + const marked = authenticated({ user: [] })(fragment); + // THEN the requirements are readable, and the value came back unchanged + expect({ requirements: isAuthenticated(marked), same: marked === fragment }).toEqual({ + requirements: [{ user: [] }], same: true, }); }); + it("keeps every requirement, in the order given", ({ fragment }) => { + // GIVEN a fragment + // WHEN it is marked with two requirements and a scope + authenticated({ user: ["orders:export"] }, { service: [] })(fragment); + // THEN both survive, in order — the runtime tries them in this order + expect(isAuthenticated(fragment)).toEqual([{ user: ["orders:export"] }, { service: [] }]); + }); + it("adds no enumerable key", ({ fragment }) => { // GIVEN a fragment with exactly one key // WHEN it is marked - const marked = authenticated(fragment); + authenticated({ user: [] })(fragment); // THEN nothing was added for `implement()` to walk as a procedure - expect(Reflect.ownKeys(marked)).toEqual(["place"]); + expect(Reflect.ownKeys(fragment)).toEqual(["place"]); }); - it("leaves an unmarked node unmarked", ({ fragment }) => { + it("answers undefined for a node nobody marked", ({ fragment }) => { // GIVEN a fragment nobody marked // WHEN it is asked - // THEN it is not authenticated - expect(isAuthenticated(fragment)).toBe(false); + // THEN it is not authenticated — `undefined`, not an empty list, so a + // caller cannot confuse "public" with "protected by nothing" + expect(isAuthenticated(fragment)).toBeUndefined(); }); it("registers the mark where a second copy of this package would find it", ({ fragment }) => { // GIVEN the registry as any other copy of this package would reach it - const registry = (globalThis as Record | undefined>)[ - Symbol.for("@btravstack/contract/marked") + const registry = (globalThis as Record | undefined>)[ + Symbol.for("@btravstack/contract/requirements") ]; // WHEN a node is marked - authenticated(fragment); - // THEN that shared registry is the one holding it — a module-private set + authenticated({ user: [] })(fragment); + // THEN that shared registry is the one holding it — a module-private map // here would read unmarked to a second copy, and serve the route open. - // Projected rather than optional-chained: `registry?.has(...)` reads - // `undefined` for a MISSING registry and for one that does not hold the - // node alike, and an absent registry is the failure this pins. - expect({ registered: registry !== undefined, holds: registry?.has(fragment) }).toEqual({ + expect({ registered: registry !== undefined, holds: registry?.get(fragment) }).toEqual({ registered: true, - holds: true, - }); - }); - - it("keeps two contracts' markers independent", ({ fragment }) => { - // GIVEN two nodes, one marked - const other = { find: { kind: "procedure" } as const }; - // WHEN only the first is marked - authenticated(fragment); - // THEN the second is untouched - expect({ first: isAuthenticated(fragment), second: isAuthenticated(other) }).toEqual({ - first: true, - second: false, + holds: [{ user: [] }], }); }); }); diff --git a/packages/contract/src/auth.test-d.ts b/packages/contract/src/auth.test-d.ts index dd6f01da..702296d7 100644 --- a/packages/contract/src/auth.test-d.ts +++ b/packages/contract/src/auth.test-d.ts @@ -1,13 +1,16 @@ import { describe, test } from "vitest"; -import type { Authenticated, IsMarked, PrincipalKey } from "./auth.js"; +import type { Authenticated, IsMarked, PrincipalKey, RequirementsOf } from "./auth.js"; type Fragment = { readonly place: { readonly kind: "procedure" } }; type Expect = T; describe("Authenticated carries the contract's own keys plus the phantom one", () => { test("Exclude, PrincipalKey> is exactly keyof T", () => { - const same = null as unknown as Exclude, PrincipalKey>; + const same = null as unknown as Exclude< + keyof Authenticated, + PrincipalKey + >; const fragmentKey: keyof Fragment = same; void fragmentKey; }); @@ -15,8 +18,8 @@ describe("Authenticated carries the contract's own keys plus the phantom one", ( test("IsMarked is exactly true for a marked node", () => { // Both directions: `boolean` would satisfy assignability to `true` alone. const exact = null as unknown as Expect< - [IsMarked>] extends [true] - ? [true] extends [IsMarked>] + [IsMarked>] extends [true] + ? [true] extends [IsMarked>] ? true : false : false @@ -36,7 +39,7 @@ describe("Authenticated carries the contract's own keys plus the phantom one", ( }); test("a marked node still satisfies the plain contract shape", () => { - const marked = null as unknown as Authenticated; + const marked = null as unknown as Authenticated; const plain: Fragment = marked; void plain; }); @@ -44,7 +47,21 @@ describe("Authenticated carries the contract's own keys plus the phantom one", ( test("a plain node does not satisfy the marked shape", () => { const plain = null as unknown as Fragment; // @ts-expect-error a plain node carries no [PrincipalKey] - const marked: Authenticated = plain; + const marked: Authenticated = plain; void marked; }); + + test("RequirementsOf reads the requirements back", () => { + const same = null as unknown as RequirementsOf< + Authenticated + >; + const asWritten: readonly [{ readonly user: readonly [] }] = same; + void asWritten; + }); + + test("RequirementsOf is never for an unmarked node", () => { + const none = null as unknown as RequirementsOf; + const isNever: never = none; + void isNever; + }); }); diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index fd4ba39d..aa072fb5 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -1,48 +1,75 @@ -// Never exported as a value, so the brand cannot be applied by accident and -// cannot be written literally. It is not unforgeable: `Authenticated` is +// Never exported as a value, so the mark cannot be applied by accident and +// cannot be written literally. It is not unforgeable: `Authenticated` is // exported (`@btravstack/http` needs it), so a deliberate -// `node as unknown as Authenticated` types as protected while the -// registry stays empty — no middleware installed, and a handler reading a -// principal nothing injected. Exporting the symbol would drop the cast. +// `node as unknown as Authenticated` types as +// protected while the registry stays empty. Exporting the symbol would drop +// the cast — and would also cost every consumer an annotation: +// an inferred exported type that references an inaccessible unique symbol is +// TS2527, which is why `@btravstack/http` hands back ONE nameable object. declare const PRINCIPAL: unique symbol; +/** + * One OpenAPI security requirement: a scheme, and the scopes it must grant. + * A requirement names ONE scheme — this package does not model OpenAPI's + * AND-within-a-requirement, which would put a record rather than an identity + * on the handler. See the design spec. + */ +export type Requirement = Readonly>; + +/** Requirements are ORed, in order: the first one a caller satisfies wins. */ +export type Requirements = readonly Requirement[]; + /** A contract node whose procedures require an authenticated caller. */ -export type Authenticated = T & { readonly [PRINCIPAL]: true }; +export type Authenticated = T & { readonly [PRINCIPAL]: R }; /** The marker's key, so a consumer's mapped type can `Exclude` it from `keyof`. */ export type PrincipalKey = typeof PRINCIPAL; /** Whether this exact node carries the marker. */ -export type IsMarked = T extends { readonly [PRINCIPAL]: true } ? true : false; +export type IsMarked = T extends { readonly [PRINCIPAL]: Requirements } ? true : false; -// On `globalThis`, not module-private: two copies each with their own set read +/** What this exact node requires, or `never` when it is unmarked. */ +export type RequirementsOf = T extends { readonly [PRINCIPAL]: infer R extends Requirements } + ? R + : never; + +// On `globalThis`, not module-private: two copies each with their own map read // every node the other marked as unmarked, and a protected route serves open. -const KEY: unique symbol = Symbol.for("@btravstack/contract/marked"); -const store = globalThis as unknown as { [KEY]?: WeakSet }; -const marked = (store[KEY] ??= new WeakSet()); +// The key names `requirements`, not `marked`: a copy expecting the old +// `WeakSet` would call `.has` on a `WeakMap` and get `true`, which is +// accidentally correct and not worth relying on. Under this key a mismatched +// copy reads unmarked and fails closed. +const KEY: unique symbol = Symbol.for("@btravstack/contract/requirements") as never; +const store = globalThis as unknown as { [KEY]?: WeakMap }; +const marked = (store[KEY] ??= new WeakMap()); /** - * Marks a contract node as requiring an authenticated caller — a record - * protects every procedure beneath it, a procedure protects itself. + * Marks a contract node as requiring an authenticated caller, with OpenAPI's + * own requirement shape — a scheme and the scopes it must grant, ORed in the + * order given. * * ```ts * export const contract = { - * orders: authenticated({ place, find }), - * customers: { find, quote: authenticated(oc.input(…).output(…)) }, + * orders: authenticated({ user: [] })({ place, find }), + * exports: authenticated({ user: ["orders:export"] }, { service: [] })(csvProcedure), * }; * ``` * - * Returns the node unchanged and applies after a builder chain, never inside - * one. See `packages/contract/CLAUDE.md`. + * Applied to a record it is the default for every procedure beneath it; + * applied to a procedure it replaces that default for itself. Nearest mark + * wins. Returns the node unchanged and applies after a builder chain, never + * inside one. See `packages/contract/CLAUDE.md`. */ -export const authenticated = (node: T): Authenticated => { - marked.add(node); - return node as Authenticated; -}; - -/** Whether this exact node was marked. Ancestry is the caller's to carry. */ -export const isAuthenticated = (node: object): boolean => marked.has(node); +export const authenticated = + (...requirements: R) => + (node: T): Authenticated => { + marked.set(node, requirements); + return node as Authenticated; + }; -// ponytail: opt-in by construction — an unmarked node is public, and forgetting -// the marker fails nothing. Deny-by-default is three lines away: mark the root -// and add `public(node)` that deletes it from the set. +/** + * What this exact node requires, or `undefined` when nobody marked it. + * Ancestry is the caller's to carry — `@btravstack/http`'s `routerOf` walks + * the tree and passes the nearest mark down. + */ +export const isAuthenticated = (node: object): Requirements | undefined => marked.get(node); diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index b12a8f3e..4e811ab6 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -4,4 +4,7 @@ export { type Authenticated, type IsMarked, type PrincipalKey, + type Requirement, + type Requirements, + type RequirementsOf, } from "./auth.js"; From 55bcbf6e4db9615629a4ef45fac332bd79d804a1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:26:35 +0200 Subject: [PATCH 02/18] feat(http): the principal is bare for one scheme and tagged for many --- packages/http/src/principal.test-d.ts | 67 +++++++++++++++++++++++++++ packages/http/src/principal.ts | 33 +++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 packages/http/src/principal.test-d.ts create mode 100644 packages/http/src/principal.ts diff --git a/packages/http/src/principal.test-d.ts b/packages/http/src/principal.test-d.ts new file mode 100644 index 00000000..321a63ea --- /dev/null +++ b/packages/http/src/principal.test-d.ts @@ -0,0 +1,67 @@ +import { describe, test } from "vitest"; + +import type { Principal, SchemesOf } from "./principal.js"; + +type Schemes = { + readonly user: { readonly userId: string; readonly tenantId: string }; + readonly service: { readonly appId: string }; +}; + +describe("Principal", () => { + test("one scheme is the identity, bare", () => { + const one = null as unknown as Principal<"user", Schemes>; + const tenantId: string = one.tenantId; + void tenantId; + }); + + test("two schemes are a tagged union, narrowed exhaustively", () => { + const many = null as unknown as Principal<"user" | "service", Schemes>; + const read = (): string => { + switch (many.scheme) { + case "user": + return many.identity.tenantId; + case "service": + return many.identity.appId; + } + }; + void read; + }); + + test("the one-scheme form is not tagged", () => { + const one = null as unknown as Principal<"user", Schemes>; + // @ts-expect-error -- a one-scheme principal has no `scheme` key + void one.scheme; + }); + + test("the many-scheme form is not bare", () => { + const many = null as unknown as Principal<"user" | "service", Schemes>; + // @ts-expect-error -- a two-scheme principal must be narrowed first + void many.tenantId; + }); + + test("a public leaf has nothing to read", () => { + const none = null as unknown as Principal; + // @ts-expect-error -- `never` has no properties + void none.userId; + }); + + test("a dropped switch arm is an error", () => { + const many = null as unknown as Principal<"user" | "service", Schemes>; + // @ts-expect-error -- not every path returns: "service" is unhandled + const read = (): string => { + switch (many.scheme) { + case "user": + return many.identity.tenantId; + } + }; + void read; + }); + + test("SchemesOf flattens requirements to their scheme names", () => { + const names = null as unknown as SchemesOf< + [{ readonly user: readonly ["orders:export"] }, { readonly service: readonly [] }] + >; + const asUnion: "user" | "service" = names; + void asUnion; + }); +}); diff --git a/packages/http/src/principal.ts b/packages/http/src/principal.ts new file mode 100644 index 00000000..459070fa --- /dev/null +++ b/packages/http/src/principal.ts @@ -0,0 +1,33 @@ +import type { Requirements } from "@btravstack/contract"; + +/** Every scheme any of a leaf's requirements names. */ +export type SchemesOf = keyof R[number] & string; + +// Distributes over `T`, then asks whether the whole union is assignable back +// into the one member being visited — false for a single member, true for a +// union. The standard test; do not "simplify" it to `T extends U`. +export type IsUnion = [T] extends [never] + ? false + : T extends U + ? [U] extends [T] + ? false + : true + : never; + +/** One arm per scheme, tagged by its name so a handler can switch on it. */ +export type Tagged = S extends S + ? { readonly scheme: S; readonly identity: S extends keyof Schemes ? Schemes[S] : never } + : never; + +/** + * What a leaf's handler reads. Bare when its requirements name one scheme — + * byte-for-byte what applications write today, so the common case pays nothing + * for the feature — and a discriminated union when they name several. + */ +export type Principal = [S] extends [never] + ? never + : IsUnion extends true + ? Tagged + : S extends keyof Schemes + ? Schemes[S] + : never; From 3b95be27c269c35de703be02708bd1b7a29a0fbe Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:35:24 +0200 Subject: [PATCH 03/18] fix(http): schemesOf is the union of scheme names, not their intersection --- packages/http/src/principal.test-d.ts | 12 +++++++++--- packages/http/src/principal.ts | 7 +++++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/http/src/principal.test-d.ts b/packages/http/src/principal.test-d.ts index 321a63ea..43e4ce9d 100644 --- a/packages/http/src/principal.test-d.ts +++ b/packages/http/src/principal.test-d.ts @@ -58,10 +58,16 @@ describe("Principal", () => { }); test("SchemesOf flattens requirements to their scheme names", () => { - const names = null as unknown as SchemesOf< + type Names = SchemesOf< [{ readonly user: readonly ["orders:export"] }, { readonly service: readonly [] }] >; - const asUnion: "user" | "service" = names; - void asUnion; + // Both directions. A one-way assignment out of `Names` passes even when + // `SchemesOf` collapses to `never`, since `never` is assignable to + // anything — which is how the first cut of this test missed a broken + // `SchemesOf` entirely. The assignment INTO `Names` is the half that bites. + const widen: "user" | "service" = null as unknown as Names; + const narrow: Names = null as unknown as "user" | "service"; + void widen; + void narrow; }); }); diff --git a/packages/http/src/principal.ts b/packages/http/src/principal.ts index 459070fa..f8eacc29 100644 --- a/packages/http/src/principal.ts +++ b/packages/http/src/principal.ts @@ -1,7 +1,10 @@ import type { Requirements } from "@btravstack/contract"; -/** Every scheme any of a leaf's requirements names. */ -export type SchemesOf = keyof R[number] & string; +// Mapped over the tuple, then indexed — NOT `keyof R[number]`, which is the +// INTERSECTION of each requirement's keys and so collapses to `never` the +// moment two requirements name different schemes. That is the multi-scheme +// case this type exists for, and it failed silently (measured). +export type SchemesOf = { [I in keyof R]: keyof R[I] & string }[number]; // Distributes over `T`, then asks whether the whole union is assignable back // into the one member being visited — false for a single member, true for a From 71e4ec2367ced117f3b45e22796b8ab38468c383 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:47:17 +0200 Subject: [PATCH 04/18] feat(http)!: an authenticator names a scheme and the scopes it grants --- packages/http/src/auth.test-d.ts | 27 +++++++ packages/http/src/auth.ts | 122 +++++++++++++++++++------------ 2 files changed, 101 insertions(+), 48 deletions(-) diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 8fde0c75..fea11393 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -7,6 +7,7 @@ import { start } from "@btravstack/core"; import { Module, Port, Provider } from "@btravstack/di"; import { oc } from "@orpc/contract"; import { ErrAsync, OkAsync } from "unthrown"; +import { expectTypeOf } from "vitest"; import { HttpAuthenticator, Unauthenticated } from "./auth.js"; import { HttpController } from "./controller.js"; @@ -313,3 +314,29 @@ void _scoped; void _strayScoped; void _verified; void _verifiedStray; + +// A scheme granting no scopes returns the identity bare — unchanged from what +// applications write today, which is the point. +const plain = HttpAuthenticator<{ readonly userId: string }>()({ + sync: () => () => OkAsync({ userId: "u-1" }), +}); + +// A scheme with a scope vocabulary reports what the credential granted. +const scoped = HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ + sync: () => () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:export"] }), +}); + +// Negative: a scoped scheme may not return a bare identity. +HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ + // @ts-expect-error -- a scoped scheme must report its granted scopes + sync: () => () => OkAsync({ userId: "u-1" }), +}); + +// Negative: a scope outside the declared vocabulary is refused. +HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ + // @ts-expect-error -- "orders:delete" is not in this scheme's vocabulary + sync: () => () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:delete"] }), +}); + +expectTypeOf(plain.principal).toEqualTypeOf<{ readonly userId: string }>(); +expectTypeOf(scoped.scope).toEqualTypeOf<"orders:export">(); diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 9883483d..6524d631 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -1,15 +1,8 @@ import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; -import { - Port, - Provider, - type AnyPort, - type PortClassOf, - type PortInstance, - type ServiceOf, -} from "@btravstack/di"; +import { Port, type AnyPort, type PortClassOf, type ServiceOf } from "@btravstack/di"; import { ORPCError } from "@orpc/server"; -import { ErrAsync, TaggedError, type AsyncResult } from "unthrown"; +import { TaggedError, type AsyncResult } from "unthrown"; /** * A caller was refused. Carries nothing: the starter surfaces no reason — a @@ -20,45 +13,88 @@ import { ErrAsync, TaggedError, type AsyncResult } from "unthrown"; export class Unauthenticated extends TaggedError("Unauthenticated") {} /** - * What an application provides so a marked procedure can name its caller. + * What an authenticator hands back. A scheme with no scope vocabulary returns + * the identity bare — byte-for-byte what applications write today — and one + * with a vocabulary reports what the credential actually granted, so the + * starter can compare it against what the endpoint declared. + */ +export type Granted = [Scope] extends [never] + ? P + : { readonly identity: P; readonly scopes: readonly Scope[] }; + +/** * Headers, not the request: an authenticator has no business reading a body, * and the narrower argument is what keeps it testable without a socket. */ -export type AuthenticatorService

= ( +export type AuthenticatorService = ( headers: IncomingHttpHeaders, -) => AsyncResult; +) => AsyncResult, Unauthenticated>; + +const ports = new Map(); + +/** + * One port per scheme, its id carrying the scheme name — the move + * `AmqpHandler(contract, key)` makes. The service type is erased because di + * identifies a port by id; the principal and scope types ride the provider + * `HttpAuthenticator` returns, and `defineHttp` reads the registry off them. + * + * The id is a LITERAL type, so `PortInstance<"HttpAuthenticator:user", …>` and + * `PortInstance<"HttpAuthenticator:service", …>` are different types: a + * contract naming a scheme the registry has no authenticator for leaves that + * scheme's port unmet, which is di's own diagnostic naming the port rather than + * a gate this package writes. + */ +export const authenticatorPort = ( + scheme: S, +): PortClassOf<`HttpAuthenticator:${S}`, AuthenticatorService> => { + const id = `HttpAuthenticator:${scheme}` as const; + // Memoised: `defineHttp` asks for a scheme's port when it binds the + // authenticator and `routerFor` asks again for every scheme its contract + // names, and two `Port(id)` calls under one id are di's duplicate-id warning. + const existing = ports.get(id); + if (existing !== undefined) return existing as never; + // oxlint-disable-next-line typescript/no-extraneous-class -- a port is a phantom token; only a class expression carries the construct signature `PortClassOf` describes + const minted = class extends Port(id)> {}; + ports.set(id, minted); + return minted as never; +}; /** - * The authenticator's port — one id, the starter's own, like `HttpRouterPort`. - * The service type is erased to `unknown` because di identifies a port by id; - * the principal's type is carried by the provider `HttpAuthenticator` returns - * and checked where the router and the authenticator meet. + * What `HttpAuthenticator` hands back: a description `defineHttp` binds to a + * port once the scheme name is known, carrying its principal and scope types + * so the registry can be inferred rather than declared. */ -export const AuthenticatorPort = Port("HttpAuthenticator") as PortClassOf< - "HttpAuthenticator", - AuthenticatorService ->; -export type AuthenticatorPort = PortInstance<"HttpAuthenticator", AuthenticatorService>; +export type Authenticator = { + readonly deps: unknown; + readonly options: unknown; + readonly principal: P; + readonly scope: Scope; + readonly needs: N; +}; /** - * The authenticator as a provider, with its principal type stated at the call: + * The authenticator for one scheme, with its principal type — and the scopes it + * can grant — stated at the call: * * ```ts - * export const jwtAuthenticator = HttpAuthenticator()({ verify: JwtVerifier }, { - * sync: ({ verify }) => (headers) => verify(headers.authorization), - * }); + * export const userAuth = HttpAuthenticator()( + * { verify: JwtVerifier }, + * { sync: ({ verify }) => (headers) => verify(headers.authorization) }, + * ); * * // An authenticator that reads nothing but the headers declares no deps: - * export const bearerAuthenticator = HttpAuthenticator()({ - * sync: () => (headers) => principalOf(headers.authorization), + * export const serviceAuth = HttpAuthenticator()({ + * sync: () => (headers) => apiKey(headers["x-api-key"]), * }); * ``` * - * The type argument is explicit rather than inferred from `sync`: inference - * through a returned function's `AsyncResult` is exactly where a `Principal` - * silently widens to `unknown`, and the whole point is that it cannot. + * The type arguments are explicit rather than inferred from `sync`: inference + * through a returned function's `AsyncResult` is exactly where a principal + * silently widens to `unknown`, and the whole point is that it cannot. The + * scheme NAME is not stated here — it is the key this authenticator sits under + * in `defineHttp({ authenticators })`, so it is written once. */ -export const HttpAuthenticator =

() => { +export const HttpAuthenticator = () => { // Two arms, discriminated by ARITY, mirroring `Provider(port)`'s own — an // authenticator that reads only the request's headers declares no // dependencies, which is the common shape rather than an edge case. @@ -67,30 +103,20 @@ export const HttpAuthenticator =

() => { options: { readonly sync: (services: { readonly [K in keyof D]: ServiceOf>; - }) => AuthenticatorService

; + }) => AuthenticatorService; }, - ): Provider> & { readonly principal: P }; + ): Authenticator>; function build(options: { - readonly sync: () => AuthenticatorService

; - }): Provider & { readonly principal: P }; + readonly sync: () => AuthenticatorService; + }): Authenticator; function build(depsOrOptions: unknown, options?: unknown): unknown { - return options === undefined - ? Provider(AuthenticatorPort)(depsOrOptions as never) - : Provider(AuthenticatorPort)(depsOrOptions as never, options as never); + // The port is minted by `defineHttp`, which is the only place the scheme + // NAME exists; this description is bound onto it there. + return { deps: depsOrOptions, options }; } return build; }; -/** - * Unreachable today, and kept anyway. `routerOf` falls back to this when a - * marked leaf has no authenticator behind it — which `HasMark` and - * `hasMarked` agreeing makes impossible, since a mark anywhere requires one. - * It is two lines of insurance on a seam that has already failed twice, and it - * fails **closed**: every caller refused, never a leaf served unprotected. - * `auth.spec.ts` exercises it directly, because no router can reach it. - */ -export const noAuthenticator: AuthenticatorService = () => ErrAsync(new Unauthenticated()); - /** * The one middleware this package installs, and only on a marked leaf. It reads * the request from oRPC's initial context — which is what initial context is From 80ed7ffbd3d77a9f0706c893e9ebd92a987ba9ba Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:50:17 +0200 Subject: [PATCH 05/18] feat(http)!: a record's requirements are the default, a procedure's replace them --- packages/http/src/controller.test-d.ts | 33 ++++++++++- packages/http/src/controller.ts | 26 +++------ packages/http/src/orpc.ts | 81 ++++++++++++++++---------- 3 files changed, 90 insertions(+), 50 deletions(-) diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index c2237754..28039c40 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -2,12 +2,13 @@ // `@ts-expect-error` is an assertion: if one stops erroring, the gate is gone. import { authenticated } from "@btravstack/contract"; import { Provider } from "@btravstack/di"; -import { oc } from "@orpc/contract"; +import { oc, type as ocType } from "@orpc/contract"; import { OkAsync } from "unthrown"; +import { expectTypeOf } from "vitest"; import { HttpController } from "./controller.js"; import { httpAuth } from "./http-auth.js"; -import { HttpRouter } from "./orpc.js"; +import { HttpRouter, type Implementation } from "./orpc.js"; const contract = { orders: { place: oc }, users: { find: oc } }; @@ -140,3 +141,31 @@ void IdentityRouter(markedContract.orders)( void IdentityRouter(markedContract)({ orders: markedOrders, users: markedUsers }); // @ts-expect-error — `markedOrders` needs a principal the unmarked contract declares nowhere void IdentityRouter(contract)({ orders: markedOrders, users: markedUsers }); + +// The inheritance half: a record's requirements are the default for every +// procedure beneath it, and a procedure's own REPLACE that default rather than +// adding to it. `Schemes` is the registry `defineHttp` infers; here it is +// written out so `Implementation` can be probed on its own. +type TwoSchemes = { + readonly user: { readonly userId: string }; + readonly service: { readonly appId: string }; +}; + +const grouped = authenticated({ user: [] })({ + place: oc.input(ocType<{ readonly id: string }>()).output(ocType<{ readonly id: string }>()), + export: authenticated( + { user: [] }, + { service: [] }, + )(oc.output(ocType<{ readonly csv: string }>())), +}); + +// A procedure under a marked record inherits that record's requirement. +type PlaceContext = Parameters["place"]>[0]["context"]; +expectTypeOf().toEqualTypeOf<{ readonly userId: string }>(); + +// A procedure with its own mark replaces the default rather than adding to it. +type ExportContext = Parameters["export"]>[0]["context"]; +expectTypeOf().toEqualTypeOf< + | { readonly scheme: "user"; readonly identity: { readonly userId: string } } + | { readonly scheme: "service"; readonly identity: { readonly appId: string } } +>(); diff --git a/packages/http/src/controller.ts b/packages/http/src/controller.ts index 7177ba9a..1092cf51 100644 --- a/packages/http/src/controller.ts +++ b/packages/http/src/controller.ts @@ -27,20 +27,20 @@ import type { Implementation } from "./orpc.js"; * measured on `examples/order-api`). */ /** What both arms of a minted controller return; `N` is the only thing that differs. */ -type Minted = Provider< - PortInstance>, +type Minted = Provider< + PortInstance>, never, N -> & { readonly port: PortClassOf> }; +> & { readonly port: PortClassOf> }; export const controllerFor = - () => + () => (name: Name, contract: C) => { // The parameter is named, not `_`-prefixed, so it reads as `contract` in the // published `.d.ts` and in an editor hint; nothing needs its value. void contract; // oxlint-disable-next-line typescript/no-extraneous-class -- a port is a phantom token; only a class expression carries the construct signature `PortClassOf` describes - const port = class extends Port(name)> {}; + const port = class extends Port(name)> {}; // Two arms, discriminated by ARITY, mirroring `Provider(port)`'s own — // a controller that calls no use case is the common shape here, not an @@ -52,12 +52,12 @@ export const controllerFor = options: { readonly sync: (services: { readonly [K in keyof D]: ServiceOf>; - }) => Implementation; + }) => Implementation; }, - ): Minted>; + ): Minted>; function build(options: { - readonly sync: () => Implementation; - }): Minted; + readonly sync: () => Implementation; + }): Minted; function build(depsOrOptions: unknown, options?: unknown): unknown { return options === undefined ? Provider(port as never)(depsOrOptions as never) @@ -65,11 +65,3 @@ export const controllerFor = } return build; }; - -/** - * The controller, with no server-side identity: a handler under a marked - * fragment sees `principal: never`, so any read of it is a compile error — - * the "use the factory" signal. `httpAuth()` mints the form whose - * handlers see the application's own principal. - */ -export const HttpController: ReturnType> = controllerFor(); diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 2bbe9c3c..8182d5da 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -3,6 +3,8 @@ import { type Authenticated, type IsMarked, type PrincipalKey, + type Requirements, + type RequirementsOf, } from "@btravstack/contract"; import { Port, @@ -29,6 +31,7 @@ import { type AuthenticatorService, } from "./auth.js"; import { HttpHandler } from "./handler.js"; +import type { Principal, SchemesOf } from "./principal.js"; export type OrpcOptions = { /** Where the RPC endpoint is mounted. Default `/rpc`. */ @@ -125,17 +128,17 @@ export const orpc = (options: OrpcOptions = {}) => { * contract must be covered — a missing or extra key is a compile error. */ /** What every `HttpRouter` arm returns; only the needs channel `N` differs. */ -type Built = Provider< +type Built = Provider< PortInstance<"HttpRouter", Router>>, never, N > & { readonly port: PortClassOf<"HttpRouter", Router>>; - readonly identity: Identity; + readonly identity: Schemes; }; export const routerFor = - () => + () => >(contract: C) => { // The implementer is walked untyped: `Implementation` above is the // whole check — a key the contract does not declare is a compile error @@ -151,20 +154,20 @@ export const routerFor = options: { readonly sync: (services: { readonly [K in keyof D]: ServiceOf>; - }) => Implementation; + }) => Implementation; }, ): Built< - Identity, + Schemes, InstanceType | (HasMark extends true ? AuthenticatorPort : never) >; function build(options: { - readonly sync: () => Implementation; - }): Built extends true ? AuthenticatorPort : never>; + readonly sync: () => Implementation; + }): Built extends true ? AuthenticatorPort : never>; function build< M extends { readonly [K in Exclude]: ControllerFor< - Inherit>, - Identity + Inherit>, + Schemes >; }, >( @@ -174,7 +177,7 @@ export const routerFor = ]: `UNDECLARED KEY — the contract declares no fragment under ${K & string}`; }, ): Built< - Identity, + Schemes, InstanceType | (HasMark extends true ? AuthenticatorPort : never) >; function build(depsOrControllers: unknown, options?: unknown): unknown { @@ -263,8 +266,8 @@ export const HttpRouter: ReturnType> = routerFor( const AUTHENTICATOR = "@btravstack/http/authenticator"; /** A controller for one fragment — what `HttpController` returns, as the keyed form consumes it. */ -type ControllerFor = { - readonly port: PortClassOf>; +type ControllerFor = { + readonly port: PortClassOf>; }; /** @@ -274,12 +277,16 @@ type ControllerFor = { * the input is the contract's parsed input, the output its declared output * and the `errors` helpers its declared error map. */ -export type Implementation = +export type Implementation< + C extends RouterContract, + Schemes = never, + R extends Requirements = never, +> = C extends ProcedureContract ? Parameters< ProcedureImplementer< DefaultInitialContext & object, - ContextOf, + ContextOf, I, O, E @@ -287,32 +294,44 @@ export type Implementation = >[0] : { readonly [K in Exclude]: C[K] extends RouterContract - ? Implementation>, Identity> + ? Implementation> : never; }; /** - * What a leaf's handler gets on `opts.context`: the principal when the leaf is - * marked, and `object` — today's spelling, unchanged — when it is not. It rides - * oRPC's own context channel, injected into `ProcedureImplementer`'s second - * type parameter, so this package adds no second handler parameter and wraps no - * `.result()` handler. + * The requirements actually in force at a node: its own, or the inherited ones. + * Nearest mark wins, which is OpenAPI's own rule. + */ +type Effective = IsMarked extends true ? RequirementsOf : R; + +/** + * What a leaf's handler gets on `opts.context`: the principal its effective + * requirements name, and `object` — today's spelling, unchanged — when it has + * none. It rides oRPC's own context channel, injected into + * `ProcedureImplementer`'s second type parameter, so this package adds no + * second handler parameter and wraps no `.result()` handler. * - * The contract says only **whether** a leaf is protected; `Identity` — from - * `httpAuth()` — says **what** the principal is. The top-level - * `HttpRouter` / `HttpController` pass `never`, so a marked leaf reached - * without the factory types `principal: never` and any read of it is a compile - * error: the "use the factory" signal, rather than a principal invented from a - * type the contract no longer carries. + * The contract says **which schemes** protect a leaf; `Schemes` — the registry + * `defineHttp` infers from its authenticators — says what each one resolves to. + * A leaf reached without the factory sees `Schemes = never`, so `principal` is + * `never` and any read of it is a compile error — the "use the factory" signal, + * rather than a principal invented from a contract that names none. */ -type ContextOf = IsMarked extends true ? { readonly principal: Identity } : object; +type ContextOf = [Effective] extends [never] + ? object + : { readonly principal: Principal>, Schemes> }; /** - * Pushes a record's marker onto each of its children, so a marked fragment - * protects every procedure beneath it. The runtime walk in `routerOf` carries - * the same fact as an argument; these two must agree. + * Pushes a record's requirements onto a child that carries none, so a marked + * fragment protects every procedure beneath it. Nearest mark wins: a node with + * its own requirements is left alone. This is the type side of `routerOf`'s + * `inherited` argument; the two must agree. */ -type Inherit = Marked extends true ? Authenticated : T; +type Inherit = [R] extends [never] + ? T + : IsMarked extends true + ? T + : Authenticated; /** * Whether the contract marks anything, anywhere — a yes/no, not a type, since From c0f3af65decceb29100a7e8430f810236dca9571 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:53:36 +0200 Subject: [PATCH 06/18] feat(http)!: defineHttp is the one door, and it infers its registry --- packages/http/src/define-http.test-d.ts | 46 +++++++++++++ packages/http/src/define-http.ts | 86 +++++++++++++++++++++++++ packages/http/src/http-auth.ts | 32 --------- packages/http/src/index.ts | 13 ++-- packages/http/src/orpc.ts | 68 ++++++++++++------- 5 files changed, 183 insertions(+), 62 deletions(-) create mode 100644 packages/http/src/define-http.test-d.ts create mode 100644 packages/http/src/define-http.ts delete mode 100644 packages/http/src/http-auth.ts diff --git a/packages/http/src/define-http.test-d.ts b/packages/http/src/define-http.test-d.ts new file mode 100644 index 00000000..135d4b40 --- /dev/null +++ b/packages/http/src/define-http.test-d.ts @@ -0,0 +1,46 @@ +import { Port } from "@btravstack/di"; +import { OkAsync } from "unthrown"; +import { describe, expectTypeOf, test } from "vitest"; + +import { HttpAuthenticator } from "./auth.js"; +import { defineHttp } from "./define-http.js"; + +describe("defineHttp", () => { + test("infers the scheme registry from the authenticators", () => { + const api = defineHttp({ + authenticators: { + user: HttpAuthenticator<{ readonly userId: string }>()({ + sync: () => () => OkAsync({ userId: "u-1" }), + }), + service: HttpAuthenticator<{ readonly appId: string }>()({ + sync: () => () => OkAsync({ appId: "a-1" }), + }), + }, + }); + expectTypeOf(api.authenticators.user.principal).toEqualTypeOf<{ readonly userId: string }>(); + expectTypeOf(api.authenticators.service.principal).toEqualTypeOf<{ readonly appId: string }>(); + }); + + test("an application with no auth needs no argument", () => { + const api = defineHttp(); + expectTypeOf(api.authenticators).toEqualTypeOf>(); + }); + + test("an authenticator's own dependencies ride through to the graph", () => { + class Verifier extends Port("DefineVerifier")<(token: string) => { readonly userId: string }> {} + const api = defineHttp({ + authenticators: { + user: HttpAuthenticator<{ readonly userId: string }>()( + { verify: Verifier }, + { + sync: + ({ verify }) => + (headers) => + OkAsync(verify(headers.authorization ?? "")), + }, + ), + }, + }); + expectTypeOf(api.authenticators.user.needs).toEqualTypeOf(); + }); +}); diff --git a/packages/http/src/define-http.ts b/packages/http/src/define-http.ts new file mode 100644 index 00000000..d0016772 --- /dev/null +++ b/packages/http/src/define-http.ts @@ -0,0 +1,86 @@ +import { Provider, type AnyProvider, type PortInstance } from "@btravstack/di"; + +import { authenticatorPort, type Authenticator, type AuthenticatorService } from "./auth.js"; +import { controllerFor } from "./controller.js"; +import { routerFor } from "./orpc.js"; + +/** The authenticators an application declares, keyed by scheme name. */ +export type Authenticators = Readonly>>; + +/** The scheme registry, read off the authenticators rather than declared twice. */ +export type SchemesFrom = { readonly [K in keyof A]: A[K]["principal"] }; + +/** + * 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 + * an authenticator that reads a `JwtVerifier` still owes it where `HttpModule` + * puts it in `provides`. + */ +type SchemeProviders = { + readonly [K in keyof A]: Provider< + PortInstance<`HttpAuthenticator:${K & string}`, AuthenticatorService>, + never, + A[K]["needs"] + >; +}[keyof A]; + +/** + * Everything an application mints from one call. Held as ONE binding and never + * destructured: each binding of a destructured member expands to a type + * mentioning `@btravstack/contract`'s inaccessible `unique symbol`, which is + * TS2527 (measured). Held whole, the inferred type collapses to `Http`, + * which is nameable — so an application writes no annotation at all. + */ +export type Http = { + readonly HttpController: ReturnType>>; + readonly HttpRouter: ReturnType, SchemeProviders>>; + readonly authenticators: A; +}; + +/** + * The one door to the marker-typed entities. Declaring a scheme and + * implementing it are the same act, so a scheme without an authenticator is + * not a state this can reach — there is no coverage gate because there is + * nothing to forget. + * + * ```ts + * export const api = defineHttp({ authenticators: { user: userAuth } }); + * export const api = defineHttp(); // a public API: `principal` is `never` + * ``` + * + * The default registry is `Record`, not `Record`: + * an index signature over `string` would make EVERY scheme's port look + * available to di, so a marked contract composed under `defineHttp()` would + * type-check and then fail at build. Empty, the port stays unmet and the + * composition is refused. + */ +export const defineHttp = >(options?: { + readonly authenticators: A; +}): Http => { + const declared = (options?.authenticators ?? {}) as Readonly< + Record> + >; + const providers = Object.entries(declared).map(([scheme, authenticator]) => + bind(scheme, authenticator), + ); + return { + HttpController: controllerFor>(), + HttpRouter: routerFor, SchemeProviders>(providers as never), + authenticators: declared as A, + }; +}; + +/** + * `HttpAuthenticator`'s no-deps arm puts its single argument in `deps` and + * leaves `options` undefined — the same arity discrimination `Provider(port)` + * makes, replayed here now that the scheme NAME exists to mint a port from. + */ +const bind = ( + scheme: string, + authenticator: Authenticator, +): AnyProvider => { + const port = authenticatorPort(scheme); + return authenticator.options === undefined + ? Provider(port as never)(authenticator.deps as never) + : Provider(port as never)(authenticator.deps as never, authenticator.options as never); +}; diff --git a/packages/http/src/http-auth.ts b/packages/http/src/http-auth.ts deleted file mode 100644 index f481e948..00000000 --- a/packages/http/src/http-auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { HttpAuthenticator } from "./auth.js"; -import { controllerFor } from "./controller.js"; -import { routerFor } from "./orpc.js"; - -/** - * Mints `HttpController`, `HttpRouter` and `HttpAuthenticator` on one identity — - * the contract says whether a route is protected, this says what the principal - * is. Written once per application, because a handler's parameter types are - * fixed where the arrow is written: a composition root cannot re-type a `sync` - * callback living in a slice's module. See `packages/http/CLAUDE.md`. - */ -export const httpAuth = (): HttpAuth => ({ - HttpController: controllerFor(), - HttpRouter: routerFor(), - HttpAuthenticator: HttpAuthenticator(), -}); - -type HttpAuth = { - readonly HttpController: HttpControllerOf; - readonly HttpRouter: HttpRouterOf; - readonly HttpAuthenticator: HttpAuthenticatorOf; -}; - -/** - * What a consumer annotates with. `Identity` cannot reach a `.d.ts` through the - * inferred type of the call: a controller's port expands to a type carrying - * `@btravstack/contract`'s phantom `unique symbol`, which no consumer can name - * (TS2527, measured on `examples/order-api`). - */ -export type HttpControllerOf = ReturnType>; -export type HttpRouterOf = ReturnType>; -export type HttpAuthenticatorOf = ReturnType>; diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index 74b6b37c..d9a0fee6 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,11 +1,10 @@ -export { AuthenticatorPort, HttpAuthenticator, Unauthenticated } from "./auth.js"; -export type { AuthenticatorService } from "./auth.js"; -export { HttpController } from "./controller.js"; -export { httpAuth } from "./http-auth.js"; -export type { HttpAuthenticatorOf, HttpControllerOf, HttpRouterOf } from "./http-auth.js"; +export { HttpAuthenticator, Unauthenticated, authenticatorPort } from "./auth.js"; +export type { Authenticator, AuthenticatorService, Granted } from "./auth.js"; +export { defineHttp } from "./define-http.js"; +export type { Authenticators, Http, SchemesFrom } from "./define-http.js"; export { HttpModule } from "./http-module.js"; export type { HttpModuleOptions } from "./http-module.js"; export { HttpConfig, HttpRuntime, http } from "./http-runtime.js"; -export { HttpRouter } from "./orpc.js"; -export type { HasMark } from "./orpc.js"; export type { HttpInfo, HttpOptions } from "./http-runtime.js"; +export type { HasMark } from "./orpc.js"; +export type { Principal, SchemesOf } from "./principal.js"; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 8182d5da..e8a9b1d6 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -10,6 +10,7 @@ import { Port, Provider, type AnyPort, + type AnyProvider, type PortClassOf, type PortInstance, type ServiceOf, @@ -128,17 +129,23 @@ export const orpc = (options: OrpcOptions = {}) => { * contract must be covered — a missing or extra key is a compile error. */ /** What every `HttpRouter` arm returns; only the needs channel `N` differs. */ -type Built = Provider< +type Built = Provider< PortInstance<"HttpRouter", Router>>, never, N > & { readonly port: PortClassOf<"HttpRouter", Router>>; - readonly identity: Schemes; + /** + * The scheme authenticators `defineHttp` bound, carried here so `HttpModule` + * can put them in `provides` off the one option an application already + * passes. It rides the router because the router is what needs them: they + * are the providers that discharge its scheme ports. + */ + readonly authenticators: readonly Auth[]; }; export const routerFor = - () => + (authenticators: readonly Auth[]) => >(contract: C) => { // The implementer is walked untyped: `Implementation` above is the // whole check — a key the contract does not declare is a compile error @@ -156,13 +163,10 @@ export const routerFor = readonly [K in keyof D]: ServiceOf>; }) => Implementation; }, - ): Built< - Schemes, - InstanceType | (HasMark extends true ? AuthenticatorPort : never) - >; + ): Built | SchemePortsOf>; function build(options: { readonly sync: () => Implementation; - }): Built extends true ? AuthenticatorPort : never>; + }): Built>; function build< M extends { readonly [K in Exclude]: ControllerFor< @@ -176,10 +180,7 @@ export const routerFor = K in Exclude> ]: `UNDECLARED KEY — the contract declares no fragment under ${K & string}`; }, - ): Built< - Schemes, - InstanceType | (HasMark extends true ? AuthenticatorPort : never) - >; + ): Built | SchemePortsOf>; function build(depsOrControllers: unknown, options?: unknown): unknown { const guarded = hasMarked(contract); // The authenticator rides a NAMESPACED key on the deps record, for the @@ -254,14 +255,6 @@ export const routerFor = return build; }; -/** - * The router, with no server-side identity: a handler under a marked key sees - * `principal: never`, so any read of it is a compile error. That is the - * "use the factory" signal — `httpAuth()` is what mints the form - * whose handlers see the application's own principal. - */ -export const HttpRouter: ReturnType> = routerFor(); - // Namespaced so it cannot collide with a key the caller wrote; see `build`. const AUTHENTICATOR = "@btravstack/http/authenticator"; @@ -333,11 +326,40 @@ type Inherit = [R] extends [never] ? T : Authenticated; +/** + * Every requirement the contract carries, anywhere in its tree — the same walk + * as `HasMark`, keeping what it found instead of answering yes. Over-, never + * under-approximating: a requirement a nearer mark shadows still contributes + * its scheme, which costs a dep nothing uses rather than a missing one. + */ +type AllRequirementsOf = + | RequirementsOf + | (C extends ProcedureContract + ? never + : { + readonly [K in Exclude]: AllRequirementsOf; + }[Exclude]); + +/** Distributes `SchemesOf` over the union of requirement tuples the walk collected. */ +type SchemesIn = R extends Requirements ? SchemesOf : never; + +/** + * 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 + * types — a scheme with no authenticator behind it is di's own unmet need + * naming `HttpAuthenticator:`, not a gate this package writes. The + * runtime side is `schemesOf` below; these two must agree. + */ +type SchemePortsOf = + SchemesIn> extends infer S extends string + ? S extends string + ? PortInstance<`HttpAuthenticator:${S}`, AuthenticatorService> + : never + : never; + /** * Whether the contract marks anything, anywhere — a yes/no, not a type, since - * the contract names no principal. It is what makes the authenticator - * dependency conditional on both `build` overloads, and the type side of the - * `hasMarked` walk below; these two must agree. + * the contract names no principal. */ export type HasMark = IsMarked extends true From b6b37dac11fe224c00d7bd39fd75d91894e34bf4 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:57:25 +0200 Subject: [PATCH 07/18] feat(http)!: a router declares one dep per scheme its contract names --- packages/http/src/controller.test-d.ts | 120 ++++++++++++++++------- packages/http/src/orpc.ts | 126 ++++++++++++++----------- 2 files changed, 160 insertions(+), 86 deletions(-) diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index 28039c40..42388624 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -1,24 +1,31 @@ -// The five compile gates the keyed router form exists to provide. Each -// `@ts-expect-error` is an assertion: if one stops erroring, the gate is gone. +// The five compile gates the keyed router form exists to provide, the +// inheritance rule the contract's requirements follow, and the scheme ports a +// router declares from them. Each `@ts-expect-error` is an assertion: if one +// stops erroring, the gate is gone. import { authenticated } from "@btravstack/contract"; -import { Provider } from "@btravstack/di"; +import type { PortInstance, Provider } from "@btravstack/di"; import { oc, type as ocType } from "@orpc/contract"; import { OkAsync } from "unthrown"; import { expectTypeOf } from "vitest"; -import { HttpController } from "./controller.js"; -import { httpAuth } from "./http-auth.js"; -import { HttpRouter, type Implementation } from "./orpc.js"; +import { HttpAuthenticator, type AuthenticatorService } from "./auth.js"; +import { defineHttp } from "./define-http.js"; +import type { Implementation } from "./orpc.js"; + +type Expect = T; + +/** A public API: no authenticators, so nothing types a principal anywhere. */ +const publicApi = defineHttp(); const contract = { orders: { place: oc }, users: { find: oc } }; -const orders = HttpController( +const orders = publicApi.HttpController( "GateOrders", contract.orders, )({ sync: () => ({ place: () => OkAsync("placed") }), }); -const users = HttpController( +const users = publicApi.HttpController( "GateUsers", contract.users, )({ @@ -27,18 +34,18 @@ const users = HttpController( // 1. Every contract key must be covered. // @ts-expect-error — `users` is missing from the record -void HttpRouter(contract)({ orders }); +void publicApi.HttpRouter(contract)({ orders }); // 2. A key the contract does not declare is rejected. // @ts-expect-error — `billing` is not in the contract -void HttpRouter(contract)({ orders, users, billing: orders }); +void publicApi.HttpRouter(contract)({ orders, users, billing: orders }); // 3. A controller wired under the wrong key is rejected. // @ts-expect-error — `users`'s fragment is not `orders`'s -void HttpRouter(contract)({ orders: users, users: orders }); +void publicApi.HttpRouter(contract)({ orders: users, users: orders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void HttpController( +void publicApi.HttpController( "GateTypo", contract.orders, )({ @@ -52,7 +59,7 @@ void HttpController( // that controller built. Strictly stronger than re-implementing the fragment // with a fresh `sync`, which would prove nothing about the controller. The // spec marks this "do not break"; this is what would catch breaking it. -void HttpRouter(contract.orders)( +void publicApi.HttpRouter(contract.orders)( { implementation: orders.port }, { sync: ({ implementation }) => implementation }, ); @@ -62,8 +69,8 @@ void HttpRouter(contract.orders)( // helper in the family with three forms and two arguments' worth of arity, so // these two one-argument calls are told apart by whether `sync` holds a // function (orpc.ts). Break that and one of these two lines stops compiling. -const composed = HttpRouter(contract)({ orders, users }); -void HttpRouter(contract)({ +const composed = publicApi.HttpRouter(contract)({ orders, users }); +void publicApi.HttpRouter(contract)({ sync: () => ({ orders: { place: () => OkAsync("placed") }, users: { find: () => OkAsync("f") }, @@ -75,27 +82,36 @@ void HttpRouter(contract)({ // pollutes the inferred `M`, this collapses to `never` and di stops ordering // the controllers before the router, silently. type NeedsOf = T extends Provider ? N : never; -type Expect = T; type _ComposedNeedsAreDeclared = Expect<[NeedsOf] extends [never] ? false : true>; // All five again, against a contract whose `orders` fragment is MARKED. The // marker is a phantom key on the fragment, so every gate above has to survive // it — the fifth especially: a marked slice must still lift out of the composed // router with its controller unchanged. The contract names no principal, so the -// controllers here come from `httpAuth()`, which is what types one. -const markedContract = { orders: authenticated(contract.orders), users: contract.users }; +// controllers here come from `defineHttp`, which is what types one. +const api = defineHttp({ + authenticators: { + user: HttpAuthenticator<{ readonly userId: string }>()({ + sync: () => () => OkAsync({ userId: "u-1" }), + }), + service: HttpAuthenticator<{ readonly appId: string }>()({ + sync: () => () => OkAsync({ appId: "a-1" }), + }), + }, +}); -const { HttpController: IdentityController, HttpRouter: IdentityRouter } = httpAuth<{ - readonly userId: string; -}>(); +const markedContract = { + orders: authenticated({ user: [] })(contract.orders), + users: contract.users, +}; -const markedOrders = IdentityController( +const markedOrders = api.HttpController( "GateMarkedOrders", markedContract.orders, )({ sync: () => ({ place: (opts) => OkAsync(opts.context.principal.userId) }), }); -const markedUsers = IdentityController( +const markedUsers = api.HttpController( "GateMarkedUsers", markedContract.users, )({ @@ -104,10 +120,10 @@ const markedUsers = IdentityController( // 1. Every contract key must be covered. // @ts-expect-error — `users` is missing from the record -void IdentityRouter(markedContract)({ orders: markedOrders }); +void api.HttpRouter(markedContract)({ orders: markedOrders }); // 2. A key the contract does not declare is rejected. -void IdentityRouter(markedContract)({ +void api.HttpRouter(markedContract)({ orders: markedOrders, users: markedUsers, // @ts-expect-error — `billing` is not in the contract @@ -116,10 +132,10 @@ void IdentityRouter(markedContract)({ // 3. A controller wired under the wrong key is rejected. // @ts-expect-error — `users`'s fragment is not the marked `orders`'s -void IdentityRouter(markedContract)({ orders: markedUsers, users: markedOrders }); +void api.HttpRouter(markedContract)({ orders: markedUsers, users: markedOrders }); // 4. A procedure the fragment does not declare is rejected inside the controller. -void IdentityController( +void api.HttpController( "GateMarkedTypo", markedContract.orders, )({ @@ -128,7 +144,7 @@ void IdentityController( }); // 5. The do-not-break lift, for a marked fragment. -void IdentityRouter(markedContract.orders)( +void api.HttpRouter(markedContract.orders)( { implementation: markedOrders.port }, { sync: ({ implementation }) => implementation }, ); @@ -138,14 +154,16 @@ void IdentityRouter(markedContract.orders)( // under an unmarked contract key, where nothing would inject one. (The reverse // — an unmarked controller under a marked key — is accepted, and correctly so: // a handler that ignores `opts.context.principal` is contravariantly fine.) -void IdentityRouter(markedContract)({ orders: markedOrders, users: markedUsers }); +const markedComposed = api.HttpRouter(markedContract)({ + orders: markedOrders, + users: markedUsers, +}); // @ts-expect-error — `markedOrders` needs a principal the unmarked contract declares nowhere -void IdentityRouter(contract)({ orders: markedOrders, users: markedUsers }); +void api.HttpRouter(contract)({ orders: markedOrders, users: markedUsers }); // The inheritance half: a record's requirements are the default for every // procedure beneath it, and a procedure's own REPLACE that default rather than -// adding to it. `Schemes` is the registry `defineHttp` infers; here it is -// written out so `Implementation` can be probed on its own. +// adding to it. type TwoSchemes = { readonly user: { readonly userId: string }; readonly service: { readonly appId: string }; @@ -169,3 +187,41 @@ expectTypeOf().toEqualTypeOf< | { readonly scheme: "user"; readonly identity: { readonly userId: string } } | { readonly scheme: "service"; readonly identity: { readonly appId: string } } >(); + +// The router depends on one port per scheme the contract names — so a missing +// authenticator is di's own unmet-need error naming the port, not a gate this +// package writes. +const twoSchemeRouter = api.HttpRouter(grouped)({ + sync: () => ({ place: () => OkAsync({ id: "o-1" }), export: () => OkAsync({ csv: "" }) }), +}); + +type SchemePort = S extends string + ? PortInstance<`HttpAuthenticator:${S}`, AuthenticatorService> + : never; + +// BOTH directions. A one-way check passes on a collapsed `never`, which is how +// a broken scheme walk would slip through — the same hole that hid a broken +// `SchemesOf` in `principal.test-d.ts`. +type _TwoSchemeNeeds = Expect< + [Extract, SchemePort>] extends [ + SchemePort<"user" | "service">, + ] + ? [SchemePort<"user" | "service">] extends [NeedsOf] + ? true + : false + : false +>; + +// One mark, one scheme, one port — never the whole registry. +type _OneSchemeNeeds = Expect< + [Extract, SchemePort>] extends [SchemePort<"user">] + ? [SchemePort<"user">] extends [NeedsOf] + ? true + : false + : false +>; + +// An all-public contract declares none at all. +type _NoSchemeNeeds = Expect< + [Extract, SchemePort>] extends [never] ? true : false +>; diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index e8a9b1d6..79badb31 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -25,12 +25,7 @@ import { import { RPCHandler, type NodeHttpHandlerPlugin } from "@orpc/server/node"; import "@unthrown/orpc/extensions/result"; -import { - AuthenticatorPort, - noAuthenticator, - principalMiddleware, - type AuthenticatorService, -} from "./auth.js"; +import { authenticatorPort, principalMiddleware, type AuthenticatorService } from "./auth.js"; import { HttpHandler } from "./handler.js"; import type { Principal, SchemesOf } from "./principal.js"; @@ -182,17 +177,21 @@ export const routerFor = }, ): Built | SchemePortsOf>; function build(depsOrControllers: unknown, options?: unknown): unknown { - const guarded = hasMarked(contract); - // The authenticator rides a NAMESPACED key on the deps record, for the + const schemes = schemesOf(contract); + // Each scheme's port rides a NAMESPACED key on the deps record, for the // same reason `tapped`'s port id is namespaced: the other keys are the - // caller's own names, and this one must not be able to collide with a - // dependency somebody called `authenticator`. - const own = (services: Record): Record => { - const { [AUTHENTICATOR]: _authenticator, ...rest } = services; - return rest; - }; - const withAuthenticator = (deps: Record): Record => - guarded ? { ...deps, [AUTHENTICATOR]: AuthenticatorPort } : deps; + // caller's own names, and these must not be able to collide with a + // dependency somebody called `user`. + const own = (services: Record): Record => + Object.fromEntries( + Object.entries(services).filter(([key]) => !key.startsWith(AUTHENTICATOR)), + ); + const withSchemes = (deps: Record): Record => ({ + ...deps, + ...Object.fromEntries( + schemes.map((scheme) => [`${AUTHENTICATOR}${scheme}`, authenticatorPort(scheme)]), + ), + }); const routerFrom = ( implementation: Record, services: Record, @@ -203,7 +202,12 @@ export const routerFor = implementation, contract, isAuthenticated(contract), - guarded ? (services[AUTHENTICATOR] as AuthenticatorService) : undefined, + Object.fromEntries( + schemes.map((scheme) => [ + scheme, + services[`${AUTHENTICATOR}${scheme}`] as AuthenticatorService, + ]), + ), ), ); @@ -233,7 +237,9 @@ export const routerFor = const built = armOnly === undefined ? call(own(services)) : call(); return routerFrom(built as Record, services); }; - return Provider(HttpRouterPort)(withAuthenticator(deps), { sync } as never); + return Object.assign(Provider(HttpRouterPort)(withSchemes(deps), { sync } as never), { + authenticators, + }); } // The controllers record is keyed by contract key, and so is the services @@ -242,13 +248,16 @@ export const routerFor = const controllers = depsOrControllers as Record; const sync = (services: Record): Router> => routerFrom(own(services), services); - return Provider(HttpRouterPort)( - withAuthenticator( - Object.fromEntries( - Object.entries(controllers).map(([key, controller]) => [key, controller.port]), + return Object.assign( + Provider(HttpRouterPort)( + withSchemes( + Object.fromEntries( + Object.entries(controllers).map(([key, controller]) => [key, controller.port]), + ), ), + { sync } as never, ), - { sync } as never, + { authenticators }, ); } @@ -256,7 +265,8 @@ export const routerFor = }; // Namespaced so it cannot collide with a key the caller wrote; see `build`. -const AUTHENTICATOR = "@btravstack/http/authenticator"; +// The trailing colon is part of the prefix: the scheme name follows it. +const AUTHENTICATOR = "@btravstack/http/authenticator:"; /** A controller for one fragment — what `HttpController` returns, as the keyed form consumes it. */ type ControllerFor = { @@ -373,38 +383,46 @@ export type HasMark = : false; /** - * Whether the contract marks anything, anywhere. Walked once, at composition, - * because it is what makes the authenticator dependency conditional: a router - * with no marked leaf declares no such need, so an application with no - * protected route provides nothing. The type side of the same condition is - * `HasMark` on both `build` overloads; these two must agree. + * Every scheme the contract names, anywhere. Walked once, at composition, + * because it is what the router's dependencies are: one port per scheme, so an + * application with no protected route declares nothing. The type side is + * `SchemePortsOf` above; these two must agree. */ -const hasMarked = (node: unknown, seen: WeakSet = new WeakSet()): boolean => { - if (typeof node !== "object" || node === null || seen.has(node)) return false; - // Every object, not only a plain record: `routerOf` reaches a mark through - // whatever `contract[key]` holds, so anything this walk declines to enter is - // a mark it can miss and the walk cannot — and missing one is the unsafe - // direction. `seen` is what makes entering everything terminate, since a - // schema is free to be recursive. - seen.add(node); - if (isAuthenticated(node)) return true; - return Object.values(node as Record).some((child) => hasMarked(child, seen)); +const schemesOf = (contract: unknown): readonly string[] => { + const found = new Set(); + const walk = (node: unknown, seen: WeakSet): void => { + if (typeof node !== "object" || node === null || seen.has(node)) return; + // Every object, not only a plain record: `routerOf` reaches a mark through + // whatever `contract[key]` holds, so anything this walk declines to enter is + // a mark it can miss and the walk cannot — and missing one is the unsafe + // direction. `seen` is what makes entering everything terminate, since a + // schema is free to be recursive. + seen.add(node); + for (const requirement of isAuthenticated(node) ?? []) + for (const scheme of Object.keys(requirement)) found.add(scheme); + // No early return on a mark: a procedure inside a marked record may name a + // scheme of its own, and that scheme still needs a port. + for (const child of Object.values(node as Record)) walk(child, seen); + }; + walk(contract, new WeakSet()); + return [...found]; }; // Walks the implementation record next to the implementer and the contract: a // function is a procedure and becomes `implementer.result(fn)`, anything else // is a nested router. The types above are the whole check; the walk trusts // them, and drops a key the implementer has no node for rather than defecting -// on it. `inherited` carries a marked record's mark down to its procedures — -// `isAuthenticated` answers for one node only — the same way `Inherit` -// carries it in the types. `.use` must come BEFORE `.result`: `.result` -// returns an `ImplementedProcedure`, whose own `.use` has no `.result` left. +// on it. `inherited` carries a marked record's requirements down to its +// procedures — `isAuthenticated` answers for one node only — the same way +// `Inherit` carries them in the types. `.use` must come BEFORE `.result`: +// `.result` returns an `ImplementedProcedure`, whose own `.use` has no +// `.result` left. const routerOf = ( implementer: Record, implementation: Record, contract: Record, - inherited: boolean, - authenticate: AuthenticatorService | undefined, + inherited: Requirements | undefined, + authenticators: Readonly>>, ): Record => Object.fromEntries( Object.entries(implementation).flatMap(([key, value]) => { @@ -418,14 +436,14 @@ const routerOf = ( | undefined; if (node === undefined) return []; const child = contract[key]; - const marked = - inherited || (typeof child === "object" && child !== null && isAuthenticated(child)); + // Nearest mark wins: this node's own requirements, or the enclosing + // record's when it declares none. + const declared = + typeof child === "object" && child !== null ? isAuthenticated(child) : undefined; + const effective = declared ?? inherited; if (typeof value === "function") { - // Fail closed: a mark with no authenticator behind it refuses every - // caller rather than serving the leaf unprotected. - const target = marked - ? node.use(principalMiddleware(authenticate ?? noAuthenticator)) - : node; + const target = + effective === undefined ? node : node.use(principalMiddleware(effective, authenticators)); return [[key, target.result(value)]]; } return [ @@ -435,8 +453,8 @@ const routerOf = ( node, value as Record, (child ?? {}) as Record, - marked, - authenticate, + effective, + authenticators, ), ], ]; From 23c1b3810184066c1aa733fedf60609d5697eafa Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 21:59:06 +0200 Subject: [PATCH 08/18] feat(http)!: requirements are tried in order, and a defect stops the walk --- packages/http/src/auth.spec.ts | 88 +++++++++++++++++++++++++++--- packages/http/src/auth.ts | 69 +++++++++++++++++------ packages/http/src/test-fixtures.ts | 9 ++- 3 files changed, 139 insertions(+), 27 deletions(-) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 5660fa75..6f088d40 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -1,6 +1,7 @@ +import { ErrAsync, OkAsync } from "unthrown"; import { describe, expect } from "vitest"; -import { Unauthenticated, noAuthenticator } from "./auth.js"; +import { Unauthenticated, principalMiddleware } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("an authenticated procedure", () => { @@ -112,13 +113,84 @@ describe("a router over a marked contract", () => { }); }); -describe("the fail-closed authenticator", () => { - it("refuses every caller, so a mark with nothing behind it is a 401", async () => { - // GIVEN the stand-in a marked leaf gets when no authenticator reached the walk - // WHEN it is asked to name a caller - // THEN it refuses — the safe direction for a disagreement between the two halves - await expect(noAuthenticator({})).resolves.toBeErrWith( - expect.objectContaining({ constructor: Unauthenticated }), +describe("a leaf naming several requirements", () => { + it("takes the first requirement a caller satisfies", async ({ headers }) => { + // GIVEN two schemes where only the second accepts this caller + const middleware = principalMiddleware([{ user: [] }, { service: [] }], { + user: () => ErrAsync(new Unauthenticated()), + service: () => OkAsync({ appId: "a-1" }), + }); + + // WHEN a request arrives + const injected = middleware({ + context: { request: { headers } as never }, + next: (o) => Promise.resolve(o.context.principal), + }); + + // THEN the second scheme's principal is injected, tagged because the leaf + // names more than one + await expect(injected).resolves.toEqual({ scheme: "service", identity: { appId: "a-1" } }); + }); + + it("refuses with UNAUTHORIZED when no requirement is satisfied", async ({ headers }) => { + // GIVEN a scheme that accepts nobody + const middleware = principalMiddleware([{ user: [] }], { + user: () => ErrAsync(new Unauthenticated()), + }); + + // WHEN a request arrives + const refused = middleware({ + context: { request: { headers } as never }, + next: () => Promise.resolve(undefined), + }).catch((error: unknown) => error); + + // THEN it is a 401 carrying no message a caller is not entitled to + await expect(refused).resolves.toEqual( + expect.objectContaining({ + code: "UNAUTHORIZED", + message: expect.not.stringContaining("user"), + }), ); }); + + it("refuses with FORBIDDEN when the credential is valid but under-scoped", async ({ + headers, + }) => { + // GIVEN an endpoint requiring a scope the credential does not grant + const middleware = principalMiddleware([{ user: ["orders:export"] }], { + user: () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:read"] }), + }); + + // WHEN a request arrives + const refused = middleware({ + context: { request: { headers } as never }, + next: () => Promise.resolve(undefined), + }).catch((error: unknown) => error); + + // THEN authenticated-but-insufficient is 403, not 401 + await expect(refused).resolves.toEqual(expect.objectContaining({ code: "FORBIDDEN" })); + }); + + it("does not fall through to the next requirement on a defect", async ({ headers }) => { + // GIVEN a first scheme whose authenticator is buggy and a second that accepts + const boom = new Error("verifier exploded"); + const middleware = principalMiddleware([{ user: [] }, { service: [] }], { + user: () => + OkAsync().map((): never => { + // oxlint-disable-next-line unthrown/no-throw -- the subject under test: a defect is what a buggy authenticator produces, and `Defect` has no public constructor + throw boom; + }), + service: () => OkAsync({ appId: "a-1" }), + }); + + // WHEN a request arrives + const raised = middleware({ + context: { request: { headers } as never }, + next: () => Promise.resolve(undefined), + }).catch((error: unknown) => error); + + // THEN the bug surfaces rather than silently promoting the caller to the + // second scheme + await expect(raised).resolves.toBe(boom); + }); }); diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 6524d631..dfcd3b55 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -1,5 +1,6 @@ import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; +import type { Requirements } from "@btravstack/contract"; import { Port, type AnyPort, type PortClassOf, type ServiceOf } from "@btravstack/di"; import { ORPCError } from "@orpc/server"; import { TaggedError, type AsyncResult } from "unthrown"; @@ -118,31 +119,63 @@ export const HttpAuthenticator = () => { }; /** - * The one middleware this package installs, and only on a marked leaf. It reads - * the request from oRPC's initial context — which is what initial context is - * for — and either injects the principal or refuses. + * The one middleware this package installs, and only on a leaf whose + * requirements say so. It reads the request from oRPC's initial context — which + * is what initial context is for — and tries the requirements in the order the + * contract declared them, taking the first a caller satisfies. */ export const principalMiddleware = - (authenticate: AuthenticatorService) => + ( + requirements: Requirements, + authenticators: Readonly>>, + ) => async (options: { readonly context: { readonly request: IncomingMessage }; readonly next: (injected: { readonly context: { readonly principal: unknown }; }) => Promise; }): Promise => { - const resolved = await authenticate(options.context.request.headers); - if (resolved.isErr()) { - // No message: oRPC serializes `message` to the client, and a refusal - // has nothing a caller is entitled to. - // oxlint-disable-next-line unthrown/no-throw -- oRPC terminates a request by throwing an ORPCError; its middleware protocol has no returned-error arm to use instead - throw new ORPCError("UNAUTHORIZED"); + // Tagged only when the leaf names more than one scheme: the single-scheme + // form is what applications already write, and paying a wrapper for it + // would make the common case worse to serve the rare one. + const tagged = requirements.length > 1; + let underScoped = false; + for (const requirement of requirements) { + for (const [scheme, required] of Object.entries(requirement)) { + // Asserted, not guarded: the router declares one dep per scheme its + // contract names, so every scheme a requirement names is a key here and + // di refuses the graph long before a request lands. + const authenticate = authenticators[scheme] as AuthenticatorService; + const resolved = await authenticate(options.context.request.headers); + if (resolved.isDefect()) { + // A defect is a bug in the authenticator, not a refusal. Falling + // through would let a broken verifier silently promote every caller + // to the next scheme. + // oxlint-disable-next-line unthrown/no-throw -- the only way to hand a defect back to oRPC, whose middleware protocol has no returned-error arm + throw resolved.cause; + } + if (resolved.isErr()) continue; + // The port's service type is erased, which spells the scoped form for + // every scheme; one with no vocabulary answers bare, so this is read + // back structurally rather than trusted. + const granted: unknown = resolved.value; + const scoped = + typeof granted === "object" && granted !== null && "scopes" in granted + ? (granted as { readonly identity: unknown; readonly scopes: readonly string[] }) + : undefined; + if (scoped !== undefined && !required.every((scope) => scoped.scopes.includes(scope))) { + underScoped = true; + continue; + } + const identity = scoped === undefined ? granted : scoped.identity; + return await options.next({ + context: { principal: tagged ? { scheme, identity } : identity }, + }); + } } - if (resolved.isDefect()) { - // A defect is a bug in the authenticator, not a refusal. Its own cause - // goes up unchanged so oRPC's INTERNAL_SERVER_ERROR collapse answers it — - // folding it into the 401 above would report a bug as a rejected caller. - // oxlint-disable-next-line unthrown/no-throw -- the only way to hand a defect back to oRPC, whose middleware protocol has no returned-error arm - throw resolved.cause; - } - return options.next({ context: { principal: resolved.value } }); + // No message: oRPC serializes `message` to the client, and a refusal has + // nothing a caller is entitled to. A credential that was valid but + // under-scoped is a 403, never the 401 an anonymous caller gets. + // oxlint-disable-next-line unthrown/no-throw -- oRPC terminates a request by throwing an ORPCError; its middleware protocol has no returned-error arm to use instead + throw new ORPCError(underScoped ? "FORBIDDEN" : "UNAUTHORIZED"); }; diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index d1759a67..7fce9902 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -1,4 +1,4 @@ -import type { Server } from "node:http"; +import type { IncomingHttpHeaders, Server } from "node:http"; import { vi } from "vitest"; @@ -540,6 +540,8 @@ export type HttpFixtures = { }; /** The starter over a router with oRPC's CORS plugin configured. Shut down by the fixture. */ readonly rpcWithCors: { readonly url: string }; + /** A bare request's headers — the one argument an authenticator is handed. */ + readonly headers: IncomingHttpHeaders; }; export const it = test.extend({ @@ -771,6 +773,11 @@ export const it = test.extend({ }); }, + // oxlint-disable-next-line no-empty-pattern -- see above + headers: async ({}, use) => { + await use({}); + }, + rpcWithCors: async ({ boot }, use) => { const app = boot(rpcWithCorsAppOf()); const info = (await app.runtimeInfo()).get(); From fe6822083f5ae5de6c15b6d849ea9e4e4b147fb1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 22:05:44 +0200 Subject: [PATCH 09/18] feat(http)!: the sugar carries the authenticators, so an application never lists them --- packages/http/src/auth.spec.ts | 29 ++- packages/http/src/auth.test-d.ts | 309 +++++++++---------------- packages/http/src/auth.ts | 24 +- packages/http/src/controller.test-d.ts | 2 +- packages/http/src/define-http.ts | 2 +- packages/http/src/http-module.ts | 67 ++---- packages/http/src/orpc.ts | 13 +- packages/http/src/test-fixtures.ts | 151 ++++++++---- 8 files changed, 279 insertions(+), 318 deletions(-) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 6f088d40..66320085 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -5,12 +5,12 @@ import { Unauthenticated, principalMiddleware } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("an authenticated procedure", () => { - it("hands the handler the identity its factory typed", async ({ rpcAuthed }) => { + it("hands the handler the identity its scheme resolves", async ({ rpcAuthed }) => { // GIVEN a client presenting a token the authenticator accepts const client = rpcAuthed.clientWith("good"); // WHEN a marked procedure reads a field the contract declares nowhere — - // the contract names no identity type, so `httpAuth()` is the + // the contract names no identity type, so `defineHttp`'s registry is the // only thing that could have typed it await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-good" }); }); @@ -65,6 +65,20 @@ describe("an authenticated procedure", () => { }); }); +describe("an authenticator with dependencies of its own", () => { + it("is built from the services it declared, and names the caller with them", async ({ + rpcVerified, + }) => { + // GIVEN a client presenting a token only the injected table knows + const client = await rpcVerified("keyed"); + + // WHEN a marked procedure is called + // THEN the authenticator resolved it through the dependency di gave it — + // `defineHttp` bound the deps arm, and the need reached the graph + await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-keyed" }); + }); +}); + describe("a contract marked at its root", () => { it("protects every leaf beneath it", async ({ rpcRootMarked }) => { // GIVEN a client presenting a token the authenticator rejects @@ -93,19 +107,20 @@ describe("a contract marked at its root", () => { }); describe("a router over a marked contract", () => { - it("adds the authenticator to the dependencies the caller already declared", ({ + it("declares the scheme's own port alongside the dependencies the caller wrote", ({ authedRouterDeps, }) => { // GIVEN the same marked contract composed through both arms of HttpRouter // WHEN each provider's declared dependencies are read - // THEN the authenticator joins both, alongside — never in place of — the caller's own + // THEN the scheme's port joins both, named for the scheme and alongside — + // never in place of — the caller's own expect(authedRouterDeps).toEqual({ - keyed: ["AuthedOrders", "AuthedHealth", "HttpAuthenticator"], - fromDeps: ["Greeter", "HttpAuthenticator"], + keyed: ["AuthedOrders", "AuthedHealth", "HttpAuthenticator:user"], + fromDeps: ["Greeter", "HttpAuthenticator:user"], }); }); - it("declares no authenticator when the contract marks nothing", ({ controllers }) => { + it("declares no scheme port when the contract marks nothing", ({ controllers }) => { // GIVEN a router composed from a controller over an unmarked contract // WHEN its declared dependencies are read // THEN nothing was appended — an application with no protected route provides nothing diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index fea11393..53aa0efe 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -1,6 +1,7 @@ -// The type half of the auth marker: a marked contract node types its handler's -// principal on oRPC's own context channel, and an unmarked one does not. Each -// `@ts-expect-error` is an assertion: if one stops erroring, the gate is gone. +// The type half of the auth surface: a marked contract node types its handler's +// principal on oRPC's own context channel from the requirements it names, and +// an unmarked one does not. Each `@ts-expect-error` is an assertion: if one +// stops erroring, the gate is gone. import { Env } from "@btravstack/config"; import { authenticated, type Authenticated } from "@btravstack/contract"; import { start } from "@btravstack/core"; @@ -10,24 +11,32 @@ import { ErrAsync, OkAsync } from "unthrown"; import { expectTypeOf } from "vitest"; import { HttpAuthenticator, Unauthenticated } from "./auth.js"; -import { HttpController } from "./controller.js"; -import { httpAuth } from "./http-auth.js"; +import { defineHttp } from "./define-http.js"; import { HttpModule } from "./http-module.js"; -import { HttpRouter, type HasMark, type Implementation } from "./orpc.js"; +import type { HasMark, Implementation } from "./orpc.js"; type Identity = { readonly userId: string; readonly tenantId: string }; +type ServiceIdentity = { readonly appId: string }; const contract = { - orders: authenticated({ place: oc }), + orders: authenticated({ user: [] })({ place: oc }), health: { ping: oc }, - quote: authenticated(oc), + quote: authenticated({ user: [] }, { service: [] })(oc), }; -const { - HttpController: IdentityController, - HttpRouter: IdentityRouter, - HttpAuthenticator: IdentityAuthenticator, -} = httpAuth(); +/** Two schemes, so the tagged principal and the bare one are both in play. */ +const api = defineHttp({ + authenticators: { + user: HttpAuthenticator()({ + sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), + }), + service: HttpAuthenticator()({ + sync: () => () => OkAsync({ appId: "a" }), + }), + }, +}); + +type Schemes = { readonly user: Identity; readonly service: ServiceIdentity }; type Expect = T; type HandlerContext = H extends (opts: infer O, ...rest: never) => unknown @@ -36,19 +45,24 @@ type HandlerContext = H extends (opts: infer O, ...rest: never) => unknown : never : never; -type OrdersImpl = Implementation<(typeof contract)["orders"], Identity>; -type HealthImpl = Implementation<(typeof contract)["health"], Identity>; -type QuoteImpl = Implementation<(typeof contract)["quote"], Identity>; +type OrdersImpl = Implementation<(typeof contract)["orders"], Schemes>; +type HealthImpl = Implementation<(typeof contract)["health"], Schemes>; +type QuoteImpl = Implementation<(typeof contract)["quote"], Schemes>; -// 1. A marked RECORD pushes its marker onto every procedure beneath it, and the -// factory's identity arrives on `opts.context` — oRPC's own channel, no -// second handler parameter added by this package. +// 1. A marked RECORD pushes its requirements onto every procedure beneath it, +// and the scheme's identity arrives on `opts.context` — oRPC's own channel, +// no second handler parameter added by this package. One scheme, so it is +// the identity bare. declare const ordersContext: HandlerContext; const _inherited: Identity = ordersContext.principal; -// 2. A marked PROCEDURE protects itself. +// 2. A marked PROCEDURE protects itself, and two requirements make the +// principal a discriminated union rather than a widened guess. declare const quoteContext: HandlerContext; -const _leaf: Identity = quoteContext.principal; +expectTypeOf(quoteContext.principal).toEqualTypeOf< + | { readonly scheme: "user"; readonly identity: Identity } + | { readonly scheme: "service"; readonly identity: ServiceIdentity } +>(); // 3. The marker's phantom key never becomes a procedure key. type _OrdersKeys = Expect< @@ -84,101 +98,72 @@ type _Unmarked = Expect< >; void _inherited; -void _leaf; void _none; -// The composition half: a marked contract needs an authenticator, and the -// composition root is where the router and the authenticator meet. The two -// gates below are DIFFERENT gates, and fire at different calls. Whether an -// authenticator is there at all is an unmet need `start` refuses (7) — its -// `module` parameter takes only `Scope | Env` outstanding, so the diagnostic -// names the port; NOT di's `UNSATISFIED DEPENDENCIES` arity gate, which guards -// `Module.build`/`Module.scoped`. Same mechanism as -// `examples/order-api/src/needs-gate.test-d.ts` pins for the router. Whether it resolves what the handlers read is this -// package's own options check at the `HttpModule(...)` call (8), because -// `AuthenticatorPort`'s service type is erased to `AuthenticatorService< -// unknown>`: the need cannot carry the identity, so only the options type -// can compare it — the ROUTER's identity against the AUTHENTICATOR's, since -// the contract declares none. -const markedRouter = IdentityRouter({ orders: contract.orders, health: contract.health })({ +// The composition half. Declaring a scheme and implementing it are now the same +// act, so there is no authenticator to forget and no identity pair to compare — +// what is left is di's own unmet need, on the port whose id carries the scheme +// name. +const markedRouter = api.HttpRouter({ orders: contract.orders, health: contract.health })({ sync: () => ({ orders: { place: ({ context }) => OkAsync({ id: context.principal.userId }) }, health: { ping: () => OkAsync({ ok: true as const }) }, }), }); -const matching = IdentityAuthenticator({ - sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), -}); -const other = httpAuth<{ readonly sub: string }>().HttpAuthenticator({ - sync: () => () => OkAsync({ sub: "s" }), -}); - const options = { signals: false, probes: false } as const; -// 7. A marked router with no authenticator supplied owes the port, and since -// di's `needs` gate that is refused HERE rather than at `start` — the -// module is where the omission is, and naming it in `needs` would only move -// the obligation to a root that still has to discharge it. -// @ts-expect-error — UNDECLARED NEEDS: the authenticator port the marked router needs. -const MissingApi = HttpModule("Missing")({ needs: [Env], router: markedRouter }); -// @ts-expect-error — and the kernel's own gate still refuses it, on the needs channel. -const _missing = start(MissingApi, options); - -// 8. An authenticator minted on a DIFFERENT identity is refused. Unlike 7, -// this one is not the needs channel and does not wait for `start`: the -// authenticator port's service type is erased to `unknown`, so di sees the -// need discharged. The two identities meet on `HttpModule`'s own options — -// `RouterIdentity` is inferred from the router — which is where it is caught. -const MismatchedApi = HttpModule("Mismatched")({ - needs: [Env], - router: markedRouter, - // @ts-expect-error — the authenticator's identity is not the router's. - authenticator: other, -}); +// 7. The application lists no authenticators: the sugar carries them in from +// the same call that declared the schemes, so this is the whole root. +const WiredApi = HttpModule("Wired")({ needs: [Env], router: markedRouter }); +const _wired = start(WiredApi, options); -// 9. The matching pair compiles. -const WiredApi = HttpModule("Wired")({ +// 8. `authenticator` is gone as an option — schemes come from `defineHttp`. +void HttpModule("Rejected")({ needs: [Env], router: markedRouter, - authenticator: matching, -}); -const _wired = start(WiredApi, options); - -// 10. An unmarked router with an authenticator supplied is not this package's -// error to raise: di decides, and a provider nothing needs is no defect. -const publicRouter = IdentityRouter({ health: contract.health })({ - sync: () => ({ health: { ping: () => OkAsync({ ok: true as const }) } }), + // @ts-expect-error — there is no `authenticator` option any more + authenticator: HttpAuthenticator()({ + sync: () => () => OkAsync({ userId: "u", tenantId: "t" }), + }), }); -const _public = start( - HttpModule("Public")({ needs: [Env], router: publicRouter, authenticator: matching }), - options, -); -void _missing; -void MismatchedApi; void _wired; -void _public; - -// 11. A ROOT-marked contract composes through the KEYED form, and a controller -// under it reads the identity the factory declares. The keyed overload -// must therefore `Exclude` the phantom key from the keys it demands (or the -// record can never be complete) and `Inherit` the root's mark down to each -// fragment (or no controller under it could type `context.principal`) — -// both of which the deps arm already did. `contract.orders` above -// marks a KEY, so neither omission showed there. -declare const ordersFragment: Authenticated<{ readonly whoami: typeof oc }>; -const rootOrders = IdentityController( + +// 9. A contract naming a scheme the registry has no authenticator for is +// refused, and it is refused as an ORDINARY unmet need on that scheme's own +// port — not a gate this package writes. `defineHttp()` declares nothing, so +// `HttpAuthenticator:user` reaches nobody. +const openApi = defineHttp(); +const strandedFragment = { orders: authenticated({ user: [] })({ place: oc }) }; +const strandedRouter = openApi.HttpRouter(strandedFragment)({ + // The handler reads no principal — under `defineHttp()` it would be `never`. + sync: () => ({ orders: { place: () => OkAsync({ id: "o-1" }) } }), +}); +// @ts-expect-error — UNDECLARED NEEDS: nothing discharges `HttpAuthenticator:user` +void HttpModule("Stranded")({ needs: [Env], router: strandedRouter }); + +// 10. A ROOT-marked contract composes through the KEYED form, and a controller +// under it reads the identity its scheme resolves. The keyed overload must +// therefore `Exclude` the phantom key from the keys it demands (or the +// record can never be complete) and `Inherit` the root's requirements down +// to each fragment (or no controller under it could type +// `context.principal`) — both of which the deps arm already did. +// `contract.orders` above marks a KEY, so neither omission showed there. +declare const ordersFragment: Authenticated< + { readonly whoami: typeof oc }, + [{ readonly user: readonly [] }] +>; +const rootOrders = api.HttpController( "RootOrders", ordersFragment, )({ sync: () => ({ whoami: ({ context }) => OkAsync(context.principal.userId) }), }); -const rootMarkedContract = authenticated({ orders: { whoami: oc } }); +const rootMarkedContract = authenticated({ user: [] })({ orders: { whoami: oc } }); const _rootKeyed = HttpModule("RootKeyed")({ needs: [Env], - router: IdentityRouter(rootMarkedContract)({ orders: rootOrders }), - authenticator: matching, + router: api.HttpRouter(rootMarkedContract)({ orders: rootOrders }), // The controller is provided too: the keyed router depends on its PORT, and // a root that names no slice still owes it. provides: [rootOrders], @@ -186,104 +171,36 @@ const _rootKeyed = HttpModule("RootKeyed")({ void _rootKeyed; -// The contract says WHETHER a route is protected; the factory says WHAT the -// principal is. The arms below are what makes that division checkable: an -// identity a contract could never have named, and the top-level form — which -// names none — refusing to invent one. - -// 12. A factory-minted controller's MARKED handler sees the factory's identity, -// a type the contract declares nowhere. -const scopedOrders = IdentityController( - "ScopedOrders", - contract.orders, -)({ - sync: () => ({ place: ({ context }) => OkAsync(context.principal.tenantId) }), -}); - -// 13. The top-level `HttpController` mints no identity, so the same marked -// fragment types `principal: never` — the "use the factory" signal, since -// any read of it is a compile error. -void HttpController( - "ContractOrders", - contract.orders, -)({ - // @ts-expect-error — no factory, so there is no principal type to read - sync: () => ({ place: ({ context }) => OkAsync(context.principal.userId) }), -}); - -// 14. A factory invents no principal on an UNMARKED fragment: the identity -// reaches a marked leaf and no other. -void IdentityController( - "ScopedHealth", - contract.health, -)({ - // @ts-expect-error — `principal` is not on an unmarked handler's context - sync: () => ({ ping: ({ context }) => OkAsync(context.principal.tenantId) }), -}); - -// 15. A factory-minted router composes factory-minted controllers, and the -// `HttpModule` gate checks the authenticator against the ROUTER's identity. -const scopedHealth = IdentityController( - "ScopedHealthOk", - contract.health, -)({ - sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), -}); -const _scoped = HttpModule("Scoped")({ - needs: [Env], - router: IdentityRouter({ orders: contract.orders, health: contract.health })({ - orders: scopedOrders, - health: scopedHealth, - }), - authenticator: matching, - provides: [scopedOrders, scopedHealth], -}); - -// 16. An authenticator minted on another identity is still refused, and a -// hand-written `HttpAuthenticator

()` is no way around it. -const strayAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ - sync: () => () => OkAsync({ sub: "s" }), -}); -const _strayScoped = HttpModule("StrayScoped")({ - needs: [Env], - router: IdentityRouter({ orders: contract.orders, health: contract.health })({ - orders: scopedOrders, - health: scopedHealth, - }), - // @ts-expect-error — the authenticator's identity is not the router's - authenticator: strayAuthenticator, -}); - -// 17. An authenticator that DECLARES DEPENDENCIES is the documented shape — a -// JWT verifier, a key set, a user directory — and it discharges the gate -// like any other. Pinned because nothing else covers it: every other -// authenticator on this branch takes `[]`, so the one form every adopter -// actually writes was checked by a reviewer's scratch file and by nothing -// that runs. `deps` are di's, so the services arrive by name and -// `sync` closes over them; what reaches `HttpModule` is still a provider -// on the same identity. +// 11. An authenticator that DECLARES DEPENDENCIES is the documented shape — a +// JWT verifier, a key set, a user directory. Its own need travels with it +// into `provides`, so a root that imports nothing satisfying it is refused +// at THIS call by di's `NeedsGate`, exactly as a hand-listed provider would +// be. That is what carrying the authenticators on the router has to buy. class Verifier extends Port("Verifier")<(token: string) => Identity | undefined> {} -const verifiedAuthenticator = IdentityAuthenticator( - { verify: Verifier }, - { - sync: - ({ verify }) => - (headers) => { - const claimed = verify(headers.authorization ?? ""); - return claimed === undefined ? ErrAsync(new Unauthenticated()) : OkAsync(claimed); +const verifying = defineHttp({ + authenticators: { + user: HttpAuthenticator()( + { verify: Verifier }, + { + sync: + ({ verify }) => + (headers) => { + const claimed = verify(headers.authorization ?? ""); + return claimed === undefined ? ErrAsync(new Unauthenticated()) : OkAsync(claimed); + }, }, + ), }, -); +}); + +const verifiedRouter = verifying.HttpRouter({ orders: contract.orders })({ + sync: () => ({ orders: { place: ({ context }) => OkAsync({ id: context.principal.tenantId }) } }), +}); const _verified = HttpModule("Verified")({ needs: [Env], - router: IdentityRouter({ orders: contract.orders, health: contract.health })({ - orders: scopedOrders, - health: scopedHealth, - }), - authenticator: verifiedAuthenticator, - provides: [scopedOrders, scopedHealth], + router: verifiedRouter, imports: [ Module("Verifying")({ provides: [Provider(Verifier)({ value: () => undefined })], @@ -292,28 +209,12 @@ const _verified = HttpModule("Verified")({ ], }); -// 18. The dependency does not loosen the identity check: the same declared -// deps with a foreign identity are still refused at the same call. -const verifiedStray = HttpAuthenticator<{ readonly sub: string }>()( - { verify: Verifier }, - { - sync: () => () => OkAsync({ sub: "s" }), - }, -); -const _verifiedStray = HttpModule("VerifiedStray")({ - needs: [Env], - router: IdentityRouter({ orders: contract.orders, health: contract.health })({ - orders: scopedOrders, - health: scopedHealth, - }), - // @ts-expect-error — declaring deps is no way around the identity gate - authenticator: verifiedStray, -}); +// 12. The same root with nothing supplying `Verifier` is refused: the +// authenticator's need is real, not erased by riding in on the router. +// @ts-expect-error — UNDECLARED NEEDS: the authenticator's own `Verifier` +void HttpModule("Unverified")({ needs: [Env], router: verifiedRouter }); -void _scoped; -void _strayScoped; void _verified; -void _verifiedStray; // A scheme granting no scopes returns the identity bare — unchanged from what // applications write today, which is the point. diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index dfcd3b55..501e7654 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -35,9 +35,11 @@ const ports = new Map(); /** * One port per scheme, its id carrying the scheme name — the move - * `AmqpHandler(contract, key)` makes. The service type is erased because di - * identifies a port by id; the principal and scope types ride the provider - * `HttpAuthenticator` returns, and `defineHttp` reads the registry off them. + * `AmqpHandler(contract, key)` makes. The service type is erased to + * `AuthenticatorService` — `Granted` is `unknown`, so + * it admits the bare and the scoped answer alike — because di identifies a port + * by id; the principal and scope types ride the description `HttpAuthenticator` + * returns, and `defineHttp` reads the registry off them. * * The id is a LITERAL type, so `PortInstance<"HttpAuthenticator:user", …>` and * `PortInstance<"HttpAuthenticator:service", …>` are different types: a @@ -47,7 +49,7 @@ const ports = new Map(); */ export const authenticatorPort = ( scheme: S, -): PortClassOf<`HttpAuthenticator:${S}`, AuthenticatorService> => { +): PortClassOf<`HttpAuthenticator:${S}`, AuthenticatorService> => { const id = `HttpAuthenticator:${scheme}` as const; // Memoised: `defineHttp` asks for a scheme's port when it binds the // authenticator and `routerFor` asks again for every scheme its contract @@ -55,7 +57,7 @@ export const authenticatorPort = ( const existing = ports.get(id); if (existing !== undefined) return existing as never; // oxlint-disable-next-line typescript/no-extraneous-class -- a port is a phantom token; only a class expression carries the construct signature `PortClassOf` describes - const minted = class extends Port(id)> {}; + const minted = class extends Port(id)> {}; ports.set(id, minted); return minted as never; }; @@ -127,7 +129,7 @@ export const HttpAuthenticator = () => { export const principalMiddleware = ( requirements: Requirements, - authenticators: Readonly>>, + authenticators: Readonly>>, ) => async (options: { readonly context: { readonly request: IncomingMessage }; @@ -145,7 +147,7 @@ export const principalMiddleware = // Asserted, not guarded: the router declares one dep per scheme its // contract names, so every scheme a requirement names is a key here and // di refuses the graph long before a request lands. - const authenticate = authenticators[scheme] as AuthenticatorService; + const authenticate = authenticators[scheme] as AuthenticatorService; const resolved = await authenticate(options.context.request.headers); if (resolved.isDefect()) { // A defect is a bug in the authenticator, not a refusal. Falling @@ -155,10 +157,10 @@ export const principalMiddleware = throw resolved.cause; } if (resolved.isErr()) continue; - // The port's service type is erased, which spells the scoped form for - // every scheme; one with no vocabulary answers bare, so this is read - // back structurally rather than trusted. - const granted: unknown = resolved.value; + // `Granted` is erased to `unknown` on the port, because a scheme with a + // vocabulary answers `{ identity, scopes }` and one without answers the + // identity bare — so which it is has to be read back structurally. + const granted = resolved.value; const scoped = typeof granted === "object" && granted !== null && "scopes" in granted ? (granted as { readonly identity: unknown; readonly scopes: readonly string[] }) diff --git a/packages/http/src/controller.test-d.ts b/packages/http/src/controller.test-d.ts index 42388624..feda293d 100644 --- a/packages/http/src/controller.test-d.ts +++ b/packages/http/src/controller.test-d.ts @@ -196,7 +196,7 @@ const twoSchemeRouter = api.HttpRouter(grouped)({ }); type SchemePort = S extends string - ? PortInstance<`HttpAuthenticator:${S}`, AuthenticatorService> + ? PortInstance<`HttpAuthenticator:${S}`, AuthenticatorService> : never; // BOTH directions. A one-way check passes on a collapsed `never`, which is how diff --git a/packages/http/src/define-http.ts b/packages/http/src/define-http.ts index d0016772..d0efde25 100644 --- a/packages/http/src/define-http.ts +++ b/packages/http/src/define-http.ts @@ -18,7 +18,7 @@ export type SchemesFrom = { readonly [K in keyof A]: A */ type SchemeProviders = { readonly [K in keyof A]: Provider< - PortInstance<`HttpAuthenticator:${K & string}`, AuthenticatorService>, + PortInstance<`HttpAuthenticator:${K & string}`, AuthenticatorService>, never, A[K]["needs"] >; diff --git a/packages/http/src/http-module.ts b/packages/http/src/http-module.ts index 02b0205a..2a336a38 100644 --- a/packages/http/src/http-module.ts +++ b/packages/http/src/http-module.ts @@ -11,7 +11,6 @@ import { import type { DefaultInitialContext } from "@orpc/server"; import type { NodeHttpHandlerPlugin } from "@orpc/server/node"; -import type { AuthenticatorPort } from "./auth.js"; import { HttpRuntime, http, type HttpConfig } from "./http-runtime.js"; import type { HttpRouterPort } from "./orpc.js"; @@ -22,50 +21,38 @@ type HttpStarter = Module = readonly [...I, HttpStarter]; /** - * The router provider, the authenticator when there is one, and the - * application's own — the tuple `Module(name)` is handed. `Auth` is inferred - * from the option, so an omitted authenticator contributes no element and the - * marked router's need for one stays unmet: di's gate, at `start`. + * The router provider, the scheme authenticators `defineHttp` bound, and the + * application's own — what `Module(name)` is handed. A union-element array + * rather than a tuple: `Auth` is one type per scheme, so it arrives as a union + * and a tuple takes one rest element, not two. di reads `P[number]` throughout, + * so nothing downstream wants the arity — and the authenticators' own needs + * (a `JwtVerifier`, a key set) reach `NeedsGate` because they are here. */ type Provides< P extends readonly AnyProvider[], RouterError, RouterNeeds, - Auth extends AnyProvider | undefined, -> = readonly [ - Provider, - ...([Auth] extends [undefined] ? [] : [NonNullable]), - ...P, -]; + Auth extends AnyProvider, +> = readonly (Provider | Auth | P[number])[]; export type HttpModuleOptions< RouterError, RouterNeeds, - RouterIdentity, - Auth extends AnyProvider | undefined, + Auth extends AnyProvider, I extends readonly AnyModule[], P extends readonly AnyProvider[], X extends readonly Exportable, Provides>[], N extends readonly AnyPort[], > = { - /** The application's oRPC router — `HttpRouter(contract)(deps, arm)`, the provider that builds it from the services its procedures call. */ - readonly router: Provider & { - readonly identity: RouterIdentity; - }; /** - * Resolves the principal a marked procedure's handler receives — - * `HttpAuthenticator()({ name: Dep }, { sync })`. Required exactly when - * the router's contract marks something: a marked router declares - * `AuthenticatorPort` as a need, and di refuses a graph that does not - * discharge it. Whether it resolves what the handlers actually read is the - * one thing that need cannot say — the port's service type is erased — so - * `RouterIdentity`, read off `router`, is what checks it here: the - * authenticator must resolve **at least** the identity the router was minted - * with, so a router from `httpAuth()` refuses an authenticator from - * `httpAuth()`. A router minted by the top-level `HttpRouter` carries no - * identity (`never`), and there is then nothing to compare. + * The application's oRPC router — `api.HttpRouter(contract)(deps, arm)`, the + * provider that builds it from the services its procedures call. It carries + * the scheme authenticators `defineHttp` bound, which is how they reach + * `provides` without an application ever listing them. */ - readonly authenticator?: Auth; + readonly router: Provider & { + readonly authenticators: readonly Auth[]; + }; /** Where the RPC endpoint is mounted. Default `/rpc`. */ readonly prefix?: `/${string}`; /** Pins for a test — otherwise `PORT`/`HOST` from the environment. */ @@ -122,21 +109,16 @@ export const HttpModule = < RouterError, RouterNeeds, - RouterIdentity, - const Auth extends - | (Provider & { - readonly principal: [RouterIdentity] extends [never] ? unknown : RouterIdentity; - }) - | undefined = undefined, + Auth extends AnyProvider = never, const I extends readonly AnyModule[] = [], const P extends readonly AnyProvider[] = [], const X extends readonly Exportable, Provides>[] = [], const N extends readonly AnyPort[] = [], >( - options: HttpModuleOptions, + options: HttpModuleOptions, ) => { - const { router, authenticator, prefix, port, hostname, plugins, securityHeaders } = options; + const { router, prefix, port, hostname, plugins, securityHeaders } = options; const imports = (options.imports ?? []) as I; const provides = (options.provides ?? []) as P; const exports = (options.exports ?? []) as X; @@ -159,11 +141,12 @@ export const HttpModule = // `Module` (measured). return Module(name)({ imports: [...imports, starter] as Imports, - provides: [ - router, - ...(authenticator === undefined ? [] : [authenticator]), - ...provides, - ] as unknown as Provides, + provides: [router, ...router.authenticators, ...provides] as unknown as Provides< + P, + RouterError, + RouterNeeds, + Auth + >, exports: [HttpRuntime, ...exports] as readonly [typeof HttpRuntime, ...X], needs: (options.needs ?? []) as N, } as { diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 79badb31..3a6a96a6 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -87,10 +87,11 @@ export const orpc = (options: OrpcOptions = {}) => { }; /** - * The router as a provider, **from the contract**: + * The router as a provider, **from the contract** — minted by `defineHttp`, so + * its handlers are typed by the scheme registry that call inferred: * * ```ts - * const orderRouter = HttpRouter(orderContract)({ place: PlaceOrder, find: FindOrder }, { + * const orderRouter = api.HttpRouter(orderContract)({ place: PlaceOrder, find: FindOrder }, { * sync: ({ place, find }) => ({ * orders: { * place: ({ errors }, input) => place.execute(input.id, input.quantity).map(view).mapErrCases(…), @@ -118,7 +119,7 @@ export const orpc = (options: OrpcOptions = {}) => { * * The second call also takes a **keyed record of controllers** instead of * `(deps, { sync })` — one argument rather than two, which is what tells the - * two apart, exactly as `Provider(port)(…)` discriminates its own: `HttpRouter(contract)({ orders: ordersController, users: + * two apart, exactly as `Provider(port)(…)` discriminates its own: `api.HttpRouter(contract)({ orders: ordersController, users: * usersController })`, one `HttpController` per top-level contract key. Each * fragment is composed as-is rather than re-implemented, and every key of the * contract must be covered — a missing or extra key is a compile error. @@ -205,7 +206,7 @@ export const routerFor = Object.fromEntries( schemes.map((scheme) => [ scheme, - services[`${AUTHENTICATOR}${scheme}`] as AuthenticatorService, + services[`${AUTHENTICATOR}${scheme}`] as AuthenticatorService, ]), ), ), @@ -363,7 +364,7 @@ type SchemesIn = R extends Requirements ? SchemesOf : never; type SchemePortsOf = SchemesIn> extends infer S extends string ? S extends string - ? PortInstance<`HttpAuthenticator:${S}`, AuthenticatorService> + ? PortInstance<`HttpAuthenticator:${S}`, AuthenticatorService> : never : never; @@ -422,7 +423,7 @@ const routerOf = ( implementation: Record, contract: Record, inherited: Requirements | undefined, - authenticators: Readonly>>, + authenticators: Readonly>>, ): Record => Object.fromEntries( Object.entries(implementation).flatMap(([key, value]) => { diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 7fce9902..144f4fb8 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -37,10 +37,9 @@ import { CORSHandlerPlugin } from "@orpc/server/plugins"; import { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { test } from "vitest"; -import { Unauthenticated } from "./auth.js"; -import { HttpController } from "./controller.js"; +import { HttpAuthenticator, Unauthenticated } from "./auth.js"; +import { defineHttp } from "./define-http.js"; import { HttpHandler } from "./handler.js"; -import { httpAuth } from "./http-auth.js"; import { HttpModule } from "./http-module.js"; import { HttpConfig, @@ -49,10 +48,12 @@ import { type HttpInfo, type HttpOptions, } from "./http-runtime.js"; -import { HttpRouter } from "./orpc.js"; type Handler = ServiceOf; +/** Everything the unmarked fixtures below mint: no authenticators, no schemes. */ +const publicApi = defineHttp(); + /** * The transport under test with a bare listener where `http()` would put the * oRPC one — the internal seam `httpModule` exists for, so the guarantees @@ -92,7 +93,7 @@ const greetingImplementation = (greeter: ServiceOf) => ({ }); /** The router as a service, built from the greeter it declares — contract-first, on the starter's own router port. */ -const greetingRouter = HttpRouter(greetingContract)( +const greetingRouter = publicApi.HttpRouter(greetingContract)( { greeter: Greeter }, { sync: ({ greeter }) => greetingImplementation(greeter), @@ -100,7 +101,7 @@ const greetingRouter = HttpRouter(greetingContract)( ); /** Two controllers over the same contract's two halves — what the keyed router composes. */ -export const helloController = HttpController("HelloController", helloFragment)( +export const helloController = publicApi.HttpController("HelloController", helloFragment)( { greeter: Greeter }, { sync: ({ greeter }) => ({ hello: () => OkAsync(greeter.greet("world")) }) }, ); @@ -115,7 +116,7 @@ export const helloController = HttpController("HelloController", helloFragment)( const slicedContract = oc.router({ greetings: helloFragment, echoes: nestedFragment }); /** The other half of `slicedContract`, alongside the reused `helloController`. */ -const echoesController = HttpController( +const echoesController = publicApi.HttpController( "EchoesController", nestedFragment, )({ @@ -132,7 +133,7 @@ const echoesController = HttpController( */ const syncKeyedContract = oc.router({ sync: helloFragment }); -export const syncKeyedRouter = HttpRouter(syncKeyedContract)({ sync: helloController }); +export const syncKeyedRouter = publicApi.HttpRouter(syncKeyedContract)({ sync: helloController }); /** * An arm-only router whose `sync` records its own arity. The arm-only form is @@ -141,7 +142,7 @@ export const syncKeyedRouter = HttpRouter(syncKeyedContract)({ sync: helloContro */ export const armOnlyRouterRecording = () => { let seen = -1; - const provider = HttpRouter(oc.router({ greetings: helloFragment }))({ + const provider = publicApi.HttpRouter(oc.router({ greetings: helloFragment }))({ sync: (...args: readonly unknown[]) => { seen = args.length; return { greetings: { hello: () => OkAsync("hello world") } }; @@ -151,7 +152,7 @@ export const armOnlyRouterRecording = () => { }; /** The same kind of API as `greetingRouter`, composed from controllers instead of one `sync`. */ -const slicedRouter = HttpRouter(slicedContract)({ +const slicedRouter = publicApi.HttpRouter(slicedContract)({ greetings: helloController, echoes: echoesController, }); @@ -171,28 +172,39 @@ const rpcSlicedAppOf = () => /** * What this deployment knows about a caller. The contract names no identity - * type at all, so the factory is the only place one is stated — and the only + * type at all, so `defineHttp` is the only place one is stated — and the only * route by which a handler gets a readable `context.principal`. */ type Identity = { readonly tenantId: string; readonly userId: string }; -const { - HttpController: AuthedController, - HttpRouter: AuthedRouter, - HttpAuthenticator: AuthedAuthenticator, -} = httpAuth(); +const userAuthenticator = HttpAuthenticator()({ + sync: () => (headers) => { + if (headers.authorization === "Bearer boom") { + return OkAsync().map((): Identity => { + // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect + throw new Error("authenticator bug"); + }); + } + return headers.authorization === "Bearer good" + ? OkAsync({ tenantId: "t-good", userId: "u-good" }) + : ErrAsync(new Unauthenticated()); + }, +}); + +/** One scheme, `user` — the registry every marked fixture below is typed by. */ +const api = defineHttp({ authenticators: { user: userAuthenticator } }); /** One protected fragment and one public one — the marker's runtime half, end to end. */ const whoami = oc .input(ocType<{ readonly id: string }>()) .output(ocType<{ readonly userId: string }>()); const ping = oc.output(ocType<{ readonly ok: true }>()); -const authedContract = { orders: authenticated({ whoami }), health: { ping } }; +const authedContract = { orders: authenticated({ user: [] })({ whoami }), health: { ping } }; /** Counted so a test can assert the handler was never entered on a refusal. */ let authedRuns = 0; -const authedOrdersController = AuthedController( +const authedOrdersController = api.HttpController( "AuthedOrders", authedContract.orders, )({ @@ -204,37 +216,23 @@ const authedOrdersController = AuthedController( }), }); -const authedHealthController = AuthedController( +const authedHealthController = api.HttpController( "AuthedHealth", authedContract.health, )({ sync: () => ({ ping: () => OkAsync({ ok: true as const }) }), }); -const authenticator = AuthedAuthenticator({ - sync: () => (headers) => { - if (headers.authorization === "Bearer boom") { - return OkAsync().map((): Identity => { - // oxlint-disable-next-line unthrown/no-throw -- an authenticator bug IS the subject under test, and a throw inside a combinator is the only way to mint a Defect - throw new Error("authenticator bug"); - }); - } - return headers.authorization === "Bearer good" - ? OkAsync({ tenantId: "t-good", userId: "u-good" }) - : ErrAsync(new Unauthenticated()); - }, -}); - -const authedRouter = AuthedRouter(authedContract)({ +const authedRouter = api.HttpRouter(authedContract)({ orders: authedOrdersController, health: authedHealthController, }); /** - * The same marked contract through the deps form, so the authenticator's own - * key on the deps record is pinned for both arms of `build`. + * The same marked contract through the deps form, so the scheme's own key on + * the deps record is pinned for both arms of `build`. */ -const authedPositionalRouter = AuthedRouter(authedContract)( +const authedPositionalRouter = api.HttpRouter(authedContract)( { greeter: Greeter }, { sync: ({ greeter }) => ({ @@ -246,26 +244,25 @@ const authedPositionalRouter = AuthedRouter(authedContract)( }, ); -/** `HttpModule` over the protected router, with the authenticator the router now needs. */ +/** `HttpModule` over the protected router; the authenticator rides in with it. */ const rpcAuthedAppOf = () => HttpModule("RpcAuthedApp")({ router: authedRouter, port: 0, hostname: "127.0.0.1", - authenticator, provides: [authedOrdersController, authedHealthController], }); /** * The marker on the contract's ROOT, where the walk has no `contract[key]` to * read it from: every leaf inherits it, the same way `Implementation`'s - * record arm inherits `IsMarked`. + * record arm inherits the enclosing requirements. */ -const rootMarkedContract = authenticated({ orders: { whoami } }); +const rootMarkedContract = authenticated({ user: [] })({ orders: { whoami } }); let rootMarkedRuns = 0; -const rootMarkedRouter = AuthedRouter(rootMarkedContract)({ +const rootMarkedRouter = api.HttpRouter(rootMarkedContract)({ sync: () => ({ orders: { whoami: ({ context }) => { @@ -281,7 +278,55 @@ const rpcRootMarkedAppOf = () => router: rootMarkedRouter, port: 0, hostname: "127.0.0.1", - authenticator, + }); + +/** + * The other arm of `HttpAuthenticator`: one that DECLARES a dependency — a JWT + * verifier, a key set, a token table — which is the form every adopter writes + * and the one `defineHttp` binds through `Provider(port)(deps, arm)`. + */ +class TokenTable extends Port("TokenTable")<(token: string) => Identity | undefined> {} + +const verifying = defineHttp({ + authenticators: { + user: HttpAuthenticator()( + { tokens: TokenTable }, + { + sync: + ({ tokens }) => + (headers) => { + const claimed = tokens(headers.authorization ?? ""); + return claimed === undefined ? ErrAsync(new Unauthenticated()) : OkAsync(claimed); + }, + }, + ), + }, +}); + +const verifiedRouter = verifying.HttpRouter({ + orders: authenticated({ user: [] })({ whoami }), +})({ + sync: () => ({ + orders: { whoami: ({ context }) => OkAsync({ userId: context.principal.userId }) }, + }), +}); + +const rpcVerifiedAppOf = () => + HttpModule("RpcVerifiedApp")({ + router: verifiedRouter, + port: 0, + hostname: "127.0.0.1", + imports: [ + Module("Tokens")({ + provides: [ + Provider(TokenTable)({ + value: (token) => + token === "Bearer keyed" ? { tenantId: "t-keyed", userId: "u-keyed" } : undefined, + }), + ], + exports: [TokenTable], + }), + ], }); /** `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ @@ -307,7 +352,7 @@ type RootMarkedClient = RouterContractClient<{ * reachable past the types (the assertion is the bypass), which is what * `routerOf`'s own guard exists for: the stray key is dropped, not defected on. */ -const strayRouter = HttpRouter(greetingContract)( +const strayRouter = publicApi.HttpRouter(greetingContract)( { greeter: Greeter }, { sync: ({ greeter }) => @@ -336,7 +381,7 @@ const corsContract = oc.router({ greet: oc.input(ocType<{ readonly name: string }>()).output(ocType()), }); -const corsRouter = HttpRouter(corsContract)({ +const corsRouter = publicApi.HttpRouter(corsContract)({ sync: () => ({ greet: ({ input }) => OkAsync(`hello ${input.name}`) }), }); @@ -515,7 +560,7 @@ export type HttpFixtures = { /** * The starter over a contract whose `orders` fragment is `authenticated(...)`, * with an authenticator that accepts exactly one token — router, controllers - * and authenticator all minted by one `httpAuth()`. Shut down by + * and authenticator all minted by one `defineHttp(...)`. Shut down by * the fixture; the handler's run count is reset before the test body. */ readonly rpcAuthed: { @@ -538,6 +583,12 @@ export type HttpFixtures = { readonly keyed: readonly string[]; readonly fromDeps: readonly string[]; }; + /** + * The starter over a router whose scheme's authenticator DECLARES a + * dependency, resolved by an imported module — the form `defineHttp` binds + * through `Provider(port)(deps, arm)`. Shut down by the fixture. + */ + readonly rpcVerified: (token: string) => Promise; /** The starter over a router with oRPC's CORS plugin configured. Shut down by the fixture. */ readonly rpcWithCors: { readonly url: string }; /** A bare request's headers — the one argument an authenticator is handed. */ @@ -773,6 +824,14 @@ export const it = test.extend({ }); }, + rpcVerified: async ({ boot }, use) => { + const app = boot(rpcVerifiedAppOf()); + const info = (await app.runtimeInfo()).get(); + assert.ok(info !== undefined, "the runtime published no Serving.info"); + const origin = `http://127.0.0.1:${info.port}`; + await use((token) => Promise.resolve(createORPCClient(linkOf(origin, token)))); + }, + // oxlint-disable-next-line no-empty-pattern -- see above headers: async ({}, use) => { await use({}); From 495dd1d0e064a01b49f6c56cdec53bb33194fab6 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 22:20:21 +0200 Subject: [PATCH 10/18] fix(http): a declared scope is enforced, and tagging counts schemes --- packages/http/src/auth.spec.ts | 57 ++++++++++++++++++++++++++++++++++ packages/http/src/auth.ts | 19 +++++++++--- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 66320085..37df11be 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -168,6 +168,63 @@ describe("a leaf naming several requirements", () => { ); }); + it("admits a caller whose credential grants the scope the requirement names", async ({ + headers, + }) => { + // GIVEN an endpoint requiring a scope this credential does grant + const middleware = principalMiddleware([{ user: ["orders:export"] }], { + user: () => + OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:read", "orders:export"] }), + }); + + // WHEN a request arrives + const injected = middleware({ + context: { request: { headers } as never }, + next: (o) => Promise.resolve(o.context.principal), + }); + + // THEN the identity is injected, bare — the leaf names one scheme, and the + // scopes are checked rather than handed to the handler + await expect(injected).resolves.toEqual({ userId: "u-1" }); + }); + + it("refuses with FORBIDDEN when the scheme grants no scopes at all", async ({ headers }) => { + // GIVEN a requirement naming a scope against a scheme declared with no + // vocabulary, which answers the identity BARE + const middleware = principalMiddleware([{ user: ["orders:export"] }], { + user: () => OkAsync({ userId: "u-1" }), + }); + + // WHEN a request arrives + const refused = middleware({ + context: { request: { headers } as never }, + next: () => Promise.resolve(undefined), + }).catch((error: unknown) => error); + + // THEN a credential reporting no scopes covers none of them — skipping the + // comparison for a bare answer admitted the caller outright + await expect(refused).resolves.toEqual(expect.objectContaining({ code: "FORBIDDEN" })); + }); + + it("tags the principal when ONE requirement names two schemes", async ({ headers }) => { + // GIVEN a single requirement naming two schemes — what `SchemesOf` unions, + // and so what the handler was typed against + const middleware = principalMiddleware([{ user: [], service: [] }], { + user: () => ErrAsync(new Unauthenticated()), + service: () => OkAsync({ appId: "a-1" }), + }); + + // WHEN a request arrives + const injected = middleware({ + context: { request: { headers } as never }, + next: (o) => Promise.resolve(o.context.principal), + }); + + // THEN it is tagged: counting requirements rather than schemes would inject + // bare here, and `principal.scheme` would read `undefined` + await expect(injected).resolves.toEqual({ scheme: "service", identity: { appId: "a-1" } }); + }); + it("refuses with FORBIDDEN when the credential is valid but under-scoped", async ({ headers, }) => { diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 501e7654..510e9d38 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -137,10 +137,13 @@ export const principalMiddleware = readonly context: { readonly principal: unknown }; }) => Promise; }): Promise => { - // Tagged only when the leaf names more than one scheme: the single-scheme - // form is what applications already write, and paying a wrapper for it - // would make the common case worse to serve the rare one. - const tagged = requirements.length > 1; + // Tagged when the leaf names more than one SCHEME, not more than one + // requirement. One requirement may name several schemes, and counting + // requirements disagreed with `SchemesOf`, which unions the scheme names + // across all of them: the handler typed `Tagged` while this injected bare, + // so `principal.scheme` read `undefined` with no type error to catch it. + const tagged = + new Set(requirements.flatMap((requirement) => Object.keys(requirement))).size > 1; let underScoped = false; for (const requirement of requirements) { for (const [scheme, required] of Object.entries(requirement)) { @@ -165,7 +168,13 @@ export const principalMiddleware = typeof granted === "object" && granted !== null && "scopes" in granted ? (granted as { readonly identity: unknown; readonly scopes: readonly string[] }) : undefined; - if (scoped !== undefined && !required.every((scope) => scoped.scopes.includes(scope))) { + // A requirement that names scopes is NOT satisfied by a credential + // reporting none. A scheme declared without a vocabulary answers bare, + // and skipping the comparison for it admitted the caller outright — + // the one place in this package where the failure direction matters. + // An empty `required` still passes trivially. + const scopesGranted = scoped?.scopes ?? []; + if (!required.every((scope) => scopesGranted.includes(scope))) { underScoped = true; continue; } From cec785c6cc56a4c599d70a9846c190fe26c2ca06 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 22:24:47 +0200 Subject: [PATCH 11/18] feat(examples)!: the API contract names a scope and a second scheme --- examples/order-api-contract/src/contract.ts | 28 +++++++++++++-------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index 00c96622..dd1c1bb0 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -62,7 +62,7 @@ const customerRef = z.object({ id: z.uuidv7() }); export type CustomerRef = z.infer; /** The orders slice's own fragment — a contract in its own right, so the slice can be served alone. */ -const ordersContract = { +const ordersContract = authenticated({ user: [] })({ place: oc .input(z.object({ id: z.uuidv7(), quantity: z.number() })) .output(orderView) @@ -75,7 +75,14 @@ const ordersContract = { .input(orderRef) .output(orderView) .errors({ NOT_FOUND: { data: orderRef } }), -}; + + // Overrides the group default for itself: a service token may export too, + // and a user token needs the scope. + export: authenticated( + { user: ["orders:export"] }, + { service: [] }, + )(oc.output(z.object({ csv: z.string() }))), +}); /** The customers slice's own fragment. Reached as `contract.customers`; a fragment is a contract in its own right, so the slice can be served alone. */ const customersContract = { @@ -99,10 +106,14 @@ const customersContract = { * controller. Adding a domain error without adding a code here stops that * file compiling. * - * `orders` is `authenticated(...)`, `customers` is not: the marker is a - * type-level fact about the fragment, so a client reads which half of this API - * needs credentials off the contract itself, and a server that serves the - * marked half without an authenticator does not compile. + * `orders` is `authenticated({ user: [] })(...)`, `customers` is not: the + * marker is a type-level fact about the fragment, so a client reads which + * half of this API needs credentials off the contract itself, and a server + * that serves the marked half without an authenticator does not compile. + * `orders.export` overrides that group default for itself — a user token + * needs the `orders:export` scope, or a `service` token needs none — which is + * how the marker exercises a per-procedure override, a scope and a second + * scheme all at once. * * **The contract says WHETHER a route is protected, and nothing about who the * caller is.** No principal type is named here, so nothing about what this @@ -110,7 +121,4 @@ const customersContract = { * client, and enriching it is never a contract change. What the principal * actually is, is `examples/order-api`'s `httpAuth()` to say. */ -export const contract = { - orders: authenticated(ordersContract), - customers: customersContract, -}; +export const contract = { orders: ordersContract, customers: customersContract }; From 5863df9334a5cc8ff8f93352f7150acca51eed3e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 22:40:49 +0200 Subject: [PATCH 12/18] feat(examples)!: the API declares two schemes and a scope in one call --- examples/order-api/src/api.spec.ts | 33 +++++++ examples/order-api/src/auth.ts | 87 ++++++++++++------- examples/order-api/src/authenticator.ts | 34 -------- .../order-api/src/docs-examples.test-d.ts | 47 ++++++---- examples/order-api/src/module.ts | 17 ++-- examples/order-api/src/needs-gate.test-d.ts | 57 ++---------- .../src/slices/customers/controller.ts | 4 +- .../order-api/src/slices/orders/controller.ts | 39 +++++++-- examples/order-api/src/test-fixtures.ts | 20 +++-- 9 files changed, 177 insertions(+), 161 deletions(-) delete mode 100644 examples/order-api/src/authenticator.ts diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index 3715c109..383afaf7 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -357,6 +357,39 @@ describe("order-api", () => { ); }); + it("serves the export to a service token, on the requirement the walk reaches second", async ({ + serve, + serviceClientFor, + api, + }) => { + // GIVEN the real composition root and a caller holding only an API key + const client = await serviceClientFor(serve(api)); + + // WHEN the export is called — `user` is the requirement declared first, and + // this caller presents nothing it accepts + // THEN the walk fell through to the second requirement and served the call + await expect(client.orders.export()).toBeOkWith({ csv: "" }); + }); + + it("refuses a user token that grants no scope with FORBIDDEN, not UNAUTHORIZED", async ({ + serve, + clientFor, + api, + }) => { + // GIVEN a caller whose token is valid but names no scope + const client = await clientFor(serve(api)); + + // WHEN the export, which asks a user token for `orders:export`, is called + const refused = await client.orders.export(); + + // THEN authenticated-but-under-scoped is a 403: the credential was good, so + // this is not the 401 an anonymous caller gets, and no scheme in the + // requirement list rescued it + expect(refused).toBeDefectWith( + expect.objectContaining({ constructor: ORPCError, code: "FORBIDDEN", inferable: false }), + ); + }); + it("refuses a malformed input before the use case is reached", async ({ serve, clientFor, diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts index f00d343f..10e9e7f0 100644 --- a/examples/order-api/src/auth.ts +++ b/examples/order-api/src/auth.ts @@ -1,21 +1,16 @@ -import type { TenantId } from "@btravstack/example-order-domain"; -import { - httpAuth, - type HttpAuthenticatorOf, - type HttpControllerOf, - type HttpRouterOf, -} from "@btravstack/http"; +import { TenantId } from "@btravstack/example-order-domain"; +import { HttpAuthenticator, Unauthenticated, defineHttp } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; /** - * What this deployment knows about a caller — and the one place it is stated. + * What this deployment knows about a caller under the `user` scheme — and the + * one place it is stated. * - * **The contract says whether a route is protected; this says what the - * principal is.** `@btravstack/example-order-api-contract` names no identity - * type at all, so none of this reaches a client and enriching it — roles, an - * org tier, an internal id — is never a contract change. A handler minted - * below sees `Identity` with no annotation at its own call site, and - * `HttpModule`'s gate compares the router's identity against the - * authenticator's, both of which come from the one call here. + * **The contract says whether a route is protected and under which schemes; + * this says what each scheme resolves to.** + * `@btravstack/example-order-api-contract` names no identity type at all, so + * none of this reaches a client and enriching it — roles, an org tier, an + * internal id — is never a contract change. * * `tenantId` is the domain's `TenantId` rather than a `string`, so the value * the authenticator resolved is already the one every port in the application @@ -25,24 +20,52 @@ import { */ export type Identity = { readonly tenantId: TenantId; readonly userId: string }; +/** What the `service` scheme resolves to: a machine caller, with no tenant of its own. */ +export type ServiceIdentity = { readonly appId: string }; + /** - * The three the factory mints, together — imported by the slices instead of - * `@btravstack/http`'s own. Written once per application, because a handler's - * parameter types are fixed where the arrow is written: the composition root - * cannot re-type a `sync` callback that lives in a slice's module. - * - * The authenticator and the controllers cannot disagree about the identity, - * since both come from this call — and there is no other way to read a - * principal: a marked fragment reached through `@btravstack/http`'s own - * top-level `HttpController` types `principal: never`. + * A stand-in, not a recommendation: `Bearer ::`. It + * is also where a header becomes a **tenant**, and therefore the one place + * this path claims the `TenantId` brand: from here on the identity carries it, + * and no controller casts anything. * - * Each is annotated rather than left to inference: a controller's port expands - * to a type carrying `@btravstack/contract`'s phantom `unique symbol`, which - * this file cannot name in its own declaration emit (TS2527). The aliases the - * starter exports are what it names instead. + * The scope vocabulary is declared at the call, so the granted list is checked + * against it here rather than compared as strings at the endpoint. */ -const identity = httpAuth(); +export const userAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; + const [tenantId, userId, scopes = ""] = token.split(":"); + return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ + identity: { tenantId: TenantId(tenantId), userId }, + scopes: scopes + .split(",") + .filter((scope): scope is "orders:export" => scope === "orders:export"), + }); + }, +}); + +/** The second scheme: an API key, no scopes, no tenant — what a reporting job presents. */ +export const serviceAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const key = headers["x-api-key"]; + return typeof key === "string" && key !== "" + ? OkAsync({ appId: key }) + : ErrAsync(new Unauthenticated()); + }, +}); -export const HttpController: HttpControllerOf = identity.HttpController; -export const HttpRouter: HttpRouterOf = identity.HttpRouter; -export const HttpAuthenticator: HttpAuthenticatorOf = identity.HttpAuthenticator; +/** + * The one door: every HTTP entity this application mints comes from here, and + * declaring a scheme and implementing it are the same act — so there is no + * registry to keep in step with the contract and no authenticator for a root + * to list. + * + * Held whole rather than destructured: each binding of a destructured member + * expands to a type mentioning `@btravstack/contract`'s inaccessible + * `unique symbol`, which this file could not emit (TS2527). + */ +export const api = defineHttp({ authenticators: { user: userAuth, service: serviceAuth } }); diff --git a/examples/order-api/src/authenticator.ts b/examples/order-api/src/authenticator.ts deleted file mode 100644 index 85d92ad9..00000000 --- a/examples/order-api/src/authenticator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TenantId } from "@btravstack/example-order-domain"; -import { Unauthenticated } from "@btravstack/http"; -import { ErrAsync, OkAsync } from "unthrown"; - -import { HttpAuthenticator } from "./auth.js"; - -/** - * A stand-in, not a recommendation: `Bearer :`. What matters - * for the example is the shape — an ordinary di provider on the starter's - * port, so a real deployment swaps in JWT verification by composing a - * different provider and changes nothing else. - * - * `[]` because this one needs no service; a verifier, a key set or a user - * directory would be named there and injected the way any provider's - * dependencies are. - * - * The identity is `./auth.ts`'s — the same call the slices' controllers are - * minted from — so a token resolving to the wrong shape is a compile error - * here, and the handlers cannot be reading a different one. - * - * This is also where a header becomes a **tenant**, and therefore the one - * place this path claims the `TenantId` brand: from here on the identity - * carries it, and neither controller casts anything. - */ -export const bearerAuthenticator = HttpAuthenticator({ - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId] = token.split(":"); - return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId: TenantId(tenantId), userId }); - }, -}); diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts index 0736a9b1..c9696896 100644 --- a/examples/order-api/src/docs-examples.test-d.ts +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -38,10 +38,9 @@ import { } from "@btravstack/example-order-infrastructure"; import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; -import { P } from "unthrown"; +import { OkAsync, P } from "unthrown"; -import { HttpController, HttpRouter } from "./auth.js"; -import { bearerAuthenticator } from "./authenticator.js"; +import { api } from "./auth.js"; const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quantity }); @@ -55,13 +54,14 @@ const customerViewOf = (customer: Customer): CustomerView => ({ // "The slices" — docs/examples/order-api.md; "The kernel maps nothing"'s // `place` fragment — docs/explanation/the-kernel-maps-nothing.md. // -// `HttpController` is `./auth.ts`'s, not `@btravstack/http`'s: reached through -// the package's own, a marked fragment types `principal: never` and every read -// below is a compile error. That substitution is half of what these pages -// were getting wrong, so it is pinned by the import rather than asserted. +// The controllers are `./auth.ts`'s `api`, the one `defineHttp` call this +// application makes: reached through anything else, a marked fragment types +// `principal: never` and every read below is a compile error. That +// substitution is half of what these pages were getting wrong, so it is +// pinned by the import rather than asserted. // --------------------------------------------------------------------------- -const ordersController = HttpController("DocsOrdersController", contract.orders)( +const ordersController = api.HttpController("DocsOrdersController", contract.orders)( { place: PlaceOrder, find: FindOrder, logger: Logger }, { sync: ({ place, find, logger }) => ({ @@ -92,13 +92,21 @@ const ordersController = HttpController("DocsOrdersController", contract.orders) errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), ), ), + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + return OkAsync({ csv: context.principal.identity.userId }); + case "service": + return OkAsync({ csv: context.principal.identity.appId }); + } + }, }), }, ); // The unmarked half, and the contrast every page draws: no `principal` on the // context at all, the tenant off the input instead. -const customersController = HttpController("DocsCustomersController", contract.customers)( +const customersController = api.HttpController("DocsCustomersController", contract.customers)( { find: FindCustomer }, { sync: ({ find }) => ({ @@ -134,7 +142,7 @@ const DocsCustomersSlice = Module("DocsCustomersSlice")({ // "The router" and "The composition root" — docs/examples/order-api.md. // --------------------------------------------------------------------------- -const docsRouter = HttpRouter(contract)({ +const docsRouter = api.HttpRouter(contract)({ orders: ordersController, customers: customersController, }); @@ -142,7 +150,6 @@ const docsRouter = HttpRouter(contract)({ const _DocsOrderApi = HttpModule("DocsOrderApi")({ needs: [Env], router: docsRouter, - authenticator: bearerAuthenticator, imports: [DocsOrdersSlice, DocsCustomersSlice, observability()], exports: [Logger], }); @@ -154,11 +161,11 @@ const _DocsOrderApi = HttpModule("DocsOrderApi")({ // // The do-not-break property: the slice, its module and its controller are the // very ones composed above — a new composition root and one fewer import, not -// a rewrite. The lifted fragment carries its marker, so the lifted root needs -// the same authenticator. +// a rewrite. The lifted fragment carries its marker, so the lifted root owes +// the same schemes — which the router brings with it, from the same `api`. // --------------------------------------------------------------------------- -const liftedOrdersRouter = HttpRouter(contract.orders)( +const liftedOrdersRouter = api.HttpRouter(contract.orders)( { implementation: ordersController.port }, { sync: ({ implementation }) => implementation }, ); @@ -166,7 +173,6 @@ const liftedOrdersRouter = HttpRouter(contract.orders)( const _DocsOrdersApi = HttpModule("DocsOrdersApi")({ needs: [Env], router: liftedOrdersRouter, - authenticator: bearerAuthenticator, imports: [DocsOrdersSlice, observability()], }); @@ -178,7 +184,7 @@ const _DocsOrdersApi = HttpModule("DocsOrdersApi")({ // controller all reduce to this call. // --------------------------------------------------------------------------- -const depsOrdersRouter = HttpRouter(contract.orders)( +const depsOrdersRouter = api.HttpRouter(contract.orders)( { place: PlaceOrder, find: FindOrder }, { sync: ({ place, find }) => ({ @@ -207,6 +213,14 @@ const depsOrdersRouter = HttpRouter(contract.orders)( errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), ), ), + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + return OkAsync({ csv: context.principal.identity.userId }); + case "service": + return OkAsync({ csv: context.principal.identity.appId }); + } + }, }), }, ); @@ -214,7 +228,6 @@ const depsOrdersRouter = HttpRouter(contract.orders)( const _DocsDepsApi = HttpModule("DocsDepsApi")({ needs: [Env], router: depsOrdersRouter, - authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], }); diff --git a/examples/order-api/src/module.ts b/examples/order-api/src/module.ts index c2bcad8c..28b4d53b 100644 --- a/examples/order-api/src/module.ts +++ b/examples/order-api/src/module.ts @@ -2,8 +2,7 @@ import { contract } from "@btravstack/example-order-api-contract"; import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; -import { HttpRouter } from "./auth.js"; -import { bearerAuthenticator } from "./authenticator.js"; +import { api } from "./auth.js"; import { customersController } from "./slices/customers/controller.js"; import { CustomersSlice } from "./slices/customers/module.js"; import { ordersController } from "./slices/orders/controller.js"; @@ -14,7 +13,7 @@ import { OrdersSlice } from "./slices/orders/module.js"; * contract's own top-level keys, so a key the contract does not declare is a * compile error and a declared key with no controller is too. */ -export const orderRouter = HttpRouter(contract)({ +export const orderRouter = api.HttpRouter(contract)({ orders: ordersController, customers: customersController, }); @@ -28,12 +27,11 @@ export const orderRouter = HttpRouter(contract)({ * * What is left here is what no slice owns: `observability()`, whose `Logger` * every layer writes to and which is exported because the per-request - * `RequestModule` reads it out of the application scope, and the - * `authenticator` — one per process, because who a caller is is not a slice's - * question. It is required here and nowhere else because the contract marks - * `orders`: the router provider carries `AuthenticatorPort` as a need, so - * dropping this line is an unmet dependency `start` refuses, and supplying one - * that resolves a different principal is a compile error at this very call. + * `RequestModule` reads it out of the application scope. The two + * authenticators are **not** listed: they ride the router, which is what needs + * them, and `HttpModule` puts them in `provides` itself — a scheme the + * contract names with no authenticator behind it is di's own unmet need on + * `HttpAuthenticator:`, not a line this file could forget. * Importing the router * and the starter is what empties the needs channel (a composition without the * router provider does not compile — the starter's provider depends on it, so @@ -51,7 +49,6 @@ export const orderRouter = HttpRouter(contract)({ */ export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); diff --git a/examples/order-api/src/needs-gate.test-d.ts b/examples/order-api/src/needs-gate.test-d.ts index 23133be2..1a1d4547 100644 --- a/examples/order-api/src/needs-gate.test-d.ts +++ b/examples/order-api/src/needs-gate.test-d.ts @@ -14,17 +14,9 @@ import { start } from "@btravstack/core"; * this package's `test:types` script, never executed. */ import { Module } from "@btravstack/di"; -import { - AuthenticatorPort, - HttpAuthenticator, - HttpModule, - HttpRuntime, - http, -} from "@btravstack/http"; +import { HttpRuntime, http } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; -import { OkAsync } from "unthrown"; -import { bearerAuthenticator } from "./authenticator.js"; import { OrderApi, orderRouter } from "./module.js"; import { RequestModule } from "./request-scope.js"; import { CustomersSlice } from "./slices/customers/module.js"; @@ -41,10 +33,11 @@ const _wired = start(OrderApi, options); const RuntimelessApi = Module("RuntimelessApi")({ needs: [Env], imports: [OrdersSlice, CustomersSlice, observability()], - // The authenticator is here so this arm fails on the marker ALONE: the contract - // marks `orders`, so a graph carrying the router without one has an unmet - // need too, and an arm that could fail either way pins neither gate. - provides: [orderRouter, bearerAuthenticator], + // The scheme authenticators are here so this arm fails on the marker ALONE: + // the contract marks `orders`, so a graph carrying the router without them + // has an unmet need too, and an arm that could fail either way pins neither + // gate. `HttpModule` is what spreads them for a root that uses the sugar. + provides: [orderRouter, ...orderRouter.authenticators], exports: [Logger], }); @@ -80,45 +73,9 @@ const _withUnit = start(OrderApi, { ...options, unit: RequestModule }); const UnloggedApi = Module("UnloggedApi")({ needs: [Env], imports: [OrdersSlice, CustomersSlice, observability(), http()], - provides: [orderRouter, bearerAuthenticator], + provides: [orderRouter, ...orderRouter.authenticators], exports: [HttpRuntime], }); // @ts-expect-error — UNSATISFIED UNIT NEEDS: the module does not export Logger for RequestModule to read. const _unitUnmet = start(UnloggedApi, { ...options, unit: RequestModule }); - -// The real root minus its authenticator. `contract.orders` is marked -// `authenticated`, so `HttpRouter` gave the router provider a dependency on -// the starter's `AuthenticatorPort` and nothing here discharges it. Same -// `AuthenticatorPort` IS exported, unlike the router port above, so this arm -// can declare it — and that is what keeps it a `start` negative: declaring -// moves the obligation to the composition root, it does not discharge it. -const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({ - needs: [Env, AuthenticatorPort], - router: orderRouter, - imports: [OrdersSlice, CustomersSlice, observability()], - exports: [Logger], -}); - -// @ts-expect-error — the composition needs the authenticator port and nothing provides it. -const _missingAuthenticator = start(UnauthenticatedApi, options); - -// The OTHER authenticator gate, and a different one: whether the authenticator -// resolves what the handlers read. `AuthenticatorPort`'s service type is -// erased to `unknown`, so the needs channel sees it discharged and would let this -// through — `HttpModuleOptions` compares the ROUTER's identity against the -// authenticator's itself, at the `HttpModule(...)` call, which is why this -// directive sits on the option and not on a `start` below it. The contract -// declares no principal to compare against; `./auth.ts` is what declares one. -const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ - sync: () => () => OkAsync({ sub: "s-1" }), -}); - -const _mismatchedApi = HttpModule("MismatchedApi")({ - needs: [Env], - router: orderRouter, - // @ts-expect-error — the authenticator resolves `{ sub }`, not the router's Identity. - authenticator: wrongAuthenticator, - imports: [OrdersSlice, CustomersSlice, observability()], - exports: [Logger], -}); diff --git a/examples/order-api/src/slices/customers/controller.ts b/examples/order-api/src/slices/customers/controller.ts index 4950ec0f..66282c05 100644 --- a/examples/order-api/src/slices/customers/controller.ts +++ b/examples/order-api/src/slices/customers/controller.ts @@ -3,7 +3,7 @@ import { FindCustomer } from "@btravstack/example-order-application"; import { TenantId, type Customer } from "@btravstack/example-order-domain"; import { P } from "unthrown"; -import { HttpController } from "../../auth.js"; +import { api } from "../../auth.js"; const view = (customer: Customer): CustomerView => ({ id: customer.id, name: customer.name }); @@ -20,7 +20,7 @@ const view = (customer: Customer): CustomerView => ({ id: customer.id, name: cus * shape. A slice is defined by owning its fragment, its controller and its * triage, not by owning a private adapter. */ -export const customersController = HttpController("CustomersController", contract.customers)( +export const customersController = api.HttpController("CustomersController", contract.customers)( { find: FindCustomer }, { sync: ({ find }) => ({ diff --git a/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index fff32daa..3845bebc 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -2,9 +2,9 @@ import { contract, type OrderView } from "@btravstack/example-order-api-contract import { FindOrder, PlaceOrder } from "@btravstack/example-order-application"; import type { Order } from "@btravstack/example-order-domain"; import { Logger } from "@btravstack/observability"; -import { P } from "unthrown"; +import { OkAsync, P } from "unthrown"; -import { HttpController } from "../../auth.js"; +import { api } from "../../auth.js"; const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quantity }); @@ -28,12 +28,20 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * * The tenant comes off `context.principal`, the value this application's own * authenticator resolved from the request's headers — `contract.orders` is - * marked `authenticated`, so the principal is typed here and a handler that - * misreads it does not compile. `HttpController` is `../../auth.ts`'s, minted - * by `httpAuth()`, which is why the principal has a readable type at - * all: the contract says only that the route is protected, and the factory is - * what puts this deployment's own identity in scope where the handler is - * written. Who placed an order is a transport-boundary fact, so it is logged + * marked `authenticated({ user: [] })`, so the principal is typed here and a + * handler that misreads it does not compile. `HttpController` is + * `../../auth.ts`'s `api`, minted by `defineHttp({ authenticators })`, which is + * why the principal has a readable type at all: the contract says only which + * schemes protect the route, and that call is what says what each one + * resolves to. + * + * `export` is where the two halves separate: it names a second scheme, so its + * principal is a discriminated union the handler has to narrow, and the + * compiler checks that every scheme the contract named is answered for. The + * other two name one scheme and keep reading `context.principal.tenantId` + * bare, which is the property the whole design rests on. + * + * Who placed an order is a transport-boundary fact, so it is logged * here rather than pushed through a use case that has no business with it. The fragment's inputs name **no** tenant: a * caller does not get to name the tenant it is served, and a required field * these handlers ignore would be a lie in the contract. The unmarked @@ -47,7 +55,7 @@ const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quant * `Provider(port)` on a port it mints for this controller, so this is a * provider like any other in the graph. */ -export const ordersController = HttpController("OrdersController", contract.orders)( +export const ordersController = api.HttpController("OrdersController", contract.orders)( { place: PlaceOrder, find: FindOrder, logger: Logger }, { sync: ({ place, find, logger }) => ({ @@ -98,6 +106,19 @@ export const ordersController = HttpController("OrdersController", contract.orde errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), ), ), + // A stand-in body: what this procedure is here for is the principal. A + // missing arm leaves a path returning nothing, which the handler's own + // return type refuses — so the switch is exhaustive or the build fails. + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + logger.info("order export requested", { userId: context.principal.identity.userId }); + return OkAsync({ csv: "" }); + case "service": + logger.info("order export requested", { appId: context.principal.identity.appId }); + return OkAsync({ csv: "" }); + } + }, }), }, ); diff --git a/examples/order-api/src/test-fixtures.ts b/examples/order-api/src/test-fixtures.ts index fb82cc2e..a84a35a3 100644 --- a/examples/order-api/src/test-fixtures.ts +++ b/examples/order-api/src/test-fixtures.ts @@ -24,7 +24,6 @@ import { bootFixture, type Boot } from "@btravstack/testing"; import { ErrAsync, fromSafePromise, OkAsync } from "unthrown"; import { inject, test } from "vitest"; -import { bearerAuthenticator } from "./authenticator.js"; import { createOrderApiClient, type OrderApiClient } from "./client.js"; import { OrderApi, orderRouter } from "./module.js"; import { RequestModule } from "./request-scope.js"; @@ -81,11 +80,6 @@ const recorderOf = () => { const apiWith = (repository: ServiceOf, sink: Sink = () => {}) => HttpModule("StubApi")({ router: orderRouter, - // The same authenticator as the real root: the contract marks `orders`, so - // every composition serving that router owes one. Swapping it out is how a - // spec would test a different identity story — not something the transport - // can be asked to skip. - authenticator: bearerAuthenticator, imports: [ OrderApplicationModule, CustomerApplicationModule, @@ -114,7 +108,6 @@ const recordingApi = () => { return { api: HttpModule("RecordingApi")({ router: orderRouter, - authenticator: bearerAuthenticator, // `level` pinned rather than bound: `boot`'s `LOG_LEVEL` silences the // real root, and this root exists to be read. imports: [ @@ -238,6 +231,12 @@ export type ApiFixtures = { app: RunningApp, token: string | undefined, ) => Promise; + /** + * A client presenting an API key and no bearer token — the `service` + * scheme's credential. `export` names `user` first, so this is the caller + * that has to reach the second requirement to be served at all. + */ + readonly serviceClientFor: (app: RunningApp) => Promise; readonly probesFor: (app: RunningApp) => Promise; readonly statusOf: (url: string) => Promise; /** The real composition root. */ @@ -295,6 +294,13 @@ export const it = test.extend({ await use(async (app) => clientWith(app, `Bearer ${tenant}:u-1`)); }, + // oxlint-disable-next-line no-empty-pattern -- see above + serviceClientFor: async ({}, use) => { + await use(async (app) => + createOrderApiClient(await originOf(app), "/rpc", { "x-api-key": "reporting" }), + ); + }, + // oxlint-disable-next-line no-empty-pattern -- see above probesFor: async ({}, use) => { await use(async (app) => { From 1364d0dfe83f7b4969474169e1ce161a109b89ae Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 22:51:20 +0200 Subject: [PATCH 13/18] fix(examples): the bearer token's scope segment survives its own delimiter --- examples/order-api/src/api.spec.ts | 20 +++++++++++++++++-- examples/order-api/src/auth.ts | 7 +++++-- .../order-api/src/slices/orders/controller.ts | 11 +++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/order-api/src/api.spec.ts b/examples/order-api/src/api.spec.ts index 383afaf7..057f35ad 100644 --- a/examples/order-api/src/api.spec.ts +++ b/examples/order-api/src/api.spec.ts @@ -367,8 +367,24 @@ describe("order-api", () => { // WHEN the export is called — `user` is the requirement declared first, and // this caller presents nothing it accepts - // THEN the walk fell through to the second requirement and served the call - await expect(client.orders.export()).toBeOkWith({ csv: "" }); + // THEN the walk fell through to the second requirement, and the service + // arm of the handler is what answered + await expect(client.orders.export()).toBeOkWith({ csv: "service,reporting" }); + }); + + it("serves the export to a user token that carries the scope", async ({ + serve, + tenant, + clientWith, + api, + }) => { + // GIVEN a caller whose token grants `orders:export` + const client = await clientWith(serve(api), `Bearer ${tenant}:u-1:orders:export`); + + // WHEN the export is called + // THEN the first requirement was satisfied outright — a granted scope is + // matched against what the endpoint declared, and the user arm answered + await expect(client.orders.export()).toBeOkWith({ csv: "user,u-1" }); }); it("refuses a user token that grants no scope with FORBIDDEN, not UNAUTHORIZED", async ({ diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts index 10e9e7f0..3a8c0676 100644 --- a/examples/order-api/src/auth.ts +++ b/examples/order-api/src/auth.ts @@ -36,12 +36,15 @@ export const userAuth = HttpAuthenticator()({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId, scopes = ""] = token.split(":"); + const [tenantId, userId, ...rest] = token.split(":"); + // Rejoined rather than taken as one field: a scope name contains the + // delimiter itself, so `orders:export` cannot survive a plain third field. + const granted = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) : OkAsync({ identity: { tenantId: TenantId(tenantId), userId }, - scopes: scopes + scopes: granted .split(",") .filter((scope): scope is "orders:export" => scope === "orders:export"), }); diff --git a/examples/order-api/src/slices/orders/controller.ts b/examples/order-api/src/slices/orders/controller.ts index 3845bebc..ee1f6a21 100644 --- a/examples/order-api/src/slices/orders/controller.ts +++ b/examples/order-api/src/slices/orders/controller.ts @@ -106,17 +106,18 @@ export const ordersController = api.HttpController("OrdersController", contract. errors.NOT_FOUND({ message: error.message, data: { id: error.id } }), ), ), - // A stand-in body: what this procedure is here for is the principal. A - // missing arm leaves a path returning nothing, which the handler's own - // return type refuses — so the switch is exhaustive or the build fails. + // A stand-in body naming the arm that produced it, so a spec pins which + // scheme served the call. A missing arm leaves a path returning nothing, + // which the handler's own return type refuses — so the switch is + // exhaustive or the build fails. export: ({ context }) => { switch (context.principal.scheme) { case "user": logger.info("order export requested", { userId: context.principal.identity.userId }); - return OkAsync({ csv: "" }); + return OkAsync({ csv: `user,${context.principal.identity.userId}` }); case "service": logger.info("order export requested", { appId: context.principal.identity.appId }); - return OkAsync({ csv: "" }); + return OkAsync({ csv: `service,${context.principal.identity.appId}` }); } }, }), From 54de3fac12f2efc205b8ef276f222200b0ef7a2f Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 23:28:20 +0200 Subject: [PATCH 14/18] docs: named security schemes, and the sentence that admits scopes --- .changeset/authenticated-contracts.md | 26 +- .changeset/http-controllers.md | 7 +- .changeset/named-security-schemes.md | 77 +++ .changeset/server-side-identity.md | 35 -- CLAUDE.md | 58 ++- docs/examples/order-api.md | 278 ++++++----- docs/explanation/starters.md | 4 +- docs/how-to/protect-a-procedure.md | 352 ++++++++------ docs/how-to/serve-orpc-over-http.md | 101 ++-- .../how-to/split-a-router-into-controllers.md | 62 +-- docs/how-to/test-an-application.md | 6 +- docs/index.md | 28 +- docs/reference/contract.md | 174 ++++--- docs/reference/http.md | 455 +++++++++++------- examples/order-api-contract/src/contract.ts | 7 +- examples/order-api/README.md | 89 ++-- .../order-api/src/docs-examples.test-d.ts | 19 +- packages/contract/CLAUDE.md | 40 +- packages/contract/README.md | 43 +- packages/contract/src/auth.ts | 5 + packages/http/CLAUDE.md | 421 +++++++++------- packages/http/README.md | 226 +++++---- 22 files changed, 1527 insertions(+), 986 deletions(-) create mode 100644 .changeset/named-security-schemes.md delete mode 100644 .changeset/server-side-identity.md diff --git a/.changeset/authenticated-contracts.md b/.changeset/authenticated-contracts.md index 48be129f..398418d0 100644 --- a/.changeset/authenticated-contracts.md +++ b/.changeset/authenticated-contracts.md @@ -6,26 +6,26 @@ Let a contract declare that a procedure requires an authenticated caller, and give `@btravstack/http` what it needs to satisfy that declaration. -**The contract says whether a route is protected; the application's -`httpAuth()` says what the principal is.** +**The contract says which schemes protect a route; the application says what +each one resolves to.** `@btravstack/contract` is a new zero-dependency package holding the marker -itself: `authenticated(node)`, one export with no factory and no type -parameter, applied to a finished procedure or to a whole record of them. It +itself, applied to a finished procedure or to a whole record of them. It names no identity type at all, so nothing about a server's view of a caller reaches a client. It returns the node unchanged — the marker lives in a -`WeakSet` and a phantom type key set to `true` — so a client can import a +`WeakMap` off `globalThis` and a phantom type key — so a client can import a marked contract without pulling in anything that implements it. `IsMarked` -answers the yes/no at the type level, `isAuthenticated(node)` at runtime. An +answers the yes/no at the type level, `isAuthenticated(node)` reads the +requirements back at runtime. An unmarked procedure is public; the marker makes the requirement legible in the -contract rather than detecting one that was forgotten. +contract rather than detecting one that was forgotten. Its full shape — the +curried `authenticated(...requirements)(node)`, scopes and per-procedure +overrides — is in the _named security schemes_ entry. -`@btravstack/http` resolves the principal through a new `Authenticator` port — -`HttpAuthenticator

()([deps], { sync })`, an ordinary di provider, wired on -`HttpModule`'s `authenticator` option. A contract that marks nothing needs no -authenticator; a marked router whose root provides none carries the port as an -unmet need `start` refuses, and an authenticator minted on a different -identity than the router is refused at `HttpModule`. A marked procedure whose +`@btravstack/http` resolves the principal before dispatch, through an +authenticator per scheme. A contract that marks nothing needs none; a marked +router whose graph provides none carries that scheme's port as an +unmet need `start` refuses. A marked procedure whose authenticator declines is answered `UNAUTHORIZED` before dispatch, with the handler never running and no reason reaching the caller — `Unauthenticated` carries none, so an authenticator logs why before returning. diff --git a/.changeset/http-controllers.md b/.changeset/http-controllers.md index 0d37cd0d..dcb8aa5d 100644 --- a/.changeset/http-controllers.md +++ b/.changeset/http-controllers.md @@ -2,14 +2,15 @@ "@btravstack/http": minor --- -Add `HttpController(name, fragment)([deps], { sync })` and a keyed +Add `HttpController(name, fragment)({ name: Dep }, { sync })` and a keyed `HttpRouter(contract)(controllers)` form, so a large API can be split into -slices that each own a contract fragment and its implementation. +slices that each own a contract fragment and its implementation. Both come off +`defineHttp` — see the _named security schemes_ entry. A controller is an ordinary di provider on a port the factory mints and hands back on `provider.port`. The root composes them by contract key, and a missing slice, an undeclared key, a controller under the wrong key and a fragment that -has drifted from the contract are all compile errors. The positional +has drifted from the contract are all compile errors. The `HttpRouter(contract)(deps, { sync })` form is unchanged and still right for a small API. diff --git a/.changeset/named-security-schemes.md b/.changeset/named-security-schemes.md new file mode 100644 index 00000000..e298bf6f --- /dev/null +++ b/.changeset/named-security-schemes.md @@ -0,0 +1,77 @@ +--- +"@btravstack/contract": minor +"@btravstack/http": minor +--- + +Let a contract name **which security schemes** a procedure accepts and **which +scopes** each must grant, and let an application say what each scheme resolves +to — in one call. + +`@btravstack/contract`'s marker carries OpenAPI's own requirement shape instead +of a boolean. `authenticated` is now **curried**: +`authenticated(...requirements)(node)`, where a `Requirement` is +`Readonly>` — a scheme name mapped to the +scopes it must grant. Several requirements are **ORed**, tried in declaration +order. Applied to a record it is the default for every procedure beneath it; +applied to a procedure it **replaces** that default for itself — nearest mark +wins, which is OpenAPI's rule. `isAuthenticated(node)` answers +`Requirements | undefined` rather than a boolean, `Authenticated` and the +new `RequirementsOf` carry the exact requirements at the type level, and the +registry is a `WeakMap` under `Symbol.for("@btravstack/contract/requirements")` +— a new key, so a mismatched copy of the package reads a node as _unmarked_ and +fails closed rather than calling `.has()` on it and getting an accidentally +correct answer. + +`@btravstack/http` gains **`defineHttp`**, the one door: + +```ts +export const api = defineHttp({ + authenticators: { user: userAuth, service: serviceAuth }, +}); +``` + +It hands back `HttpController`, `HttpRouter` and `authenticators`, all typed by +a scheme registry **inferred from the authenticators** rather than declared a +second time. Declaring a scheme and implementing it are the same act, so a +scheme without an authenticator is not a state the API can reach. Hold the +result as **one binding and never destructure it**: each destructured member +expands to a type mentioning `@btravstack/contract`'s inaccessible +`unique symbol` (TS2527), while held whole it collapses to the nameable +`Http` — so an application writes **no type annotation at all**, which is +what removed the three hand-written ones the previous shape required. + +**The principal follows the requirements.** A leaf whose requirements name one +scheme gets the identity **bare** — byte-for-byte what handlers wrote before. +A leaf naming several gets `{ scheme, identity }`, narrowed with a `switch` +whose missing arm is a compile error. A public leaf gets `never`, so reading it +cannot compile. + +**Scopes are declared in the contract and enforced before dispatch.** +`HttpAuthenticator()` states a scheme's scope vocabulary, so a +credential reports what it actually granted (`Granted` is `P` bare +when there is no vocabulary) and the starter compares it against what the +endpoint declared: a valid credential lacking a required scope is **`403`**, +no valid credential at all is **`401`**, and neither carries a message. A +`Defect` from an authenticator short-circuits rather than falling through to +the next scheme — a broken verifier must not promote every caller. + +A router now declares **one di dependency per scheme its contract names**, on a +port whose id carries the scheme name (`HttpAuthenticator:user`), so a missing +authenticator is di's own unmet need naming that port. `HttpModule` wires the +authenticator providers itself, off the router that carries them. + +**Breaking.** The top-level `HttpRouter` export is gone — it comes off +`defineHttp` now, because that is where the registry that types it is stated; +so do `HttpController` and `HttpAuthenticator`'s applied form. Also removed: +`httpAuth`, `HttpAuth`, `HttpControllerOf`, `HttpRouterOf`, +`HttpAuthenticatorOf`, `AuthenticatorPort`, `noAuthenticator`, the +`HttpModuleOptions.authenticator` option and the router/authenticator identity +comparison it carried. `authenticated(node)` must become +`authenticated({ scheme: [] })(node)`. + +**Not modelled, deliberately.** AND within one requirement — a requirement +names one scheme, because requiring two credentials at once would put a record +rather than an identity on the handler; a composite scheme models it where it +is genuinely needed. And OpenAPI document metadata (`type: http`, +`bearerFormat`, an OAuth flow), which belongs beside the contract rather than +in this factory. diff --git a/.changeset/server-side-identity.md b/.changeset/server-side-identity.md deleted file mode 100644 index 35c0c23e..00000000 --- a/.changeset/server-side-identity.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -"@btravstack/http": minor ---- - -Let a deployment state what its principal actually is, server-side, with -`httpAuth()`. - -The contract says **whether** a route is protected and names no identity type -at all. `httpAuth()` is what says **what** the principal is: it mints -`HttpController`, `HttpRouter` and `HttpAuthenticator` together, all fixed to -that identity. Written once per application, because a handler's parameter -types are fixed where the arrow is written and a composition root cannot -re-type a `sync` callback living in another module; every slice then imports -`HttpController` from that one file and its marked handlers see `Identity` on -`context.principal` with no annotation of their own. The authenticator and the -controllers cannot disagree, since both come from the same call, and it is -handed back already applied (`HttpAuthenticator([deps], { sync })`). - -It is also the only way a handler gets a readable principal: `HttpController` -and `HttpRouter` imported from the package itself name no identity, so a marked -fragment reached through them types `principal: never` and every read is a -compile error — the signal to use the factory, not a fallback. The contract -still decides _whether_: an unmarked procedure's context carries no principal, -factory or not. - -`HttpModule`'s gate compares the **router's** identity against the -**authenticator's** — `AuthIdentity extends RouterIdentity`, so an -authenticator resolving more than the handlers read discharges it, while one -minted by a different `httpAuth` call does not. `ContractPrincipal` is replaced -by `HasMark`, exactly `true` or `false`, which is all the conditional -authenticator dependency ever needed. - -Also exported: `HttpAuth` and the three `HttpControllerOf` / -`HttpRouterOf` / `HttpAuthenticatorOf` aliases, which a file exporting what the -factory returns needs to annotate with. diff --git a/CLAUDE.md b/CLAUDE.md index 2942383c..0dfad117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -399,6 +399,14 @@ transport's hands. "is there a principal, and what is it?" — is answerable before dispatch, and is the only half the contract carries. + A **scope** is the exception that proves the rule, and it is admitted on the + same test: it is a property of the credential, answerable before dispatch, + which is exactly why authentication is in the contract already. What stays + out is resource-dependent authorization — the order's owner, the row's tenant + — which a scope was never going to answer. `@btravstack/http` checks a + credential's granted scopes against the endpoint's declared ones and answers + `403`, distinct from the `401` a caller with no valid credential gets. + ## Public surface Each package's surface is stated **once**, in that package's own `CLAUDE.md`, @@ -474,24 +482,30 @@ type checker already verifies. `tsconfig.test-d.json` or `test:types` script, before it. `packages/http/src/controller.test-d.ts` pins the five compile-time gates the keyed `HttpRouter(contract)(controllers)` form - owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 40 specs, across + owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 49 specs, across `http-runtime.spec.ts`, `orpc.spec.ts`, `controller.spec.ts` and `auth.spec.ts`, drive the transport through the internal `httpModule` with a bare listener, the starter proper through `HttpModule`, the keyed router form through the - `rpcSliced` fixture, and the contract marker's runtime half — the - authenticator port and the one middleware it installs — through - `rpcAuthed`. **The contract says WHETHER a route is protected; the - application's `httpAuth()` says WHAT the principal is.** - `@btravstack/contract` names no identity type at all — `authenticated` is one - export with no factory and no type parameter — so nothing about a server's - view of a caller reaches a client, and a marked fragment reached through the - top-level `HttpController` types `principal: never`, which makes every read a + `rpcSliced` fixture, and the contract marker's runtime half — the per-scheme + authenticator ports and the one middleware they install — through + `rpcAuthed`. **The contract says WHICH SCHEMES protect a route, and which + scopes each must grant; the application's `defineHttp({ authenticators })` + says WHAT each scheme resolves to.** + `@btravstack/contract` names no identity type at all — `authenticated` takes + OpenAPI requirements and no type parameter — so nothing about a server's + view of a caller reaches a client, and a marked fragment reached through + anything but that one call types `principal: never`, which makes every read a compile error and is the signal to use the factory. - `examples/order-api/src/auth.ts` is the one file per application that names an - identity, and `HttpModule`'s gate pairs the **router's** identity with the - **authenticator's** — both from that one call — since there is no - contract-side principal left to compare against. + `examples/order-api/src/auth.ts` is the one file per application that names + its identities, and there is no identity comparison left to make: declaring a + scheme and implementing it are the same act, so a scheme the contract names + with no authenticator behind it is di's own unmet need on + `HttpAuthenticator:`. The one call's result is held as **one + binding and never destructured** — each destructured member expands to a type + mentioning `@btravstack/contract`'s inaccessible `unique symbol` (TS2527), + while held whole it collapses to the nameable `Http`, which is why the + application writes no type annotation at all. - **The whole gate runs on THREE containers, shared, and `internal/test-infra` owns them.** One `postgres:18.1`, one `rabbitmq:4.2.1-management-alpine` and one `temporalio/auto-setup:1.29.1`, started once per machine and reused by @@ -702,13 +716,14 @@ AuditSlice, observability()], … })`), so there. That is what makes a slice directory readable on its own — which ports come from outside, without naming who supplies them — and what keeps a `needs` list one line per feature instead of one per hop. `@btravstack/http`'s - `HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's - port; the root composes every slice's controller into one router with the - keyed `HttpRouter(contract)(controllers)` form, exact against the contract + `api.HttpController(name, fragment)({ name: Dep }, { sync })` mints the controller's + port — `api` being the application's one `defineHttp(...)` binding; the root + composes every slice's controller into one router with the + keyed `api.HttpRouter(contract)(controllers)` form, exact against the contract (see `packages/http/CLAUDE.md`). **A fragment is itself a valid contract**, so a slice lifts out of the modulith into a process of its own without its controller changing at all: the lifted root is - `HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`, + `api.HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`, declaring the very provider the modulith composed and handing back what it built — a new composition root and one fewer import, not a rewrite of the slice. That exact call is `controller.test-d.ts`'s fifth @@ -762,7 +777,7 @@ AuditSlice, observability()], … })`), visits 16 provider slots and di keeps 15, one `OrderDatabase` among them (the same walk over the pre-split modules visited 22 for the same 15, and the difference is the over-inclusion the split removed). The root composes them — - `orderRouter = HttpRouter(contract)({ orders: ordersController, + `orderRouter = api.HttpRouter(contract)({ orders: ordersController, customers: customersController })`, the keyed form — and **`HttpModule("OrderApi")({ router: orderRouter, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger] })`** is the whole @@ -771,6 +786,8 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `HttpRouterPort` and exports `HttpRuntime`: `OrderApi` is a constant, `PORT`/`HOST` and `DATABASE_URL` come from the environment inside the graph, and the router is mounted under `/rpc`. The + two authenticators are **not** in that list: they ride the router, which is + what needs them, and `HttpModule` puts them in `provides` itself. The **unmarked** `customers` fragment declares `tenantId` on its input, so a procedure hands it to the use case and the use case to the repository; the **marked** `orders` fragment declares none and its handlers read @@ -1188,8 +1205,9 @@ And a seventh, about the infrastructure a suite runs against: `FindCustomer` against the real `contract` through the application's own `src/auth.ts`, and a stub would have accepted every broken call — passing an order id where a tenant goes was exactly the drift. It covers both - controllers, the keyed router, the `HttpModule` root with its authenticator, - the lifted single-slice root and the bare `HttpRouter(contract)(deps, arm)` + controllers, the keyed router, the `HttpModule` root whose authenticators + ride the router, + the lifted single-slice root and the bare `api.HttpRouter(contract)(deps, arm)` form the three router-shaped pages share — `docs/index.md`, `docs/reference/http.md` and `docs/how-to/serve-orpc-over-http.md`, none of which puts a controller in between. Every deps record it compiles is diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index d410e4ae..cd5b1937 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -1,6 +1,6 @@ --- title: Order API example -description: The HTTP deployment — two slices, orders and customers, one marked authenticated and one public, each its own contract fragment, HttpController and full vertical down to Prisma, an auth.ts stating what a principal is and an authenticator resolving it, composed by the keyed HttpRouter form into one HttpModule root, RequestModule forked per request, a main.ts that is one runMain call with the kernel's events on the application's own logger, and the five compile-time gates pinned by needs-gate.test-d.ts. +description: The HTTP deployment — two slices, orders and customers, one marked with a named security scheme and one public, each its own contract fragment, HttpController and full vertical down to Prisma, an auth.ts declaring two schemes and a scope through defineHttp, composed by the keyed HttpRouter form into one HttpModule root, RequestModule forked per request, a main.ts that is one runMain call with the kernel's events on the application's own logger, and the three compile-time gates pinned by needs-gate.test-d.ts. --- # Order API (HTTP) @@ -49,7 +49,8 @@ export type CustomerView = z.infer; const customerRef = z.object({ id: z.uuidv7() }); export type CustomerRef = z.infer; -const ordersContract = { +// The group default: every procedure beneath needs the `user` scheme. +const ordersContract = authenticated({ user: [] })({ place: oc .input(z.object({ id: z.uuidv7(), quantity: z.number() })) .output(orderView) @@ -62,7 +63,14 @@ const ordersContract = { .input(orderRef) .output(orderView) .errors({ NOT_FOUND: { data: orderRef } }), -}; + + // Overrides the group default for itself: a service token may export too, + // and a user token needs the scope. + export: authenticated( + { user: ["orders:export"] }, + { service: [] }, + )(oc.output(z.object({ csv: z.string() }))), +}); const customersContract = { find: oc @@ -72,7 +80,7 @@ const customersContract = { }; export const contract = { - orders: authenticated(ordersContract), + orders: ordersContract, customers: customersContract, }; ``` @@ -91,10 +99,16 @@ The two fragments are module-private; `contract` and the view types are the package's exports, and every consumer reaches a fragment through it — `contract.orders`, `contract.customers`. -[`authenticated`](/reference/contract) on `orders` is a type-level fact about -the fragment, so a client reads which half of this API needs credentials off +[`authenticated({ user: [] })`](/reference/contract) on `orders` is a +type-level fact about +the fragment, so a client reads which half of this API needs credentials — and +under which scheme — off the contract itself, and a server that serves the marked half without an -authenticator does not compile. It is also why the two fragments' inputs +authenticator for that scheme does not compile. `orders.export` overrides that +group default for itself, which is how one contract exercises a per-procedure +override, a **scope** and a **second scheme** all at once: a `user` token +granting `orders:export`, **or** a `service` key needing no scope. It is also +why the two fragments' inputs differ: `customers.find` names its `tenantId`, because "which tenant" is part of what an anonymous caller is asking; `orders.place` and `orders.find` name none, because the caller's own identity establishes it, and a required field @@ -107,90 +121,109 @@ enriching it is never a contract change. ## What a caller is, and the one file that says so -Two files, both at the root of `src/`, and neither belongs to a slice: +One file, at the root of `src/`, belonging to no slice: ``` -src/auth.ts httpAuth() — states the principal, mints HttpController/HttpRouter/HttpAuthenticator on it -src/authenticator.ts bearerAuthenticator — the provider that resolves an Identity from the request's headers +src/auth.ts the two schemes, and the one defineHttp call that declares them ``` -`auth.ts` is where `Identity` is stated, once, and the three pieces the slices -and the root import come back fixed to it: +`auth.ts` is where each scheme's identity is stated and its authenticator +written, and where the one `defineHttp` call the application makes lives: ```ts -import type { TenantId } from "@btravstack/example-order-domain"; +import { TenantId } from "@btravstack/example-order-domain"; import { - httpAuth, - type HttpAuthenticatorOf, - type HttpControllerOf, - type HttpRouterOf, + HttpAuthenticator, + Unauthenticated, + defineHttp, } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; +/** What this deployment knows about a caller under the `user` scheme. */ export type Identity = { readonly tenantId: TenantId; readonly userId: string; }; -const identity = httpAuth(); - -export const HttpController: HttpControllerOf = - identity.HttpController; -export const HttpRouter: HttpRouterOf = identity.HttpRouter; -export const HttpAuthenticator: HttpAuthenticatorOf = - identity.HttpAuthenticator; -``` - -Once per application rather than once per slice, because a handler's parameter -types are fixed **where the arrow is written**: the composition root cannot -re-type a `sync` callback that lives inside `slices/orders/`, so the identity -has to be in scope there. That is also what makes the authenticator and the -controllers unable to disagree — both come from this one call. The three -`…Of` aliases are annotations rather than ceremony: a controller's -port expands to a type carrying the marker's phantom `unique symbol`, which -this file cannot name in its own declaration emit. - -It is the **only** way a handler gets a readable principal. A marked fragment -reached through `@btravstack/http`'s own top-level `HttpController` types -`principal: never`, so every read of it is a compile error — the signal to use -the factory, not a fallback. - -`authenticator.ts` is then an ordinary di provider, with no type argument left -to state: +/** What the `service` scheme resolves to: a machine caller, no tenant. */ +export type ServiceIdentity = { readonly appId: string }; -```ts -import { TenantId } from "@btravstack/example-order-domain"; -import { Unauthenticated } from "@btravstack/http"; -import { ErrAsync, OkAsync } from "unthrown"; - -import { HttpAuthenticator } from "./auth.js"; - -export const bearerAuthenticator = HttpAuthenticator({ +export const userAuth = HttpAuthenticator()({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId] = token.split(":"); + const [tenantId, userId, ...rest] = token.split(":"); + // Rejoined rather than taken as one field: a scope name contains the + // delimiter itself, so `orders:export` cannot survive a plain third field. + const granted = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId: TenantId(tenantId), userId }); + : OkAsync({ + identity: { tenantId: TenantId(tenantId), userId }, + scopes: granted + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + }); }, }); + +/** The second scheme: an API key, no scopes — what a reporting job presents. */ +export const serviceAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const key = headers["x-api-key"]; + return typeof key === "string" && key !== "" + ? OkAsync({ appId: key }) + : ErrAsync(new Unauthenticated()); + }, +}); + +export const api = defineHttp({ + authenticators: { user: userAuth, service: serviceAuth }, +}); ``` -`Bearer :` is a stand-in, not a recommendation — what matters -is the shape. This is also where a header becomes a **tenant**: `TenantId` is +Once per application rather than once per slice, because a handler's parameter +types are fixed **where the arrow is written**: the composition root cannot +re-type a `sync` callback that lives inside `slices/orders/`, so the registry +has to be in scope there. Declaring a scheme and implementing it are the +**same act**, which is why there is no registry to keep in step with the +contract and no authenticator for the root to list. + +::: warning Held whole — never destructured +`const { HttpController } = defineHttp(...)` is **TS2527**: each binding of a +destructured member expands to a type mentioning the marker's inaccessible +`unique symbol`, which this file could not emit. Held whole, the inferred type +collapses to `Http`, which is nameable — which is why this file, unlike the +one it replaced, carries **no type annotation at all**. +::: + +It is the **only** way a handler gets a readable principal. A marked fragment +reached through any other `defineHttp` call types +`principal: never`, so every read of it is a compile error — the signal to use +the factory, not a fallback. + +`Bearer ::` is a stand-in, not a recommendation — +what matters is the shape. This is also where a header becomes a **tenant**: +`TenantId` is the domain's branded string, so the identity carries the brand from here and no handler on this path casts anything. The constructor is a cast rather than a parse — a brand is a compile-time fiction, and what it buys is that `repository.find(tenantId, id)` can no longer be called with its two arguments -the other way round. `[]` because this one needs no service; a JWT verifier, a key set -or a user directory would be named there and injected the way any provider's -dependencies are, so swapping the stand-in for real verification changes -nothing else in the composition. See +the other way round. The scope **vocabulary** is declared at the call +(`HttpAuthenticator()`), so the granted list is +checked against it here rather than compared as loose strings at the endpoint. +Neither authenticator needs a service; a JWT verifier, a key set +or a user directory would be named in a `deps` record and injected the way any +provider's +dependencies are, and that need would travel with the authenticator into the +graph — so a root satisfying none is refused at the `HttpModule(...)` call. See [Protect a procedure](/how-to/protect-a-procedure) for the recipe in full. ## The slices: a controller and a module each @@ -202,9 +235,9 @@ use cases in [`order-application`](/examples/order-application), and the entities and Prisma adapters behind it. ``` -src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync }) +src/slices/orders/controller.ts api.HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync }) src/slices/orders/module.ts OrdersSlice — imports the vertical, provides the controller, exports only it -src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync }) +src/slices/customers/controller.ts api.HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync }) src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice ``` @@ -213,9 +246,9 @@ this slice where a domain error becomes something else — `slices/customers/con below does the same for its own slice: ```ts -import { HttpController } from "../../auth.js"; +import { api } from "../../auth.js"; -export const ordersController = HttpController( +export const ordersController = api.HttpController( "OrdersController", contract.orders, )( @@ -265,6 +298,27 @@ export const ordersController = HttpController( }), ), ), + // Two schemes, so the principal is a discriminated union — and the + // switch is exhaustive or the build fails. The body names the arm that + // produced it, so a spec can pin which scheme served the call. + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + logger.info("order export requested", { + userId: context.principal.identity.userId, + }); + return OkAsync({ + csv: `user,${context.principal.identity.userId}`, + }); + case "service": + logger.info("order export requested", { + appId: context.principal.identity.appId, + }); + return OkAsync({ + csv: `service,${context.principal.identity.appId}`, + }); + } + }, }), }, ); @@ -286,10 +340,17 @@ place that has to decide what a client sees. A `Defect` is never named: it was never modeled, and collapsing it to a 500 is the correct treatment rather than a fallback. -The tenant comes off `context.principal` — the value `bearerAuthenticator` +The tenant comes off `context.principal` — the value the `user` scheme's +authenticator resolved from this request's headers — and it is the only thing on oRPC's -context channel. `HttpController` is `auth.ts`'s, which is why `principal` has -a readable type here at all with no annotation at this call site. The starter +context channel. `HttpController` comes off `auth.ts`'s `api`, which is why +`principal` has +a readable type here at all with no annotation at this call site. `place` and +`find` name **one** scheme, so they read the identity bare — byte-for-byte what +this file held before named schemes existed; `export` names two, so its +principal is tagged and the compiler checks that every scheme the contract +named is answered for. That contrast is the whole design: the common case pays +nothing. The starter knows nothing about tenancy either way: it resolved a principal this application defined, and what the fields on it mean is the application's business. Who placed an order is a transport-boundary fact, so it is logged @@ -312,22 +373,24 @@ port over `CustomerView` itself, which pointed the dependency arrow outwards. ## The router: composed from controllers, keyed by the contract -`module.ts`'s `orderRouter` is `HttpRouter(contract)`'s **keyed** form — +`module.ts`'s `orderRouter` is `api.HttpRouter(contract)`'s **keyed** form — a record of controllers, one per top-level contract key, instead of one `sync`: ```ts -import { HttpRouter } from "./auth.js"; +import { api } from "./auth.js"; -export const orderRouter = HttpRouter(contract)({ +export const orderRouter = api.HttpRouter(contract)({ orders: ordersController, customers: customersController, }); ``` -`HttpRouter` is `auth.ts`'s here too: the marker on `contract.orders` rides -through the keyed form, so the router carries the identity its controllers were -minted with, and the root below checks the authenticator against it. +`HttpRouter` comes off the same `api` as the controllers: the marks on +`contract.orders` ride +through the keyed form, so the router declares **one dependency per scheme the +contract names** — `HttpAuthenticator:user` and `HttpAuthenticator:service` — +and carries the providers that discharge them, from that same call. This form is exact: a slice missing from the record, a key the contract does not declare, and a controller wired under the wrong key are all compile @@ -337,7 +400,7 @@ the recipe, and `packages/http/src/controller.test-d.ts` for the five gates that pin these errors and the lift below. Because a fragment is itself a valid contract, `ordersController` serves `contract.orders` alone unchanged: the lifted root is -`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` +`api.HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` over `OrdersSlice`, so extracting a slice out of this modulith is a new composition root and one fewer import, not a rewrite. @@ -348,18 +411,19 @@ composition root and one fewer import, not a rewrite. ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); ``` -The `authenticator` is here and nowhere else, because who a caller is is one -answer per process rather than a slice's question — and it is _required_ here -because the contract marks `orders`: `HttpRouter` gave the router provider a -dependency on the starter's `AuthenticatorPort`, so dropping the line is an -unmet need `start` refuses, and supplying one that resolves a different -principal is a compile error at this very call. +The two authenticators are **not** listed, and that is the point: who a caller +is is one answer per process rather than a slice's question, so they were +declared once in `auth.ts`, and they ride the router — which is what needs +them. `HttpModule` puts them in `provides` itself, so a scheme cannot be +forgotten here and cannot be wired to the wrong router. What is still checked +is di's own gate: a scheme the contract names with no authenticator behind it +leaves `HttpAuthenticator:` in the root's needs, which `start` refuses, +naming the port. Each slice imports its own vertical — `OrderApplicationModule`, whose repository is an unmet need, and `OrderPersistenceModule`, which provides it — @@ -528,7 +592,7 @@ the same client and the same running root — a `CustomerView` on the way out of a stub-backed root, a typed `NOT_FOUND` out of the real one — proving the keyed router actually mounted both controllers rather than one. -## Five gates, pinned at compile time +## Three gates, pinned at compile time `needs-gate.test-d.ts` is type-checked, never executed. It pins the two directions of `start`'s own gate and di's, side by side: @@ -542,9 +606,11 @@ const _missingRuntime = start(RuntimelessApi, options); phantom marker becomes the sentence `"NO RUNTIME — the module exports no port declared over RuntimePort"`, and the module argument fails to match its parameter type — the sentence is the error's -last line. It provides `bearerAuthenticator` even so, deliberately: the contract -marks `orders`, so a graph carrying the router without an authenticator has an -unmet need too, and an arm that could fail either way pins neither gate. +last line. It provides `orderRouter` **and `...orderRouter.authenticators`** +even so, deliberately: the contract +marks `orders`, so a graph carrying the router without them has an +unmet need too, and an arm that could fail either way pins neither gate. That +spread is exactly what `HttpModule` does for a root that uses the sugar. ```ts const RouterlessApi = Module("RouterlessApi")({ @@ -579,46 +645,18 @@ and `UnloggedApi` — runtime and router present, `observability()` imported so the port exists in the graph, `Logger` simply not exported — is rejected by the unit arm alone. -The last two are the authenticator's, and they are different gates on purpose: - -```ts -const UnauthenticatedApi = HttpModule("UnauthenticatedApi")({ - router: orderRouter, - imports: [OrdersSlice, CustomersSlice, observability()], - exports: [Logger], -}); - -// @ts-expect-error — the composition needs the authenticator port and nothing provides it. -const _missingAuthenticator = start(UnauthenticatedApi, options); -``` - -That is **di's** gate again, at `start` and not at `HttpModule(...)` — which is -why the module above builds without complaint. The other one cannot be di's at -all: `AuthenticatorPort`'s service type is erased to `unknown`, so any -authenticator discharges the need. `HttpModule` compares the router's identity -against the authenticator's itself, at the option: - -```ts -const wrongAuthenticator = HttpAuthenticator<{ readonly sub: string }>()({ - sync: () => () => OkAsync({ sub: "s-1" }), -}); - -const _mismatchedApi = HttpModule("MismatchedApi")({ - router: orderRouter, - // @ts-expect-error — the authenticator resolves `{ sub }`, not the router's Identity. - authenticator: wrongAuthenticator, - imports: [OrdersSlice, CustomersSlice, observability()], - exports: [Logger], -}); -``` - -The directive sits on the option rather than on a `start` below it, because -that is where the failure is. The contract declares no principal to compare -against; `auth.ts` is what declares one. +**What is no longer here, and why.** This file used to carry two more arms +about the authenticator — a root that forgot to pass one, and a root that +passed one resolving the wrong identity. Neither is reachable any more. The +authenticators come from the same `defineHttp` call that types the handlers and +ride the router into `provides`, so there is nothing to forget and no pair to +compare — a scheme with nobody behind it is di's own unmet need on +`HttpAuthenticator:`, which the router-port arm above already pins the +shape of. ## Where to go next - The same `DuplicateOrder`, orchestrated: [Order Temporal worker](/examples/order-temporal-worker). -- The marker, `auth.ts` and the authenticator as a recipe: [Protect a procedure](/how-to/protect-a-procedure). +- The marker, `auth.ts`, scopes and the 401/403 split as a recipe: [Protect a procedure](/how-to/protect-a-procedure). - The package behind the transport: [`@btravstack/http`](/reference/http). - Why the kernel appears in none of this: [The kernel maps nothing](/explanation/the-kernel-maps-nothing). diff --git a/docs/explanation/starters.md b/docs/explanation/starters.md index 54019588..4bf9e4e6 100644 --- a/docs/explanation/starters.md +++ b/docs/explanation/starters.md @@ -112,7 +112,9 @@ What the application supplies to a starter — a router, an activities record, a handlers record — is a **service on a port**, and each starter ships one call that returns di's own provider builder on that port: -- `HttpRouter(contract)(deps, { sync })` +- `api.HttpRouter(contract)(deps, { sync })` — `api` being the application's + one `defineHttp(...)` binding, which is also what types a protected + procedure's principal - `TemporalActivities(contract)(deps, arm)` - `AmqpHandlers(contract)(deps, arm)` diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index fa2dc937..d6d6c03e 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -1,34 +1,35 @@ --- title: Protect a procedure -description: Mark a contract fragment or a procedure with authenticated(), write the Authenticator that resolves a principal from the request headers, and hand it to HttpModule. +description: Mark a contract fragment or a procedure with authenticated(), declare the security schemes and scopes it accepts, implement each scheme with HttpAuthenticator, and read the principal in the handler. --- # Protect a procedure -> **How-to.** Declare in the contract that a procedure needs an authenticated -> caller, resolve that caller once per request, and read it in the handler. For +> **How-to.** Declare in the contract which security schemes a procedure +> accepts and which scopes each must grant, resolve the caller once per +> request, and read it in the handler. For > the marker's surface, see [`@btravstack/contract`](/reference/contract); for > the starter's, [`@btravstack/http`](/reference/http); for the worked > deployment, [Order API (HTTP)](/examples/order-api). -Three moves, in this order: **mark** the contract, **write** the -`Authenticator`, **pass** it to `HttpModule`. The marker is what makes the -other two type-checked — the router provider grows a dependency on the -authenticator port, and the marked procedures' handlers grow a -`context.principal` typed with the identity the application stated. +Three moves, in this order: **mark** the contract, **implement** each scheme, +**mint** the router and controllers from the one call that knows both. The +marker is what makes the rest type-checked — the router provider grows one +dependency per scheme its contract names, and the protected procedures' +handlers grow a `context.principal` typed by the schemes that reach them. -**The contract says _whether_ a route is protected; `httpAuth()` says -_what_ the principal is.** No identity type is named in the contract at all, so -nothing about the server's view of a caller reaches a client. +**The contract says _which schemes_ protect a route and _which scopes_ each +must grant; `defineHttp({ authenticators })` says _what each scheme resolves +to_.** No identity type is named in the contract at all, so nothing about the +server's view of a caller reaches a client. ## Recipe -1. Mark the contract with `authenticated`. -2. State the server's own identity once with `httpAuth()`, and write - the authenticator it hands back — headers in, - `AsyncResult` out. -3. Read `opts.context.principal` in the handlers of the marked procedures. -4. Pass the provider as `HttpModule(name)({ router, authenticator, needs })`. +1. Mark the contract with `authenticated(...requirements)`. +2. Implement each scheme with `HttpAuthenticator()`, and declare them + all in one `defineHttp({ authenticators })` call. +3. Read `opts.context.principal` in the handlers of the protected procedures. +4. Compose the root — there is no authenticator to pass. ## Step 1 — mark the contract @@ -46,7 +47,9 @@ const orderRef = z.object({ id: z.uuidv7() }); // not a UUIDv7: `orderRef` would reject the only payload it ever carries. const malformedRef = z.object({ id: z.string() }); -const ordersContract = { +// The group default: every procedure beneath it needs the `user` scheme, with +// no particular scope. +const ordersContract = authenticated({ user: [] })({ place: oc .input(z.object({ id: z.uuidv7(), quantity: z.number() })) .output(z.object({ id: z.uuidv7() })) @@ -55,7 +58,14 @@ const ordersContract = { BAD_REQUEST: { data: malformedRef }, CONFLICT: { data: orderRef }, }), -}; + + // Replaces the default for itself: a `user` token granting `orders:export`, + // OR a `service` key with no scopes at all. + export: authenticated( + { user: ["orders:export"] }, + { service: [] }, + )(oc.output(z.object({ csv: z.string() }))), +}); const customersContract = { find: oc @@ -64,108 +74,138 @@ const customersContract = { }; export const contract = { - orders: authenticated(ordersContract), // every procedure beneath it + orders: ordersContract, customers: customersContract, // public }; ``` -A marked **record** protects every procedure beneath it; a marked **procedure** -protects itself, so `{ find, quote: authenticated(quoteProcedure) }` is a -fragment with one of each. Apply `authenticated` to a **finished** node — the +Four rules, and they are OpenAPI's own: + +- **A requirement is a scheme name mapped to the scopes it must grant.** + `{ user: [] }` says "present the `user` scheme"; `{ user: ["orders:export"] }` + adds a scope the credential has to carry. +- **Requirements are ORed**, tried in the order given: the first one a caller + satisfies wins. `authenticated({ user: [...] }, { service: [] })` means either. +- **A requirement names one scheme.** AND-within-a-requirement is deliberately + not modelled — requiring two credentials at once would put a record rather + than a single identity on the handler. Where two really are needed, a + composite scheme models it. +- **Nearest mark wins.** A marked record is the default for every procedure + beneath it; a marked procedure **replaces** that default for itself rather + than adding to it. + +Apply `authenticated(...)` to a **finished** node — the last call in a builder chain, or a whole record of finished nodes. Applied mid-chain it is silently dropped, because `oc.router(...)` rebuilds every node. The contract stops here. It names no principal, so there is nothing in it to keep minimal and nothing in it to leak. -## Step 2 — state the identity, and write the authenticator +## Step 2 — implement each scheme, and declare them together -`httpAuth()` is where the principal's type is stated — one file per -application, which hands back `HttpController`, `HttpRouter` and -`HttpAuthenticator` all fixed to that identity: +`HttpAuthenticator()` implements **one** scheme. It resolves a +credential from the request's **headers** — not the request: an authenticator +has no business reading a body, and the narrower argument is what keeps it +testable without a socket. The scheme's **name** is not stated here; it is the +key the authenticator sits under in `defineHttp`, so it is written once. ```ts -// src/auth.ts -import type { TenantId } from "@btravstack/example-order-domain"; +// src/auth.ts — one file per application +import { TenantId } from "@btravstack/example-order-domain"; import { - httpAuth, - type HttpAuthenticatorOf, - type HttpControllerOf, - type HttpRouterOf, + HttpAuthenticator, + Unauthenticated, + defineHttp, } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; -/** What this deployment knows about a caller. The contract names none. */ +/** What the `user` scheme resolves to. The contract names none of this. */ export type Identity = { readonly tenantId: TenantId; readonly userId: string }; -const identity = httpAuth(); +/** What the `service` scheme resolves to: a machine caller, no tenant. */ +export type ServiceIdentity = { readonly appId: string }; -export const HttpController: HttpControllerOf = - identity.HttpController; -export const HttpRouter: HttpRouterOf = identity.HttpRouter; -export const HttpAuthenticator: HttpAuthenticatorOf = - identity.HttpAuthenticator; -``` - -Written once per application, because a handler's parameter types are fixed -where the arrow is written: a composition root cannot re-type a `sync` callback -that lives in a slice's module, so the identity has to be in scope where the -handler is. The three aliases are annotations rather than ceremony — a -controller's port expands to a type carrying the marker's phantom -`unique symbol`, which the file's own `.d.ts` cannot name. - -`HttpAuthenticator({ name: Dep }, { sync })` is then an ordinary di provider on the -starter's `AuthenticatorPort`, with no type argument left to state. It resolves -the identity from the request's **headers** — not the request: an authenticator -has no business reading a body, and the narrower argument is what keeps it -testable without a socket. - -```ts -import { TenantId } from "@btravstack/example-order-domain"; -import { Unauthenticated } from "@btravstack/http"; -import { ErrAsync, OkAsync } from "unthrown"; - -import { HttpAuthenticator } from "./auth.js"; - -export const bearerAuthenticator = HttpAuthenticator({ +export const userAuth = HttpAuthenticator()({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId] = token.split(":"); + const [tenantId, userId, ...rest] = token.split(":"); + // Rejoined rather than taken as one field: a scope name contains the + // delimiter itself, so `orders:export` cannot survive a plain third field. + const granted = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId: TenantId(tenantId), userId }); + : OkAsync({ + identity: { tenantId: TenantId(tenantId), userId }, + scopes: granted + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + }); + }, +}); + +export const serviceAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const key = headers["x-api-key"]; + return typeof key === "string" && key !== "" + ? OkAsync({ appId: key }) + : ErrAsync(new Unauthenticated()); }, }); + +/** The one door: declaring a scheme and implementing it are the same act. */ +export const api = defineHttp({ + authenticators: { user: userAuth, service: serviceAuth }, +}); ``` +A scheme **with** a scope vocabulary answers `{ identity, scopes }`, so the +granted list is checked against the declared vocabulary here rather than +compared as loose strings at the endpoint. A scheme **without** one answers the +identity bare — which is exactly what a handler under a single unscoped scheme +then reads. + +::: warning Hold `api` whole — never destructure it +`const { HttpController } = defineHttp(...)` is **TS2527**: each binding of a +destructured member expands to a type mentioning `@btravstack/contract`'s +inaccessible `unique symbol`, which the file cannot emit. Held whole, the +inferred type collapses to `Http`, which is nameable — which is why the file +above writes **no type annotation at all**. +::: + +Written once per application, because a handler's parameter types are fixed +where the arrow is written: a composition root cannot re-type a `sync` callback +that lives in a slice's module, so the registry has to be in scope where the +handler is. + Enriching what a deployment knows about its callers — roles, an org tier, an internal id — is a change to this file alone: not a contract change, and none of it reaches a client. -The factory is also the **only** way a handler gets a readable principal. -`@btravstack/http`'s own top-level `HttpController` and `HttpRouter` name no -identity, so a marked fragment reached through them types `principal: never` -and every read of it is a compile error — the signal to use the factory, not a -fallback. Neither form invents one: an unmarked procedure's context still has -no `principal` at all. - -`Bearer :` is a stand-in, not a recommendation — what -matters is the shape. `[]` because this one needs no service; a JWT verifier, a -key set or a user directory is named there and injected the way any provider's -dependencies are, so swapping the stand-in for real verification changes -nothing else in the composition. - -The identity is **stated**, never inferred from `sync`: inference through a -returned function's `AsyncResult` is exactly where a principal silently widens -to `unknown`, and stating it once is what makes a mismatch a compile error at -step 4 instead of an `unknown` reaching a handler. It also means the -authenticator and the controllers cannot disagree — both come from the same -`httpAuth` call. +`api` is also the **only** way a handler gets a readable principal. A marked +fragment reached through anything else types `principal: never` and every read +of it is a compile error — the signal to use the factory, not a fallback. +Neither form invents one: an unmarked procedure's context still has no +`principal` at all. + +`Bearer ::` is a stand-in, not a recommendation — +what matters is the shape. Neither authenticator here needs a service; a JWT +verifier, a key set or a user directory is named in a `deps` record and +injected the way any provider's dependencies are, so swapping the stand-in for +real verification changes nothing else in the composition — and that +dependency travels with the authenticator into the graph, so a root that +satisfies none is refused at the `HttpModule(...)` call. + +Both type arguments are **stated**, never inferred from `sync`: inference +through a returned function's `AsyncResult` is exactly where a principal +silently widens to `unknown`. `Unauthenticated` carries **nothing**: the starter surfaces no reason — a rejected caller gets an `UNAUTHORIZED` and oRPC's default message — so a payload @@ -174,22 +214,29 @@ returning, which is one more argument for naming a logger in `deps`. ## Step 3 — read the principal -A marked procedure's handler receives the principal on **oRPC's own context -channel**, `opts.context.principal`. No second parameter, no wrapper. -`HttpController` is imported from the application's own `auth.ts`, so -`context.principal` is the `Identity` — `userId` and `tenantId` both, neither -of which the contract names: +A protected procedure's handler receives the principal on **oRPC's own context +channel**, `opts.context.principal`. No second parameter, no wrapper. The +controller is minted from the application's own `api`, so the principal has a +readable type — and its **shape follows the requirements**: + +| The leaf's requirements name | `context.principal` | +| ---------------------------- | --------------------------------------------- | +| one scheme | that scheme's identity, **bare** | +| several schemes | `{ scheme, identity }`, a discriminated union | +| none (unmarked) | absent — reading it is a compile error | ```ts -import { HttpController } from "../../auth.js"; +import { api } from "../../auth.js"; -export const ordersController = HttpController( +export const ordersController = api.HttpController( "OrdersController", contract.orders, )( { place: PlaceOrder, find: FindOrder, logger: Logger }, { sync: ({ place, find, logger }) => ({ + // One scheme, so the identity arrives bare — byte-for-byte what a + // handler wrote before named schemes existed. place: ({ errors, context }, input) => { logger.info("order placement requested", { userId: context.principal.userId, @@ -233,6 +280,21 @@ export const ordersController = HttpController( }), ), ), + // Two schemes, so the principal is a discriminated union. A missing arm + // leaves a path returning nothing, which the handler's own return type + // refuses — the switch is exhaustive or the build fails. + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + return OkAsync({ + csv: `user,${context.principal.identity.userId}`, + }); + case "service": + return OkAsync({ + csv: `service,${context.principal.identity.appId}`, + }); + } + }, }), }, ); @@ -244,65 +306,71 @@ cannot be mounted under an unmarked contract key, where nothing would inject one. The reverse is fine: an unmarked controller under a marked key is a handler that ignores its caller's identity. -## Step 4 — pass it to `HttpModule` +## Step 4 — compose the root -The authenticator sits at the **root**, not in a slice: who a caller is is one -answer per process. +There is **no authenticator to pass**. The authenticators ride the router — +which is what needs them — and `HttpModule` puts them in `provides` itself: ```ts +export const orderRouter = api.HttpRouter(contract)({ + orders: ordersController, + customers: customersController, +}); + export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); ``` -Two things are checked here, and they are different gates: - -- **Omitting the line** leaves an unmet need, refused at `start`. When - the contract marks anything, `HttpRouter` appends `AuthenticatorPort` to the - router provider's dependencies, so the need is real and unmet — no new gate, - and nothing this package invents. What prints is the `Needs` channel failing - to assign: `Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'`, - down to `Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'`. - (Not di's `UNSATISFIED DEPENDENCIES` arity gate — that one guards - `Module.build`/`Module.scoped`; `start` types the need out on its `module` - parameter, which is why the port is named.) -- **Supplying one minted on a different identity** is a compile error at - the `HttpModule(...)` call itself. di cannot see it — `AuthenticatorPort`'s - service type is erased to `unknown`, so any authenticator discharges the need - — so `HttpModule` compares the **router's** identity against the - **authenticator's**, both of which came from the same `httpAuth` call in an - application that has one. The direction is - `AuthIdentity extends RouterIdentity`: the authenticator must resolve at - least what the handlers read, so a subtype discharges it. - -A router minted by the package's own top-level `HttpRouter` carries no identity -and accepts any authenticator, including none: a provider nothing needs is di's -business and not an error to invent. +What is still checked, and it is di's own gate rather than one this package +invented: `HttpRouter` declares **one dependency per scheme its contract +names**, so a scheme with no authenticator behind it is an unmet need refused +at `start`, and the diagnostic names the port — + +``` +Type '"HttpAuthenticator:user"' is not assignable to type '"@di/Scope"' +``` + +(Not di's `UNSATISFIED DEPENDENCIES` arity gate — that one guards +`Module.build`/`Module.scoped`; `start` types the need out on its `module` +parameter, which is why the port is named.) + +There is nothing left for a second gate to check. The registry that types the +handlers and the providers that discharge those ports come from the **same** +`defineHttp` call, so they cannot disagree. And an authenticator's own +dependencies reach `NeedsGate` because they are in `provides`, so a root that +imports nothing satisfying a `JwtVerifier` is refused at the `HttpModule(...)` +call itself. ## What a rejected caller gets -| Situation | Answer | -| ---------------------------------------------- | ----------------------------------------------------- | -| the authenticator returns `Unauthenticated` | `401 UNAUTHORIZED`, the handler never entered | -| the authenticator defects | oRPC's `INTERNAL_SERVER_ERROR` collapse — not a `401` | -| a marked route with no authenticator behind it | `401` — the starter's fail-closed fallback | -| an unmarked procedure, no credentials | served | +Requirements are tried in the order the contract declared them, and the first +a caller satisfies wins. + +| Situation | Answer | +| --------------------------------------------------------------- | ----------------------------------------------------- | +| no requirement accepted the caller | `401 UNAUTHORIZED`, the handler never entered | +| a credential was valid but lacked a scope the requirement named | `403 FORBIDDEN`, the handler never entered | +| an authenticator defects | oRPC's `INTERNAL_SERVER_ERROR` collapse — not a `401` | +| an unmarked procedure, no credentials | served | -A defect is a bug in the authenticator, not a rejected caller, and reporting it -as one would tell an operator the opposite of what happened. The third row is -unreachable while the types and the runtime walk agree — which is exactly why -it is there. +Neither refusal carries a message: oRPC serializes `message` to the client, and +a refusal has nothing a caller is entitled to. A requirement naming scopes is +**not** satisfied by a credential reporting none — a scheme declared without a +vocabulary answers bare, and admitting it there would admit the caller +outright. A defect is a bug in the authenticator, not a rejected caller: it +stops the walk rather than promoting the caller to the next scheme, and +reporting it as a `401` would tell an operator the opposite of what happened. -On the client, `UNAUTHORIZED` is an error the contract does **not** declare, so -it is not inferable: it lands in `defect`, not in `errCases`. A client for a -marked fragment sends its credentials up front: +On the client, `UNAUTHORIZED` and `FORBIDDEN` are errors the contract does +**not** declare, so they are not inferable: they land in `defect`, not in +`errCases`. A client for a protected fragment sends its credentials up front: ```ts const client = createOrderApiClient("http://127.0.0.1:3000", "/rpc", { - authorization: `Bearer ${tenantId}:${userId}`, + authorization: `Bearer ${tenantId}:${userId}:orders:export`, }); ``` @@ -311,7 +379,7 @@ const client = createOrderApiClient("http://127.0.0.1:3000", "/rpc", { **An unmarked procedure is public, and nothing fails if the marker is forgotten.** There is no deny-by-default: a new procedure added to an unmarked record is served to anyone, no compile error, no startup failure, no warning. -What the contract buys is that a protected route is _visible_ — one word in the +What the contract buys is that a protected route is _visible_ — one call in the artifact both sides read, in the diff, in the generated types, and in the handler's own signature. @@ -321,18 +389,20 @@ and today that is something an application writes, not something this package offers. Two further non-goals worth stating plainly: the marker does not -**authenticate** (that is your `Authenticator`, and what a token means is -yours), and it does not model **authorization** — it says who a caller is, and -nothing about what they may do. Per-procedure permissions belong in the -handler, where the use case is. +**authenticate** (that is your authenticator, and what a token means is +yours), and it does not model **resource-dependent authorization**. A **scope** +is the exception, and admitted on the same test authentication passes: it is a +property of the credential, answerable before dispatch. "Is this caller the +order's owner?" is not, and belongs in the handler, where the use case is. ## See also - [`@btravstack/contract`](/reference/contract) — `authenticated`, - `Authenticated`, `PrincipalKey`, `IsMarked`, `isAuthenticated`. -- [`@btravstack/http`](/reference/http) — `HttpAuthenticator`, - `AuthenticatorPort`, `Unauthenticated`, and the request table. + `Requirement`, `Requirements`, `Authenticated`, `PrincipalKey`, `IsMarked`, + `RequirementsOf`, `isAuthenticated`. +- [`@btravstack/http`](/reference/http) — `defineHttp`, `HttpAuthenticator`, + `Granted`, `Principal`, `Unauthenticated`, and the request table. - [Split a router into controllers](/how-to/split-a-router-into-controllers) — where the handler in step 3 lives once an API has slices. - [Order API (HTTP)](/examples/order-api) — one marked fragment, one public - one, end to end. + one, and a procedure that overrides its group's default, end to end. diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index f1c7224a..c02e6394 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -25,13 +25,15 @@ the real two-slice deployment this recipe scales into, see ## Recipe 1. Declare the contract with `@orpc/contract` — inputs, outputs and the - `.errors({...})` a client may branch on, marked `authenticated` where a - caller must be known. -2. Implement it with `HttpRouter(contract)(deps, { sync })`: a record - shaped like the contract, each leaf a `Result`-returning function. -3. Compose with - `HttpModule(name)({ router, authenticator, imports, provides, exports, needs })`. -4. `await runMain(OrdersApi)` in `main.ts`. + `.errors({...})` a client may branch on, marked + `authenticated(...requirements)` where a caller must be known. +2. Declare this deployment's security schemes once with + `defineHttp({ authenticators })`. +3. Implement the contract with `api.HttpRouter(contract)(deps, { sync })`: a + record shaped like the contract, each leaf a `Result`-returning function. +4. Compose with + `HttpModule(name)({ router, imports, provides, exports, needs })`. +5. `await runMain(OrdersApi)` in `main.ts`. ## Step 1 — the contract @@ -53,7 +55,7 @@ export type OrderRef = z.infer; // not a UUIDv7: `orderRef` would reject the only payload it ever carries. const malformedRef = z.object({ id: z.string() }); -export const ordersContract = authenticated({ +export const ordersContract = authenticated({ user: [] })({ place: oc .input(z.object({ id: z.uuidv7(), quantity: z.number() })) .output(orderView) @@ -75,16 +77,20 @@ what the compiler believes cannot drift. oRPC's `type()` would declare the same types and validate nothing — `{ quantity: "abc" }` would reach `place` typed `number`. -[`authenticated`](/reference/contract) marks the whole record, so every -procedure under it needs a known caller — and neither input names a tenant, -because the caller's own identity is what establishes it. Drop the marker and +[`authenticated({ user: [] })`](/reference/contract) marks the whole record +with one OpenAPI security requirement — the `user` scheme, no particular scope +— so every procedure under it needs a caller presenting it, and neither input +names a tenant, because the caller's own identity is what establishes it. +Several requirements are ORed and a procedure may replace the group default +with its own; see +[Protect a procedure](/how-to/protect-a-procedure). Drop the marker and this is a public API; the rest of the page is unchanged either way, except that -the handlers then have no `context.principal` to read and the root needs no -authenticator. +the handlers then have no `context.principal` to read and the router declares +no scheme dependency. ## Step 2 — the router, as a provider -`HttpRouter(ordersContract)` is di's own `Provider(port)` on the starter's +`api.HttpRouter(ordersContract)` is di's own `Provider(port)` on the starter's router port — there is no name to give, a process serves one router — so the call declares the use cases the procedures call and closes over them. **The `mapErrCases` in each procedure is the one place a domain error becomes an HTTP answer** — every case named, no @@ -96,14 +102,14 @@ import { FindOrder, PlaceOrder } from "@btravstack/example-order-application"; import type { Order } from "@btravstack/example-order-domain"; import { P } from "unthrown"; -import { HttpRouter } from "./auth.js"; +import { api } from "./auth.js"; const view = (order: Order): OrderView => ({ id: order.id, quantity: order.quantity, }); -export const ordersRouter = HttpRouter(ordersContract)( +export const ordersRouter = api.HttpRouter(ordersContract)( { place: PlaceOrder, find: FindOrder }, { sync: ({ place, find }) => ({ @@ -156,17 +162,18 @@ Each leaf is the `.result()` handler `@unthrown/orpc` gives an implementer: (the client sees `code: "CONFLICT"` as a value), and a `Defect` rethrows onto oRPC's own path, where it collapses to `INTERNAL_SERVER_ERROR`. `implement`, `os.…`, `.result(...)` and `os.router(...)` are what the call does for you. -oRPC's context carries **one** thing, and only under a marked procedure: the -`principal` the authenticator resolved. Everything else a procedure needs, the -provider declared. - -`HttpRouter` is imported from the application's own `auth.ts` — the file where -`httpAuth()` states what this deployment knows about a caller — which -is what gives `context.principal` a readable type here. The package's own -top-level `HttpRouter` names no identity and types it `never`, so every read is +oRPC's context carries **one** thing, and only under a protected procedure: the +`principal` the scheme's authenticator resolved. Everything else a procedure +needs, the provider declared. These two procedures name **one** scheme, so the +principal is that scheme's identity bare; a procedure naming several would get +a discriminated union its handler has to narrow. + +`api` is the application's own `auth.ts` binding — the file where +`defineHttp({ authenticators })` states what each scheme resolves to — which +is what gives `context.principal` a readable type here. A marked fragment +reached through anything else types it `never`, so every read is a compile error: the signal to use the factory, not a fallback. See -[Protect a procedure](/how-to/protect-a-procedure) for that file and the -authenticator below. +[Protect a procedure](/how-to/protect-a-procedure) for that file. ## Step 3 — the composition root @@ -176,19 +183,18 @@ import { OrderPersistenceModule } from "@btravstack/example-order-infrastructure import { HttpModule } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; -import { bearerAuthenticator } from "./authenticator.js"; import { ordersRouter } from "./router.js"; export const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, - authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], }); ``` `HttpModule` is `Module(name)({...})` plus `router`: it imports the starter -(`http()`), provides the router and the authenticator, and exports +(`http()`), provides the router **and the scheme authenticators the router +carries**, and exports `HttpRuntime`, and returns exactly the module the hand-written form would: ```ts @@ -199,18 +205,17 @@ Module("OrdersApi")({ observability(), http(), ], - provides: [ordersRouter, bearerAuthenticator], + provides: [ordersRouter, userAuth], exports: [HttpRuntime, Logger], }); ``` -The authenticator sits at the **root**, not beside the router: who a caller is -is one answer per process. It is required here because the contract marks the -fragment — a marked router carries `AuthenticatorPort` as a dependency, so -omitting the line leaves it in the module's `Needs` and `start` refuses the -module (`Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'` — -the port is named), and supplying one minted on a different identity is a -compile error at this very call. +There is **no authenticator to list**: it rides the router, which is what needs +it, so a scheme cannot be forgotten and cannot be wired to the wrong router. +What the compiler still checks is di's own gate — the router declares one +dependency per scheme its contract names, so a scheme with nobody behind it is +an unmet need `start` refuses, naming the port +(`Type '"HttpAuthenticator:user"' is not assignable to type '"@di/Scope"'`). [`observability()`](/reference/observability) is the other starter here: it brings the `Logger` the use cases and the request scope write to, bound from @@ -225,7 +230,8 @@ that imports `http()` without providing the router carries an unmet need — the starter's runtime provider depends on its router port through di — and `start` refuses the module, naming the port (`Type 'HttpRouterPort' is not assignable to type 'Env | Scope'`). And a root serving a **marked** -contract without an authenticator carries `AuthenticatorPort` as a second unmet +contract whose `defineHttp` declared no authenticator for one of its schemes +carries that scheme's port as a second unmet need, refused the same way; drop the marker and that third gate goes with it. ## Step 4 — `main.ts` @@ -261,13 +267,15 @@ stream rather than the default JSON on stderr; see `HttpModule(name)({...})` takes `imports`, `provides`, `exports`, `needs` and: -| Option | Default | What it does | -| --------------- | ------- | ---------------------------------------------------------------- | -| `router` | — | the router **provider** `HttpRouter` returned; required | -| `authenticator` | — | resolves the principal; required when the contract marks a route | -| `prefix` | `/rpc` | where the RPC endpoint is mounted | -| `port` | `PORT` | pins the port instead of reading the variable | -| `hostname` | `HOST` | pins the host instead of reading the variable | +| Option | Default | What it does | +| ---------- | ------- | ----------------------------------------------------------- | +| `router` | — | the router **provider** `api.HttpRouter` returned; required | +| `prefix` | `/rpc` | where the RPC endpoint is mounted | +| `port` | `PORT` | pins the port instead of reading the variable | +| `hostname` | `HOST` | pins the host instead of reading the variable | + +There is no `authenticator` option: the scheme authenticators ride the router +and the sugar puts them in `provides` itself. `http({ prefix?, port?, hostname? })` takes the last three; the router is not an option but the module's need, provided by the root. Pinning is per field — @@ -325,7 +333,8 @@ under the request already carries it — see - [`@btravstack/http`](/reference/http) — options, `HttpConfig`, `HttpInfo`, the guarantee. - [Protect a procedure](/how-to/protect-a-procedure) — the marker, `auth.ts` - and the authenticator this page uses, in full. + and the authenticators this page uses, in full, plus scopes and the 401/403 + split. - [Order API (HTTP)](/examples/order-api) — the real deployment this recipe scales into, two slices composed through controllers, client half included. - [Open a per-request scope](/how-to/open-a-per-request-scope) — the `RequestModule` in `main.ts`. diff --git a/docs/how-to/split-a-router-into-controllers.md b/docs/how-to/split-a-router-into-controllers.md index 01ef5af2..6712aa75 100644 --- a/docs/how-to/split-a-router-into-controllers.md +++ b/docs/how-to/split-a-router-into-controllers.md @@ -8,13 +8,13 @@ description: Give each slice of a large API its own contract fragment and contro > **How-to.** For an API that has outgrown one `sync`. For the shape of a > single-slice router, see [Serve an oRPC contract over HTTP](/how-to/serve-orpc-over-http). -`HttpRouter(contract)(deps, { sync })` puts every procedure's implementation in +`api.HttpRouter(contract)(deps, { sync })` puts every procedure's implementation in one function. That is right for a small API and wrong for a large one: a fifty-procedure contract would mean fifty injected services in one `sync`, one slice's typo failing the whole router's type-check, and no way to serve one slice without the rest. A **controller** is the fix: an ordinary di provider over one fragment of the contract, minted its own port, composed by the root -through a keyed `HttpRouter(contract)(controllers)` call. Everything below is +through a keyed `api.HttpRouter(contract)(controllers)` call. Everything below is lifted from `examples/order-api`, which serves an `orders` slice and a `customers` slice this way. @@ -70,7 +70,7 @@ const customersContract = { }; export const contract = { - orders: authenticated(ordersContract), + orders: authenticated({ user: [] })(ordersContract), customers: customersContract, }; ``` @@ -83,7 +83,7 @@ slice, and inferring the view type from it keeps the checked shape and the compiled one from drifting apart. The two fragments differ in one more way, and it is worth reading as part of -the split: `orders` is [`authenticated`](/reference/contract) and names no +the split: `orders` is [`authenticated({ user: [] })`](/reference/contract) and names no tenant on its inputs, because a caller's own identity establishes it; the unmarked `customers` names one, because "which tenant" is then part of what is being asked. A marker is per fragment, so slicing a contract is also where a @@ -91,7 +91,8 @@ public half and a protected one stop being one undifferentiated surface. ## Step 2 — a controller per slice -`HttpController(name, fragment)({ name: Dep }, { sync })` is `HttpRouter`'s own +`api.HttpController(name, fragment)({ name: Dep }, { sync })` is +`api.HttpRouter`'s own shape, aimed at one fragment: the first call fixes the fragment's type and mints a port under `name`; the second is di's `Provider(port)({ name: Dep }, { sync })`, @@ -99,9 +100,9 @@ so `sync`'s return is typed by the fragment at the call — a typo'd or missing procedure is a compile error inside the controller itself, not at the root: ```ts -import { HttpController } from "../../auth.js"; +import { api } from "../../auth.js"; -export const ordersController = HttpController( +export const ordersController = api.HttpController( "OrdersController", contract.orders, )( @@ -152,17 +153,19 @@ export const ordersController = HttpController( ); ``` -`HttpController` comes from the application's own `auth.ts`, not from -`@btravstack/http`: the marker on the fragment says the route is protected, and -`httpAuth()` in that one file is what says what a principal is, so -`context.principal` has a readable type here. Reached through the package's own -top-level `HttpController` it would be `never`, and every read a compile error. +`HttpController` comes off the application's own `api` in `auth.ts`, not from +`@btravstack/http` — there is no top-level one: the marker on the fragment says +which schemes protect the route, and +`defineHttp({ authenticators })` in that one file is what says what each scheme +resolves to, so +`context.principal` has a readable type here. Reached through any other +`defineHttp` call it would be `never`, and every read a compile error. The unmarked `customers` controller is unaffected either way — its context has no `principal` at all. See [Protect a procedure](/how-to/protect-a-procedure). The controller does no oRPC work of its own — it stores a plain record, and -`HttpRouter` wraps each leaf in `.result(...)` when it composes the router. -`HttpController` mints the port and carries it back on `.port`, which the +`api.HttpRouter` wraps each leaf in `.result(...)` when it composes the router. +`api.HttpController` mints the port and carries it back on `.port`, which the keyed form reads to order this controller's construction before the router's — there is nothing to name by hand. A slice ships its controller as a module that **imports the vertical it needs** and exports only that controller, the @@ -195,12 +198,12 @@ is built. ## Step 3 — the keyed root -`HttpRouter(contract)(controllers)` — a record keyed by the contract's own +`api.HttpRouter(contract)(controllers)` — a record keyed by the contract's own top-level keys, one `HttpController` per key — replaces the `(deps, { sync })` call at the root, and is told apart from it by **arity**: ```ts -export const orderRouter = HttpRouter(contract)({ +export const orderRouter = api.HttpRouter(contract)({ orders: ordersController, customers: customersController, }); @@ -212,7 +215,6 @@ owns: ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); @@ -220,10 +222,12 @@ export const OrderApi = HttpModule("OrderApi")({ `observability()` is here because every slice's layers write to its `Logger` and none of them owns it; `Logger` is exported because the per-request module -reads it. The `authenticator` is here for the same kind of reason and a -stronger one: who a caller is is one answer per process, not a slice's -question. It is required because a marked fragment made it a dependency of the -router provider, so omitting it leaves `AuthenticatorPort` in the root's +reads it. The **authenticators are not** here, and that is the point: who a +caller is is one answer per process, so they were declared once in `auth.ts` +and they ride the router, which is what needs them — `HttpModule` puts them in +`provides` itself. A marked fragment makes each scheme it names a dependency of +the router provider, so a scheme with no authenticator behind it leaves +`HttpAuthenticator:` in the root's `Needs` and `start` refuses the module — not a gate of this package's, and not di's arity gate either, but the plain assignability of the `Needs` channel against `Env | Scope`, which names the port. Nothing else about what a slice @@ -231,7 +235,7 @@ needs is spelled at the root. This form is **exact**: a key the record above is missing, a key the contract does not declare, and a controller wired under the wrong key are all -compile errors at the `HttpRouter(contract)({...})` call, not runtime +compile errors at the `api.HttpRouter(contract)({...})` call, not runtime surprises the first time a client hits the missing slice. ## Step 4 — lifting a slice into its own process @@ -242,21 +246,21 @@ controller's own port as its single dependency and hands back what that controller built: ```ts -export const ordersRouter = HttpRouter(contract.orders)( +export const ordersRouter = api.HttpRouter(contract.orders)( { implementation: ordersController.port }, { sync: ({ implementation }) => implementation }, ); export const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, observability()], }); ``` -`HttpRouter` here is `auth.ts`'s too — the lifted fragment carries its marker, -so the lifted root needs the same authenticator the modulith did, and that is -the only line about identity extraction adds. +`api` here is `auth.ts`'s too — the lifted fragment carries its marker, so the +lifted root owes the same schemes the modulith did, and the router brings the +authenticators for them from that same call. Extraction adds no line about +identity at all. `OrdersSlice` is the very module the modulith imported and `ordersController` the very provider it composed — not a copy, not a rewritten `sync`. Extraction @@ -269,9 +273,9 @@ composing slices into one router a starting point rather than a trap. - [Serve an oRPC contract over HTTP](/how-to/serve-orpc-over-http) — the one-router form, and everything the starter itself decides. -- [`@btravstack/http`](/reference/http) — `HttpController` and +- [`@btravstack/http`](/reference/http) — `defineHttp`, `HttpController` and `HttpRouter`'s full signatures. - [Protect a procedure](/how-to/protect-a-procedure) — `auth.ts`, the - authenticator, and what a marked fragment does to a controller. + authenticators, and what a marked fragment does to a controller. - [Order API (HTTP)](/examples/order-api) — the two-slice example these samples come from. diff --git a/docs/how-to/test-an-application.md b/docs/how-to/test-an-application.md index 06255728..7cb73b9d 100644 --- a/docs/how-to/test-an-application.md +++ b/docs/how-to/test-an-application.md @@ -137,11 +137,10 @@ string. Compose the root's own shape with a recording sink, and boot that: ```ts const lines: Line[] = []; +// The same router as the real root, so the same authenticators come with it: +// they ride the router and `HttpModule` puts them in `provides` itself. const recordingApi = HttpModule("RecordingApi")({ router: orderRouter, - // The same authenticator as the real root: the contract marks `orders`, so - // every composition serving that router owes one. - authenticator: bearerAuthenticator, imports: [ OrdersSlice, CustomersSlice, @@ -309,7 +308,6 @@ const recordingApi = () => { api: HttpModule("RecordingApi")({ needs: [Env], router: orderRouter, - authenticator: bearerAuthenticator, imports: [ OrdersSlice, CustomersSlice, diff --git a/docs/index.md b/docs/index.md index f63f1287..60c5d182 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,8 +46,7 @@ import { P } from "unthrown"; import { z } from "zod"; import { OrderApplicationModule, PlaceOrder } from "./application.js"; -import { HttpRouter } from "./auth.js"; -import { bearerAuthenticator } from "./authenticator.js"; +import { api } from "./auth.js"; import { OrderPersistenceModule } from "./persistence.js"; // The contract comes first; a client can take it without the server. @@ -58,9 +57,10 @@ const orderRef = z.object({ id: z.uuidv7() }); // not a UUIDv7: `orderRef` would reject the only payload it ever carries. const malformedRef = z.object({ id: z.string() }); -// `authenticated` marks the fragment. It names no tenant on the input: a -// caller does not get to pick the tenant it is served. -const ordersContract = authenticated({ +// `authenticated` marks the fragment with an OpenAPI security requirement — +// the `user` scheme, no scopes. It names no tenant on the input: a caller does +// not get to pick the tenant it is served. +const ordersContract = authenticated({ user: [] })({ place: oc .input(z.object({ id: z.uuidv7(), quantity: z.number() })) .output(orderView) @@ -73,7 +73,7 @@ const ordersContract = authenticated({ // The router is a provider: it declares the use case its procedure calls. // Every domain error is named here — the one place a Result becomes HTTP. -const ordersRouter = HttpRouter(ordersContract)( +const ordersRouter = api.HttpRouter(ordersContract)( { place: PlaceOrder }, { sync: ({ place }) => ({ @@ -111,7 +111,6 @@ const ordersRouter = HttpRouter(ordersContract)( // The composition root. The runtime is a service of this module. const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, - authenticator: bearerAuthenticator, imports: [OrderApplicationModule, OrderPersistenceModule], }); @@ -126,11 +125,14 @@ builds the graph, resolves that port and drives what it finds. `PORT` and configuration provider — nothing in `main.ts` touches `process.env`, and a malformed value is a `startFailed` event and exit code `78`. -**The contract says _whether_ a route is protected; the application says _what_ -the principal is.** `authenticated` is the fact a client reads off the -contract; `httpAuth()` in `auth.ts` is what mints the `HttpRouter` -above, so `context.principal` is typed where the handler is written; and the -authenticator resolves it once per request, at the root. See +**The contract says _which schemes_ protect a route; the application says +_what each one is_.** `authenticated({ user: [] })` is the fact a client reads +off the contract; `defineHttp({ authenticators })` in `auth.ts` is what mints +the `api.HttpRouter` above, so `context.principal` is typed where the handler +is written. The authenticators ride the router, so the root lists none — and a +scheme with nobody behind it is an unmet dependency naming the port. A required +**scope** the credential lacks is a `403`, distinct from the `401` a caller +with no valid credential gets. See [Protect a procedure](/how-to/protect-a-procedure). **SIGTERM drains in three beats.** Readiness flips false; the kernel waits for @@ -156,7 +158,7 @@ lines in [Packages and install](/reference/packages). pino behind a subpath, and the kernel's own events as lines in the same stream. Traces and metrics are not here yet. - **`@btravstack/http`** — the HTTP starter: an oRPC contract served over - `node:http`, one unit per request, `HttpRouter` and `HttpModule`. + `node:http`, one unit per request, `defineHttp` and `HttpModule`. - **`@btravstack/temporal`** — the Temporal worker starter: one unit per activity attempt, `TemporalActivities` and `TemporalModule`. - **`@btravstack/amqp`** — the AMQP consumer starter: one unit per message, diff --git a/docs/reference/contract.md b/docs/reference/contract.md index b83ff594..e1ec83d6 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -1,6 +1,6 @@ --- title: "@btravstack/contract" -description: The contract-level auth marker — authenticated(), Authenticated, PrincipalKey, IsMarked and isAuthenticated — what it puts on a contract node, and what it deliberately does not. +description: The contract-level auth marker — authenticated(), Requirement, Requirements, Authenticated, PrincipalKey, IsMarked, RequirementsOf and isAuthenticated — what it puts on a contract node, and what it deliberately does not. --- # @btravstack/contract @@ -14,47 +14,61 @@ description: The contract-level auth marker — authenticated(), Authenticated, > [API reference](/api/contract/). A marker a contract puts on a node — a record of procedures, or a single -procedure — to say _"this requires an authenticated caller"_, readable by -both the client that imports the contract and the server that implements it. -Nothing here talks to oRPC, HTTP, AMQP or Temporal: it is a plain marker over -`WeakSet` identity, transport-agnostic by construction. - -**The contract says _whether_ a route is protected; the application's -`httpAuth()` says _what_ the principal is.** No identity type is -named here at all, so nothing about the server's view of a caller reaches a -client. +procedure — to say _"this requires a caller satisfying one of these security +requirements"_, readable by both the client that imports the contract and the +server that implements it. A requirement is OpenAPI's own shape: a security +scheme's name, mapped to the scopes it must grant. Nothing here talks to oRPC, +HTTP, AMQP or Temporal: it is a plain marker over `WeakMap` identity, +transport-agnostic by construction. + +**The contract says _which schemes_ protect a route and _which scopes_ each +must grant; the application's `defineHttp({ authenticators })` says _what each +scheme resolves to_.** No identity type is named here at all, so nothing about +the server's view of a caller reaches a client. ## Exports `packages/contract/src/index.ts` exports exactly this: -| Export | Kind | What it is | -| ------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `authenticated` | value | `(node: T) => Authenticated` — marks a contract node as requiring an authenticated caller | -| `isAuthenticated` | value | `(node: object) => boolean` — whether **this exact node** was marked | -| `Authenticated` | type | `T & { readonly [PrincipalKey]: true }` — `T`'s own keys plus one phantom key that exists only for the type checker | -| `PrincipalKey` | type | `typeof PRINCIPAL`, the marker's key — exported so a consumer's mapped type can `Exclude` and land on the contract's own keys | -| `IsMarked` | type | `T extends { readonly [PrincipalKey]: true } ? true : false` — whether **this exact node** carries the marker, as a yes/no rather than a type | - -## `authenticated(node)` - -One export, no factory and no type parameter. Apply it to a record of -procedures (which protects every procedure beneath it) or to a single -procedure (which protects itself): +| Export | Kind | What it is | +| --------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `authenticated` | value | `(...requirements: R) => (node: T) => Authenticated` — curried; marks a node with the requirements it names | +| `isAuthenticated` | value | `(node: object) => Requirements \| undefined` — what **this exact node** requires, or `undefined` when nobody marked it | +| `Requirement` | type | `Readonly>` — one security scheme's name mapped to the scopes it must grant | +| `Requirements` | type | `readonly Requirement[]` — ORed, tried in declaration order | +| `Authenticated` | type | `T & { readonly [PrincipalKey]: R }` — `T`'s own keys plus one phantom key holding the exact requirements, for the type checker only | +| `PrincipalKey` | type | `typeof PRINCIPAL`, the marker's key — exported so a consumer's mapped type can `Exclude` and land on the contract's own keys | +| `IsMarked` | type | `T extends { readonly [PrincipalKey]: Requirements } ? true : false` — whether **this exact node** carries the marker, as a yes/no rather than a type | +| `RequirementsOf` | type | the exact `Requirements` **this exact node** was marked with, `never` when it is unmarked | + +## `authenticated(...requirements)(node)` + +**Curried.** The first call takes one or more `Requirement`s — a scheme name +mapped to the scopes it must grant — and returns the function that marks a +node. Apply that to a record of procedures (the **default** for every procedure +beneath it) or to a single procedure (which **replaces** that default for +itself): ```ts import { authenticated } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; -const ordersContract = { +const ordersContract = authenticated({ user: [] })({ place: oc .input(z.object({ id: z.string(), quantity: z.number() })) .output(z.object({ id: z.string() })), -}; + + // Its own mark replaces the group default: a `user` token granting the + // `orders:export` scope, or a `service` token needing no scope at all. + export: authenticated( + { user: ["orders:export"] }, + { service: [] }, + )(oc.output(z.object({ csv: z.string() }))), +}); export const contract = { - orders: authenticated(ordersContract), + orders: ordersContract, customers: { find: oc .input(z.object({ id: z.string() })) @@ -63,57 +77,75 @@ export const contract = { }; ``` -There is nothing to state twice, and nothing about identity to keep in step -between two contracts: the marker carries no principal, so `orders` above says -only that a caller must be authenticated. +Three rules, and they are OpenAPI's own: + +- **Requirements are ORed**, tried in the order given: the first one a caller + satisfies wins. +- **A requirement names one scheme.** AND-within-a-requirement is deliberately + not modelled — requiring two credentials at once would put a record rather + than a single identity on the handler. A composite scheme models it where it + is genuinely needed. +- **Nearest mark wins.** A marked record is a default; a marked procedure + beneath it replaces that default for itself rather than adding to it. + +Nothing about identity is stated here, and nothing has to be kept in step +between two contracts: `{ user: [] }` says a caller must present the `user` +scheme, not who a `user` is. -## `IsMarked` and `isAuthenticated` +## `IsMarked`, `RequirementsOf` and `isAuthenticated` -`IsMarked` answers the question at the type level; `isAuthenticated` answers -the same question at runtime, for one node: +`IsMarked` answers yes/no at the type level, `RequirementsOf` reads the exact +requirements back, and `isAuthenticated` answers the same question at runtime, +for one node: ```ts import { authenticated, isAuthenticated, type IsMarked, + type Requirements, + type RequirementsOf, } from "@btravstack/contract"; import { oc } from "@orpc/contract"; import { z } from "zod"; -const quote = authenticated( +const quote = authenticated({ user: ["quotes:read"] })( oc .input(z.object({ id: z.string() })) .output(z.object({ total: z.number() })), ); export type QuoteIsMarked = IsMarked; // true -export const isProtected: boolean = isAuthenticated(quote); // true +export type QuoteNeeds = RequirementsOf; // [{ user: ["quotes:read"] }] +export const required: Requirements | undefined = isAuthenticated(quote); ``` -`isAuthenticated` answers for **one node only**. Ancestry — a marked parent -implying a marked child — is the caller's to carry: this package tracks nodes, -not trees. `@btravstack/http`'s router walk carries an `inherited` flag for -exactly that, mirroring what the types do when a marked record pushes its -marker onto each child. +`isAuthenticated` answers for **one node only**, and answers `undefined` — not +an empty array — when nobody marked it, so "public" cannot be confused with +"protected by nothing satisfiable". Ancestry — a marked parent implying a +marked child — is the caller's to carry: this package tracks nodes, not trees. +`@btravstack/http`'s router walk carries the inherited requirements for exactly +that, mirroring what the types do when a marked record pushes its requirements +onto each child that declares none of its own. -## The contract says whether; the application says what +## The contract says which schemes; the application says what each one is Nothing here names an identity type, so there is nothing in the contract to -keep minimal and nothing in it to leak. What a principal actually **is** is -stated once, server-side, by `@btravstack/http`'s -[`httpAuth()`](/reference/http) — and a handler minted from that -factory sees it with no annotation of its own. +keep minimal and nothing in it to leak. What each scheme resolves to is stated +once, server-side, by `@btravstack/http`'s +[`defineHttp({ authenticators })`](/reference/http) — and a handler minted from +that call sees it with no annotation of its own. Two things follow. Enriching what a deployment knows about its callers — roles, an org tier, an internal id — is never a contract change and reaches no -client. And the gate pairing a router with an authenticator compares the -**router's** identity against the **authenticator's**, both of which come from -the same `httpAuth` call, rather than either against the contract. +client. And there is no identity pair left to compare: declaring a scheme and +implementing it are the same act, so a scheme the contract names with no +authenticator behind it is di's own unmet need on `HttpAuthenticator:`, +not a gate either package writes. -A marked fragment reached through the top-level `HttpController` — no factory — -types `principal: never`, so every read of it is a compile error. That is the -signal to use the factory, not a fallback. +A marked fragment reached through anything but a `defineHttp` call types +`principal: never`, so every read of it is a compile error. That is the signal +to use the factory, not a fallback. ## Three load-bearing properties @@ -123,14 +155,16 @@ the server that implements it, and what would let an AMQP or Temporal contract reuse the same marker: it has no opinion about which transport reads it. **The combinator returns the node unchanged and sets no property on it.** -`authenticated(node) === node`, with nothing added — `PRINCIPAL` is `declare`d +`authenticated(...requirements)(node) === node`, with nothing added — +`PRINCIPAL` is `declare`d and never assigned, so it exists only in the type system. There is no key for oRPC's `implement()` to walk as a procedure and nothing for its builders to -strip; the mark lives in a `WeakSet` keyed by identity, shared across copies of +strip; the mark lives in a `WeakMap` keyed by identity, mapping each node to +the requirements it was marked with, shared across copies of this package (see the warning below). **Applied after a builder chain is finished, never inside one.** -`authenticated` wraps a finished node — the last call in a chain, or a whole +`authenticated(...requirements)` wraps a finished node — the last call in a chain, or a whole record of finished nodes. Applied mid-chain it is lost, because `oc.router(...)` rebuilds every node: lost on **both** sides at once, the type and the runtime mark together, which makes it a dropped protection rather than @@ -144,9 +178,17 @@ a bypass. No oRPC builder has to know the marker exists. [Protect a procedure](/how-to/protect-a-procedure). - **It does not authenticate, and it does not name a principal.** Turning a request into a principal is `@btravstack/http`'s `HttpAuthenticator`, what - that principal's type is, is `httpAuth()`, and what a token means - is the application's. -- **It does not model authorization.** Who a caller is, not what they may do. + each scheme resolves to is `defineHttp({ authenticators })`, and what a token + means is the application's. +- **It does not check a scope either.** It declares one; comparing a + credential's granted scopes against it — and answering `403` rather than + `401` — is the starter's. +- **It does not model resource-dependent authorization.** A scope is a property + of the credential and is answerable before dispatch, which is why it is here. + "Is this caller the order's owner?" is not, and stays in the handler. +- **It carries no OpenAPI document metadata.** A scheme's own definition — + `type: http`, `bearerFormat`, an OAuth flow — belongs beside the contract, + not in the marker. ## Peer dependencies @@ -157,18 +199,18 @@ that is the whole install. Node `>=20`. `PrincipalKey` is a `unique symbol`, so two copies of this package mint two different brands: a contract marked against one does not type as marked in the other. The **runtime** registry does not split that way — it hangs off -`globalThis` under `Symbol.for("@btravstack/contract/marked")`, so every copy -reads and writes one `WeakSet`. +`globalThis` under `Symbol.for("@btravstack/contract/requirements")`, so every +copy reads and writes one `WeakMap`. -That asymmetry is deliberate. A module-private set would make a second copy -silent: `isAuthenticated` false everywhere, no authenticator required, and a -marked route **served open**. Sharing the registry makes the two halves fail +That asymmetry is deliberate. A module-private map would make a second copy +silent: `isAuthenticated` `undefined` everywhere, no scheme dependency +declared, and a marked route **served open**. Sharing the registry makes the two halves fail together, and the type half fails loudly. `@btravstack/http` peers on this package so an application holds a single copy in the first place. `PRINCIPAL` is also never exported as a value, and must stay that way: a nameable brand could be written onto a contract node by hand without the -matching `WeakSet` entry — typed as protected, unmarked at runtime, so no +matching `WeakMap` entry — typed as protected, unmarked at runtime, so no authenticator is demanded and a handler reads a principal nothing injected. See [Peer dependencies](/explanation/peer-dependencies). @@ -178,8 +220,8 @@ See - [Protect a procedure](/how-to/protect-a-procedure) — mark, authenticate, compose. -- [`@btravstack/http`](/reference/http) — `HttpAuthenticator`, - `AuthenticatorPort`, `Unauthenticated`, and what a marked leaf's handler - receives. +- [`@btravstack/http`](/reference/http) — `defineHttp`, `HttpAuthenticator`, + `Unauthenticated`, and what a marked leaf's handler receives. - [Order API (HTTP)](/examples/order-api) — a contract with one marked - fragment and one public one. + fragment, one public one, and a procedure that overrides its group's + default. diff --git a/docs/reference/http.md b/docs/reference/http.md index b109e522..b0894fc3 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -1,6 +1,6 @@ --- title: "@btravstack/http" -description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAuthenticator, http(), HttpRuntime, HttpConfig and HttpInfo, plugins and securityHeaders, what each request is answered with, and how the drain retires a keep-alive connection. +description: The HTTP starter — defineHttp, HttpModule, HttpRouter, HttpController, HttpAuthenticator, http(), HttpRuntime, HttpConfig and HttpInfo, named security schemes and scopes, plugins and securityHeaders, what each request is answered with, and how the drain retires a keep-alive connection. --- # @btravstack/http @@ -18,30 +18,36 @@ description: The HTTP starter — HttpModule, HttpRouter, HttpController, HttpAu `packages/http/src/index.ts` exports exactly this: -| Export | Kind | What it is | -| ---------------------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `HttpModule` | value | `HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports?, needs? })` — a di `Module(name)({...})` that also takes the router provider; the composition root of an HTTP deployment | -| `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | -| `HttpRouter` | value | `HttpRouter(contract)(deps, { sync })`, or `HttpRouter(contract)(controllers)` — the router as a provider on the starter's own router port, contract-first, either from one `sync` or from a keyed record of controllers | -| `HttpController` | value | `HttpController(name, fragment)({ name: Dep }, { sync })`, or `({ sync })` with no deps — one slice of a contract, as a provider on a port minted for it | -| `HttpAuthenticator` | value | `HttpAuthenticator

()({ name: Dep }, { sync })`, or `({ sync })` with no deps — the provider that turns a request's headers into a principal `P`, on `AuthenticatorPort` | -| `httpAuth` | value | `httpAuth()` — mints `HttpController`, `HttpRouter` and `HttpAuthenticator` fixed to the server's own identity; the only thing that gives a marked handler a readable `context.principal` | -| `HttpAuth` | type | what `httpAuth()` returns — the three, as one type | -| `HttpControllerOf` | type | `HttpControllerOf` — the annotation a file exporting the factory's `HttpController` needs | -| `HttpRouterOf` | type | `HttpRouterOf` — the same, for the router | -| `HttpAuthenticatorOf` | type | `HttpAuthenticatorOf` — the same, for the authenticator | -| `AuthenticatorPort` | value | `Port("HttpAuthenticator")` over `AuthenticatorService` — the port a marked contract's router depends on | -| `AuthenticatorService` | type | `(headers: IncomingHttpHeaders) => AsyncResult` — headers in, principal out | -| `Unauthenticated` | value | a `TaggedError` with an empty payload — the refusal itself; the starter surfaces no reason to the client | -| `HasMark` | type | `HasMark` — exactly `true` or `false`: whether the contract marks anything, anywhere in its tree | -| `http` | value | `http({ prefix?, port?, hostname?, plugins?, securityHeaders? })` — the starter module itself, needing the router port; what `HttpModule` imports | -| `HttpOptions` | type | `http()`'s options | -| `HttpRuntime` | value | `class HttpRuntime extends RuntimePort> {}` — the runtime's port; what `http()` provides and the module `start` boots must export | -| `HttpConfig` | value | `class HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string }> {}` — what the socket is bound with, provided by `http()` from `PORT` / `HOST` | -| `HttpInfo` | type | `{ readonly port: number }` — what the runtime publishes on `Serving.info` once listening, read back through `RunningApp.runtimeInfo()` | +| Export | Kind | What it is | +| ---------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `defineHttp` | value | `defineHttp({ authenticators })`, or `defineHttp()` for a public API — **the one door**: it declares this deployment's security schemes and hands back `HttpController`, `HttpRouter` and `authenticators` typed by them | +| `Http` | type | `Http` — what `defineHttp` returns, held as one binding and never destructured | +| `Authenticators` | type | `Readonly>>` — the registry `defineHttp` takes, keyed by scheme name | +| `SchemesFrom` | type | `SchemesFrom` — the scheme-name → identity map read off the authenticators, so it is never declared twice | +| `HttpModule` | value | `HttpModule(name)({ router, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports?, needs? })` — a di `Module(name)({...})` that also takes the router provider; the composition root of an HTTP deployment | +| `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | +| `HttpAuthenticator` | value | `HttpAuthenticator()({ name: Dep }, { sync })`, or `({ sync })` with no deps — how one scheme is implemented; the scheme's **name** is the key it sits under in `defineHttp` | +| `Authenticator` | type | what `HttpAuthenticator` hands back — a description carrying its deps, principal, scopes and needs, which `defineHttp` binds to a port | +| `Granted` | type | `Granted` — the identity **bare** when the scheme has no scope vocabulary, `{ identity, scopes }` when it has one | +| `AuthenticatorService` | type | `(headers: IncomingHttpHeaders) => AsyncResult, Unauthenticated>` — headers in, credential out | +| `authenticatorPort` | value | `authenticatorPort(scheme)` — the di port whose id is `` `HttpAuthenticator:${scheme}` ``; a router declares one per scheme its contract names | +| `Unauthenticated` | value | a `TaggedError` with an empty payload — the refusal itself; the starter surfaces no reason to the client | +| `Principal` | type | `Principal` — what a leaf's handler reads: bare for one scheme, a tagged union for several, `never` for none | +| `SchemesOf` | type | `SchemesOf` — the union of scheme names a `Requirements` tuple mentions | +| `HasMark` | type | `HasMark` — exactly `true` or `false`: whether the contract marks anything, anywhere in its tree | +| `http` | value | `http({ prefix?, port?, hostname?, plugins?, securityHeaders? })` — the starter module itself, needing the router port; what `HttpModule` imports | +| `HttpOptions` | type | `http()`'s options | +| `HttpRuntime` | value | `class HttpRuntime extends RuntimePort> {}` — the runtime's port; what `http()` provides and the module `start` boots must export | +| `HttpConfig` | value | `class HttpConfig extends Port("HttpConfig")<{ port: number; hostname: string }> {}` — what the socket is bound with, provided by `http()` from `PORT` / `HOST` | +| `HttpInfo` | type | `{ readonly port: number }` — what the runtime publishes on `Serving.info` once listening, read back through `RunningApp.runtimeInfo()` | + +`HttpController` and `HttpRouter` are **not** top-level exports: they come off +`defineHttp`, because that is where the scheme registry that types them is +stated. A marked contract reached through anything else would type +`principal: never`. `HttpRouterPort` (the starter's router port, `Port("HttpRouter")`), -`Implementation` (the record type `HttpRouter`'s `sync` returns) and +`Implementation` (the record type `HttpRouter`'s `sync` returns) and `HttpHandler` (the node listener port) exist in `src/orpc.ts` and `src/handler.ts` but are **not** exported from the package entry point: the first is reached as `provider.port` when a caller needs it, the second is @@ -52,45 +58,48 @@ inferred at the call, the third is an internal seam. Everything `Module(name)({...})` takes — `imports`, `provides`, `exports` — plus the starter's own fields. It appends `http({ prefix, port, hostname, plugins, securityHeaders })` to `imports`, -prepends `router` (and `authenticator`, when one is given) to `provides`, +prepends `router` **and the scheme authenticators the router carries** to +`provides`, prepends `HttpRuntime` to `exports`, and hands the augmented tuples to di's own `Module(name)`, whose return type is the sugar's. The kernel and both gates see a plain module. -| Option | Required | Default | What it is | -| ----------------- | -------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `router` | yes | — | the application's router **provider** — a `Provider`, what `HttpRouter(contract)(deps, arm)` returns; a provider on any other port fails at the call | -| `authenticator` | no\* | — | what `HttpAuthenticator

()({ name: Dep }, { sync })` returns; \*owed whenever the contract marks anything (see [Authentication](#authentication)) | -| `prefix` | no | `/rpc` | where the RPC endpoint is mounted; typed `` `/${string}` `` | -| `port` | no | read from `PORT` | pins the port instead of reading it | -| `hostname` | no | read from `HOST` | pins the host instead of reading it | -| `plugins` | no | `[]` | oRPC handler plugins, forwarded to `RPCHandler` — CORS, body limits, compression, CSRF | -| `securityHeaders` | no | `true` | response headers set on the raw listener, before dispatch | -| `imports` | no | `[]` | the application's modules | -| `provides` | no | `[]` | the application's own providers | -| `exports` | no | `[]` | the application's own exports; `HttpRuntime` is added | +| Option | Required | Default | What it is | +| ----------------- | -------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `router` | yes | — | the application's router **provider** — a `Provider`, what `api.HttpRouter(contract)(deps, arm)` returns; a provider on any other port fails at the call | +| `prefix` | no | `/rpc` | where the RPC endpoint is mounted; typed `` `/${string}` `` | +| `port` | no | read from `PORT` | pins the port instead of reading it | +| `hostname` | no | read from `HOST` | pins the host instead of reading it | +| `plugins` | no | `[]` | oRPC handler plugins, forwarded to `RPCHandler` — CORS, body limits, compression, CSRF | +| `securityHeaders` | no | `true` | response headers set on the raw listener, before dispatch | +| `imports` | no | `[]` | the application's modules | +| `provides` | no | `[]` | the application's own providers | +| `exports` | no | `[]` | the application's own exports; `HttpRuntime` is added | The worked composition root, from `examples/order-api/src/module.ts`: ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); ``` That is exactly the module -`Module("OrderApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], provides: [orderRouter, bearerAuthenticator], exports: [HttpRuntime, Logger] })` -would have declared. `authenticator` is a plain optional field: present, it -joins `provides`, which is all discharging di's need takes. +`Module("OrderApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], provides: [orderRouter, userAuth, serviceAuth], exports: [HttpRuntime, Logger] })` +would have declared. **There is no `authenticator` option**: the +authenticators ride the router — which is what needs them — and the sugar +spreads them into `provides` itself, so an application never lists one and +cannot list the wrong one. Their own dependencies (a JWT verifier, a key set) +travel with them, so a root that satisfies none is refused at **this** call by +di's `NeedsGate`, exactly as a hand-listed provider would be. [`observability()`](/reference/observability) is a second starter, not this package's business: it brings the `Logger` the application writes to, bound from `LOG_LEVEL`, JSON per line on stdout, every line carrying the trace id of the unit this runtime opened. -## `HttpRouter(contract)(deps, { sync })` +## `api.HttpRouter(contract)(deps, { sync })` Contract-first: `contract` is an oRPC router record (`Record` — a record, not a bare procedure), and the second call is @@ -112,16 +121,17 @@ framework-owned like `HttpConfig` — and two router providers in one graph are di's duplicate-provider defect at build. Returns `Provider>, never, InstanceType> & { readonly port: PortClassOf<"HttpRouter", Router<…>> }` — `provider.port` is the port class, for a hand-declared provider or a type -test. The implementation below is the one in +test, and `provider.authenticators` carries the scheme providers `defineHttp` +bound. The implementation below is the one in `examples/order-api/src/slices/orders/controller.ts`, served through the deps form — the example composes it as a controller instead (see the keyed form), and a fragment is a contract, so the same `sync` reads either way. -`contract.orders` is `authenticated`, so `HttpRouter` here is the application's -own — `httpAuth()`'s, from its `src/auth.ts` — and the tenant comes -off `context.principal` rather than off the input: +`contract.orders` is marked `authenticated({ user: [] })`, so `api` here is the +application's own `defineHttp` binding, from its `src/auth.ts`, and the tenant +comes off `context.principal` rather than off the input: ```ts -export const ordersRouter = HttpRouter(contract.orders)( +export const ordersRouter = api.HttpRouter(contract.orders)( { place: PlaceOrder, find: FindOrder }, { sync: ({ place, find }) => ({ @@ -164,6 +174,20 @@ export const ordersRouter = HttpRouter(contract.orders)( }), ), ), + // `export` names two schemes, so its principal is a tagged union the + // handler narrows — and the switch is exhaustive or the build fails. + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + return OkAsync({ + csv: `user,${context.principal.identity.userId}`, + }); + case "service": + return OkAsync({ + csv: `service,${context.principal.identity.appId}`, + }); + } + }, }), }, ); @@ -172,23 +196,23 @@ export const ordersRouter = HttpRouter(contract.orders)( An implementation key the contract does not declare is unreachable through the types; if one is smuggled past them it is dropped, not defected on. -### The keyed form: `HttpRouter(contract)(controllers)` +### The keyed form: `api.HttpRouter(contract)(controllers)` For a `contract` shaped `Record`, `HttpRouter` also takes a **record of controllers**, one per top-level key, instead of `(deps, { sync })`: ```ts -export const orderRouter = HttpRouter(contract)({ +export const orderRouter = api.HttpRouter(contract)({ orders: ordersController, customers: customersController, }); ``` -Each value is what [`HttpController`](#httpcontrollername-fragment) +Each value is what [`HttpController`](#api-httpcontroller-name-fragment) returns. The call is **exact**: `M` is constrained to `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` +RequirementsOf>, Schemes> }`, and the `controllers` **parameter** itself is typed: ```ts @@ -213,9 +237,10 @@ error TS2769: No overload matches this call. Read the **last** line: the ones above it name the type you passed. The `Exclude`/`Inherit` pair is the same one -[`Implementation`](#authentication) carries: a contract marked at its -**root** composes through this form too, and each fragment inherits that mark, -so a controller under it types `context.principal`. +[`Implementation`](#authentication) carries: a contract marked at +its **root** composes through this form too, and each fragment inherits those +requirements, so a controller under it types `context.principal` — unless it +carries a mark of its own, in which case that one wins. Five gates are pinned by `packages/http/src/controller.test-d.ts`: every contract key must be covered; a key the contract does not declare is rejected; a controller wired under the @@ -223,17 +248,22 @@ wrong key is rejected (its fragment does not match that key's); a procedure a controller's own fragment does not declare is rejected inside the controller, before the root ever sees it; and a slice lifts into a process of its own with its controller untouched — -`HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` +`api.HttpRouter(contract.orders)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })` compiles — the property a slice's independent deployability -rests on. The `(deps, { sync })` -form is unchanged and stays correct for a small API — the two are -discriminated **by arity** at the call, the same way -`Provider(port)(depsOrOptions, …)` discriminates its own two forms, since a -deps record and a controllers record are both objects. See +rests on. Three further arms pin what the requirements themselves do: a +procedure under a marked record inherits that record's requirement, a procedure +with its own mark replaces it, and the router's needs channel carries one +`HttpAuthenticator:` port per scheme the contract names anywhere. The +`(deps, { sync })` +form is unchanged and stays correct for a small API — it is told from the +controllers record **by arity**, the same way +`Provider(port)(depsOrOptions, …)` discriminates its own two forms, and the +third form — an arm alone, `({ sync })` — is told from a controllers record by +whether `sync` holds a function. See [Split a router into controllers](/how-to/split-a-router-into-controllers) for the worked recipe. -## `HttpController(name, fragment)` +## `api.HttpController(name, fragment)` ```ts const HttpController: ( @@ -244,19 +274,19 @@ const HttpController: ( options: { readonly sync: (services: { readonly [K in keyof D]: ServiceOf>; - }) => Implementation; + }) => Implementation; }, ) => Provider< - PortInstance>, + PortInstance>, never, InstanceType > & { - readonly port: PortClassOf>; + readonly port: PortClassOf>; }; ``` One slice of a contract, as a provider over a port minted for it — the same -two-call shape as `HttpRouter(contract)({ name: Dep }, { sync })`, aimed at a +two-call shape as `api.HttpRouter(contract)({ name: Dep }, { sync })`, aimed at a `fragment` rather than the whole contract. `fragment` is read for its **type** only: it shapes `sync`'s return, so a procedure the fragment does not declare, or a handler whose input or output has drifted, is a compile @@ -272,14 +302,17 @@ export const OrdersSlice = Module("OrdersSlice")({ }); ``` -The controller does no oRPC work: it is a plain record, and `HttpRouter`'s +`Schemes` is fixed by the `defineHttp` call the controller was minted from, +which is what gives a marked fragment's handlers a readable +`context.principal`. The controller does no oRPC work: it is a plain record, +and `HttpRouter`'s own walk wraps each leaf in `.result(...)` when the keyed form composes the router. **A fragment is itself a valid contract**, so a slice lifts out into a process of its own without its controller changing at all — the lifted root declares the controller's own port and hands back what it built: ```ts -export const ordersRouter = HttpRouter(contract.orders)( +export const ordersRouter = api.HttpRouter(contract.orders)( { implementation: ordersController.port }, { sync: ({ implementation }) => implementation }, ); @@ -291,145 +324,218 @@ slices into one router a starting point rather than a trap. ## Authentication A contract marked with [`@btravstack/contract`](/reference/contract)'s -`authenticated` is what turns this on. Nothing here is a switch on the -starter: the marker is a fact about the contract, and both halves of the -package follow it. - -**In the types.** `Implementation` branches on the marker. A -marked **leaf** gets `{ readonly principal: Identity }` in its implementer's +`authenticated(...requirements)` is what turns this on. Nothing here is a +switch on the starter: the marker is a fact about the contract, and both halves +of the package follow it. + +A **requirement** is OpenAPI's own shape — a security scheme's name mapped to +the scopes it must grant. Several requirements on one mark are **ORed**, tried +in declaration order. A marked record is the default for every procedure +beneath it; a procedure's own mark **replaces** that default for itself. +Nearest mark wins. + +**In the types.** `Implementation` branches on the marker. A +marked **leaf** gets `{ readonly principal: Principal, Schemes> }` +in its implementer's injected context, so the handler reads `opts.context.principal` — oRPC's own context channel, not a second handler parameter this package invents and not a -wrapper around `.result()`. A marked **record** pushes its marker onto each -child, so a marked fragment protects every procedure beneath it. An unmarked +wrapper around `.result()`. An unmarked leaf's context is unchanged, which is what makes reading a principal there a compile error. `HasMark` is whether the contract marks anything anywhere in its tree — a yes/no, since the contract names no principal to recover. -**At runtime.** `HttpRouter`'s walk carries the mark down the contract exactly -as the types do, and a marked leaf is built as -`node.use(principalMiddleware(authenticate)).result(fn)` — `.use` before -`.result`, which is the only order oRPC leaves available. The middleware reads -the request off oRPC's initial context, calls the authenticator with its -headers, and either injects `{ context: { principal } }` or terminates the -request. +**At runtime.** `HttpRouter`'s walk carries the effective requirements down the +contract exactly as the types do, and a protected leaf is built as +`node.use(principalMiddleware(requirements, authenticators)).result(fn)` — +`.use` before `.result`, which is the only order oRPC leaves available. The +middleware reads the request off oRPC's initial context and tries the +requirements in order, calling each scheme's authenticator with the request's +headers, until one is satisfied. + +### `Principal` — what a handler actually reads + +| The leaf's requirements name | `context.principal` | +| ---------------------------- | --------------------------------------------- | +| one scheme | that scheme's identity, **bare** | +| several schemes | `{ scheme, identity }`, a discriminated union | +| none (unmarked) | absent — reading it is a compile error | + +The one-scheme case is byte-for-byte what a handler wrote before named schemes +existed, so the common case pays nothing for the feature. The multi-scheme case +is narrowed with a `switch` whose missing arm leaves a path returning nothing, +which the handler's own return type refuses: + +```ts +export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + return OkAsync({ csv: `user,${context.principal.identity.userId}` }); + case "service": + return OkAsync({ csv: `service,${context.principal.identity.appId}` }); + } +}; +``` -### `HttpAuthenticator

()({ name: Dep }, { sync })` / `({ sync })` +### `HttpAuthenticator()({ name: Dep }, { sync })` / `({ sync })` -An ordinary di provider on `AuthenticatorPort`, whose service is -`AuthenticatorService

`: +How **one scheme** is implemented. It hands back a description `defineHttp` +binds to that scheme's port; the scheme's **name** is not stated here, because +it is the key the authenticator sits under in `defineHttp({ authenticators })` +— written once. ```ts -type AuthenticatorService

= ( +type Granted = [Scope] extends [never] + ? P + : { readonly identity: P; readonly scopes: readonly Scope[] }; + +type AuthenticatorService = ( headers: IncomingHttpHeaders, -) => AsyncResult; +) => AsyncResult, Unauthenticated>; ``` **Headers, not the request**: an authenticator has no business reading a body, and the narrower argument is what keeps it testable without a socket. `deps` are di's, so a JWT verifier or a user directory is injected the way any -provider's dependencies are. The type argument is **explicit** rather than +provider's dependencies are, and that need travels with the authenticator into +the graph. Both type arguments are **explicit** rather than inferred from `sync` — inference through a returned function's `AsyncResult` is -where a principal silently widens to `unknown` — though in an application that -has an `src/auth.ts` the argument is already fixed by `httpAuth()` -and the call is `HttpAuthenticator({ name: Dep }, { sync })`. `Unauthenticated` is a -`TaggedError` with an **empty payload**: the starter surfaces no reason, so a -field would be write-only. A rejected caller gets an `UNAUTHORIZED` and oRPC's -default message; an authenticator that wants to record why logs it before -returning. Forwarding a reason would put "no such user" versus "bad signature" -in a 401 body by default. +where a principal silently widens to `unknown`. + +A scheme with **no scope vocabulary** returns the identity bare. One **with** +a vocabulary reports what the credential actually granted, checked against the +declared vocabulary at the authenticator rather than compared as loose strings +at the endpoint: ```ts import { TenantId } from "@btravstack/example-order-domain"; -export const bearerAuthenticator = HttpAuthenticator({ +export const userAuth = HttpAuthenticator()({ sync: () => (headers) => { const header = headers.authorization ?? ""; const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; - const [tenantId, userId] = token.split(":"); + const [tenantId, userId, ...rest] = token.split(":"); + // Rejoined rather than taken as one field: a scope name contains the + // delimiter itself, so `orders:export` cannot survive a plain third field. + const granted = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId: TenantId(tenantId), userId }); + : OkAsync({ + identity: { tenantId: TenantId(tenantId), userId }, + scopes: granted + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + }); + }, +}); + +// A second scheme: an API key, no scopes, no tenant. +export const serviceAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const key = headers["x-api-key"]; + return typeof key === "string" && key !== "" + ? OkAsync({ appId: key }) + : ErrAsync(new Unauthenticated()); }, }); ``` -### `httpAuth()` — what the principal is, server-side +`Unauthenticated` is a `TaggedError` with an **empty payload**: the starter +surfaces no reason, so a field would be write-only. An authenticator that wants +to record why logs it before returning. Forwarding a reason would put "no such +user" versus "bad signature" in a 401 body by default. -**The contract says _whether_ a route is protected; this says _what_ the -principal is.** The contract names no identity type at all, so nothing about -the server's view of a caller reaches a client — and this factory is the only -thing that gives a marked handler a readable `context.principal`. It states the -identity once and hands back the three pieces fixed to it: +### `defineHttp({ authenticators })` — what each scheme resolves to -```ts -import type { TenantId } from "@btravstack/example-order-domain"; +**The contract says _which schemes_ protect a route; this says _what each one +is_.** The contract names no identity type at all, so nothing about the +server's view of a caller reaches a client — and this call is the only thing +that gives a marked handler a readable `context.principal`. Declaring a scheme +and implementing it are the **same act**, so a scheme without an authenticator +is not a state this can reach: +```ts // src/auth.ts — one per application export type Identity = { readonly tenantId: TenantId; readonly userId: string }; +export type ServiceIdentity = { readonly appId: string }; -const identity = httpAuth(); - -export const HttpController: HttpControllerOf = - identity.HttpController; -export const HttpRouter: HttpRouterOf = identity.HttpRouter; -export const HttpAuthenticator: HttpAuthenticatorOf = - identity.HttpAuthenticator; +export const api = defineHttp({ + authenticators: { user: userAuth, service: serviceAuth }, +}); ``` -Every slice imports `HttpController` from there, and its marked handlers see -`Identity` on `context.principal` with no annotation of their own; nothing else -about a controller changes. The `HttpAuthenticator` handed back is already -applied, so it is called `HttpAuthenticator({ name: Dep }, { sync })` — which is also -why the authenticator and the controllers cannot disagree about the identity. +Every slice mints its controller from that one `api`, and its handlers see the +right principal with no annotation of their own; nothing else about a +controller changes. + +::: warning Hold it whole — never destructure it +`const { HttpController } = defineHttp(...)` is **TS2527**: each binding of a +destructured member expands to a type mentioning `@btravstack/contract`'s +inaccessible `unique symbol`, which the file cannot emit. Held whole, the +inferred type collapses to `Http`, which is nameable — which is why the +file above writes **no type annotation at all**. +::: + +`defineHttp()` with no argument is the public-API case: the registry is +`Record`, so a contract that marks anything leaves a scheme port +unmet and the composition is refused. It is deliberately not +`Record` — an index signature would make every scheme's port +look available, and the composition would type-check and then fail at build. It is per application rather than per slice because a handler's parameter types are fixed **where the arrow is written**: a composition root cannot re-type a -`sync` callback that lives in another module, so the identity has to be in -scope where the handler is. The three `…Of` aliases are annotations -rather than ceremony — a controller's port expands to a type carrying the -marker's phantom `unique symbol`, which a consumer's own `.d.ts` cannot name. - -The identity reaches a **marked** node only, and none is invented on an -unmarked one: the contract still decides _whether_. `HttpController` and -`HttpRouter` imported from the package itself are the `Identity = never` case — -a marked fragment reached through them types `principal: never`, so every read -is a compile error. That is the signal to use the factory, not a fallback. - -### Two gates, and why they are two - -When the contract marks anything, `HttpRouter` adds `AuthenticatorPort` to the -router provider's deps record under a **namespaced** key -(`"@btravstack/http/authenticator"`, so it cannot collide with one you wrote), -strips it back out before your own `sync` sees the record, and adds it to the -provider's needs channel. Which makes a marked router with no authenticator -behind it an ordinary unmet need at `start`, not a gate this package invented. -What prints is `start`'s `module` parameter refusing the leftover need — -`Type 'AuthenticatorPort' is not assignable to type 'Env | Scope'`, down to -`Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'` — so the -port is named. (Not di's `UNSATISFIED DEPENDENCIES` arity gate: that one guards +`sync` callback that lives in another module, so the registry has to be in +scope where the handler is. + +### The gate: one dependency per scheme + +For every scheme its contract names anywhere, `HttpRouter` adds that scheme's +port — `` `HttpAuthenticator:${scheme}` `` — to the router provider's deps +record under a **namespaced** key (so it cannot collide with one you wrote), +strips those keys back out before your own `sync` sees the record, and adds +them to the provider's needs channel. A scheme with no authenticator behind it +is therefore an ordinary unmet need at `start`, not a gate this package +invented, and the diagnostic **names the port**: + +``` +Type '"HttpAuthenticator:user"' is not assignable to type '"@di/Scope"' +``` + +(Not di's `UNSATISFIED DEPENDENCIES` arity gate: that one guards `Module.build`/`Module.scoped`, and `start` types the need out on its parameter instead.) -What di cannot see is the **identity**: `AuthenticatorPort`'s service type is -erased to `unknown`, so any authenticator discharges that need. So -`HttpModule` checks the other half — the **router's** identity, inferred from -`router.identity`, against the **authenticator's**. A router minted by -`httpAuth()` refuses an authenticator minted by `httpAuth()`, at the -`HttpModule(...)` call. The direction is `AuthIdentity extends RouterIdentity`: -the authenticator must resolve **at least** what the handlers read, so a -subtype discharges it. A router from the package's own top-level `HttpRouter` -carries no identity and accepts any authenticator, including none — a provider -nothing needs is di's business and not an error to invent. - -A mark with no authenticator behind it still **fails closed**: an internal -`noAuthenticator` refuses every caller, so such a leaf answers `401` rather -than serving unprotected. It is unreachable while the types and the walk -agree, which is exactly why it is there. +There is nothing left for a second gate to check. The registry that types the +handlers and the providers that discharge those ports come from the **same** +`defineHttp` call, so they cannot disagree — which is why the identity +comparison an earlier design performed at `HttpModule` is gone, along with the +`authenticator` option it lived on. + +### `401` and `403` + +Requirements are tried in the order the contract declared them, and the first +a caller satisfies wins. + +| Outcome | Answer | +| --------------------------------------------------------------- | -------------------------------------------- | +| a requirement is satisfied | the handler runs, principal injected | +| no requirement accepted the caller | **`401 UNAUTHORIZED`** | +| a credential was valid but lacked a scope the requirement named | **`403 FORBIDDEN`** | +| an authenticator returned a `Defect` | oRPC's `INTERNAL_SERVER_ERROR`, walk stopped | + +Neither refusal carries a message: oRPC serializes `message` to the client, and +a refusal has nothing a caller is entitled to. A requirement naming scopes is +**not** satisfied by a credential reporting none — a scheme declared without a +vocabulary answers bare, and admitting it there would admit the caller +outright. An empty scope list still passes trivially. A `Defect` is a bug in +the authenticator rather than a refusal, so it short-circuits: falling through +would let a broken verifier silently promote every caller to the next scheme. ### The marker is legibility, not enforcement @@ -460,7 +566,7 @@ hand. `HttpOptions`: The module **provides** `HttpRuntime` and `HttpConfig`, exports both, and **needs** `Env` (the kernel discharges it) and the starter's router port -(`HttpRouterPort`, the port `HttpRouter(contract)(deps, arm)` provides on) — +(`HttpRouterPort`, the port `api.HttpRouter(contract)(deps, arm)` provides on) — the runtime provider depends on the router through di, which is why a composition that imports `http()` without providing the router carries an unmet need `start` refuses (di's gate, not the kernel's). The router is not an @@ -540,17 +646,18 @@ that is the only way to learn the port that was actually bound. ## What it decides about a request -| Request | Answer | Decided by | -| ----------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------- | -| a procedure under `prefix` | the procedure's output, or the `ORPCError` its `Result` was mapped to | oRPC, the router | -| a defect thrown inside a procedure | oRPC's own `INTERNAL_SERVER_ERROR` collapse | oRPC | -| a marked procedure whose authenticator returned `Unauthenticated` | `401 UNAUTHORIZED`, the handler never entered | this package | -| a marked procedure whose authenticator defected | oRPC's `INTERNAL_SERVER_ERROR` collapse — a bug, not a rejected caller | oRPC | -| a path under `prefix` naming no procedure | `404 {"error":"NotFound"}` — oRPC declines it unwritten | this package | -| any path outside `prefix` | `404 {"error":"NotFound"}` — likewise | this package | -| the listener resolved without writing | `404 {"error":"NotFound"}` | this package | -| the listener failed before headers were out | `500 {"error":"InternalError"}` | this package | -| a failure with headers already on the wire | the socket is destroyed — a reset, not a hang | this package | +| Request | Answer | Decided by | +| ------------------------------------------------------------ | ---------------------------------------------------------------------- | ---------------- | +| a procedure under `prefix` | the procedure's output, or the `ORPCError` its `Result` was mapped to | oRPC, the router | +| a defect thrown inside a procedure | oRPC's own `INTERNAL_SERVER_ERROR` collapse | oRPC | +| a protected procedure no requirement accepted the caller for | `401 UNAUTHORIZED`, the handler never entered | this package | +| a protected procedure whose caller lacked a required scope | `403 FORBIDDEN`, the handler never entered | this package | +| a protected procedure whose authenticator defected | oRPC's `INTERNAL_SERVER_ERROR` collapse — a bug, not a rejected caller | oRPC | +| a path under `prefix` naming no procedure | `404 {"error":"NotFound"}` — oRPC declines it unwritten | this package | +| any path outside `prefix` | `404 {"error":"NotFound"}` — likewise | this package | +| the listener resolved without writing | `404 {"error":"NotFound"}` | this package | +| the listener failed before headers were out | `500 {"error":"InternalError"}` | this package | +| a failure with headers already on the wire | the socket is destroyed — a reset, not a hang | this package | The last three are the package's own fallbacks, guaranteeing that every request produces exactly one completed response. The two `500` shapes are @@ -615,10 +722,20 @@ read as unmarked here. Node `>=20`. and no listener port to provide. - **A middleware slot for application logic.** oRPC's own, inside the router's procedures. `principalMiddleware` is the one per-request hook the - package installs, only on a marked leaf. [`plugins`](#plugins) is an honest + package installs, only on a leaf whose requirements say so. + [`plugins`](#plugins) is an honest escape hatch rather than a keyhole — a plugin can reach the handler's interceptors — but the ordinary path is configuration visible at the composition root, and an application middleware acting on the handler's `Result` is what this package refuses. - **`Result` → HTTP status.** The router's `.result()` triage owns it. +- **Resource-dependent authorization.** A **scope** is checked here, because it + is a property of the credential and answerable before dispatch. "Is this + caller the order's owner?" is not, and stays in the handler. +- **AND within one requirement.** A requirement names one scheme; requiring two + credentials at once would put a record rather than an identity on the + handler. A composite scheme models it where it is genuinely needed. +- **OpenAPI document metadata.** A scheme's own definition — `type: http`, + `bearerFormat`, an OAuth flow — belongs beside the contract, not in + `defineHttp`. - **HTTPS, HTTP/2.** `node:http` only; terminate TLS at the ingress. diff --git a/examples/order-api-contract/src/contract.ts b/examples/order-api-contract/src/contract.ts index dd1c1bb0..48bad3d6 100644 --- a/examples/order-api-contract/src/contract.ts +++ b/examples/order-api-contract/src/contract.ts @@ -115,10 +115,11 @@ const customersContract = { * how the marker exercises a per-procedure override, a scope and a second * scheme all at once. * - * **The contract says WHETHER a route is protected, and nothing about who the + * **The contract says WHICH SCHEMES protect a route, and nothing about who the * caller is.** No principal type is named here, so nothing about what this * deployment knows about a caller — a user id, roles, an org tier — reaches a - * client, and enriching it is never a contract change. What the principal - * actually is, is `examples/order-api`'s `httpAuth()` to say. + * client, and enriching it is never a contract change. What each scheme + * resolves to is `examples/order-api`'s `defineHttp({ authenticators })` to + * say. */ export const contract = { orders: ordersContract, customers: customersContract }; diff --git a/examples/order-api/README.md b/examples/order-api/README.md index f5501010..4ebdb0b5 100644 --- a/examples/order-api/README.md +++ b/examples/order-api/README.md @@ -9,15 +9,14 @@ socket, and the router itself is a di-provided service. The contract lives in its own package, because a client needs it and needs none of this. ``` -src/auth.ts Identity, and the HttpController / HttpRouter / HttpAuthenticator httpAuth() mints from it -src/authenticator.ts bearerAuthenticator — headers in, Identity out, on the starter's port -src/slices/orders/controller.ts HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync }) — where the orders slice's own domain error becomes an ORPCError +src/auth.ts the two schemes (user, service), their authenticators, and the one api = defineHttp({ authenticators }) call +src/slices/orders/controller.ts api.HttpController("OrdersController", contract.orders)({ place: PlaceOrder, find: FindOrder, logger: Logger }, { sync }) — where the orders slice's own domain error becomes an ORPCError src/slices/orders/module.ts OrdersSlice — provides the controller, exports only it -src/slices/customers/controller.ts HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync }) — same shape, for the customers slice's own domain error +src/slices/customers/controller.ts api.HttpController("CustomersController", contract.customers)({ find: FindCustomer }, { sync }) — same shape, for the customers slice's own domain error src/slices/customers/module.ts CustomersSlice — same shape as OrdersSlice src/request-scope.ts RequestModule — passed as StartOptions.unit; the kernel forks it per request src/client.ts an AsyncResult client for the same contract -src/module.ts OrderApi — the composition root: orderRouter = HttpRouter(contract)({ orders, customers }), then HttpModule("OrderApi")({ +src/module.ts OrderApi — the composition root: orderRouter = api.HttpRouter(contract)({ orders, customers }), then HttpModule("OrderApi")({ needs: [Env], router: orderRouter, … }) src/main.ts the process: runMain(OrderApi, { unit: RequestModule, onEvent: kernelEvents(…) }) src/test-fixtures.ts boot / serve / clientFor / gate / recording, as Vitest fixtures — boot from @btravstack/testing @@ -49,7 +48,7 @@ root, and [`order-amqp-worker`](../order-amqp-worker) by never folding it at a consumer at all — its writes broadcast facts instead. Each procedure is a plain `Result`-returning function — `@unthrown/orpc`'s -`.result(...)` handler, which `HttpController` attaches for you inside each +`.result(...)` handler, which `api.HttpController` attaches for you inside each slice's controller — and that is what performs the elimination; the `mapErrCases` inside it is the triage point — the boundary where the application's vocabulary stops: @@ -89,9 +88,9 @@ Binding the socket, one unit per request, the drain that retires a busy keep-alive connection, the trace-id policy, oRPC's node adapter mounted under `/rpc` all live in [`@btravstack/http`](../../packages/http) — see its README for the guarantee it makes and the one way it answers HTTP. -What this example writes is two slices, each an `HttpController(name, fragment)({ name: Dep }, { sync })` +What this example writes is two slices, each an `api.HttpController(name, fragment)({ name: Dep }, { sync })` over its own contract fragment, and a root router composed by the **keyed** -`HttpRouter(contract)({ orders: ordersController, customers: +`api.HttpRouter(contract)({ orders: ordersController, customers: customersController })` — contract-first, exact (a missing slice, a stray key or a controller under the wrong key are all compile errors at that call) — each procedure a plain `Result`-returning function typed by the fragment, @@ -101,46 +100,57 @@ root that is a `Module(...)` which also knows about it: ```ts export const OrderApi = HttpModule("OrderApi")({ router: orderRouter, - authenticator: bearerAuthenticator, imports: [OrdersSlice, CustomersSlice, observability()], exports: [Logger], }); ``` -`authenticator` is owed because the contract marks its `orders` fragment -`authenticated`: the router provider carries `AuthenticatorPort` as a need, so -omitting the line is an unmet dependency `start` refuses, and supplying one -minted on a different identity is a compile error at this call. It sits at -the root rather than in a slice — who a caller is is one answer per process — -and it is an ordinary provider, so swapping this example's -`Bearer :` stand-in for JWT verification changes nothing -else. +There is **no authenticator to list**. The contract marks its `orders` +fragment, so the router declares one dependency per scheme that fragment names +— `HttpAuthenticator:user` and `HttpAuthenticator:service` — and carries the +providers that discharge them, which `HttpModule` puts in `provides` itself. A +scheme with nobody behind it is an unmet dependency `start` refuses, naming the +port. Both are ordinary providers, so swapping this example's +`Bearer ::` stand-in for JWT verification changes +nothing else — and an authenticator that declared a `JwtVerifier` would carry +that need into the graph, refused at this very call if nothing satisfied it. -Where the identity is **stated** is `src/auth.ts`, the whole of it: +Where the schemes are **declared** is `src/auth.ts`: ```ts export type Identity = { readonly tenantId: TenantId; readonly userId: string }; +export type ServiceIdentity = { readonly appId: string }; -const identity = httpAuth(); +export const userAuth = HttpAuthenticator()({ + sync: () => (headers) => …, +}); + +export const serviceAuth = HttpAuthenticator()({ + sync: () => (headers) => …, +}); -export const HttpController: HttpControllerOf = - identity.HttpController; -export const HttpRouter: HttpRouterOf = identity.HttpRouter; -export const HttpAuthenticator: HttpAuthenticatorOf = - identity.HttpAuthenticator; +export const api = defineHttp({ + authenticators: { user: userAuth, service: serviceAuth }, +}); ``` -**The contract says whether a route is protected; `httpAuth()` says -what the principal is.** The contract names no identity type at all, so nothing +**The contract says which schemes protect a route and which scopes each must +grant; `defineHttp({ authenticators })` says what each one resolves to.** The +contract names no identity type at all, so nothing here reaches a client and enriching it — roles, an org tier, an internal id — -is never a contract change. Both slices import `HttpController` from there -instead of from `@btravstack/http`, and the orders controller reads +is never a contract change. Both slices mint their controller from that one +`api`, and the orders controller reads `context.principal.userId` to log who asked for a placement. Who placed an order is a transport-boundary fact, so it is logged there rather than pushed through a use case that has no business with it. -It is also the only way to read a principal at all: a marked fragment reached -through `@btravstack/http`'s own top-level `HttpController` types +`api` is held as **one binding and never destructured**: each destructured +member expands to a type mentioning the marker's inaccessible `unique symbol` +(TS2527), while held whole it collapses to the nameable `Http` — which is +why this file carries no type annotation at all. + +It is also the only way to read a principal: a marked fragment reached +through any other `defineHttp` call types `principal: never`, so every read of it is a compile error. And it is written once per application rather than per slice — a handler's parameter types are fixed where the arrow is written, so the composition root cannot re-type a @@ -174,7 +184,7 @@ graph builds one database (measured on this composition — a naive walk visits 16 provider slots and di keeps 15, where the same walk over the pre-split modules visited 22 for the same 15). `exports` takes the provider itself, not `ordersController.port`: -`HttpController` minted that port, so there is no class to spell back off it. +`api.HttpController` minted that port, so there is no class to spell back off it. `HttpModule` is sugar over the same primitives: it imports the starter (`http()` — the whole surface), provides the @@ -217,7 +227,7 @@ and no handler code manages any of it. ```ts const client = createOrderApiClient("http://127.0.0.1:3000", "/rpc", { - authorization: `Bearer ${tenantId}:${userId}`, + authorization: `Bearer ${tenantId}:${userId}:orders:export`, }); const named = (await client.orders.place({ id, quantity })).match({ @@ -236,7 +246,12 @@ const named = (await client.orders.place({ id, quantity })).match({ The header is not optional here: `orders` is the marked half of the contract, so the same call without it is refused before any procedure runs — as an `UNAUTHORIZED` the contract does not declare, which means it is not inferable -and lands in `defect` rather than `errCases`. `customers` is unmarked and +and lands in `defect` rather than `errCases`. A caller whose token is valid but +lacks a scope the procedure named gets a `FORBIDDEN` instead, on the same +channel — which is why the token above carries `orders:export`, the scope +`orders.export` requires of a `user`; a caller presenting the `service` scheme +(`x-api-key`) reaches that one procedure with no scope at all. `customers` is +unmarked and answers either way — and names its tenant on the input, which `orders` does not: the tenant a marked procedure serves is the token's, so there is nothing for the caller to say about it. @@ -248,7 +263,7 @@ the server's `mapErrCases`. ## Running it ```bash -pnpm --filter @btravstack/example-order-api test # 17 api specs +pnpm --filter @btravstack/example-order-api test # 26 api specs ``` The specs run against a real HTTP server and a real oRPC client — genuine JSON @@ -325,7 +340,7 @@ const customersContract = { .errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }), }; -const ordersContract = { +const ordersContract = authenticated({ user: [] })({ place: oc .input(type<{ readonly id: string; readonly quantity: number }>()) .output(type()) @@ -335,12 +350,12 @@ const ordersContract = { CONFLICT: { data: type() }, }), … -}; +}); ``` The `customers` controller hands `input.tenantId` straight to the use case, which hands it to the repository, which puts it in the `WHERE`. The `orders` -fragment is marked `authenticated`, so its controller takes the tenant from +fragment is marked `authenticated({ user: [] })`, so its controller takes the tenant from `context.principal.tenantId` — this deployment's `Identity`, which the contract never names — and its inputs name none: a required field the handler ignores is a field that lies, and a caller that could name a tenant it diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts index c9696896..d9930433 100644 --- a/examples/order-api/src/docs-examples.test-d.ts +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -58,7 +58,10 @@ const customerViewOf = (customer: Customer): CustomerView => ({ // application makes: reached through anything else, a marked fragment types // `principal: never` and every read below is a compile error. That // substitution is half of what these pages were getting wrong, so it is -// pinned by the import rather than asserted. +// pinned by the import rather than asserted. `place` and `find` read the +// identity bare — one scheme — while `export` narrows a tagged union, which is +// the contrast every page draws and the reason its bodies match the real +// controller's byte for byte. // --------------------------------------------------------------------------- const ordersController = api.HttpController("DocsOrdersController", contract.orders)( @@ -95,9 +98,15 @@ const ordersController = api.HttpController("DocsOrdersController", contract.ord export: ({ context }) => { switch (context.principal.scheme) { case "user": - return OkAsync({ csv: context.principal.identity.userId }); + logger.info("order export requested", { + userId: context.principal.identity.userId, + }); + return OkAsync({ csv: `user,${context.principal.identity.userId}` }); case "service": - return OkAsync({ csv: context.principal.identity.appId }); + logger.info("order export requested", { + appId: context.principal.identity.appId, + }); + return OkAsync({ csv: `service,${context.principal.identity.appId}` }); } }, }), @@ -216,9 +225,9 @@ const depsOrdersRouter = api.HttpRouter(contract.orders)( export: ({ context }) => { switch (context.principal.scheme) { case "user": - return OkAsync({ csv: context.principal.identity.userId }); + return OkAsync({ csv: `user,${context.principal.identity.userId}` }); case "service": - return OkAsync({ csv: context.principal.identity.appId }); + return OkAsync({ csv: `service,${context.principal.identity.appId}` }); } }, }), diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index 03249f4c..a1386cd5 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -54,19 +54,21 @@ extends Requirements } ? R : never`. What this exact node's mark requires, at nothing satisfiable". Ancestry (a marked parent implying a marked child) is the caller's to carry; the package tracks nodes, not trees. -## The contract says whether; the application says what - -**The contract names no identity type at all.** A marked node says a caller -must be authenticated and stops there; `@btravstack/http`'s -`httpAuth()` is what says what a principal is, server-side, and a -handler minted from it sees that type. So nothing about the server's own view -of a caller — roles, an org tier, an internal id — reaches a client, and -enriching it is never a contract change and never a client-visible field. - -There is therefore nothing here to keep minimal and nothing here to leak. The -gate that used to compare a contract's principal against an authenticator's -now compares the **router's** identity against the authenticator's, inside -`@btravstack/http`, where both come from the same `httpAuth` call. +## The contract says which schemes; the application says what each one is + +**The contract names no identity type at all.** A marked node names the +schemes a caller may present and the scopes each must grant, and stops there; +`@btravstack/http`'s `defineHttp({ authenticators })` is what says what each +scheme resolves to, server-side, and a handler minted from that call sees +those types. So nothing about the server's own view of a caller — roles, an +org tier, an internal id — reaches a client, and enriching it is never a +contract change and never a client-visible field. + +There is therefore nothing here to keep minimal and nothing here to leak. +There is also no identity comparison left to make: declaring a scheme and +implementing it are the same act in `defineHttp`, so a scheme the contract +names with no authenticator behind it is di's own unmet need on +`HttpAuthenticator:`, not a gate either package writes. ## Three load-bearing properties @@ -88,8 +90,8 @@ Identity is exactly why a consumer takes this package as a **peer** rather than an ordinary dependency — `@btravstack/http` and `examples/order-api-contract` both do. Two copies in one install would each hold their own registry, a contract marked by one would read unmarked to the -other, `HttpRouter` would declare no authenticator need and the protected -route would be served **open**. So the registry is copy-proof: it hangs off +other, `HttpRouter` would declare no scheme dependency at all and the +protected route would be served **open**. So the registry is copy-proof: it hangs off `globalThis` under `Symbol.for("@btravstack/contract/requirements")`, and every copy shares the one `WeakMap`. The key changed from the earlier `.../marked` — it named a `WeakSet` of marked nodes; naming it `requirements` @@ -110,9 +112,11 @@ protected while the registry stays empty: `HasMark` answers `true` and `routerOf` installs no middleware, and the leaf serves unauthenticated. It takes a double cast to reach, which is the whole of the protection. Exporting the symbol would remove even that, which is why the TS2527 wart a consumer -hits when re-exporting an inferred controller type is worth paying — the -aliases `@btravstack/http` exports (`HttpControllerOf` and friends) -are how it is paid. +hits when re-exporting an inferred controller type is worth paying — +`@btravstack/http` pays it by handing back **one** nameable object, +`Http`, from `defineHttp`: held whole rather than destructured, the +inferred type never mentions this symbol and an application writes no +annotation at all. **Applied after a builder chain is finished, never inside one.** `authenticated` wraps a finished contract node — the last call in a chain, or a whole record diff --git a/packages/contract/README.md b/packages/contract/README.md index 991f1d6b..8f77c4c0 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -1,10 +1,10 @@ # @btravstack/contract > Contract-level markers shared by a client and the server that implements -> it: declare **whether** a procedure requires an authenticated caller, and -> nothing about who that caller is. Zero dependencies, zero peers — a client -> can take a contract without the server, and any transport's contract can use -> the same marker. +> it: declare **which security schemes** a procedure accepts and which scopes +> each must grant, and nothing about who the caller is. Zero dependencies, +> zero peers — a client can take a contract without the server, and any +> transport's contract can use the same marker. ```sh pnpm add @btravstack/contract @@ -18,29 +18,42 @@ Node `>=20`. Not yet published: this repository has not cut a release yet. import { authenticated } from "@btravstack/contract"; export const contract = { - orders: authenticated({ place, find }), - customers: { find, quote: authenticated(oc.input(…).output(…)) }, + orders: authenticated({ user: [] })({ + place, + find, + // Overrides the group default for itself: a `user` token needs the scope, + // or a `service` token needs nothing. + export: authenticated({ user: ["orders:export"] }, { service: [] })(oc.output(…)), + }), + customers: { find }, }; ``` -A marked record protects every procedure beneath it; a marked procedure -protects itself. Apply `authenticated` after a builder chain is finished, +`authenticated` is **curried**: it takes one or more OpenAPI security +requirements — a scheme name mapped to the scopes it must grant — and hands +back the function that marks a node. Several requirements are **ORed**, tried +in the order given. A marked record is the default for every procedure beneath +it; a marked procedure **replaces** that default for itself. Nearest mark +wins, which is OpenAPI's own rule. Apply it after a builder chain is finished, never inside one. -**The contract says whether a route is protected; the application's -`httpAuth()` says what the principal is.** No identity type is named -here, so nothing about the server's own view of a caller reaches a client, and -enriching it is never a contract change. +**The contract says which schemes protect a route; the application's +`defineHttp({ authenticators })` says what each one resolves to.** No identity +type is named here, so nothing about the server's own view of a caller reaches +a client, and enriching it is never a contract change. -The marker is **identity-based** — a `WeakSet`, no property on the node — which +The marker is **identity-based** — a `WeakMap`, no property on the node — which is why a package shipping a marked contract takes this one as a **peer** dependency rather than an ordinary one: two copies would mean two registries, and a contract marked by one reading unmarked to the other is a protected route served open. The registry is copy-proof against that anyway (it hangs off -`globalThis` under `Symbol.for("@btravstack/contract/marked")`, so every copy -shares one `WeakSet`), so a stray second copy costs a compile error on the +`globalThis` under `Symbol.for("@btravstack/contract/requirements")`, so every +copy shares one `WeakMap`), so a stray second copy costs a compile error on the mismatched marker symbol, not an open route. +`isAuthenticated(node)` reads back the `Requirements` a node was marked with, +or `undefined` when nobody marked it. + ## License [MIT](./LICENSE) © Benoit TRAVERS diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index aa072fb5..b0e39a54 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -73,3 +73,8 @@ export const authenticated = * the tree and passes the nearest mark down. */ export const isAuthenticated = (node: object): Requirements | undefined => marked.get(node); + +// ponytail: opt-in by construction — an unmarked node is public, and forgetting +// the marker fails nothing. Deny-by-default is three lines away: mark the root +// with the deployment's default requirements and add `public(node)` that +// deletes it from the map. diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 958f4d81..011ab1da 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -8,7 +8,7 @@ the same commit, and with `README.md` — the package ships no ## Public surface -- **`HttpModule(name)({ router, authenticator?, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports?, needs? })`** +- **`HttpModule(name)({ router, prefix?, port?, hostname?, plugins?, securityHeaders?, imports?, provides?, exports?, needs? })`** (`http-module.ts`) — THE way an application declares an HTTP deployment: `Module(name)({...})` plus the router **provider**. It appends `http({ prefix?, port?, hostname? })` to `imports`, prepends the provider to @@ -22,32 +22,28 @@ the same commit, and with `README.md` — the package ships no declaration emit keeps such an alias unreduced and cannot name imported modules' internal ports — TS2883, measured.) `router` is `Provider` — a provider on the - starter's own router port, which is what `HttpRouter(contract)(deps, arm)` + starter's own router port, which is what `api.HttpRouter(contract)(deps, arm)` returns — so a provider of anything else fails at the call, and there is no port to read off it: the starter needs `HttpRouterPort`, and the sugar's job is to provide it. Covered by the package's own `rpc` fixture, which composes `RpcApp` through it. Options `port`/`hostname` pin as for `http()`. - **`authenticator`** is what a marked contract needs — - `HttpAuthenticator

()({ name: Dep }, { sync })` — and it is a plain optional - field: present, it joins `provides`, which is all discharging di's need - takes. `Auth` is inferred from it and `Provides` spreads - `[Auth] extends [undefined] ? [] : [NonNullable]`, so an **omitted** - authenticator contributes no element and a marked router's need survives - to `start`, which refuses it — no gate of this package's, and **not** di's - `UNSATISFIED DEPENDENCIES` arity gate either (that one guards - `Module.build`/`Module.scoped`): `start`'s `module` parameter takes only - `Scope | Env` outstanding, so the leftover need fails to assign and the - diagnostic names the port, ending on - `Type '"HttpAuthenticator"' is not assignable to type '"@di/Scope"'`. - What di cannot see is the **principal**: `AuthenticatorPort`'s service type - is erased to `unknown`, so any authenticator discharges the need whatever it - resolves. That half is checked here instead — `Principal` is inferred from - `router`'s own `readonly principal`, and `Auth`'s constraint requires - `readonly principal: [Principal] extends [never] ? unknown : Principal` — so - a mismatch fails at the `HttpModule(...)` call, while an **unmarked** - router (`Principal` is `never`) accepts any authenticator, since a provider - nothing needs is di's business and not an error to invent. Both are pinned by - `auth.test-d.ts`, on the two different lines they fire at. + **There is no `authenticator` option.** The router provider carries + `readonly authenticators: readonly Auth[]` — the per-scheme providers + `defineHttp` bound — and the sugar spreads them into `provides` itself, so + an application never lists one and cannot list the wrong one. `Auth` is + inferred from the router, and `Provides` is + `readonly (Provider | Auth | P[number])[]` — a + union-element **array**, not a tuple, and that is forced: `Auth` is one type + per scheme, so with two schemes it arrives as a union and a tuple takes one + rest element, not two. Nothing downstream wants the arity — di reads + `P[number]` throughout — and putting the authenticators in `provides` is what + carries **their own needs** (a `JwtVerifier`, a key set) into `NeedsGate`, so + a root that satisfies none is refused at THIS call exactly as a hand-listed + provider would be (`auth.test-d.ts`'s arms 11 and 12). A scheme the contract + names that the registry has no authenticator for is di's own unmet need on + `HttpAuthenticator:`, not a gate this package writes — and the + identity comparison the old `authenticator` option performed is gone with it, + since declaring a scheme and implementing it are now the same act. - **`HttpRouterPort`** (`orpc.ts`, exported from the file for the package's own tests, **not** from `index.ts`) — the router's port, one id, the starter's own: `Port("HttpRouter")` cast to di's `PortClassOf<"HttpRouter", @@ -58,10 +54,11 @@ Router>>`, with the matching `PortInstance` alias. A defect at build, which is correct. The service type is contract-agnostic (a context-free oRPC router), so this is one concrete port — unlike the temporal and amqp starters', which are typed per contract. -- **`HttpRouter(contract)(deps, { sync })`** (`orpc.ts`) — contract-first - router provider. `Implementation` is the record type: recursing the +- **`api.HttpRouter(contract)(deps, { sync })`** (`orpc.ts`, minted by + `defineHttp`) — contract-first + router provider. `Implementation` is the record type: recursing the contract's shape, each `ProcedureContract` becomes - `Parameters, + `Parameters, I, O, E>["result"]>[0]` — the `.result()` handler `@unthrown/orpc` gives that procedure's implementer (`import "@unthrown/orpc/extensions/result"` here; `@orpc/contract` and `@unthrown/orpc` are peers for it) — so `sync`'s return @@ -82,25 +79,27 @@ PortInstance<…> }`) rather than the class's own type because a class (TS4023, measured on `examples/order-api`) — which is also why `HttpRouterPort` itself is a cast `Port("HttpRouter")` and not a `class`. `provider.port` stays on the result for a hand-declared provider or a type - test. Only the `sync` arm: a router is built, not + test, and `provider.authenticators` carries the per-scheme providers + `defineHttp` bound — on the router because the router is what needs them. + Only the `sync` arm: a router is built, not acquired. `HttpModule({ router: orderRouter })`, or `http()` next to `provides: [orderRouter]`, take it from there. Covered by the `rpc` fixture's `greetingRouter` (a bare-procedure `oc.router`, one nested) and the stray-key guard by `strayRouter` (the same implementation with an undeclared key, cast past the types). -- **`HttpRouter(contract)(controllers)` — the keyed form** (`orpc.ts`, a - second overload of `build`) — for `contract: Record`, +- **`api.HttpRouter(contract)(controllers)` — the keyed form** (`orpc.ts`, a + third overload of `build`) — for `contract: Record`, a record keyed by the contract's own top-level keys, one `HttpController` per key, instead of `(deps, { sync })`. `M` is constrained `{ readonly [K in Exclude]: ControllerFor>, Identity> }`, and the `controllers` +RequirementsOf>, Schemes> }`, and the `controllers` **parameter** is typed ``M & { readonly [K in Exclude>]: `UNDECLARED KEY — the contract declares no fragment under ${K & string}` }`` — the same `Exclude` and the same `Inherit` the - deps arm's `Implementation` carries, so a **root-marked** contract + deps arm's `Implementation` carries, so a **root-marked** contract composes here at all (the phantom key is not a controller to supply) and each - fragment inherits the root's mark (a controller under it types - `context.principal`). Both were missing until `auth.test-d.ts`'s eleventh arm + fragment inherits the root's requirements (a controller under it types + `context.principal`). Both were missing until `auth.test-d.ts`'s tenth arm went in; the marked fixtures in `controller.test-d.ts` mark a **key**, which is why neither showed there. The exactness intersection is on the parameter, not on `M`: a key `M` has that `C` does not declare types as a sentence **naming that key** — @@ -142,36 +141,42 @@ ${K & string}` }`` — the same `Exclude` and the same `Inherit` the rejected, a procedure a controller's fragment does not declare rejected inside the controller, and — the fifth, marked "do not break" — a slice lifting out of the composed router **with its controller unchanged**: - `HttpRouter(contract.orders)({ implementation: orders.port }, { sync: ({ implementation }) => implementation })` + `api.HttpRouter(contract.orders)({ implementation: orders.port }, { sync: ({ implementation }) => implementation })` compiles, so the lifted root declares the very controller the modulith composed and hands back what it built. The gate names the controller deliberately — a fresh `sync` literal over the fragment would pin only that a fragment is a valid contract, the weaker half, which says nothing about the controller surviving the lift. All five are pinned **twice**: once against a plain contract and once against one whose `orders` fragment is - `authenticated(...)`, so the marker's phantom key cannot quietly break any of + `authenticated({ user: [] })(...)`, so the marker's phantom key cannot quietly break any of them — the fifth least of all. The same block pins the one direction that must be refused: a controller whose handler reads `opts.context.principal` cannot be mounted under an **unmarked** contract key, where nothing would inject one. The reverse is accepted and correctly so — an unmarked controller under a marked key is a handler that ignores the principal, which is contravariantly fine. + Three further arms pin what the requirements themselves do: a procedure + under a marked record inherits that record's requirement, a procedure with + its own mark **replaces** it rather than adding to it, and the router's needs + channel carries one `HttpAuthenticator:` port per scheme the contract + names anywhere — two schemes, one scheme, and none at all, each asserted in + **both** directions, since a one-way check passes on a collapsed `never`. Covered at runtime by the `rpcSliced` fixture, composing `helloController` and `echoesController` over `slicedContract`'s two fragments. -- **`HttpController(name, fragment)({ name: Dep }, { sync })`, or `({ sync })` - with no deps** (`controller.ts`) — +- **`api.HttpController(name, fragment)({ name: Dep }, { sync })`, or `({ sync })` + with no deps** (`controller.ts`, minted by `defineHttp`) — one slice of a contract, as a provider on a port minted for it. The first call fixes `fragment`'s type — read for its type only, so a procedure the fragment does not declare or a handler whose input or output has drifted is a compile error inside the controller rather than at the root — and mints - `class extends Port(name)> {}`; the second is di's + `class extends Port(name)> {}`; the second is di's `Provider(port)({ name: Dep }, { sync })`, unchanged — **including its no-deps arm**, which this helper mirrors by arity for the same reason di has one: a controller that calls no use case is the common shape here, not an edge case, and `({}, { sync })` is what it would otherwise spell. Returns - `Provider>, never, -InstanceType> & { readonly port: PortClassOf> }` — + `Provider>, never, +InstanceType> & { readonly port: PortClassOf> }` — the same `PortInstance`/`PortClassOf` spelling `HttpRouter` uses and for the same reason (TS4023 on a class expression's own type). The controller does no oRPC work: it is a plain record; `HttpRouter`'s `routerOf` walk is what @@ -181,16 +186,22 @@ InstanceType> & { readonly port: PortClassOf by `controller.spec.ts`'s `controllers` fixture (the port and declared deps a controller carries) and by every gate in `controller.test-d.ts` above. - **`@btravstack/contract`'s marker, in the types and at runtime.** - `authenticated(node)` brands a contract node `Authenticated` — an - intersection with a `unique symbol` key set to `true`, no runtime property - and **no principal type** — and `Implementation` branches on - `IsMarked`. A marked **leaf** gets `{ readonly principal: Identity }` in + `authenticated(...requirements)(node)` brands a contract node + `Authenticated` — an + intersection with a `unique symbol` key holding the exact `Requirements`, no + runtime property + and **no principal type** — and `Implementation` branches on + `IsMarked`. A marked **leaf** gets + `{ readonly principal: Principal, Schemes> }` in `ProcedureImplementer`'s **second** type parameter (`TInjectedContext`), so the principal arrives on `opts.context.principal`: **oRPC's own context channel**, not a second handler parameter this package invents and not a - wrapper around `.result()`. A marked **record** pushes its marker onto each - child (`Inherit`), so a marked fragment protects every procedure - beneath it, and the record arm walks `Exclude` so the + wrapper around `.result()`. A marked **record** pushes its requirements onto + each child that carries none (`Inherit`), so a marked fragment protects + every procedure beneath it while a procedure's own mark **replaces** that + default for itself — **nearest mark wins**, which is OpenAPI's own rule and + what `Effective` spells. The record arm walks + `Exclude` so the phantom key never becomes a procedure key. An unmarked leaf keeps today's spelling, `object`, exactly — which is what makes the negative gate meaningful, since `DefaultInitialContext` is an empty interface rather than @@ -200,112 +211,182 @@ InstanceType> & { readonly port: PortClassOf `auth.test-d.ts` because a `boolean` result would satisfy either. Pinned by `auth.test-d.ts`, mutation-checked. What makes the type true at runtime is `principalMiddleware`, below. -- **`HttpAuthenticator

()({ name: Dep }, { sync })` — or `({ sync })`, the +- **`Principal` and `SchemesOf`** (`principal.ts`) — what a + leaf's handler actually reads, from the scheme NAMES its effective + requirements union to. **One scheme is the identity bare**, byte-for-byte + what applications wrote before this feature, so the common case pays nothing + for it; **several schemes are a discriminated union**, + `{ scheme, identity }` per arm, narrowed with an exhaustive `switch` whose + missing arm is a compile error; **no scheme is `never`**, so a public leaf's + `principal` cannot be read at all. `SchemesOf` maps over the tuple and + then indexes — `{ [I in keyof R]: keyof R[I] & string }[number]` — and is + **not** `keyof R[number]`, which is the INTERSECTION of each requirement's + keys and collapses to `never` the moment two requirements name different + schemes: exactly the multi-scheme case, and it failed silently (measured). + `IsUnion` is the standard distribute-then-compare-back test; do not + "simplify" it to `T extends U`. All seven arms are pinned by + `principal.test-d.ts` — the last of them asserting `SchemesOf` in **both** + directions, since a one-way assignment out of a collapsed `never` passes and + is how the first cut of that test missed a broken `SchemesOf` entirely. +- **`HttpAuthenticator()({ name: Dep }, { sync })` — or `({ sync })`, the common shape, since an authenticator reading only headers declares no - dependencies — plus `AuthenticatorPort`, - `Unauthenticated`, `AuthenticatorService

`** (`auth.ts`) — what an - application provides so a marked procedure can name its caller. - `AuthenticatorService

` is - `(headers: IncomingHttpHeaders) => AsyncResult` — + dependencies — plus `authenticatorPort(scheme)`, + `Unauthenticated`, `Granted`, `AuthenticatorService`** + (`auth.ts`) — how one **security scheme** is implemented. + `AuthenticatorService` is + `(headers: IncomingHttpHeaders) => AsyncResult, Unauthenticated>` — **headers, not the request**: an authenticator has no business reading a body, and the narrower argument is what keeps it testable without a socket. - `AuthenticatorPort` is `Port("HttpAuthenticator")` cast to - `PortClassOf<"HttpAuthenticator", AuthenticatorService>`, the same - spelling and for the same reason as `HttpRouterPort`; its service type is - **erased to `unknown`** because the principal's type is carried by the - provider instead — `HttpAuthenticator

()` returns - `Provider & { readonly principal: P }`. The - type argument is explicit rather than inferred from `sync`: inference - through a returned function's `AsyncResult` is exactly where a `Principal` - silently widens to `unknown`. `Unauthenticated` is a `TaggedError` with an + `Granted` is `P` when `Scope` is `never` — a scheme with no scope + vocabulary returns the identity bare, byte-for-byte what applications wrote + before — and `{ identity: P; scopes: readonly Scope[] }` when it has one, so + the granted list is checked against the declared vocabulary at the + authenticator rather than compared as loose strings at the endpoint. + `authenticatorPort(scheme)` mints + ``Port(`HttpAuthenticator:${scheme}`)>`` — the + move `AmqpHandler(contract, key)` makes, with the scheme name on the port + **id**, so `HttpAuthenticator:user` and `HttpAuthenticator:service` are + different types and a scheme with nobody behind it is di's own unmet need + naming the port. It is **memoised** in a module-level `Map`: `defineHttp` + asks for a port when it binds an authenticator and `routerFor` asks again for + every scheme its contract names, and two `Port(id)` calls under one id are + di's duplicate-id warning. The service type is **erased to `unknown`** + (`Granted` is `unknown`, so it admits the bare and the scoped + answer alike) because di identifies a port by id; the principal and scope + types ride the description `HttpAuthenticator` hands back — + `{ deps, options, principal: P, scope: Scope, needs: N }` — which is what + `defineHttp` binds and reads the registry off. + Both type arguments are explicit rather than inferred from `sync`: inference + through a returned function's `AsyncResult` is exactly where a principal + silently widens to `unknown`. The **scheme name is not stated here** — it is + the key this authenticator sits under in `defineHttp({ authenticators })`, so + it is written once. `Unauthenticated` is a `TaggedError` with an **empty payload**: the starter surfaces no reason — a refused caller gets an `UNAUTHORIZED` and oRPC's default message — so a field here would be write-only. An authenticator that wants to record why logs it before returning. Forwarding a reason would put "no such user" versus "bad signature" in a 401 body by default. -- **`httpAuth()` → `{ HttpController, HttpRouter, HttpAuthenticator }`, - plus `HttpControllerOf` / `HttpRouterOf` / - `HttpAuthenticatorOf`** - (`http-auth.ts`) — **the** place a principal type is stated. - **The contract says whether a route is protected; this says what the - principal is.** `Implementation` and `ContextOf` - carry it: a **marked** leaf's `opts.context.principal` is `Identity`, an - **unmarked** one is still `object`. `Identity = never` is "no factory", and - the top-level `HttpController` / `HttpRouter` are `controllerFor()` / - `routerFor()` — so a marked fragment reached through them types - `principal: never` and **any read of it is a compile error** (measured: - TS2339 on a property of `never`). That is the "use the factory" signal, and - it is the only thing the top-level form can honestly say now that the - contract carries no principal to fall back on. `controllerFor` and +- **`defineHttp({ authenticators })` → `Http`, carrying `HttpController`, + `HttpRouter` and `authenticators`** (`define-http.ts`) — **the one door** to + the marker-typed entities, and the place a scheme registry is stated. + **The contract says which schemes protect a route; this says what each one + resolves to.** `SchemesFrom` reads the registry off the authenticators + (`{ [K in keyof A]: A[K]["principal"] }`) rather than having it declared a + second time, and `Implementation` / `ContextOf` + carry it down to each leaf. Declaring a scheme and implementing it are **the + same act**, so a scheme with no authenticator is not a state this can reach — + there is no coverage gate because there is nothing to forget. + `Schemes = never` is "no factory": a marked fragment reached through anything + but a `defineHttp` call types `principal: never` and **any read of it is a + compile error** (measured: TS2339 on a property of `never`). That is the + "use the factory" signal. `controllerFor` and `routerFor` are exported from their own files for this factory alone, not - from `index.ts`. - What it replaced: a principal type named in the contract, which put the - server's own view of a caller — a user id, roles — in the artifact a client - imports, and left a handler unable to see anything the contract had not - published. + from `index.ts`, and there is **no** top-level `HttpController` / `HttpRouter` + any more: a form whose principal could only ever be `never` was a trap with + no correct use. + The default type argument is `Record`, **not** + `Record`: an index signature over `string` would make every + scheme's port look available to di, so a marked contract composed under + `defineHttp()` would type-check and then fail at build. Empty, the port stays + unmet and the composition is refused. + **The result is held as ONE binding and never destructured.** Each binding of + a destructured member expands to a type mentioning `@btravstack/contract`'s + inaccessible `unique symbol`, which is TS2527 (measured); held whole, the + inferred type collapses to `Http`, which is nameable — so an application + writes **no type annotation at all**, and the three `…Of` aliases + the previous factory needed are gone with the annotations they existed for. + At runtime the call binds one provider per scheme — + `Provider(authenticatorPort(scheme))(deps)` or `(deps, options)`, discriminated + by whether `HttpAuthenticator`'s no-deps arm left `options` undefined, the + same arity discrimination `Provider(port)` makes — and hands them to + `routerFor`, which carries them out on `provider.authenticators`. It is **per application, not per slice**, and that is forced rather than chosen: a handler's parameter types are fixed where the arrow is written, so a composition root cannot retroactively re-type a `sync` callback in another - module. The identity must be in scope where the handler is, and the factory + module. The registry must be in scope where the handler is, and the factory is how it gets there with no per-call-site annotation. - The three `…Of` aliases exist because a file **exporting** what the - factory returns cannot infer it: a controller's port expands to a type - carrying `@btravstack/contract`'s phantom `unique symbol` (TS2527, measured - on `examples/order-api`, and the same reason `HttpController` / - `HttpRouter` themselves are annotated `ReturnType>` - here). `HttpAuthenticator` is handed back **already applied** — the type - argument it exists to state is what the factory just fixed — so it is called - `HttpAuthenticator({ name: Dep }, { sync })`. - `HttpModule`'s gate compares the authenticator's principal to the - **router's** identity, both of which come from the same `httpAuth` call in an - ordinary application. Pinned by `auth.test-d.ts`'s arms 12–16 (the identity - on a marked leaf, the top-level form typing `principal: never` on the same - fragment, no principal invented on an unmarked one, the keyed compose, and a - stray authenticator refused) and at runtime by `auth.spec.ts`'s `rpcAuthed` - fixture, whose contract names no identity at all and whose handler reads a - `userId` only the factory typed. -- **`principalMiddleware` and `noAuthenticator`** (`auth.ts`, internal — + What it replaced: a principal type named in the contract, which put the + server's own view of a caller — a user id, roles — in the artifact a client + imports; and then a single-identity factory, which named **one** identity per + application and so could not describe a route two different kinds of caller + may reach. Pinned by `define-http.test-d.ts` (the registry inferred from the + authenticators, the no-argument call, an authenticator's own dependency + riding through), by `auth.test-d.ts`'s arms 7–12, and at runtime by + `auth.spec.ts`'s `rpcAuthed`, `rpcRootMarked` and `rpcVerified` fixtures. +- **`principalMiddleware(requirements, authenticators)`** (`auth.ts`, internal — **not** exported from `index.ts`, like `HttpHandler`) — the one middleware this package installs, - and only on a marked leaf. It reads the request off oRPC's **initial - context** (`orpc()` now passes `context: { request }` to - `RPCHandler.handle`, which is what initial context is for), calls the - authenticator with its headers, and either injects - `{ context: { principal } }` through `next` or terminates the request. An - `Unauthenticated` becomes `throw new ORPCError("UNAUTHORIZED")` — oRPC's - middleware protocol has no returned-error arm, which is the one place in - this package a `throw` is right, carried by an `unthrown/no-throw` disable - naming why. **No message is derived from the refusal**: oRPC serializes - `message` to the client, so the caller gets oRPC's default `"Unauthorized"` - and the `reason` never leaves the process. Pinned by `auth.spec.ts`'s - _"answers 401 without the authenticator's reason"_, mutation-verified. A - **defect** is rethrown as its own cause instead, so a bug in the - authenticator stays oRPC's `INTERNAL_SERVER_ERROR` collapse rather than - being reported as a rejected caller. -- **The authenticator dependency is conditional, and the two halves must + and only on a leaf whose effective requirements say so. It reads the request + off oRPC's **initial + context** (`orpc()` passes `context: { request }` to + `RPCHandler.handle`, which is what initial context is for) and tries the + requirements **in the order the contract declared them**, taking the first a + caller satisfies, then injects `{ context: { principal } }` through `next`. + Four decisions live here, each pinned by `auth.spec.ts`: + - **Tagged when the leaf names more than one SCHEME**, not more than one + requirement — `new Set(requirements.flatMap(Object.keys)).size > 1`. One + requirement may name several schemes, and counting requirements disagreed + with `SchemesOf`, which unions scheme names across all of them: the handler + typed `Tagged` while this injected bare, so `principal.scheme` read + `undefined` with **no type error to catch it**. + - **A required scope is not satisfied by a credential reporting none.** A + scheme declared without a vocabulary answers bare, and skipping the + comparison for it admitted the caller outright — the one place in this + package where the failure direction matters. An empty `required` still + passes trivially. + - **`403` is not `401`.** A credential that was valid but under-scoped gets + `FORBIDDEN`; only a caller no requirement accepted at all gets + `UNAUTHORIZED`. Both are `throw new ORPCError(...)` — oRPC's + middleware protocol has no returned-error arm, which is the one place in + this package a `throw` is right, carried by an `unthrown/no-throw` disable + naming why — and **neither derives a message from the refusal**: oRPC + serializes `message` to the client, so the caller gets oRPC's default and + the reason never leaves the process. + - **A defect short-circuits rather than falling through.** A defect is a bug + in the authenticator, not a refusal; falling through would let a broken + verifier silently promote every caller to the next scheme. It is rethrown + as its own cause, so it stays oRPC's `INTERNAL_SERVER_ERROR` collapse. + + The authenticators arrive as a plain record keyed by scheme, and the lookup + is **asserted, not guarded**: the router declares one dep per scheme its + contract names, so every scheme a requirement names is a key here and di + refuses the graph long before a request lands. That is also why + `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. + +- **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` flag — - `isAuthenticated(node)` answers for one node only, so a marked record's mark - is pushed down by the walk exactly as `Inherit` pushes it in the types - — and a marked leaf becomes - `node.use(principalMiddleware(authenticate)).result(fn)`. **`.use` before + **contract** alongside the implementer, carrying an `inherited` requirements + value — + `isAuthenticated(node)` answers for one node only, so a marked record's + requirements are + pushed down by the walk exactly as `Inherit` pushes them in the types, + and a node's own mark **replaces** what it inherited, exactly as + `Effective` does — and a leaf with effective requirements becomes + `node.use(principalMiddleware(effective, authenticators)).result(fn)`. **`.use` before `.result`, never the reverse**: `.result` returns an `ImplementedProcedure` whose own `.use` has no `.result` left. Three things keep the two halves - from parting, each of which was a live bypass before it was fixed: - - **The walk is seeded with `isAuthenticated(contract)`, not `false`.** The + from parting: + - **The walk is seeded with `isAuthenticated(contract)`, not `undefined`.** The root node has no `contract[key]` to be read from, so a marked **root** — - `HttpRouter(authenticated(contract))` — would otherwise wrap nothing at - all while `Implementation`'s record arm typed every leaf with a + `api.HttpRouter(authenticated({ user: [] })(contract))` — would otherwise wrap nothing at + all while `Implementation`'s record arm typed every leaf with a principal that never arrived. Pinned by `auth.spec.ts`'s `rpcRootMarked` fixture, mutation-verified. - - **`hasMarked` enters every object, not only plain records**, cycle-guarded - by a `WeakSet` (a schema is free to be recursive). Anything it declines to + - **`schemesOf` enters every object, not only plain records**, cycle-guarded + by a `WeakSet` (a schema is free to be recursive), and does **not** stop at + a mark — a procedure inside a marked record may name a scheme of its own, + and that scheme still needs a port. Anything it declines to enter is a mark it can miss and the walk cannot, and missing one is the - unsafe direction; over-approximating only ever declares an authenticator - nothing uses. - - **A mark with no authenticator behind it fails closed**, through - `auth.ts`'s `noAuthenticator` — an `AuthenticatorService` that refuses - every caller, so the leaf answers `401` instead of serving unprotected. - Unreachable while the two halves agree, which is exactly why it is there. + unsafe direction; over-approximating only ever declares a port nothing + uses. Its type-level twin is `SchemePortsOf`, built on + `AllRequirementsOf` — the same tree walk as `HasMark`, keeping what + it found instead of answering yes — and the two must agree. + - **A scheme with no authenticator behind it does not build.** There is no + fail-closed stand-in any more, and none is wanted: the router names one + port per scheme, `defineHttp` binds one provider per authenticator, and a + scheme in the first set but not the second is di's own unmet need naming + `HttpAuthenticator:` — refused before a request can arrive rather + than answered `401` once one has. It also takes **`needs`**, forwarded to di's own — what this root's OWN providers expect from outside. The starter's `Env` is not among them: the @@ -317,21 +398,23 @@ InstanceType> & { readonly port: PortClassOf slipping past into `start`; see `packages/di/CLAUDE.md`'s **Module visibility**. - When `hasMarked(contract)` answers true, - `AuthenticatorPort` joins the provider's deps record under the **namespaced** - key `"@btravstack/http/authenticator"` — namespaced for the same reason + For every scheme `schemesOf(contract)` found, that scheme's port joins the + provider's deps record under the **namespaced** + key the `AUTHENTICATOR` constant builds — `"@btravstack/http/authenticator"` + plus a trailing colon, then the scheme name — namespaced for the same reason `tapped`'s port id is, since every other key on that record is a name the - caller chose and this one must not be able to collide with a dependency - somebody called `authenticator`; `sync` reads it off the services record and - hands the caller's own `sync` the rest — and both `build` overloads add - `HasMark extends true ? AuthenticatorPort : never` to the needs channel - plus `readonly identity: Identity` to the result. - A marked router whose root provides no authenticator is therefore an + caller chose and these must not be able to collide with a dependency + somebody called `user`; `sync` reads them off the services record into the + record `principalMiddleware` takes and + hands the caller's own `sync` the rest — and all three `build` overloads add + `SchemePortsOf` to the needs channel + plus `readonly authenticators` to the result. + A router naming a scheme nobody implements is therefore an ordinary unmet need refused at `start` — no new gate, and not di's arity - gate (see the `authenticator` bullet for what prints). Whether the - authenticator resolves what the handlers read is the one thing that - gate cannot see, and `HttpModule`'s `authenticator` option is where it is - checked (see the first bullet). Note + gate. There is nothing left for a + gate to check afterwards: the registry that types the handlers and the + providers that discharge the ports come from the **same** `defineHttp` call, + so they cannot disagree. Note `oc.router(...)` **rebuilds** every node, so a marker applied inside a builder chain is lost — on **both** sides at once (`AugmentedContractRouter` maps `[K in keyof T]` and answers `never` @@ -348,7 +431,7 @@ InstanceType> & { readonly port: PortClassOf it is enforced, not offered among alternatives. The router is not an option: the module **needs** `HttpRouterPort`, and the application provides it — a provider that declares the use cases its procedures call (di injects - them, oRPC's context stays empty), built by `HttpRouter(contract)(deps, + them, oRPC's context stays empty), built by `api.HttpRouter(contract)(deps, arm)`. The starter provides `Runtime` on the **`HttpRuntime`** port (a class over core's `RuntimePort`, **an empty `resolves`**), which the composition root imports @@ -485,7 +568,7 @@ prefix })`, unmatched → resolves unwritten), and the `HttpRuntime` provider de `httpModule(socket, orpc({ prefix }))`; the package's own transport specs hand it a bare listener instead. It exists for that second reason only. `httpRuntime`, the runtime value's factory, is internal too. -- **40 specs, 100% lines/functions.** Every app boots through the `boot` +- **49 specs, 100% lines/functions.** Every app boots through the `boot` fixture — `@btravstack/testing`'s `bootFixture()`, which `serve`, `rpc`, `configured` and `appOnPort` depend on — so it is stopped when the test ends, on every exit path, and the teardown is Defect-only: a startup @@ -516,32 +599,44 @@ greetingRouter, port: 0, hostname: "127.0.0.1", provides: [Greeter] })` over a `greet`-only router configured with oRPC's own `CORSHandlerPlugin`, proving `plugins` reaches `RPCHandler` rather than being silently accepted and dropped: the plugin, not this package, decided the response's - `access-control-allow-origin`. `controller.spec.ts` carries the - remaining 2, through the `controllers` and `rpcSliced` fixtures: a + `access-control-allow-origin`. `controller.spec.ts` carries 4, + through the `controllers` and `rpcSliced` fixtures: a `HttpController` carries the port it was minted under and the deps it - declared, and `HttpRouter(contract)({...})` serves a router composed from + declared, `api.HttpRouter(contract)({...})` serves a router composed from two controllers — `helloController` and `echoesController`, each over its own fragment of `slicedContract` — with a procedure from each answering through one client, proving every controller's slice was mounted under its - own contract key. A process still serves one router (thesis #1); the keyed + own contract key, and two pin the three-form discrimination at runtime: a + contract declaring a key literally called `sync` still resolves to the keyed + form, and an arm-only router's `sync` is handed **no arguments** at all. + A process still serves one router (thesis #1); the keyed form changes how many providers build it, not that fact. `auth.spec.ts` - carries the last 9, through the `rpcAuthed`, `rpcRootMarked`, - `authedRouterDeps` and `controllers` fixtures — every one of them over a - router, controllers and authenticator minted by ONE `httpAuth()`, + carries the last 16, through the `rpcAuthed`, `rpcRootMarked`, + `controllers` and `headers` fixtures — every router, controller and + authenticator in them minted by ONE `defineHttp({ authenticators })`, since a contract naming no principal leaves the factory as the only way a handler gets a readable one. Four are over - `authedContract` — `{ orders: authenticated({ whoami }), health: { ping } }`, + `authedContract` — `{ orders: authenticated({ user: [] })({ whoami }), health: { ping } }`, one protected fragment and one public one: the handler reading a `userId` only the factory typed, a rejected token answering `UNAUTHORIZED` with the handler never entered, an authenticator's own defect collapsing to `INTERNAL_SERVER_ERROR` rather than a 401, and an unmarked procedure served - with no credentials at all. Two are over `rootMarkedContract` — - `authenticated({ orders: { whoami } })`, the mark on the **root**, where - there is no `contract[key]` to read it from: a rejected token still gets a - 401 with the handler never entered, and an accepted one still reaches the - handler with its principal. Two are composition-time — the authenticator - appended **last** in both `build` arms, and nothing appended at all when the - contract marks nothing. The ninth is `noAuthenticator` itself, refusing - every caller. + with no credentials at all. One more is over the authenticator that + **declares a dependency** — a `Verifier` port, the arm `defineHttp` binds + through `Provider(port)(deps, arm)` — proving its need travelled with it into + the graph. Two are over `rootMarkedContract` — + `authenticated({ user: [] })({ orders: { whoami } })`, the mark on the **root**, where + there is no `contract[key]` to read it from: every leaf beneath it is + protected, and an accepted caller still reaches the + handler with its principal. Two are composition-time — the scheme's own port + declared alongside the dependencies the caller wrote, and no scheme port at + all when the contract marks nothing. The last seven drive + `principalMiddleware` directly, over the `headers` fixture, and are where the + feature's own rules are pinned: the first requirement a caller satisfies + wins, `UNAUTHORIZED` when none is, a granted scope admits, `FORBIDDEN` when + the scheme grants no scopes at all, `FORBIDDEN` when the credential is valid + but under-scoped, the principal tagged when **one** requirement names two + schemes, and a defect stopping the walk instead of falling through to the + next requirement. `controller.test-d.ts` is the package's own compile-time gate — see Public surface. diff --git a/packages/http/README.md b/packages/http/README.md index 744e58e3..b3511080 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -11,8 +11,8 @@ [API Reference](https://btravstack.github.io/start/api/http/) ```sh -pnpm add @btravstack/http @btravstack/core @btravstack/config @btravstack/di unthrown \ - @orpc/server @orpc/contract @unthrown/orpc +pnpm add @btravstack/http @btravstack/core @btravstack/config @btravstack/di \ + @btravstack/contract unthrown @orpc/server @orpc/contract @unthrown/orpc ``` All of those are peer dependencies — install every one, so the application @@ -23,13 +23,18 @@ has not cut a release yet. ```ts import { runMain } from "@btravstack/core"; -import { HttpModule, HttpRouter } from "@btravstack/http"; +import { HttpModule, defineHttp } from "@btravstack/http"; import { P } from "unthrown"; +// One call mints every marker-typed entity this application uses. A public +// API declares no security scheme, so it takes no argument. Hold the result +// as ONE binding — never destructure it (see "Protecting a procedure"). +const api = defineHttp(); + // Contract-first: the record is shaped like the contract, each leaf a plain // Result-returning function typed by it. The use cases arrive under the names // the deps record gave them — di injects them; oRPC's context stays empty. -const ordersRouter = HttpRouter(ordersContract)( +const ordersRouter = api.HttpRouter(ordersContract)( { place: PlaceOrder, find: FindOrder }, { sync: ({ place, find }) => ({ @@ -97,15 +102,15 @@ port back from `app.runtimeInfo()`. ## Splitting a large API into slices -`HttpRouter(contract)(deps, { sync })` is right for a small API; a large one -splits into **controllers**, one per slice of the contract, composed at the +`api.HttpRouter(contract)(deps, { sync })` is right for a small API; a large +one splits into **controllers**, one per slice of the contract, composed at the root by a keyed call instead: ```ts -const ordersController = HttpController("OrdersController", ordersContract)( - [PlaceOrder, FindOrder], +const ordersController = api.HttpController("OrdersController", ordersContract)( + { place: PlaceOrder, find: FindOrder }, { - sync: (place, find) => ({ + sync: ({ place, find }) => ({ place: ({ errors }, input) => place .execute(input.id, input.quantity) @@ -149,101 +154,125 @@ const ordersController = HttpController("OrdersController", ordersContract)( }, ); -const orderRouter = HttpRouter(orderContract)({ +const orderRouter = api.HttpRouter(orderContract)({ orders: ordersController, customers: customersController, }); ``` -`HttpController(name, fragment)({ name: Dep }, { sync })` — or just +`api.HttpController(name, fragment)({ name: Dep }, { sync })` — or just `({ sync })` when the slice calls nothing — is the same two-call shape -as `HttpRouter`, aimed at one fragment: it mints a port under `name` and +as `api.HttpRouter`, aimed at one fragment: it mints a port under `name` and returns the provider carrying it on `.port`. The keyed form is **exact** — a missing slice, an undeclared key and a controller under the wrong key are all compile errors — and because a fragment is itself a valid contract, a slice can be served alone, its controller unchanged: the lifted root is -`HttpRouter(ordersContract)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`, +`api.HttpRouter(ordersContract)({ implementation: ordersController.port }, { sync: ({ implementation }) => implementation })`, declaring the very provider the modulith composed. See [Split a router into controllers](https://btravstack.github.io/start/how-to/split-a-router-into-controllers). ## Protecting a procedure -A contract can say a procedure needs an authenticated caller. The marker is -`@btravstack/contract`'s, so it lives in the artifact a client holds too — and -it says **whether**, not who: no identity type is named there, so nothing about +A contract says which **security schemes** a procedure accepts and which scopes +each must grant. The marker is `@btravstack/contract`'s, so it lives in the +artifact a client holds too — and it names no identity type, so nothing about the server's view of a caller reaches a client. -`httpAuth()` is what says **what** the principal is. It is written -once per application and hands back `HttpController`, `HttpRouter` and -`HttpAuthenticator` fixed to that identity: +`defineHttp({ authenticators })` is what says **what each scheme resolves to**. +Declaring a scheme and implementing it are the same act, so there is no +registry to keep in step with the contract and nothing for a composition root +to forget: ```ts -// src/auth.ts — the one file that names the identity +// src/auth.ts — the one file that names this deployment's identities import { - httpAuth, - type HttpControllerOf, - type HttpRouterOf, - type HttpAuthenticatorOf, + HttpAuthenticator, + Unauthenticated, + defineHttp, } from "@btravstack/http"; +import { ErrAsync, OkAsync } from "unthrown"; export type Identity = { readonly tenantId: string; readonly userId: string }; +export type ServiceIdentity = { readonly appId: string }; + +// An ordinary di provider description: `deps` are di's, so a JWT verifier or a +// user directory is injected the way any provider's are — an authenticator +// reading only headers declares none. The scope vocabulary is the second type +// argument, so the granted list is checked here rather than compared as loose +// strings at the endpoint. +const userAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") + ? header.slice("Bearer ".length) + : ""; + const [tenantId, userId, ...rest] = token.split(":"); + // Empty is not absent: `Authorization: :` splits into two defined strings, + // and admitting them is admitting an anonymous caller as tenant "". + return tenantId === undefined || + tenantId === "" || + userId === undefined || + userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync({ + identity: { tenantId, userId }, + scopes: rest + .join(":") + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + }); + }, +}); -const identity = httpAuth(); +const serviceAuth = HttpAuthenticator()({ + sync: () => (headers) => { + const key = headers["x-api-key"]; + return typeof key === "string" && key !== "" + ? OkAsync({ appId: key }) + : ErrAsync(new Unauthenticated()); + }, +}); -export const HttpController: HttpControllerOf = - identity.HttpController; -export const HttpRouter: HttpRouterOf = identity.HttpRouter; -export const HttpAuthenticator: HttpAuthenticatorOf = - identity.HttpAuthenticator; +// The scheme NAMES are the keys here, written once. Held whole and never +// destructured: each destructured member expands to a type mentioning the +// marker's inaccessible `unique symbol` (TS2527), while held whole it collapses +// to `Http` — which is why this file writes no type annotation at all. +export const api = defineHttp({ + authenticators: { user: userAuth, service: serviceAuth }, +}); ``` -The three aliases are annotations, not ceremony: a controller's port expands to -a type carrying the marker's phantom `unique symbol`, which a consumer's -`.d.ts` cannot name. - ```ts import { authenticated } from "@btravstack/contract"; -import { HttpModule, Unauthenticated } from "@btravstack/http"; +import { HttpModule } from "@btravstack/http"; import { oc, type } from "@orpc/contract"; -import { ErrAsync, OkAsync, P } from "unthrown"; +import { OkAsync, P } from "unthrown"; -import { HttpAuthenticator, HttpRouter } from "./auth.js"; +import { api } from "./auth.js"; -const ordersContract = authenticated({ +const ordersContract = authenticated({ user: [] })({ find: oc .input(type<{ readonly id: string }>()) .output(type()) .errors({ NOT_FOUND: { data: type() } }), -}); -// An ordinary di provider on the starter's port: `deps` are di's, so a JWT -// verifier or a user directory is injected the way any provider's are. It -// takes no type argument — `httpAuth()` already fixed one, which is -// why the authenticator and the controllers cannot disagree. -const bearerAuthenticator = HttpAuthenticator({ - sync: () => (headers) => { - const header = headers.authorization ?? ""; - const token = header.startsWith("Bearer ") - ? header.slice("Bearer ".length) - : ""; - const [tenantId, userId] = token.split(":"); - // Empty is not absent: `Authorization: :` splits into two defined strings, - // and admitting them is admitting an anonymous caller as tenant "". - return tenantId === undefined || - tenantId === "" || - userId === undefined || - userId === "" - ? ErrAsync(new Unauthenticated()) - : OkAsync({ tenantId, userId }); - }, + // Overrides the group default for itself — nearest mark wins. Requirements + // are ORed in declaration order: a `user` token granting `orders:export`, or + // a `service` key with no scopes at all. + export: authenticated( + { user: ["orders:export"] }, + { service: [] }, + )(oc.output(type<{ readonly csv: string }>())), }); -// The principal arrives on oRPC's own context channel, typed by `Identity`. -const ordersRouter = HttpRouter({ orders: ordersContract })( +const ordersRouter = api.HttpRouter({ orders: ordersContract })( { find: FindOrder }, { sync: ({ find }) => ({ orders: { + // One scheme, so the principal is the identity BARE. find: ({ context, errors }, input) => find .execute(context.principal.tenantId, input.id) @@ -256,35 +285,54 @@ const ordersRouter = HttpRouter({ orders: ordersContract })( }), ), ), + // Two schemes, so it is a discriminated union — and the switch is + // exhaustive or the build fails. + export: ({ context }) => { + switch (context.principal.scheme) { + case "user": + return OkAsync({ + csv: `user,${context.principal.identity.userId}`, + }); + case "service": + return OkAsync({ + csv: `service,${context.principal.identity.appId}`, + }); + } + }, }, }), }, ); +// No authenticator to list: they ride the router, which is what needs them, +// and `HttpModule` puts them in `provides` itself. const OrdersApi = HttpModule("OrdersApi")({ router: ordersRouter, - authenticator: bearerAuthenticator, imports: [Application, Persistence], }); ``` -Every slice imports `HttpController` from `src/auth.ts` instead of from this -package, and its handlers see `Identity` on `context.principal` with no -annotation of their own. It is the **only** way to read one: the top-level -`HttpController` and `HttpRouter` name no identity, so a marked fragment -reached through them types `principal: never` and every read is a compile -error — the signal to use the factory. An unmarked procedure still gets no -principal at all: the contract decides _whether_, the factory decides _what_. - -A marked router carries the authenticator port as a **need**, so forgetting -`authenticator` is an unmet dependency `start` refuses, and supplying one -minted on a different identity is a compile error at the `HttpModule(...)` -call — the router's identity against the authenticator's, both from the same -`httpAuth` call. -A marked record protects every procedure beneath it. `Unauthenticated` carries -**nothing**: the starter surfaces no reason — a rejected caller gets an -`UNAUTHORIZED` and oRPC's default message — so an authenticator that wants to -record why logs it before returning. See +Every slice mints its controller from that one `api`, and its handlers see the +right principal on `context.principal` with no annotation of their own. It is +the **only** way to read one: a marked fragment reached through anything else +types `principal: never` and every read is a compile error — the signal to use +the factory. An unmarked procedure still gets no principal at all: the contract +decides _which schemes_, the factory decides _what each one is_. + +A router declares **one dependency per scheme its contract names**, so a scheme +with no authenticator behind it is an ordinary unmet need `start` refuses, +naming the port (`HttpAuthenticator:user`). There is no identity pair left to +compare: the registry that types the handlers and the providers that discharge +those ports come from the same call. + +Before dispatch, the requirements are tried in the order the contract declared +them and the first a caller satisfies wins. A caller no requirement accepts +gets **`401`**; a caller whose credential was valid but lacked a required scope +gets **`403`**. Neither carries a message — oRPC serializes `message` to the +client, and a refusal has nothing a caller is entitled to, so an authenticator +that wants to record why logs it before returning `Unauthenticated`. A +**defect** from an authenticator is a bug, not a refusal: it stops the walk +rather than promoting the caller to the next scheme. See [Protect a procedure](https://btravstack.github.io/start/how-to/protect-a-procedure). ## What it guarantees @@ -306,7 +354,7 @@ nothing. The drain retires busy keep-alive connections; a client's option and no listener port to provide. - **A middleware slot for application logic.** oRPC's own middleware, inside the router's procedures, is where that belongs. The one the package installs - itself is `principalMiddleware`, on a marked leaf only. `plugins` is an + itself is `principalMiddleware`, on a leaf whose requirements say so. `plugins` is an honest escape hatch rather than a keyhole — an oRPC plugin's `init` transforms handler options **including interceptors**, so an application determined to see a procedure's outcome can get there. What the option buys @@ -322,11 +370,19 @@ nothing. The drain retires busy keep-alive connections; a client's none of them is the limit anybody meant. The ingress or gateway is where a request count is counted once — and an application that wants one anyway writes an oRPC plugin and passes it through `plugins`. -- **Authorization.** "May this caller do this?" usually depends on the - resource — the order's owner, its state, the row's tenant — which cannot be - answered before the handler has run and fetched it. Authentication, "is - there a principal and what is it?", is answerable before dispatch, and is - the only half the contract carries. +- **Resource-dependent authorization.** "May this caller do this?" usually + depends on the resource — the order's owner, its state, the row's tenant — + which cannot be answered before the handler has run and fetched it. A + **scope** is the exception, and on the same test: it is a property of the + credential, answerable before dispatch, so the contract declares it and this + package enforces it. Anything the handler has to fetch first stays the + handler's. +- **AND within one requirement.** A requirement names one scheme. Requiring two + credentials at once would put a record rather than an identity on the + handler; a composite scheme models it where it is genuinely needed. +- **OpenAPI document metadata.** The schemes' own definitions — `type: http`, + `bearerFormat`, an OAuth flow — belong beside the contract, not in this + factory. - **HTTPS, HTTP/2.** `node:http` only; terminate TLS at the ingress. ## License From a4516c9f51acbb627ed1742665dddda2860a4173 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 22 Aug 2026 23:58:07 +0200 Subject: [PATCH 15/18] fix(http)!: a scoped grant is branded, and a requirement names one scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects the final review found, both silent. `principalMiddleware` told the scoped answer from the bare one with `"scopes" in granted`. The type parameter is erased at runtime, so no structural test can be sound: a scheme declared WITHOUT a vocabulary whose identity carries a `scopes` field — an ordinary JWT-claims shape, and what the design spec advises — was read as the scoped answer, and its absent `identity` was injected. The handler is typed as the identity, receives `undefined`, and in this stack the principal is where the tenant lives, so it is a 500 on every request to that route. `granted(identity, scopes)` now mints the scoped answer, stamping a module-private symbol, and `Granted`'s scoped arm IS that branded result — so the helper is mandatory rather than advisory and a hand-built `{ identity, scopes }` no longer type-checks. The middleware tests for the brand. `[Scope] extends [never]` still means the identity bare, unchanged. `Requirement` allowed several keys, which OpenAPI reads as AND and this starter runs as OR — an author copying a requirement out of an OpenAPI document got a weaker rule than the one written. Four documents said "a requirement names one scheme" and nothing enforced it; `authenticated`'s constraint does now. --- .changeset/named-security-schemes.md | 21 ++++++++--- docs/how-to/protect-a-procedure.md | 11 ++++-- docs/reference/contract.md | 13 +++++-- docs/reference/http.md | 49 ++++++++++++++++++------- examples/order-api/src/auth.ts | 18 +++++---- packages/contract/CLAUDE.md | 16 ++++++-- packages/contract/src/auth.test-d.ts | 19 ++++++++++ packages/contract/src/auth.ts | 22 ++++++++++- packages/http/CLAUDE.md | 17 ++++++++- packages/http/README.md | 24 +++++++----- packages/http/src/auth.spec.ts | 29 +++++++++++++-- packages/http/src/auth.test-d.ts | 24 ++++++++++-- packages/http/src/auth.ts | 55 +++++++++++++++++++++++----- packages/http/src/index.ts | 4 +- 14 files changed, 251 insertions(+), 71 deletions(-) diff --git a/.changeset/named-security-schemes.md b/.changeset/named-security-schemes.md index e298bf6f..74876ae2 100644 --- a/.changeset/named-security-schemes.md +++ b/.changeset/named-security-schemes.md @@ -11,8 +11,11 @@ to — in one call. of a boolean. `authenticated` is now **curried**: `authenticated(...requirements)(node)`, where a `Requirement` is `Readonly>` — a scheme name mapped to the -scopes it must grant. Several requirements are **ORed**, tried in declaration -order. Applied to a record it is the default for every procedure beneath it; +scopes it must grant, and **exactly one scheme**: a second key does not +compile, because OpenAPI reads two keys in one requirement as AND while this +starter walks them as OR, so a requirement copied out of an OpenAPI document +would silently execute a weaker rule than the one it states. Several +requirements are **ORed**, tried in declaration order. Applied to a record it is the default for every procedure beneath it; applied to a procedure it **replaces** that default for itself — nearest mark wins, which is OpenAPI's rule. `isAuthenticated(node)` answers `Requirements | undefined` rather than a boolean, `Authenticated` and the @@ -48,12 +51,18 @@ cannot compile. **Scopes are declared in the contract and enforced before dispatch.** `HttpAuthenticator()` states a scheme's scope vocabulary, so a -credential reports what it actually granted (`Granted` is `P` bare -when there is no vocabulary) and the starter compares it against what the -endpoint declared: a valid credential lacking a required scope is **`403`**, +credential reports what it actually granted through the new +**`granted(identity, scopes)`** (`Granted` is `P` bare when there is +no vocabulary, and the branded `Grant` when there is one) and the +starter compares it against what the endpoint declared: a valid credential lacking a required scope is **`403`**, no valid credential at all is **`401`**, and neither carries a message. A `Defect` from an authenticator short-circuits rather than falling through to -the next scheme — a broken verifier must not promote every caller. +the next scheme — a broken verifier must not promote every caller. `granted()` +is **mandatory rather than advisory**: the type parameter is erased at +runtime, so the module-private symbol it stamps is the only sound way the +starter can tell a scoped answer from an identity that merely carries a +`scopes` field — the ordinary JWT-claims shape, which a structural test read +as the scoped answer and handed the handler `undefined`. A router now declares **one di dependency per scheme its contract names**, on a port whose id carries the scheme name (`HttpAuthenticator:user`), so a missing diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index d6d6c03e..4d75a824 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -86,10 +86,13 @@ Four rules, and they are OpenAPI's own: adds a scope the credential has to carry. - **Requirements are ORed**, tried in the order given: the first one a caller satisfies wins. `authenticated({ user: [...] }, { service: [] })` means either. -- **A requirement names one scheme.** AND-within-a-requirement is deliberately - not modelled — requiring two credentials at once would put a record rather - than a single identity on the handler. Where two really are needed, a - composite scheme models it. +- **A requirement names one scheme**, and `authenticated({ user: [], mtls: [] })` + does not compile. AND-within-a-requirement is deliberately not modelled — + requiring two credentials at once would put a record rather than a single + identity on the handler — and it is refused rather than documented because + the discrepancy weakens the rule: OpenAPI reads two keys as AND, this + starter would run them as OR. Where two really are needed, a composite + scheme models it. - **Nearest mark wins.** A marked record is the default for every procedure beneath it; a marked procedure **replaces** that default for itself rather than adding to it. diff --git a/docs/reference/contract.md b/docs/reference/contract.md index e1ec83d6..6dbf3719 100644 --- a/docs/reference/contract.md +++ b/docs/reference/contract.md @@ -34,7 +34,7 @@ the server's view of a caller reaches a client. | --------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `authenticated` | value | `(...requirements: R) => (node: T) => Authenticated` — curried; marks a node with the requirements it names | | `isAuthenticated` | value | `(node: object) => Requirements \| undefined` — what **this exact node** requires, or `undefined` when nobody marked it | -| `Requirement` | type | `Readonly>` — one security scheme's name mapped to the scopes it must grant | +| `Requirement` | type | `Readonly>` — one security scheme's name mapped to the scopes it must grant; a second key is refused at the mark | | `Requirements` | type | `readonly Requirement[]` — ORed, tried in declaration order | | `Authenticated` | type | `T & { readonly [PrincipalKey]: R }` — `T`'s own keys plus one phantom key holding the exact requirements, for the type checker only | | `PrincipalKey` | type | `typeof PRINCIPAL`, the marker's key — exported so a consumer's mapped type can `Exclude` and land on the contract's own keys | @@ -81,10 +81,15 @@ Three rules, and they are OpenAPI's own: - **Requirements are ORed**, tried in the order given: the first one a caller satisfies wins. -- **A requirement names one scheme.** AND-within-a-requirement is deliberately +- **A requirement names one scheme**, and a second key is a **compile error** + rather than a documented caveat. AND-within-a-requirement is deliberately not modelled — requiring two credentials at once would put a record rather - than a single identity on the handler. A composite scheme models it where it - is genuinely needed. + than a single identity on the handler — and the discrepancy runs the wrong + way: OpenAPI reads `{ user: [], mtls: [] }` as AND while the starter walks + the entries and takes the first that satisfies, which is OR, so a + requirement copied out of an OpenAPI document would silently admit a caller + presenting either. A composite scheme models it where it is genuinely + needed. - **Nearest mark wins.** A marked record is a default; a marked procedure beneath it replaces that default for itself rather than adding to it. diff --git a/docs/reference/http.md b/docs/reference/http.md index b0894fc3..f44073ee 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -28,7 +28,9 @@ description: The HTTP starter — defineHttp, HttpModule, HttpRouter, HttpContro | `HttpModuleOptions` | type | The options object `HttpModule(name)` takes | | `HttpAuthenticator` | value | `HttpAuthenticator()({ name: Dep }, { sync })`, or `({ sync })` with no deps — how one scheme is implemented; the scheme's **name** is the key it sits under in `defineHttp` | | `Authenticator` | type | what `HttpAuthenticator` hands back — a description carrying its deps, principal, scopes and needs, which `defineHttp` binds to a port | -| `Granted` | type | `Granted` — the identity **bare** when the scheme has no scope vocabulary, `{ identity, scopes }` when it has one | +| `granted` | value | `granted(identity, scopes)` — mints the scoped answer, stamped with a module-private symbol so the starter can tell it from a bare identity that carries a `scopes` field | +| `Granted` | type | `Granted` — the identity **bare** when the scheme has no scope vocabulary, a `Grant` when it has one | +| `Grant` | type | `Grant` — the branded `{ identity, scopes }` `granted()` returns; unforgeable from outside the package | | `AuthenticatorService` | type | `(headers: IncomingHttpHeaders) => AsyncResult, Unauthenticated>` — headers in, credential out | | `authenticatorPort` | value | `authenticatorPort(scheme)` — the di port whose id is `` `HttpAuthenticator:${scheme}` ``; a router declares one per scheme its contract names | | `Unauthenticated` | value | a `TaggedError` with an empty payload — the refusal itself; the starter surfaces no reason to the client | @@ -384,9 +386,20 @@ it is the key the authenticator sits under in `defineHttp({ authenticators })` — written once. ```ts +type Grant = { + readonly identity: P; + readonly scopes: readonly Scope[]; + readonly [GRANT]: true; // a module-private symbol; `granted()` is what stamps it +}; + type Granted = [Scope] extends [never] ? P - : { readonly identity: P; readonly scopes: readonly Scope[] }; + : Grant; + +const granted: ( + identity: P, + scopes: readonly Scope[], +) => Grant; type AuthenticatorService = ( headers: IncomingHttpHeaders, @@ -402,12 +415,18 @@ inferred from `sync` — inference through a returned function's `AsyncResult` i where a principal silently widens to `unknown`. A scheme with **no scope vocabulary** returns the identity bare. One **with** -a vocabulary reports what the credential actually granted, checked against the -declared vocabulary at the authenticator rather than compared as loose strings -at the endpoint: +a vocabulary reports what the credential actually granted through +**`granted(identity, scopes)`**, checked against the declared vocabulary at the +authenticator rather than compared as loose strings at the endpoint. The helper +is **mandatory, not advisory**: the type parameter is erased at runtime, so the +brand it stamps is the only sound way the starter can tell the scoped answer +from an identity that merely happens to carry a `scopes` field — an ordinary +JWT-claims shape, which a structural test read as the scoped answer and handed +the handler `undefined`. ```ts import { TenantId } from "@btravstack/example-order-domain"; +import { granted } from "@btravstack/http"; export const userAuth = HttpAuthenticator()({ sync: () => (headers) => { @@ -418,20 +437,22 @@ export const userAuth = HttpAuthenticator()({ const [tenantId, userId, ...rest] = token.split(":"); // Rejoined rather than taken as one field: a scope name contains the // delimiter itself, so `orders:export` cannot survive a plain third field. - const granted = rest.join(":"); + const claimed = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ - identity: { tenantId: TenantId(tenantId), userId }, - scopes: granted - .split(",") - .filter( - (scope): scope is "orders:export" => scope === "orders:export", - ), - }); + : OkAsync( + granted( + { tenantId: TenantId(tenantId), userId }, + claimed + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + ), + ); }, }); diff --git a/examples/order-api/src/auth.ts b/examples/order-api/src/auth.ts index 3a8c0676..6a2e4283 100644 --- a/examples/order-api/src/auth.ts +++ b/examples/order-api/src/auth.ts @@ -1,5 +1,5 @@ import { TenantId } from "@btravstack/example-order-domain"; -import { HttpAuthenticator, Unauthenticated, defineHttp } from "@btravstack/http"; +import { HttpAuthenticator, Unauthenticated, defineHttp, granted } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; /** @@ -39,15 +39,17 @@ export const userAuth = HttpAuthenticator()({ const [tenantId, userId, ...rest] = token.split(":"); // Rejoined rather than taken as one field: a scope name contains the // delimiter itself, so `orders:export` cannot survive a plain third field. - const granted = rest.join(":"); + const claimed = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ - identity: { tenantId: TenantId(tenantId), userId }, - scopes: granted - .split(",") - .filter((scope): scope is "orders:export" => scope === "orders:export"), - }); + : OkAsync( + granted( + { tenantId: TenantId(tenantId), userId }, + claimed + .split(",") + .filter((scope): scope is "orders:export" => scope === "orders:export"), + ), + ); }, }); diff --git a/packages/contract/CLAUDE.md b/packages/contract/CLAUDE.md index a1386cd5..080acd51 100644 --- a/packages/contract/CLAUDE.md +++ b/packages/contract/CLAUDE.md @@ -19,7 +19,7 @@ identity, transport-agnostic by construction. ## Public surface - **`authenticated(...requirements)(node)`** (`auth.ts`) — curried: - `(...requirements: R) => (node: T) => + ` }>(...requirements: R) => (node: T) => Authenticated`. Call it with one or more `Requirement`s to get back a function that marks a node with them, in the order given. Apply it to a record of procedures (the **default** for every procedure beneath it) or to @@ -27,10 +27,20 @@ Authenticated`. Call it with one or more `Requirement`s to get back a mark wins). - **`Requirement`** — `Readonly>`, e.g. `{ user: ["orders:export"] }`: one security scheme's name mapped to the - scopes it must grant. Names exactly one scheme deliberately — + scopes it must grant. It is the **carrier** — what a marked node holds and + `isAuthenticated` reads back — so it says nothing about arity; the + module-private `OneScheme` in `authenticated`'s own constraint is what + refuses a second key where one is written. Exactly one scheme deliberately: AND-within-a-requirement is not modelled, because that would put a record rather than a single identity on the handler, and a handler wants to know - which scheme authenticated the caller, not juggle several at once. + which scheme authenticated the caller, not juggle several at once. **The + constraint is not documentation, because the discrepancy silently WEAKENS + the rule**: OpenAPI reads `{ user: [], mtls: [] }` as AND, and + `@btravstack/http` walks the entries taking the first that satisfies, which + is OR — so a requirement copied out of an OpenAPI document would have + admitted a caller presenting either. `OneScheme` is + `SeveralKeys extends false ? Q : never`, over the standard + distribute-then-compare-back union test; pinned by `auth.test-d.ts`. - **`Requirements`** — `readonly Requirement[]`. Several requirements on one mark are **ORed**, tried in declaration order: the first the caller satisfies wins. diff --git a/packages/contract/src/auth.test-d.ts b/packages/contract/src/auth.test-d.ts index 702296d7..da37797a 100644 --- a/packages/contract/src/auth.test-d.ts +++ b/packages/contract/src/auth.test-d.ts @@ -1,5 +1,6 @@ import { describe, test } from "vitest"; +import { authenticated } from "./auth.js"; import type { Authenticated, IsMarked, PrincipalKey, RequirementsOf } from "./auth.js"; type Fragment = { readonly place: { readonly kind: "procedure" } }; @@ -65,3 +66,21 @@ describe("Authenticated carries the contract's own keys plus the phantom one", ( void isNever; }); }); + +describe("a requirement names exactly one scheme", () => { + test("one scheme is marked, and so is a second requirement beside it", () => { + const node = { place: { kind: "procedure" } } as const; + const marked = authenticated({ user: ["orders:export"] }, { service: [] })(node); + const requirements: readonly [ + { readonly user: readonly ["orders:export"] }, + { readonly service: readonly [] }, + ] = null as unknown as RequirementsOf; + void requirements; + }); + + test("two schemes in ONE requirement are refused", () => { + const node = { place: { kind: "procedure" } } as const; + // @ts-expect-error — OpenAPI reads this as AND; `@btravstack/http` would run it as OR + void authenticated({ user: [], service: [] })(node); + }); +}); diff --git a/packages/contract/src/auth.ts b/packages/contract/src/auth.ts index b0e39a54..ecd9c625 100644 --- a/packages/contract/src/auth.ts +++ b/packages/contract/src/auth.ts @@ -13,9 +13,27 @@ declare const PRINCIPAL: unique symbol; * A requirement names ONE scheme — this package does not model OpenAPI's * AND-within-a-requirement, which would put a record rather than an identity * on the handler. See the design spec. + * + * This type is the CARRIER — what a marked node holds and `isAuthenticated` + * reads back — so it says nothing about arity. `OneScheme` below is what + * refuses a second key where one is written. */ export type Requirement = Readonly>; +// Distributes over the key union, then asks whether the whole union is +// assignable back into the member being visited: `false` for one key, `true` +// for several. The standard union test; do not "simplify" it to `K extends U`. +type SeveralKeys = K extends U ? ([U] extends [K] ? false : true) : never; + +/** + * A requirement naming two schemes is OpenAPI's AND — both credentials must be + * presented — and nothing here models it: `@btravstack/http` walks the entries + * and takes the first that satisfies, which is OR, so a two-key requirement + * copied out of an OpenAPI document would silently execute as a WEAKER rule + * than the one it states. Refused at the mark instead. + */ +type OneScheme = SeveralKeys extends false ? Q : never; + /** Requirements are ORed, in order: the first one a caller satisfies wins. */ export type Requirements = readonly Requirement[]; @@ -61,7 +79,9 @@ const marked = (store[KEY] ??= new WeakMap()); * inside one. See `packages/contract/CLAUDE.md`. */ export const authenticated = - (...requirements: R) => + }>( + ...requirements: R + ) => (node: T): Authenticated => { marked.set(node, requirements); return node as Authenticated; diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 011ab1da..1e2a0cad 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -231,7 +231,8 @@ InstanceType> & { readonly port: PortClassOf()({ name: Dep }, { sync })` — or `({ sync })`, the common shape, since an authenticator reading only headers declares no dependencies — plus `authenticatorPort(scheme)`, - `Unauthenticated`, `Granted`, `AuthenticatorService`** + `Unauthenticated`, `granted(identity, scopes)`, `Grant`, + `Granted`, `AuthenticatorService`** (`auth.ts`) — how one **security scheme** is implemented. `AuthenticatorService` is `(headers: IncomingHttpHeaders) => AsyncResult, Unauthenticated>` — @@ -239,9 +240,21 @@ InstanceType> & { readonly port: PortClassOf` is `P` when `Scope` is `never` — a scheme with no scope vocabulary returns the identity bare, byte-for-byte what applications wrote - before — and `{ identity: P; scopes: readonly Scope[] }` when it has one, so + before — and `Grant` when it has one, so the granted list is checked against the declared vocabulary at the authenticator rather than compared as loose strings at the endpoint. + **`Grant` is BRANDED with a module-private `unique symbol` and `granted()` + is the only thing that mints one**, which makes the helper mandatory rather + than advisory: a hand-built `{ identity, scopes }` does not type-check as the + scoped answer. The type parameter is erased at runtime, so a structural test + is the alternative and is unsound — `"scopes" in answer` reads a + claims-shaped BARE identity (`{ userId, tenantId, scopes }`, the ordinary JWT + case) as the scoped one, injects its absent `identity`, and hands every + handler on that route `undefined`. `Symbol.for` rather than `Symbol()`: two + copies of this package would otherwise read each other's grants as bare. + `Scope` is not inferred from the vocabulary on `granted` itself — an empty + grant would collapse it to `never` and take the return type back to the bare + arm — so the array states it and the assignment checks it. `authenticatorPort(scheme)` mints ``Port(`HttpAuthenticator:${scheme}`)>`` — the move `AmqpHandler(contract, key)` makes, with the scheme name on the port diff --git a/packages/http/README.md b/packages/http/README.md index b3511080..9ee0d79e 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -189,6 +189,7 @@ import { HttpAuthenticator, Unauthenticated, defineHttp, + granted, } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; @@ -214,15 +215,20 @@ const userAuth = HttpAuthenticator()({ userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ - identity: { tenantId, userId }, - scopes: rest - .join(":") - .split(",") - .filter( - (scope): scope is "orders:export" => scope === "orders:export", - ), - }); + : OkAsync( + // `granted()` is mandatory, not advisory: the brand it stamps is the + // only sound way the starter tells a scoped answer from an identity + // that merely carries a `scopes` field. + granted( + { tenantId, userId }, + rest + .join(":") + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + ), + ); }, }); diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 37df11be..da005d89 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -1,7 +1,7 @@ import { ErrAsync, OkAsync } from "unthrown"; import { describe, expect } from "vitest"; -import { Unauthenticated, principalMiddleware } from "./auth.js"; +import { Unauthenticated, granted, principalMiddleware } from "./auth.js"; import { it } from "./test-fixtures.js"; describe("an authenticated procedure", () => { @@ -173,8 +173,7 @@ describe("a leaf naming several requirements", () => { }) => { // GIVEN an endpoint requiring a scope this credential does grant const middleware = principalMiddleware([{ user: ["orders:export"] }], { - user: () => - OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:read", "orders:export"] }), + user: () => OkAsync(granted({ userId: "u-1" }, ["orders:read", "orders:export"])), }); // WHEN a request arrives @@ -188,6 +187,28 @@ describe("a leaf naming several requirements", () => { await expect(injected).resolves.toEqual({ userId: "u-1" }); }); + it("injects a bare identity carrying a `scopes` field whole", async ({ headers }) => { + // GIVEN a scheme declared with NO vocabulary whose identity happens to + // carry claims-shaped scopes — the ordinary JWT shape + const middleware = principalMiddleware([{ user: [] }], { + user: () => OkAsync({ userId: "u-1", tenantId: "t-1", scopes: ["a"] }), + }); + + // WHEN a request arrives + const injected = middleware({ + context: { request: { headers } as never }, + next: (o) => Promise.resolve(o.context.principal), + }); + + // THEN the whole identity reached the handler: reading the arm structurally + // took this for the scoped answer and injected its absent `identity` + await expect(injected).resolves.toEqual({ + userId: "u-1", + tenantId: "t-1", + scopes: ["a"], + }); + }); + it("refuses with FORBIDDEN when the scheme grants no scopes at all", async ({ headers }) => { // GIVEN a requirement naming a scope against a scheme declared with no // vocabulary, which answers the identity BARE @@ -230,7 +251,7 @@ describe("a leaf naming several requirements", () => { }) => { // GIVEN an endpoint requiring a scope the credential does not grant const middleware = principalMiddleware([{ user: ["orders:export"] }], { - user: () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:read"] }), + user: () => OkAsync(granted({ userId: "u-1" }, ["orders:read"])), }); // WHEN a request arrives diff --git a/packages/http/src/auth.test-d.ts b/packages/http/src/auth.test-d.ts index 53aa0efe..3683d040 100644 --- a/packages/http/src/auth.test-d.ts +++ b/packages/http/src/auth.test-d.ts @@ -10,7 +10,7 @@ import { oc } from "@orpc/contract"; import { ErrAsync, OkAsync } from "unthrown"; import { expectTypeOf } from "vitest"; -import { HttpAuthenticator, Unauthenticated } from "./auth.js"; +import { HttpAuthenticator, Unauthenticated, granted } from "./auth.js"; import { defineHttp } from "./define-http.js"; import { HttpModule } from "./http-module.js"; import type { HasMark, Implementation } from "./orpc.js"; @@ -222,9 +222,17 @@ const plain = HttpAuthenticator<{ readonly userId: string }>()({ sync: () => () => OkAsync({ userId: "u-1" }), }); -// A scheme with a scope vocabulary reports what the credential granted. +// A scheme with a scope vocabulary reports what the credential granted, through +// `granted()` — which is the only thing that mints the brand the middleware +// reads, so this is mandatory rather than advisory. const scoped = HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ - sync: () => () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:export"] }), + sync: () => () => OkAsync(granted({ userId: "u-1" }, ["orders:export"])), +}); + +// A grant of nothing is still a grant: the vocabulary types the array, so an +// empty one does not collapse `Scope` back to the bare arm. +HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ + sync: () => () => OkAsync(granted({ userId: "u-1" }, [])), }); // Negative: a scoped scheme may not return a bare identity. @@ -233,10 +241,18 @@ HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ sync: () => () => OkAsync({ userId: "u-1" }), }); +// Negative: nor a hand-built record. The brand is unforgeable from outside this +// package, so `{ identity, scopes }` is not the scoped answer — which is what +// stops a bare identity carrying `scopes` from being mistaken for one. +HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ + // @ts-expect-error -- the scoped answer comes from `granted()` + sync: () => () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:export"] }), +}); + // Negative: a scope outside the declared vocabulary is refused. HttpAuthenticator<{ readonly userId: string }, "orders:export">()({ // @ts-expect-error -- "orders:delete" is not in this scheme's vocabulary - sync: () => () => OkAsync({ identity: { userId: "u-1" }, scopes: ["orders:delete"] }), + sync: () => () => OkAsync(granted({ userId: "u-1" }, ["orders:delete"])), }); expectTypeOf(plain.principal).toEqualTypeOf<{ readonly userId: string }>(); diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 510e9d38..6e76a11a 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -13,15 +13,49 @@ import { TaggedError, type AsyncResult } from "unthrown"; */ export class Unauthenticated extends TaggedError("Unauthenticated") {} +// Module-private, so the mark cannot be applied by accident: `Grant` is the +// only shape carrying it and `granted` the only thing that mints one. The type +// parameter is erased at runtime, so structure is all the middleware has to go +// on — and `{ userId, tenantId, scopes }` is an ordinary JWT-claims identity, a +// BARE answer that a `"scopes" in granted` test read as the scoped one and +// destroyed. `Symbol.for` rather than `Symbol()`: two copies of this package +// would otherwise read each other's grants as bare. +const GRANT: unique symbol = Symbol.for("@btravstack/http/grant") as never; + +/** + * The scoped answer, and the reason `granted()` is mandatory rather than + * advisory: the brand is what tells it from an identity that merely happens to + * carry a `scopes` field. + */ +export type Grant = { + readonly identity: P; + readonly scopes: readonly Scope[]; + readonly [GRANT]: true; +}; + /** * What an authenticator hands back. A scheme with no scope vocabulary returns * the identity bare — byte-for-byte what applications write today — and one * with a vocabulary reports what the credential actually granted, so the * starter can compare it against what the endpoint declared. */ -export type Granted = [Scope] extends [never] - ? P - : { readonly identity: P; readonly scopes: readonly Scope[] }; +export type Granted = [Scope] extends [never] ? P : Grant; + +/** + * What a scoped scheme answers with: + * + * ```ts + * OkAsync(granted({ userId }, ["orders:export"])); + * ``` + * + * `Scope` is not inferred from the vocabulary — an empty grant would collapse + * it to `never` and take the return type back to the bare arm — so the array is + * what states it, checked against the vocabulary by the assignment. + */ +export const granted = ( + identity: P, + scopes: readonly Scope[], +): Grant => ({ identity, scopes, [GRANT]: true }); /** * Headers, not the request: an authenticator has no business reading a body, @@ -160,13 +194,14 @@ export const principalMiddleware = throw resolved.cause; } if (resolved.isErr()) continue; - // `Granted` is erased to `unknown` on the port, because a scheme with a - // vocabulary answers `{ identity, scopes }` and one without answers the - // identity bare — so which it is has to be read back structurally. - const granted = resolved.value; + // `Granted` is erased to `unknown` on the port, so which arm answered + // has to be read back at runtime. The BRAND is what says so — a + // structural `"scopes" in answer` test misreads a claims-shaped bare + // identity as the scoped answer and hands the handler `undefined`. + const answer = resolved.value; const scoped = - typeof granted === "object" && granted !== null && "scopes" in granted - ? (granted as { readonly identity: unknown; readonly scopes: readonly string[] }) + typeof answer === "object" && answer !== null && GRANT in answer + ? (answer as Grant) : undefined; // A requirement that names scopes is NOT satisfied by a credential // reporting none. A scheme declared without a vocabulary answers bare, @@ -178,7 +213,7 @@ export const principalMiddleware = underScoped = true; continue; } - const identity = scoped === undefined ? granted : scoped.identity; + const identity = scoped === undefined ? answer : scoped.identity; return await options.next({ context: { principal: tagged ? { scheme, identity } : identity }, }); diff --git a/packages/http/src/index.ts b/packages/http/src/index.ts index d9a0fee6..3c4ee057 100644 --- a/packages/http/src/index.ts +++ b/packages/http/src/index.ts @@ -1,5 +1,5 @@ -export { HttpAuthenticator, Unauthenticated, authenticatorPort } from "./auth.js"; -export type { Authenticator, AuthenticatorService, Granted } from "./auth.js"; +export { HttpAuthenticator, Unauthenticated, authenticatorPort, granted } from "./auth.js"; +export type { Authenticator, AuthenticatorService, Grant, Granted } from "./auth.js"; export { defineHttp } from "./define-http.js"; export type { Authenticators, Http, SchemesFrom } from "./define-http.js"; export { HttpModule } from "./http-module.js"; From d5bf0333f9201362e2f9fc5ab83e7365e591cdb9 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 00:02:49 +0200 Subject: [PATCH 16/18] docs: the sugar's expansion compiles, and no page imports a deleted export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects the final review found outside the diff the last sweep covered. The tutorial's step 4 imported `HttpRouter` from `@btravstack/http`, an export this branch deleted — a `TS2305` on the sample's first line. It goes through `defineHttp()`, the no-argument public-API call, like every other page. Compiled in a scratch file under `packages/http/src` before it landed. `serve-orpc-over-http.md` and `reference/http.md` spelled the "exactly the module the sugar builds" equivalence as `provides: [ordersRouter, userAuth]`. Those are `Authenticator<…>` descriptions, not providers; the expansion is `...router.authenticators`, which `examples/order-api.md` and the needs-gate type test already used. The branch stated it three ways and got it right once. `serve-orpc-over-http.md` also named a `userAuth` binding it never introduced, which the same edit removes. `open-a-per-request-scope.md`'s unit-gate arm claimed to isolate that gate while carrying a marked router with no authenticators, so it would have failed for two reasons. Spread them, as `needs-gate.test-d.ts` already does. Plus the ride-alongs: `tenant.ts` named `bearerAuthenticator`, deleted on this branch, and seven pages spelled the router and controller helpers bare where they now hang off `api`. --- docs/api/index.md | 4 ++-- docs/examples/index.md | 2 +- docs/explanation/compile-time-wiring.md | 2 +- docs/explanation/design-decisions.md | 2 +- docs/how-to/open-a-per-request-scope.md | 2 +- docs/how-to/serve-orpc-over-http.md | 2 +- docs/how-to/split-a-worker-into-slices.md | 2 +- docs/reference/di/modules.md | 2 +- docs/reference/di/ports.md | 2 +- docs/reference/di/providers.md | 2 +- docs/reference/http.md | 2 +- docs/tutorial/getting-started.md | 16 +++++++++++----- examples/order-domain/src/tenant.ts | 2 +- 13 files changed, 24 insertions(+), 18 deletions(-) diff --git a/docs/api/index.md b/docs/api/index.md index aa1f03c2..376e715a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -38,7 +38,7 @@ harness, the observability starter and the three transport starters on top of `Attributes`, `Line`, `Sink`, `ObservabilityOptions`. The `@btravstack/observability/pino` subpath carries `pinoSink` alone, so `pino` stays an optional peer. -- **[`@btravstack/http`](/api/http/)** — `HttpModule`, `HttpRouter`, `http`, +- **[`@btravstack/http`](/api/http/)** — `HttpModule`, `defineHttp`, `http`, the ports `HttpRuntime` and `HttpConfig`, and the types `HttpModuleOptions`, `HttpOptions`, `HttpInfo`. - **[`@btravstack/temporal`](/api/temporal/)** — `TemporalModule`, @@ -68,7 +68,7 @@ because operations hang off the values by convention — import { Module, Port, Provider } from "@btravstack/di"; import { Config, Env } from "@btravstack/config"; import { runMain, start } from "@btravstack/core"; -import { HttpModule, HttpRouter } from "@btravstack/http"; +import { HttpModule, defineHttp } from "@btravstack/http"; ``` `Scope` is a **type-only** export of `di`, and `PortClass`, diff --git a/docs/examples/index.md b/docs/examples/index.md index 1701157e..86df56da 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -120,7 +120,7 @@ two kinds of type test that keep the arrows pointing the right way. ### [Order API (HTTP)](/examples/order-api) -`HttpRouter(contract)` — every procedure a plain +`api.HttpRouter(contract)` — every procedure a plain `Result`-returning function and one exhaustive `mapErrCases` where a domain `Err` becomes a typed `ORPCError`; `HttpModule("OrderApi")` as the whole composition root; `RequestModule` forked per request through diff --git a/docs/explanation/compile-time-wiring.md b/docs/explanation/compile-time-wiring.md index 02e5aca0..4da4e370 100644 --- a/docs/explanation/compile-time-wiring.md +++ b/docs/explanation/compile-time-wiring.md @@ -284,7 +284,7 @@ ends on, which is the payload of the whole message. | `start`'s `StartGate` — `UNSATISFIED RUNTIME PORTS` | a runtime's `resolves` uncovered by the module's exports | `Expected 4 arguments, but got 1.` | ends on `"UNSATISFIED RUNTIME PORTS — the runtime resolves a port the module does not export"` | | `start`'s `StartGate` — `UNSATISFIED UNIT NEEDS` | a unit module's needs uncovered | `Expected 4 arguments, but got 2.` | ends on `"UNSATISFIED UNIT NEEDS — the unit module needs a port the module does not export"` | | amqp's/temporal's composer — `UNCOVERED HANDLERS`/`UNCOVERED ACTIVITIES` | `AmqpHandlers(contract)([...])` / `TemporalActivities(contract)([...])` missing a key | ends on `'"UNCOVERED HANDLERS"'` / `'"UNCOVERED ACTIVITIES"'` | ends on `'"UNCOVERED HANDLERS — the contract declares a consumer this array does not cover"'` / the `ACTIVITIES` twin; the missing key prints too, as a separate diagnostic on the trailing element, once the array is as long as the marker tuple (measured: `'"orderAudit"'`, `'"fulfillOrder"'`) | -| http's keyed router — `UNDECLARED KEY` | `HttpRouter(contract)(controllers)` with a key the contract does not declare | ends on `'never'` | ends on `'"UNDECLARED KEY — the contract declares no fragment under billing"'` — the key is named too, straight from the mapped type's own `K` | +| http's keyed router — `UNDECLARED KEY` | `api.HttpRouter(contract)(controllers)` with a key the contract does not declare | ends on `'never'` | ends on `'"UNDECLARED KEY — the contract declares no fragment under billing"'` — the key is named too, straight from the mapped type's own `K` | No gate's behaviour moved: the same 82 `@ts-expect-error` directives fire after this branch as before it — none added or removed, and none now guards a diff --git a/docs/explanation/design-decisions.md b/docs/explanation/design-decisions.md index f59c814a..bc281b28 100644 --- a/docs/explanation/design-decisions.md +++ b/docs/explanation/design-decisions.md @@ -133,7 +133,7 @@ router" option: oRPC is the one way, and the listener port is internal. ## The starter sugars name nothing -`HttpRouter(contract)(deps, arm)`, `TemporalActivities(contract)(deps, arm)` +`api.HttpRouter(contract)(deps, arm)`, `TemporalActivities(contract)(deps, arm)` and `AmqpHandlers(contract)(deps, arm)` take no port name: each returns di's `Provider(port)` on a port the starter owns and declares once — `Port("HttpRouter")`, `Port("TemporalActivities")`, `Port("AmqpHandlers")` — diff --git a/docs/how-to/open-a-per-request-scope.md b/docs/how-to/open-a-per-request-scope.md index b65765cb..ede8492b 100644 --- a/docs/how-to/open-a-per-request-scope.md +++ b/docs/how-to/open-a-per-request-scope.md @@ -137,7 +137,7 @@ const UnloggedApi = Module("UnloggedApi")({ observability(), http(), ], - provides: [orderRouter], + provides: [orderRouter, ...orderRouter.authenticators], exports: [HttpRuntime], }); diff --git a/docs/how-to/serve-orpc-over-http.md b/docs/how-to/serve-orpc-over-http.md index c02e6394..e0b17fc4 100644 --- a/docs/how-to/serve-orpc-over-http.md +++ b/docs/how-to/serve-orpc-over-http.md @@ -205,7 +205,7 @@ Module("OrdersApi")({ observability(), http(), ], - provides: [ordersRouter, userAuth], + provides: [ordersRouter, ...ordersRouter.authenticators], exports: [HttpRuntime, Logger], }); ``` diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index b8383033..7abec9f6 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -26,7 +26,7 @@ from `examples/order-amqp-worker` (two subscriber slices) and [Split a router into controllers](/how-to/split-a-router-into-controllers) starts from a contract that is already nested — `{ orders: {...}, customers: {...} }` — so a slice's fragment is a sub-object and the root composes a -**record**, one controller per top-level key, with `HttpRouter(contract)({ +**record**, one controller per top-level key, with `api.HttpRouter(contract)({ orders: ordersController, customers: customersController })`. An `amqp-contract` or `temporal-contract` contract has no such nesting: its consumers and its workflows are already flat top-level keys of one contract, diff --git a/docs/reference/di/modules.md b/docs/reference/di/modules.md index 71dd9f3c..271a17b8 100644 --- a/docs/reference/di/modules.md +++ b/docs/reference/di/modules.md @@ -44,7 +44,7 @@ All four lists are optional and default to empty. Exporting a provider means exactly what exporting its port class means — same `Exports` channel, same gates — and it is the only spelling available when the port was minted inside a helper (`Config.provider("RelayConfig")(schema)`, -`HttpController(name, fragment)`), where there is no class to name: +`api.HttpController(name, fragment)`), where there is no class to name: ```ts exports: [Logger, ordersController], // a port class and a provider, together diff --git a/docs/reference/di/ports.md b/docs/reference/di/ports.md index b9096ecb..a1c5a7b3 100644 --- a/docs/reference/di/ports.md +++ b/docs/reference/di/ports.md @@ -100,7 +100,7 @@ The class expression `class extends Port(id) {}` has an anonymous type that declaration emit cannot name across packages; `PortClassOf` is its nameable spelling. This is what `Config.provider("Name")(schema)` returns as the type of `provider.port`, and how the starters spell their own fixed ports — -`HttpRouter(contract)(…)` returns `PortClassOf<"HttpRouter", …>`, +`api.HttpRouter(contract)(…)` returns `PortClassOf<"HttpRouter", …>`, `TemporalActivities` / `AmqpHandlers` a `PortClassOf<"TemporalActivities", …>` / `PortClassOf<"AmqpHandlers", …>` typed for the contract — and what a consumer that **exports** such a provider needs so its own `.d.ts` can be diff --git a/docs/reference/di/providers.md b/docs/reference/di/providers.md index e5ebaf5e..48a710ad 100644 --- a/docs/reference/di/providers.md +++ b/docs/reference/di/providers.md @@ -99,7 +99,7 @@ What `Provider(port)(…)` returns is `Provider & { readonly port: P }` — the port class, typed, rides on the provider. It exists for the helpers that hand back a provider on a port the application never declared — `Config.provider("Name")(schema)`, which mints one; a starter's -`HttpRouter(contract)({ name: Dep }, arm)` / `TemporalActivities(…)` / +`api.HttpRouter(contract)({ name: Dep }, arm)` / `TemporalActivities(…)` / `AmqpHandlers(…)`, which target the starter's own fixed port — so the application holds one value and reads the port off it: `provider.port` is what another provider lists in its `deps`, what a module lists in `exports`, diff --git a/docs/reference/http.md b/docs/reference/http.md index f44073ee..6f1c482a 100644 --- a/docs/reference/http.md +++ b/docs/reference/http.md @@ -89,7 +89,7 @@ export const OrderApi = HttpModule("OrderApi")({ ``` That is exactly the module -`Module("OrderApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], provides: [orderRouter, userAuth, serviceAuth], exports: [HttpRuntime, Logger] })` +`Module("OrderApi")({ imports: [OrdersSlice, CustomersSlice, observability(), http()], provides: [orderRouter, ...orderRouter.authenticators], exports: [HttpRuntime, Logger] })` would have declared. **There is no `authenticator` option**: the authenticators ride the router — which is what needs them — and the sugar spreads them into `provides` itself, so an application never lists one and diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 16e102f8..6d26a373 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -94,19 +94,25 @@ service without the server's code — which is why it is its own file. ## Step 4 — Implement the contract as a router The router is a provider like any other: it declares the services its -procedures call, and di builds it from them. `HttpRouter(contract)` types the -implementation from the contract — a typo'd key or a wrong output is a compile -error here: +procedures call, and di builds it from them. Every HTTP entity comes from +**one** `defineHttp` call — the door where an application declares its +security schemes; this service is public, so it takes no argument. Then +`api.HttpRouter(contract)` types the implementation from the contract — a +typo'd key or a wrong output is a compile error here: ```ts // router.ts -import { HttpRouter } from "@btravstack/http"; +import { defineHttp } from "@btravstack/http"; import { OkAsync } from "unthrown"; import { contract } from "./contract.js"; import { Greeter } from "./greeter.js"; -export const greetingRouter = HttpRouter(contract)( +// Held whole and never destructured: each destructured member expands to a +// type mentioning an inaccessible `unique symbol` (TS2527). +const api = defineHttp(); + +export const greetingRouter = api.HttpRouter(contract)( { greeter: Greeter }, { sync: ({ greeter }) => ({ diff --git a/examples/order-domain/src/tenant.ts b/examples/order-domain/src/tenant.ts index 8cfa199c..acee7c25 100644 --- a/examples/order-domain/src/tenant.ts +++ b/examples/order-domain/src/tenant.ts @@ -17,7 +17,7 @@ import { z } from "zod"; * Deployment configuration the operator wrote — `OUTBOX_TENANTS` — is * trusted rather than validated at all: nothing upstream of it checks the * shape. The HTTP-marked path is a third case, and neither of the above: its - * stand-in `bearerAuthenticator` checks only that the token's tenant segment + * stand-in `userAuth` checks only that the token's tenant segment * is non-empty before casting, so this boundary *vouches* for the value * rather than validating it — a real deployment swaps it for verification * that does. A brand is a compile-time fiction: nothing is asked of a caller From ef171a88b3f908294a6e158eedc9ac22473c94aa Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 00:35:54 +0200 Subject: [PATCH 17/18] fix(docs): the authenticator sample was ungated, and it had drifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pages still built a scoped answer as a plain `{ identity, scopes }` object after `granted()` became mandatory. It does not type-check — auth.test-d.ts pins that exact literal as a negative — and casting past it is worse than the compile error: principalMiddleware reads an unbranded object as a BARE identity, so scopesGranted is empty and every caller on a scoped route is refused forever. One of the pages also shadowed `granted` with a local of the same name. The reason both drifted is that docs-examples.test-d.ts covered the controllers, the router and the module root but never the authenticator, so nothing compiled the half that changed. It does now, which is this repo's own stated trigger: add the gate the next time a sample is found to have drifted. Also: the prose on protect-a-procedure still described the old hand-built shape, and the spec count reached 50 when the fail-open fix added a test. --- CLAUDE.md | 2 +- docs/examples/order-api.md | 21 ++++++----- docs/how-to/protect-a-procedure.md | 32 ++++++++++------ .../order-api/src/docs-examples.test-d.ts | 37 ++++++++++++++++++- packages/http/CLAUDE.md | 2 +- 5 files changed, 69 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0dfad117..1e8cea92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -482,7 +482,7 @@ type checker already verifies. `tsconfig.test-d.json` or `test:types` script, before it. `packages/http/src/controller.test-d.ts` pins the five compile-time gates the keyed `HttpRouter(contract)(controllers)` form - owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 49 specs, across + owes (see `packages/http/CLAUDE.md`). `@btravstack/http`'s 50 specs, across `http-runtime.spec.ts`, `orpc.spec.ts`, `controller.spec.ts` and `auth.spec.ts`, drive the transport through the internal `httpModule` with a bare listener, the diff --git a/docs/examples/order-api.md b/docs/examples/order-api.md index cd5b1937..d9cc10d5 100644 --- a/docs/examples/order-api.md +++ b/docs/examples/order-api.md @@ -136,6 +136,7 @@ import { HttpAuthenticator, Unauthenticated, defineHttp, + granted, } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; @@ -157,20 +158,22 @@ export const userAuth = HttpAuthenticator()({ const [tenantId, userId, ...rest] = token.split(":"); // Rejoined rather than taken as one field: a scope name contains the // delimiter itself, so `orders:export` cannot survive a plain third field. - const granted = rest.join(":"); + const claimed = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ - identity: { tenantId: TenantId(tenantId), userId }, - scopes: granted - .split(",") - .filter( - (scope): scope is "orders:export" => scope === "orders:export", - ), - }); + : OkAsync( + granted( + { tenantId: TenantId(tenantId), userId }, + claimed + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + ), + ); }, }); diff --git a/docs/how-to/protect-a-procedure.md b/docs/how-to/protect-a-procedure.md index 4d75a824..32fc694e 100644 --- a/docs/how-to/protect-a-procedure.md +++ b/docs/how-to/protect-a-procedure.md @@ -119,6 +119,7 @@ import { HttpAuthenticator, Unauthenticated, defineHttp, + granted, } from "@btravstack/http"; import { ErrAsync, OkAsync } from "unthrown"; @@ -137,20 +138,22 @@ export const userAuth = HttpAuthenticator()({ const [tenantId, userId, ...rest] = token.split(":"); // Rejoined rather than taken as one field: a scope name contains the // delimiter itself, so `orders:export` cannot survive a plain third field. - const granted = rest.join(":"); + const claimed = rest.join(":"); return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" ? ErrAsync(new Unauthenticated()) - : OkAsync({ - identity: { tenantId: TenantId(tenantId), userId }, - scopes: granted - .split(",") - .filter( - (scope): scope is "orders:export" => scope === "orders:export", - ), - }); + : OkAsync( + granted( + { tenantId: TenantId(tenantId), userId }, + claimed + .split(",") + .filter( + (scope): scope is "orders:export" => scope === "orders:export", + ), + ), + ); }, }); @@ -169,9 +172,14 @@ export const api = defineHttp({ }); ``` -A scheme **with** a scope vocabulary answers `{ identity, scopes }`, so the -granted list is checked against the declared vocabulary here rather than -compared as loose strings at the endpoint. A scheme **without** one answers the +A scheme **with** a scope vocabulary answers `granted(identity, scopes)` — the +helper is mandatory, not advisory, because it stamps a symbol the middleware +tests for. A hand-built `{ identity, scopes }` does not type-check, and the +reason it may not is worth knowing: the `Scope` type parameter is erased at run +time, so deciding bare-from-scoped structurally would misread any identity that +happens to carry a `scopes` claim of its own. The granted list is checked +against the declared vocabulary here rather than compared as loose strings at +the endpoint. A scheme **without** one answers the identity bare — which is exactly what a handler under a single unscoped scheme then reads. diff --git a/examples/order-api/src/docs-examples.test-d.ts b/examples/order-api/src/docs-examples.test-d.ts index d9930433..c23d0f6b 100644 --- a/examples/order-api/src/docs-examples.test-d.ts +++ b/examples/order-api/src/docs-examples.test-d.ts @@ -36,9 +36,9 @@ import { CustomerPersistenceModule, OrderPersistenceModule, } from "@btravstack/example-order-infrastructure"; -import { HttpModule } from "@btravstack/http"; +import { HttpAuthenticator, HttpModule, Unauthenticated, granted } from "@btravstack/http"; import { Logger, observability } from "@btravstack/observability"; -import { OkAsync, P } from "unthrown"; +import { ErrAsync, OkAsync, P } from "unthrown"; import { api } from "./auth.js"; @@ -240,3 +240,36 @@ const _DocsDepsApi = HttpModule("DocsDepsApi")({ imports: [OrderApplicationModule, OrderPersistenceModule, observability()], exports: [Logger], }); + +// --------------------------------------------------------------------------- +// "Declare the schemes" — docs/how-to/protect-a-procedure.md, and "One file +// per application" — docs/examples/order-api.md. +// +// The authenticator half of those pages was ungated until it drifted: both +// showed a scoped scheme answering a hand-built `{ identity, scopes }`, which +// does not type-check — and, cast past, is read as a BARE identity, so every +// caller on a scoped route is refused forever. Pinned here so the next reader +// of those pages is reading something that compiles. +// --------------------------------------------------------------------------- + +const _docsUserAuth = HttpAuthenticator< + { readonly tenantId: TenantId; readonly userId: string }, + "orders:export" +>()({ + sync: () => (headers) => { + const header = headers.authorization ?? ""; + const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : ""; + const [tenantId, userId, ...rest] = token.split(":"); + const claimed = rest.join(":"); + return tenantId === undefined || tenantId === "" || userId === undefined || userId === "" + ? ErrAsync(new Unauthenticated()) + : OkAsync( + granted( + { tenantId: TenantId(tenantId), userId }, + claimed + .split(",") + .filter((scope): scope is "orders:export" => scope === "orders:export"), + ), + ); + }, +}); diff --git a/packages/http/CLAUDE.md b/packages/http/CLAUDE.md index 1e2a0cad..70147e1f 100644 --- a/packages/http/CLAUDE.md +++ b/packages/http/CLAUDE.md @@ -581,7 +581,7 @@ prefix })`, unmatched → resolves unwritten), and the `HttpRuntime` provider de `httpModule(socket, orpc({ prefix }))`; the package's own transport specs hand it a bare listener instead. It exists for that second reason only. `httpRuntime`, the runtime value's factory, is internal too. -- **49 specs, 100% lines/functions.** Every app boots through the `boot` +- **50 specs, 100% lines/functions.** Every app boots through the `boot` fixture — `@btravstack/testing`'s `bootFixture()`, which `serve`, `rpc`, `configured` and `appOnPort` depend on — so it is stopped when the test ends, on every exit path, and the teardown is Defect-only: a startup From 74c13f1d361f2f2783b79cabf04f1852a73b07b2 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 11:30:26 +0200 Subject: [PATCH 18/18] test(contract): the registry is a WeakMap, and the cast now says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec reached the shared registry through a `Map` cast while the implementation stores a `WeakMap`. It passed because both carry `.get`, but a cast that misdescribes what it points at is a trap for the next edit — and this one sits on the fail-closed property two copies of the package depend on. --- packages/contract/src/auth.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contract/src/auth.spec.ts b/packages/contract/src/auth.spec.ts index 0fcb860c..c8605b1e 100644 --- a/packages/contract/src/auth.spec.ts +++ b/packages/contract/src/auth.spec.ts @@ -41,7 +41,7 @@ describe("authenticated", () => { it("registers the mark where a second copy of this package would find it", ({ fragment }) => { // GIVEN the registry as any other copy of this package would reach it - const registry = (globalThis as Record | undefined>)[ + const registry = (globalThis as Record | undefined>)[ Symbol.for("@btravstack/contract/requirements") ]; // WHEN a node is marked