first commit
This commit is contained in:
68
docs/api-stack/api/api-auth-provider/README.md
Normal file
68
docs/api-stack/api/api-auth-provider/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user