first commit

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

View File

@@ -0,0 +1,109 @@
# @suaveplan/api-auth - Abstract session-resolver contract for the Suaveplan API plugin system. Defines the SessionResolver interface (Request → Principal | null), SessionResolverChain (ordered, first-match-wins, fail-closed-on-throw), SecuritySchemeContribution (OpenAPI emission metadata), CsrfPolicy default rules, the composite AuthAdapter, the AUTH_CODES registry, and a runAuthSessionConformance harness reused by every concrete resolver in Stages 4/6/8.
**$2** for the Suaveplan API plugin system. Defines
the abstract session-resolver shape (`Request → Principal | null`) used by the
host on every request, plus the first-match-wins runner, the OpenAPI
security-scheme contribution, the default CSRF policy, the composite
`AuthAdapter`, and the conformance harness every concrete resolver
implementation passes.
This package is **session resolution only** — account lifecycle (login,
register, oauth callback, password reset) lives in the separate
`@suaveplan/api-auth-provider` package per design doc §2.3.
<!-- begin:auto-docs -->
## Documentation tree
- [capability-id](./src/capability-id/capability-id.md) — Pins this package's `CapabilityId` contribution as a stand-alone literal type plus runtime constant. The `CapabilityId`…
- [capability-id](./src/capability-id/capability-id.md) — Pins this package's `CapabilityId` contribution as a stand-alone literal type plus runtime constant. The `CapabilityId`…
- [chain](./src/chain/chain.md) — Ordered, first-match-wins runtime over an array of `SessionResolver`s. The chain is the integration point at design-doc…
- [chain](./src/chain/chain.md) — Ordered, first-match-wins runtime over an array of `SessionResolver`s. The chain is the integration point at design-doc…
- [csrf-policy](./src/csrf-policy/csrf-policy.md) — Exposes a single pure predicate, `CsrfPolicy.requiresCsrf(scheme)`, that defaults CSRF protection ON for any cookie-bas…
- [csrf-policy](./src/csrf-policy/csrf-policy.md) — Exposes a single pure predicate, `CsrfPolicy.requiresCsrf(scheme)`, that defaults CSRF protection ON for any cookie-bas…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth` package and the concrete `AuthError` c…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth` package and the concrete `AuthError` c…
- [resolver](./src/resolver/resolver.md) — `SessionResolver` is the abstract per-request session-resolver shape that every concrete adapter in Stages 4/6/8 (`api-…
- [resolver](./src/resolver/resolver.md) — `SessionResolver` is the abstract per-request session-resolver shape that every concrete adapter in Stages 4/6/8 (`api-…
- [security-scheme](./src/security-scheme/security-scheme.md) — Defines the shape every concrete `SessionResolver` MUST contribute so the `api-openapi` package can emit the right `com…
- [security-scheme](./src/security-scheme/security-scheme.md) — Defines the shape every concrete `SessionResolver` MUST contribute so the `api-openapi` package can emit the right `com…
- [session-adapter](./src/session-adapter/session-adapter.md) — `AuthAdapter` is the composite shape every concrete session-auth adapter exports — pairing the per-request `SessionReso…
- [session-adapter](./src/session-adapter/session-adapter.md) — `AuthAdapter` is the composite shape every concrete session-auth adapter exports — pairing the per-request `SessionReso…
- [testing](./src/testing/conformance.md) — A reusable Vitest `describe(...)` block every concrete `SessionResolver` must pass before publishing. The harness cover…
- [conformance](./src/testing/conformance.md) — A reusable Vitest `describe(...)` block every concrete `SessionResolver` must pass before publishing. The harness cover…
<!-- end:auto-docs -->
## What's inside
| Module | Subpath | Purpose |
|-------------------------------|----------------------------------------|---------------------------------------------------------------|
| `SessionResolver` interface | `@suaveplan/api-auth/resolver` | Abstract `resolve(req) → Principal \| null` + `dispose`. |
| `SessionResolverChain` runner | `@suaveplan/api-auth/chain` | Ordered first-match-wins; fail-closed-on-throw by default. |
| Security-scheme contract | `@suaveplan/api-auth/security-scheme` | Zod-validated OpenAPI security-scheme metadata. |
| CSRF policy default | `@suaveplan/api-auth/csrf-policy` | Cookie schemes → ON; bearer / oauth2 / OIDC → OFF. |
| Composite `AuthAdapter` | `@suaveplan/api-auth/session-adapter` | `{ resolver, securityScheme, csrf? }` registration shape. |
| `auth.session` capability id | `@suaveplan/api-auth/capability-id` | Re-export of `"auth.session"` with type-level assertion. |
| `AUTH_CODES` registry | `@suaveplan/api-auth/error-codes` | Frozen `AUTH.*` error-code registry + `AuthError` class. |
| Conformance harness | `@suaveplan/api-auth/testing` | `runAuthSessionConformance(spec)` for concrete resolvers. |
## Co-located documentation
Each module ships a sibling `.md`:
- [`src/resolver/resolver.md`](src/resolver/resolver.md)
- [`src/chain/chain.md`](src/chain/chain.md)
- [`src/security-scheme/security-scheme.md`](src/security-scheme/security-scheme.md)
- [`src/csrf-policy/csrf-policy.md`](src/csrf-policy/csrf-policy.md)
- [`src/session-adapter/session-adapter.md`](src/session-adapter/session-adapter.md)
- [`src/capability-id/capability-id.md`](src/capability-id/capability-id.md)
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md)
- [`src/testing/conformance.md`](src/testing/conformance.md)
## Quick example
```ts
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
import { validateAuthAdapter } from "@suaveplan/api-auth/session-adapter";
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
declare const jwt: SessionResolver;
declare const staticBearer: SessionResolver;
const jwtAdapter = validateAuthAdapter({
resolver: jwt,
securityScheme: {
name: "bearerAuth",
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
},
});
const bearerAdapter = validateAuthAdapter({
resolver: staticBearer,
securityScheme: {
name: "staticBearer",
scheme: { type: "http", scheme: "bearer" },
},
});
const chain = new SessionResolverChain([
jwtAdapter.resolver,
bearerAdapter.resolver,
]);
const principal = await chain.resolve(new Request("https://example.test"));
```
## Versioning
1.0`. Graduates to `1.0.0` when the Stage 1
contract bundle (`api-types`, `api-auth`, `api-tenant-resolver`,
`api-secrets`, `api-db-adapter`) all reach 1.0.
## Dependencies
| Dep | Why |
|---------------------------|------------------------------------------------------|
| `@suaveplan/api-types` | `Principal`, `CapabilityId`. |
| `@suaveplan/error` | `SuaveplanError`, `defineCodes`. |
| `@suaveplan/types` | Cross-package contracts (logger, clock, etc.). |
| `tslib` | TypeScript runtime helpers (catalog). |
| `zod` | Boundary validation for security-scheme contributions. |

View File

@@ -0,0 +1,85 @@
# `capability-id` — `"auth.session"` contribution
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pins this package's `CapabilityId` contribution as a stand-alone literal type
plus runtime constant. The `CapabilityId` union in
`@suaveplan/api-types/capabilities` already enumerates `"auth.session"`
directly; this module ties the api-auth package to that identifier so any
future rename surfaces here first as a build break.
Exporting the runtime literal as `AUTH_SESSION_CAPABILITY_ID` lets plugin
manifests declare `requires: [AUTH_SESSION_CAPABILITY_ID] as const` without
hard-coding the string at the manifest site — refactor-safe and centrally
documented.
## Features
- Compile-time `const _assignable: CapabilityId = AUTH_SESSION_CAPABILITY_ID`
asserts membership at build time. If `api-types` ever drops the token, this
package fails to type-check rather than silently regressing.
- Type and value are exported together so the consumer can pick the form that
fits the call site without re-stringifying.
## Usage: Basic
```ts
import { AUTH_SESSION_CAPABILITY_ID } from "@suaveplan/api-auth/capability-id";
const requires = [AUTH_SESSION_CAPABILITY_ID] as const;
```
## Usage: Advanced
Use the literal type to constrain a manifest builder helper:
```ts
import {
AUTH_SESSION_CAPABILITY_ID,
type AuthSessionCapabilityId,
} from "@suaveplan/api-auth/capability-id";
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
function manifestRequires<const T extends readonly CapabilityId[]>(req: T): T {
return req;
}
const requires = manifestRequires([
AUTH_SESSION_CAPABILITY_ID,
"logger",
] satisfies readonly (AuthSessionCapabilityId | "logger")[]);
```
## API Reference
| Symbol | Kind | Description |
|---------------------------------|------|----------------------------------------------|
| `AuthSessionCapabilityId` | type | The literal `"auth.session"`. |
| `AUTH_SESSION_CAPABILITY_ID` | const| `"auth.session"` runtime value. |
## Implementation Notes
- The `void _assignable` keeps the assignment from being tree-shaken; without
it Rollup MAY drop the line and the build-time assertion silently regresses
to no-op.
- This module is import-time-only — no behaviour at runtime beyond the
string literal binding.
- The literal is exported as both a `type` and a runtime `const` deliberately:
consumers that build manifests imperatively want the runtime value; consumers
that constrain compile-time-only structures (a `definePlugin` builder, for
example) want the literal type. Re-stringifying `"auth.session"` at the
consumer side would defeat the purpose of having the package central.
- The pairing with `@suaveplan/api-types/capabilities` is deliberately
one-directional. `api-types` is the source of truth for the closed union;
this package only re-asserts membership and binds the runtime value. If a
future change wanted to flip the relationship — have api-auth contribute via
declaration merging instead — that would require relaxing the `CapabilityId`
union in api-types to be `open` (i.e. `string & { brand }`), which is a
larger contract change documented as out-of-scope here.
- The runtime constant uses a type annotation rather than `as const`: the
annotation makes the assignable check above explicit, and `as const` would
widen any future enum-style rewrite.

View File

@@ -0,0 +1,89 @@
# `chain` — `SessionResolverChain`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ordered, first-match-wins runtime over an array of `SessionResolver`s. The
chain is the integration point at design-doc §6 step 3: the host's per-request
pipeline calls `chain.resolve(req)` exactly once and routes the result into the
`RequestContext.principal` slot (`publicRoutes` decides 401 vs `null`).
The chain owns three orthogonal contracts that are easy to get wrong if every
adapter re-implements them: (1) iteration order — first non-null wins, no
later resolver is invoked; (2) fail-closed throw semantics — a resolver
exception surfaces `AUTH.RESOLVER_THREW` and short-circuits the chain, with an
opt-in `continueOnThrow` escape hatch documented for SaaS-provider flakiness;
(3) empty-chain rejection — `new SessionResolverChain([])` throws
`AUTH.CHAIN_EMPTY` at construction so deployment misconfiguration cannot mask
itself as "no resolvers, every request 401s."
## Features
- Class form implements `SessionResolver` itself — the chain can be nested
inside another chain, used as the host's only resolver, or composed with
middleware identically.
- Idempotent `dispose()` propagates to every wrapped resolver.
- Cause-chain propagation via `AuthError`'s `cause` option; the index of the
throwing resolver is recorded in `context.resolverIndex`.
## Usage: Basic
```ts
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
declare const jwtResolver: SessionResolver;
declare const bearerStaticResolver: SessionResolver;
const chain = new SessionResolverChain([jwtResolver, bearerStaticResolver]);
const principal = await chain.resolve(new Request("https://example.test"));
```
## Usage: Advanced
Enable `continueOnThrow` for a deployment that fronts an intentionally flaky
SaaS auth upstream — the chain logs the cause via the `AuthError` it would
have thrown, but keeps moving to the static-bearer fallback resolver:
```ts
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
declare const saasJwtResolver: SessionResolver;
declare const staticBearerResolver: SessionResolver;
const chain = new SessionResolverChain(
[saasJwtResolver, staticBearerResolver],
{ continueOnThrow: true },
);
const req = new Request("https://example.test/api/orders", {
headers: { authorization: "Bearer st_ops_token" },
});
const principal = await chain.resolve(req); // may come from staticBearerResolver
```
## API Reference
| Symbol | Kind | Description |
|---------------------------------|--------|------------------------------------------------------------|
| `SessionResolverChain` | class | `new(resolvers, options?)`; implements `SessionResolver`. |
| `SessionResolverChainOptions` | type | `{ continueOnThrow?: boolean }`. |
## Implementation Notes
- The chain iterates synchronously over `this.resolvers` and awaits each
resolver in turn — concurrent resolution is intentionally NOT offered.
Concurrency would change the "first match wins" semantics into "first to
return wins," producing a non-deterministic principal selection when two
resolvers both recognise the request.
- The throw-handling branch records the resolver index but NOT the resolver
identity — concrete adapters MAY redact stack traces, and the chain has no
inspection contract on its members beyond `resolve` + `dispose`.
- `dispose()` is idempotent via a `disposed` flag; the conformance harness
calls it twice and asserts no throw.

View File

@@ -0,0 +1,90 @@
# `csrf-policy` — `CsrfPolicy.requiresCsrf`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes a single pure predicate, `CsrfPolicy.requiresCsrf(scheme)`, that
defaults CSRF protection ON for any cookie-based scheme and OFF for any scheme
where the caller must actively attach the credential. This default codifies the
design-doc §6 rule "Cookie-based auth adapters trigger CSRF verification" so
every concrete adapter inherits the right behaviour without restating it.
The package exposes only the default; the host (`api-host`) applies per-route
`csrf: false` overrides on top. Keeping the default and the override separate
means a route-level opt-out is always auditable as an explicit decision in the
manifest, never a side-effect of which adapter happened to be wired.
## Features
- Pure function — no side effects, no module-load cost.
- Frozen container object so test code can `expect(CsrfPolicy)` reliably and
consumers cannot monkey-patch the predicate.
- Total over the `OpenApiSecurityScheme` discriminated union — no `default`
branch means an added scheme type at the schema layer will surface a
type-level reminder to revisit the policy.
## Usage: Basic
```ts
import { CsrfPolicy } from "@suaveplan/api-auth/csrf-policy";
const needsCsrf = CsrfPolicy.requiresCsrf({
type: "apiKey",
in: "cookie",
name: "sp_session",
}); // → true
```
## Usage: Advanced
Drive a host-side CSRF middleware off the policy, with a per-route override
applied at the call site:
```ts
import { CsrfPolicy } from "@suaveplan/api-auth/csrf-policy";
import type { OpenApiSecurityScheme } from "@suaveplan/api-auth/security-scheme";
interface RouteCsrfDecision {
readonly required: boolean;
readonly reason: "scheme-default" | "route-override";
}
function decideCsrf(
scheme: OpenApiSecurityScheme,
routeOverride: boolean | undefined,
): RouteCsrfDecision {
if (routeOverride !== undefined) {
return { required: routeOverride, reason: "route-override" };
}
return {
required: CsrfPolicy.requiresCsrf(scheme),
reason: "scheme-default",
};
}
const decision = decideCsrf(
{ type: "http", scheme: "bearer" },
undefined,
);
// → { required: false, reason: "scheme-default" }
```
## API Reference
| Symbol | Kind | Description |
|----------------------------|-------------|----------------------------------------------------------|
| `CsrfPolicy.requiresCsrf` | function | `(scheme) => boolean`. Cookie schemes → `true`. |
## Implementation Notes
- The predicate inspects `type === "apiKey" && in === "cookie"` — apiKey-in-
header is treated identically to `Authorization: Bearer …` for CSRF purposes
because the caller must attach it explicitly.
- `oauth2` and `openIdConnect` default OFF — both are bearer-token-derived in
practice; if a deployment exposes an oauth2 access token via cookie, that's
a custom apiKey-in-cookie scheme, not the `oauth2` scheme.
- `Object.freeze` guards the predicate slot; the predicate itself is a regular
method and TypeScript prevents reassignment via the `Readonly` inference.

View File

@@ -0,0 +1,84 @@
# `error-codes` — `AUTH_CODES` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth`
package and the concrete `AuthError` class. Every code is reachable from a
production throw site somewhere in the package (chain construction, chain
execution, adapter registration, or principal-shape validation). The registry
is frozen via `defineCodes("AUTH", […])` so accidental drift between thrown
codes and the documented set is impossible.
Consumers — including downstream concrete resolvers in Stages 4/6/8 such as
`api-auth-jwt`, `api-auth-anonymous`, and `api-auth-bearer-static` — branch on
the string literal (`"AUTH.RESOLVER_THREW"` etc.) or compare to the constant
(`AUTH_CODES.RESOLVER_THREW`). The literal form is preferred at the API
boundary; the constant is preferred inside the package itself so the linter
flags typos as type errors.
## Features
- Frozen string-literal union of all four codes.
- `AuthError` extends `SuaveplanError<AuthErrorCode>` so `instanceof AuthError`
narrows `code` correctly.
- Cause-chain propagation: the optional `options.cause` field is forwarded to
the base class.
- Public constructor (the base class is `protected`).
## Usage: Basic
```ts
import { AUTH_CODES, AuthError } from "@suaveplan/api-auth/error-codes";
if (chain.length === 0) {
throw new AuthError(AUTH_CODES.CHAIN_EMPTY, "at least one resolver required");
}
```
## Usage: Advanced
Re-throw a resolver error with a cause chain and inspect it downstream:
```ts
import { AUTH_CODES, AuthError } from "@suaveplan/api-auth/error-codes";
try {
await resolver.resolve(req);
} catch (cause) {
throw new AuthError(AUTH_CODES.RESOLVER_THREW, "resolver A threw", {
cause,
context: { resolverIndex: 0 },
});
}
```
```ts
import { AuthError } from "@suaveplan/api-auth/error-codes";
if (err instanceof AuthError && err.code === "AUTH.RESOLVER_THREW") {
logger.warn("upstream session resolver failed", { cause: err.cause });
}
```
## API Reference
| Symbol | Kind | Description |
|----------------|--------|-----------------------------------------------------------------|
| `AUTH_CODES` | const | Frozen lookup `{ RESOLVER_THREW, CHAIN_EMPTY, … }`. |
| `AuthErrorCode`| type | Literal union of the four code strings. |
| `AuthError` | class | Concrete `SuaveplanError` subclass narrowed to `AuthErrorCode`. |
## Implementation Notes
- `defineCodes("AUTH", [...] as const)` produces an object literal with
string-literal value types, which is what gives `AuthErrorCode` its narrow
union. Removing `as const` would widen the type to `string`.
- The class field `authCode` mirrors `code` for ergonomic narrowing on
pre-instantiated error objects (some test runners obscure the inherited
`code` field via Proxy wrappers).
- Code values follow the `AUTH.<UPPER_SNAKE>` template enforced by
`@suaveplan/api-types`' `ErrorCode` template literal type.

View File

@@ -0,0 +1,119 @@
# `resolver` — `SessionResolver` contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`SessionResolver` is the abstract per-request session-resolver shape that every
concrete adapter in Stages 4/6/8 (`api-auth-jwt`, `api-auth-anonymous`,
`api-auth-bearer-static`, `api-auth-better-auth`, `api-auth-rolled`) implements.
It deliberately captures only the universal vocabulary — inspect a WHATWG
`Request`, return a `Principal | null`, dispose — so any concrete resolver can
plug behind the host's chain runner without further coordination.
The tri-valued return contract (principal / null / throw) collapses to two
host-level outcomes: the chain short-circuits on the first principal, the host
applies its `publicRoutes` decision when every resolver returned `null`, and
`AUTH.RESOLVER_THREW` is surfaced when any resolver throws (fail-closed by
default per design doc §6).
## Features
- Generic over `P extends Principal` — concrete adapters MAY narrow `Principal`
via interface merging without losing assignability to the chain's `Principal`
return type.
- Async by construction; resolvers MAY fetch JWKs, hit Redis, or call a token
introspection endpoint.
- `dispose` is part of the contract, not a side concern — the host blue/green
rotates resolver chains and relies on `dispose` to release JWK caches, close
HTTP keepalive pools, etc.
## Usage: Basic
A minimal resolver that always returns null (the "anonymous fallback" pattern
codified in `api-auth-anonymous`):
```ts
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
const anonymous: SessionResolver = {
async resolve() {
return null;
},
async dispose() {
/* no resources to release */
},
};
```
## Usage: Advanced
A resolver that reads a `Bearer` token, parses claims, and returns a typed
principal — with `dispose` releasing a token-cache eviction timer:
```ts
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
import type { Principal, PrincipalId, Scope } from "@suaveplan/api-types/principal";
interface JwtClaims {
sub: string;
scope?: string;
[key: string]: unknown;
}
class JwtResolver implements SessionResolver {
private readonly evictTimer: ReturnType<typeof setInterval>;
private disposed = false;
public constructor(private readonly verifyJwt: (raw: string) => Promise<JwtClaims | null>) {
this.evictTimer = setInterval(() => {
/* prune cache */
}, 30_000);
}
public async resolve(req: Request): Promise<Principal | null> {
const header = req.headers.get("authorization");
if (!header?.startsWith("Bearer ")) {
return null;
}
const claims = await this.verifyJwt(header.slice("Bearer ".length));
if (claims === null) {
return null;
}
const scopes = (claims.scope ?? "").split(" ").filter(Boolean) as readonly Scope[];
return {
id: claims.sub as PrincipalId,
scopes,
claims,
};
}
public async dispose(): Promise<void> {
if (this.disposed) {
return;
}
this.disposed = true;
clearInterval(this.evictTimer);
}
}
```
## API Reference
| Symbol | Kind | Description |
|-------------------|-------------|------------------------------------------------------|
| `SessionResolver` | interface | `resolve(req): Promise<P \| null>` + `dispose()`. |
| `P` (generic) | type param | Defaults to `Principal`. Narrowing is opt-in. |
## Implementation Notes
- `Request` is the WHATWG Fetch `Request` (DOM lib). Node ≥18, Bun, Deno, and
every Workers runtime all expose it as a global; `api-auth` does not import
any Node-specific request type so the contract is universal.
- Returning `null` for "credential present but invalid" is acceptable but
discouraged — emit `AUTH.PRINCIPAL_INVALID_SHAPE` instead, so the host can
distinguish "no credential" from "bad credential" in metrics.
- `dispose` SHOULD be idempotent; tests in the conformance harness call it
twice in a row and assert no throw.

View File

@@ -0,0 +1,89 @@
# `security-scheme` — `SecuritySchemeContribution`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the shape every concrete `SessionResolver` MUST contribute so the
`api-openapi` package can emit the right `components.securitySchemes` block in
the generated OpenAPI document (design doc §7 step 3). Exposed as both a Zod
schema (`SecuritySchemeContributionSchema`) and an inferred TypeScript type
(`SecuritySchemeContribution`), with a `validateSecurityScheme()` boundary
helper used by the host's adapter-registration pipeline.
Validation at the boundary is mandatory: without it, a typo in a concrete
adapter's `name` or `scheme.type` would silently emit an invalid OpenAPI
document and break every downstream client generator. The host therefore calls
`validateSecurityScheme()` from its registration code and aborts boot rather
than serve traffic with a half-configured document.
## Features
- Discriminated-union schema covering the four OAS 3.1 scheme types
(`apiKey`, `http`, `oauth2`, `openIdConnect`).
- Boundary validator throws `AUTH.SECURITY_SCHEME_MISSING` on absence or shape
mismatch — the host's documented signal to abort boot.
- Cause-chain propagation: Zod's `issues` array is attached as `context.issues`
so the operator log explains exactly which field rejected.
## Usage: Basic
```ts
import { validateSecurityScheme } from "@suaveplan/api-auth/security-scheme";
const contribution = validateSecurityScheme({
name: "bearerAuth",
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
});
```
## Usage: Advanced
Use the inferred type to constrain an adapter factory, then call the validator
at registration time:
```ts
import {
type SecuritySchemeContribution,
validateSecurityScheme,
} from "@suaveplan/api-auth/security-scheme";
interface AdapterFactory {
readonly securityScheme: SecuritySchemeContribution;
}
function registerAdapter(adapter: AdapterFactory): void {
const validated = validateSecurityScheme(adapter.securityScheme);
openApiRegistry.add(validated);
}
const cookieAdapter: AdapterFactory = {
securityScheme: {
name: "sessionCookie",
scheme: { type: "apiKey", in: "cookie", name: "sp_session" },
},
};
registerAdapter(cookieAdapter);
```
## API Reference
| Symbol | Kind | Description |
|------------------------------------|--------|--------------------------------------------------------------|
| `OpenApiSecuritySchemeSchema` | const | Zod discriminated union over the four OAS 3.1 scheme types. |
| `OpenApiSecurityScheme` | type | `z.infer<typeof OpenApiSecuritySchemeSchema>`. |
| `SecuritySchemeContributionSchema` | const | `{ name, scheme }` Zod object. |
| `SecuritySchemeContribution` | type | Inferred contribution shape. |
| `validateSecurityScheme` | fn | Boundary validator; throws `AUTH.SECURITY_SCHEME_MISSING`. |
## Implementation Notes
- `import z from "zod"` (default import, per project rule).
- `z.discriminatedUnion("type", […])` is preferred over a plain union — it
surfaces a much sharper Zod error when the input has an unknown `type`.
- The contribution schema is the host-visible shape; concrete adapters MAY
extend their internal scheme record with extension fields (`x-*`) but the
host validates and forwards only the OAS-known keys.

View File

@@ -0,0 +1,81 @@
# `session-adapter` — composite `AuthAdapter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`AuthAdapter` is the composite shape every concrete session-auth adapter
exports — pairing the per-request `SessionResolver` with the OpenAPI
`SecuritySchemeContribution` and the optional adapter-level CSRF override.
`validateAuthAdapter()` is the boundary helper the host calls before stitching
an adapter into its resolver chain; it throws `AUTH.SECURITY_SCHEME_MISSING`
when the contribution is absent or malformed.
This package owns the marriage of "what does the credential look like on the
wire" (security scheme) and "how do we turn it into a Principal" (resolver) so
that downstream consumers — `api-host`, `api-openapi`, and the host's CSRF
middleware — read a single source-of-truth shape instead of three loosely
coupled fields scattered across every concrete adapter package.
## Features
- `csrf` is optional, intentionally: an unset slot defers to the
`CsrfPolicy.requiresCsrf(scheme)` default, while a set value pins per-adapter
intent (auditable in the host's adapter table).
- `validateAuthAdapter` is identity on the happy path so it composes with
registration pipelines that need to forward the validated value.
## Usage: Basic
```ts
import { type AuthAdapter, validateAuthAdapter } from "@suaveplan/api-auth/session-adapter";
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
declare const jwtResolver: SessionResolver;
const adapter: AuthAdapter = {
resolver: jwtResolver,
securityScheme: {
name: "bearerAuth",
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
},
};
validateAuthAdapter(adapter);
```
## Usage: Advanced
Register multiple adapters at host startup; the validator centralises the
fail-fast and the host stitches the resolvers into a `SessionResolverChain`:
```ts
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
import { type AuthAdapter, validateAuthAdapter } from "@suaveplan/api-auth/session-adapter";
function buildChain(adapters: readonly AuthAdapter[]): SessionResolverChain {
const validated = adapters.map((a) => validateAuthAdapter(a));
return new SessionResolverChain(validated.map((a) => a.resolver));
}
```
## API Reference
| Symbol | Kind | Description |
|-----------------------|-------------|---------------------------------------------------------------|
| `AuthAdapter` | interface | `{ resolver, securityScheme, csrf? }`. |
| `validateAuthAdapter` | function | Boundary validator; throws `AUTH.SECURITY_SCHEME_MISSING`. |
## Implementation Notes
- `csrf` is a `boolean | undefined` field rather than a richer `CsrfDecision`
record. The host's middleware combines this slot with the per-route override
and the `CsrfPolicy.requiresCsrf` default into a final decision — the
adapter does NOT need three-valued logic for the same intent.
- `validateAuthAdapter` returns its argument unchanged so call sites can write
`const a = validateAuthAdapter(buildAdapter(opts));` without re-binding.
- The structural type is `interface` (not `type`) so concrete adapter packages
MAY use declaration merging to attach extension fields without forking the
contract.

View File

@@ -0,0 +1,101 @@
# `testing` — `runAuthSessionConformance` harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
A reusable Vitest `describe(...)` block every concrete `SessionResolver` must
pass before publishing. The harness covers the three behaviours that define a
correct resolver implementation independent of the underlying credential
shape: returning `null` for unrecognised requests, returning the documented
`Principal` for a recognised request, and an idempotent `dispose()`.
Concrete adapters in Stages 4/6/8 (`api-auth-jwt`, `api-auth-anonymous`,
`api-auth-bearer-static`, `api-auth-better-auth`, `api-auth-rolled`) call
`runAuthSessionConformance({ … })` from one of their `*.test.ts` files. The
single conformance suite means every adapter ships against the same observable
contract; new adapters drop in without re-deriving the test plan.
## Features
- Pure function — wires `describe` / `it` from `@suaveplan/testing/runner` so
the suite runs identically under Vitest (default) and `bun:test` (under the
`bun` test condition).
- Factory pattern — adapters re-construct their resolver inside each `it` so
parallel runners do not share resolver state across tests.
- Every `it` carries `{ timeout: 5000 }` per the project's hard rule.
## Usage: Basic
```ts
import { runAuthSessionConformance } from "@suaveplan/api-auth/testing";
import type { PrincipalId, Scope } from "@suaveplan/api-types/principal";
import { buildAnonymousResolver } from "../src/anonymous.js";
runAuthSessionConformance({
label: "anonymous",
factory: () => buildAnonymousResolver(),
seedNull: () => new Request("https://example.test/api"),
seedPrincipal: () => ({
request: new Request("https://example.test/api", {
headers: { "x-test-principal": "1" },
}),
expected: {
id: "anon" as PrincipalId,
scopes: [] as readonly Scope[],
claims: {},
},
}),
});
```
## Usage: Advanced
Compose the harness with adapter-specific extra coverage (per-adapter codes,
JWK rotation, etc.):
```ts
import { describe, it, expect } from "@suaveplan/testing/runner";
import { runAuthSessionConformance } from "@suaveplan/api-auth/testing";
import { buildJwtResolver, JWT_CODES } from "../src/index.js";
runAuthSessionConformance({
label: "jwt-hs256",
factory: async () => buildJwtResolver({ jwks: testJwks }),
seedNull: () => new Request("https://example.test/api"),
seedPrincipal: () => ({
request: new Request("https://example.test/api", {
headers: { authorization: `Bearer ${testToken}` },
}),
expected: testPrincipal,
}),
});
describe("jwt-hs256 specific", () => {
it("emits JWT.EXPIRED on stale token", { timeout: 5000 }, async () => {
/* … */
});
});
```
## API Reference
| Symbol | Kind | Description |
|-----------------------------------|-------------|----------------------------------------------------------------------|
| `runAuthSessionConformance` | function | Adds a `describe(...)` block; runs three baseline scenarios. |
| `AuthSessionConformanceFactory` | interface | `{ factory, label, seedNull, seedPrincipal }`. |
## Implementation Notes
- The harness asserts `id` + `scopes` equality on the principal, not deep
equality of `claims` — `claims` is intentionally free-form and adapters may
add provider-specific fields that callers must not pin in conformance.
- `dispose()` is asserted to resolve to `undefined`, not just "not throw" —
some legacy resolvers were returning the wrong shape from the second
invocation; this assertion caught it.
- `expect` and `describe`/`it` come from `@suaveplan/testing/runner` per
project rule 11.