first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
# @suaveplan/api-auth-provider - Abstract auth-provider contract for the Suaveplan API plugin system. Defines AuthProviderAdapter, the canonical provider route surface, path-ownership registry, collision detector, the auth.provider CapabilityId, the AUTH_PROVIDER error-code registry, and the provider conformance harness. $2; concrete providers (better-auth, rolled) ship in Stages 6 and 8.
Abstract account-lifecycle contract for the Suaveplan API plugin system
(contracts). This package is OPTIONAL: hosted-IdP deployments
(Clerk, Auth0, Cognito) install only the session resolver from
`@suaveplan/api-auth`; self-hosted deployments install exactly one provider
implementation (`api-auth-better-auth` , `api-auth-rolled` in
Stage 8) which structurally implements `AuthProviderAdapter` from this
package.
<!-- begin:auto-docs -->
## Documentation tree
- [capability-id](./src/capability-id/capability-id.md) — The `capability-id` module pins the runtime literal for the `"auth.provider"` capability this package contributes to th…
- [capability-id](./src/capability-id/capability-id.md) — The `capability-id` module pins the runtime literal for the `"auth.provider"` capability this package contributes to th…
- [collision-detector](./src/collision-detector/collision-detector.md) — `collision-detector` is the install-time guard that prevents a plugin from accidentally squatting on the auth-provider'…
- [collision-detector](./src/collision-detector/collision-detector.md) — `collision-detector` is the install-time guard that prevents a plugin from accidentally squatting on the auth-provider'…
- [error-codes](./src/error-codes/error-codes.md) — This module centralises the four stable error codes the auth-provider contract emits — `PATH_COLLISION`, `MULTIPLE_PROV…
- [error-codes](./src/error-codes/error-codes.md) — This module centralises the four stable error codes the auth-provider contract emits — `PATH_COLLISION`, `MULTIPLE_PROV…
- [path-ownership](./src/path-ownership/path-ownership.md) — `path-ownership` is the runtime registry the host consults to enforce the "exactly one auth provider per deployment" in…
- [path-ownership](./src/path-ownership/path-ownership.md) — `path-ownership` is the runtime registry the host consults to enforce the "exactly one auth provider per deployment" in…
- [provider](./src/provider/provider.md) — The `provider` module is the type-level + thin-runtime heart of `@suaveplan/api-auth-provider`. It declares the abstrac…
- [provider](./src/provider/provider.md) — The `provider` module is the type-level + thin-runtime heart of `@suaveplan/api-auth-provider`. It declares the abstrac…
- [route-surface](./src/route-surface/route-surface.md) — `route-surface` declares the closed set of paths an auth provider OWNS relative to its `pathPrefix`. The set is the uni…
- [route-surface](./src/route-surface/route-surface.md) — `route-surface` declares the closed set of paths an auth provider OWNS relative to its `pathPrefix`. The set is the uni…
- [testing](./src/testing/conformance.md) — `runAuthProviderConformance` is the runner-agnostic harness every concrete `AuthProviderAdapter` MUST clear. It exercis…
- [conformance](./src/testing/conformance.md) — `runAuthProviderConformance` is the runner-agnostic harness every concrete `AuthProviderAdapter` MUST clear. It exercis…
<!-- end:auto-docs -->
## What ships here
- `AuthProviderAdapter` contract — `id`, `pathPrefix`,
`routes(router, ctx)`, four-hook lifecycle.
- `AuthPathPrefix` brand + Zod-validated `authPathPrefix` constructor.
- `PROVIDER_ROUTE_SURFACE` — the canonical 9-entry route table providers
project from.
- `PathOwnership` runtime registry — enforces "exactly one provider"
install-time invariant.
- `detectAuthPathCollisions` — install-time guard that plugin routes do not
squat on the provider's owned prefix.
- `AUTH_PROVIDER_CAPABILITY_ID` constant — `"auth.provider"`.
- `AUTH_PROVIDER_CODES` — the four-code error registry plus
`AuthProviderError` and the `authProviderError` factory.
- `runAuthProviderConformance` — runner-agnostic harness every concrete
provider clears.
## Module index
- [provider](src/provider/provider.md)
- [route-surface](src/route-surface/route-surface.md)
- [path-ownership](src/path-ownership/path-ownership.md)
- [collision-detector](src/collision-detector/collision-detector.md)
- [capability-id](src/capability-id/capability-id.md)
- [error-codes](src/error-codes/error-codes.md)
- [testing/conformance](src/testing/conformance.md)
## Dependencies
- `@suaveplan/api-types` (workspace) — `AuthProviderAdapter` consumes
`Router`, `PluginContext`, `CapabilityId`, `CapabilityRequirements`.
- `@suaveplan/error` (workspace) — `SuaveplanError` + `defineCodes`.
- `zod` (catalog) — `AuthPathPrefixSchema`.
- `tslib` (catalog) — emitter helpers.
## Stage / status
Concrete providers ship in Stages 6 / 8.

View File

@@ -0,0 +1,78 @@
# `capability-id` — `"auth.provider"` capability contribution
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `capability-id` module pins the runtime literal for the
`"auth.provider"` capability this package contributes to the host's
capability registry. The closed `CapabilityId` union in
`@suaveplan/api-types` already includes `"auth.provider"` (alongside
`"auth.session"`); this module simply exposes a typed constant and predicate
so concrete providers, the host's capability resolver, and downstream
plugin manifests can author against a single value rather than a free-form
string. Centralising the literal gives us one place to grep if the id ever
needs to evolve via an OpenSpec change.
## Features
- `AUTH_PROVIDER_CAPABILITY_ID` constant pinned to `"auth.provider"` via a
`satisfies` clause that fails the build if the literal drifts out of the
`CapabilityId` union.
- `isAuthProviderCapabilityId(value)` runtime type-guard for capability
descriptors that flow through `unknown`.
- `AuthProviderCapabilityId` type alias — `Extract<CapabilityId, "auth.provider">` —
so consumers can name the type without re-spelling the literal.
## Usage: Basic
```ts
import { AUTH_PROVIDER_CAPABILITY_ID } from "@suaveplan/api-auth-provider/capability-id";
definePlugin({
requires: [AUTH_PROVIDER_CAPABILITY_ID] as const,
start: async (ctx) => {
// ctx.capabilities["auth.provider"] is now in scope.
},
});
```
## Usage: Advanced
The host's capability resolver inspects untyped descriptors and narrows them
to the typed slot via the predicate:
```ts
import {
AUTH_PROVIDER_CAPABILITY_ID,
isAuthProviderCapabilityId,
} from "@suaveplan/api-auth-provider/capability-id";
function bindCapability(descriptor: { id: string }) {
if (isAuthProviderCapabilityId(descriptor.id)) {
// descriptor.id is now typed as "auth.provider".
registerSlot(AUTH_PROVIDER_CAPABILITY_ID, descriptor);
}
}
```
## API Reference
- `const AUTH_PROVIDER_CAPABILITY_ID: AuthProviderCapabilityId` — frozen
literal.
- `function isAuthProviderCapabilityId(value: string): value is AuthProviderCapabilityId`
- `type AuthProviderCapabilityId = Extract<CapabilityId, "auth.provider">`
## Implementation Notes
The `satisfies` clause is the load-bearing line of the module — if the
`CapabilityId` union ever loses `"auth.provider"`, the build fails here
rather than downstream. The predicate uses an equality test against the
constant rather than the literal to keep the two in lockstep — if a future
change renames the id, the constant and predicate move together. There is
intentionally no companion type-augmentation block here; the union in
`@suaveplan/api-types` is closed and pre-includes every capability the host
knows about, so adding a capability is a co-located OpenSpec change to that
package and not a downstream module-augmentation pattern.

View File

@@ -0,0 +1,88 @@
# `collision-detector` — install-time auth-path collision check
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`collision-detector` is the install-time guard that prevents a plugin from
accidentally squatting on the auth-provider's owned path prefix. It walks
every plugin route, segment-aligns its path against the provider's
`pathPrefix`, and emits `AUTH_PROVIDER.PATH_COLLISION` entries for every
overlap that is not on the plugin's `publicRoutes` allowlist or marked
`isPublic` on the per-route descriptor. Returning a report instead of
throwing keeps callers in control of how to surface aggregated failures —
typically the host turns a non-empty report into a single error listing
every offender.
## Features
- Segment-aware containment — `/auth` matches `/auth` and `/auth/login` but
NOT `/authoritative`.
- Configurable prefix — a deployment that mounts the provider under
`/myauth` shifts the collision domain accordingly.
- Per-route `isPublic` opt-out for plugin routes that augment the auth
surface (e.g. a custom `/auth/healthz`).
- Plugin-level `publicRoutes` allowlist — identical effect, declared once
per plugin manifest.
- Aggregating reporter — every offender is returned in one frozen report.
- Trailing-slash normalisation — `/auth` and `/auth/` are treated as equal.
## Usage: Basic
```ts
import { detectAuthPathCollisions } from "@suaveplan/api-auth-provider/collision-detector";
const report = detectAuthPathCollisions({ provider, plugins });
if (report.hasCollisions) {
throw new Error(`Auth-path collisions: ${report.entries.length}`);
}
```
## Usage: Advanced
Aggregating a problem-detail response across every offender:
```ts
import {
detectAuthPathCollisions,
type CollisionReportEntry,
} from "@suaveplan/api-auth-provider/collision-detector";
const report = detectAuthPathCollisions({ provider, plugins });
if (report.hasCollisions) {
const offenders = report.entries.map(
(e: CollisionReportEntry) => ({ plugin: e.details.plugin, path: e.details.path })
);
return new Response(
JSON.stringify({ type: "auth-collision", offenders }),
{ status: 409 }
);
}
```
## API Reference
- `function detectAuthPathCollisions(input: DetectCollisionsInput): CollisionReport`
- `function isInsidePrefix(routePath: string, prefix: string): boolean`
- `function normalisePrefix(prefix: string): string`
- `interface DetectCollisionsInput { provider; plugins }`
- `interface PluginDescriptor { name; routes; publicRoutes? }`
- `interface PluginRouteDescriptor { path; method?; isPublic? }`
- `interface CollisionReport { entries; hasCollisions }`
- `interface CollisionReportEntry { code; message; details }`
## Implementation Notes
The detector returns a non-throwing report so the host can decide whether a
collision is fatal or merely loggable — empirically a deployment running an
older plugin against a newer provider needs the choice. Segment-aware
matching is essential: a naïve `startsWith` would falsely flag
`/authoritative` (or worse, `/auth-cache`) when the provider owns `/auth`.
The root prefix `"/"` is short-circuited to "owns nothing" so a deployment
that mistakenly configures the auth provider at the root does not blow up
the whole route table; the path-prefix validator catches that mis-configuration
at construction time. Two opt-out paths (per-route `isPublic` and plugin
`publicRoutes`) coexist because manifests historically use one or the other
and supporting both keeps migration tractable.

View File

@@ -0,0 +1,91 @@
# `error-codes` — `AUTH_PROVIDER` error-code registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module centralises the four stable error codes the auth-provider
contract emits — `PATH_COLLISION`, `MULTIPLE_PROVIDERS_REGISTERED`,
`PROVIDER_LIFECYCLE_FAILED`, `OAUTH_CALLBACK_INVALID_STATE` — and pairs each
with HTTP / severity / retry metadata so downstream consumers (problem-detail
mappers, log routers, retry wrappers) classify failures without
string-matching messages. The registry is built with `defineCodes` from
`@suaveplan/error`, which preserves the literal-typed union through the
public surface so exhaustive `switch (err.code)` blocks report unhandled
reasons at type-check time.
## Features
- `AUTH_PROVIDER_CODES` — frozen `{ REASON: "AUTH_PROVIDER.REASON" }` record.
- `AuthProviderErrorCode` union of fully-qualified codes.
- `AuthProviderErrorKey` union of short keys (`"PATH_COLLISION"`).
- `AUTH_PROVIDER_ERROR_METADATA` — per-code HTTP status / severity / retryable
flag / human description.
- `AuthProviderError` — concrete `SuaveplanError` subclass with a public
constructor.
- `authProviderError(key, opts)` — factory that defaults `messageKey` to
`auth_provider.errors.<lower_case_key>`.
- `getAuthProviderCodeMetadata(code)` — lookup helper used by adapters.
## Usage: Basic
```ts
import { authProviderError } from "@suaveplan/api-auth-provider/error-codes";
throw authProviderError("OAUTH_CALLBACK_INVALID_STATE", {
context: { state: requestState },
});
```
## Usage: Advanced
A problem-detail mapper turns any thrown `AuthProviderError` into an HTTP
response with the correct status, retry hint, and severity-driven log level.
```ts
import {
AUTH_PROVIDER_ERROR_METADATA,
AuthProviderError,
} from "@suaveplan/api-auth-provider/error-codes";
function mapToProblem(err: AuthProviderError): Response {
const meta = AUTH_PROVIDER_ERROR_METADATA[err.code];
return new Response(
JSON.stringify({
type: `https://errors.suaveplan/${err.code}`,
title: err.message,
status: meta.httpStatus,
retryable: meta.retryable,
}),
{ status: meta.httpStatus }
);
}
```
## API Reference
- `const AUTH_PROVIDER_CODES`
- `type AuthProviderErrorCode`
- `type AuthProviderErrorKey`
- `interface AuthProviderCodeMetadata`
- `const AUTH_PROVIDER_ERROR_METADATA: Readonly<Record<AuthProviderErrorCode, AuthProviderCodeMetadata>>`
- `class AuthProviderError extends SuaveplanError<AuthProviderErrorCode>` —
public constructor `(code, messageKey, opts?)`.
- `function authProviderError(key, opts?): AuthProviderError`
- `function getAuthProviderCodeMetadata(code: string): AuthProviderCodeMetadata | undefined`
## Implementation Notes
`SuaveplanError`'s base constructor is `protected`; the subclass exposes a
public constructor so callers outside this package — including the
conformance harness — can build typed errors without going through the
factory. The factory is preferred for everyday use because it derives the
i18n message key from the short reason key, keeping the call site dense. The
metadata record is frozen with `Object.freeze` (not just `as const`) so a
runtime caller cannot mutate the table; `satisfies` keeps the table
exhaustive at type-check time. `PATH_COLLISION` maps to HTTP 409 because the
host refuses the install rather than overwriting an existing route; an
adapter that needs a different status should override the mapper rather than
re-shape the registry.

View File

@@ -0,0 +1,83 @@
# `path-ownership` — single-provider registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`path-ownership` is the runtime registry the host consults to enforce the
"exactly one auth provider per deployment" invariant from design §2.3. A
provider claims its `pathPrefix` at install time; any subsequent registration
with a different provider id throws `MULTIPLE_PROVIDERS_REGISTERED`. The
registry is deliberately not a module-global singleton — each host instance
constructs its own `PathOwnership`, which keeps multi-tenant test runners
and hot-reload scenarios deterministic.
## Features
- `PathOwnership` class with `register`, `release`, `current`, `hasProvider`.
- Injectable `clock` — defaults to `Date.now`, used for the `claimedAt`
timestamp on the recorded claim.
- Idempotent registration — re-registering the same instance, or a different
instance with the same provider id, returns the existing claim.
- Strict rejection of a different provider id with
`AUTH_PROVIDER.MULTIPLE_PROVIDERS_REGISTERED`, including a structured
`context` payload (`incomingProviderId`, `incumbentProviderId`,
`prefix`) suitable for diagnostic logs.
- `release()` drops the current claim — used by host shutdown and the
blue-green hot-reload sequence to swap providers atomically.
## Usage: Basic
```ts
import { PathOwnership } from "@suaveplan/api-auth-provider/path-ownership";
const ownership = new PathOwnership();
ownership.register(myProvider);
```
## Usage: Advanced
Tests inject a deterministic clock so the recorded `claimedAt` is stable
across runs:
```ts
import { PathOwnership } from "@suaveplan/api-auth-provider/path-ownership";
const ownership = new PathOwnership({ clock: () => 1_000_000 });
const claim = ownership.register(myProvider);
// claim.claimedAt === 1_000_000
// Hot-reload: release before swapping in the new provider.
ownership.release();
ownership.register(replacementProvider);
```
## API Reference
- `class PathOwnership` — runtime registry.
- `constructor(options?: PathOwnershipOptions)`
- `register(provider): PathOwnershipClaim`
- `release(): void`
- `current(): ProviderClaim | null`
- `hasProvider(): boolean`
- `interface PathOwnershipOptions { clock? }`
- `type OwnershipClock = () => number`
- `interface PathOwnershipClaim { providerId; prefix; claimedAt }`
- `interface ProviderClaim<Provider> { provider; claim }`
## Implementation Notes
The registry stores both the `AuthProviderAdapter` instance and a flat
`PathOwnershipClaim` record. Two layers exist because consumers fall into
two camps: hosts that need the live provider for lifecycle dispatch, and
diagnostic tooling that only needs the metadata. Idempotency is decided by
identity first, id second — so a host that constructs a fresh provider
instance per hot-reload still cannot accidentally register two providers
with the same id. The `MULTIPLE_PROVIDERS_REGISTERED` error carries enough
context for operators to diagnose double-installation in a misconfigured
multi-process deployment without consulting the host logs. The class uses a
private field rather than a module-level variable so multiple registries can
coexist in the same process — essential for runner-agnostic test suites and
multi-tenant in-process deployments.

View File

@@ -0,0 +1,92 @@
# `provider` — `AuthProviderAdapter` contract + path-prefix validation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `provider` module is the type-level + thin-runtime heart of
`@suaveplan/api-auth-provider`. It declares the abstract `AuthProviderAdapter`
interface every concrete account-lifecycle provider (Better-Auth, the rolled
reference, future Cognito/Auth0 bridges) structurally implements, and ships
the Zod-validated `authPathPrefix` constructor that asserts a deployment's
configured prefix is a legal segment-aligned mount point. By splitting the
contract from any concrete provider we keep this package optional: a
hosted-IdP deployment installs the session resolver (`api-auth`) only and
never pulls in a provider implementation.
## Features
- `AuthProviderAdapter<Caps>` interface with `id`, configurable `pathPrefix`,
`routes(router, ctx)` registration callback, and the four-hook
`AuthProviderLifecycle` quartet (`install`/`start`/`stop`/`uninstall`).
- `AuthPathPrefix` opaque brand — protects against accidental string
substitution at the install boundary.
- `AuthPathPrefixSchema` — a Zod schema enforcing leading slash, no trailing
slash (except `/`), no path parameters, no whitespace.
- `authPathPrefix(value)` constructor — returns the branded value or throws
`AUTH_PROVIDER.PROVIDER_LIFECYCLE_FAILED` with a structured `reason`.
- `isAuthPathPrefix(value)` predicate — non-throwing variant for tests.
- `DEFAULT_AUTH_PATH_PREFIX` constant (`"/auth"`).
## Usage: Basic
```ts
import {
authPathPrefix,
DEFAULT_AUTH_PATH_PREFIX,
type AuthProviderAdapter,
} from "@suaveplan/api-auth-provider/provider";
const myProvider: AuthProviderAdapter = {
id: "rolled",
pathPrefix: authPathPrefix(DEFAULT_AUTH_PATH_PREFIX),
routes(router) {
router.post("/login", { auth: null }, async () => new Response());
},
lifecycle: {
install: async () => {},
start: async () => {},
stop: async () => {},
uninstall: async () => {},
},
};
```
## Usage: Advanced
A deployment that mounts its provider under a non-standard prefix MUST run
the input through `authPathPrefix` so the value is validated before the host
records the claim in the path-ownership registry.
```ts
import { authPathPrefix } from "@suaveplan/api-auth-provider/provider";
const prefix = authPathPrefix(process.env.AUTH_PREFIX ?? "/auth/v2");
// Throws AUTH_PROVIDER.PROVIDER_LIFECYCLE_FAILED on input like
// "/auth/v2/" or "/auth/:tenant".
```
## API Reference
- `interface AuthProviderAdapter<Caps>` — provider contract.
- `interface AuthProviderLifecycle<Caps>` — install/start/stop/uninstall.
- `type AuthPathPrefix` — branded prefix string.
- `const AuthPathPrefixSchema: ZodType` — validation schema.
- `function authPathPrefix(value: string): AuthPathPrefix` — throwing
constructor.
- `function isAuthPathPrefix(value: string): boolean` — predicate.
- `const DEFAULT_AUTH_PATH_PREFIX = "/auth"` — host default.
## Implementation Notes
The brand is purely a TypeScript construct (`unique symbol`) — at runtime
`authPathPrefix("/auth")` returns the string unchanged. The schema's order of
refinements is significant: the leading-slash check fires first so callers
see the most actionable error message. Whitespace is rejected before the
parameter check because shell-supplied configuration is the typical source of
both classes of error. The constructor surfaces failures as
`PROVIDER_LIFECYCLE_FAILED` (not a hypothetical `INVALID_PREFIX`) because the
install lifecycle is the only legitimate call site — keeping the code small
makes the error registry tighter and the metadata table denser.

View File

@@ -0,0 +1,87 @@
# `route-surface` — canonical auth-provider route surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`route-surface` declares the closed set of paths an auth provider OWNS
relative to its `pathPrefix`. The set is the union of the routes any
realistic concrete provider exposes — register, login, logout, refresh, two
OAuth-flow endpoints, email verification, and the forgot/reset-password
pair. Providers MAY implement a subset; the canonical list is the bar the
collision detector measures against and the manifest the OpenAPI emitter
projects when stitching a unified document at build time.
## Features
- `PROVIDER_ROUTE_SURFACE` — frozen array of `ProviderRouteEntry` records,
every entry carrying `path`, `method`, stable `name`, and OpenAPI
`summary`.
- `ProviderRouteName` literal union — exhaustive narrowing in switch blocks.
- `lookupProviderRoute(name)` — name → entry lookup.
- `resolveProviderRoutePath(prefix, entry)` — mount under a given prefix
(handles `"/"` as a no-op).
- `listProviderRouteNames()` — declaration-order name iteration; used by the
conformance harness to verify every canonical name has a scenario.
## Usage: Basic
```ts
import {
PROVIDER_ROUTE_SURFACE,
resolveProviderRoutePath,
} from "@suaveplan/api-auth-provider/route-surface";
for (const entry of PROVIDER_ROUTE_SURFACE) {
console.log(entry.method, resolveProviderRoutePath("/auth", entry));
}
```
## Usage: Advanced
The OpenAPI emitter walks the surface to generate the global `paths` block
for the unified document, projecting `summary` directly into the OpenAPI
operation and using `name` as `operationId` after kebab → camelCase
conversion.
```ts
import {
PROVIDER_ROUTE_SURFACE,
resolveProviderRoutePath,
} from "@suaveplan/api-auth-provider/route-surface";
const openapiPaths: Record<string, unknown> = {};
for (const entry of PROVIDER_ROUTE_SURFACE) {
const path = resolveProviderRoutePath(provider.pathPrefix, entry);
openapiPaths[path] = {
[entry.method.toLowerCase()]: {
operationId: entry.name.replace(/[-.]/g, "_"),
summary: entry.summary,
tags: ["auth"],
},
};
}
```
## API Reference
- `interface ProviderRouteEntry { path; method; name; summary }`
- `type ProviderRouteName` — closed union of 9 names.
- `const PROVIDER_ROUTE_SURFACE: ReadonlyArray<ProviderRouteEntry>`
- `function lookupProviderRoute(name: ProviderRouteName): ProviderRouteEntry | undefined`
- `function resolveProviderRoutePath(prefix: string, entry: ProviderRouteEntry): string`
- `function listProviderRouteNames(): ReadonlyArray<ProviderRouteName>`
## Implementation Notes
The surface is frozen at module-load with two layers of `Object.freeze` —
once around the outer array and once around each entry — to keep downstream
hosts from mutating the table at runtime. `resolveProviderRoutePath` treats
`"/"` as the empty-prefix case so a deployment that mounts the provider at
the host root produces `"/login"` rather than `"//login"`. Adding a new
canonical route requires an OpenSpec change because the conformance harness
and the OpenAPI projector both treat the surface as authoritative; touching
either side without the other is the bug class this list-as-data design
guards against.

View File

@@ -0,0 +1,89 @@
# `testing/conformance` — provider conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runAuthProviderConformance` is the runner-agnostic harness every concrete
`AuthProviderAdapter` MUST clear. It exercises the four critical contract
surfaces — path ownership, canonical route surface, lifecycle quartet, and
collision-detector behaviour — against a fresh provider instance per
scenario. Concrete provider packages call the harness inside a `describe()`
block in their own test file and turn each returned `ConformanceScenarioResult`
into an `it()` assertion. The harness deliberately does not import a test
runner; that responsibility stays at the call site so providers compatible
with `vitest`, `bun:test`, or any other runner can all share the same
behaviour bar.
## Features
- Four-category coverage — `path-ownership`, `route-surface`, `lifecycle`,
`collision`.
- Fresh-provider-per-scenario semantics — the factory is invoked once per
scenario so cross-scenario state cannot leak.
- Records every category and scenario name in the returned array so callers
can verify (in their own tests) that every category is exercised.
- Returns a `passed` boolean per scenario plus an optional `detail` string
for debug context.
- Frozen return value — the caller cannot accidentally mutate the report
while iterating.
## Usage: Basic
```ts
import { describe, expect, it } from "@suaveplan/testing/runner";
import { runAuthProviderConformance } from "@suaveplan/api-auth-provider/testing";
describe("better-auth conformance", () => {
const results = runAuthProviderConformance({
label: "better-auth",
factory: () => buildBetterAuthProvider(),
});
for (const r of results) {
it(`[${r.category}] ${r.name}`, { timeout: 5000 }, () => {
expect(r.passed, r.detail).toBe(true);
});
}
});
```
## Usage: Advanced
A provider whose factory is async (e.g. needs to bring up a Mailpit
testcontainer for verify-email) wraps the synchronous factory the harness
expects and runs setup outside it:
```ts
import { runAuthProviderConformance } from "@suaveplan/api-auth-provider/testing";
const env = await startMailpit();
const results = runAuthProviderConformance({
label: "better-auth-with-mailpit",
factory: () => buildBetterAuthProvider({ smtp: env.connection }),
});
await env.dispose();
```
## API Reference
- `function runAuthProviderConformance(options): ReadonlyArray<ConformanceScenarioResult>`
- `interface AuthProviderConformanceOptions { label; factory }`
- `type AuthProviderConformanceFactory = () => AuthProviderAdapter`
- `type AuthProviderConformanceCategory =
"path-ownership" | "route-surface" | "lifecycle" | "collision"`
- `interface ConformanceScenarioResult { category; name; passed; detail? }`
## Implementation Notes
The harness deliberately drives a synchronous factory because the
information it asserts is structural — `id`, `pathPrefix`, lifecycle quartet
presence, route surface enumeration — and forcing async setup into every
provider creates fixture-management noise where none is needed. Providers
that need real I/O (e.g. database-backed user creation) layer their own
scenarios on top. Each category drives a fresh provider instance, so a
scenario that crashes one provider does not leave global state poisoned for
the next. The `lifecycle` category checks for the four-function quartet by
`typeof === "function"`; a provider whose `stop` hook is missing or
non-callable fails the check loud rather than at runtime.