Initial Commit
This commit is contained in:
68
packages/api-stack/api/api-auth-provider/README.md
Normal file
68
packages/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.
|
||||
|
||||
131
packages/api-stack/api/api-auth-provider/package.json
Normal file
131
packages/api-stack/api/api-auth-provider/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUTH_PROVIDER_CAPABILITY_ID,
|
||||
isAuthProviderCapabilityId,
|
||||
} from "./capability-id.js";
|
||||
export type { AuthProviderCapabilityId } from "./capability-id.types.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
detectAuthPathCollisions,
|
||||
isInsidePrefix,
|
||||
normalisePrefix,
|
||||
} from "./collision-detector.js";
|
||||
export type {
|
||||
CollisionReport,
|
||||
CollisionReportEntry,
|
||||
DetectCollisionsInput,
|
||||
PluginDescriptor,
|
||||
PluginRouteDescriptor,
|
||||
} from "./collision-detector.types.js";
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
77
packages/api-stack/api/api-auth-provider/src/index.ts
Normal file
77
packages/api-stack/api/api-auth-provider/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
type OwnershipClock,
|
||||
PathOwnership,
|
||||
type PathOwnershipOptions,
|
||||
} from "./path-ownership.js";
|
||||
export type {
|
||||
PathOwnershipClaim,
|
||||
ProviderClaim,
|
||||
} from "./path-ownership.types.js";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
listProviderRouteNames,
|
||||
lookupProviderRoute,
|
||||
PROVIDER_ROUTE_SURFACE,
|
||||
resolveProviderRoutePath,
|
||||
} from "./route-surface.js";
|
||||
export type {
|
||||
ProviderRouteEntry,
|
||||
ProviderRouteName,
|
||||
} from "./route-surface.types.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
isRegisterIdempotent,
|
||||
rejectsSecondProvider,
|
||||
runAuthProviderConformance,
|
||||
} from "./conformance.js";
|
||||
export type {
|
||||
AuthProviderConformanceCategory,
|
||||
AuthProviderConformanceFactory,
|
||||
AuthProviderConformanceOptions,
|
||||
ConformanceScenarioResult,
|
||||
} from "./conformance.types.js";
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-auth-provider/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-auth-provider/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
36
packages/api-stack/api/api-auth-provider/vite.config.ts
Normal file
36
packages/api-stack/api/api-auth-provider/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user