Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,131 @@
{
"name": "@suaveplan/api-auth-provider",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Abstract auth-provider contract for the Suaveplan API plugin foundation. 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. Stage 1 contract package; concrete providers (better-auth, rolled) ship in Stages 6 and 8.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./provider": {
"import": {
"types": "./dist/provider/index.d.ts",
"default": "./dist/provider/index.js"
},
"require": {
"types": "./dist/provider/index.d.ts",
"default": "./dist/provider/index.js"
}
},
"./route-surface": {
"import": {
"types": "./dist/route-surface/index.d.ts",
"default": "./dist/route-surface/index.js"
},
"require": {
"types": "./dist/route-surface/index.d.ts",
"default": "./dist/route-surface/index.js"
}
},
"./path-ownership": {
"import": {
"types": "./dist/path-ownership/index.d.ts",
"default": "./dist/path-ownership/index.js"
},
"require": {
"types": "./dist/path-ownership/index.d.ts",
"default": "./dist/path-ownership/index.js"
}
},
"./collision-detector": {
"import": {
"types": "./dist/collision-detector/index.d.ts",
"default": "./dist/collision-detector/index.js"
},
"require": {
"types": "./dist/collision-detector/index.d.ts",
"default": "./dist/collision-detector/index.js"
}
},
"./capability-id": {
"import": {
"types": "./dist/capability-id/index.d.ts",
"default": "./dist/capability-id/index.js"
},
"require": {
"types": "./dist/capability-id/index.d.ts",
"default": "./dist/capability-id/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
},
"require": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
},
"require": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,22 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_PROVIDER_CAPABILITY_ID,
isAuthProviderCapabilityId,
} from "./capability-id.js";
describe("AUTH_PROVIDER_CAPABILITY_ID", () => {
it("equals 'auth.provider'", { timeout: 5000 }, () => {
expect(AUTH_PROVIDER_CAPABILITY_ID).toBe("auth.provider");
});
});
describe("isAuthProviderCapabilityId", () => {
it("returns true for 'auth.provider'", { timeout: 5000 }, () => {
expect(isAuthProviderCapabilityId("auth.provider")).toBe(true);
});
it("returns false for any other string", { timeout: 5000 }, () => {
expect(isAuthProviderCapabilityId("auth.session")).toBe(false);
expect(isAuthProviderCapabilityId("logger")).toBe(false);
expect(isAuthProviderCapabilityId("")).toBe(false);
});
});

View File

@@ -0,0 +1,22 @@
/**
* Runtime constant for the `"auth.provider"` capability id. Consumers
* (concrete providers, the host's capability resolver) MUST import this
* constant rather than spelling the literal — it gives us one place to grep
* if the id ever needs to evolve via OpenSpec.
*/
import type { AuthProviderCapabilityId } from "./capability-id.types.js";
/** The `"auth.provider"` capability id this package contributes. */
export const AUTH_PROVIDER_CAPABILITY_ID =
"auth.provider" satisfies AuthProviderCapabilityId;
/**
* Predicate — returns `true` for the literal `"auth.provider"`. Useful for
* runtime type-guards when the host inspects a capability descriptor.
*/
export function isAuthProviderCapabilityId(
value: string
): value is AuthProviderCapabilityId {
return value === AUTH_PROVIDER_CAPABILITY_ID;
}

View File

@@ -0,0 +1,16 @@
/**
* Capability-id contribution of this package — the literal `"auth.provider"`.
*
* The closed union in `@suaveplan/api-types/capabilities` already includes
* `"auth.provider"`; this module pins a typed constant so consumers in this
* package (and downstream concrete providers) author against a single value
* rather than a free-form string.
*/
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
/**
* The capability-id this package contributes. Assignable to
* `CapabilityId` — verified by `capability-id.spec-conformance.test.ts`.
*/
export type AuthProviderCapabilityId = Extract<CapabilityId, "auth.provider">;

View File

@@ -0,0 +1,5 @@
export {
AUTH_PROVIDER_CAPABILITY_ID,
isAuthProviderCapabilityId,
} from "./capability-id.js";
export type { AuthProviderCapabilityId } from "./capability-id.types.js";

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,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_PROVIDER_CODES } from "../error-codes/error-codes.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
import {
detectAuthPathCollisions,
isInsidePrefix,
normalisePrefix,
} from "./collision-detector.js";
const makeProvider = (pathPrefix = "/auth"): AuthProviderAdapter => ({
id: "better-auth",
pathPrefix,
routes: () => {},
lifecycle: {
install: async () => {},
start: async () => {},
stop: async () => {},
uninstall: async () => {},
},
});
describe("normalisePrefix", () => {
it("strips a trailing slash", { timeout: 5000 }, () => {
expect(normalisePrefix("/auth/")).toBe("/auth");
});
it("preserves the bare root", { timeout: 5000 }, () => {
expect(normalisePrefix("/")).toBe("/");
});
it("returns short prefixes unchanged", { timeout: 5000 }, () => {
expect(normalisePrefix("/auth")).toBe("/auth");
});
});
describe("isInsidePrefix", () => {
it("matches exact prefix", { timeout: 5000 }, () => {
expect(isInsidePrefix("/auth", "/auth")).toBe(true);
});
it("matches descendant segments", { timeout: 5000 }, () => {
expect(isInsidePrefix("/auth/login", "/auth")).toBe(true);
expect(isInsidePrefix("/auth/oauth/google/start", "/auth")).toBe(true);
});
it("rejects neighbouring prefix-string matches without segment boundary", {
timeout: 5000,
}, () => {
expect(isInsidePrefix("/authoritative", "/auth")).toBe(false);
});
it("returns false for the root prefix '/'", { timeout: 5000 }, () => {
expect(isInsidePrefix("/billing/x", "/")).toBe(false);
});
});
describe("detectAuthPathCollisions", () => {
it("reports a plugin route nested in the auth prefix", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "plugin-x",
routes: [{ path: "/auth/custom" }],
},
],
});
expect(report.hasCollisions).toBe(true);
expect(report.entries.length).toBe(1);
const entry = report.entries[0];
expect(entry?.code).toBe(AUTH_PROVIDER_CODES.PATH_COLLISION);
expect(entry?.details.plugin).toBe("plugin-x");
expect(entry?.details.path).toBe("/auth/custom");
expect(entry?.details.prefix).toBe("/auth");
});
it("does not report routes outside the auth prefix", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "plugin-x",
routes: [{ path: "/billing/invoices" }],
},
],
});
expect(report.hasCollisions).toBe(false);
expect(report.entries.length).toBe(0);
});
it("configurable prefix shifts collision domain", { timeout: 5000 }, () => {
const myProvider = makeProvider("/myauth");
const r1 = detectAuthPathCollisions({
provider: myProvider,
plugins: [{ name: "p", routes: [{ path: "/auth/x" }] }],
});
expect(r1.hasCollisions).toBe(false);
const r2 = detectAuthPathCollisions({
provider: myProvider,
plugins: [{ name: "p", routes: [{ path: "/myauth/x" }] }],
});
expect(r2.hasCollisions).toBe(true);
});
it("respects per-route isPublic opt-out", { timeout: 5000 }, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "p",
routes: [{ path: "/auth/healthz", isPublic: true }],
},
],
});
expect(report.hasCollisions).toBe(false);
});
it("publicRoutes allowlist does not silence unrelated paths", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "p",
routes: [{ path: "/auth/other" }],
publicRoutes: ["/auth/something-else"],
},
],
});
expect(report.hasCollisions).toBe(true);
});
it("respects plugin-level publicRoutes allowlist", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{
name: "p",
routes: [{ path: "/auth/legacy" }],
publicRoutes: ["/auth/legacy"],
},
],
});
expect(report.hasCollisions).toBe(false);
});
it("aggregates collisions across plugins", { timeout: 5000 }, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [
{ name: "a", routes: [{ path: "/auth/a" }] },
{ name: "b", routes: [{ path: "/auth/b" }, { path: "/safe" }] },
],
});
expect(report.entries.length).toBe(2);
expect(
report.entries.map((e) => `${e.details.plugin}:${e.details.path}`)
).toEqual(["a:/auth/a", "b:/auth/b"]);
});
it("freezes the returned report", { timeout: 5000 }, () => {
const report = detectAuthPathCollisions({
provider: makeProvider(),
plugins: [],
});
expect(Object.isFrozen(report)).toBe(true);
expect(Object.isFrozen(report.entries)).toBe(true);
});
it("trailing-slash provider prefix still detects collisions", {
timeout: 5000,
}, () => {
const report = detectAuthPathCollisions({
provider: makeProvider("/auth/"),
plugins: [{ name: "p", routes: [{ path: "/auth/x" }] }],
});
expect(report.hasCollisions).toBe(true);
});
});

View File

@@ -0,0 +1,97 @@
/**
* Install-time auth-path collision detector.
*
* For each plugin route, if its path is "inside" the provider's `pathPrefix`
* AND it is not on the plugin's `publicRoutes` allowlist AND the route entry
* itself is not explicitly marked `isPublic`, the detector emits an
* `AUTH_PROVIDER.PATH_COLLISION` entry. Plugin routes outside the prefix are
* never reported.
*
* "Inside" follows segment-aware semantics — `/auth` covers `/auth`,
* `/auth/login`, and `/auth/oauth/x/start`, but NOT `/authoritative` (segment
* boundary required). The root prefix `/` is treated as "owns nothing" so a
* `/` provider does not collide with arbitrary plugin routes.
*/
import { AUTH_PROVIDER_CODES } from "../error-codes/error-codes.js";
import type {
CollisionReport,
CollisionReportEntry,
DetectCollisionsInput,
PluginDescriptor,
} from "./collision-detector.types.js";
/** Normalise a prefix — strip trailing slash so `/auth` ≡ `/auth/`. */
export function normalisePrefix(prefix: string): string {
if (prefix.length > 1 && prefix.endsWith("/")) {
return prefix.slice(0, -1);
}
return prefix;
}
/**
* Segment-aware containment check. `prefix === "/auth"` matches `/auth` and
* `/auth/anything` but not `/authoritative`. The root prefix `/` matches
* nothing (it would otherwise own the entire surface).
*/
export function isInsidePrefix(routePath: string, prefix: string): boolean {
const norm = normalisePrefix(prefix);
if (norm === "/") {
return false;
}
if (routePath === norm) {
return true;
}
return routePath.startsWith(`${norm}/`);
}
/** True when the supplied path is in the plugin's `publicRoutes` allowlist. */
function isOnAllowlist(plugin: PluginDescriptor, routePath: string): boolean {
if (plugin.publicRoutes === undefined) {
return false;
}
for (const allowed of plugin.publicRoutes) {
if (allowed === routePath) {
return true;
}
}
return false;
}
/**
* Detect collisions between an installed provider and a candidate plugin set.
* Returns a report — never throws. Callers decide whether to throw based on
* `report.hasCollisions`.
*/
export function detectAuthPathCollisions(
input: DetectCollisionsInput
): CollisionReport {
const prefix = normalisePrefix(input.provider.pathPrefix);
const entries: CollisionReportEntry[] = [];
for (const plugin of input.plugins) {
for (const route of plugin.routes) {
if (!isInsidePrefix(route.path, prefix)) {
continue;
}
if (route.isPublic === true) {
continue;
}
if (isOnAllowlist(plugin, route.path)) {
continue;
}
entries.push({
code: AUTH_PROVIDER_CODES.PATH_COLLISION,
message: `Plugin "${plugin.name}" declares route "${route.path}" inside the auth prefix "${prefix}" owned by provider "${input.provider.id}".`,
details: {
plugin: plugin.name,
path: route.path,
prefix,
},
});
}
}
return Object.freeze({
entries: Object.freeze(entries),
hasCollisions: entries.length > 0,
});
}

View File

@@ -0,0 +1,64 @@
/**
* Type shapes for the install-time collision detector. Input descriptors are
* deliberately structural so the host can pass either Zod-parsed manifest
* fragments or hand-rolled fixtures.
*/
import type { HttpMethod } from "@suaveplan/api-types/router";
import type { AuthProviderErrorCode } from "../error-codes/error-codes.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
/** Minimal shape of a plugin's declared route — what the detector needs. */
export interface PluginRouteDescriptor {
/** Mounted path (e.g. `/billing/invoices` or `/auth/custom`). Must begin with `/`. */
readonly path: string;
/** HTTP method — informational; the detector reports per-route regardless. */
readonly method?: HttpMethod;
/**
* Optional opt-in: a route declared on `publicRoutes` is allowed inside
* the auth prefix (e.g. a plugin that augments `/auth/healthz`). Empty
* by default — strict mode.
*/
readonly isPublic?: boolean;
}
/** Minimal shape of a plugin participating in the collision check. */
export interface PluginDescriptor {
/** Stable plugin identifier (e.g. `"api-plugin-billing"`). */
readonly name: string;
/** Routes the plugin declares in its manifest. */
readonly routes: ReadonlyArray<PluginRouteDescriptor>;
/**
* Paths under the auth prefix the plugin is explicitly allowed to keep —
* mirrors `publicRoutes` in the per-route descriptor for plugins that
* declare an allowlist at the plugin level.
*/
readonly publicRoutes?: ReadonlyArray<string>;
}
/** One detected collision. */
export interface CollisionReportEntry {
/** `AUTH_PROVIDER.PATH_COLLISION`. */
readonly code: AuthProviderErrorCode;
/** Free-form human-readable message. */
readonly message: string;
/** Structured fields: `plugin`, `path`, `prefix`. */
readonly details: {
readonly plugin: string;
readonly path: string;
readonly prefix: string;
};
}
/** Full collision report — empty `entries` means no collisions. */
export interface CollisionReport {
readonly entries: ReadonlyArray<CollisionReportEntry>;
/** Convenience boolean — `entries.length > 0`. */
readonly hasCollisions: boolean;
}
/** Input to {@link detectAuthPathCollisions}. */
export interface DetectCollisionsInput {
readonly provider: AuthProviderAdapter;
readonly plugins: ReadonlyArray<PluginDescriptor>;
}

View File

@@ -0,0 +1,12 @@
export {
detectAuthPathCollisions,
isInsidePrefix,
normalisePrefix,
} from "./collision-detector.js";
export type {
CollisionReport,
CollisionReportEntry,
DetectCollisionsInput,
PluginDescriptor,
PluginRouteDescriptor,
} from "./collision-detector.types.js";

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,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_PROVIDER_CODES,
AUTH_PROVIDER_ERROR_METADATA,
AuthProviderError,
authProviderError,
getAuthProviderCodeMetadata,
} from "./error-codes.js";
describe("AUTH_PROVIDER_CODES", () => {
it("contains exactly the four documented codes", { timeout: 5000 }, () => {
const keys = Object.keys(AUTH_PROVIDER_CODES).sort();
expect(keys).toEqual(
[
"MULTIPLE_PROVIDERS_REGISTERED",
"OAUTH_CALLBACK_INVALID_STATE",
"PATH_COLLISION",
"PROVIDER_LIFECYCLE_FAILED",
].sort()
);
});
it("namespaces every value under AUTH_PROVIDER", { timeout: 5000 }, () => {
for (const value of Object.values(AUTH_PROVIDER_CODES)) {
expect(value.startsWith("AUTH_PROVIDER.")).toBe(true);
}
});
it("freezes the record", { timeout: 5000 }, () => {
expect(Object.isFrozen(AUTH_PROVIDER_CODES)).toBe(true);
});
});
describe("AUTH_PROVIDER_ERROR_METADATA", () => {
it("has a metadata entry for every code", { timeout: 5000 }, () => {
for (const code of Object.values(AUTH_PROVIDER_CODES)) {
expect(AUTH_PROVIDER_ERROR_METADATA[code]).toBeDefined();
}
});
it("uses sensible HTTP statuses", { timeout: 5000 }, () => {
expect(
AUTH_PROVIDER_ERROR_METADATA[AUTH_PROVIDER_CODES.PATH_COLLISION]
.httpStatus
).toBe(409);
expect(
AUTH_PROVIDER_ERROR_METADATA[
AUTH_PROVIDER_CODES.OAUTH_CALLBACK_INVALID_STATE
].httpStatus
).toBe(400);
});
});
describe("authProviderError factory", () => {
it("constructs an AuthProviderError with the requested code", {
timeout: 5000,
}, () => {
const err = authProviderError("PATH_COLLISION");
expect(err).toBeInstanceOf(AuthProviderError);
expect(err.code).toBe(AUTH_PROVIDER_CODES.PATH_COLLISION);
});
it("defaults messageKey to auth_provider.errors.<lower>", {
timeout: 5000,
}, () => {
const err = authProviderError("MULTIPLE_PROVIDERS_REGISTERED");
expect(err.message).toContain(
"auth_provider.errors.multiple_providers_registered"
);
});
it("threads messageKey override", { timeout: 5000 }, () => {
const err = authProviderError("OAUTH_CALLBACK_INVALID_STATE", {
messageKey: "custom.oauth.bad-state",
});
expect(err.message).toContain("custom.oauth.bad-state");
});
it("threads SuaveplanErrorOptions context", { timeout: 5000 }, () => {
const err = authProviderError("PROVIDER_LIFECYCLE_FAILED", {
context: { phase: "install" },
});
expect(err.context?.phase).toBe("install");
});
});
describe("AuthProviderError direct construction", () => {
it("public constructor accepts the four args", { timeout: 5000 }, () => {
const err = new AuthProviderError(
AUTH_PROVIDER_CODES.PATH_COLLISION,
"auth_provider.errors.path_collision",
{ context: { foo: "bar" } }
);
expect(err.code).toBe(AUTH_PROVIDER_CODES.PATH_COLLISION);
expect(err.context?.foo).toBe("bar");
});
it("public constructor defaults options to {}", { timeout: 5000 }, () => {
const err = new AuthProviderError(
AUTH_PROVIDER_CODES.PATH_COLLISION,
"auth_provider.errors.path_collision"
);
expect(err.code).toBe(AUTH_PROVIDER_CODES.PATH_COLLISION);
expect(err.context).toBeUndefined();
});
});
describe("getAuthProviderCodeMetadata", () => {
it("returns metadata for a registered code", { timeout: 5000 }, () => {
const metadata = getAuthProviderCodeMetadata(
AUTH_PROVIDER_CODES.PATH_COLLISION
);
expect(metadata?.httpStatus).toBe(409);
});
it("returns undefined for an unregistered code", { timeout: 5000 }, () => {
expect(getAuthProviderCodeMetadata("UNKNOWN.CODE")).toBeUndefined();
});
});

View File

@@ -0,0 +1,113 @@
/**
* Stable error-code registry for `@suaveplan/api-auth-provider`. Every error
* thrown by the path-ownership registry, the collision detector, or a
* concrete provider's lifecycle wrapper surfaces one of these four codes.
*
* The registry uses the `defineCodes` builder from `@suaveplan/error`, which
* produces a frozen `{ REASON: "AUTH_PROVIDER.REASON" }` record so the
* literal-typed union is preserved through the public surface.
*/
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
export const AUTH_PROVIDER_CODES = defineCodes("AUTH_PROVIDER", [
"PATH_COLLISION",
"MULTIPLE_PROVIDERS_REGISTERED",
"PROVIDER_LIFECYCLE_FAILED",
"OAUTH_CALLBACK_INVALID_STATE",
] as const);
/** Union of every fully-qualified code emitted by this package. */
export type AuthProviderErrorCode =
(typeof AUTH_PROVIDER_CODES)[keyof typeof AUTH_PROVIDER_CODES];
/** Short keys (e.g. `"PATH_COLLISION"`) usable to look up a fully-qualified code. */
export type AuthProviderErrorKey = keyof typeof AUTH_PROVIDER_CODES;
/** Shape of an error-code metadata entry for documentation / HTTP mapping. */
export interface AuthProviderCodeMetadata {
readonly httpStatus: number;
readonly severity: "info" | "warning" | "error";
readonly retryable: boolean;
readonly description: string;
}
/** Mapping of every {@link AuthProviderErrorCode} to its HTTP/retry metadata. */
export const AUTH_PROVIDER_ERROR_METADATA: Readonly<
Record<AuthProviderErrorCode, AuthProviderCodeMetadata>
> = Object.freeze({
[AUTH_PROVIDER_CODES.PATH_COLLISION]: {
httpStatus: 409,
severity: "error",
retryable: false,
description:
"A plugin attempted to register a route inside the auth path prefix that is owned by the installed auth provider.",
},
[AUTH_PROVIDER_CODES.MULTIPLE_PROVIDERS_REGISTERED]: {
httpStatus: 409,
severity: "error",
retryable: false,
description:
"A second auth provider was registered while another provider already owned the path prefix.",
},
[AUTH_PROVIDER_CODES.PROVIDER_LIFECYCLE_FAILED]: {
httpStatus: 500,
severity: "error",
retryable: false,
description:
"A provider lifecycle hook (install/start/stop/uninstall) threw or supplied invalid input.",
},
[AUTH_PROVIDER_CODES.OAUTH_CALLBACK_INVALID_STATE]: {
httpStatus: 400,
severity: "warning",
retryable: false,
description:
"The OAuth callback was invoked with a `state` parameter that was not previously issued, has expired, or has already been consumed.",
},
} satisfies Record<AuthProviderErrorCode, AuthProviderCodeMetadata>);
/** Typed base class for every auth-provider error. */
export class AuthProviderError extends SuaveplanError<AuthProviderErrorCode> {
public constructor(
code: AuthProviderErrorCode,
messageKey: string,
options: SuaveplanErrorOptions = {}
) {
super(code, messageKey, options);
}
}
interface AuthProviderErrorOpts extends SuaveplanErrorOptions {
readonly messageKey?: string;
}
/**
* Factory — constructs an {@link AuthProviderError} with the given short key,
* defaulting the message key to `auth_provider.errors.<lower_case_key>`.
*/
export function authProviderError(
key: AuthProviderErrorKey,
opts: AuthProviderErrorOpts = {}
): AuthProviderError {
const code = AUTH_PROVIDER_CODES[key];
const messageKey =
opts.messageKey ?? `auth_provider.errors.${key.toLowerCase()}`;
const { messageKey: _omit, ...rest } = opts;
return new AuthProviderError(code, messageKey, rest);
}
/**
* Lookup helper for HTTP problem-detail mappers. Returns `undefined` for any
* code outside the registry.
*/
export function getAuthProviderCodeMetadata(
code: string
): AuthProviderCodeMetadata | undefined {
return (
AUTH_PROVIDER_ERROR_METADATA as Record<string, AuthProviderCodeMetadata>
)[code];
}

View File

@@ -0,0 +1,12 @@
export type {
AuthProviderCodeMetadata,
AuthProviderErrorCode,
AuthProviderErrorKey,
} from "./error-codes.js";
export {
AUTH_PROVIDER_CODES,
AUTH_PROVIDER_ERROR_METADATA,
AuthProviderError,
authProviderError,
getAuthProviderCodeMetadata,
} from "./error-codes.js";

View File

@@ -0,0 +1,77 @@
/**
* `@suaveplan/api-auth-provider` — root barrel.
*
* Namespace re-exports per subpath plus explicit named re-exports for the
* most common types/values. No `export *` (CLAUDE.md rule 10).
*/
// capability-id
export * as capabilityId from "./capability-id/index.js";
export {
AUTH_PROVIDER_CAPABILITY_ID,
type AuthProviderCapabilityId,
isAuthProviderCapabilityId,
} from "./capability-id/index.js";
// collision-detector
export * as collisionDetector from "./collision-detector/index.js";
export {
type CollisionReport,
type CollisionReportEntry,
type DetectCollisionsInput,
detectAuthPathCollisions,
isInsidePrefix,
normalisePrefix,
type PluginDescriptor,
type PluginRouteDescriptor,
} from "./collision-detector/index.js";
// error-codes
export * as errorCodes from "./error-codes/index.js";
export {
AUTH_PROVIDER_CODES,
AUTH_PROVIDER_ERROR_METADATA,
type AuthProviderCodeMetadata,
AuthProviderError,
type AuthProviderErrorCode,
type AuthProviderErrorKey,
authProviderError,
getAuthProviderCodeMetadata,
} from "./error-codes/index.js";
// path-ownership
export * as pathOwnership from "./path-ownership/index.js";
export {
type OwnershipClock,
PathOwnership,
type PathOwnershipClaim,
type PathOwnershipOptions,
type ProviderClaim,
} from "./path-ownership/index.js";
// provider
export * as provider from "./provider/index.js";
export {
type AuthPathPrefix,
AuthPathPrefixSchema,
type AuthProviderAdapter,
type AuthProviderLifecycle,
authPathPrefix,
DEFAULT_AUTH_PATH_PREFIX,
isAuthPathPrefix,
} from "./provider/index.js";
// route-surface
export * as routeSurface from "./route-surface/index.js";
export {
listProviderRouteNames,
lookupProviderRoute,
PROVIDER_ROUTE_SURFACE,
type ProviderRouteEntry,
type ProviderRouteName,
resolveProviderRoutePath,
} from "./route-surface/index.js";
// testing
export * as testing from "./testing/index.js";
export {
type AuthProviderConformanceCategory,
type AuthProviderConformanceFactory,
type AuthProviderConformanceOptions,
type ConformanceScenarioResult,
runAuthProviderConformance,
} from "./testing/index.js";

View File

@@ -0,0 +1,9 @@
export {
type OwnershipClock,
PathOwnership,
type PathOwnershipOptions,
} from "./path-ownership.js";
export type {
PathOwnershipClaim,
ProviderClaim,
} from "./path-ownership.types.js";

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,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_PROVIDER_CODES,
AuthProviderError,
} from "../error-codes/error-codes.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
import { PathOwnership } from "./path-ownership.js";
const makeProvider = (
id: string,
pathPrefix = "/auth"
): AuthProviderAdapter => ({
id,
pathPrefix,
routes: () => {},
lifecycle: {
install: async () => {},
start: async () => {},
stop: async () => {},
uninstall: async () => {},
},
});
describe("PathOwnership", () => {
it("starts with no provider registered", { timeout: 5000 }, () => {
const reg = new PathOwnership();
expect(reg.hasProvider()).toBe(false);
expect(reg.current()).toBeNull();
});
it("registers the first provider and records claimedAt via injected clock", {
timeout: 5000,
}, () => {
const reg = new PathOwnership({ clock: () => 12345 });
const provider = makeProvider("better-auth");
const claim = reg.register(provider);
expect(claim.providerId).toBe("better-auth");
expect(claim.prefix).toBe("/auth");
expect(claim.claimedAt).toBe(12345);
expect(reg.hasProvider()).toBe(true);
const current = reg.current();
expect(current?.provider).toBe(provider);
});
it("defaults clock to Date.now", { timeout: 5000 }, () => {
const reg = new PathOwnership();
const before = Date.now();
const claim = reg.register(makeProvider("rolled"));
const after = Date.now();
expect(claim.claimedAt).toBeGreaterThanOrEqual(before);
expect(claim.claimedAt).toBeLessThanOrEqual(after);
});
it("re-registering the same instance is idempotent", {
timeout: 5000,
}, () => {
const reg = new PathOwnership({ clock: () => 7 });
const provider = makeProvider("better-auth");
const first = reg.register(provider);
const second = reg.register(provider);
expect(second).toBe(first);
});
it("re-registering a different instance with the same id is idempotent", {
timeout: 5000,
}, () => {
const reg = new PathOwnership({ clock: () => 7 });
const a = makeProvider("better-auth");
const b = makeProvider("better-auth", "/auth");
reg.register(a);
const claim = reg.register(b);
expect(claim.providerId).toBe("better-auth");
});
it("rejects a different provider with MULTIPLE_PROVIDERS_REGISTERED", {
timeout: 5000,
}, () => {
const reg = new PathOwnership();
reg.register(makeProvider("better-auth"));
let caught: unknown;
try {
reg.register(makeProvider("rolled"));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuthProviderError);
expect((caught as AuthProviderError).code).toBe(
AUTH_PROVIDER_CODES.MULTIPLE_PROVIDERS_REGISTERED
);
expect((caught as AuthProviderError).context?.incomingProviderId).toBe(
"rolled"
);
});
it("release() drops the claim so a new provider can register", {
timeout: 5000,
}, () => {
const reg = new PathOwnership();
reg.register(makeProvider("better-auth"));
reg.release();
expect(reg.hasProvider()).toBe(false);
const claim = reg.register(makeProvider("rolled"));
expect(claim.providerId).toBe("rolled");
});
});

View File

@@ -0,0 +1,84 @@
/**
* Runtime registry tracking which provider OWNS the auth path prefix in a
* given host. Exactly one provider may be installed; the second `register`
* call with a different provider MUST throw
* `AUTH_PROVIDER.MULTIPLE_PROVIDERS_REGISTERED`. Re-registering the same
* provider instance is idempotent (no-op).
*/
import { authProviderError } from "../error-codes/error-codes.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
import type {
PathOwnershipClaim,
ProviderClaim,
} from "./path-ownership.types.js";
/** Time source for the registry — injectable for determinism. */
export type OwnershipClock = () => number;
/** Construction options for {@link PathOwnership}. */
export interface PathOwnershipOptions {
/** Optional clock for the `claimedAt` field. Defaults to `Date.now`. */
readonly clock?: OwnershipClock;
}
/**
* Path-ownership registry. Instances are isolated — tests and multi-host
* deployments construct a fresh registry rather than sharing process state.
*/
export class PathOwnership {
readonly #clock: OwnershipClock;
#current: ProviderClaim | null = null;
public constructor(options: PathOwnershipOptions = {}) {
this.#clock = options.clock ?? (() => Date.now());
}
/**
* Claim `provider.pathPrefix` for the given provider. The second call
* with a DIFFERENT provider throws
* `AUTH_PROVIDER.MULTIPLE_PROVIDERS_REGISTERED`. Calling with the SAME
* provider instance (or one whose `id` matches the current claim) is a
* no-op.
*/
public register(provider: AuthProviderAdapter): PathOwnershipClaim {
if (this.#current === null) {
const claim: PathOwnershipClaim = {
providerId: provider.id,
prefix: provider.pathPrefix,
claimedAt: this.#clock(),
};
this.#current = { provider, claim };
return claim;
}
const existing = this.#current;
if (
existing.provider === provider ||
existing.provider.id === provider.id
) {
return existing.claim;
}
throw authProviderError("MULTIPLE_PROVIDERS_REGISTERED", {
context: {
incomingProviderId: provider.id,
incumbentProviderId: existing.provider.id,
prefix: existing.provider.pathPrefix,
},
});
}
/** Drop the current claim. Used by host shutdown / hot-reload. */
public release(): void {
this.#current = null;
}
/** Current claim, or `null` when no provider is installed. */
public current(): ProviderClaim | null {
return this.#current;
}
/** Convenience predicate — `true` when any provider is registered. */
public hasProvider(): boolean {
return this.#current !== null;
}
}

View File

@@ -0,0 +1,24 @@
/**
* Type-level shape of the path-ownership claim. The runtime registry lives in
* `path-ownership.ts`.
*/
import type { AuthProviderAdapter } from "../provider/provider.types.js";
/** Snapshot of a single provider's ownership claim. */
export interface PathOwnershipClaim {
/** Provider that claimed the prefix. */
readonly providerId: string;
/** Owned path prefix (e.g. `/auth`). */
readonly prefix: string;
/** Wall-clock timestamp the claim was made (ms since epoch). */
readonly claimedAt: number;
}
/** Adapter-typed claim — useful for callers that need the full provider. */
export interface ProviderClaim<
Provider extends AuthProviderAdapter = AuthProviderAdapter,
> {
readonly provider: Provider;
readonly claim: PathOwnershipClaim;
}

View File

@@ -0,0 +1,12 @@
export {
AuthPathPrefixSchema,
authPathPrefix,
DEFAULT_AUTH_PATH_PREFIX,
firstIssueMessage,
isAuthPathPrefix,
} from "./provider.js";
export type {
AuthPathPrefix,
AuthProviderAdapter,
AuthProviderLifecycle,
} from "./provider.types.js";

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,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_PROVIDER_CODES,
AuthProviderError,
} from "../error-codes/error-codes.js";
import {
AuthPathPrefixSchema,
authPathPrefix,
DEFAULT_AUTH_PATH_PREFIX,
firstIssueMessage,
isAuthPathPrefix,
} from "./provider.js";
describe("authPathPrefix", () => {
it("returns a branded prefix for a legal value", { timeout: 5000 }, () => {
const prefix = authPathPrefix("/auth");
expect(prefix).toBe("/auth");
});
it("accepts the bare root prefix '/'", { timeout: 5000 }, () => {
const prefix = authPathPrefix("/");
expect(prefix).toBe("/");
});
it("rejects an empty string", { timeout: 5000 }, () => {
let caught: unknown;
try {
authPathPrefix("");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(AuthProviderError);
expect((caught as AuthProviderError).code).toBe(
AUTH_PROVIDER_CODES.PROVIDER_LIFECYCLE_FAILED
);
});
it("rejects a missing leading slash", { timeout: 5000 }, () => {
expect(() => authPathPrefix("auth")).toThrow();
});
it("rejects a trailing slash", { timeout: 5000 }, () => {
expect(() => authPathPrefix("/auth/")).toThrow();
});
it("rejects an embedded parameter", { timeout: 5000 }, () => {
expect(() => authPathPrefix("/auth/:id")).toThrow();
});
it("rejects whitespace", { timeout: 5000 }, () => {
expect(() => authPathPrefix("/auth ")).toThrow();
});
});
describe("isAuthPathPrefix", () => {
it("returns true for a legal prefix", { timeout: 5000 }, () => {
expect(isAuthPathPrefix("/auth")).toBe(true);
expect(isAuthPathPrefix("/myauth/v2")).toBe(true);
expect(isAuthPathPrefix("/")).toBe(true);
});
it("returns false for malformed input", { timeout: 5000 }, () => {
expect(isAuthPathPrefix("auth")).toBe(false);
expect(isAuthPathPrefix("/auth/")).toBe(false);
expect(isAuthPathPrefix("/auth/:id")).toBe(false);
});
});
describe("AuthPathPrefixSchema", () => {
it("safeParse exposes structured issue list on failure", {
timeout: 5000,
}, () => {
const parsed = AuthPathPrefixSchema.safeParse("missing-leading-slash");
expect(parsed.success).toBe(false);
if (!parsed.success) {
expect(parsed.error.issues.length).toBeGreaterThan(0);
}
});
});
describe("firstIssueMessage", () => {
it("returns the first issue message when present", {
timeout: 5000,
}, () => {
expect(
firstIssueMessage([{ message: "first" }, { message: "second" }])
).toBe("first");
});
it("returns 'invalid' for an empty issue list", { timeout: 5000 }, () => {
expect(firstIssueMessage([])).toBe("invalid");
});
});
describe("DEFAULT_AUTH_PATH_PREFIX", () => {
it("is /auth", { timeout: 5000 }, () => {
expect(DEFAULT_AUTH_PATH_PREFIX).toBe("/auth");
});
});

View File

@@ -0,0 +1,76 @@
/**
* Runtime helpers around the `AuthProviderAdapter` contract — Zod input
* validation for `pathPrefix`, default-prefix constant, and a tagged-template
* helper for asserting a string is a well-formed prefix.
*/
import z from "zod";
import { authProviderError } from "../error-codes/error-codes.js";
import type { AuthPathPrefix } from "./provider.types.js";
/** The default `/auth` prefix used when a provider doesn't override it. */
export const DEFAULT_AUTH_PATH_PREFIX = "/auth";
/**
* Zod schema asserting a string is a legal auth path prefix:
* - starts with `/`
* - does not contain `:` (no path parameters)
* - does not contain whitespace
* - either equals `/` OR does not end with `/`
*/
export const AuthPathPrefixSchema = z
.string()
.min(1)
.refine((value) => value.startsWith("/"), {
message: "auth path prefix must start with '/'",
})
.refine((value) => !/[\s]/.test(value), {
message: "auth path prefix must not contain whitespace",
})
.refine((value) => !value.includes(":"), {
message: "auth path prefix must not contain a path parameter",
})
.refine((value) => value === "/" || !value.endsWith("/"), {
message: "auth path prefix must not end with '/' (except '/' itself)",
});
/**
* Extract the first issue's message from a Zod issue list. Returns
* `"invalid"` when the list is empty; the public refinements above guarantee
* a populated list, so the fallback exists only as a defensive default.
*/
export function firstIssueMessage(
issues: ReadonlyArray<{ readonly message: string }>
): string {
for (const issue of issues) {
return issue.message;
}
return "invalid";
}
/**
* Brand-validate a raw string as an `AuthPathPrefix`. Throws
* `AUTH_PROVIDER.PROVIDER_LIFECYCLE_FAILED` on invalid input — this runs at
* install time so callers see the error before the host installs the provider.
*/
export function authPathPrefix(value: string): AuthPathPrefix {
const parsed = AuthPathPrefixSchema.safeParse(value);
if (!parsed.success) {
throw authProviderError("PROVIDER_LIFECYCLE_FAILED", {
messageKey: "auth_provider.errors.invalid_path_prefix",
context: {
pathPrefix: value,
reason: firstIssueMessage(parsed.error.issues),
},
});
}
return parsed.data as AuthPathPrefix;
}
/**
* Type-guard convenience — returns `true` when the supplied string passes the
* prefix schema.
*/
export function isAuthPathPrefix(value: string): boolean {
return AuthPathPrefixSchema.safeParse(value).success;
}

View File

@@ -0,0 +1,69 @@
/**
* `AuthProviderAdapter` contract — types-only.
*
* A provider is the optional account-lifecycle half of the auth split (design
* §2.3). Hosted-IdP deployments (Clerk, Auth0, Cognito) do not install one;
* self-hosted deployments install exactly one (better-auth or rolled).
*/
import type {
CapabilityRequirements,
PluginContext,
Router,
} from "@suaveplan/api-types";
/**
* Lifecycle hook quartet that every provider implements. Mirrors the generic
* plugin lifecycle (`install`/`start`/`stop`/`uninstall`) — the host's
* `api-lifecycle` FSM drives transitions, providers expose the work that
* happens at each edge.
*/
export interface AuthProviderLifecycle<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
install(ctx: PluginContext<Caps>): Promise<void>;
start(ctx: PluginContext<Caps>): Promise<void>;
stop(ctx: PluginContext<Caps>): Promise<void>;
uninstall(ctx: PluginContext<Caps>): Promise<void>;
}
/**
* Path-prefix declaration — branded against accidental string substitution.
* Must start with a forward slash, must not end with one (except `/`), and
* must not contain a path parameter (Zod-validated at construction).
*/
export type AuthPathPrefix = string & {
readonly __authPathPrefix: unique symbol;
};
/**
* Abstract auth-provider adapter. Concrete providers (`api-auth-better-auth`,
* `api-auth-rolled`) structurally implement this. The host MUST refuse to
* install more than one — see `path-ownership`.
*
* @example
* const provider: AuthProviderAdapter = {
* id: "better-auth",
* pathPrefix: "/auth",
* routes: (router, _ctx) => {
* router.post("/login", { auth: null }, async () => new Response());
* },
* lifecycle: { install: async () => {}, start: async () => {}, stop: async () => {}, uninstall: async () => {} },
* };
*/
export interface AuthProviderAdapter<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
/** Stable identifier (e.g. `"better-auth"`, `"rolled"`). */
readonly id: string;
/** Owned path prefix (default `/auth`). Configurable per deployment. */
readonly pathPrefix: AuthPathPrefix | string;
/**
* Called once at install time with a router slot scoped to `pathPrefix`.
* Providers register their route surface (subset of
* `PROVIDER_ROUTE_SURFACE`) here.
*/
routes(router: Router<Caps>, ctx: PluginContext<Caps>): void;
/** Install/start/stop/uninstall hooks. */
readonly lifecycle: AuthProviderLifecycle<Caps>;
}

View File

@@ -0,0 +1,10 @@
export {
listProviderRouteNames,
lookupProviderRoute,
PROVIDER_ROUTE_SURFACE,
resolveProviderRoutePath,
} from "./route-surface.js";
export type {
ProviderRouteEntry,
ProviderRouteName,
} from "./route-surface.types.js";

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,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
listProviderRouteNames,
lookupProviderRoute,
PROVIDER_ROUTE_SURFACE,
resolveProviderRoutePath,
} from "./route-surface.js";
import type { ProviderRouteName } from "./route-surface.types.js";
const EXPECTED_NAMES: readonly ProviderRouteName[] = [
"register",
"login",
"logout",
"refresh",
"oauth.start",
"oauth.callback",
"verify-email",
"forgot-password",
"reset-password",
];
describe("PROVIDER_ROUTE_SURFACE", () => {
it("enumerates every documented canonical route name", {
timeout: 5000,
}, () => {
const names = PROVIDER_ROUTE_SURFACE.map((entry) => entry.name);
expect(names).toEqual(EXPECTED_NAMES);
});
it("every entry has path, method, name, summary", { timeout: 5000 }, () => {
for (const entry of PROVIDER_ROUTE_SURFACE) {
expect(entry.path.startsWith("/")).toBe(true);
expect(entry.method.length).toBeGreaterThan(0);
expect(entry.name.length).toBeGreaterThan(0);
expect(entry.summary.length).toBeGreaterThan(0);
}
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(PROVIDER_ROUTE_SURFACE)).toBe(true);
});
});
describe("lookupProviderRoute", () => {
it("returns the entry for a known name", { timeout: 5000 }, () => {
const entry = lookupProviderRoute("login");
expect(entry?.path).toBe("/login");
expect(entry?.method).toBe("POST");
});
it("returns undefined for an unknown name", { timeout: 5000 }, () => {
expect(
lookupProviderRoute("bogus" as ProviderRouteName)
).toBeUndefined();
});
});
describe("resolveProviderRoutePath", () => {
it("prepends the prefix to the canonical path", { timeout: 5000 }, () => {
const entry = lookupProviderRoute("login");
expect(entry).toBeDefined();
if (entry !== undefined) {
expect(resolveProviderRoutePath("/auth", entry)).toBe(
"/auth/login"
);
expect(resolveProviderRoutePath("/myauth", entry)).toBe(
"/myauth/login"
);
}
});
it("treats '/' as a no-op prefix", { timeout: 5000 }, () => {
const entry = lookupProviderRoute("register");
expect(entry).toBeDefined();
if (entry !== undefined) {
expect(resolveProviderRoutePath("/", entry)).toBe("/register");
}
});
});
describe("listProviderRouteNames", () => {
it("returns names in declaration order", { timeout: 5000 }, () => {
expect(listProviderRouteNames()).toEqual(EXPECTED_NAMES);
});
});

View File

@@ -0,0 +1,102 @@
/**
* The canonical set of routes an auth provider OWNS, relative to its
* `pathPrefix`. A concrete provider MAY implement any subset. The host uses
* this surface to:
* 1. Detect collisions with plugin-declared routes (see `collision-detector`).
* 2. Emit a complete OpenAPI security-scheme + paths block at build time.
* 3. Drive the conformance harness — every documented route name MUST have
* a test category in `runAuthProviderConformance`.
*/
import type {
ProviderRouteEntry,
ProviderRouteName,
} from "./route-surface.types.js";
/**
* Frozen canonical surface. Adding a new entry requires an OpenSpec change —
* downstream consumers (`api-openapi`, `api-auth-better-auth`, the
* conformance harness) project off this constant.
*/
export const PROVIDER_ROUTE_SURFACE: ReadonlyArray<ProviderRouteEntry> =
Object.freeze([
Object.freeze({
path: "/register",
method: "POST",
name: "register",
summary: "Create a new account",
}),
Object.freeze({
path: "/login",
method: "POST",
name: "login",
summary: "Exchange credentials for a session",
}),
Object.freeze({
path: "/logout",
method: "POST",
name: "logout",
summary: "Invalidate the current session",
}),
Object.freeze({
path: "/refresh",
method: "POST",
name: "refresh",
summary: "Refresh a session / rotate refresh token",
}),
Object.freeze({
path: "/oauth/:provider/start",
method: "GET",
name: "oauth.start",
summary: "Begin the OAuth authorisation-code flow",
}),
Object.freeze({
path: "/oauth/:provider/callback",
method: "GET",
name: "oauth.callback",
summary: "Complete the OAuth authorisation-code flow",
}),
Object.freeze({
path: "/verify-email/:token",
method: "GET",
name: "verify-email",
summary: "Verify an email-address ownership token",
}),
Object.freeze({
path: "/forgot-password",
method: "POST",
name: "forgot-password",
summary: "Request a password-reset email",
}),
Object.freeze({
path: "/reset-password",
method: "POST",
name: "reset-password",
summary: "Submit a new password using a reset token",
}),
] as const);
/** Lookup by stable name. Returns `undefined` for unknown names. */
export function lookupProviderRoute(
name: ProviderRouteName
): ProviderRouteEntry | undefined {
for (const entry of PROVIDER_ROUTE_SURFACE) {
if (entry.name === name) {
return entry;
}
}
return undefined;
}
/** Full mounted path of a canonical route under the given prefix. */
export function resolveProviderRoutePath(
prefix: string,
entry: ProviderRouteEntry
): string {
return prefix === "/" ? entry.path : `${prefix}${entry.path}`;
}
/** All route names in declaration order — used by the conformance harness. */
export function listProviderRouteNames(): ReadonlyArray<ProviderRouteName> {
return PROVIDER_ROUTE_SURFACE.map((entry) => entry.name);
}

View File

@@ -0,0 +1,34 @@
/**
* Type-level surface a concrete provider declares in its manifest. A provider
* MAY implement any subset of {@link PROVIDER_ROUTE_SURFACE}.
*/
import type { HttpMethod } from "@suaveplan/api-types/router";
/**
* One canonical provider-owned route, relative to `pathPrefix`. The full
* mounted path is `${pathPrefix}${path}` — e.g. `/auth/login` when the
* provider keeps the default prefix.
*/
export interface ProviderRouteEntry {
/** Path relative to the provider's pathPrefix. Starts with `/`. */
readonly path: string;
/** HTTP method the canonical surface uses. */
readonly method: HttpMethod;
/** Stable name used by the conformance harness + capability descriptors. */
readonly name: ProviderRouteName;
/** OpenAPI summary / human-readable description. */
readonly summary: string;
}
/** The closed set of canonical provider route names. */
export type ProviderRouteName =
| "register"
| "login"
| "logout"
| "refresh"
| "oauth.start"
| "oauth.callback"
| "verify-email"
| "forgot-password"
| "reset-password";

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.

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_PROVIDER_CODES,
AuthProviderError,
} from "../error-codes/error-codes.js";
import { PathOwnership } from "../path-ownership/path-ownership.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
import {
isRegisterIdempotent,
rejectsSecondProvider,
runAuthProviderConformance,
} from "./conformance.js";
import type { AuthProviderConformanceCategory } from "./conformance.types.js";
const makeProvider = (): AuthProviderAdapter => ({
id: "stub-provider",
pathPrefix: "/auth",
routes: () => {},
lifecycle: {
install: async () => {},
start: async () => {},
stop: async () => {},
uninstall: async () => {},
},
});
describe("runAuthProviderConformance", () => {
it("covers path-ownership, route-surface, lifecycle, collision categories", {
timeout: 5000,
}, () => {
const results = runAuthProviderConformance({
label: "stub",
factory: makeProvider,
});
const categories = new Set<AuthProviderConformanceCategory>();
for (const r of results) {
categories.add(r.category);
}
expect(categories.has("path-ownership")).toBe(true);
expect(categories.has("route-surface")).toBe(true);
expect(categories.has("lifecycle")).toBe(true);
expect(categories.has("collision")).toBe(true);
});
it("passes every scenario for a well-formed stub provider", {
timeout: 5000,
}, () => {
const results = runAuthProviderConformance({
label: "stub",
factory: makeProvider,
});
for (const r of results) {
expect(r.passed).toBe(true);
}
});
it("returns a frozen result list", { timeout: 5000 }, () => {
const results = runAuthProviderConformance({
label: "stub",
factory: makeProvider,
});
expect(Object.isFrozen(results)).toBe(true);
});
it("flags a provider whose lifecycle quartet is missing", {
timeout: 5000,
}, () => {
const broken: AuthProviderAdapter = {
id: "broken",
pathPrefix: "/auth",
routes: () => {},
lifecycle: {
install: async () => {},
start: async () => {},
stop: undefined as unknown as () => Promise<void>,
uninstall: async () => {},
},
};
const results = runAuthProviderConformance({
label: "broken",
factory: () => broken,
});
const lifecycleResult = results.find(
(r) =>
r.category === "lifecycle" &&
r.name === "lifecycle quartet present"
);
expect(lifecycleResult?.passed).toBe(false);
});
});
describe("isRegisterIdempotent", () => {
it("returns true when re-register does not throw", {
timeout: 5000,
}, () => {
const reg = new PathOwnership();
const provider = makeProvider();
reg.register(provider);
expect(isRegisterIdempotent(reg, provider)).toBe(true);
});
it("returns false when re-register throws", { timeout: 5000 }, () => {
const reg = new PathOwnership();
reg.register(makeProvider());
const distinct: AuthProviderAdapter = {
...makeProvider(),
id: "different",
};
expect(isRegisterIdempotent(reg, distinct)).toBe(false);
});
});
describe("rejectsSecondProvider", () => {
it("returns true when the registry throws MULTIPLE_PROVIDERS_REGISTERED", {
timeout: 5000,
}, () => {
const reg = new PathOwnership();
reg.register(makeProvider());
const distinct: AuthProviderAdapter = {
...makeProvider(),
id: "different",
};
expect(rejectsSecondProvider(reg, distinct)).toBe(true);
});
it("returns false when register succeeds (no throw)", {
timeout: 5000,
}, () => {
const reg = new PathOwnership();
expect(rejectsSecondProvider(reg, makeProvider())).toBe(false);
});
it("returns false when a different error is thrown", {
timeout: 5000,
}, () => {
const fake = {
register: () => {
throw new AuthProviderError(
AUTH_PROVIDER_CODES.PROVIDER_LIFECYCLE_FAILED,
"auth_provider.errors.other"
);
},
} as unknown as PathOwnership;
expect(rejectsSecondProvider(fake, makeProvider())).toBe(false);
});
it("returns false when a non-Error value is thrown", {
timeout: 5000,
}, () => {
const fake = {
register: () => {
throw "string-error";
},
} as unknown as PathOwnership;
expect(rejectsSecondProvider(fake, makeProvider())).toBe(false);
});
});

View File

@@ -0,0 +1,167 @@
/**
* Reusable conformance assertions every concrete `AuthProviderAdapter` MUST
* pass. Implemented as a set of pure functions so the runner-agnostic
* assertions in this package can be driven from `@suaveplan/testing/runner`
* without forcing concrete providers onto a specific runner.
*
* `runAuthProviderConformance` returns a per-category list of scenario
* results; concrete providers wrap it in `describe`/`it` from their own test
* file (`import { describe, it, expect } from "@suaveplan/testing/runner";`).
*/
import { detectAuthPathCollisions } from "../collision-detector/collision-detector.js";
import { AUTH_PROVIDER_CODES } from "../error-codes/error-codes.js";
import { PathOwnership } from "../path-ownership/path-ownership.js";
import { isAuthPathPrefix } from "../provider/provider.js";
import type { AuthProviderAdapter } from "../provider/provider.types.js";
import { listProviderRouteNames } from "../route-surface/route-surface.js";
import type {
AuthProviderConformanceCategory,
AuthProviderConformanceOptions,
ConformanceScenarioResult,
} from "./conformance.types.js";
/**
* Returns `true` when re-registering the same provider on an existing
* registry does NOT throw (the documented idempotency invariant).
*/
export function isRegisterIdempotent(
registry: PathOwnership,
provider: AuthProviderAdapter
): boolean {
try {
registry.register(provider);
return true;
} catch {
return false;
}
}
/**
* Returns `true` when attempting to register `incoming` against a registry
* already owned by another provider throws
* `AUTH_PROVIDER.MULTIPLE_PROVIDERS_REGISTERED`. Any other thrown shape — or
* no throw at all — returns `false`.
*/
export function rejectsSecondProvider(
registry: PathOwnership,
incoming: AuthProviderAdapter
): boolean {
try {
registry.register(incoming);
return false;
} catch (err) {
const code =
err instanceof Error && "code" in err
? (err as { readonly code: unknown }).code
: undefined;
return code === AUTH_PROVIDER_CODES.MULTIPLE_PROVIDERS_REGISTERED;
}
}
/**
* Drive the harness against the supplied factory. Returns one
* `ConformanceScenarioResult` per scenario, grouped per category. Concrete
* providers iterate the result list and turn each entry into an `it()`
* assertion.
*/
export function runAuthProviderConformance(
options: AuthProviderConformanceOptions
): ReadonlyArray<ConformanceScenarioResult> {
const results: ConformanceScenarioResult[] = [];
// --- path-ownership ----------------------------------------------------
const pathOwnership: AuthProviderConformanceCategory = "path-ownership";
{
const provider = options.factory();
const registry = new PathOwnership();
registry.register(provider);
results.push({
category: pathOwnership,
name: "registers provider on first call",
passed: registry.hasProvider(),
});
results.push({
category: pathOwnership,
name: "re-registering same provider is idempotent",
passed: isRegisterIdempotent(registry, provider),
});
const second = options.factory();
const distinct: typeof second = { ...second, id: `${second.id}.alt` };
results.push({
category: pathOwnership,
name: "second provider rejected with MULTIPLE_PROVIDERS_REGISTERED",
passed: rejectsSecondProvider(registry, distinct),
});
}
// --- route-surface -----------------------------------------------------
const routeSurface: AuthProviderConformanceCategory = "route-surface";
{
const provider = options.factory();
results.push({
category: routeSurface,
name: "pathPrefix is a legal prefix",
passed: isAuthPathPrefix(provider.pathPrefix),
});
const names = listProviderRouteNames();
results.push({
category: routeSurface,
name: "canonical surface enumerates every documented name",
passed: names.length >= 9,
detail: `canonical names: ${names.join(", ")}`,
});
}
// --- lifecycle ---------------------------------------------------------
const lifecycle: AuthProviderConformanceCategory = "lifecycle";
{
const provider = options.factory();
const lc = provider.lifecycle;
results.push({
category: lifecycle,
name: "lifecycle quartet present",
passed:
typeof lc.install === "function" &&
typeof lc.start === "function" &&
typeof lc.stop === "function" &&
typeof lc.uninstall === "function",
});
}
// --- collision ---------------------------------------------------------
const collision: AuthProviderConformanceCategory = "collision";
{
const provider = options.factory();
const innerReport = detectAuthPathCollisions({
provider,
plugins: [
{
name: "plugin-x",
routes: [{ path: `${provider.pathPrefix}/custom` }],
},
],
});
results.push({
category: collision,
name: "plugin route inside auth prefix is reported",
passed: innerReport.hasCollisions,
});
const outerReport = detectAuthPathCollisions({
provider,
plugins: [
{
name: "plugin-y",
routes: [{ path: "/billing/invoices" }],
},
],
});
results.push({
category: collision,
name: "plugin route outside auth prefix is not reported",
passed: !outerReport.hasCollisions,
});
}
return Object.freeze(results);
}

View File

@@ -0,0 +1,40 @@
/**
* Type-level surface for the provider conformance harness. Concrete providers
* pass an `AuthProviderConformanceFactory` that constructs a fresh provider
* instance for each scenario, plus a label used in the `describe()` name.
*/
import type { AuthProviderAdapter } from "../provider/provider.types.js";
/** A factory returns a fresh provider instance for each scenario. */
export type AuthProviderConformanceFactory = () => AuthProviderAdapter;
/** Options passed to {@link runAuthProviderConformance}. */
export interface AuthProviderConformanceOptions {
/** Human-readable label — used in the outer `describe()` block. */
readonly label: string;
/** Factory constructing a fresh provider for each scenario. */
readonly factory: AuthProviderConformanceFactory;
}
/**
* Categories the harness drives. Concrete providers covering more behaviour
* MAY add their own scenarios alongside the harness — the harness pins the
* minimum bar every provider MUST clear.
*/
export type AuthProviderConformanceCategory =
| "path-ownership"
| "route-surface"
| "lifecycle"
| "collision";
/**
* Result of a synchronous-call scenario invocation. The harness uses this so
* runner-agnostic assertions can be expressed without importing the runner.
*/
export interface ConformanceScenarioResult {
readonly category: AuthProviderConformanceCategory;
readonly name: string;
readonly passed: boolean;
readonly detail?: string;
}

View File

@@ -0,0 +1,11 @@
export {
isRegisterIdempotent,
rejectsSecondProvider,
runAuthProviderConformance,
} from "./conformance.js";
export type {
AuthProviderConformanceCategory,
AuthProviderConformanceFactory,
AuthProviderConformanceOptions,
ConformanceScenarioResult,
} from "./conformance.types.js";

View File

@@ -0,0 +1,99 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PACKAGE_ROOT = join(__dirname, "..");
interface PackageJson {
readonly name: string;
readonly version: string;
readonly dependencies?: Readonly<Record<string, string>>;
readonly devDependencies?: Readonly<Record<string, string>>;
readonly peerDependencies?: Readonly<Record<string, string>>;
readonly sideEffects?: boolean;
}
const pkg = JSON.parse(
readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")
) as PackageJson;
describe("package metadata", () => {
it("name is @suaveplan/api-auth-provider", { timeout: 5000 }, () => {
expect(pkg.name).toBe("@suaveplan/api-auth-provider");
});
it("version is 0.1.0 (genesis stage-1)", { timeout: 5000 }, () => {
expect(pkg.version).toBe("0.1.0");
});
it("sideEffects is false", { timeout: 5000 }, () => {
expect(pkg.sideEffects).toBe(false);
});
});
describe("dependencies", () => {
it("depends on @suaveplan/api-types via workspace range", {
timeout: 5000,
}, () => {
expect(pkg.dependencies?.["@suaveplan/api-types"]).toBe("workspace:^");
});
it("depends on @suaveplan/error via workspace range", {
timeout: 5000,
}, () => {
expect(pkg.dependencies?.["@suaveplan/error"]).toBe("workspace:^");
});
it("depends on tslib via catalog:build", { timeout: 5000 }, () => {
expect(pkg.dependencies?.tslib).toBe("catalog:build");
});
it("depends on zod via catalog:forms", { timeout: 5000 }, () => {
expect(pkg.dependencies?.zod).toBe("catalog:forms");
});
});
function walkTsFiles(root: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const full = join(root, entry.name);
if (entry.isDirectory()) {
out.push(...walkTsFiles(full));
} else if (entry.isFile() && entry.name.endsWith(".ts")) {
out.push(full);
}
}
return out;
}
describe("zod import form", () => {
it("every src file uses default-import z", { timeout: 5000 }, () => {
const files = walkTsFiles(join(PACKAGE_ROOT, "src"));
const offenders: string[] = [];
for (const file of files) {
const text = readFileSync(file, "utf8");
if (/from\s+['"]zod['"]/.test(text)) {
if (!/import\s+z\s+from\s+['"]zod['"]/.test(text)) {
offenders.push(file);
}
if (/import\s*\{\s*z\s*[,}]/.test(text)) {
offenders.push(`${file} (named-import)`);
}
}
}
expect(offenders).toEqual([]);
});
});
describe("no static-analysis suppression comments", () => {
it("no biome-ignore comments anywhere in src", { timeout: 5000 }, () => {
const files = walkTsFiles(join(PACKAGE_ROOT, "src"));
const offenders: string[] = [];
for (const file of files) {
const text = readFileSync(file, "utf8");
if (
/biome-ignore|eslint-disable|@ts-ignore|@ts-expect-error/.test(
text
)
) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-auth-provider",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@suaveplan/api-types",
"@suaveplan/error",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,3 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig();