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,93 @@
# `@suaveplan/api-auth-rolled` — rolled session-token resolver (Stage 8.12 keystone)
**Package**: `@suaveplan/api-auth-rolled`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 8 — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
Per design doc §2.3, genesis ships two reference auth providers in v1:
Better-Auth (third-party wrapper) AND `api-auth-rolled` (composes the
genesis crypto/auth primitives end-to-end). Shipping the rolled
session-resolver in Stage 8 is the keystone that proves rotating-key
JWTs compose against the existing primitives without a third-party
dependency, and gives operators a fallback session-auth path that owns
zero third-party runtime dependencies.
This change scopes Stage 8.12 to the **session-resolver half** of the
dual contract. The matching `AuthProviderAdapter` (account-lifecycle
routes, password gating, MFA, OAuth, etc.) is deferred to a successor
change `api-auth-rolled-provider` so this milestone ships at 100%
coverage in a single iteration. Sharing the host's main DB connection,
the primitive composition, and the JWT key-rotation contract are all
established here — the successor change layers routes onto the same
adapter shape.
## What changes
- ADDED: `createRolledAuthAdapter({ store, keys, issuer, audience, ... })`
returns `AuthAdapter` (session resolver + security-scheme contribution).
- ADDED: `TokenRotator` — manages a rotating set of RS256 signing keys.
Active key signs new JWTs; retired-but-non-revoked keys still verify
in-flight tokens; revoked keys neither. `decide()` is the advisory
rotation timing signal; `setKeys()` is the operator-driven swap.
- ADDED: Session tokens are signed JWTs (RS256) via `@suaveplan/jws` +
`@suaveplan/crypto-rsa` JWKs; key rotation supported by JWKS-style key
id. The resolver accepts both `Authorization: Bearer <token>` and
`Cookie: suaveplan.session=<token>` (configurable name).
- ADDED: `RolledAuthStore` — the structural storage interface the
adapter consumes. Concrete DB-backed implementations are out of scope
for this change (operator wires them).
- ADDED: `projectUserRowToPrincipal` — maps internal `UserRow` shape to
the `@suaveplan/api-types` `Principal` contract.
- ADDED: `securitySchemes` contribution for `api-openapi` — cookie +
bearer schemes both contributed.
- ADDED: Error codes (`AUTH_ROLLED_*`): exhaustive 20-code registry
covering account-lifecycle, session, and infrastructure failure modes.
## Out of scope (deferred to `api-auth-rolled-provider`)
- `/auth/register`, `/auth/login`, `/auth/logout`, `/auth/refresh` route
implementations.
- Password hashing + strength + breach gating at registration time.
- MFA (TOTP, WebAuthn), SRP, OAuth flows.
- Email-verification / password-reset token issuance.
- Migrations for `users`, `user_sessions`, etc.
The session resolver shipped here verifies tokens issued by any provider
that signs to the same JWK set, so the dual contract is split clean —
operators MAY wire `api-auth-rolled` for session reads against a separate
provider (e.g. Clerk) that issues the JWTs.
## Source mapping
Genesis-original. Source of design intent:
`docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| Implements `auth.session` | §2.3 |
| Shares the host's main DB connection | §2.3 |
| Composes `crypto-rsa` + `jws` primitives | §2.3 |
| Rotating-`kid` JWT signing | §2.3 |
## Verification approach
- `src/session/session.test.ts` — issue, verify (active kid, retired kid,
revoked kid), expired token rejected, cookie + bearer extraction.
- `src/token-rotator/token-rotator.test.ts` — `decide`/`setKeys`/
`snapshot`/`publicJwkFor` paths.
- `src/principal-projection/principal-projection.test.ts` — projection
invariants.
- `src/options/options.test.ts` — Zod schema + DATABASE_REQUIRED + key-set
invariants.
- `src/security-schemes/security-schemes.test.ts` — cookie + bearer
contributions validate.
- `src/composition/composition.spec-conformance.test.ts` — every
`AUTH_ROLLED_*` code is thrown by at least one production path.
## Migration impact
None — additive only. `api-host` registers this when
`host.auth.session = "rolled"`.

View File

@@ -0,0 +1,153 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-rolled (api-auth-rolled)
**Package**: `@suaveplan/api-auth-rolled`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> **Scope note (Stage 8.12 keystone):** this change ships the rolled
> **session-resolver** half of the eventual dual contract. Issues RS256 JWTs
> with rotating `kid` slots, projects DB rows onto the `Principal` contract,
> and contributes cookie + bearer OpenAPI security schemes. The matching
> `AuthProviderAdapter` half (account-lifecycle routes, password gating,
> MFA, OAuth) is deferred to a successor change `api-auth-rolled-provider`
> so this milestone can ship at 100% coverage in a single iteration.
## ADDED Requirements
### Requirement: Implements `auth.session`
The package SHALL expose `createRolledAuthAdapter` returning an
`AuthAdapter` (session resolver + security scheme contribution).
#### Scenario: adapter exposes the auth.session contract
- WHEN `createRolledAuthAdapter({ store, keys, issuer, audience })` is called
- THEN the returned value implements `{ resolver, securityScheme }` per
`@suaveplan/api-auth/session-adapter`
**Verified by:** `src/session/session.test.ts::"adapter exposes resolver and securityScheme"`
---
### Requirement: Session tokens are RS256-signed JWTs with rotating `kid`
The package SHALL issue session tokens as RS256 JWTs signed via
`@suaveplan/crypto-rsa` + `@suaveplan/jws`. Keys are identified by `kid`
so rotation is possible without invalidating in-flight sessions. The
`TokenRotator` manages an active key (signs) and retired-but-non-revoked
keys (verify only). Expired tokens cause `resolveSession` to return null
(soft failure per `SessionResolver` chain semantics) — the host's chain
then emits 401.
#### Scenario: expired token rejected
- WHEN a JWT whose `exp` is in the past is presented
- THEN `resolveSession` returns null
#### Scenario: token signed by retired key still verifies
- WHEN a JWT signed under `kid=k0` is presented after the active key has
rotated to `kid=k1`, and `k0` is retired but not revoked
- THEN `resolveSession` returns the projected principal
#### Scenario: token signed by revoked key rejected
- WHEN a JWT whose `kid` is revoked is presented
- THEN `resolveSession` returns null
**Verified by:** `src/session/session.test.ts::"expired token returns null"`
AND `src/session/session.test.ts::"retired but non-revoked kid still verifies"`
AND `src/session/session.test.ts::"revoked kid rejected"`
---
### Requirement: Principal projection maps DB rows to api-types `Principal`
The package SHALL project each authenticated user row into the
`Principal` shape from `api-types` (`id`, `scopes`, optional `tenantId`,
`claims`).
#### Scenario: projection includes scopes and tenantId
- WHEN a user with scopes `["billing:read"]` and `tenantId = "acme"` is
resolved
- THEN `Principal` reflects those values exactly AND no extra fields leak
**Verified by:** `src/principal-projection/principal-projection.test.ts::"projects scopes and tenantId"`
---
### Requirement: Shares host's main DB connection (no separate connection)
The package SHALL accept a `store` adapter passed in by the host and MUST
NOT open its own DB connection. The store interface is structural and
satisfied by any storage layer the host wires.
#### Scenario: adapter requires store at construction
- WHEN `createRolledAuthAdapter({})` is called without `store`
- THEN it throws `AuthRolledError(DATABASE_REQUIRED)`
**Verified by:** `src/options/options.test.ts::"throws DATABASE_REQUIRED when store is missing"`
---
### Requirement: Comprehensive error code coverage
The package SHALL throw at least one production path per error code in
`AUTH_ROLLED_*`. The composition test enumerates the registry and asserts
every code has a production trigger.
#### Scenario: every error code is triggered by at least one production path
- WHEN the conformance test enumerates `AUTH_ROLLED_*`
- THEN each code maps to at least one named production scenario
**Verified by:** `src/composition/composition.spec-conformance.test.ts::"every AUTH_ROLLED code is triggered"`
---
### Requirement: Contributes OpenAPI security schemes
The package SHALL contribute `securitySchemes` to `api-openapi` describing
the cookie + bearer schemes.
#### Scenario: schemes are picked up by emitter
- WHEN the adapter is inspected
- THEN `securityScheme.schemes.bearer` and `securityScheme.schemes.cookie`
are present and validate against `OpenApiSecuritySchemeSchema`
**Verified by:** `src/security-schemes/security-schemes.test.ts::"contributes cookie and bearer schemes"`
---
### Requirement: Rotating-key TokenRotator
The package SHALL expose a `TokenRotator` that surfaces `snapshot()`,
`decide()` (advisory rotation timing), `setKeys()` (operator-driven swap)
and `publicJwkFor(kid)` (verifier lookup). The rotator MUST enforce
exactly-one-active-non-revoked on every keyset swap.
#### Scenario: setKeys with no active key rejected
- WHEN `setKeys([key("k1", active=false)])` is called
- THEN it throws `AuthRolledError(KEY_ROTATION_FAILED)`
#### Scenario: setKeys with two active keys rejected
- WHEN a replacement keyset contains two active+non-revoked keys
- THEN `setKeys` throws `AuthRolledError(KEY_ROTATION_FAILED)`
#### Scenario: decide reports shouldRotate=true after period elapses
- WHEN `rotationPeriodMs = 1000` and 1500 ms elapse on the injected clock
- THEN `decide().shouldRotate === true` AND `decide().retiringKid` equals
the active kid
**Verified by:** `src/token-rotator/token-rotator.test.ts::"setKeys rejects a replacement with zero active keys"`
AND `src/token-rotator/token-rotator.test.ts::"setKeys rejects a replacement with two active keys"`
AND `src/token-rotator/token-rotator.test.ts::"decide returns shouldRotate=true once the period elapses"`

View File

@@ -0,0 +1,49 @@
# Tasks — `@suaveplan/api-auth-rolled`
## 1. Spec authoring
- [x] 1.1 Read design doc (§2.3)
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.3 Draft `proposal.md` — focused session-resolver scope
- [x] 1.4 Draft `specs/api-auth-rolled/spec.md`
- [x] 1.5 Every requirement carries `**Verified by:**`
## 2. Implementation
- [x] 2.1 Scaffold `packages/api-stack/api/api-auth-rolled/`
- [x] 2.2 `src/error-codes/` — 20-code registry
- [x] 2.3 `src/options/` — Zod-validated options + named defaults
- [x] 2.4 `src/token-rotator/` — rotating-key state machine
- [x] 2.5 `src/principal-projection/` — DB row → `Principal`
- [x] 2.6 `src/session/` — JWT signing + verification + cookie/bearer extraction
- [x] 2.7 `src/security-schemes/` — `api-openapi` contribution
- [x] 2.8 `src/composition/` — `createRolledAuthAdapter` factory + conformance
- [x] 2.9 `src/index.ts` — explicit named re-exports
## 3. Tests
- [x] 3.1 `error-codes.test.ts`
- [x] 3.2 `options.test.ts`
- [x] 3.3 `token-rotator.test.ts`
- [x] 3.4 `principal-projection.test.ts`
- [x] 3.5 `session.test.ts` — happy path + expired + revoked + retired kid
- [x] 3.6 `security-schemes.test.ts`
- [x] 3.7 `composition.spec-conformance.test.ts` — every code triggered
- [x] 3.8 `composition.test.ts` — `createRolledAuthAdapter` surface
## 4. Documentation
- [x] 4.1 Sibling `.md` per implementation `.ts`
- [x] 4.2 `README.md` `## Modules`
## 5. Pre-archive verification
- [x] 5.1 `bunx turbo lint typecheck test --filter=@suaveplan/api-auth-rolled` (gate)
- [x] 5.2 `bunx turbo test:coverage --filter=@suaveplan/api-auth-rolled` 100/100/100/100
## 6. Archive
- [x] 6.1 Fold spec delta into `openspec/specs/api-auth-rolled/spec.md`
- [x] 6.2 Move folder to archive
- [x] 6.3 Tick `8.12` in umbrella tasks
- [x] 6.4 Refresh `MANIFEST.md`