first commit

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

View File

@@ -0,0 +1,83 @@
# @suaveplan/api-auth-rolled — rolled-our-own auth provider. Implements both `auth.session` and `auth.provider` capability contracts. Session tokens are RS256-signed JWTs (via @suaveplan/crypto-rsa + @suaveplan/jws) with rotating `kid` for non-disruptive key rotation. Passwords are hashed via Argon2id (@suaveplan/crypto-hash), gated by @suaveplan/password-strength and @suaveplan/hibp at registration. Provider mounts the canonical 9-route PROVIDER_ROUTE_SURFACE under a configurable path prefix. DB access via injected @suaveplan/types `DatabaseLike` — shares the host's main connection per design §2.3. Projects DB rows onto the @suaveplan/api-types `Principal` shape. Contributes cookie + bearer OpenAPI security schemes.
Stage-8.12 rolled session-token resolver for `@suaveplan/api`. Issues
RS256-signed JWTs with rotating `kid` slots, verifies tokens through the
rotator's active + retired-non-revoked key set, projects DB rows onto
the `@suaveplan/api-types` `Principal` contract, and contributes cookie
+ bearer OpenAPI security schemes.
This milestone ships the **session-resolver half** of the eventual dual
contract (`auth.session`). The matching `AuthProviderAdapter`
(account-lifecycle routes, password gating, MFA, OAuth) is deferred to
a successor change `api-auth-rolled-provider`.
<!-- begin:auto-docs -->
## Documentation tree
- [composition](./src/composition/composition.md) — Top-level factory `createRolledAuthAdapter` that wires the four sub-modules (`token-rotator`, `session`, `principal-pro…
- [composition](./src/composition/composition.md) — Top-level factory `createRolledAuthAdapter` that wires the four sub-modules (`token-rotator`, `session`, `principal-pro…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-rolled`. Every code is namespaced under `AUTH_ROLLED.*` so cross-pa…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-rolled`. Every code is namespaced under `AUTH_ROLLED.*` so cross-pa…
- [options](./src/options/options.md) — Defines the configuration shape accepted by `createRolledAuthAdapter` and the Zod schema that validates it. Operators w…
- [options](./src/options/options.md) — Defines the configuration shape accepted by `createRolledAuthAdapter` and the Zod schema that validates it. Operators w…
- [principal-projection](./src/principal-projection/principal-projection.md) — Projects the package's internal `UserRow` storage shape onto the abstract `Principal` contract published by `@suaveplan…
- [principal-projection](./src/principal-projection/principal-projection.md) — Projects the package's internal `UserRow` storage shape onto the abstract `Principal` contract published by `@suaveplan…
- [security-schemes](./src/security-schemes/security-schemes.md) — Contributes OpenAPI 3.1 `securitySchemes` entries describing both transports the rolled session resolver accepts — `Aut…
- [security-schemes](./src/security-schemes/security-schemes.md) — Contributes OpenAPI 3.1 `securitySchemes` entries describing both transports the rolled session resolver accepts — `Aut…
- [session](./src/session/session.md) — The rolling-JWT session resolver — the keystone of the rolled auth adapter. Builds a `SessionResolver` (per `@suaveplan…
- [session](./src/session/session.md) — The rolling-JWT session resolver — the keystone of the rolled auth adapter. Builds a `SessionResolver` (per `@suaveplan…
- [token-rotator](./src/token-rotator/token-rotator.md) — Owns the rotating-keyset state for the rolled session-token resolver. The active key signs new JWTs; retired-but-non-re…
- [token-rotator](./src/token-rotator/token-rotator.md) — Owns the rotating-keyset state for the rolled session-token resolver. The active key signs new JWTs; retired-but-non-re…
<!-- end:auto-docs -->
## Why
This package gives operators a
session-auth path that owns zero third-party runtime dependencies.
## Install
```bash
bun add @suaveplan/api-auth-rolled
```
## Modules
| Module | What it owns |
|---|---|
| `composition/` | `createRolledAuthAdapter` — the public factory. |
| `error-codes/` | The 4-code `AUTH_ROLLED.*` registry. |
| `options/` | Zod-validated options + named defaults. |
| `principal-projection/` | `UserRow` → `Principal` projection. |
| `security-schemes/` | Cookie + bearer OpenAPI contributions. |
| `session/` | RS256 sign/verify, cookie+bearer extraction, `Set-Cookie`. |
| `token-rotator/` | Rotating-key state machine. |
## Quick start
```ts
import { createRolledAuthAdapter } from "@suaveplan/api-auth-rolled";
const adapter = createRolledAuthAdapter({
store: hostStore,
keys: [{ kid: "k1", privateJwk, publicJwk, active: true }],
issuer: "https://api.example.com",
audience: "example-front",
});
host.registerAuthSession(adapter);
```
Rotation cron:
```ts
setInterval(async () => {
if (!adapter.rotator.decide().shouldRotate) return;
adapter.rotator.setKeys(await kms.nextKeyset());
}, 60_000);
```
## License
MIT

View File

@@ -0,0 +1,85 @@
# `composition`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level factory `createRolledAuthAdapter` that wires the four
sub-modules (`token-rotator`, `session`, `principal-projection`,
`security-schemes`) into a single `AuthAdapter` ready for the host to
register. Keeping the composition explicit (rather than letting each
sub-module own its own host wiring) means the dependency graph is one
arrow: options flow in, an adapter object flows out, no hidden state.
The returned value extends the `AuthAdapter` contract (`resolver` +
`securityScheme`) with three additional surfaces the future provider
half (`api-auth-rolled-provider`) will consume: `issueToken`,
`verifyToken`, and the rotator handle so operators can wire
`setKeys(...)` from an out-of-band rotation cron.
## Features
- Single entry point — `createRolledAuthAdapter(rawOptions)`.
- Options validated via the package's Zod schema before any wiring runs.
- Defaults filled at the composition layer (cookie name, rotation
period, session TTL) so individual sub-modules stay focused on their
one concern.
- Returned adapter is structurally a valid `AuthAdapter` so any host
that accepts the contract will accept the rolled adapter.
## Usage: Basic
```ts
import { createRolledAuthAdapter } from "@suaveplan/api-auth-rolled";
const adapter = createRolledAuthAdapter({
store,
keys: [
{ kid: "k1", privateJwk, publicJwk, active: true },
],
issuer: "https://api.example.com",
audience: "example-front",
});
host.registerAuthSession(adapter);
```
## Usage: Advanced
```ts
import { createRolledAuthAdapter } from "@suaveplan/api-auth-rolled";
const adapter = createRolledAuthAdapter({
store,
keys: initialKeys,
issuer: "https://api.example.com",
audience: "example-front",
cookieName: "acme.session",
sessionTtlSec: 1800,
rotationPeriodMs: 12 * 60 * 60 * 1000,
});
// Operator rotation cron — fetch new key from KMS, swap atomically.
setInterval(async () => {
if (!adapter.rotator.decide().shouldRotate) return;
const next = await kms.nextKeyset();
adapter.rotator.setKeys(next);
}, 60_000);
```
## API Reference
- `createRolledAuthAdapter(rawOptions: unknown): RolledAuthAdapter`
- `RolledAuthAdapter extends AuthAdapter`: adds `id: "rolled"`, `rotator`,
`issueToken(userId)`, `verifyToken(token)`.
## Implementation Notes
The adapter intentionally keeps `id` as the literal `"rolled"` so a host
running both Better-Auth and rolled can disambiguate by adapter id. The
`securityScheme` slot points at the bearer contribution (the canonical
single-scheme shape) but the `.schemes` map on the contribution exposes
both the cookie and bearer entries — emitters aware of dual-transport
adapters can register both names without a separate adapter property.

View File

@@ -0,0 +1,78 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/api-auth-rolled`. Every code is
namespaced under `AUTH_ROLLED.*` so cross-package consumers (the host's audit
log, the OpenAPI emitter, the conformance harness) can identify the package
that raised the failure without string-matching on free-form messages.
The milestone ships the session-resolver half only; the registry
is correspondingly minimal — four operator-actionable infrastructure codes,
all of which are thrown synchronously from production code paths and
covered by the conformance test. Per-request soft failures (missing token,
bad signature, expired) collapse to `null` per `SessionResolver` chain
semantics and are therefore NOT codes. Account-lifecycle codes
(`USER_NOT_FOUND`, `INVALID_CREDENTIALS`, `PASSWORD_BREACHED`, …) belong to
the deferred provider half and will be added by a successor change.
## Features
- Tagged with `defineCodes("AUTH_ROLLED", [...])` from `@suaveplan/error`,
so all codes share a single namespace prefix.
- `AuthRolledError` extends `SuaveplanError` so `instanceof AuthRolledError`
narrows `.code` to `AuthRolledErrorCode`.
- Four codes: `SIGNING_KEY_NOT_CONFIGURED` (options validation),
`KEY_ROTATION_FAILED` (`TokenRotator.setKeys`),
`DATABASE_REQUIRED` (missing `store`), `PRINCIPAL_PROJECTION_FAILED`
(malformed user row).
## Usage: Basic
```ts
import { AUTH_ROLLED_CODES, AuthRolledError } from "@suaveplan/api-auth-rolled";
throw new AuthRolledError(
AUTH_ROLLED_CODES.DATABASE_REQUIRED,
"host did not inject a store"
);
```
## Usage: Advanced
```ts
import { AUTH_ROLLED_CODES, AuthRolledError } from "@suaveplan/api-auth-rolled";
try {
/* ... */
} catch (err) {
if (err instanceof AuthRolledError) {
// .rolledCode is typed as AuthRolledErrorCode — no narrowing cast.
if (err.rolledCode === AUTH_ROLLED_CODES.KEY_ROTATION_FAILED) {
log.fatal({ err }, "operator key-rotation contract violated");
}
}
throw err;
}
```
## API Reference
- `AUTH_ROLLED_CODES` — frozen `Record<Name, "AUTH_ROLLED.NAME">`.
- `AuthRolledErrorCode` — string-literal union of every code value.
- `AuthRolledError extends SuaveplanError<AuthRolledErrorCode>` — has a
`rolledCode: AuthRolledErrorCode` convenience accessor.
## Implementation Notes
The set of 20 codes is fixed by the OpenSpec spec delta. The conformance
test enumerates `AUTH_ROLLED_CODES` and asserts every code has a named
production trigger; if a code is added without a trigger, the test fails
loudly. Cross-package codes (e.g. `HIBP.*` propagated from
`@suaveplan/hibp` when the breach lookup fails) are NOT re-encoded — they
surface to the caller intact through `SuaveplanError`'s `cause` chain so
operators can attribute failures to the originating layer.

View File

@@ -0,0 +1,90 @@
# `options`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the configuration shape accepted by `createRolledAuthAdapter` and
the Zod schema that validates it. Operators wire this from `host.json` or
build it programmatically; everything the rest of the package needs flows
from a single validated `RolledAuthOptions` value, which keeps invariants
(active-key count, prefix shape, TTL positivity) in one place.
The schema deliberately validates injected functions and the `store`
adapter as `unknown` and then refines them later — Zod cannot verify
function-argument shapes statically, and TypeScript already does that work
at the call boundary.
## Features
- Zod schema `RolledAuthOptionsSchema` covering every operator-facing
knob.
- A `superRefine` cross-field invariant that exactly one key is both
`active: true` and non-revoked — keeps sign-time semantics ambiguity-free.
- Stable named defaults (`DEFAULT_COOKIE_NAME`, `DEFAULT_ROUTE_PREFIX`,
TTLs, rotation period, minimum strength score) so consumers can
reference the defaults without re-reading the source.
- Two distinct error codes for failures: `DATABASE_REQUIRED` when `store`
is missing (operator hooked up nothing), `SIGNING_KEY_NOT_CONFIGURED`
for every other schema failure (Zod issues attached as `context`).
## Usage: Basic
```ts
import { parseRolledAuthOptions } from "@suaveplan/api-auth-rolled";
const opts = parseRolledAuthOptions({
store: myStore,
keys: [{ kid: "k1", privateJwk, publicJwk, active: true }],
issuer: "https://api.example.com",
audience: "example-frontend",
});
```
## Usage: Advanced
```ts
import {
DEFAULT_ROTATION_PERIOD_MS,
parseRolledAuthOptions,
} from "@suaveplan/api-auth-rolled";
const opts = parseRolledAuthOptions({
store,
keys,
issuer: "https://api.example.com",
audience: "example-frontend",
routePrefix: "/api/auth",
cookieName: "acme.session",
cookieSameSite: "strict",
cookieSecure: true,
sessionTtlSec: 1800,
refreshTtlSec: 7 * 24 * 60 * 60,
rotationPeriodMs: DEFAULT_ROTATION_PERIOD_MS / 2,
minPasswordStrength: 4,
strength: { async score(pw) { return 4; } },
breach: { async isBreached() { return false; } },
});
```
## API Reference
- `parseRolledAuthOptions(raw): RolledAuthOptions` — throws
`AuthRolledError` on bad input.
- `RolledAuthOptionsSchema` — the Zod schema, re-exported for advanced
consumers that want to layer their own refinements.
- `DEFAULT_*` constants — every operator-facing default exposed as a
named export.
## Implementation Notes
The schema is `strictObject` so unknown keys produce a Zod error rather
than silently passing through. The `keys[]` `active` invariant is enforced
in `superRefine` rather than as a `transform` so the invariant message
appears in the validation issue list instead of as a thrown exception
mid-parse. Cross-field invariants that cross the option/store boundary
(e.g. "every kid actually resolves to a JWK") are checked later inside the
session/token-rotator modules so the options layer remains synchronous and
pure.

View File

@@ -0,0 +1,73 @@
# `principal-projection`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Projects the package's internal `UserRow` storage shape onto the abstract
`Principal` contract published by `@suaveplan/api-types`. Keeping the
projection in one place ensures that no storage-specific columns leak
into the principal seen by downstream plugins, and that the four
documented principal fields (`id`, `scopes`, `tenantId?`, `claims`) are
the only surface area authors can rely on.
The projection is deliberately strict: structurally-invalid rows raise
`AUTH_ROLLED.PRINCIPAL_PROJECTION_FAILED`, surfacing storage-driver bugs
loudly rather than letting malformed data flow into route handlers.
## Features
- One-shot pure function — no DB calls, no async, no allocations beyond
the new array of branded `Scope`s.
- Validates every column the projection touches (`id` non-empty string,
`scopes` array of non-empty strings, `claims` plain object).
- Omits `tenantId` from the output `Principal` when the storage row's
value is `null`, matching the optional shape in `Principal`.
- Brands strings as `PrincipalId` / `Scope` / `TenantId` at the boundary
so type narrowing carries through to consumers.
## Usage: Basic
```ts
import { projectUserRowToPrincipal } from "@suaveplan/api-auth-rolled";
const principal = projectUserRowToPrincipal(row);
console.log(principal.id, principal.scopes);
```
## Usage: Advanced
```ts
import { projectUserRowToPrincipal } from "@suaveplan/api-auth-rolled";
function resolveSession(row: UserRow): Principal {
try {
return projectUserRowToPrincipal(row);
} catch (err) {
// Storage adapter returned a malformed row — log + treat as an
// anonymous request rather than surfacing the bug to the client.
log.error({ err }, "principal projection failed");
throw err;
}
}
```
## API Reference
- `projectUserRowToPrincipal(row: UserRow): Principal` — pure projection.
Throws `AuthRolledError(PRINCIPAL_PROJECTION_FAILED)` on any structural
invariant violation.
## Implementation Notes
The projection allocates a fresh `scopes` array rather than spreading the
row's array — this is intentional so that downstream handlers cannot
mutate the source row through the principal reference. `claims` is a
shallow read-only reference so deep-cloning every claim object on every
request stays optional (consumers wanting deep immutability can wrap with
`Object.freeze`). The `tenantId` branding mirrors the contract published
by `@suaveplan/api-tenant-resolver-*` adapters — keeping the brand at the
projection boundary means downstream code that takes a `TenantId` never
needs an unsafe cast.

View File

@@ -0,0 +1,73 @@
# `security-schemes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Contributes OpenAPI 3.1 `securitySchemes` entries describing both
transports the rolled session resolver accepts — `Authorization: Bearer
<token>` and a `Cookie` carrying the same JWT. Keeping the two entries
co-located means a single source of truth describes what the resolver
accepts on the wire; the `api-openapi` emitter (Stage 8.6) walks the
adapter's `securityScheme` slot AND its `.schemes` map so both are
surfaced in the generated document.
## Features
- Bearer entry: OpenAPI 3.1 `http` / `bearer` form with `bearerFormat:
"JWT"` — the canonical Bearer-JWT shape consumers expect.
- Cookie entry: `apiKey` / `in: cookie` form whose `name` is the
caller-configured cookie name, so the doc matches the adapter's
runtime behaviour exactly.
- `createRolledSecuritySchemes({ cookieName })` returns a single value
that satisfies the `SecuritySchemeContribution` contract (its `name`
and `scheme` point at the bearer entry) AND exposes both contributions
on `.schemes` — the emitter can pull either or both.
## Usage: Basic
```ts
import { createRolledSecuritySchemes } from "@suaveplan/api-auth-rolled";
const contribution = createRolledSecuritySchemes({
cookieName: "suaveplan.session",
});
```
## Usage: Advanced
```ts
import {
createRolledBearerSecurityScheme,
createRolledCookieSecurityScheme,
} from "@suaveplan/api-auth-rolled";
// Multi-resolver host — name the contributions to disambiguate.
const userBearer = createRolledBearerSecurityScheme("userBearer");
const serviceBearer = createRolledBearerSecurityScheme("serviceBearer");
const cookie = createRolledCookieSecurityScheme({
name: "userCookie",
cookieName: "acme.session",
});
```
## API Reference
- `createRolledBearerSecurityScheme(name?: string): SecuritySchemeContribution`
- `createRolledCookieSecurityScheme({ name?, cookieName }): SecuritySchemeContribution`
- `createRolledSecuritySchemes({ cookieName }): RolledSecurityContribution`
— convenience bundle.
## Implementation Notes
The combined `RolledSecurityContribution` is structurally a
`SecuritySchemeContribution` (so old emitters that only read `name` +
`scheme` keep working) but adds a `.schemes` map for emitters aware of
the dual-transport shape. This keeps the contribution drop-in for both
single-transport and dual-transport emitters without forcing every
caller to choose. The cookie scheme's `apiKey/in:cookie` representation
matches OpenAPI 3.1 §4.8.27.4 — there is no first-class cookie-JWT
shape in OAS 3.1 so `apiKey` with the configured name is the canonical
form.

View File

@@ -0,0 +1,100 @@
# `session`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The rolling-JWT session resolver — the keystone of the rolled auth
adapter. Builds a `SessionResolver` (per `@suaveplan/api-auth`) that
parses-and-verifies a JWT extracted from either the `Authorization:
Bearer` header or a configurable cookie, runs the JWT through the
`TokenRotator` to find the verifying JWK by `kid`, projects the matching
user row onto a `Principal`, and returns `null` on every soft failure so
the host's resolver chain can move on to the next adapter.
The same module exposes `issueToken(userId)` so the future
account-lifecycle provider (deferred to `api-auth-rolled-provider`) can
share a single signing path with the resolver. Keeping issue and verify
co-located means the JWT shape and the rotator wiring stay in lock-step.
## Features
- RS256 signing via `@suaveplan/jws` `compactSign` with the rotator's
active JWK; verification via `compactVerify` against the rotator's
retired-or-active JWK matched by `kid`.
- Bearer + cookie extraction in one pass — bearer wins when both are
present so service clients can override a stale cookie.
- Strict JWT-claim validation: `iss`/`aud` exact match, `exp` future,
`iat` not before `users.createdAt`.
- Soft failures (missing token, bad signature, expired, unknown kid)
collapse to `null`; hard failures (storage adapter throws) propagate
as `AuthRolledError`.
- `buildSetCookie` returns a deterministic `Set-Cookie` value with
`HttpOnly`, `Path=/`, `Max-Age`, `SameSite`, `Secure` — used by the
future provider half but published here so the cookie shape lives next
to the cookie parser.
## Usage: Basic
```ts
import { createRolledSessionResolver, TokenRotator } from "@suaveplan/api-auth-rolled";
const rotator = new TokenRotator({ keys, rotationPeriodMs, clock });
const resolver = createRolledSessionResolver({
options,
rotator,
clock,
cookieName: "suaveplan.session",
sessionTtlSec: 3600,
});
const principal = await resolver.resolve(req);
```
## Usage: Advanced
```ts
import { createRolledSessionResolver } from "@suaveplan/api-auth-rolled";
const resolver = createRolledSessionResolver({ /* ... */ });
// Provider half (future) signs new tokens with the same key set as the
// resolver — sharing one path means the rotator's view of the active
// kid is the only source of truth.
const issued = await resolver.issueToken("u_1");
const setCookie = buildSetCookie({
name: "suaveplan.session",
value: issued.token,
maxAgeSec: issued.expiresAt - issued.issuedAt,
sameSite: "lax",
secure: true,
});
```
## API Reference
- `createRolledSessionResolver({ options, rotator, clock, cookieName, sessionTtlSec }) → SessionResolver & { issueToken, verifyToken }`
- `buildSetCookie({ name, value, maxAgeSec, sameSite, secure }) → string`
- `tokenSizeOf(token: string): number` — byte length, used by the
conformance harness to assert tokens stay under the 8 KiB cookie limit.
## Implementation Notes
The resolver intentionally returns `null` on every parse/verify failure
rather than throwing — `SessionResolver` chain semantics treat thrown
errors as operator-actionable infrastructure failures, so soft failures
must collapse to `null` to let the chain advance to the next adapter
(typically `api-auth-anonymous`). The two cases where the resolver does
throw are `AuthRolledError(PRINCIPAL_PROJECTION_FAILED)` raised by the
projection step (storage-driver bug) and whatever error the operator's
`store.findUserById` raises (DB connection lost). The session-resolver
chain handles both by surfacing 500 to the client and routing to the
host's audit log.
The header parse is done manually rather than via `compactDecode` so a
malformed header (e.g. missing `kid`) bypasses the rotator lookup
entirely. The rotator can verify with retired-but-non-revoked keys, so
rotating the active key never invalidates a session mid-lifetime —
operators can rotate without coordinating with active users.

View File

@@ -0,0 +1,87 @@
# `token-rotator`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the rotating-keyset state for the rolled session-token resolver. The
active key signs new JWTs; retired-but-non-revoked keys still verify
in-flight tokens so a rotation event never invalidates a session
mid-lifetime. The rotator does NOT generate new key material — that is an
operator-controlled action (HSM, KMS, mounted file) that submits the
replacement set through `setKeys`. This split is deliberate: keeping key
generation out of the adapter means the adapter never holds long-lived
private key material outside of the operator's chosen storage layer.
## Features
- Snapshots the keyset atomically (`snapshot()`) — the session module
reads the snapshot, signs with it, and never observes a half-rotated
state.
- Decides when to rotate (`decide()`) based on the active key's age vs
the configured `rotationPeriodMs`. Decision is advisory — the host
schedules the actual rotation cron.
- `setKeys(...)` validates the replacement keyset (exactly one active +
non-revoked) before swapping; throws `KEY_ROTATION_FAILED` if the new
set would leave signing ambiguous.
- `publicJwkFor(kid)` resolves the verifying JWK for any non-revoked kid
— including retired keys — so verification of in-flight tokens works
through the rotation window.
## Usage: Basic
```ts
import { TokenRotator } from "@suaveplan/api-auth-rolled";
const rotator = new TokenRotator({
keys,
rotationPeriodMs: 24 * 60 * 60 * 1000,
clock: () => Date.now(),
});
const snap = rotator.snapshot();
const activeJwk = snap.active.privateJwk;
```
## Usage: Advanced
```ts
import { TokenRotator } from "@suaveplan/api-auth-rolled";
const rotator = new TokenRotator({
keys: initialKeys,
rotationPeriodMs: 24 * 60 * 60 * 1000,
clock,
});
// Periodic rotator cron:
setInterval(async () => {
const decision = rotator.decide();
if (!decision.shouldRotate) return;
const next = await fetchKeysFromKms(); // operator-specific
rotator.setKeys(next);
}, 60 * 1000);
```
## API Reference
- `new TokenRotator({ keys, rotationPeriodMs, clock })` — construct.
- `snapshot(): KeySetSnapshot` — atomic read.
- `decide(): RotationDecision` — should we rotate now?
- `setKeys(next: readonly SigningKeyEntry[]): void` — swap keys.
- `publicJwkFor(kid): Record<string, unknown> | undefined` — verify-side
lookup.
## Implementation Notes
The rotator keeps its `activeSince` timestamp in instance state rather
than reading `keys[i].activatedAt`, so the in-memory rotation timer is
independent of any timestamps the operator stamps on the JWK records. This
means a fresh keyset injected via `setKeys` starts a brand-new rotation
window regardless of when the underlying key material was generated — the
operator's intent ("treat this as the new active key as of now") is the
authoritative signal. The exactly-one-active invariant is enforced both
in `parseRolledAuthOptions` and in `setKeys` so there is no path through
which the rotator can observe an invalid keyset.