Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/ponytail-trims.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions packages/di/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<P, E, N> &
{ 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
Expand Down Expand Up @@ -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<I, P>` 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 —
Expand All @@ -263,7 +265,7 @@ of this gate noisy.

### The gate cannot be computed generically — and that is why the casts exist

`Unmet<I, P>` 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
Expand Down
2 changes: 1 addition & 1 deletion packages/di/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
20 changes: 3 additions & 17 deletions packages/di/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,20 +160,6 @@ type ResolvedExports<X extends readonly unknown[]> =
: never)
| ExportsOfModule<Extract<X[number], AnyModule>>;

/**
* 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<I extends readonly AnyModule[], P extends readonly AnyProvider[]> = Exclude<
NeedOf<P[number]> | NeedsOfModule<I[number]>,
Available<I, P>
>;

/**
* 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
Expand Down Expand Up @@ -254,12 +240,12 @@ function ModuleDeclaration<const Name extends string>(name: Name) {
readonly exports?: X;
readonly needs?: N;
} & NeedsGate<I, P, N>,
// Inline, NOT `Unmet<I, P>`: 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<X>,
ErrOf<P[number]> | ErrOfModule<I[number]>,
Expand Down
17 changes: 16 additions & 1 deletion packages/http/src/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
18 changes: 15 additions & 3 deletions packages/http/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,26 @@ const ports = new Map<string, unknown>();
* 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 = <const S extends string>(
scheme: S,
): PortClassOf<`HttpAuthenticator:${S}`, AuthenticatorService<unknown>> => {
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
Expand Down
11 changes: 8 additions & 3 deletions packages/http/src/define-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export type Http<A extends Authenticators> = {
readonly HttpRouter: ReturnType<
typeof routerFor<SchemesFrom<A>, SchemeProviders<A>, VocabFrom<A>>
>;
/**
* 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;
};

Expand All @@ -66,9 +73,7 @@ export type Http<A extends Authenticators> = {
export const defineHttp = <const A extends Authenticators = Record<never, never>>(options?: {
readonly authenticators: A;
}): Http<A> => {
const declared = (options?.authenticators ?? {}) as Readonly<
Record<string, Authenticator<unknown, string, unknown>>
>;
const declared: Authenticators = options?.authenticators ?? {};
const providers = Object.entries(declared).map(([scheme, authenticator]) =>
bind(scheme, authenticator),
);
Expand Down
6 changes: 5 additions & 1 deletion packages/http/src/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,11 @@ type SchemePortsOf<C> =

/**
* 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<C> =
IsMarked<C> extends true
Expand Down
14 changes: 7 additions & 7 deletions packages/http/src/principal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export type IsUnion<T, U = T> = [T] extends [never]
: never;

/** One arm per scheme, tagged by its name so a handler can switch on it. */
export type Tagged<S extends string, Schemes> = S extends S
? { readonly scheme: S; readonly identity: S extends keyof Schemes ? Schemes[S] : never }
export type Tagged<S extends keyof Schemes & string, Schemes> = S extends S
? { readonly scheme: S; readonly identity: Schemes[S] }
: never;

/**
Expand All @@ -29,8 +29,8 @@ export type Tagged<S extends string, Schemes> = S extends S
*/
export type Principal<S extends string, Schemes> = [S] extends [never]
? never
: IsUnion<S> extends true
? Tagged<S, Schemes>
: S extends keyof Schemes
? Schemes[S]
: never;
: [S] extends [keyof Schemes]
? IsUnion<S> extends true
? Tagged<S & keyof Schemes, Schemes>
: Schemes[S & keyof Schemes]
: never;
38 changes: 35 additions & 3 deletions packages/http/src/test-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<RootMarkedClient>;
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. */
Expand Down Expand Up @@ -829,7 +853,15 @@ export const it = test.extend<HttpFixtures>({
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
Expand Down
Loading