diff --git a/.changeset/ponytail-trims.md b/.changeset/ponytail-trims.md new file mode 100644 index 0000000..1ef3899 --- /dev/null +++ b/.changeset/ponytail-trims.md @@ -0,0 +1,26 @@ +--- +"@btravstack/contract": minor +"@btravstack/di": minor +"@btravstack/config": minor +"@btravstack/core": minor +"@btravstack/testing": minor +"@btravstack/observability": minor +"@btravstack/http": minor +"@btravstack/temporal": minor +"@btravstack/amqp": minor +--- + +The `Unmet` type is gone from `@btravstack/di` + +Its documented purpose — a shaped module re-declaring the gates with it — was +impossible to serve: declaration emit keeps the alias unreduced, and the +unreduced form names imported modules' internal ports (TS2883 on the first +consumer that exports a composition root), which is why every in-repo sugar +already inlined the computation instead. Inline it; `NeedsGate` is unchanged +and still exported. + +Internal trims alongside, none of them surface: `@btravstack/http` no longer +memoises scheme ports (di resolves by id, so a fresh class per call is the same +lookup — measured), and `HasMark`, `authenticatorPort` and `Http.authenticators` +now carry TSDoc naming the external consumer each exists for, so their lack of +an in-repo caller stops reading as dead surface. diff --git a/packages/di/CLAUDE.md b/packages/di/CLAUDE.md index f54f2e7..8084eae 100644 --- a/packages/di/CLAUDE.md +++ b/packages/di/CLAUDE.md @@ -115,7 +115,7 @@ missing: N]`. What that **prints** is the arity line alone — private). `Provider(port)({ name: Dep }, arm)`'s return type is `Provider & { readonly port: typeof port }` — the provider carries its port class typed, so `provider.port` is what a dependent lists in its deps; purely additive. `AnyModule`, `AnyProvider`, - `Exportable`, **`NeedsGate` and `Unmet`** are exported so a package offering a **shaped module** (a + `Exportable` and **`NeedsGate`** are exported so a package offering a **shaped module** (a starter's `HttpModule(name)({ router, imports, provides, exports })` sugar, which appends its own import and export to what the application wrote) can constrain its `imports`/`provides`/`exports` the way `Module(name)` does and @@ -240,8 +240,10 @@ broke something: reason: declaration emit keeps a named alias unreduced, and the unreduced form names the imported modules' internal ports — TS2883/TS4023 on the first consumer that exports a composition root (`OrderApi` "cannot be named - without a reference to 'OrderDatabase'"). `Unmet` is exported for the - starters' sugars and used only inside parameter types. + without a reference to 'OrderDatabase'"). The same wall is why there is no + exported `Unmet` helper: a shaped module could not name it in a return type + either, so the computation is inlined wherever it appears — there was such an + export once, and its documented purpose was impossible to serve. The channel itself is unchanged: `Needs` is still what the module genuinely owes, computed, not what it declared. Declaring a port nothing owes is inert — @@ -263,7 +265,7 @@ of this gate noisy. ### The gate cannot be computed generically — and that is why the casts exist -`Unmet` over a generic tuple `I` is a deferred conditional, and no object +The unmet-needs computation over a generic tuple `I` is a deferred conditional, and no object literal satisfies one. So a **generic wrapper around `Module(name)`** — the three starter sugars, `start`'s `Env` wrapper, `@btravstack/testing`'s `tapped`, a factory like `makeAppModule` — cannot satisfy the gate at its own diff --git a/packages/di/src/index.ts b/packages/di/src/index.ts index c331767..4eb4ccc 100644 --- a/packages/di/src/index.ts +++ b/packages/di/src/index.ts @@ -51,5 +51,5 @@ export { Module } from "./module.js"; // through a named generic alias was tried and removed: declaration emit keeps // such an alias unreduced and cannot name imported modules' internal ports — // TS2883.) -export type { AnyModule, AnyProvider, Exportable, NeedsGate, Unmet } from "./module.js"; +export type { AnyModule, AnyProvider, Exportable, NeedsGate } from "./module.js"; export type { ScopedOptions } from "./build.js"; diff --git a/packages/di/src/module.ts b/packages/di/src/module.ts index b4a15da..579f316 100644 --- a/packages/di/src/module.ts +++ b/packages/di/src/module.ts @@ -160,20 +160,6 @@ type ResolvedExports = : never) | ExportsOfModule>; -/** - * What the module still owes: its providers' dependencies and its imports' - * own needs, minus everything visible to it. This is the `Needs` CHANNEL — - * everything outstanding, however it got there — and is deliberately wider - * than what `NeedsGate` makes a module declare, which is its own providers' - * half alone. Exported because a shaped module — a starter's - * `HttpModule(name)({...})` — re-declares this package's gates over its own - * augmented tuples, the way it already re-declares `Exportable`. - */ -export type Unmet = Exclude< - NeedOf | NeedsOfModule, - Available ->; - /** * The declaration gate. A port **this module's own providers** read, and that * nothing here satisfies, is an error unless it is named in `needs` — so a @@ -254,12 +240,12 @@ function ModuleDeclaration(name: Name) { readonly exports?: X; readonly needs?: N; } & NeedsGate, - // Inline, NOT `Unmet`: declaration emit keeps a named alias + // Inline, never a named alias: declaration emit keeps an alias here // unreduced, and the unreduced form names the imported modules' internal // ports — TS2883/TS4023 on the first consumer that exports a composition // root (measured: `OrderApi` "cannot be named without a reference to - // 'OrderDatabase'"). `Unmet` is the same computation, used only where it - // stays inside a parameter type. + // 'OrderDatabase'"). The same wall is why no `Unmet` helper is exported: + // a shaped module cannot use one either, and the in-repo sugars inline. ): Module< ResolvedExports, ErrOf | ErrOfModule, diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index da005d8..1157eed 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -65,12 +65,27 @@ describe("an authenticated procedure", () => { }); }); +describe("substituting one scheme's authenticator", () => { + it("serves a caller the real table would refuse, without building the verifier", async ({ + rpcSubstituted, + }) => { + // GIVEN a hand-rolled composition providing a stub on the scheme's own + // port — recomposition, not a second registry: the TokenTable-backed + // authenticator is not in this graph at all + const client = rpcSubstituted("not-in-any-table"); + + // WHEN a marked procedure is called with a token only the stub accepts + // THEN the stub named the caller + await expect(client.orders.whoami({ id: "o-1" })).resolves.toEqual({ userId: "u-stub" }); + }); +}); + 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"); + const client = rpcVerified("keyed"); // WHEN a marked procedure is called // THEN the authenticator resolved it through the dependency di gave it — diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 6e76a11..cf56491 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -80,14 +80,26 @@ const ports = new Map(); * 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. + * + * Exported for the consumer `defineHttp` does not cover: a test composition + * substituting ONE scheme's authenticator provides its own on this port — + * `Provider(authenticatorPort("user"))({ value: stub })` — instead of minting + * a second registry. `test-fixtures.ts`'s `rpcSubstitutedAppOf` is that story + * exercised: the stub composition serves a caller the real token table would + * refuse, and never builds the verifier at all. */ 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. + // Memoised — but not for resolution: di identifies a port by its `portId` + // string and the instance type is branded by the id literal, so two classes + // under one id ARE the same type and the same lookup, and the suite passes + // with a fresh class per call (measured). What a second `Port(id)` call DOES + // cost is di's dev-time duplicate-id warning, once per scheme, in every + // consumer's terminal (measured on the built package) — and `defineHttp` + // binding plus `routerFor` depending is the designed two-call pattern, not a + // declaration bug the warning exists to catch. 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 diff --git a/packages/http/src/define-http.ts b/packages/http/src/define-http.ts index f93d6c8..903c3d9 100644 --- a/packages/http/src/define-http.ts +++ b/packages/http/src/define-http.ts @@ -43,6 +43,13 @@ export type Http = { readonly HttpRouter: ReturnType< typeof routerFor, SchemeProviders, VocabFrom> >; + /** + * The declarations as given, kept for a consumer this repo does not contain: + * a hand-rolled composition or a custom sugar reads the registry off these + * the way `defineHttp` itself does. No in-repo example will — `HttpModule` + * carries the bound providers on the router — and that is not evidence + * against the field: the examples are scenarios, not the library's one user. + */ readonly authenticators: A; }; @@ -66,9 +73,7 @@ export type Http = { export const defineHttp = >(options?: { readonly authenticators: A; }): Http => { - const declared = (options?.authenticators ?? {}) as Readonly< - Record> - >; + const declared: Authenticators = options?.authenticators ?? {}; const providers = Object.entries(declared).map(([scheme, authenticator]) => bind(scheme, authenticator), ); diff --git a/packages/http/src/orpc.ts b/packages/http/src/orpc.ts index 169b314..5badf3f 100644 --- a/packages/http/src/orpc.ts +++ b/packages/http/src/orpc.ts @@ -432,7 +432,11 @@ type SchemePortsOf = /** * Whether the contract marks anything, anywhere — a yes/no, not a type, since - * the contract names no principal. + * the contract names no principal. Nothing inside this package consumes it any + * more (`SchemePortsOf` replaced it on the router's overloads); it stays + * exported for the consumer a library must assume: tooling over a contract — + * an OpenAPI generator deciding whether to emit `security` at all is the + * canonical reader. */ export type HasMark = IsMarked extends true diff --git a/packages/http/src/principal.ts b/packages/http/src/principal.ts index f8eacc2..54881d8 100644 --- a/packages/http/src/principal.ts +++ b/packages/http/src/principal.ts @@ -18,8 +18,8 @@ export type IsUnion = [T] extends [never] : 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 } +export type Tagged = S extends S + ? { readonly scheme: S; readonly identity: Schemes[S] } : never; /** @@ -29,8 +29,8 @@ export type Tagged = S extends S */ export type Principal = [S] extends [never] ? never - : IsUnion extends true - ? Tagged - : S extends keyof Schemes - ? Schemes[S] - : never; + : [S] extends [keyof Schemes] + ? IsUnion extends true + ? Tagged + : Schemes[S & keyof Schemes] + : never; diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 144f4fb..5a0391f 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -37,13 +37,14 @@ import { CORSHandlerPlugin } from "@orpc/server/plugins"; import { ErrAsync, OkAsync, fromSafePromise } from "unthrown"; import { test } from "vitest"; -import { HttpAuthenticator, Unauthenticated } from "./auth.js"; +import { HttpAuthenticator, Unauthenticated, authenticatorPort } from "./auth.js"; import { defineHttp } from "./define-http.js"; import { HttpHandler } from "./handler.js"; import { HttpModule } from "./http-module.js"; import { HttpConfig, HttpRuntime, + http, httpModule, type HttpInfo, type HttpOptions, @@ -329,6 +330,27 @@ const rpcVerifiedAppOf = () => ], }); +/** + * The substitution seam `authenticatorPort` exists for: a hand-rolled + * composition provides its OWN authenticator on the scheme's port and never + * spreads `router.authenticators` — recomposition, this repo's stated way to + * swap an adapter, not a second `defineHttp` registry and not a provider + * layered over one (di refuses two providers for one port). The real, + * `TokenTable`-backed authenticator is not in this graph at all, which is the + * point: the stub composition never builds the verifier. + */ +const rpcSubstitutedAppOf = () => + Module("RpcSubstitutedApp")({ + imports: [http({ port: 0, hostname: "127.0.0.1" })], + provides: [ + verifiedRouter, + Provider(authenticatorPort("user"))({ + value: () => OkAsync({ userId: "u-stub" }), + }), + ], + exports: [HttpRuntime], + }); + /** `Bearer ${token}`, or no credentials at all when `token` is `undefined`. */ const linkOf = (origin: string, token: string | undefined) => new RPCLink({ @@ -588,7 +610,9 @@ export type HttpFixtures = { * 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; + readonly rpcVerified: (token: string) => RootMarkedClient; + /** The same router with the `user` scheme's authenticator substituted on its port. Shut down by the fixture. */ + readonly rpcSubstituted: (token: string) => RootMarkedClient; /** 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. */ @@ -829,7 +853,15 @@ export const it = test.extend({ 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)))); + await use((token) => createORPCClient(linkOf(origin, token))); + }, + + rpcSubstituted: async ({ boot }, use) => { + const app = boot(rpcSubstitutedAppOf()); + 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) => createORPCClient(linkOf(origin, token))); }, // oxlint-disable-next-line no-empty-pattern -- see above