Files
suaveplan/openspec/archive/2026-05-25-api-auth/specs/api-auth/spec.md
2026-05-27 23:29:53 +01:00

5.5 KiB

Capability Spec Delta: api-auth (api-auth)

Package: @suaveplan/api-auth Status: PROPOSED

Every requirement carries a **Verified by:** line.

ADDED Requirements

Requirement: SessionResolver contract

The package SHALL export SessionResolver interface: resolve(req: Request): Promise<Principal | null>, dispose(): Promise<void>. A resolver returning null means "I don't recognise this request" — NOT "auth failed." Auth failure (401) is the host's decision based on chain outcome + route's publicRoutes membership.

Scenario: every method present

  • WHEN expectTypeOf<SessionResolver>() is checked
  • THEN resolve and dispose are declared with the documented signatures

Verified by: src/resolver/resolver.spec-conformance.test.ts::"SessionResolver declares resolve and dispose"


Requirement: import z from "zod" everywhere

Every Zod import MUST use the default form.

Scenario: no named import of z

  • WHEN the test greps import z from "zod"
  • THEN zero matches

Verified by: tests/zod-import-form.test.ts::"every src file uses default-import z"


Requirement: Resolver chain — first-match-wins

The package SHALL export SessionResolverChain(resolvers: readonly SessionResolver[]).resolve(req): Promise<Principal | null> running each resolver in order; the first non-null result wins; if all return null, the chain returns null. An empty chain MUST throw AUTH.CHAIN_EMPTY at construction. By default, a resolver throwing MUST surface AUTH.RESOLVER_THREW AND short-circuit the chain (fail-closed). An opt-in continueOnThrow: true flag lets the chain try the next resolver; this is documented as escape-hatch only.

Scenario: first non-null wins

  • WHEN chain [null, principalA, principalB] is run
  • THEN the chain returns principalA AND resolver B is NOT called

Scenario: all null returns null

  • WHEN every resolver returns null
  • THEN the chain returns null

Scenario: throw is fail-closed by default

  • WHEN resolver A throws AND continueOnThrow is false (default)
  • THEN the chain throws AUTH.RESOLVER_THREW AND resolver B is NOT called

Scenario: empty chain rejected at construction

  • WHEN new SessionResolverChain([]) is called
  • THEN it throws AUTH.CHAIN_EMPTY

Verified by: src/chain/chain.test.ts::"first non-null wins, later resolvers skipped" AND src/chain/chain.test.ts::"all null returns null" AND src/chain/chain.test.ts::"throw is fail-closed by default" AND src/chain/chain.test.ts::"empty chain rejected at construction"


Requirement: SecuritySchemeContribution

The package SHALL export SecuritySchemeContribution with { name: string, scheme: OpenApiSecurityScheme }. Every concrete SessionResolver MUST contribute exactly one scheme so api-openapi can emit the right securitySchemes block. An AuthAdapter whose securityScheme is absent at registration MUST surface AUTH.SECURITY_SCHEME_MISSING.

Scenario: missing scheme rejected

  • WHEN an AuthAdapter without securityScheme is registered
  • THEN registration throws AUTH.SECURITY_SCHEME_MISSING

Verified by: src/security-scheme/security-scheme.test.ts::"registration rejects adapter without securityScheme"


Requirement: CSRF default policy

The package SHALL export CsrfPolicy.requiresCsrf(scheme: OpenApiSecurityScheme): boolean. Cookie-based schemes (type: "apiKey" AND in: "cookie") default ON; JWT/Bearer schemes (type: "http" AND scheme: "bearer") default OFF. Per-route csrf: false opt-out is honoured by the host (this package exposes the default; the host applies the override).

  • WHEN requiresCsrf({ type: "apiKey", in: "cookie", ... }) is called
  • THEN the result is true

Scenario: bearer scheme defaults to CSRF OFF

  • WHEN requiresCsrf({ type: "http", scheme: "bearer" }) is called
  • THEN the result is false

Verified by: src/csrf-policy/csrf-policy.test.ts::"cookie schemes default to CSRF ON" AND src/csrf-policy/csrf-policy.test.ts::"bearer schemes default to CSRF OFF"


Requirement: CapabilityId augmentation contributes "auth.session"

The package SHALL augment the CapabilityId union to include "auth.session".

Scenario: auth.session assignable to CapabilityId

  • WHEN expectTypeOf<"auth.session">().toMatchTypeOf<CapabilityId>() is checked
  • THEN the assertion passes

Verified by: src/capability-id/capability-id.spec-conformance.test.ts::"auth.session is a valid CapabilityId after augmentation"


Requirement: Error codes registry frozen

The package SHALL export AUTH_CODES containing exactly: RESOLVER_THREW, CHAIN_EMPTY, PRINCIPAL_INVALID_SHAPE, SECURITY_SCHEME_MISSING.

Scenario: every code reachable

  • WHEN the test enumerates AUTH_CODES
  • THEN every code has a production throw site

Verified by: src/error-codes/error-codes.spec-conformance.test.ts::"every AUTH_CODES entry thrown by production path"


Requirement: Conformance harness for resolvers

The package SHALL export runAuthSessionConformance({ factory, label }). Every concrete resolver in Stages 4/6/8 MUST pass it.

Scenario: harness covers resolve null, resolve principal, dispose

  • WHEN the harness is run against a stub resolver
  • THEN test categories exist for each documented behavior

Verified by: src/testing/conformance.test.ts::"harness covers resolve null, resolve principal, dispose lifecycle"