From b8fdee90a161e417d61ed8ea9085f8ff3a9bf7cf Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 23:05:25 +0200 Subject: [PATCH 1/3] chore!: the audit trims, and the kept surface says why it stays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the 2026-08-23 ponytail audit, re-triaged under the library lens: unused by the examples is not unused, because the examples are scenarios rather than the library's one user. Cut: di's `Unmet` export, whose documented purpose (shaped modules re-declaring the gates with it) was impossible — declaration emit keeps the alias unreduced and TS2883 follows, which is why every in-repo sugar already inlined; http's scheme-port memoisation, measured unnecessary (di resolves by id, a fresh class per call is the same lookup, 50/50 and 26/26 green without it); the `declared` double-cast; `Tagged`/`Principal`'s twice-guarded `keyof Schemes`; and the `rpcVerified` fixture's async wrapper around a sync value. Kept, with the reason now in the TSDoc where the next audit will look: `HasMark` (tooling over a contract — an OpenAPI generator deciding whether to emit `security` is the canonical reader), `authenticatorPort` (a test composition substituting one scheme's authenticator provides on this port), and `Http.authenticators` (the introspection surface a hand-rolled composition reads). --- .changeset/ponytail-trims.md | 26 ++++++++++++++++++++++++++ packages/di/CLAUDE.md | 10 ++++++---- packages/di/src/index.ts | 2 +- packages/di/src/module.ts | 20 +++----------------- packages/http/src/auth.spec.ts | 2 +- packages/http/src/auth.ts | 25 ++++++++++++++----------- packages/http/src/define-http.ts | 11 ++++++++--- packages/http/src/orpc.ts | 6 +++++- packages/http/src/principal.ts | 14 +++++++------- packages/http/src/test-fixtures.ts | 4 ++-- 10 files changed, 73 insertions(+), 47 deletions(-) create mode 100644 .changeset/ponytail-trims.md 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..9498b72 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -70,7 +70,7 @@ describe("an authenticator with dependencies of its own", () => { 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..06e1468 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -65,8 +65,6 @@ export type AuthenticatorService = ( headers: IncomingHttpHeaders, ) => 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 to @@ -80,20 +78,25 @@ 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. No in-repo example does; the examples are scenarios, not + * the library's one user. */ 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; + // Not memoised: di identifies a port by its `portId` string and the instance + // type is branded by the id literal, so two classes minted under one id are + // the same type and the same lookup — `defineHttp` binding and `routerFor` + // depending resolve to one provider either way (measured: the suite passes + // with a fresh class per call). // 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; + return class extends Port(`HttpAuthenticator:${scheme}`)< + AuthenticatorService + > {} as never; }; /** 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..9267aa3 100644 --- a/packages/http/src/test-fixtures.ts +++ b/packages/http/src/test-fixtures.ts @@ -588,7 +588,7 @@ 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 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 +829,7 @@ 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))); }, // oxlint-disable-next-line no-empty-pattern -- see above From 9dfcb52300367345c0b3cd5e66f75998bd5f75a3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 23 Aug 2026 23:10:07 +0200 Subject: [PATCH 2/3] test(http): the substitution story authenticatorPort documents is exercised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rpcSubstitutedAppOf is a hand-rolled composition providing its own stub on the `user` scheme's port — 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 a port). It reuses verifiedRouter, whose real authenticator depends on a TokenTable; the stub graph never builds either, and the spec proves it by serving a token the real table would refuse. The TSDoc written earlier in this branch said "no in-repo example does" — true for one commit; it now points at the fixture instead. --- packages/http/src/auth.spec.ts | 15 +++++++++++++ packages/http/src/auth.ts | 5 +++-- packages/http/src/test-fixtures.ts | 34 +++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/http/src/auth.spec.ts b/packages/http/src/auth.spec.ts index 9498b72..1157eed 100644 --- a/packages/http/src/auth.spec.ts +++ b/packages/http/src/auth.spec.ts @@ -65,6 +65,21 @@ 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, diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 06e1468..8f00d4d 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -82,8 +82,9 @@ export type AuthenticatorService = ( * 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. No in-repo example does; the examples are scenarios, not - * the library's one user. + * 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, diff --git a/packages/http/src/test-fixtures.ts b/packages/http/src/test-fixtures.ts index 9267aa3..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({ @@ -589,6 +611,8 @@ export type HttpFixtures = { * through `Provider(port)(deps, arm)`. Shut down by the fixture. */ 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. */ @@ -832,6 +856,14 @@ export const it = test.extend({ 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 headers: async ({}, use) => { await use({}); From 3cac0b5a4b2fcb3af4247b7a5a066365e140aeac Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Mon, 24 Aug 2026 09:20:51 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(http):=20the=20port=20memoisation=20is?= =?UTF-8?q?=20restored=20=E2=80=94=20the=20warning=20was=20the=20point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #96 caught what my removal measured wrong: I measured the suite passing, not the output being pristine. di's Port() warns once per duplicate id in development — measured directly on the built package, node prints `[di] duplicate port id "HttpAuthenticator:user" — one will shadow the other` on the second mint — and defineHttp binding plus routerFor depending is the designed two-call pattern, so an un-memoised authenticatorPort puts one warning per scheme in every consumer's terminal. The comment now states the true reason: memoised for the dev-time warning, not for resolution, which really is by id (the original comment's claim, kept). --- packages/http/src/auth.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/http/src/auth.ts b/packages/http/src/auth.ts index 8f00d4d..cf56491 100644 --- a/packages/http/src/auth.ts +++ b/packages/http/src/auth.ts @@ -65,6 +65,8 @@ export type AuthenticatorService = ( headers: IncomingHttpHeaders, ) => 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 to @@ -89,15 +91,21 @@ export type AuthenticatorService = ( export const authenticatorPort = ( scheme: S, ): PortClassOf<`HttpAuthenticator:${S}`, AuthenticatorService> => { - // Not memoised: di identifies a port by its `portId` string and the instance - // type is branded by the id literal, so two classes minted under one id are - // the same type and the same lookup — `defineHttp` binding and `routerFor` - // depending resolve to one provider either way (measured: the suite passes - // with a fresh class per call). + const id = `HttpAuthenticator:${scheme}` as const; + // 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 - return class extends Port(`HttpAuthenticator:${scheme}`)< - AuthenticatorService - > {} as never; + const minted = class extends Port(id)> {}; + ports.set(id, minted); + return minted as never; }; /**