Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,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,107 @@
{
"name": "@suaveplan/api-auth",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Abstract session-resolver contract for the Suaveplan API plugin foundation. 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.",
"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"
}
},
"./resolver": {
"import": {
"types": "./dist/resolver/resolver.d.ts",
"default": "./dist/resolver/resolver.js"
}
},
"./chain": {
"import": {
"types": "./dist/chain/chain.d.ts",
"default": "./dist/chain/chain.js"
}
},
"./security-scheme": {
"import": {
"types": "./dist/security-scheme/security-scheme.d.ts",
"default": "./dist/security-scheme/security-scheme.js"
}
},
"./csrf-policy": {
"import": {
"types": "./dist/csrf-policy/csrf-policy.d.ts",
"default": "./dist/csrf-policy/csrf-policy.js"
}
},
"./session-adapter": {
"import": {
"types": "./dist/session-adapter/session-adapter.d.ts",
"default": "./dist/session-adapter/session-adapter.js"
}
},
"./capability-id": {
"import": {
"types": "./dist/capability-id/capability-id.d.ts",
"default": "./dist/capability-id/capability-id.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/conformance.d.ts",
"default": "./dist/testing/conformance.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"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:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,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,28 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
AUTH_SESSION_CAPABILITY_ID,
type AuthSessionCapabilityId,
} from "./capability-id.js";
describe("auth.session capability id", () => {
it("auth.session is a valid CapabilityId after augmentation", {
timeout: 5000,
}, () => {
expectTypeOf<AuthSessionCapabilityId>().toMatchTypeOf<CapabilityId>();
expectTypeOf<"auth.session">().toMatchTypeOf<CapabilityId>();
});
it("AUTH_SESSION_CAPABILITY_ID is the literal value", {
timeout: 5000,
}, () => {
expect(AUTH_SESSION_CAPABILITY_ID).toBe("auth.session");
});
it("AUTH_SESSION_CAPABILITY_ID is assignable to CapabilityId", {
timeout: 5000,
}, () => {
const id: CapabilityId = AUTH_SESSION_CAPABILITY_ID;
expect(id).toBe("auth.session");
});
});

View File

@@ -0,0 +1,27 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
/**
* Literal token this package contributes to the global `CapabilityId` union.
*
* The `CapabilityId` union in `@suaveplan/api-types/capabilities` already
* lists `"auth.session"` directly; re-exporting it here as a literal makes the
* contribution legible at the consumption site and lets the conformance test
* `expectTypeOf<AuthSessionCapabilityId>().toMatchTypeOf<CapabilityId>()` pin
* the membership without depending on the order of identifiers inside the
* union.
*
* `AUTH_SESSION_CAPABILITY_ID` exports the runtime literal for places that
* declare `requires: [AUTH_SESSION_CAPABILITY_ID] as const`.
*/
export type AuthSessionCapabilityId = "auth.session";
export const AUTH_SESSION_CAPABILITY_ID: AuthSessionCapabilityId =
"auth.session";
/**
* Compile-time assertion that `"auth.session"` is assignable to
* `CapabilityId`. The assignment is the assertion; if `api-types` ever
* removed the token, this line would fail to type-check at build time.
*/
const _assignable: CapabilityId = AUTH_SESSION_CAPABILITY_ID;
void _assignable;

View File

@@ -0,0 +1,4 @@
export {
AUTH_SESSION_CAPABILITY_ID,
type AuthSessionCapabilityId,
} from "./capability-id.js";

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,255 @@
import type {
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { AUTH_CODES } from "../error-codes/error-codes.js";
import type { SessionResolver } from "../resolver/resolver.js";
import { SessionResolverChain } from "./chain.js";
function principalA(): Principal {
return {
id: "u_a" as PrincipalId,
scopes: ["a:read"] as readonly Scope[],
claims: {},
};
}
function principalB(): Principal {
return {
id: "u_b" as PrincipalId,
scopes: ["b:read"] as readonly Scope[],
claims: {},
};
}
function nullResolver(): SessionResolver {
return {
resolve: async () => null,
dispose: async () => undefined,
};
}
function staticResolver(p: Principal): SessionResolver {
return {
resolve: async () => p,
dispose: async () => undefined,
};
}
function throwingResolver(message: string): SessionResolver {
return {
resolve: async () => {
throw new Error(message);
},
dispose: async () => undefined,
};
}
const REQ = new Request("https://example.test");
describe("SessionResolverChain", () => {
it("first non-null wins, later resolvers skipped", {
timeout: 5000,
}, async () => {
let bCalled = 0;
const a = staticResolver(principalA());
const b: SessionResolver = {
resolve: async () => {
bCalled++;
return principalB();
},
dispose: async () => undefined,
};
const chain = new SessionResolverChain([a, b]);
const result = await chain.resolve(REQ);
expect(result?.id).toBe(principalA().id);
expect(bCalled).toBe(0);
});
it("nulls cascade to the next resolver", { timeout: 5000 }, async () => {
const chain = new SessionResolverChain([
nullResolver(),
staticResolver(principalB()),
]);
const result = await chain.resolve(REQ);
expect(result?.id).toBe(principalB().id);
});
it("all null returns null", { timeout: 5000 }, async () => {
const chain = new SessionResolverChain([
nullResolver(),
nullResolver(),
]);
const result = await chain.resolve(REQ);
expect(result).toBeNull();
});
it("throw is fail-closed by default", { timeout: 5000 }, async () => {
let bCalled = 0;
const b: SessionResolver = {
resolve: async () => {
bCalled++;
return principalB();
},
dispose: async () => undefined,
};
const chain = new SessionResolverChain([throwingResolver("boom"), b]);
await expect(chain.resolve(REQ)).rejects.toMatchObject({
code: AUTH_CODES.RESOLVER_THREW,
});
expect(bCalled).toBe(0);
});
it("throw with continueOnThrow advances to the next resolver", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain(
[throwingResolver("boom"), staticResolver(principalB())],
{ continueOnThrow: true }
);
const result = await chain.resolve(REQ);
expect(result?.id).toBe(principalB().id);
});
it("empty chain rejected at construction", { timeout: 5000 }, () => {
expect(() => new SessionResolverChain([])).toThrowError();
try {
new SessionResolverChain([]);
} catch (err) {
expect((err as { code: string }).code).toBe(AUTH_CODES.CHAIN_EMPTY);
}
});
it("PRINCIPAL_INVALID_SHAPE thrown for malformed principal returns", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
resolve: async () =>
({
id: 42,
scopes: [],
claims: {},
}) as unknown as Principal,
dispose: async () => undefined,
},
]);
await expect(chain.resolve(REQ)).rejects.toMatchObject({
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
});
});
it("PRINCIPAL_INVALID_SHAPE thrown when scopes is not an array", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
resolve: async () =>
({
id: "u_x",
scopes: "wrong",
claims: {},
}) as unknown as Principal,
dispose: async () => undefined,
},
]);
await expect(chain.resolve(REQ)).rejects.toMatchObject({
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
});
});
it("PRINCIPAL_INVALID_SHAPE thrown when claims missing", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
resolve: async () =>
({
id: "u_x",
scopes: [],
claims: null,
}) as unknown as Principal,
dispose: async () => undefined,
},
]);
await expect(chain.resolve(REQ)).rejects.toMatchObject({
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
});
});
it("PRINCIPAL_INVALID_SHAPE thrown when resolver returns non-object", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
resolve: async () => "not-an-object" as unknown as Principal,
dispose: async () => undefined,
},
]);
await expect(chain.resolve(REQ)).rejects.toMatchObject({
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
});
});
it("dispose propagates to every resolver", { timeout: 5000 }, async () => {
let aDisposed = 0;
let bDisposed = 0;
const chain = new SessionResolverChain([
{
resolve: async () => null,
dispose: async () => {
aDisposed++;
},
},
{
resolve: async () => null,
dispose: async () => {
bDisposed++;
},
},
]);
await chain.dispose();
expect(aDisposed).toBe(1);
expect(bDisposed).toBe(1);
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
let calls = 0;
const chain = new SessionResolverChain([
{
resolve: async () => null,
dispose: async () => {
calls++;
},
},
]);
await chain.dispose();
await chain.dispose();
expect(calls).toBe(1);
});
it("property — randomly shuffled nulls plus one principal yields that principal", {
timeout: 5000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.boolean(), { minLength: 0, maxLength: 10 }),
fc.nat(10),
async (nullPositions, insertAt) => {
const resolvers: SessionResolver[] = nullPositions.map(() =>
nullResolver()
);
const slot = Math.min(insertAt, resolvers.length);
resolvers.splice(slot, 0, staticResolver(principalA()));
const chain = new SessionResolverChain(resolvers);
const result = await chain.resolve(REQ);
expect(result?.id).toBe(principalA().id);
}
),
{ numRuns: 25 }
);
});
});

View File

@@ -0,0 +1,115 @@
import type { Principal } from "@suaveplan/api-types/principal";
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
import type { SessionResolver } from "../resolver/resolver.js";
function isPrincipalLike(value: unknown): value is Principal {
if (value === null || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (typeof record["id"] !== "string") {
return false;
}
if (!Array.isArray(record["scopes"])) {
return false;
}
const claims = record["claims"];
if (claims === null || typeof claims !== "object") {
return false;
}
return true;
}
/**
* Options for {@link SessionResolverChain}.
*/
export interface SessionResolverChainOptions {
/**
* When `false` (the default), a resolver throwing surfaces
* `AUTH.RESOLVER_THREW` AND short-circuits the chain — no later resolver
* is tried. This is the fail-closed contract documented in the spec.
*
* When `true`, the chain catches the error and proceeds to the next
* resolver as if `null` had been returned. This is an explicit escape
* hatch — `api-host` does NOT set it; concrete adapters that wrap an
* intentionally flaky upstream (e.g. SaaS auth provider) MAY enable it.
*/
readonly continueOnThrow?: boolean;
}
/**
* Ordered, first-match-wins chain of `SessionResolver`s.
*
* Runtime semantics (design doc §2.3):
*
* - Each resolver is invoked in array order.
* - The first resolver to return a non-null `Principal` wins; later resolvers
* are NOT called.
* - If every resolver returns `null`, the chain returns `null`.
* - If a resolver throws AND `continueOnThrow` is `false` (default), the
* chain throws `AUTH.RESOLVER_THREW` with the original error chained as
* `cause` — fail closed, do not try later resolvers.
* - An empty chain is rejected at construction with `AUTH.CHAIN_EMPTY`.
*
* Disposal: `dispose()` calls every wrapped resolver's `dispose()` in array
* order. Idempotent — calling twice is safe.
*/
export class SessionResolverChain implements SessionResolver {
private readonly resolvers: readonly SessionResolver[];
private readonly continueOnThrow: boolean;
private disposed = false;
public constructor(
resolvers: readonly SessionResolver[],
options: SessionResolverChainOptions = {}
) {
if (resolvers.length === 0) {
throw new AuthError(
AUTH_CODES.CHAIN_EMPTY,
"SessionResolverChain requires at least one resolver"
);
}
this.resolvers = resolvers;
this.continueOnThrow = options.continueOnThrow ?? false;
}
public async resolve(req: Request): Promise<Principal | null> {
for (let index = 0; index < this.resolvers.length; index++) {
const resolver = this.resolvers[index] as SessionResolver;
let result: Principal | null;
try {
result = await resolver.resolve(req);
} catch (cause) {
if (this.continueOnThrow) {
continue;
}
throw new AuthError(
AUTH_CODES.RESOLVER_THREW,
`resolver at index ${index} threw`,
{ cause, context: { resolverIndex: index } }
);
}
if (result !== null) {
if (!isPrincipalLike(result)) {
throw new AuthError(
AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
`resolver at index ${index} returned an invalid Principal shape`,
{ context: { resolverIndex: index } }
);
}
return result;
}
}
return null;
}
public async dispose(): Promise<void> {
if (this.disposed) {
return;
}
this.disposed = true;
for (const resolver of this.resolvers) {
await resolver.dispose();
}
}
}

View File

@@ -0,0 +1,4 @@
export {
SessionResolverChain,
type SessionResolverChainOptions,
} from "./chain.js";

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,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CsrfPolicy } from "./csrf-policy.js";
describe("CsrfPolicy.requiresCsrf", () => {
it("cookie schemes default to CSRF ON", { timeout: 5000 }, () => {
expect(
CsrfPolicy.requiresCsrf({
type: "apiKey",
in: "cookie",
name: "sp_session",
})
).toBe(true);
});
it("apiKey-in-header defaults to CSRF OFF", { timeout: 5000 }, () => {
expect(
CsrfPolicy.requiresCsrf({
type: "apiKey",
in: "header",
name: "x-api-key",
})
).toBe(false);
});
it("apiKey-in-query defaults to CSRF OFF", { timeout: 5000 }, () => {
expect(
CsrfPolicy.requiresCsrf({
type: "apiKey",
in: "query",
name: "api_key",
})
).toBe(false);
});
it("bearer schemes default to CSRF OFF", { timeout: 5000 }, () => {
expect(
CsrfPolicy.requiresCsrf({ type: "http", scheme: "bearer" })
).toBe(false);
});
it("oauth2 defaults to CSRF OFF", { timeout: 5000 }, () => {
expect(
CsrfPolicy.requiresCsrf({
type: "oauth2",
flows: { implicit: {} },
})
).toBe(false);
});
it("openIdConnect defaults to CSRF OFF", { timeout: 5000 }, () => {
expect(
CsrfPolicy.requiresCsrf({
type: "openIdConnect",
openIdConnectUrl:
"https://idp.example/.well-known/openid-configuration",
})
).toBe(false);
});
it("CsrfPolicy is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(CsrfPolicy)).toBe(true);
});
});

View File

@@ -0,0 +1,28 @@
import type { OpenApiSecurityScheme } from "../security-scheme/security-scheme.js";
/**
* Default CSRF policy keyed off an `OpenApiSecurityScheme`.
*
* Per design doc §6: "Cookie-based auth adapters trigger CSRF verification."
* The default is ON for any scheme that ambient-authority sends with the
* request (cookies); OFF for any scheme that requires the caller to actively
* attach the credential to the request (bearer tokens, OAuth2 access tokens,
* OIDC).
*
* The host applies per-route overrides on top of this default. This module
* exposes only the policy default; the host owns the override path.
*/
export const CsrfPolicy = Object.freeze({
/**
* Returns `true` when requests authenticated via the supplied scheme MUST
* carry a CSRF token (double-submit, header-pin, or origin check — the
* host picks the mechanism). Returns `false` when CSRF verification is
* not required by default.
*/
requiresCsrf(scheme: OpenApiSecurityScheme): boolean {
if (scheme.type === "apiKey" && scheme.in === "cookie") {
return true;
}
return false;
},
});

View File

@@ -0,0 +1 @@
export { CsrfPolicy } from "./csrf-policy.js";

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,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SessionResolverChain } from "../chain/chain.js";
import { validateSecurityScheme } from "../security-scheme/security-scheme.js";
import { validateAuthAdapter } from "../session-adapter/session-adapter.js";
import { AUTH_CODES } from "./error-codes.js";
/**
* Conformance — every AUTH_CODES entry MUST be reachable from a production
* throw site somewhere in this package. The test enumerates the registry and
* asserts a throw for each code.
*/
describe("AUTH_CODES — every code reachable from production path", () => {
it("CHAIN_EMPTY thrown by SessionResolverChain construction", {
timeout: 5000,
}, () => {
expect(() => new SessionResolverChain([])).toThrowError();
try {
new SessionResolverChain([]);
} catch (err) {
expect((err as { code: string }).code).toBe(AUTH_CODES.CHAIN_EMPTY);
}
});
it("RESOLVER_THREW thrown by chain when underlying resolver throws", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
resolve: () => {
throw new Error("boom");
},
dispose: async () => undefined,
},
]);
await expect(
chain.resolve(new Request("https://example.test"))
).rejects.toMatchObject({ code: AUTH_CODES.RESOLVER_THREW });
});
it("SECURITY_SCHEME_MISSING thrown when adapter omits scheme", {
timeout: 5000,
}, () => {
expect(() => validateSecurityScheme(undefined)).toThrowError();
try {
validateAuthAdapter({
resolver: {
resolve: async () => null,
dispose: async () => undefined,
},
securityScheme: undefined as never,
});
} catch (err) {
expect((err as { code: string }).code).toBe(
AUTH_CODES.SECURITY_SCHEME_MISSING
);
}
});
it("PRINCIPAL_INVALID_SHAPE thrown when resolver returns malformed Principal", {
timeout: 5000,
}, async () => {
const chain = new SessionResolverChain([
{
// deliberately wrong shape — missing scopes + claims
resolve: async () => ({ id: "p_1" }) as unknown as null,
dispose: async () => undefined,
},
]);
await expect(
chain.resolve(new Request("https://example.test"))
).rejects.toMatchObject({
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
});
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_CODES, AuthError, type AuthErrorCode } from "./error-codes.js";
describe("AUTH_CODES registry", () => {
it("contains exactly the four documented codes", { timeout: 5000 }, () => {
expect(Object.values(AUTH_CODES).sort()).toEqual(
[
"AUTH.CHAIN_EMPTY",
"AUTH.PRINCIPAL_INVALID_SHAPE",
"AUTH.RESOLVER_THREW",
"AUTH.SECURITY_SCHEME_MISSING",
].sort()
);
});
it("every code value follows the AUTH.<UPPER_SNAKE> template", {
timeout: 5000,
}, () => {
for (const value of Object.values(AUTH_CODES)) {
expect(value).toMatch(/^AUTH\.[A-Z_]+$/);
}
});
it("AuthError narrows its code to AuthErrorCode", { timeout: 5000 }, () => {
const err = new AuthError(AUTH_CODES.CHAIN_EMPTY, "boom");
const code: AuthErrorCode = err.code;
expect(code).toBe("AUTH.CHAIN_EMPTY");
expect(err.authCode).toBe("AUTH.CHAIN_EMPTY");
expect(err).toBeInstanceOf(AuthError);
expect(err.message).toBe("boom");
});
it("AuthError carries the optional cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new AuthError(AUTH_CODES.RESOLVER_THREW, "wrap", { cause });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,43 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-auth`.
*
* Every public failure path raises an {@link AuthError} carrying one of these
* namespaced `AUTH.*` codes. Consumers branch on the string value (e.g.
* `error.code === AUTH_CODES.RESOLVER_THREW`).
*
* The 4 codes cover:
* - Resolver throw surfaced by the chain (`RESOLVER_THREW`).
* - Empty chain rejected at construction (`CHAIN_EMPTY`).
* - Principal returned by a resolver that fails structural validation
* (`PRINCIPAL_INVALID_SHAPE`).
* - An `AuthAdapter` registered without a `SecuritySchemeContribution`
* (`SECURITY_SCHEME_MISSING`).
*/
export const AUTH_CODES = defineCodes("AUTH", [
"RESOLVER_THREW",
"CHAIN_EMPTY",
"PRINCIPAL_INVALID_SHAPE",
"SECURITY_SCHEME_MISSING",
] as const);
export type AuthErrorCode = (typeof AUTH_CODES)[keyof typeof AUTH_CODES];
/**
* Concrete error class for the api-auth package. Extends {@link SuaveplanError}
* so `instanceof AuthError` narrows `code` to {@link AuthErrorCode}.
*/
export class AuthError extends SuaveplanError<AuthErrorCode> {
public readonly authCode: AuthErrorCode;
public constructor(
code: AuthErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.authCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
AUTH_CODES,
AuthError,
type AuthErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,42 @@
// Side-effect import — surfaces the error-codes registry to consumers via
// `import "@suaveplan/api-auth"`.
import "./error-codes/error-codes.js";
// Capability id
export {
AUTH_SESSION_CAPABILITY_ID,
type AuthSessionCapabilityId,
} from "./capability-id/capability-id.js";
// Chain
export {
SessionResolverChain,
type SessionResolverChainOptions,
} from "./chain/chain.js";
// CSRF policy
export { CsrfPolicy } from "./csrf-policy/csrf-policy.js";
// Error codes
export {
AUTH_CODES,
AuthError,
type AuthErrorCode,
} from "./error-codes/error-codes.js";
// Resolver
export type { SessionResolver } from "./resolver/resolver.js";
// Security scheme
export {
type OpenApiSecurityScheme,
OpenApiSecuritySchemeSchema,
type SecuritySchemeContribution,
SecuritySchemeContributionSchema,
validateSecurityScheme,
} from "./security-scheme/security-scheme.js";
// Session adapter
export {
type AuthAdapter,
validateAuthAdapter,
} from "./session-adapter/session-adapter.js";
// Testing harness
export {
type AuthSessionConformanceFactory,
runAuthSessionConformance,
} from "./testing/conformance.js";

View File

@@ -0,0 +1 @@
export type { SessionResolver } from "./resolver.js";

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,28 @@
import type { Principal } from "@suaveplan/api-types/principal";
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { SessionResolver } from "./resolver.js";
describe("SessionResolver — type-level contract", () => {
it("declares resolve and dispose with documented signatures", {
timeout: 5000,
}, () => {
expectTypeOf<SessionResolver>().toHaveProperty("resolve");
expectTypeOf<SessionResolver>().toHaveProperty("dispose");
expectTypeOf<SessionResolver["resolve"]>().toEqualTypeOf<
(req: Request) => Promise<Principal | null>
>();
expectTypeOf<SessionResolver["dispose"]>().toEqualTypeOf<
() => Promise<void>
>();
});
it("is generic over Principal", { timeout: 5000 }, () => {
interface AdminPrincipal extends Principal {
readonly admin: true;
}
expectTypeOf<
SessionResolver<AdminPrincipal>["resolve"]
>().toEqualTypeOf<(req: Request) => Promise<AdminPrincipal | null>>();
});
});

View File

@@ -0,0 +1,36 @@
import type { Principal } from "@suaveplan/api-types/principal";
/**
* Abstract per-request session resolver.
*
* `resolve` is called by the host at step 3 of the per-request pipeline (design
* doc §6). The return value semantics are deliberately tri-valued at the
* resolver level but collapse to a binary at the host:
*
* - `Principal` — this resolver recognised the caller; the host attaches it
* to `RequestContext` and short-circuits the chain.
* - `null` — "I don't recognise this request." The host moves to the next
* resolver in the chain. After every resolver returns `null`, the host's
* `publicRoutes` decision applies: a non-public route 401s; a public route
* proceeds with `principal: null`.
*
* `dispose` is called once at host shutdown (or whenever the host rebuilds its
* resolver chain — e.g. blue/green key rotation). Implementations MUST be
* idempotent — the host MAY call `dispose` more than once on cleanup paths.
*
* @typeParam P - allow concrete resolvers to narrow the principal shape via
* module augmentation of `Claims` (or a subclass of `Principal`).
* The chain runner widens back to `Principal` at the boundary.
*/
export interface SessionResolver<P extends Principal = Principal> {
/**
* Inspect the request and return a principal, or `null` to defer to the
* next resolver. MUST be safe to call concurrently — the host fans many
* requests through a single resolver instance.
*/
resolve(req: Request): Promise<P | null>;
/**
* Tear-down hook. Idempotent — safe to call more than once.
*/
dispose(): Promise<void>;
}

View File

@@ -0,0 +1,7 @@
export {
type OpenApiSecurityScheme,
OpenApiSecuritySchemeSchema,
type SecuritySchemeContribution,
SecuritySchemeContributionSchema,
validateSecurityScheme,
} from "./security-scheme.js";

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,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_CODES } from "../error-codes/error-codes.js";
import {
OpenApiSecuritySchemeSchema,
SecuritySchemeContributionSchema,
validateSecurityScheme,
} from "./security-scheme.js";
describe("SecuritySchemeContribution — schema", () => {
it("accepts a valid http-bearer contribution", { timeout: 5000 }, () => {
const parsed = SecuritySchemeContributionSchema.parse({
name: "bearerAuth",
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
});
expect(parsed.name).toBe("bearerAuth");
});
it("accepts a valid apiKey-cookie contribution", { timeout: 5000 }, () => {
const parsed = SecuritySchemeContributionSchema.parse({
name: "session",
scheme: { type: "apiKey", in: "cookie", name: "sp_session" },
});
expect(parsed.scheme.type).toBe("apiKey");
});
it("accepts a valid oauth2 contribution", { timeout: 5000 }, () => {
const parsed = SecuritySchemeContributionSchema.parse({
name: "oauth",
scheme: { type: "oauth2", flows: { implicit: {} } },
});
expect(parsed.scheme.type).toBe("oauth2");
});
it("accepts a valid openIdConnect contribution", { timeout: 5000 }, () => {
const parsed = SecuritySchemeContributionSchema.parse({
name: "oidc",
scheme: {
type: "openIdConnect",
openIdConnectUrl:
"https://example.test/.well-known/openid-configuration",
},
});
expect(parsed.scheme.type).toBe("openIdConnect");
});
it("rejects an unknown scheme type", { timeout: 5000 }, () => {
const result = SecuritySchemeContributionSchema.safeParse({
name: "bad",
scheme: { type: "mutual-tls" },
});
expect(result.success).toBe(false);
});
it("rejects an empty name", { timeout: 5000 }, () => {
const result = SecuritySchemeContributionSchema.safeParse({
name: "",
scheme: { type: "http", scheme: "bearer" },
});
expect(result.success).toBe(false);
});
it("rejects an apiKey with bad `in` value", { timeout: 5000 }, () => {
const result = OpenApiSecuritySchemeSchema.safeParse({
type: "apiKey",
in: "body",
name: "x",
});
expect(result.success).toBe(false);
});
});
describe("validateSecurityScheme", () => {
it("returns the validated contribution on success", {
timeout: 5000,
}, () => {
const out = validateSecurityScheme({
name: "bearerAuth",
scheme: { type: "http", scheme: "bearer" },
});
expect(out.name).toBe("bearerAuth");
});
it("rejects undefined with SECURITY_SCHEME_MISSING", {
timeout: 5000,
}, () => {
try {
validateSecurityScheme(undefined);
throw new Error("expected throw");
} catch (err) {
expect((err as { code: string }).code).toBe(
AUTH_CODES.SECURITY_SCHEME_MISSING
);
}
});
it("rejects null with SECURITY_SCHEME_MISSING", { timeout: 5000 }, () => {
try {
validateSecurityScheme(null as unknown as undefined);
throw new Error("expected throw");
} catch (err) {
expect((err as { code: string }).code).toBe(
AUTH_CODES.SECURITY_SCHEME_MISSING
);
}
});
it("rejects malformed contribution with SECURITY_SCHEME_MISSING and surfaces zod issues", {
timeout: 5000,
}, () => {
try {
validateSecurityScheme({ name: "x" } as never);
throw new Error("expected throw");
} catch (err) {
const e = err as { code: string; context?: { issues?: unknown } };
expect(e.code).toBe(AUTH_CODES.SECURITY_SCHEME_MISSING);
expect(Array.isArray(e.context?.issues)).toBe(true);
}
});
});

View File

@@ -0,0 +1,83 @@
import z from "zod";
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
/**
* Minimal OpenAPI 3.1 Security Scheme Object shape that the host accepts from
* concrete resolvers. Matches the OAS section "Security Scheme Object" — the
* union of the four schemes the foundation natively supports.
*
* The runtime schema is exported as `OpenApiSecuritySchemeSchema` and the
* inferred type as `OpenApiSecurityScheme`.
*/
export const OpenApiSecuritySchemeSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("apiKey"),
name: z.string().min(1),
in: z.enum(["header", "query", "cookie"]),
description: z.string().optional(),
}),
z.object({
type: z.literal("http"),
scheme: z.string().min(1),
bearerFormat: z.string().optional(),
description: z.string().optional(),
}),
z.object({
type: z.literal("oauth2"),
flows: z.record(z.string(), z.unknown()),
description: z.string().optional(),
}),
z.object({
type: z.literal("openIdConnect"),
openIdConnectUrl: z.string().min(1),
description: z.string().optional(),
}),
]);
export type OpenApiSecurityScheme = z.infer<typeof OpenApiSecuritySchemeSchema>;
/**
* One adapter's contribution to `components.securitySchemes` in the generated
* OpenAPI document. `name` is the map key (e.g. `"bearerAuth"`); `scheme` is
* the value the OpenAPI emitter inlines verbatim.
*
* Schema is exported alongside the type so registration code can validate
* adapter input at the boundary (rule: "Zod wired at security-scheme
* boundary").
*/
export const SecuritySchemeContributionSchema = z.object({
name: z.string().min(1),
scheme: OpenApiSecuritySchemeSchema,
});
export type SecuritySchemeContribution = z.infer<
typeof SecuritySchemeContributionSchema
>;
/**
* Validate an adapter's security-scheme contribution at registration time.
*
* Returns the validated contribution on success; throws `AUTH.SECURITY_SCHEME_MISSING`
* when the contribution is missing or malformed. Hosts call this from their
* adapter-registration pipeline; the throw is the documented signal to abort
* boot rather than serve traffic with a half-configured OpenAPI document.
*/
export function validateSecurityScheme(
contribution: SecuritySchemeContribution | undefined
): SecuritySchemeContribution {
if (contribution === undefined || contribution === null) {
throw new AuthError(
AUTH_CODES.SECURITY_SCHEME_MISSING,
"AuthAdapter registered without a SecuritySchemeContribution"
);
}
const parsed = SecuritySchemeContributionSchema.safeParse(contribution);
if (!parsed.success) {
throw new AuthError(
AUTH_CODES.SECURITY_SCHEME_MISSING,
"SecuritySchemeContribution failed schema validation",
{ context: { issues: parsed.error.issues } }
);
}
return parsed.data;
}

View File

@@ -0,0 +1 @@
export { type AuthAdapter, validateAuthAdapter } from "./session-adapter.js";

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,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_CODES } from "../error-codes/error-codes.js";
import type { SessionResolver } from "../resolver/resolver.js";
import { type AuthAdapter, validateAuthAdapter } from "./session-adapter.js";
const noopResolver: SessionResolver = {
resolve: async () => null,
dispose: async () => undefined,
};
describe("validateAuthAdapter", () => {
it("returns the adapter on success", { timeout: 5000 }, () => {
const adapter: AuthAdapter = {
resolver: noopResolver,
securityScheme: {
name: "bearerAuth",
scheme: { type: "http", scheme: "bearer" },
},
};
expect(validateAuthAdapter(adapter)).toBe(adapter);
});
it("honours an explicit csrf override field", { timeout: 5000 }, () => {
const adapter: AuthAdapter = {
resolver: noopResolver,
securityScheme: {
name: "session",
scheme: { type: "apiKey", in: "cookie", name: "sp_session" },
},
csrf: false,
};
const out = validateAuthAdapter(adapter);
expect(out.csrf).toBe(false);
});
it("permits adapter with omitted securityScheme (anonymous tail)", {
timeout: 5000,
}, () => {
const adapter: AuthAdapter = { resolver: noopResolver };
expect(validateAuthAdapter(adapter)).toBe(adapter);
});
it("rejects adapter with malformed securityScheme", {
timeout: 5000,
}, () => {
try {
validateAuthAdapter({
resolver: noopResolver,
securityScheme: {
name: "",
scheme: { type: "http", scheme: "" },
} as never,
});
throw new Error("expected throw");
} catch (err) {
expect((err as { code: string }).code).toBe(
AUTH_CODES.SECURITY_SCHEME_MISSING
);
}
});
});

View File

@@ -0,0 +1,49 @@
import type { SessionResolver } from "../resolver/resolver.js";
import {
type SecuritySchemeContribution,
validateSecurityScheme,
} from "../security-scheme/security-scheme.js";
/**
* Composite shape a concrete session-auth adapter exports.
*
* Pairs a resolver with the metadata the host needs to wire it into the
* pipeline: the resolver itself (`resolver`), what to emit into the OpenAPI
* document (`securityScheme`), and an optional per-adapter CSRF default
* override (`csrf`).
*
* `csrf` defaults to `undefined` — meaning "use the policy in
* `@suaveplan/api-auth/csrf-policy`". A concrete adapter MAY pin a
* non-default value when it knows better than the generic policy (e.g. a
* cookie scheme that uses SameSite=Strict and a per-request nonce internally
* MAY pin `csrf: false`).
*/
export interface AuthAdapter {
readonly resolver: SessionResolver;
/**
* OpenAPI security scheme contribution. Optional — `api-auth-anonymous`
* (the trivial null-returning resolver used as the tail of public-API
* chains) ships without a scheme because the route security list is empty
* in that topology. Every other resolver (Stage 4 onwards) MUST supply one.
*/
readonly securityScheme?: SecuritySchemeContribution;
readonly csrf?: boolean;
}
/**
* Validate an `AuthAdapter` at host registration time.
*
* Throws `AUTH.SECURITY_SCHEME_MISSING` if the adapter's `securityScheme` is
* present-but-malformed. An omitted `securityScheme` is permitted (anonymous
* tail adapter). Returns the adapter unchanged on success so the call site can
* chain it.
*
* The resolver itself is not validated structurally — TypeScript ensures the
* shape statically and the conformance harness covers behaviour at test time.
*/
export function validateAuthAdapter(adapter: AuthAdapter): AuthAdapter {
if (adapter.securityScheme !== undefined) {
validateSecurityScheme(adapter.securityScheme);
}
return adapter;
}

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.

View File

@@ -0,0 +1,71 @@
import type { PrincipalId, Scope } from "@suaveplan/api-types/principal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SessionResolver } from "../resolver/resolver.js";
import { runAuthSessionConformance } from "./conformance.js";
const TARGET = "https://example.test/api";
function buildStubResolver(): SessionResolver {
let _disposed = 0;
return {
resolve: async (req: Request) => {
const url = new URL(req.url);
if (url.searchParams.get("authed") === "1") {
return {
id: "u_stub" as PrincipalId,
scopes: ["read:self"] as readonly Scope[],
claims: { sub: "u_stub" },
};
}
return null;
},
dispose: async () => {
_disposed++;
},
};
}
runAuthSessionConformance({
label: "harness-self-test",
factory: () => buildStubResolver(),
seedNull: () => new Request(TARGET),
seedPrincipal: () => ({
request: new Request(`${TARGET}?authed=1`),
expected: {
id: "u_stub" as PrincipalId,
scopes: ["read:self"] as readonly Scope[],
claims: { sub: "u_stub" },
},
}),
});
// Null-only resolver path — exercises the `else` branch of the harness
// (the "anonymous tail of every resolver chain" scenario, where
// `seedPrincipal` is omitted). Pins coverage of lines 74-87 inside this
// package rather than relying on a downstream consumer (api-auth-anonymous).
function buildNullOnlyResolver(): SessionResolver {
return {
resolve: async () => null,
dispose: async () => undefined,
};
}
runAuthSessionConformance({
label: "harness-self-test-null-only",
factory: () => buildNullOnlyResolver(),
seedNull: () => new Request(TARGET),
});
describe("runAuthSessionConformance — harness shape", () => {
it("harness covers resolve null, resolve principal, dispose lifecycle", {
timeout: 5000,
}, async () => {
// Smoke: build a resolver, exercise the three behaviours directly.
const r = buildStubResolver();
expect(await r.resolve(new Request(TARGET))).toBeNull();
const p = await r.resolve(new Request(`${TARGET}?authed=1`));
expect(p?.id).toBe("u_stub");
await r.dispose();
await expect(r.dispose()).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,98 @@
import type { Principal } from "@suaveplan/api-types/principal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { SessionResolver } from "../resolver/resolver.js";
/**
* Inputs to {@link runAuthSessionConformance}.
*
* `factory()` MUST return a freshly constructed resolver pre-seeded with two
* known requests:
*
* - `seedNull` — a request the resolver returns `null` for.
* - `seedPrincipal` — a request the resolver returns a known principal for,
* along with the expected `Principal` object so the assertion can compare.
*/
export interface AuthSessionConformanceFactory {
readonly factory: () => Promise<SessionResolver> | SessionResolver;
readonly label: string;
readonly seedNull: () => Request;
/**
* Optional principal seed. Omit (or set to `null`) for resolvers that
* never recognise a request — e.g. `api-auth-anonymous`, the "no auth"
* tail of every resolver chain. When omitted, the "recognised principal"
* scenario is skipped; the null + dispose scenarios still run.
*/
readonly seedPrincipal?:
| (() => {
readonly request: Request;
readonly expected: Principal;
})
| null;
}
/**
* Reusable harness every concrete `SessionResolver` (Stages 4/6/8) MUST pass.
*
* Covers the documented behaviours:
* - resolve returns `null` for a request the adapter does not recognise.
* - resolve returns the expected `Principal` for a recognised request.
* - `dispose()` is idempotent — calling it twice is safe.
*/
export function runAuthSessionConformance(
spec: AuthSessionConformanceFactory
): void {
describe(`SessionResolver conformance — ${spec.label}`, () => {
it("resolve returns null for unrecognised request", {
timeout: 5000,
}, async () => {
const resolver = await spec.factory();
try {
const result = await resolver.resolve(spec.seedNull());
expect(result).toBeNull();
} finally {
await resolver.dispose();
}
});
const seedPrincipal = spec.seedPrincipal;
if (seedPrincipal) {
it("resolve returns expected principal for recognised request", {
timeout: 5000,
}, async () => {
const resolver = await spec.factory();
try {
const { request, expected } = seedPrincipal();
const result = await resolver.resolve(request);
expect(result).not.toBeNull();
expect(result?.id).toBe(expected.id);
expect(result?.scopes).toEqual(expected.scopes);
} finally {
await resolver.dispose();
}
});
} else {
it("resolve returns null for every input (null-only resolver)", {
timeout: 5000,
}, async () => {
const resolver = await spec.factory();
try {
// Exercise a recognisable-looking request — anonymous-style
// resolvers must still return null even when authentication
// material is present.
const probe = new Request("https://example.test/probe", {
headers: { authorization: "Bearer probe.token.xxx" },
});
expect(await resolver.resolve(probe)).toBeNull();
} finally {
await resolver.dispose();
}
});
}
it("dispose is idempotent", { timeout: 5000 }, async () => {
const resolver = await spec.factory();
await resolver.dispose();
await expect(resolver.dispose()).resolves.toBeUndefined();
});
});
}

View File

@@ -0,0 +1,4 @@
export {
type AuthSessionConformanceFactory,
runAuthSessionConformance,
} from "./conformance.js";

View File

@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
readonly dependencies?: Record<string, string>;
readonly peerDependencies?: Record<string, string>;
};
describe("@suaveplan/api-auth dep graph", () => {
it("depends on api-types, error, types, tslib, zod (and nothing else)", {
timeout: 5000,
}, () => {
const deps = Object.keys(PKG.dependencies ?? {}).sort();
expect(deps).toEqual(
[
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
].sort()
);
});
it("declares no peerDependencies", { timeout: 5000 }, () => {
expect(Object.keys(PKG.peerDependencies ?? {})).toEqual([]);
});
});

View File

@@ -0,0 +1,46 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
function walk(dir: string): readonly string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out.push(...walk(full));
} else if (full.endsWith(".ts")) {
out.push(full);
}
}
return out;
}
describe("zod import form", () => {
it("every src file uses default-import z", { timeout: 5000 }, () => {
const bad: string[] = [];
const files = walk(SRC);
for (const file of files) {
const text = readFileSync(file, "utf8");
if (/import\s*\{\s*z\s*[},]/.test(text)) {
bad.push(file);
}
}
expect(bad).toEqual([]);
});
it("source files importing zod use the default form", {
timeout: 5000,
}, () => {
let imports = 0;
for (const file of walk(SRC)) {
const text = readFileSync(file, "utf8");
if (/from\s+["']zod["']/.test(text)) {
imports++;
expect(text).toMatch(/import\s+z\s+from\s+["']zod["']/);
}
}
expect(imports).toBeGreaterThan(0);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-auth",
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: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});