From e0d3ebb365c13180e6e144347d52ca11690ebfa8 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 07:38:31 -0700 Subject: [PATCH 01/49] API --- AGENTS.md | 1 + SPEC.md | 2 +- .../auth/src/spike/make-auth-typecheck.ts | 110 +++++++++ packages/auth/src/spike/make-auth.ts | 226 ++++++++++++++++++ 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 packages/auth/src/spike/make-auth-typecheck.ts create mode 100644 packages/auth/src/spike/make-auth.ts diff --git a/AGENTS.md b/AGENTS.md index 1eba366..f56dcf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ This is security-critical code. - ESM only, no CommonJS - TypeScript only, no transpile to JS - Factories should be prefixed with `make` (e.g., `makeAuth`, `makeMemoryAdapters`) +- No optional parameters and no defaults — anywhere, API or config (decided 2026-07-17). Noisier but more distinct. May be reconsidered later; not now. - Never export local symbols - Use TS/JS style comments diff --git a/SPEC.md b/SPEC.md index cc227f7..2226da8 100644 --- a/SPEC.md +++ b/SPEC.md @@ -16,7 +16,7 @@ Passkeys + OTP as composable primitives. Apps choose their flow. - **Nano scope** — intentionally small, won't grow into Auth0 - **Zero dependencies** — no runtime dependencies, peer dependencies only where unavoidable - **Strong typings** — no type assertions (`as`), full type inference from API design -- **All fields required** — config types have no optional fields. Explicit beats convenient. +- **All fields required** — no optional parameters and no defaults, anywhere (decided 2026-07-17). Noisier but more distinct; explicit beats convenient. Optionals/defaults may be reconsidered later, not now. ### Inverted architecture diff --git a/packages/auth/src/spike/make-auth-typecheck.ts b/packages/auth/src/spike/make-auth-typecheck.ts new file mode 100644 index 0000000..57c5711 --- /dev/null +++ b/packages/auth/src/spike/make-auth-typecheck.ts @@ -0,0 +1,110 @@ +/** + * Type tests for the builder factory spike. + * + * Compiled by tsc (bun run check), never executed — this file IS the test; + * @ts-expect-error lines fail the build if the error stops occurring, so the + * contract is checked in both directions. + */ +import type { + MakeAuthConfig, + WithOtpConfig, + WithPasskeyConfig, + AuthCore, + AuthCoreOtp, + AuthCorePasskey, + AuthFull, +} from "./make-auth"; +import { makeAuth } from "./make-auth"; + +declare const session: MakeAuthConfig; +declare const otp: WithOtpConfig; +declare const passkey: WithPasskeyConfig; + +function expectType(value: T): T { + return value; +} + +/* Methods follow the chain — the four shapes */ +expectType(makeAuth(session)); +expectType(makeAuth(session).withOtp(otp)); +expectType(makeAuth(session).withPasskey(passkey)); +expectType(makeAuth(session).withOtp(otp).withPasskey(passkey)); + +/* Chain order doesn't matter */ +expectType(makeAuth(session).withPasskey(passkey).withOtp(otp)); + +/* The session namespace is present at every step */ +void makeAuth(session).session.get; +void makeAuth(session).withOtp(otp).session.endAll; +void makeAuth(session).withOtp(otp).withPasskey(passkey).session.create; + +/* Strategy namespaces only exist after their step */ +void makeAuth(session).withOtp(otp).otp.verify; +void makeAuth(session).withPasskey(passkey).passkey.verifyAuthentication; +void makeAuth(session).withOtp(otp).withPasskey(passkey).passkey + .registrationOptions; + +// @ts-expect-error otp namespace does not exist before withOtp +void makeAuth(session).otp; + +// @ts-expect-error otp namespace does not exist on a passkey-only instance +void makeAuth(session).withPasskey(passkey).otp; + +// @ts-expect-error passkey namespace does not exist on an otp-only instance +void makeAuth(session).withOtp(otp).passkey; + +/* All methods live in namespaces — the root holds no methods */ + +// @ts-expect-error getSession is not a root method — it is auth.session.get +void makeAuth(session).getSession; + +// @ts-expect-error createSession is not a root method — it is auth.session.create +void makeAuth(session).withOtp(otp).createSession; + +// @ts-expect-error verifyOtp is not a root method — it is auth.otp.verify +void makeAuth(session).withOtp(otp).verifyOtp; + +// @ts-expect-error verifyAuthentication is not a root method — it is auth.passkey.verifyAuthentication +void makeAuth(session).withPasskey(passkey).verifyAuthentication; + +/* Duplicate steps are type errors — with* removes itself from the chain */ + +// @ts-expect-error withOtp cannot be chained twice +void makeAuth(session).withOtp(otp).withOtp(otp); + +// @ts-expect-error withPasskey cannot be chained twice +void makeAuth(session).withPasskey(passkey).withPasskey(passkey); + +// @ts-expect-error nothing left to chain after both strategies +void makeAuth(session).withOtp(otp).withPasskey(passkey).withOtp(otp); + +/* Unknown config keys are rejected */ + +// @ts-expect-error unknown key in session config +void makeAuth({ ...session, unknown: true }); + +// @ts-expect-error unknown key in otp config +void makeAuth(session).withOtp({ ...otp, unknown: true }); + +// @ts-expect-error unknown key in passkey config +void makeAuth(session).withPasskey({ ...passkey, unknown: true }); + +/* Every field is required */ + +// @ts-expect-error delivery is required in otp config +void makeAuth(session).withOtp({ storage: otp.storage }); + +// @ts-expect-error challenges is required in passkey config +void makeAuth(session).withPasskey({ + storage: passkey.storage, + registrationCodec: passkey.registrationCodec, + webAuthn: passkey.webAuthn, +}); + +// @ts-expect-error debug is required in session config +void makeAuth({ + storage: session.storage, + codec: session.codec, + transport: session.transport, + ttl: session.ttl, +}); diff --git a/packages/auth/src/spike/make-auth.ts b/packages/auth/src/spike/make-auth.ts new file mode 100644 index 0000000..e747ac3 --- /dev/null +++ b/packages/auth/src/spike/make-auth.ts @@ -0,0 +1,226 @@ +/** + * Builder factory — type spike, no runtime. + * + * Proves the type contract: methods follow the chain, each step takes an + * exact concrete config, and misconfiguration is unrepresentable. Only four + * auth shapes exist, so every type is concrete — no generics. + * + * Shape rule: every config unit yields one method namespace. makeAuth's + * unit is the session core (`session`); each with* step takes one config + * group and adds its namespace (`otp`, `passkey`). The root holds only + * namespaces and the remaining chain steps. + * + * Promote into make-auth.ts when the builder is implemented; the three old + * factories are deleted then. See make-auth-typecheck.ts for the assertions. + */ +import type { + SessionStorage, + SessionCodec, + SessionTransportAdapter, + OtpStorage, + OtpTransportAdapter, + CredentialStorage, + RegistrationCodec, + WebAuthnConfig, + CreateSessionResult, + RequestOtpResult, + VerifyOtpResult, + CreateRegistrationTokenResult, + ValidateRegistrationTokenResult, + GenerateRegistrationOptionsResult, + VerifyRegistrationResult, + GenerateAuthenticationOptionsResult, + VerifyAuthenticationResult, + RegistrationCredential, + AuthenticationCredential, +} from "../types"; + +/** WebAuthn challenge record (single-use) */ +export type ChallengeRecord = { + challenge: string; + /** Set for registration ceremonies, null for authentication */ + userId: string | null; + expiresAt: Date; +}; + +/** Challenge storage adapter — challenges are single-use, so take-shaped */ +export type ChallengeStorage = { + store: (record: ChallengeRecord) => Promise; + /** Atomic fetch-and-delete. Unknown challenge returns null. */ + take: (challenge: string) => Promise; +}; + +/** Config for makeAuth — the session core (session is not a sub-object; it IS the core) */ +export type MakeAuthConfig = { + storage: SessionStorage; + codec: SessionCodec; + transport: SessionTransportAdapter; + /** Session TTL in ms (Infinity = forever). Inactivity timeout with sliding refresh. */ + ttl: number; + debug: boolean; +}; + +/** Config for withOtp */ +export type WithOtpConfig = { + storage: OtpStorage; + delivery: OtpTransportAdapter; +}; + +/** Config for withPasskey */ +export type WithPasskeyConfig = { + storage: CredentialStorage; + challenges: ChallengeStorage; + registrationCodec: RegistrationCodec; + webAuthn: WebAuthnConfig; +}; + +/** Session methods — the core namespace, present at every step */ +export type SessionNamespace = { + create: (args: { userId: string }) => Promise; + get: () => Promise<{ userId: string } | null>; + /** End the current session (signs the user out) */ + end: () => Promise; + /** End all sessions for the current user (signs out every device) */ + endAll: () => Promise; +}; + +/** Otp methods — added as the `otp` namespace by withOtp */ +export type OtpNamespace = { + request: (args: { identifier: string }) => Promise; + verify: (args: { + identifier: string; + otp: string; + }) => Promise; +}; + +/** Passkey methods — added as the `passkey` namespace by withPasskey */ +export type PasskeyNamespace = { + createRegistrationToken: (args: { + userId: string; + identifier: string; + }) => Promise; + validateRegistrationToken: (args: { + token: string; + }) => Promise; + registrationOptions: (args: { + registrationToken: string; + }) => Promise; + verifyRegistration: (args: { + registrationToken: string; + credential: RegistrationCredential; + }) => Promise; + authenticationOptions: () => Promise; + verifyAuthentication: (args: { + credential: AuthenticationCredential; + }) => Promise; +}; + +/** Session-only auth — both strategies still available to chain */ +export type AuthCore = { + session: SessionNamespace; + withOtp: (config: WithOtpConfig) => AuthCoreOtp; + withPasskey: (config: WithPasskeyConfig) => AuthCorePasskey; +}; + +/** Sessions + otp — only withPasskey remains */ +export type AuthCoreOtp = { + session: SessionNamespace; + otp: OtpNamespace; + withPasskey: (config: WithPasskeyConfig) => AuthFull; +}; + +/** Sessions + passkeys — only withOtp remains */ +export type AuthCorePasskey = { + session: SessionNamespace; + passkey: PasskeyNamespace; + withOtp: (config: WithOtpConfig) => AuthFull; +}; + +/** Everything configured — nothing left to chain */ +export type AuthFull = { + session: SessionNamespace; + otp: OtpNamespace; + passkey: PasskeyNamespace; +}; + +export declare function makeAuth(config: MakeAuthConfig): AuthCore; +export type makeAuth2 = (config: MakeAuthConfig) => AuthCore; + +/* Playground — no-op adapters. Note every one of them fails closed. */ + +export const auth = makeAuth({ + storage: { + get: async () => null, + deleteAll: async () => undefined, + store: async () => undefined, + delete: async () => undefined, + }, + codec: { + encode: async () => "", + decode: async () => null, + ttl: 0, + }, + transport: { + get: () => undefined, + set: () => "", + clear: () => undefined, + }, + ttl: 0, + debug: false, +}); + +auth.session.create({ userId: "123" }); +auth.session.end(); + +const otpAuth = auth.withOtp({ + storage: { + verify: async () => false, + store: async () => undefined, + }, + delivery: { + send: async () => undefined, + ttl: 0, + }, +}); + +otpAuth.otp.request({ identifier: "test@example.com" }); +otpAuth.otp.verify({ identifier: "test@example.com", otp: "123456" }); + +export const passkey = auth.withPasskey({ + storage: { + get: async () => [], + getById: async () => null, + updateCounter: async () => undefined, + delete: async () => undefined, + store: async () => undefined, + }, + challenges: { + store: async () => undefined, + take: async () => null, + }, + registrationCodec: { + encode: async () => "", + decode: async () => null, + }, + webAuthn: { + rpId: "localhost", + rpName: "Spike", + challengeTtl: 0, + }, +}); + +passkey.passkey.authenticationOptions(); +// passkey.passkey.validateRegistrationToken() + +export const passkeyAndOtp = passkey.withOtp({ + storage: { + verify: async () => false, + store: async () => undefined, + }, + delivery: { + send: async () => undefined, + ttl: 0, + }, +}); + +passkeyAndOtp.otp.verify({ identifier: "test@example.com", otp: "123456" }); From 15fb5bc8ffc7c8cfc7ca53d8aa3f2bca45306b4c Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 08:06:24 -0700 Subject: [PATCH 02/49] API --- SPEC.md | 54 ++++++++++--------- .../auth/src/spike/make-auth-typecheck.ts | 22 ++++++++ packages/auth/src/spike/make-auth.ts | 33 +++++++++--- 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2226da8..94e2c10 100644 --- a/SPEC.md +++ b/SPEC.md @@ -55,14 +55,15 @@ This means: These are independent primitives. Apps decide how to combine them: -| Flow | Description | Use case | -| -------------------------- | ----------------------------------------------------- | -------------------------------------------------- | -| **Passkeys only** | Passkey sign-up and sign-in, no OTP | Anonymous/pseudonymous apps, maximum privacy | -| **OTP only** | OTP sign-up and sign-in, no passkeys | Simple apps, Clerk-like DX | -| **Passkey → OTP** | Passkey first, OTP to collect email later | Privacy-first, email optional for communication | -| **OTP → Passkey** | OTP to verify email, then passkey (current default) | Most apps — verified email + passkey auth | -| **OTP → Passkey (strict)** | OTP for initial sign-up only, passkey-only after | High security — no OTP backdoor for existing users | -| **OTP for email changes** | Use OTP to verify new email/phone while authenticated | Common feature — add/change contact info | +| Flow | Description | Use case | +| --------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------- | +| **Passkeys only** | Passkey sign-up and sign-in, no OTP | Anonymous/pseudonymous apps, maximum privacy | +| **OTP only** | OTP sign-up and sign-in, no passkeys | Simple apps, Clerk-like DX | +| **Passkey → OTP** | Passkey first, OTP to collect email later | Privacy-first, email optional for communication | +| **OTP → Passkey** | OTP to verify email, then passkey (current default) | Most apps — verified email + passkey auth | +| **OTP → Passkey (strict)** | OTP for initial sign-up only, passkey-only after | High security — no OTP backdoor for existing users | +| **OTP while authenticated** | Verify a new email/phone, or step-up before a sensitive action | Add/change contact info, sudo mode | +| **Bring your own** | Session core only — app verifies by its own means, then creates the session | Invite tokens, recovery codes, SSO assertions, guest-first apps | The library provides primitives. Your app composes the flow that fits your security/UX tradeoffs. @@ -70,28 +71,30 @@ The library provides primitives. Your app composes the flow that fits your secur See `CoreMethods`, `OtpMethods`, `PasskeyMethods`, and `AuthClient` types in `packages/auth/src/types.ts` for the complete API with JSDoc documentation. -| Primitive | What it does | Client | -| ------------------------------------------------------- | ------------------------------------ | ------ | -| `createSession({ userId })` | Create session for user | ❌ | -| `requestOtp({ identifier })` | Send OTP to identifier (email/phone) | ✅ | -| `verifyOtp({ identifier, otp })` | Verify OTP → `{ success }` | ✅ | -| `createRegistrationToken({ userId, identifier })` | Create registration token | ❌ | -| `validateRegistrationToken({ token })` | Validate → `{ userId, identifier }` | ❌ | -| `generateRegistrationOptions({ registrationToken })` | WebAuthn registration options | ✅ | -| `verifyRegistration({ registrationToken, credential })` | Verify + store + session | ✅ | -| `generateAuthenticationOptions()` | WebAuthn sign-in options | ✅ | -| `verifyAuthentication({ credential })` | Verify + session | ✅ | -| `getSession()` | Get session data | ❌ | -| `signOut()` | End session | ✅ | -| `signOutAll()` | End all sessions for user | ❌ | +| Primitive | What it does | Client | +| ------------------------------------------------------- | ------------------------------------- | ------ | +| `createSession({ userId })` | Create session for user | ❌ | +| `requestOtp({ identifier })` | Send OTP to identifier (email/phone) | ✅ | +| `verifyOtp({ identifier, otp })` | Verify OTP → `{ success }` | ✅ | +| `createRegistrationToken({ userId, identifier })` | Create registration token | ❌ | +| `validateRegistrationToken({ token })` | Validate → `{ userId, identifier }` | ❌ | +| `generateRegistrationOptions({ registrationToken })` | WebAuthn registration options | ✅ | +| `verifyRegistration({ registrationToken, credential })` | Verify + store passkey → `{ userId }` | ✅ | +| `generateAuthenticationOptions()` | WebAuthn sign-in options | ✅ | +| `verifyAuthentication({ credential })` | Verify passkey → `{ userId }` | ✅ | +| `getSession()` | Get session data | ❌ | +| `signOut()` | End session | ✅ | +| `signOutAll()` | End all sessions for user | ❌ | **Client column:** ✅ = exposed via `makeAuthClient` / callable from browser. ❌ = server-side only. -**Key design:** `verifyOtp` only verifies — it doesn't create sessions. For OTP-only auth, apps call `createSession` explicitly after verification. `verifyRegistration` and `verifyAuthentication` create sessions implicitly. Apps compose the flow they need. +**Key design:** Verification never creates sessions. `verifyOtp`, `verifyRegistration`, and `verifyAuthentication` only prove facts and return the verified `userId` — apps create sessions explicitly via `createSession`. One rule, no exceptions. + +> **Decided (2026-07-17): Pure verification.** Previously `verifyRegistration`/`verifyAuthentication` created sessions implicitly. Removed: core produces verified facts; a session is policy, and policy lives in userland — passkey sign-in is verify then `createSession`, identical in shape to the otp flow. The uniform rule unlocks zero-feature composition (multi-factor, step-up, custom flows need no library support) and fails closed (forgetting the session call yields no session). Consequences: (1) a browser-only REST flow can't complete sign-in — composing verify + createSession requires an app server function, which sharpens the open question about `makeAuthHandler`'s role; (2) the app now binds the verified userId to the session — examples must always thread the verify result's `userId` into `createSession` (see branded verified ids under Future). ### Flows -The library provides primitives. Apps compose flows. Below are common patterns. +The library provides primitives. Apps compose flows. Below are common patterns. In the diagrams, `session` at the end of a chain is an explicit `createSession` call by your app — nothing creates sessions implicitly. **Passkeys only** (no OTP): @@ -576,7 +579,7 @@ Note: the legacy `examples/tmp/tanstack-start/` has working versions of several Library additions (as needed): - [ ] `allowCredentials` in `generateAuthenticationOptions()` — see design notes below -- [ ] Make `identifier` optional in `createRegistrationToken()` — support passkey-only sign-up +- [x] `identifier: string | null` in `createRegistrationToken()` — explicit null for passkey-only sign-up (nullable, not optional, per the no-optionals rule; decided 2026-07-17, spiked) - [x] Session management: `signOutAll()` on core methods, `deleteAll()` on `SessionStorage` - [x] Passkey management: `delete()` on `CredentialStorage` — apps call storage directly for list/delete @@ -630,6 +633,7 @@ _Future:_ - Feature: E2EE/PRF module — WebAuthn PRF for key derivation - Feature: Recovery codes — generate/verify with KDF (80-bit entropy, e.g. `7KF3-M9PN-2XLT-8HVQ`). For regular apps: code → session. For E2EE: code → recovery key → unwrap DEK client-side, then create new passkey - Feature: Cross-ecosystem add-device — QR code flow with ephemeral key exchange. Device A (signed in) displays QR, device B scans and creates passkey. For E2EE: securely transfers KEK so device A can wrap DEK for the new credential. Same flow works for regular apps (ignore the KEK) +- Feature: Branded verified ids — verification results return a branded `VerifiedUserId` that `createSession` prefers, making the verified-user-to-session binding type-enforced (closes the wrong-userId composition footgun where an app passes its own lookup's id instead of the verify result's). Deferred until evidence it's needed — no just-in-case abstractions. - Feature: LLM rules — ship Cursor/AI rules with the package, like `bun init` generates - Service: Hosted user dashboard - Service: Email relay service — hosted OTP email sending so users don't need to set up Resend/SendGrid, DNS, SPF, etc. (workspace in this repo, deployed separately) diff --git a/packages/auth/src/spike/make-auth-typecheck.ts b/packages/auth/src/spike/make-auth-typecheck.ts index 57c5711..4433f28 100644 --- a/packages/auth/src/spike/make-auth-typecheck.ts +++ b/packages/auth/src/spike/make-auth-typecheck.ts @@ -67,6 +67,28 @@ void makeAuth(session).withOtp(otp).verifyOtp; // @ts-expect-error verifyAuthentication is not a root method — it is auth.passkey.verifyAuthentication void makeAuth(session).withPasskey(passkey).verifyAuthentication; +/* Passkey verification is pure — it returns the userId, never a session */ + +declare const verifyResult: Awaited< + ReturnType +>; + +if (verifyResult.success) { + void verifyResult.userId; + // @ts-expect-error verification carries no session — create one explicitly via session.create + void verifyResult.session; +} + +declare const registrationResult: Awaited< + ReturnType +>; + +if (registrationResult.success) { + void registrationResult.userId; + // @ts-expect-error registration carries no session — create one explicitly via session.create + void registrationResult.session; +} + /* Duplicate steps are type errors — with* removes itself from the chain */ // @ts-expect-error withOtp cannot be chained twice diff --git a/packages/auth/src/spike/make-auth.ts b/packages/auth/src/spike/make-auth.ts index e747ac3..0d58731 100644 --- a/packages/auth/src/spike/make-auth.ts +++ b/packages/auth/src/spike/make-auth.ts @@ -26,13 +26,11 @@ import type { RequestOtpResult, VerifyOtpResult, CreateRegistrationTokenResult, - ValidateRegistrationTokenResult, GenerateRegistrationOptionsResult, - VerifyRegistrationResult, GenerateAuthenticationOptionsResult, - VerifyAuthenticationResult, RegistrationCredential, AuthenticationCredential, + Result, } from "../types"; /** WebAuthn challenge record (single-use) */ @@ -93,11 +91,27 @@ export type OtpNamespace = { }) => Promise; }; +/** + * Passkey verification results — pure verification, no session piping. + * Verification returns the userId; apps create sessions explicitly via + * session.create, exactly like the otp flow. Symmetry unlocks composition: + * multi-factor, step-up checks, and custom flows need no library support. + */ +export type PasskeyVerifyRegistrationResult = Result<{ userId: string }>; +export type PasskeyVerifyAuthenticationResult = Result<{ userId: string }>; + +/** Mirrors createRegistrationToken's nullable identifier */ +export type ValidateRegistrationTokenResult = Result<{ + userId: string; + identifier: string | null; +}>; + /** Passkey methods — added as the `passkey` namespace by withPasskey */ export type PasskeyNamespace = { createRegistrationToken: (args: { userId: string; - identifier: string; + /** Shown in the passkey picker (user.name). Null for identifier-less sign-up (passkey-only apps). */ + identifier: string | null; }) => Promise; validateRegistrationToken: (args: { token: string; @@ -105,14 +119,16 @@ export type PasskeyNamespace = { registrationOptions: (args: { registrationToken: string; }) => Promise; + /** Verifies and stores the credential. Does NOT create a session. */ verifyRegistration: (args: { registrationToken: string; credential: RegistrationCredential; - }) => Promise; + }) => Promise; authenticationOptions: () => Promise; + /** Verifies the assertion against the stored credential. Does NOT create a session. */ verifyAuthentication: (args: { credential: AuthenticationCredential; - }) => Promise; + }) => Promise; }; /** Session-only auth — both strategies still available to chain */ @@ -212,6 +228,11 @@ export const passkey = auth.withPasskey({ passkey.passkey.authenticationOptions(); // passkey.passkey.validateRegistrationToken() +// Sign in is two explicit calls — verification never creates sessions: +// const verified = await auth.passkey.verifyAuthentication({ credential }); +// if (!verified.success) return verified; +// return auth.session.create({ userId: verified.userId }); + export const passkeyAndOtp = passkey.withOtp({ storage: { verify: async () => false, From e2b02a54720af763c931e165c124547613bccdcf Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 09:38:27 -0700 Subject: [PATCH 03/49] Spec --- SPEC.md | 7 ++++-- THREAT-MODEL.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 THREAT-MODEL.md diff --git a/SPEC.md b/SPEC.md index 94e2c10..4789cc0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -293,7 +293,9 @@ Storage is split by concern: `OtpStorage`, `SessionStorage`, `CredentialStorage` > **Decided (2026-07-16): Mechanisms make the common case correct by construction.** We ship factories that produce correct adapters from dumb atomic primitives: `makeOtpStorage({ store, take })` returns an `OtpStorage` with expiry check, comparison, and one-time consumption built in — written and race-tested once by us. `take(identifier)` is atomic fetch-and-delete (`DELETE … RETURNING`, `GETDEL`); that one-word guarantee — atomic — is the entire adapter obligation, and the lazy implementation fails closed. Database recipes target `store`/`take`; power users (delegated verification, custom lockouts, dev bypasses) implement `OtpStorage` raw — full control, visibly off the blessed path. Conformance tests ship alongside: sequential (take twice → second null) plus deterministic barrier-based race checks — no hammering (see https://www.lirbank.com/harnessing-postgres-race-conditions.md). -> **Decided (2026-07-16): One attempt per otp.** A wrong otp consumes it — the user starts over with a fresh request. No attempt budgets, no re-store logic; every failure path fails closed. The typo cost is one email round-trip; acceptable for v1, revisit only on observed user friction. Per-identifier/per-IP rate limiting remains out of scope (infrastructure layer). +> **Decided (2026-07-16): One attempt per otp.** A wrong otp consumes it — the user starts over with a fresh request. No attempt budgets, no re-store logic; every failure path fails closed. The typo cost is one email round-trip; acceptable for v1, revisit only on observed user friction. Per-IP rate limiting stays with the app; per-identifier cooldown is mechanism-layer (decided 2026-07-17, below). + +> **Decided (2026-07-17): Rate limiting splits by who holds the state.** A rate limit is a decision over state and is enforced where that state lives, atomically — or it isn't real. The library owns exactly one relevant state, the otp record, so it enforces exactly one limit: per-identifier issuance frequency. `OtpStorage.store` may refuse (returns `false`), `requestOtp` fails with `rate_limited` and sends nothing, and the cooldown knob lives on `makeOtpStorage` (required field, `0` = explicitly off), enforced by an atomic conditional-put `store` primitive — the same one-word obligation as `take`. Per-IP/volume/bot defense stays with the app (it holds the request); sender reputation stays with the sending service (it holds cross-app outcomes). Full vector map and deployment shapes: THREAT-MODEL.md. Supersedes the blanket "rate limiting (infrastructure-layer concern)" exclusion. **Why no database drivers?** @@ -637,6 +639,7 @@ _Future:_ - Feature: LLM rules — ship Cursor/AI rules with the package, like `bun init` generates - Service: Hosted user dashboard - Service: Email relay service — hosted OTP email sending so users don't need to set up Resend/SendGrid, DNS, SPF, etc. (workspace in this repo, deployed separately) +- Service: Hosted session storage (idea, 2026-07-17) — a plain `SessionStorage` adapter, HMAC codec only (storage is consulted only at token refresh, so latency amortizes and outages degrade in token-TTL windows). The line: the service may host library-owned state (sessions, otp records, challenges), never app-owned state (users) — the Clerk problem is sync direction, not hosting, and library-state references only `userId` strings, so there is nothing to sync. Fits OTP-shaped apps, which already depend on a remote sender at sign-in; passkey-only apps lose self-sufficiency and should stay local. Credentials are borderline — UIs list passkeys, so hosting them adds a remote query; per-concern adapters mean you can host sessions and keep credentials local **Exclusions:** @@ -645,7 +648,7 @@ _Future:_ - ❌ Password-based auth - ❌ Legacy browser support - ❌ SAML / SSO / enterprise features -- ❌ Rate limiting (infrastructure-layer concern) +- ❌ IP/volume rate limiting and bot defense — request-layer state the library never sees (app middleware, WAF, captcha). Per-identifier cooldown is in scope — see THREAT-MODEL.md **Constraints:** diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md new file mode 100644 index 0000000..49072c5 --- /dev/null +++ b/THREAT-MODEL.md @@ -0,0 +1,60 @@ +# Threat model + +Who owns each defense on the otp surface: library, adapters, app/infra, or the (optional, future) sending service. + +**The placement principle (decided 2026-07-17):** a rate limit is a decision over state. It is enforced where that state lives, atomically — or it isn't real. "Rate limiting" is not one thing; each vector below has its own state, and therefore its own owner. + +## The surface + +`requestOtp({ identifier })` is unauthenticated by design — anyone can type an email and trigger a send. Cost, inbox noise, and sender reputation all ride on that one call. `verifyOtp` adds the guessing surface. + +## Vector map + +| # | Vector | Attack | State that decides it | Owner | Defense | +| --- | ------------------------ | ---------------------------------------- | -------------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Resend flooding | Hammer `requestOtp` for one identifier | The live otp record | **Library** (mechanism) | Per-identifier cooldown in `makeOtpStorage` — atomic conditional put; `requestOtp` fails `rate_limited`, nothing sent | +| 2 | Inbox bombing | Sustained sends to one victim over hours | Per-identifier counters | **App/infra**; service when used | Hourly/daily caps. The library ships none — counter state is out of scope | +| 3 | Mass sending, cost abuse | Scripted sends to many identifiers | Request metadata (IP), global counters | **App/infra** | Gate before `requestOtp` — the server function holds the IP (TanStack `getRequestIP`, Next.js `headers()`); captcha/WAF for bots | +| 4 | Reputation burn | Sends to spam traps and dead addresses | Cross-app sending outcomes (bounces) | **Sending service**; else your ESP | Service: per-recipient and per-key caps, bounce handling. Self-hosted: your domain, your risk | +| 5 | Otp brute force | Guess the otp at `verifyOtp` | The otp record | **Library** (decided 2026-07-16) | One attempt per otp — a wrong guess consumes it; short TTL; single use | +| 6 | Otp phishing | Trick the user into relaying the otp | None — human factor | **App** (flow choice) | Passkeys for everyday sign-in; strict mode closes the otp backdoor for existing users | + +Rows 1 and 5 are the library's complete obligation — everything its own state can enforce atomically. Rows 2–4 need state the library never sees (counters, requests, cross-app outcomes); pulling them in would mean importing new state classes, which is how nano scope dies. + +## Deployment shapes + +The sending service is optional and does not exist yet. The map must be sound without it: + +| Defense | Dev (console) | Self-hosted (own Resend/SendGrid) | With sending service | +| ------------- | ------------- | --------------------------------- | --------------------------------------------------------------------------------------- | +| 1 Cooldown | ✓ library | ✓ library | ✓ library (+ service, per recipient) | +| 2 Caps | — | app/infra | ✓ service, per recipient | +| 3 IP/bots | — | app/infra | app/infra — unchanged; the service only ever sees your server's IP, never your callers' | +| 4 Reputation | n/a | your domain, your ESP account | ✓ service (shared domain) | +| 5 Brute force | ✓ library | ✓ library | ✓ library | + +Self-hosted posture = library (1, 5) + app/infra (2, 3) + owning your ESP risk (4). The service moves 2 and 4 service-side; 3 always stays with the app. + +## Sending service (future — mapped ahead of build) + +A free hosted endpoint that sends a fixed-template otp email via Resend. Takes only `(email, otp)` — no subject, no body, no sender. Exists so users skip DNS/SPF/ESP setup; anyone can bypass it with their own delivery adapter. + +**Why the fixed template matters:** the only attacker-controlled inputs are the recipient and the otp. There is no spam-content vector — almost. The otp itself is a free-text field until validated, so the service must enforce otp shape (digits, fixed length) or that one field becomes a spam channel. + +Service-side defenses (its own abuse surface, not the library's): + +- Per-recipient cooldown and daily cap **across all API keys** — victim protection no single app can provide +- Per-key caps — cost and abuse isolation +- Otp shape validation — kills content injection through the one free-text field +- Bounce/complaint handling, disposable-domain policy — protects the shared sending domain +- Library integration: service refusals (429) surface through delivery as `rate_limited` — requires widening `send` from `Promise`, deferred until this adapter exists + +Known trade-off (2026-07-17): send-only means the service never sees verification outcomes, so reputation scoring is limited to volume and bounces. Accepted for launch. + +Open questions: + +- Key issuance — anonymous free keys vs sign-up +- Actual limit numbers (cooldown, per-recipient cap, per-key cap) +- Whether verification-outcome feedback ever gets added +- Forwarding the end-user IP (`requestOtp({ identifier, ip })` → delivery → service) so the service can rate limit per caller on the app's behalf. Self-reported, so it protects honest apps — never the service itself (per-key caps do that). Lands with the service adapter and the `send` widening, as `ip: string | null` +- SMS transport later From 54fb2a57d0c03fcd9cda3b126a7fa021ae0ba916 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 09:42:02 -0700 Subject: [PATCH 04/49] Deps --- bun.lock | 28 +++++++++++++--------------- packages/auth-react/package.json | 2 +- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 0225f99..b84e4e3 100644 --- a/bun.lock +++ b/bun.lock @@ -176,12 +176,12 @@ "name": "@repo/example-tanstack-start", "dependencies": { "@starmode/auth": "workspace:*", - "@tailwindcss/vite": "^4.3.2", - "@tanstack/react-router": "^1.170.17", - "@tanstack/react-start": "^1.168.27", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-router": "^1.170.18", + "@tanstack/react-start": "^1.168.28", "react": "^19.2.7", "react-dom": "^19.2.7", - "tailwindcss": "^4.3.2", + "tailwindcss": "^4.3.3", }, "devDependencies": { "@types/node": "^26.1.1", @@ -189,7 +189,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", "typescript": "^6.0.3", - "vite": "^8.1.4", + "vite": "^8.1.5", }, }, "packages/auth": { @@ -221,7 +221,7 @@ "peerDependencies": { "@starmode/auth": "workspace:*", "react": "^19.2.7", - "tailwindcss": "^4.3.2", + "tailwindcss": "^4.3.3", }, }, }, @@ -639,7 +639,7 @@ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - "caniuse-lite": ["caniuse-lite@1.0.30001805", "", {}, "sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA=="], + "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -669,7 +669,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.393", "", {}, "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg=="], "enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="], @@ -677,7 +677,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], + "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], @@ -767,7 +767,7 @@ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - "isbot": ["isbot@5.2.0", "", {}, "sha512-gbZiGCb4B5xaoxg9mS7koAyRdvJnArk10VLSHOgz6rtBG93/pi1xOFaVvXMKZ7JXgyZ8zAbNRK5uIBdIUTFSqw=="], + "isbot": ["isbot@5.2.1", "", {}, "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -831,7 +831,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -841,7 +841,7 @@ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], - "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -859,7 +859,7 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "postcss": ["postcss@8.5.17", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w=="], + "postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -994,7 +994,5 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - - "vitest/vite": ["vite@8.1.4", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.16", "rolldown": "~1.1.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ=="], } } diff --git a/packages/auth-react/package.json b/packages/auth-react/package.json index dcbde8e..811c829 100644 --- a/packages/auth-react/package.json +++ b/packages/auth-react/package.json @@ -12,7 +12,7 @@ "peerDependencies": { "@starmode/auth": "workspace:*", "react": "^19.2.7", - "tailwindcss": "^4.3.2" + "tailwindcss": "^4.3.3" }, "devDependencies": { "@types/react": "^19.2.17" From a09252c6fb4c4dec4db53dc92568fe3d5035d3f3 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 10:26:12 -0700 Subject: [PATCH 05/49] Contract --- packages/auth/src/spike/make-auth.ts | 460 +++++++++++++++++--------- packages/auth/src/spike/playground.ts | 86 +++++ 2 files changed, 394 insertions(+), 152 deletions(-) create mode 100644 packages/auth/src/spike/playground.ts diff --git a/packages/auth/src/spike/make-auth.ts b/packages/auth/src/spike/make-auth.ts index 0d58731..306ebcb 100644 --- a/packages/auth/src/spike/make-auth.ts +++ b/packages/auth/src/spike/make-auth.ts @@ -1,76 +1,111 @@ /** - * Builder factory — type spike, no runtime. + * ΛUTH contracts — the typed API spec. * - * Proves the type contract: methods follow the chain, each step takes an - * exact concrete config, and misconfiguration is unrepresentable. Only four - * auth shapes exist, so every type is concrete — no generics. + * Self-sufficient: imports nothing from the legacy tree, so it can be edited + * freely. This file is the source of intent while the API is finalized; the + * README is rewritten from it at promotion. It contains contracts only — + * everything user code touches: adapter interfaces, config shapes, method + * namespaces, the four auth shapes, factory signatures. Mechanisms, bindings, + * and config bundles are implementations of these contracts and live outside. * - * Shape rule: every config unit yields one method namespace. makeAuth's - * unit is the session core (`session`); each with* step takes one config - * group and adds its namespace (`otp`, `passkey`). The root holds only - * namespaces and the remaining chain steps. + * Layout previews the promoted file split: each banner section becomes a file. * - * Promote into make-auth.ts when the builder is implemented; the three old - * factories are deleted then. See make-auth-typecheck.ts for the assertions. + * Verified by make-auth-typecheck.ts (compile-time assertions, both + * directions) and exercised by playground.ts. */ -import type { - SessionStorage, - SessionCodec, - SessionTransportAdapter, - OtpStorage, - OtpTransportAdapter, - CredentialStorage, - RegistrationCodec, - WebAuthnConfig, - CreateSessionResult, - RequestOtpResult, - VerifyOtpResult, - CreateRegistrationTokenResult, - GenerateRegistrationOptionsResult, - GenerateAuthenticationOptionsResult, - RegistrationCredential, - AuthenticationCredential, - Result, -} from "../types"; -/** WebAuthn challenge record (single-use) */ -export type ChallengeRecord = { - challenge: string; - /** Set for registration ceremonies, null for authentication */ - userId: string | null; - expiresAt: Date; +/* ──────────────────────────────────────────────────────────────────────── + * Shared vocabulary + * ──────────────────────────────────────────────────────────────────────── */ + +/** Error codes for auth failures */ +export type AuthErrorCode = + | "invalid_otp" + | "invalid_token" + | "challenge_expired" + | "user_mismatch" + | "credential_not_found" + | "verification_failed" + | "invalid_request" + | "internal_error"; + +/** Generic result type for failable operations — expected failures are values, never exceptions */ +export type Result = + ({ success: true } & T) | { success: false; error: AuthErrorCode }; + +/* ──────────────────────────────────────────────────────────────────────── + * Session — the core. Records → adapters → config → namespace → results. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Session DB record */ +export type SessionRecord = { + sessionId: string; + userId: string; + /** null = never expires */ + expiresAt: Date | null; }; -/** Challenge storage adapter — challenges are single-use, so take-shaped */ -export type ChallengeStorage = { - store: (record: ChallengeRecord) => Promise; - /** Atomic fetch-and-delete. Unknown challenge returns null. */ - take: (challenge: string) => Promise; +/** Session storage adapter — plain reads and writes; core enforces expiry */ +export type SessionStorage = { + /** Upsert */ + store: (record: SessionRecord) => Promise; + get: (sessionId: string) => Promise; + delete: (sessionId: string) => Promise; + /** Delete all sessions belonging to the same user as this session */ + deleteAll: (sessionId: string) => Promise; +}; + +/** Session token payload */ +export type SessionPayload = { + sessionId: string; + /** Session expiry (null = never expires). Slides on every request. */ + sessionExp: Date | null; + userId: string; +}; + +/** Decoded session token. Returned only when authentic; forged/garbled tokens decode to null. */ +export type SessionDecoded = SessionPayload & { + /** Token expiration (fixed — the revocation window) */ + exp: Date; + /** Token expired (exp < now) — forces the storage revocation check */ + expired: boolean; +}; + +/** Session codec — token format (HMAC, opaque, or bring a JWT library) */ +export type SessionCodec = { + /** expiresAt: null mints a fresh token TTL; a Date preserves an existing expiry (sliding refresh) */ + encode: ( + payload: SessionPayload, + options: { expiresAt: Date | null }, + ) => Promise; + decode: (token: string) => Promise; + /** Token TTL in ms — the revocation window */ + ttl: number; +}; + +/** Session transport — how the token rides on requests (cookie, header) */ +export type SessionTransport = { + /** Read token from the incoming request */ + get: () => string | undefined; + /** Store token and return what goes in the response body */ + set: (token: string) => string; + /** Clear the stored token */ + clear: () => void; }; /** Config for makeAuth — the session core (session is not a sub-object; it IS the core) */ export type MakeAuthConfig = { storage: SessionStorage; codec: SessionCodec; - transport: SessionTransportAdapter; + transport: SessionTransport; /** Session TTL in ms (Infinity = forever). Inactivity timeout with sliding refresh. */ ttl: number; debug: boolean; }; -/** Config for withOtp */ -export type WithOtpConfig = { - storage: OtpStorage; - delivery: OtpTransportAdapter; -}; - -/** Config for withPasskey */ -export type WithPasskeyConfig = { - storage: CredentialStorage; - challenges: ChallengeStorage; - registrationCodec: RegistrationCodec; - webAuthn: WebAuthnConfig; -}; +export type CreateSessionResult = Result<{ + session: { token: string; userId: string }; +}>; /** Session methods — the core namespace, present at every step */ export type SessionNamespace = { @@ -82,6 +117,66 @@ export type SessionNamespace = { endAll: () => Promise; }; +/* ──────────────────────────────────────────────────────────────────────── + * OTP — identity verification, optionally authentication. + * ──────────────────────────────────────────────────────────────────────── */ + +/** OTP DB record */ +export type OtpRecord = { + /** Identifier (email address, phone number, etc.) */ + identifier: string; + otp: string; + expiresAt: Date; +}; + +/** + * OTP storage adapter — the semantic contract. + * + * verify states meaning, not mechanism: implementations must guarantee + * expiry, comparison, and one-time use — or be produced by makeOtpStorage, + * which builds those guarantees from two dumb atomic primitives. Delegated + * verification (e.g. a provider that checks the otp remotely) implements + * this contract directly. + */ +export type OtpStorage = { + store: (record: OtpRecord) => Promise; + /** One attempt per otp: a wrong guess consumes it */ + verify: (identifier: string, otp: string) => Promise; +}; + +/** + * Input contract for makeOtpStorage — two dumb primitives, one guarantee. + * take must be an atomic fetch-and-delete (DELETE … RETURNING, GETDEL). + * The lazy implementation fails closed: returning null denies access. + */ +export type MakeOtpStorageConfig = { + /** Upsert */ + store: (record: OtpRecord) => Promise; + /** Atomic fetch-and-delete. Unknown identifier returns null. */ + take: (identifier: string) => Promise; +}; + +/** Builds a correct OtpStorage (expiry, comparison, one-time use) from store/take */ +export declare function makeOtpStorage( + config: MakeOtpStorageConfig, +): OtpStorage; + +/** OTP delivery adapter (email, SMS, console) */ +export type OtpDelivery = { + send: (identifier: string, otp: string) => Promise; + /** OTP validity duration in ms */ + ttl: number; +}; + +/** Config for withOtp */ +export type WithOtpConfig = { + storage: OtpStorage; + delivery: OtpDelivery; +}; + +export type RequestOtpResult = { success: true }; +export type VerifyOtpResult = Result; + /** Otp methods — added as the `otp` namespace by withOtp */ export type OtpNamespace = { request: (args: { identifier: string }) => Promise; @@ -91,21 +186,162 @@ export type OtpNamespace = { }) => Promise; }; -/** - * Passkey verification results — pure verification, no session piping. - * Verification returns the userId; apps create sessions explicitly via - * session.create, exactly like the otp flow. Symmetry unlocks composition: - * multi-factor, step-up checks, and custom flows need no library support. - */ -export type PasskeyVerifyRegistrationResult = Result<{ userId: string }>; -export type PasskeyVerifyAuthenticationResult = Result<{ userId: string }>; +/* ──────────────────────────────────────────────────────────────────────── + * Passkey — WebAuthn authentication. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Credential (passkey) stored data */ +export type StoredCredential = { + id: string; + publicKey: Uint8Array; + counter: number; + transports?: AuthenticatorTransport[] | undefined; +}; + +/** Credential DB record */ +export type CredentialRecord = { + userId: string; + credential: StoredCredential; +}; + +/** Credential (passkey) storage adapter */ +export type CredentialStorage = { + store: (record: CredentialRecord) => Promise; + get: (userId: string) => Promise; + getById: ( + credentialId: string, + ) => Promise<{ userId: string; credential: StoredCredential } | null>; + /** Persist the WebAuthn signature counter after authentication (clone detection) */ + updateCounter: (credentialId: string, counter: number) => Promise; + delete: (credentialId: string) => Promise; +}; + +/** WebAuthn challenge record (single-use) */ +export type ChallengeRecord = { + challenge: string; + /** Set for registration ceremonies, null for authentication */ + userId: string | null; + expiresAt: Date; +}; + +/** Challenge storage adapter — challenges are single-use, so take-shaped */ +export type ChallengeStorage = { + store: (record: ChallengeRecord) => Promise; + /** Atomic fetch-and-delete. Unknown challenge returns null. */ + take: (challenge: string) => Promise; +}; + +/** Registration token payload */ +export type RegistrationPayload = { + userId: string; + /** Null for identifier-less sign-up (passkey-only apps) */ + identifier: string | null; +}; + +/** Decoded registration token. Returned only when authentic; forged tokens decode to null. */ +export type RegistrationDecoded = RegistrationPayload & { + /** Token expiration */ + exp: Date; + /** Token expired (exp < now) — expired tokens must be rejected */ + expired: boolean; +}; + +/** Registration codec (short-lived token authorizing passkey registration) */ +export type RegistrationCodec = { + encode: (payload: RegistrationPayload) => Promise; + decode: (token: string) => Promise; +}; + +export type WebAuthnConfig = { + rpId: string; + rpName: string; + /** Challenge validity duration in ms */ + challengeTtl: number; +}; + +/** Config for withPasskey */ +export type WithPasskeyConfig = { + storage: CredentialStorage; + challenges: ChallengeStorage; + registrationCodec: RegistrationCodec; + webAuthn: WebAuthnConfig; +}; + +/* Wire types — WebAuthn JSON for transport between browser and server. + * These mirror the browser API; optionality follows the WebAuthn spec. */ + +export type PublicKeyCredentialCreationOptionsJSON = { + challenge: string; + rp: { name: string; id: string }; + user: { id: string; name: string; displayName: string }; + pubKeyCredParams: { type: "public-key"; alg: number }[]; + timeout?: number; + attestation?: AttestationConveyancePreference; + excludeCredentials?: { id: string; type: "public-key" }[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + // extensions omitted — add when PRF support is implemented +}; + +export type PublicKeyCredentialRequestOptionsJSON = { + challenge: string; + rpId: string; + timeout?: number; + allowCredentials?: { id: string; type: "public-key" }[]; + userVerification?: UserVerificationRequirement; + // extensions omitted — add when PRF support is implemented +}; + +export type RegistrationCredential = { + id: string; + rawId: string; + type: "public-key"; + response: { + clientDataJSON: string; + attestationObject: string; + transports?: AuthenticatorTransport[] | undefined; + }; + authenticatorAttachment?: AuthenticatorAttachment | undefined; + clientExtensionResults: AuthenticationExtensionsClientOutputs; +}; + +export type AuthenticationCredential = { + id: string; + rawId: string; + type: "public-key"; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle?: string | undefined; + }; + authenticatorAttachment?: AuthenticatorAttachment | undefined; + clientExtensionResults: AuthenticationExtensionsClientOutputs; +}; + +/* Results — pure verification, no session piping. Verification returns the + * userId; apps create sessions explicitly via session.create, exactly like + * the otp flow. Symmetry unlocks composition: multi-factor, step-up checks, + * and custom flows need no library support. */ + +export type CreateRegistrationTokenResult = { registrationToken: string }; -/** Mirrors createRegistrationToken's nullable identifier */ export type ValidateRegistrationTokenResult = Result<{ userId: string; identifier: string | null; }>; +export type RegistrationOptionsResult = Result<{ + options: PublicKeyCredentialCreationOptionsJSON; +}>; + +export type AuthenticationOptionsResult = { + options: PublicKeyCredentialRequestOptionsJSON; +}; + +export type VerifyRegistrationResult = Result<{ userId: string }>; + +export type VerifyAuthenticationResult = Result<{ userId: string }>; + /** Passkey methods — added as the `passkey` namespace by withPasskey */ export type PasskeyNamespace = { createRegistrationToken: (args: { @@ -118,19 +354,24 @@ export type PasskeyNamespace = { }) => Promise; registrationOptions: (args: { registrationToken: string; - }) => Promise; + }) => Promise; /** Verifies and stores the credential. Does NOT create a session. */ verifyRegistration: (args: { registrationToken: string; credential: RegistrationCredential; - }) => Promise; - authenticationOptions: () => Promise; + }) => Promise; + authenticationOptions: () => Promise; /** Verifies the assertion against the stored credential. Does NOT create a session. */ verifyAuthentication: (args: { credential: AuthenticationCredential; - }) => Promise; + }) => Promise; }; +/* ──────────────────────────────────────────────────────────────────────── + * Composition — the builder. Every config unit yields one namespace. + * Four concrete shapes, no generics; misconfiguration does not compile. + * ──────────────────────────────────────────────────────────────────────── */ + /** Session-only auth — both strategies still available to chain */ export type AuthCore = { session: SessionNamespace; @@ -160,88 +401,3 @@ export type AuthFull = { }; export declare function makeAuth(config: MakeAuthConfig): AuthCore; -export type makeAuth2 = (config: MakeAuthConfig) => AuthCore; - -/* Playground — no-op adapters. Note every one of them fails closed. */ - -export const auth = makeAuth({ - storage: { - get: async () => null, - deleteAll: async () => undefined, - store: async () => undefined, - delete: async () => undefined, - }, - codec: { - encode: async () => "", - decode: async () => null, - ttl: 0, - }, - transport: { - get: () => undefined, - set: () => "", - clear: () => undefined, - }, - ttl: 0, - debug: false, -}); - -auth.session.create({ userId: "123" }); -auth.session.end(); - -const otpAuth = auth.withOtp({ - storage: { - verify: async () => false, - store: async () => undefined, - }, - delivery: { - send: async () => undefined, - ttl: 0, - }, -}); - -otpAuth.otp.request({ identifier: "test@example.com" }); -otpAuth.otp.verify({ identifier: "test@example.com", otp: "123456" }); - -export const passkey = auth.withPasskey({ - storage: { - get: async () => [], - getById: async () => null, - updateCounter: async () => undefined, - delete: async () => undefined, - store: async () => undefined, - }, - challenges: { - store: async () => undefined, - take: async () => null, - }, - registrationCodec: { - encode: async () => "", - decode: async () => null, - }, - webAuthn: { - rpId: "localhost", - rpName: "Spike", - challengeTtl: 0, - }, -}); - -passkey.passkey.authenticationOptions(); -// passkey.passkey.validateRegistrationToken() - -// Sign in is two explicit calls — verification never creates sessions: -// const verified = await auth.passkey.verifyAuthentication({ credential }); -// if (!verified.success) return verified; -// return auth.session.create({ userId: verified.userId }); - -export const passkeyAndOtp = passkey.withOtp({ - storage: { - verify: async () => false, - store: async () => undefined, - }, - delivery: { - send: async () => undefined, - ttl: 0, - }, -}); - -passkeyAndOtp.otp.verify({ identifier: "test@example.com", otp: "123456" }); diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts new file mode 100644 index 0000000..8acbc8f --- /dev/null +++ b/packages/auth/src/spike/playground.ts @@ -0,0 +1,86 @@ +/** + * Playground — exercises the contracts with no-op adapters. + * Note every no-op fails closed: a do-nothing auth denies everything. + */ +import { makeAuth } from "./make-auth"; + +export const auth = makeAuth({ + storage: { + get: async () => null, + deleteAll: async () => undefined, + store: async () => undefined, + delete: async () => undefined, + }, + codec: { + encode: async () => "", + decode: async () => null, + ttl: 0, + }, + transport: { + get: () => undefined, + set: () => "", + clear: () => undefined, + }, + ttl: 0, + debug: false, +}); + +auth.session.create({ userId: "123" }); +auth.session.end(); + +const otpAuth = auth.withOtp({ + storage: { + verify: async () => false, + store: async () => undefined, + }, + delivery: { + send: async () => undefined, + ttl: 0, + }, +}); + +otpAuth.otp.request({ identifier: "test@example.com" }); +otpAuth.otp.verify({ identifier: "test@example.com", otp: "123456" }); + +export const passkey = auth.withPasskey({ + storage: { + get: async () => [], + getById: async () => null, + updateCounter: async () => undefined, + delete: async () => undefined, + store: async () => undefined, + }, + challenges: { + store: async () => undefined, + take: async () => null, + }, + registrationCodec: { + encode: async () => "", + decode: async () => null, + }, + webAuthn: { + rpId: "localhost", + rpName: "Spike", + challengeTtl: 0, + }, +}); + +passkey.passkey.authenticationOptions(); + +// Sign in is two explicit calls — verification never creates sessions: +// const verified = await auth.passkey.verifyAuthentication({ credential }); +// if (!verified.success) return verified; +// return auth.session.create({ userId: verified.userId }); + +export const passkeyAndOtp = passkey.withOtp({ + storage: { + verify: async () => false, + store: async () => undefined, + }, + delivery: { + send: async () => undefined, + ttl: 0, + }, +}); + +passkeyAndOtp.otp.verify({ identifier: "test@example.com", otp: "123456" }); From b7093dc7cb46c0e442905af111e867ec708f4c0a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 10:29:17 -0700 Subject: [PATCH 06/49] Renam --- .../spike/{make-auth-typecheck.ts => contracts-typecheck.ts} | 4 ++-- packages/auth/src/spike/{make-auth.ts => contracts.ts} | 0 packages/auth/src/spike/playground.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename packages/auth/src/spike/{make-auth-typecheck.ts => contracts-typecheck.ts} (98%) rename packages/auth/src/spike/{make-auth.ts => contracts.ts} (100%) diff --git a/packages/auth/src/spike/make-auth-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts similarity index 98% rename from packages/auth/src/spike/make-auth-typecheck.ts rename to packages/auth/src/spike/contracts-typecheck.ts index 4433f28..664d05f 100644 --- a/packages/auth/src/spike/make-auth-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -13,8 +13,8 @@ import type { AuthCoreOtp, AuthCorePasskey, AuthFull, -} from "./make-auth"; -import { makeAuth } from "./make-auth"; +} from "./contracts"; +import { makeAuth } from "./contracts"; declare const session: MakeAuthConfig; declare const otp: WithOtpConfig; diff --git a/packages/auth/src/spike/make-auth.ts b/packages/auth/src/spike/contracts.ts similarity index 100% rename from packages/auth/src/spike/make-auth.ts rename to packages/auth/src/spike/contracts.ts diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index 8acbc8f..dd70528 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -2,7 +2,7 @@ * Playground — exercises the contracts with no-op adapters. * Note every no-op fails closed: a do-nothing auth denies everything. */ -import { makeAuth } from "./make-auth"; +import { makeAuth } from "./contracts"; export const auth = makeAuth({ storage: { From b0688e2afbe2be30d31cdc191fc7b16b9fac75bd Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 11:43:27 -0700 Subject: [PATCH 07/49] Update AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index f56dcf0..9c6dd36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ This is security-critical code. - No optional parameters and no defaults — anywhere, API or config (decided 2026-07-17). Noisier but more distinct. May be reconsidered later; not now. - Never export local symbols - Use TS/JS style comments +- Comments (decided 2026-07-17): JSDoc in contract/spec files is spec text — held to completeness, state every constraint the type can't show. Everywhere else comments are held to necessity — only what code can't express, never narration. Tone: professional library API docs (what the reader needs to use it correctly) — never design rationale, internal notes, or decision history; those belong in SPEC.md. ## Error handling From 5395a0cdd254a9063bad2bc5f5c334005faed6e3 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 13:36:02 -0700 Subject: [PATCH 08/49] Update AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 9c6dd36..a2d935c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ This is security-critical code. - No optional parameters and no defaults — anywhere, API or config (decided 2026-07-17). Noisier but more distinct. May be reconsidered later; not now. - Never export local symbols - Use TS/JS style comments -- Comments (decided 2026-07-17): JSDoc in contract/spec files is spec text — held to completeness, state every constraint the type can't show. Everywhere else comments are held to necessity — only what code can't express, never narration. Tone: professional library API docs (what the reader needs to use it correctly) — never design rationale, internal notes, or decision history; those belong in SPEC.md. +- Comments (decided 2026-07-17): doc blocks (`/** */`, prose only — never `@param`/`@returns` tags, types carry the signatures) in contract/spec files are spec text — held to completeness, state every constraint the type can't show. Everywhere else comments are held to necessity — only what code can't express, never narration. Tone: professional library API docs — never design rationale, internal notes, or decision history; those belong in SPEC.md. ## Error handling From b333067d38c90eb01804ccd0005d8cf9b7a30b0d Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 13:47:49 -0700 Subject: [PATCH 09/49] Spec --- packages/auth/src/spike/contracts.ts | 60 +++++++++++++-------------- packages/auth/src/spike/playground.ts | 1 + 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 306ebcb..225c756 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -1,17 +1,10 @@ /** * ΛUTH contracts — the typed API spec. * - * Self-sufficient: imports nothing from the legacy tree, so it can be edited - * freely. This file is the source of intent while the API is finalized; the - * README is rewritten from it at promotion. It contains contracts only — - * everything user code touches: adapter interfaces, config shapes, method - * namespaces, the four auth shapes, factory signatures. Mechanisms, bindings, - * and config bundles are implementations of these contracts and live outside. - * - * Layout previews the promoted file split: each banner section becomes a file. - * - * Verified by make-auth-typecheck.ts (compile-time assertions, both - * directions) and exercised by playground.ts. + * Everything user code touches: adapter interfaces, config shapes, method + * namespaces, auth shapes, and factory signatures. Source of intent while + * the API is finalized. Verified by contracts-typecheck.ts and exercised by + * playground.ts. */ /* ──────────────────────────────────────────────────────────────────────── @@ -63,7 +56,7 @@ export type SessionPayload = { userId: string; }; -/** Decoded session token. Returned only when authentic; forged/garbled tokens decode to null. */ +/** Decoded session token. Invalid or forged tokens decode to null. */ export type SessionDecoded = SessionPayload & { /** Token expiration (fixed — the revocation window) */ exp: Date; @@ -93,7 +86,7 @@ export type SessionTransport = { clear: () => void; }; -/** Config for makeAuth — the session core (session is not a sub-object; it IS the core) */ +/** Config for makeAuth — the session core */ export type MakeAuthConfig = { storage: SessionStorage; codec: SessionCodec; @@ -130,13 +123,12 @@ export type OtpRecord = { }; /** - * OTP storage adapter — the semantic contract. + * OTP storage adapter. * - * verify states meaning, not mechanism: implementations must guarantee - * expiry, comparison, and one-time use — or be produced by makeOtpStorage, - * which builds those guarantees from two dumb atomic primitives. Delegated - * verification (e.g. a provider that checks the otp remotely) implements - * this contract directly. + * Implementations must guarantee expiry, comparison, and one-time use. Use + * makeOtpStorage to build these guarantees from two primitives, or implement + * verify directly (e.g. delegated verification via a provider's check + * endpoint). */ export type OtpStorage = { store: (record: OtpRecord) => Promise; @@ -145,9 +137,9 @@ export type OtpStorage = { }; /** - * Input contract for makeOtpStorage — two dumb primitives, one guarantee. - * take must be an atomic fetch-and-delete (DELETE … RETURNING, GETDEL). - * The lazy implementation fails closed: returning null denies access. + * Input for makeOtpStorage: otp storage as two primitives. take must be + * atomic — fetch and delete in one operation (e.g. DELETE … RETURNING, + * GETDEL). */ export type MakeOtpStorageConfig = { /** Upsert */ @@ -224,7 +216,7 @@ export type ChallengeRecord = { expiresAt: Date; }; -/** Challenge storage adapter — challenges are single-use, so take-shaped */ +/** Challenge storage adapter. Challenges are single-use. */ export type ChallengeStorage = { store: (record: ChallengeRecord) => Promise; /** Atomic fetch-and-delete. Unknown challenge returns null. */ @@ -238,7 +230,7 @@ export type RegistrationPayload = { identifier: string | null; }; -/** Decoded registration token. Returned only when authentic; forged tokens decode to null. */ +/** Decoded registration token. Invalid or forged tokens decode to null. */ export type RegistrationDecoded = RegistrationPayload & { /** Token expiration */ exp: Date; @@ -255,6 +247,12 @@ export type RegistrationCodec = { export type WebAuthnConfig = { rpId: string; rpName: string; + /** + * Exact allowed origins, scheme + host + port — e.g. ["https://app.example.com"]. + * Matched exactly against clientDataJSON.origin: no wildcards, no subdomain + * logic, never inferred from rpId. + */ + allowedOrigins: string[]; /** Challenge validity duration in ms */ challengeTtl: number; }; @@ -318,10 +316,8 @@ export type AuthenticationCredential = { clientExtensionResults: AuthenticationExtensionsClientOutputs; }; -/* Results — pure verification, no session piping. Verification returns the - * userId; apps create sessions explicitly via session.create, exactly like - * the otp flow. Symmetry unlocks composition: multi-factor, step-up checks, - * and custom flows need no library support. */ +/* Results — verification returns the verified userId; sessions are created + * explicitly via session.create. */ export type CreateRegistrationTokenResult = { registrationToken: string }; @@ -355,21 +351,21 @@ export type PasskeyNamespace = { registrationOptions: (args: { registrationToken: string; }) => Promise; - /** Verifies and stores the credential. Does NOT create a session. */ + /** Verifies and stores the credential. Does not create a session — call session.create. */ verifyRegistration: (args: { registrationToken: string; credential: RegistrationCredential; }) => Promise; authenticationOptions: () => Promise; - /** Verifies the assertion against the stored credential. Does NOT create a session. */ + /** Verifies the assertion against the stored credential. Does not create a session — call session.create. */ verifyAuthentication: (args: { credential: AuthenticationCredential; }) => Promise; }; /* ──────────────────────────────────────────────────────────────────────── - * Composition — the builder. Every config unit yields one namespace. - * Four concrete shapes, no generics; misconfiguration does not compile. + * Composition — the builder. Each configured unit adds its namespace. + * Invalid configurations do not compile. * ──────────────────────────────────────────────────────────────────────── */ /** Session-only auth — both strategies still available to chain */ diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index dd70528..875e69a 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -61,6 +61,7 @@ export const passkey = auth.withPasskey({ webAuthn: { rpId: "localhost", rpName: "Spike", + allowedOrigins: [], challengeTtl: 0, }, }); From 1d32a60e7e95985f5a4596300b6b9f1034471785 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 13:58:43 -0700 Subject: [PATCH 10/49] Update contracts.ts --- packages/auth/src/spike/contracts.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 225c756..b60978d 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -19,10 +19,16 @@ export type AuthErrorCode = | "user_mismatch" | "credential_not_found" | "verification_failed" + /** Wire-level only (REST handler); never returned by auth methods */ | "invalid_request" + /** Wire-level only (REST handler); never returned by auth methods */ | "internal_error"; -/** Generic result type for failable operations — expected failures are values, never exceptions */ +/** + * Result of an operation with expected failure modes — including malformed + * client input. Expected failures are values, never exceptions; absence is + * null; idempotent success is void. Infrastructure failures throw. + */ export type Result = ({ success: true } & T) | { success: false; error: AuthErrorCode }; @@ -96,13 +102,15 @@ export type MakeAuthConfig = { debug: boolean; }; -export type CreateSessionResult = Result<{ - session: { token: string; userId: string }; -}>; - /** Session methods — the core namespace, present at every step */ export type SessionNamespace = { - create: (args: { userId: string }) => Promise; + /** + * Creates a session for the given user. The token is also delivered via + * the session transport; it is returned for header-based clients. + */ + create: (args: { + userId: string; + }) => Promise<{ token: string; userId: string }>; get: () => Promise<{ userId: string } | null>; /** End the current session (signs the user out) */ end: () => Promise; @@ -166,12 +174,15 @@ export type WithOtpConfig = { delivery: OtpDelivery; }; -export type RequestOtpResult = { success: true }; export type VerifyOtpResult = Result; /** Otp methods — added as the `otp` namespace by withOtp */ export type OtpNamespace = { - request: (args: { identifier: string }) => Promise; + /** + * Sends an otp to the identifier. Never reveals whether delivery + * succeeded (enumeration safety). + */ + request: (args: { identifier: string }) => Promise; verify: (args: { identifier: string; otp: string; From eac78ccd76740a5ae0f3ce4faffd86f5acd5fb9a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 14:19:47 -0700 Subject: [PATCH 11/49] Error handling --- AGENTS.md | 8 +- SPEC.md | 2 + .../auth/src/spike/contracts-typecheck.ts | 22 ++++++ packages/auth/src/spike/contracts.ts | 76 +++++++++++-------- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2d935c..ff46bb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,13 @@ This is security-critical code. ## Error handling -- All public API functions return `Result` — never throw +The mental model (decided 2026-07-17): three channels, one rule per kind of function. + +- Commands (public API methods that do something) return `Result` with a narrowed per-method error union — expected failures are values, including malformed client input. `E = never` collapses the type to an always-success envelope, so no dead error branches. +- Queries (public API lookups) return the value or `null` — absence is not failure. Currently only `session.get`. +- Adapter interfaces (SPI) return plain values/null — the envelope is how the library speaks, not how it listens. +- Infrastructure failures throw, everywhere — they are breakage, not outcomes. Throws are for the error monitor; Results are for the user. +- The library never throws as flow control — no auth flow requires try/catch. Every shipped wire layer converts throws to error envelopes (500 + `internal_error`). - Use `result.ok()` for success, `result.fail()` for expected failures - Invariants: Never use type assertions (`as`). Throw instead — surfaces bugs immediately. Comment each invariant `Invariant: reasoning` - Must prove the error with a test before adding try-catch diff --git a/SPEC.md b/SPEC.md index 4789cc0..615f55e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -92,6 +92,8 @@ See `CoreMethods`, `OtpMethods`, `PasskeyMethods`, and `AuthClient` types in `pa > **Decided (2026-07-17): Pure verification.** Previously `verifyRegistration`/`verifyAuthentication` created sessions implicitly. Removed: core produces verified facts; a session is policy, and policy lives in userland — passkey sign-in is verify then `createSession`, identical in shape to the otp flow. The uniform rule unlocks zero-feature composition (multi-factor, step-up, custom flows need no library support) and fails closed (forgetting the session call yields no session). Consequences: (1) a browser-only REST flow can't complete sign-in — composing verify + createSession requires an app server function, which sharpens the open question about `makeAuthHandler`'s role; (2) the app now binds the verified userId to the session — examples must always thread the verify result's `userId` into `createSession` (see branded verified ids under Future). +> **Decided (2026-07-17): Return-type model — commands, queries, throws.** Commands return `Result` with a per-method error union: expected failures (wrong otp, bad token, malformed client input) are values the caller branches on, and `E = never` collapses the type to an always-success envelope so methods without failure modes carry no dead error branch. Queries return the value or `null` — absence is not failure. Adapter interfaces return plain values/null — the envelope is how the library speaks, not how it listens. Infrastructure failures throw everywhere: they are breakage, not outcomes — throws feed the error monitor (and the wire layers convert them to `internal_error` envelopes), Results feed the user. Rationale: signatures must teach callers exactly what to handle — a `Result` that cannot fail teaches dead branches, a thrown expected failure forces try/catch as flow control (rejected; cf. tRPC client ergonomics), and catch-all Result wrapping shadows infrastructure errors into user-facing paths (the Zod `safeParse` split, applied consistently). Evolution: a method gaining a failure mode widens its `E` without changing shape. Replaces the earlier blanket rule "all public API functions return Result and never throw." + ### Flows The library provides primitives. Apps compose flows. Below are common patterns. In the diagrams, `session` at the end of a chain is an explicit `createSession` call by your app — nothing creates sessions implicitly. diff --git a/packages/auth/src/spike/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts index 664d05f..0ecfd56 100644 --- a/packages/auth/src/spike/contracts-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -89,6 +89,28 @@ if (registrationResult.success) { void registrationResult.session; } +/* Commands without failure modes collapse — the envelope needs no narrowing */ + +declare const created: Awaited>; +void created.success; +void created.token; +void created.userId; + +/* Commands with failure modes require narrowing before payload access */ + +declare const otpVerified: Awaited>; +// @ts-expect-error error exists only on the failure branch — narrow on success first +void otpVerified.error; + +/* Error unions are narrowed per method */ + +declare const otpFailure: Extract< + Awaited>, + { success: false } +>; +const otpError: "invalid_otp" = otpFailure.error; +void otpError; + /* Duplicate steps are type errors — with* removes itself from the chain */ // @ts-expect-error withOtp cannot be chained twice diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index b60978d..ac600fc 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -25,12 +25,17 @@ export type AuthErrorCode = | "internal_error"; /** - * Result of an operation with expected failure modes — including malformed - * client input. Expected failures are values, never exceptions; absence is - * null; idempotent success is void. Infrastructure failures throw. + * Command result. Expected failures — including malformed client input — + * are values the caller branches on; E lists exactly the failures the + * command can produce, and E = never collapses the type to an always-success + * envelope. Queries return the value or null instead: absence is not + * failure. Infrastructure failures throw. */ -export type Result = - ({ success: true } & T) | { success: false; error: AuthErrorCode }; +export type Result = [E] extends [ + never, +] + ? { success: true } & T + : ({ success: true } & T) | { success: false; error: E }; /* ──────────────────────────────────────────────────────────────────────── * Session — the core. Records → adapters → config → namespace → results. @@ -110,12 +115,12 @@ export type SessionNamespace = { */ create: (args: { userId: string; - }) => Promise<{ token: string; userId: string }>; + }) => Promise>; get: () => Promise<{ userId: string } | null>; - /** End the current session (signs the user out) */ - end: () => Promise; - /** End all sessions for the current user (signs out every device) */ - endAll: () => Promise; + /** Ends the current session (signs the user out) */ + end: () => Promise>; + /** Ends all sessions for the current user (signs out every device) */ + endAll: () => Promise>; }; /* ──────────────────────────────────────────────────────────────────────── @@ -174,7 +179,7 @@ export type WithOtpConfig = { delivery: OtpDelivery; }; -export type VerifyOtpResult = Result; +export type VerifyOtpResult = Result; /** Otp methods — added as the `otp` namespace by withOtp */ export type OtpNamespace = { @@ -182,7 +187,7 @@ export type OtpNamespace = { * Sends an otp to the identifier. Never reveals whether delivery * succeeded (enumeration safety). */ - request: (args: { identifier: string }) => Promise; + request: (args: { identifier: string }) => Promise>; verify: (args: { identifier: string; otp: string; @@ -330,24 +335,35 @@ export type AuthenticationCredential = { /* Results — verification returns the verified userId; sessions are created * explicitly via session.create. */ -export type CreateRegistrationTokenResult = { registrationToken: string }; - -export type ValidateRegistrationTokenResult = Result<{ - userId: string; - identifier: string | null; -}>; - -export type RegistrationOptionsResult = Result<{ - options: PublicKeyCredentialCreationOptionsJSON; -}>; - -export type AuthenticationOptionsResult = { - options: PublicKeyCredentialRequestOptionsJSON; -}; - -export type VerifyRegistrationResult = Result<{ userId: string }>; - -export type VerifyAuthenticationResult = Result<{ userId: string }>; +export type CreateRegistrationTokenResult = Result< + { registrationToken: string }, + never +>; + +export type ValidateRegistrationTokenResult = Result< + { userId: string; identifier: string | null }, + "invalid_token" +>; + +export type RegistrationOptionsResult = Result< + { options: PublicKeyCredentialCreationOptionsJSON }, + "invalid_token" +>; + +export type AuthenticationOptionsResult = Result< + { options: PublicKeyCredentialRequestOptionsJSON }, + never +>; + +export type VerifyRegistrationResult = Result< + { userId: string }, + "invalid_token" | "challenge_expired" | "user_mismatch" | "verification_failed" +>; + +export type VerifyAuthenticationResult = Result< + { userId: string }, + "credential_not_found" | "challenge_expired" | "verification_failed" +>; /** Passkey methods — added as the `passkey` namespace by withPasskey */ export type PasskeyNamespace = { From b1e765e4234fca820c51dcbbe78f847592d5b72a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 14:21:27 -0700 Subject: [PATCH 12/49] Spec --- packages/auth/src/spike/contracts.ts | 5 +++-- packages/auth/src/spike/playground.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index ac600fc..9ca54a0 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -215,10 +215,11 @@ export type CredentialRecord = { /** Credential (passkey) storage adapter */ export type CredentialStorage = { store: (record: CredentialRecord) => Promise; - get: (userId: string) => Promise; - getById: ( + get: ( credentialId: string, ) => Promise<{ userId: string; credential: StoredCredential } | null>; + /** All credentials belonging to the user */ + list: (userId: string) => Promise; /** Persist the WebAuthn signature counter after authentication (clone detection) */ updateCounter: (credentialId: string, counter: number) => Promise; delete: (credentialId: string) => Promise; diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index 875e69a..37da465 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -44,11 +44,11 @@ otpAuth.otp.verify({ identifier: "test@example.com", otp: "123456" }); export const passkey = auth.withPasskey({ storage: { - get: async () => [], - getById: async () => null, + store: async () => undefined, + get: async () => null, + list: async () => [], updateCounter: async () => undefined, delete: async () => undefined, - store: async () => undefined, }, challenges: { store: async () => undefined, From 28916b75dd6fcabff3eea2075bcb6af833d02dc3 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 14:25:29 -0700 Subject: [PATCH 13/49] Update contracts.ts --- packages/auth/src/spike/contracts.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 9ca54a0..b71f724 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -358,7 +358,10 @@ export type AuthenticationOptionsResult = Result< export type VerifyRegistrationResult = Result< { userId: string }, - "invalid_token" | "challenge_expired" | "user_mismatch" | "verification_failed" + | "invalid_token" + | "challenge_expired" + | "user_mismatch" + | "verification_failed" >; export type VerifyAuthenticationResult = Result< From 62f9f8c34fd79b5f859b6e3d6a589c9e7e33b47a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 14:25:59 -0700 Subject: [PATCH 14/49] Update AGENTS.md --- AGENTS.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff46bb2..e4e6ee8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,17 @@ - Use `bun run check` after edits to type check all workspaces +## Session start + +Read before working, in order: + +1. `packages/auth/src/spike/contracts.ts` — the typed API spec, current source of intent. Wins over the README and the code while the API is finalized. +2. `TODO.md` — the work queue (gitignored, local to this machine). +3. `SPEC.md` — rationale and dated decision blocks. Search it before proposing design changes; don't bulk-read (~700 lines). + ## Development workflow -- Use `packages/auth/README.md` as the source of intent — it documents the target API and wins over code +- Source of intent: `packages/auth/src/spike/contracts.ts` while the API is finalized; `packages/auth/README.md` resumes as the contract at promotion, rewritten from the settled contracts - Order of work: types (signatures) → tests → implementation. Signatures make the contract concrete, tests encode it, implementation satisfies it. - When designing an adapter interface, ask what the lazy implementation does — it must fail closed (deny access), never open - Type files are split by layer (see SPEC.md "Adapter layering"): contracts, mechanisms, bindings, configs — file organization mirrors the layers @@ -19,7 +27,8 @@ Roles (decided 2026-07-16): README = contract, SPEC = rationale, TODO = queue. Now: -- `packages/auth/README.md` — the contract: target API, drives implementation. Where it disagrees with the code, the README wins. +- `packages/auth/src/spike/contracts.ts` — the typed API spec: source of intent, wins over README and code while the API is finalized. +- `packages/auth/README.md` — the contract prose: stale during API finalization, rewritten from contracts.ts at promotion. - `SPEC.md` — rationale and dated decision record. Partially stale; never treat it as the contract. - `TODO.md` — work queue from the 2026-07 repo review. Deliberately uncommitted. From c3e1a4459c86bcf1d76421b5ad88c78e677a0948 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 15:19:19 -0700 Subject: [PATCH 15/49] Spec --- SPEC.md | 3 +++ packages/auth/src/spike/contracts-typecheck.ts | 2 +- packages/auth/src/spike/contracts.ts | 17 ++++++++--------- packages/auth/src/spike/playground.ts | 4 +--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/SPEC.md b/SPEC.md index 615f55e..ee6f57d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,6 +10,7 @@ Passkeys + OTP as composable primitives. Apps choose their flow. - **Primitives-first** — core API is low-level primitives, flows are composed on top - **Library-first** — your database is the source of truth, with an optional hosted service +- **User owns the database** — the library doesn't care: storage adapters map exchange shapes to your schema; no dictated tables, indexes, or drivers (decided 2026-07-17) - **LLM-friendly** — no DNS config, no OAuth dashboards, no external clicks required - **Explicit over implicit** — no magic defaults, everything is a visible import - **Semantic contracts** — adapter interfaces state meaning (`verify`), never mechanism (`take`), so core stays frozen while implementations evolve freely @@ -295,6 +296,8 @@ Storage is split by concern: `OtpStorage`, `SessionStorage`, `CredentialStorage` > **Decided (2026-07-16): Mechanisms make the common case correct by construction.** We ship factories that produce correct adapters from dumb atomic primitives: `makeOtpStorage({ store, take })` returns an `OtpStorage` with expiry check, comparison, and one-time consumption built in — written and race-tested once by us. `take(identifier)` is atomic fetch-and-delete (`DELETE … RETURNING`, `GETDEL`); that one-word guarantee — atomic — is the entire adapter obligation, and the lazy implementation fails closed. Database recipes target `store`/`take`; power users (delegated verification, custom lockouts, dev bypasses) implement `OtpStorage` raw — full control, visibly off the blessed path. Conformance tests ship alongside: sequential (take twice → second null) plus deterministic barrier-based race checks — no hammering (see https://www.lirbank.com/harnessing-postgres-race-conditions.md). +> **Decided (2026-07-17): User owns the database; the API is request-scoped.** The library never dictates schema — record types (`SessionRecord`, `OtpRecord`, `CredentialRecord`) are exchange shapes at the adapter boundary, mapped to and from the user's own representation; reads must return records equivalent to what writes received, nothing more. Two placement rules follow. A namespace method exists only for request-scoped protocol operations — state named by the token riding the current request: `session.end` stays because only the library can name "the current session" and the transport pair is its own (create sets the cookie, end clears it). An adapter method exists only where core calls it during a protocol operation. Everything identifier-keyed — sign out everywhere, list sessions, revoke one session, remove a passkey — is plain CRUD on tables the user owns, done storage-direct: correct by construction, and deletion converges within the codec ttl on every token format (immediately with opaque). Accordingly `session.endAll`, `SessionStorage.deleteAll`, and `CredentialStorage.delete` are cut — superseding the shipped `signOutAll`/`deleteAll` noted in the roadmap. The sessionId-keyed `deleteAll` also failed open: a missing current-session record made it a silent no-op exactly when compromise response matters. The README gets a management-recipes table at promotion; management surfaces on convenience adapters are a later layer's question, tabled. + > **Decided (2026-07-16): One attempt per otp.** A wrong otp consumes it — the user starts over with a fresh request. No attempt budgets, no re-store logic; every failure path fails closed. The typo cost is one email round-trip; acceptable for v1, revisit only on observed user friction. Per-IP rate limiting stays with the app; per-identifier cooldown is mechanism-layer (decided 2026-07-17, below). > **Decided (2026-07-17): Rate limiting splits by who holds the state.** A rate limit is a decision over state and is enforced where that state lives, atomically — or it isn't real. The library owns exactly one relevant state, the otp record, so it enforces exactly one limit: per-identifier issuance frequency. `OtpStorage.store` may refuse (returns `false`), `requestOtp` fails with `rate_limited` and sends nothing, and the cooldown knob lives on `makeOtpStorage` (required field, `0` = explicitly off), enforced by an atomic conditional-put `store` primitive — the same one-word obligation as `take`. Per-IP/volume/bot defense stays with the app (it holds the request); sender reputation stays with the sending service (it holds cross-app outcomes). Full vector map and deployment shapes: THREAT-MODEL.md. Supersedes the blanket "rate limiting (infrastructure-layer concern)" exclusion. diff --git a/packages/auth/src/spike/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts index 0ecfd56..6611907 100644 --- a/packages/auth/src/spike/contracts-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -35,7 +35,7 @@ expectType(makeAuth(session).withPasskey(passkey).withOtp(otp)); /* The session namespace is present at every step */ void makeAuth(session).session.get; -void makeAuth(session).withOtp(otp).session.endAll; +void makeAuth(session).withOtp(otp).session.end; void makeAuth(session).withOtp(otp).withPasskey(passkey).session.create; /* Strategy namespaces only exist after their step */ diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index b71f724..53d7bea 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -41,7 +41,11 @@ export type Result = [E] extends [ * Session — the core. Records → adapters → config → namespace → results. * ──────────────────────────────────────────────────────────────────────── */ -/** Session DB record */ +/** + * Session record — the shape exchanged with SessionStorage, not a stored + * schema. Storage maps it to and from its own representation; reads must + * return records equivalent to what store received. + */ export type SessionRecord = { sessionId: string; userId: string; @@ -55,8 +59,6 @@ export type SessionStorage = { store: (record: SessionRecord) => Promise; get: (sessionId: string) => Promise; delete: (sessionId: string) => Promise; - /** Delete all sessions belonging to the same user as this session */ - deleteAll: (sessionId: string) => Promise; }; /** Session token payload */ @@ -119,15 +121,13 @@ export type SessionNamespace = { get: () => Promise<{ userId: string } | null>; /** Ends the current session (signs the user out) */ end: () => Promise>; - /** Ends all sessions for the current user (signs out every device) */ - endAll: () => Promise>; }; /* ──────────────────────────────────────────────────────────────────────── * OTP — identity verification, optionally authentication. * ──────────────────────────────────────────────────────────────────────── */ -/** OTP DB record */ +/** Otp record — the shape exchanged with otp storage, not a stored schema */ export type OtpRecord = { /** Identifier (email address, phone number, etc.) */ identifier: string; @@ -206,7 +206,7 @@ export type StoredCredential = { transports?: AuthenticatorTransport[] | undefined; }; -/** Credential DB record */ +/** Credential record — the shape exchanged with CredentialStorage, not a stored schema */ export type CredentialRecord = { userId: string; credential: StoredCredential; @@ -221,8 +221,7 @@ export type CredentialStorage = { /** All credentials belonging to the user */ list: (userId: string) => Promise; /** Persist the WebAuthn signature counter after authentication (clone detection) */ - updateCounter: (credentialId: string, counter: number) => Promise; - delete: (credentialId: string) => Promise; + setCounter: (credentialId: string, counter: number) => Promise; }; /** WebAuthn challenge record (single-use) */ diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index 37da465..1a96434 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -7,7 +7,6 @@ import { makeAuth } from "./contracts"; export const auth = makeAuth({ storage: { get: async () => null, - deleteAll: async () => undefined, store: async () => undefined, delete: async () => undefined, }, @@ -47,8 +46,7 @@ export const passkey = auth.withPasskey({ store: async () => undefined, get: async () => null, list: async () => [], - updateCounter: async () => undefined, - delete: async () => undefined, + setCounter: async () => undefined, }, challenges: { store: async () => undefined, From ad15957ed2c2790ed1f80e8d6f41deb609534a5b Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 17:21:29 -0700 Subject: [PATCH 16/49] Spec --- .../auth/src/spike/contracts-typecheck.ts | 22 ++- packages/auth/src/spike/contracts.ts | 152 +++++++++++------- packages/auth/src/spike/playground.ts | 2 +- 3 files changed, 111 insertions(+), 65 deletions(-) diff --git a/packages/auth/src/spike/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts index 6611907..893cb3b 100644 --- a/packages/auth/src/spike/contracts-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -74,9 +74,9 @@ declare const verifyResult: Awaited< >; if (verifyResult.success) { - void verifyResult.userId; + void verifyResult.data.userId; // @ts-expect-error verification carries no session — create one explicitly via session.create - void verifyResult.session; + void verifyResult.data.session; } declare const registrationResult: Awaited< @@ -84,19 +84,29 @@ declare const registrationResult: Awaited< >; if (registrationResult.success) { - void registrationResult.userId; + void registrationResult.data.userId; // @ts-expect-error registration carries no session — create one explicitly via session.create - void registrationResult.session; + void registrationResult.data.session; } /* Commands without failure modes collapse — the envelope needs no narrowing */ declare const created: Awaited>; void created.success; +void created.data.token; +void created.data.userId; + +// @ts-expect-error T rides in data, never spread into the envelope void created.token; -void created.userId; -/* Commands with failure modes require narrowing before payload access */ +/* Void commands drop the data field entirely */ + +declare const ended: Awaited>; +void ended.success; +// @ts-expect-error void commands carry no data field +void ended.data; + +/* Commands with failure modes require narrowing before data access */ declare const otpVerified: Awaited>; // @ts-expect-error error exists only on the failure branch — narrow on success first diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 53d7bea..2ad1ceb 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -11,6 +11,40 @@ * Shared vocabulary * ──────────────────────────────────────────────────────────────────────── */ +/* + * Vocabulary + * + * Type suffixes, by boundary: + * - *Record — shape exchanged with a storage adapter; never a stored schema + * - *Decoded — what decode returns: what the token carries (record, grant) plus its TokenStatus + * - *Config — input to a factory or builder step, named for its consumer + * - *Namespace — the methods a builder step adds; *Result — a command's envelope + * - *Credential / *JSON — WebAuthn wire shapes mirroring the browser API + * + * Adapter roles: + * - Storage — persistence the user owns + * - Codec — token format (encode/decode) + * - Transport — how the session token rides on requests (cookie, header) + * - Delivery — out-of-band send to the user (email, SMS, console) + * + * Adapter verbs: + * - store — upsert by the record's key + * - get — read by key; take — atomic fetch-and-delete; both null when absent + * - list — all records for a key + * - set* — plain overwrite, never read-modify-write + * - verify — check and consume (single-use); send — deliver out of band + * - encode / decode — mint and read tokens; invalid tokens decode to null + * + * Method verbs: + * - verify — checks and consumes a single-use artifact + * - validate — repeatable check, consumes nothing + * + * Field rules: + * - absence is null, never undefined; nullable, never optional (wire types excepted) + * - expiry: expiresAt dates and expired flags, scoped by their object; ttl is a duration in ms + * - API methods take one args object; adapter methods take positional args + */ + /** Error codes for auth failures */ export type AuthErrorCode = | "invalid_otp" @@ -28,23 +62,30 @@ export type AuthErrorCode = * Command result. Expected failures — including malformed client input — * are values the caller branches on; E lists exactly the failures the * command can produce, and E = never collapses the type to an always-success - * envelope. Queries return the value or null instead: absence is not + * envelope. T rides in data; T = void drops the field for commands that + * return nothing. Queries return the value or null instead: absence is not * failure. Infrastructure failures throw. */ -export type Result = [E] extends [ - never, -] - ? { success: true } & T - : ({ success: true } & T) | { success: false; error: E }; +export type Result = + | ([T] extends [void] ? { success: true } : { success: true; data: T }) + | ([E] extends [never] ? never : { success: false; error: E }); + +/** The token's own status, as read by decode */ +export type TokenStatus = { + expiresAt: Date; + /** expiresAt < now, computed by the codec — the codec owns the token clock */ + expired: boolean; +}; /* ──────────────────────────────────────────────────────────────────────── * Session — the core. Records → adapters → config → namespace → results. * ──────────────────────────────────────────────────────────────────────── */ /** - * Session record — the shape exchanged with SessionStorage, not a stored - * schema. Storage maps it to and from its own representation; reads must - * return records equivalent to what store received. + * Session record — the session's exchange shape: what SessionStorage stores + * and the session token carries. Not a stored schema: storage maps it to and + * from its own representation; reads must return records equivalent to what + * store received. */ export type SessionRecord = { sessionId: string; @@ -55,34 +96,29 @@ export type SessionRecord = { /** Session storage adapter — plain reads and writes; core enforces expiry */ export type SessionStorage = { - /** Upsert */ store: (record: SessionRecord) => Promise; get: (sessionId: string) => Promise; delete: (sessionId: string) => Promise; }; -/** Session token payload */ -export type SessionPayload = { - sessionId: string; - /** Session expiry (null = never expires). Slides on every request. */ - sessionExp: Date | null; - userId: string; -}; - /** Decoded session token. Invalid or forged tokens decode to null. */ -export type SessionDecoded = SessionPayload & { - /** Token expiration (fixed — the revocation window) */ - exp: Date; - /** Token expired (exp < now) — forces the storage revocation check */ - expired: boolean; +export type SessionDecoded = { + /** The session record the token carries or resolves to */ + record: SessionRecord; + /** Expiry is fixed (the revocation window); expired forces the storage revocation check */ + token: TokenStatus; }; -/** Session codec — token format (HMAC, opaque, or bring a JWT library) */ +/** + * Session codec — token format (HMAC, opaque, or bring a JWT library). + * Self-contained tokens carry the session record; reference (opaque) tokens + * resolve it via lookup. Storage remains the authority on revocation. + */ export type SessionCodec = { - /** expiresAt: null mints a fresh token TTL; a Date preserves an existing expiry (sliding refresh) */ + /** token.expiresAt: null mints a fresh token TTL; a Date preserves the existing token expiry (sliding refresh) */ encode: ( - payload: SessionPayload, - options: { expiresAt: Date | null }, + record: SessionRecord, + token: { expiresAt: Date | null }, ) => Promise; decode: (token: string) => Promise; /** Token TTL in ms — the revocation window */ @@ -92,7 +128,7 @@ export type SessionCodec = { /** Session transport — how the token rides on requests (cookie, header) */ export type SessionTransport = { /** Read token from the incoming request */ - get: () => string | undefined; + get: () => string | null; /** Store token and return what goes in the response body */ set: (token: string) => string; /** Clear the stored token */ @@ -120,7 +156,7 @@ export type SessionNamespace = { }) => Promise>; get: () => Promise<{ userId: string } | null>; /** Ends the current session (signs the user out) */ - end: () => Promise>; + end: () => Promise>; }; /* ──────────────────────────────────────────────────────────────────────── @@ -155,7 +191,6 @@ export type OtpStorage = { * GETDEL). */ export type MakeOtpStorageConfig = { - /** Upsert */ store: (record: OtpRecord) => Promise; /** Atomic fetch-and-delete. Unknown identifier returns null. */ take: (identifier: string) => Promise; @@ -179,7 +214,7 @@ export type WithOtpConfig = { delivery: OtpDelivery; }; -export type VerifyOtpResult = Result; +export type VerifyOtpResult = Result; /** Otp methods — added as the `otp` namespace by withOtp */ export type OtpNamespace = { @@ -187,7 +222,7 @@ export type OtpNamespace = { * Sends an otp to the identifier. Never reveals whether delivery * succeeded (enumeration safety). */ - request: (args: { identifier: string }) => Promise>; + request: (args: { identifier: string }) => Promise>; verify: (args: { identifier: string; otp: string; @@ -203,7 +238,8 @@ export type StoredCredential = { id: string; publicKey: Uint8Array; counter: number; - transports?: AuthenticatorTransport[] | undefined; + /** null = the client reported no transport hints */ + transports: AuthenticatorTransport[] | null; }; /** Credential record — the shape exchanged with CredentialStorage, not a stored schema */ @@ -215,9 +251,7 @@ export type CredentialRecord = { /** Credential (passkey) storage adapter */ export type CredentialStorage = { store: (record: CredentialRecord) => Promise; - get: ( - credentialId: string, - ) => Promise<{ userId: string; credential: StoredCredential } | null>; + get: (credentialId: string) => Promise; /** All credentials belonging to the user */ list: (userId: string) => Promise; /** Persist the WebAuthn signature counter after authentication (clone detection) */ @@ -239,24 +273,27 @@ export type ChallengeStorage = { take: (challenge: string) => Promise; }; -/** Registration token payload */ -export type RegistrationPayload = { +/** + * A grant to register a passkey: the user it belongs to, and the identifier + * shown in the passkey picker (user.name; null for identifier-less sign-up + * in passkey-only apps). + */ +export type RegistrationGrant = { userId: string; - /** Null for identifier-less sign-up (passkey-only apps) */ identifier: string | null; }; /** Decoded registration token. Invalid or forged tokens decode to null. */ -export type RegistrationDecoded = RegistrationPayload & { - /** Token expiration */ - exp: Date; - /** Token expired (exp < now) — expired tokens must be rejected */ - expired: boolean; +export type RegistrationDecoded = { + /** The grant the token carries */ + grant: RegistrationGrant; + /** Expired tokens must be rejected */ + token: TokenStatus; }; /** Registration codec (short-lived token authorizing passkey registration) */ export type RegistrationCodec = { - encode: (payload: RegistrationPayload) => Promise; + encode: (grant: RegistrationGrant) => Promise; decode: (token: string) => Promise; }; @@ -335,23 +372,24 @@ export type AuthenticationCredential = { /* Results — verification returns the verified userId; sessions are created * explicitly via session.create. */ -export type CreateRegistrationTokenResult = Result< - { registrationToken: string }, - never ->; +/** Success data is the registration token */ +export type CreateRegistrationTokenResult = Result; +/** Success data is the grant the token carries */ export type ValidateRegistrationTokenResult = Result< - { userId: string; identifier: string | null }, + RegistrationGrant, "invalid_token" >; +/** Success data is the WebAuthn creation options */ export type RegistrationOptionsResult = Result< - { options: PublicKeyCredentialCreationOptionsJSON }, + PublicKeyCredentialCreationOptionsJSON, "invalid_token" >; +/** Success data is the WebAuthn request options */ export type AuthenticationOptionsResult = Result< - { options: PublicKeyCredentialRequestOptionsJSON }, + PublicKeyCredentialRequestOptionsJSON, never >; @@ -370,13 +408,11 @@ export type VerifyAuthenticationResult = Result< /** Passkey methods — added as the `passkey` namespace by withPasskey */ export type PasskeyNamespace = { - createRegistrationToken: (args: { - userId: string; - /** Shown in the passkey picker (user.name). Null for identifier-less sign-up (passkey-only apps). */ - identifier: string | null; - }) => Promise; + createRegistrationToken: ( + args: RegistrationGrant, + ) => Promise; validateRegistrationToken: (args: { - token: string; + registrationToken: string; }) => Promise; registrationOptions: (args: { registrationToken: string; diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index 1a96434..0d3edcc 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -16,7 +16,7 @@ export const auth = makeAuth({ ttl: 0, }, transport: { - get: () => undefined, + get: () => null, set: () => "", clear: () => undefined, }, From e8b15b2ba19095f8bcee81b8d29671b0272a2a2c Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 17:44:29 -0700 Subject: [PATCH 17/49] Spec --- .../auth/src/spike/contracts-typecheck.ts | 7 +- packages/auth/src/spike/contracts.ts | 72 ++++++++++--------- packages/auth/src/spike/playground.ts | 11 ++- 3 files changed, 45 insertions(+), 45 deletions(-) diff --git a/packages/auth/src/spike/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts index 893cb3b..1bc0d2c 100644 --- a/packages/auth/src/spike/contracts-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -42,7 +42,7 @@ void makeAuth(session).withOtp(otp).withPasskey(passkey).session.create; void makeAuth(session).withOtp(otp).otp.verify; void makeAuth(session).withPasskey(passkey).passkey.verifyAuthentication; void makeAuth(session).withOtp(otp).withPasskey(passkey).passkey - .registrationOptions; + .createRegistrationOptions; // @ts-expect-error otp namespace does not exist before withOtp void makeAuth(session).otp; @@ -93,8 +93,7 @@ if (registrationResult.success) { declare const created: Awaited>; void created.success; -void created.data.token; -void created.data.userId; +void created.data; // @ts-expect-error T rides in data, never spread into the envelope void created.token; @@ -148,7 +147,7 @@ void makeAuth(session).withPasskey({ ...passkey, unknown: true }); // @ts-expect-error delivery is required in otp config void makeAuth(session).withOtp({ storage: otp.storage }); -// @ts-expect-error challenges is required in passkey config +// @ts-expect-error challengeStorage is required in passkey config void makeAuth(session).withPasskey({ storage: passkey.storage, registrationCodec: passkey.registrationCodec, diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 2ad1ceb..6b827c1 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -41,7 +41,8 @@ * * Field rules: * - absence is null, never undefined; nullable, never optional (wire types excepted) - * - expiry: expiresAt dates and expired flags, scoped by their object; ttl is a duration in ms + * - expiry: expiresAt dates and expired flags, scoped by their object + * - ttl: a duration in ms — unit policy on unit configs; mechanism TTLs live in their factories, off the SPI * - API methods take one args object; adapter methods take positional args */ @@ -70,7 +71,12 @@ export type Result = | ([T] extends [void] ? { success: true } : { success: true; data: T }) | ([E] extends [never] ? never : { success: false; error: E }); -/** The token's own status, as read by decode */ +/** + * The token's trust status, as read by decode. expiresAt is the trust + * horizon: how long the carried record may be trusted without consulting + * storage. Self-contained tokens embed it; lookup codecs report now — their + * trust is per-decode, so expired is never true. + */ export type TokenStatus = { expiresAt: Date; /** expiresAt < now, computed by the codec — the codec owns the token clock */ @@ -112,17 +118,16 @@ export type SessionDecoded = { /** * Session codec — token format (HMAC, opaque, or bring a JWT library). * Self-contained tokens carry the session record; reference (opaque) tokens - * resolve it via lookup. Storage remains the authority on revocation. + * resolve it via lookup. Storage remains the authority on revocation. The + * token TTL is the codec factory's own config — never core's. */ export type SessionCodec = { - /** token.expiresAt: null mints a fresh token TTL; a Date preserves the existing token expiry (sliding refresh) */ + /** token.expiresAt: null mints a fresh horizon from the codec's own TTL; a Date preserves the existing horizon (sliding refresh) */ encode: ( record: SessionRecord, token: { expiresAt: Date | null }, ) => Promise; decode: (token: string) => Promise; - /** Token TTL in ms — the revocation window */ - ttl: number; }; /** Session transport — how the token rides on requests (cookie, header) */ @@ -148,12 +153,11 @@ export type MakeAuthConfig = { /** Session methods — the core namespace, present at every step */ export type SessionNamespace = { /** - * Creates a session for the given user. The token is also delivered via - * the session transport; it is returned for header-based clients. + * Creates a session for the given user. Success data is the session token + * — also delivered via the session transport; returned for header-based + * clients. */ - create: (args: { - userId: string; - }) => Promise>; + create: (args: { userId: string }) => Promise>; get: () => Promise<{ userId: string } | null>; /** Ends the current session (signs the user out) */ end: () => Promise>; @@ -204,18 +208,16 @@ export declare function makeOtpStorage( /** OTP delivery adapter (email, SMS, console) */ export type OtpDelivery = { send: (identifier: string, otp: string) => Promise; - /** OTP validity duration in ms */ - ttl: number; }; /** Config for withOtp */ export type WithOtpConfig = { storage: OtpStorage; delivery: OtpDelivery; + /** Otp validity duration in ms — core stamps OtpRecord.expiresAt from it */ + ttl: number; }; -export type VerifyOtpResult = Result; - /** Otp methods — added as the `otp` namespace by withOtp */ export type OtpNamespace = { /** @@ -226,34 +228,29 @@ export type OtpNamespace = { verify: (args: { identifier: string; otp: string; - }) => Promise; + }) => Promise>; }; /* ──────────────────────────────────────────────────────────────────────── * Passkey — WebAuthn authentication. * ──────────────────────────────────────────────────────────────────────── */ -/** Credential (passkey) stored data */ -export type StoredCredential = { - id: string; +/** Credential record — the shape exchanged with CredentialStorage, not a stored schema */ +export type CredentialRecord = { + credentialId: string; + userId: string; publicKey: Uint8Array; counter: number; /** null = the client reported no transport hints */ transports: AuthenticatorTransport[] | null; }; -/** Credential record — the shape exchanged with CredentialStorage, not a stored schema */ -export type CredentialRecord = { - userId: string; - credential: StoredCredential; -}; - /** Credential (passkey) storage adapter */ export type CredentialStorage = { store: (record: CredentialRecord) => Promise; get: (credentialId: string) => Promise; /** All credentials belonging to the user */ - list: (userId: string) => Promise; + list: (userId: string) => Promise; /** Persist the WebAuthn signature counter after authentication (clone detection) */ setCounter: (credentialId: string, counter: number) => Promise; }; @@ -291,12 +288,17 @@ export type RegistrationDecoded = { token: TokenStatus; }; -/** Registration codec (short-lived token authorizing passkey registration) */ +/** + * Registration codec (short-lived token authorizing passkey registration). + * The validity window is the codec factory's own config; encode mints at + * that horizon. + */ export type RegistrationCodec = { encode: (grant: RegistrationGrant) => Promise; decode: (token: string) => Promise; }; +/** WebAuthn protocol identity — who the relying party is and which origins may speak for it */ export type WebAuthnConfig = { rpId: string; rpName: string; @@ -306,16 +308,16 @@ export type WebAuthnConfig = { * logic, never inferred from rpId. */ allowedOrigins: string[]; - /** Challenge validity duration in ms */ - challengeTtl: number; }; /** Config for withPasskey */ export type WithPasskeyConfig = { storage: CredentialStorage; - challenges: ChallengeStorage; + challengeStorage: ChallengeStorage; registrationCodec: RegistrationCodec; webAuthn: WebAuthnConfig; + /** Challenge validity duration in ms — core stamps ChallengeRecord.expiresAt from it */ + challengeTtl: number; }; /* Wire types — WebAuthn JSON for transport between browser and server. @@ -382,13 +384,13 @@ export type ValidateRegistrationTokenResult = Result< >; /** Success data is the WebAuthn creation options */ -export type RegistrationOptionsResult = Result< +export type CreateRegistrationOptionsResult = Result< PublicKeyCredentialCreationOptionsJSON, "invalid_token" >; /** Success data is the WebAuthn request options */ -export type AuthenticationOptionsResult = Result< +export type CreateAuthenticationOptionsResult = Result< PublicKeyCredentialRequestOptionsJSON, never >; @@ -414,15 +416,15 @@ export type PasskeyNamespace = { validateRegistrationToken: (args: { registrationToken: string; }) => Promise; - registrationOptions: (args: { + createRegistrationOptions: (args: { registrationToken: string; - }) => Promise; + }) => Promise; /** Verifies and stores the credential. Does not create a session — call session.create. */ verifyRegistration: (args: { registrationToken: string; credential: RegistrationCredential; }) => Promise; - authenticationOptions: () => Promise; + createAuthenticationOptions: () => Promise; /** Verifies the assertion against the stored credential. Does not create a session — call session.create. */ verifyAuthentication: (args: { credential: AuthenticationCredential; diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index 0d3edcc..4670dbd 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -13,7 +13,6 @@ export const auth = makeAuth({ codec: { encode: async () => "", decode: async () => null, - ttl: 0, }, transport: { get: () => null, @@ -34,8 +33,8 @@ const otpAuth = auth.withOtp({ }, delivery: { send: async () => undefined, - ttl: 0, }, + ttl: 0, }); otpAuth.otp.request({ identifier: "test@example.com" }); @@ -48,7 +47,7 @@ export const passkey = auth.withPasskey({ list: async () => [], setCounter: async () => undefined, }, - challenges: { + challengeStorage: { store: async () => undefined, take: async () => null, }, @@ -60,11 +59,11 @@ export const passkey = auth.withPasskey({ rpId: "localhost", rpName: "Spike", allowedOrigins: [], - challengeTtl: 0, }, + challengeTtl: 0, }); -passkey.passkey.authenticationOptions(); +passkey.passkey.createAuthenticationOptions(); // Sign in is two explicit calls — verification never creates sessions: // const verified = await auth.passkey.verifyAuthentication({ credential }); @@ -78,8 +77,8 @@ export const passkeyAndOtp = passkey.withOtp({ }, delivery: { send: async () => undefined, - ttl: 0, }, + ttl: 0, }); passkeyAndOtp.otp.verify({ identifier: "test@example.com", otp: "123456" }); From d28ef7c8245fc492506bccfd25095330060e852b Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Fri, 17 Jul 2026 17:56:54 -0700 Subject: [PATCH 18/49] Spec --- AGENTS.md | 6 ++--- SPEC.md | 2 ++ packages/auth/src/spike/contracts.ts | 32 +++++++++------------------ packages/auth/src/spike/mechanisms.ts | 23 +++++++++++++++++++ 4 files changed, 38 insertions(+), 25 deletions(-) create mode 100644 packages/auth/src/spike/mechanisms.ts diff --git a/AGENTS.md b/AGENTS.md index e4e6ee8..a7f74fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,13 +6,13 @@ Read before working, in order: -1. `packages/auth/src/spike/contracts.ts` — the typed API spec, current source of intent. Wins over the README and the code while the API is finalized. +1. `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` — the typed API spec (contracts and mechanisms layers), current source of intent. Wins over the README and the code while the API is finalized. 2. `TODO.md` — the work queue (gitignored, local to this machine). 3. `SPEC.md` — rationale and dated decision blocks. Search it before proposing design changes; don't bulk-read (~700 lines). ## Development workflow -- Source of intent: `packages/auth/src/spike/contracts.ts` while the API is finalized; `packages/auth/README.md` resumes as the contract at promotion, rewritten from the settled contracts +- Source of intent: `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` while the API is finalized; `packages/auth/README.md` resumes as the contract at promotion, rewritten from the settled contracts - Order of work: types (signatures) → tests → implementation. Signatures make the contract concrete, tests encode it, implementation satisfies it. - When designing an adapter interface, ask what the lazy implementation does — it must fail closed (deny access), never open - Type files are split by layer (see SPEC.md "Adapter layering"): contracts, mechanisms, bindings, configs — file organization mirrors the layers @@ -27,7 +27,7 @@ Roles (decided 2026-07-16): README = contract, SPEC = rationale, TODO = queue. Now: -- `packages/auth/src/spike/contracts.ts` — the typed API spec: source of intent, wins over README and code while the API is finalized. +- `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` — the typed API spec, split by layer: source of intent, wins over README and code while the API is finalized. - `packages/auth/README.md` — the contract prose: stale during API finalization, rewritten from contracts.ts at promotion. - `SPEC.md` — rationale and dated decision record. Partially stale; never treat it as the contract. - `TODO.md` — work queue from the 2026-07 repo review. Deliberately uncommitted. diff --git a/SPEC.md b/SPEC.md index ee6f57d..358e213 100644 --- a/SPEC.md +++ b/SPEC.md @@ -298,6 +298,8 @@ Storage is split by concern: `OtpStorage`, `SessionStorage`, `CredentialStorage` > **Decided (2026-07-17): User owns the database; the API is request-scoped.** The library never dictates schema — record types (`SessionRecord`, `OtpRecord`, `CredentialRecord`) are exchange shapes at the adapter boundary, mapped to and from the user's own representation; reads must return records equivalent to what writes received, nothing more. Two placement rules follow. A namespace method exists only for request-scoped protocol operations — state named by the token riding the current request: `session.end` stays because only the library can name "the current session" and the transport pair is its own (create sets the cookie, end clears it). An adapter method exists only where core calls it during a protocol operation. Everything identifier-keyed — sign out everywhere, list sessions, revoke one session, remove a passkey — is plain CRUD on tables the user owns, done storage-direct: correct by construction, and deletion converges within the codec ttl on every token format (immediately with opaque). Accordingly `session.endAll`, `SessionStorage.deleteAll`, and `CredentialStorage.delete` are cut — superseding the shipped `signOutAll`/`deleteAll` noted in the roadmap. The sessionId-keyed `deleteAll` also failed open: a missing current-session record made it a silent no-op exactly when compromise response matters. The README gets a management-recipes table at promotion; management surfaces on convenience adapters are a later layer's question, tabled. +> **Decided (2026-07-17): TTLs — unit policy on unit configs; mechanism TTLs in mechanism factories, never on the SPI.** Core stamps every record deadline (`expiresAt` on session, otp, and challenge records) from unit config (`MakeAuthConfig.ttl`, `WithOtpConfig.ttl`, `WithPasskeyConfig.challengeTtl`). Token TTLs are mechanism-private: only self-contained codecs have a revocation window, so the number lives in the codec factory (`sessionHmac({ secret, ttl })`) — `SessionCodec.ttl` is removed from the SPI, and an opaque user configures no dead knob; the registration-token window is likewise codec-private. `TokenStatus.expiresAt` is the trust horizon — how long the carried record may be trusted without consulting storage: self-contained tokens embed it, lookup codecs report now (per-decode trust, `expired` never true — the zero-length revocation window that is opaque's known trade-off). Encode's directive stays `token: { expiresAt: Date | null }`: null = mint at the codec's own horizon, a Date = preserve it (sliding refresh) — that null branch is the seam that keeps horizon policy inside the only component that has one. Pressure-tested against splitting the codec into self-contained and lookup interfaces: rejected — it forks core into two algorithms (variation belongs at the edges; core stays frozen) and closes the taxonomy to hybrids such as lookup-with-short-cache; if lookup boilerplate ever matters, `makeLookupCodec({ lookup })` is a mechanisms-layer wrapper and the contract never moves. Accepted cost: a delivery template rendering "expires in N minutes" duplicates the otp ttl (config + template) — policy stays off the SPI. + > **Decided (2026-07-16): One attempt per otp.** A wrong otp consumes it — the user starts over with a fresh request. No attempt budgets, no re-store logic; every failure path fails closed. The typo cost is one email round-trip; acceptable for v1, revisit only on observed user friction. Per-IP rate limiting stays with the app; per-identifier cooldown is mechanism-layer (decided 2026-07-17, below). > **Decided (2026-07-17): Rate limiting splits by who holds the state.** A rate limit is a decision over state and is enforced where that state lives, atomically — or it isn't real. The library owns exactly one relevant state, the otp record, so it enforces exactly one limit: per-identifier issuance frequency. `OtpStorage.store` may refuse (returns `false`), `requestOtp` fails with `rate_limited` and sends nothing, and the cooldown knob lives on `makeOtpStorage` (required field, `0` = explicitly off), enforced by an atomic conditional-put `store` primitive — the same one-word obligation as `take`. Per-IP/volume/bot defense stays with the app (it holds the request); sender reputation stays with the sending service (it holds cross-app outcomes). Full vector map and deployment shapes: THREAT-MODEL.md. Supersedes the blanket "rate limiting (infrastructure-layer concern)" exclusion. diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 6b827c1..a3339c5 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -78,6 +78,7 @@ export type Result = * trust is per-decode, so expired is never true. */ export type TokenStatus = { + /** End of the trust horizon — the carried record is trusted without a storage check until this */ expiresAt: Date; /** expiresAt < now, computed by the codec — the codec owns the token clock */ expired: boolean; @@ -96,7 +97,7 @@ export type TokenStatus = { export type SessionRecord = { sessionId: string; userId: string; - /** null = never expires */ + /** Session expiry — slides on activity; null = never expires */ expiresAt: Date | null; }; @@ -158,6 +159,7 @@ export type SessionNamespace = { * clients. */ create: (args: { userId: string }) => Promise>; + /** The current session's user; null when signed out */ get: () => Promise<{ userId: string } | null>; /** Ends the current session (signs the user out) */ end: () => Promise>; @@ -172,6 +174,7 @@ export type OtpRecord = { /** Identifier (email address, phone number, etc.) */ identifier: string; otp: string; + /** Stamped by core from WithOtpConfig.ttl */ expiresAt: Date; }; @@ -189,22 +192,6 @@ export type OtpStorage = { verify: (identifier: string, otp: string) => Promise; }; -/** - * Input for makeOtpStorage: otp storage as two primitives. take must be - * atomic — fetch and delete in one operation (e.g. DELETE … RETURNING, - * GETDEL). - */ -export type MakeOtpStorageConfig = { - store: (record: OtpRecord) => Promise; - /** Atomic fetch-and-delete. Unknown identifier returns null. */ - take: (identifier: string) => Promise; -}; - -/** Builds a correct OtpStorage (expiry, comparison, one-time use) from store/take */ -export declare function makeOtpStorage( - config: MakeOtpStorageConfig, -): OtpStorage; - /** OTP delivery adapter (email, SMS, console) */ export type OtpDelivery = { send: (identifier: string, otp: string) => Promise; @@ -240,6 +227,7 @@ export type CredentialRecord = { credentialId: string; userId: string; publicKey: Uint8Array; + /** WebAuthn signature counter (clone detection) */ counter: number; /** null = the client reported no transport hints */ transports: AuthenticatorTransport[] | null; @@ -260,6 +248,7 @@ export type ChallengeRecord = { challenge: string; /** Set for registration ceremonies, null for authentication */ userId: string | null; + /** Stamped by core from WithPasskeyConfig.challengeTtl */ expiresAt: Date; }; @@ -270,13 +259,10 @@ export type ChallengeStorage = { take: (challenge: string) => Promise; }; -/** - * A grant to register a passkey: the user it belongs to, and the identifier - * shown in the passkey picker (user.name; null for identifier-less sign-up - * in passkey-only apps). - */ +/** A grant to register a passkey */ export type RegistrationGrant = { userId: string; + /** Shown in the passkey picker (user.name); null for identifier-less sign-up (passkey-only apps) */ identifier: string | null; }; @@ -300,7 +286,9 @@ export type RegistrationCodec = { /** WebAuthn protocol identity — who the relying party is and which origins may speak for it */ export type WebAuthnConfig = { + /** Relying party id — the registrable domain passkeys are bound to */ rpId: string; + /** Human-readable app name shown by authenticators */ rpName: string; /** * Exact allowed origins, scheme + host + port — e.g. ["https://app.example.com"]. diff --git a/packages/auth/src/spike/mechanisms.ts b/packages/auth/src/spike/mechanisms.ts new file mode 100644 index 0000000..9de682a --- /dev/null +++ b/packages/auth/src/spike/mechanisms.ts @@ -0,0 +1,23 @@ +/** + * ΛUTH mechanisms — the mechanisms-layer spec: adapter logic shipped by the + * library, environment-free. Factories here build correct adapters from + * atomic primitives. Source of intent while the API is finalized, alongside + * contracts.ts. + */ +import type { OtpRecord, OtpStorage } from "./contracts"; + +/** + * Input for makeOtpStorage: otp storage as two primitives. take must be + * atomic — fetch and delete in one operation (e.g. DELETE … RETURNING, + * GETDEL). + */ +export type MakeOtpStorageConfig = { + store: (record: OtpRecord) => Promise; + /** Atomic fetch-and-delete. Unknown identifier returns null. */ + take: (identifier: string) => Promise; +}; + +/** Builds a correct OtpStorage (expiry, comparison, one-time use) from store/take */ +export declare function makeOtpStorage( + config: MakeOtpStorageConfig, +): OtpStorage; From b4ab58650c3c9551bd6b0ff116d388ab9d71167b Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 13:31:31 -0700 Subject: [PATCH 19/49] Update contracts.ts --- packages/auth/src/spike/contracts.ts | 65 +++------------------------- 1 file changed, 6 insertions(+), 59 deletions(-) diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index a3339c5..8498e08 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -2,7 +2,7 @@ * ΛUTH contracts — the typed API spec. * * Everything user code touches: adapter interfaces, config shapes, method - * namespaces, auth shapes, and factory signatures. Source of intent while + * namespaces, auth shapes, and the factory signature. Source of intent while * the API is finalized. Verified by contracts-typecheck.ts and exercised by * playground.ts. */ @@ -19,7 +19,7 @@ * - *Decoded — what decode returns: what the token carries (record, grant) plus its TokenStatus * - *Config — input to a factory or builder step, named for its consumer * - *Namespace — the methods a builder step adds; *Result — a command's envelope - * - *Credential / *JSON — WebAuthn wire shapes mirroring the browser API + * - *JSON — WebAuthn wire shapes, ambient from lib.dom; never redeclared here * * Adapter roles: * - Storage — persistence the user owns @@ -40,7 +40,7 @@ * - validate — repeatable check, consumes nothing * * Field rules: - * - absence is null, never undefined; nullable, never optional (wire types excepted) + * - absence is null, never undefined; nullable, never optional * - expiry: expiresAt dates and expired flags, scoped by their object * - ttl: a duration in ms — unit policy on unit configs; mechanism TTLs live in their factories, off the SPI * - API methods take one args object; adapter methods take positional args @@ -148,6 +148,7 @@ export type MakeAuthConfig = { transport: SessionTransport; /** Session TTL in ms (Infinity = forever). Inactivity timeout with sliding refresh. */ ttl: number; + /** Log expected auth failures to the console (development aid) */ debug: boolean; }; @@ -308,60 +309,6 @@ export type WithPasskeyConfig = { challengeTtl: number; }; -/* Wire types — WebAuthn JSON for transport between browser and server. - * These mirror the browser API; optionality follows the WebAuthn spec. */ - -export type PublicKeyCredentialCreationOptionsJSON = { - challenge: string; - rp: { name: string; id: string }; - user: { id: string; name: string; displayName: string }; - pubKeyCredParams: { type: "public-key"; alg: number }[]; - timeout?: number; - attestation?: AttestationConveyancePreference; - excludeCredentials?: { id: string; type: "public-key" }[]; - authenticatorSelection?: AuthenticatorSelectionCriteria; - // extensions omitted — add when PRF support is implemented -}; - -export type PublicKeyCredentialRequestOptionsJSON = { - challenge: string; - rpId: string; - timeout?: number; - allowCredentials?: { id: string; type: "public-key" }[]; - userVerification?: UserVerificationRequirement; - // extensions omitted — add when PRF support is implemented -}; - -export type RegistrationCredential = { - id: string; - rawId: string; - type: "public-key"; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: AuthenticatorTransport[] | undefined; - }; - authenticatorAttachment?: AuthenticatorAttachment | undefined; - clientExtensionResults: AuthenticationExtensionsClientOutputs; -}; - -export type AuthenticationCredential = { - id: string; - rawId: string; - type: "public-key"; - response: { - clientDataJSON: string; - authenticatorData: string; - signature: string; - userHandle?: string | undefined; - }; - authenticatorAttachment?: AuthenticatorAttachment | undefined; - clientExtensionResults: AuthenticationExtensionsClientOutputs; -}; - -/* Results — verification returns the verified userId; sessions are created - * explicitly via session.create. */ - /** Success data is the registration token */ export type CreateRegistrationTokenResult = Result; @@ -410,12 +357,12 @@ export type PasskeyNamespace = { /** Verifies and stores the credential. Does not create a session — call session.create. */ verifyRegistration: (args: { registrationToken: string; - credential: RegistrationCredential; + credential: RegistrationResponseJSON; }) => Promise; createAuthenticationOptions: () => Promise; /** Verifies the assertion against the stored credential. Does not create a session — call session.create. */ verifyAuthentication: (args: { - credential: AuthenticationCredential; + credential: AuthenticationResponseJSON; }) => Promise; }; From ae4d07bfd1380db7ce863e715be5b2a91d43b2a6 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 13:37:45 -0700 Subject: [PATCH 20/49] Update contracts.ts --- packages/auth/src/spike/contracts.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 8498e08..2ae38f6 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -46,7 +46,6 @@ * - API methods take one args object; adapter methods take positional args */ -/** Error codes for auth failures */ export type AuthErrorCode = | "invalid_otp" | "invalid_token" @@ -72,10 +71,10 @@ export type Result = | ([E] extends [never] ? never : { success: false; error: E }); /** - * The token's trust status, as read by decode. expiresAt is the trust - * horizon: how long the carried record may be trusted without consulting - * storage. Self-contained tokens embed it; lookup codecs report now — their - * trust is per-decode, so expired is never true. + * The token's trust status, as read by decode. The trust horizon bounds how + * long the carried record may be trusted without a storage check: + * self-contained tokens embed it; lookup codecs report now — their trust is + * per-decode, so expired is never true. */ export type TokenStatus = { /** End of the trust horizon — the carried record is trusted without a storage check until this */ @@ -85,7 +84,7 @@ export type TokenStatus = { }; /* ──────────────────────────────────────────────────────────────────────── - * Session — the core. Records → adapters → config → namespace → results. + * Session — the core. * ──────────────────────────────────────────────────────────────────────── */ /** @@ -137,7 +136,6 @@ export type SessionTransport = { get: () => string | null; /** Store token and return what goes in the response body */ set: (token: string) => string; - /** Clear the stored token */ clear: () => void; }; @@ -213,6 +211,7 @@ export type OtpNamespace = { * succeeded (enumeration safety). */ request: (args: { identifier: string }) => Promise>; + /** A wrong otp consumes it — the user starts over with a fresh request */ verify: (args: { identifier: string; otp: string; @@ -330,6 +329,7 @@ export type CreateAuthenticationOptionsResult = Result< never >; +/** Success data is the verified userId */ export type VerifyRegistrationResult = Result< { userId: string }, | "invalid_token" @@ -338,6 +338,7 @@ export type VerifyRegistrationResult = Result< | "verification_failed" >; +/** Success data is the verified userId */ export type VerifyAuthenticationResult = Result< { userId: string }, "credential_not_found" | "challenge_expired" | "verification_failed" @@ -399,4 +400,5 @@ export type AuthFull = { passkey: PasskeyNamespace; }; +/** The entry point — builds the session core; chain withOtp and withPasskey to add strategies */ export declare function makeAuth(config: MakeAuthConfig): AuthCore; From ea1c3aea449323d1e8e293e0932704f4712588e2 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 13:55:59 -0700 Subject: [PATCH 21/49] Deps --- bun.lock | 22 +++++++++---------- .../otp-memory/package.json | 2 +- .../otp-passkey-memory/package.json | 2 +- .../otp-passkey-strict-memory/package.json | 2 +- .../passkey-memory/package.json | 2 +- .../passkey-otp-memory/package.json | 2 +- examples/tmp/tanstack-start/package.json | 2 +- packages/auth/package.json | 2 +- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/bun.lock b/bun.lock index b84e4e3..d3179ad 100644 --- a/bun.lock +++ b/bun.lock @@ -57,7 +57,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -79,7 +79,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -101,7 +101,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -123,7 +123,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -145,7 +145,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -197,7 +197,7 @@ "version": "0.0.1", "devDependencies": { "@eslint/js": "^10.0.1", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "eslint": "^10.7.0", "globals": "^17.7.0", "neon-testing": "^3.0.0", @@ -513,11 +513,11 @@ "@tanstack/react-router": ["@tanstack/react-router@1.170.18", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.15", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ=="], - "@tanstack/react-start": ["@tanstack/react-start@1.168.28", "", { "dependencies": { "@tanstack/react-router": "1.170.18", "@tanstack/react-start-client": "1.168.16", "@tanstack/react-start-rsc": "0.1.27", "@tanstack/react-start-server": "1.167.22", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.14", "@tanstack/start-plugin-core": "1.171.20", "@tanstack/start-server-core": "1.169.17", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-UyJ/OYBw7x8MQclyXKlbg72qzSTK6Do4AugHcx0LoagHNg6tQgvF0l2HZqll2CrxKAlv2Ar8+0gEkHnFYxxI5Q=="], + "@tanstack/react-start": ["@tanstack/react-start@1.168.32", "", { "dependencies": { "@tanstack/react-router": "1.170.18", "@tanstack/react-start-client": "1.168.16", "@tanstack/react-start-rsc": "0.1.31", "@tanstack/react-start-server": "1.167.22", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.14", "@tanstack/start-plugin-core": "1.171.24", "@tanstack/start-server-core": "1.169.17", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-y1WXHo+jPfHxiuuN1m+br06IcriiBQnEWryBdbKdEOS5vw2PmnOj+Cgf1/YcGOqtSougScWFeE2rL1FXWvsLLg=="], "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.16", "", { "dependencies": { "@tanstack/react-router": "1.170.18", "@tanstack/router-core": "1.171.15", "@tanstack/start-client-core": "1.170.14" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-1OfHgy0wpHwe2tlB3FxMeA+IMX6Il/QAMf+8UdXuimReIc2Lz3BkMLBL38k4GIxBguX9sI8EMLO5jlTZ4e1olw=="], - "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.27", "", { "dependencies": { "@tanstack/react-router": "1.170.18", "@tanstack/router-core": "1.171.15", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.20", "@tanstack/start-server-core": "1.169.17", "@tanstack/start-storage-context": "1.167.17", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-4Gt8+XHRhcYJjfw1rTlkW5ZxRWcp6AmvPoBf1p7ysOSVlnfVitQBqgAAFNGy+d745vWfoz1/JWqyFBP8IVVYYg=="], + "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.31", "", { "dependencies": { "@tanstack/react-router": "1.170.18", "@tanstack/router-core": "1.171.15", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.14", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.24", "@tanstack/start-server-core": "1.169.17", "@tanstack/start-storage-context": "1.167.17", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-WxjkXYflq550vTNJpdPyMaPC+Vyh88L5wOL+SiDTjPMGne9ad7FZmoJxqfCFEv1e7HVKMH/mMoE8619TsTNVzQ=="], "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.22", "", { "dependencies": { "@tanstack/react-router": "1.170.18", "@tanstack/router-core": "1.171.15", "@tanstack/start-server-core": "1.169.17" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-eH2PeHuLfL3R5YzE9+y2FfcE4Ld1LNV2ZfrCNVPJMMJFt+9nXDaRHg9BsEmc+JkTAGzz3FKLyQEoWwpbG6Ehqg=="], @@ -525,9 +525,9 @@ "@tanstack/router-core": ["@tanstack/router-core@1.171.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA=="], - "@tanstack/router-generator": ["@tanstack/router-generator@1.167.19", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-db3AQGLinf8ZQ8AKMyxLVWwHIFZxtx+zLktvPXv/nFH/B793/Z7lKLl4dUWM3JpAluynDSVVKaTWQVTAgTYmVA=="], + "@tanstack/router-generator": ["@tanstack/router-generator@1.167.21", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-m3oXZyienj8owialdyoZ0txHQrnEx/Ra+D9kWtar5fC2cWZr5Pvxl86VY2mX5RRLC5QLKLeRGT1x4HV95wHVDQ=="], - "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.20", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-generator": "1.167.19", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-G5S63xDr2AxADtWP+0tQtJsduijT/Mk85hDh3Od4ct8UV0PHr2f/H1CGl72lYWLgcWRMn6bKJoQhz27QFIgLRQ=="], + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.23", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-generator": "1.167.21", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.18", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-0+PIcvnaAimFwjoEIeV3h7LKjzC8zNnp7pH2UamdKwQ9QlY99WU9V0Xl0zbM0i9hrUa/mKgWPDAzELmPUu5fMA=="], "@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="], @@ -535,7 +535,7 @@ "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="], - "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.20", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-generator": "1.167.19", "@tanstack/router-plugin": "1.168.20", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.17", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.4", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-s2n82ykGXGkDSoJwGZP85vC9Dv5vAN6fya5TCSm/cvUn+r/g30iDR1x+Ptizas3fIvcracSOOvRnma4RJ8Orww=="], + "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.24", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.15", "@tanstack/router-generator": "1.167.21", "@tanstack/router-plugin": "1.168.23", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.17", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.4", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-l/tm+T0ntXHeIzr9kJDTJ2IDNZC0yFazjkvbEVeZsDOrJ8F+HiZmY+tXYqI5/nDYkwxY0DVQr+kGsTRVb6y2Jw=="], "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.17", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/router-core": "1.171.15", "@tanstack/start-client-core": "1.170.14", "@tanstack/start-storage-context": "1.167.17", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.4" } }, "sha512-u0N+PHJhMHnzfnlXYI9F+A/qweDe3E2X0mfkORPGIEkNQgvS548RA9fjwvixR2en5b848CfpEqUzwFhm/tQ40Q=="], diff --git a/examples/tanstack-start-react/otp-memory/package.json b/examples/tanstack-start-react/otp-memory/package.json index c08a6fe..6b9cb87 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/otp-memory/package.json @@ -12,7 +12,7 @@ "@repo/auth-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", diff --git a/examples/tanstack-start-react/otp-passkey-memory/package.json b/examples/tanstack-start-react/otp-passkey-memory/package.json index ef9e190..aa9c02e 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-memory/package.json @@ -12,7 +12,7 @@ "@repo/auth-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json index 42975ab..0fdbc4c 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json @@ -12,7 +12,7 @@ "@repo/auth-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", diff --git a/examples/tanstack-start-react/passkey-memory/package.json b/examples/tanstack-start-react/passkey-memory/package.json index a704721..209da7e 100644 --- a/examples/tanstack-start-react/passkey-memory/package.json +++ b/examples/tanstack-start-react/passkey-memory/package.json @@ -12,7 +12,7 @@ "@repo/auth-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", diff --git a/examples/tanstack-start-react/passkey-otp-memory/package.json b/examples/tanstack-start-react/passkey-otp-memory/package.json index 3a4422b..7f15941 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/package.json +++ b/examples/tanstack-start-react/passkey-otp-memory/package.json @@ -12,7 +12,7 @@ "@repo/auth-react": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", diff --git a/examples/tmp/tanstack-start/package.json b/examples/tmp/tanstack-start/package.json index 862a52d..4619618 100644 --- a/examples/tmp/tanstack-start/package.json +++ b/examples/tmp/tanstack-start/package.json @@ -11,7 +11,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3" diff --git a/packages/auth/package.json b/packages/auth/package.json index fef610a..de5fa42 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -29,7 +29,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "eslint": "^10.7.0", "globals": "^17.7.0", "neon-testing": "^3.0.0", From b61d116aac01ce8784d4238fd34bf648a9ee4237 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 13:59:42 -0700 Subject: [PATCH 22/49] Spec --- .../auth/src/spike/contracts-typecheck.ts | 16 ++++++------ packages/auth/src/spike/contracts.ts | 26 +++++++++---------- packages/auth/src/spike/mechanisms.ts | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/auth/src/spike/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts index 1bc0d2c..cd110b0 100644 --- a/packages/auth/src/spike/contracts-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -9,9 +9,9 @@ import type { MakeAuthConfig, WithOtpConfig, WithPasskeyConfig, - AuthCore, - AuthCoreOtp, - AuthCorePasskey, + Auth, + AuthOtp, + AuthPasskey, AuthFull, } from "./contracts"; import { makeAuth } from "./contracts"; @@ -25,9 +25,9 @@ function expectType(value: T): T { } /* Methods follow the chain — the four shapes */ -expectType(makeAuth(session)); -expectType(makeAuth(session).withOtp(otp)); -expectType(makeAuth(session).withPasskey(passkey)); +expectType(makeAuth(session)); +expectType(makeAuth(session).withOtp(otp)); +expectType(makeAuth(session).withPasskey(passkey)); expectType(makeAuth(session).withOtp(otp).withPasskey(passkey)); /* Chain order doesn't matter */ @@ -91,7 +91,7 @@ if (registrationResult.success) { /* Commands without failure modes collapse — the envelope needs no narrowing */ -declare const created: Awaited>; +declare const created: Awaited>; void created.success; void created.data; @@ -100,7 +100,7 @@ void created.token; /* Void commands drop the data field entirely */ -declare const ended: Awaited>; +declare const ended: Awaited>; void ended.success; // @ts-expect-error void commands carry no data field void ended.data; diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 2ae38f6..55f05bb 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -168,7 +168,7 @@ export type SessionNamespace = { * OTP — identity verification, optionally authentication. * ──────────────────────────────────────────────────────────────────────── */ -/** Otp record — the shape exchanged with otp storage, not a stored schema */ +/** OTP record — the shape exchanged with OTP storage, not a stored schema */ export type OtpRecord = { /** Identifier (email address, phone number, etc.) */ identifier: string; @@ -187,7 +187,7 @@ export type OtpRecord = { */ export type OtpStorage = { store: (record: OtpRecord) => Promise; - /** One attempt per otp: a wrong guess consumes it */ + /** One attempt per OTP: a wrong guess consumes it */ verify: (identifier: string, otp: string) => Promise; }; @@ -200,18 +200,18 @@ export type OtpDelivery = { export type WithOtpConfig = { storage: OtpStorage; delivery: OtpDelivery; - /** Otp validity duration in ms — core stamps OtpRecord.expiresAt from it */ + /** OTP validity duration in ms — core stamps OtpRecord.expiresAt from it */ ttl: number; }; -/** Otp methods — added as the `otp` namespace by withOtp */ +/** OTP methods — added as the `otp` namespace by withOtp */ export type OtpNamespace = { /** - * Sends an otp to the identifier. Never reveals whether delivery + * Sends an OTP to the identifier. Never reveals whether delivery * succeeded (enumeration safety). */ request: (args: { identifier: string }) => Promise>; - /** A wrong otp consumes it — the user starts over with a fresh request */ + /** A wrong OTP consumes it — the user starts over with a fresh request */ verify: (args: { identifier: string; otp: string; @@ -373,21 +373,21 @@ export type PasskeyNamespace = { * ──────────────────────────────────────────────────────────────────────── */ /** Session-only auth — both strategies still available to chain */ -export type AuthCore = { +export type Auth = { session: SessionNamespace; - withOtp: (config: WithOtpConfig) => AuthCoreOtp; - withPasskey: (config: WithPasskeyConfig) => AuthCorePasskey; + withOtp: (config: WithOtpConfig) => AuthOtp; + withPasskey: (config: WithPasskeyConfig) => AuthPasskey; }; -/** Sessions + otp — only withPasskey remains */ -export type AuthCoreOtp = { +/** Sessions + OTP — only withPasskey remains */ +export type AuthOtp = { session: SessionNamespace; otp: OtpNamespace; withPasskey: (config: WithPasskeyConfig) => AuthFull; }; /** Sessions + passkeys — only withOtp remains */ -export type AuthCorePasskey = { +export type AuthPasskey = { session: SessionNamespace; passkey: PasskeyNamespace; withOtp: (config: WithOtpConfig) => AuthFull; @@ -401,4 +401,4 @@ export type AuthFull = { }; /** The entry point — builds the session core; chain withOtp and withPasskey to add strategies */ -export declare function makeAuth(config: MakeAuthConfig): AuthCore; +export declare function makeAuth(config: MakeAuthConfig): Auth; diff --git a/packages/auth/src/spike/mechanisms.ts b/packages/auth/src/spike/mechanisms.ts index 9de682a..81a21cf 100644 --- a/packages/auth/src/spike/mechanisms.ts +++ b/packages/auth/src/spike/mechanisms.ts @@ -7,7 +7,7 @@ import type { OtpRecord, OtpStorage } from "./contracts"; /** - * Input for makeOtpStorage: otp storage as two primitives. take must be + * Input for makeOtpStorage: OTP storage as two primitives. take must be * atomic — fetch and delete in one operation (e.g. DELETE … RETURNING, * GETDEL). */ From 746678a9dce95a21e4225e378f34b06cccfd9036 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 14:18:36 -0700 Subject: [PATCH 23/49] Update AGENTS.md --- AGENTS.md | 60 ++++++++++++++++++++++++------------------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7f74fd..3e2380b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,40 @@ # Agent guidelines +## Commands + - Use `bun run check` after edits to type check all workspaces ## Session start Read before working, in order: -1. `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` — the typed API spec (contracts and mechanisms layers), current source of intent. Wins over the README and the code while the API is finalized. -2. `TODO.md` — the work queue (gitignored, local to this machine). -3. `SPEC.md` — rationale and dated decision blocks. Search it before proposing design changes; don't bulk-read (~700 lines). - -## Development workflow - -- Source of intent: `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` while the API is finalized; `packages/auth/README.md` resumes as the contract at promotion, rewritten from the settled contracts -- Order of work: types (signatures) → tests → implementation. Signatures make the contract concrete, tests encode it, implementation satisfies it. -- When designing an adapter interface, ask what the lazy implementation does — it must fail closed (deny access), never open -- Type files are split by layer (see SPEC.md "Adapter layering"): contracts, mechanisms, bindings, configs — file organization mirrors the layers -- Generate code + tests together in small chunks -- Human reviews for: unnecessary complexity, over-engineering, maintainability -- Iterate until tight -- Tests become the true spec — the README is the contract they encode +1. `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` +2. `TODO.md` +3. `SPEC.md` ## Documentation map -Roles (decided 2026-07-16): README = contract, SPEC = rationale, TODO = queue. - -Now: - -- `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` — the typed API spec, split by layer: source of intent, wins over README and code while the API is finalized. -- `packages/auth/README.md` — the contract prose: stale during API finalization, rewritten from contracts.ts at promotion. +- `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` — the contract: the typed API spec. Wins over README and code. - `SPEC.md` — rationale and dated decision record. Partially stale; never treat it as the contract. -- `TODO.md` — work queue from the 2026-07 repo review. Deliberately uncommitted. +- `TODO.md` — the work queue. Gitignored, local to this machine. Never delete items: mark `[x]` with a resolution note; add new items for follow-on work. +- `packages/auth/README.md` — stale. At promotion it is rewritten from the settled spike and becomes the contract. -Destination (hold until the contract is finished and proven by the builder factory implementation): +At promotion (only after the implementation has proven the contract): - `/README.md` (root, new) — rationale: philosophy, positioning, security model, dated decisions, absorbed from `SPEC.md` - `packages/auth/README.md` — contract plus behavioral docs (TTL/session mechanics move here or to a linked docs file) - `SPEC.md` — deleted once fully dissolved - Queue moves to issues, or `TODO.md` remains -Don't start the doc reorganization ahead of that milestone — SPEC content can't be sorted into rationale vs behavioral docs until the contract settles. +## Development workflow + +- Order of work: types → tests → implementation, in small chunks — one unit at a time +- Design adapter interfaces so the laziest implementation is safe: a no-op adapter may only deny access (fail closed), never grant it. If a lazy adapter could grant access, move that obligation into core or a shipped mechanism. +- Type files are split by layer; file organization mirrors the layers: + - Contracts — the adapter interfaces, the product. Semantic, never mechanical; core runs on anything satisfying them. + - Mechanisms — logic shipped as adapters, environment-free. No framework imports, ever. + - Bindings — environment glue. Zero logic — an if-statement means the logic moves down into a mechanism. + - Configs — pre-composed config values. Data only — composition plus literals, no functions of their own. ## Quality over speed @@ -49,7 +44,6 @@ This is security-critical code. - Don't add edge cases that weren't asked for - Don't over-abstract — abstractions must earn their keep - Don't add "just in case" code -- Match the style and conventions already in the codebase - Every test should be necessary — don't test unlikely edge cases - Code should be simple enough to explain in a security audit @@ -60,20 +54,17 @@ This is security-critical code. - ESM only, no CommonJS - TypeScript only, no transpile to JS - Factories should be prefixed with `make` (e.g., `makeAuth`, `makeMemoryAdapters`) -- No optional parameters and no defaults — anywhere, API or config (decided 2026-07-17). Noisier but more distinct. May be reconsidered later; not now. +- No optional parameters and no defaults — anywhere, API or config - Never export local symbols -- Use TS/JS style comments -- Comments (decided 2026-07-17): doc blocks (`/** */`, prose only — never `@param`/`@returns` tags, types carry the signatures) in contract/spec files are spec text — held to completeness, state every constraint the type can't show. Everywhere else comments are held to necessity — only what code can't express, never narration. Tone: professional library API docs — never design rationale, internal notes, or decision history; those belong in SPEC.md. +- Doc blocks are `/** */`, prose only — never `@param`/`@returns` tags, types carry the signatures +- In contract/spec files doc blocks are spec text — held to completeness, state every constraint the type can't show +- Everywhere else comments are held to necessity — only what code can't express, never narration +- Comment tone: professional library API docs — never design rationale, internal notes, or decision history; those belong in SPEC.md ## Error handling -The mental model (decided 2026-07-17): three channels, one rule per kind of function. +How methods return (commands, queries, adapters, throws) is specified by the `Result` doc block in the contracts. -- Commands (public API methods that do something) return `Result` with a narrowed per-method error union — expected failures are values, including malformed client input. `E = never` collapses the type to an always-success envelope, so no dead error branches. -- Queries (public API lookups) return the value or `null` — absence is not failure. Currently only `session.get`. -- Adapter interfaces (SPI) return plain values/null — the envelope is how the library speaks, not how it listens. -- Infrastructure failures throw, everywhere — they are breakage, not outcomes. Throws are for the error monitor; Results are for the user. -- The library never throws as flow control — no auth flow requires try/catch. Every shipped wire layer converts throws to error envelopes (500 + `internal_error`). - Use `result.ok()` for success, `result.fail()` for expected failures - Invariants: Never use type assertions (`as`). Throw instead — surfaces bugs immediately. Comment each invariant `Invariant: reasoning` - Must prove the error with a test before adding try-catch @@ -87,9 +78,10 @@ The mental model (decided 2026-07-17): three channels, one rule per kind of func ## Prose style - Use sentence case, never title case -- Don't use the word "code" with regards to OTP (use "otp") +- OTP: uppercase in prose, `Otp*`/`otp` in identifiers; never call it a "code" ## Code review instructions - Look for dead code - Look for useless assertions in tests +- Look for unnecessary complexity and over-engineering From ea480c69199e7fbcedccf7bebdec50379a5ad660 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 14:34:45 -0700 Subject: [PATCH 24/49] Update mechanisms.ts --- packages/auth/src/spike/mechanisms.ts | 36 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/auth/src/spike/mechanisms.ts b/packages/auth/src/spike/mechanisms.ts index 81a21cf..04cf490 100644 --- a/packages/auth/src/spike/mechanisms.ts +++ b/packages/auth/src/spike/mechanisms.ts @@ -1,10 +1,14 @@ /** * ΛUTH mechanisms — the mechanisms-layer spec: adapter logic shipped by the - * library, environment-free. Factories here build correct adapters from - * atomic primitives. Source of intent while the API is finalized, alongside - * contracts.ts. + * library, environment-free. Factories here build correct adapters. Source + * of intent while the API is finalized, alongside contracts.ts. */ -import type { OtpRecord, OtpStorage } from "./contracts"; +import type { + OtpRecord, + OtpStorage, + RegistrationCodec, + SessionCodec, +} from "./contracts"; /** * Input for makeOtpStorage: OTP storage as two primitives. take must be @@ -21,3 +25,27 @@ export type MakeOtpStorageConfig = { export declare function makeOtpStorage( config: MakeOtpStorageConfig, ): OtpStorage; + +/** Input for makeSessionHmacCodec */ +export type MakeSessionHmacCodecConfig = { + secret: string; + /** Token TTL in ms — the trust horizon minted on fresh encodes */ + ttl: number; +}; + +/** Builds a self-contained session codec: an HMAC-signed token carrying the record */ +export declare function makeSessionHmacCodec( + config: MakeSessionHmacCodecConfig, +): SessionCodec; + +/** Input for makeRegistrationHmacCodec */ +export type MakeRegistrationHmacCodecConfig = { + secret: string; + /** Registration token validity in ms */ + ttl: number; +}; + +/** Builds a self-contained registration codec: an HMAC-signed token carrying the grant */ +export declare function makeRegistrationHmacCodec( + config: MakeRegistrationHmacCodecConfig, +): RegistrationCodec; From edc4f5236692e3d7229255ce76ecddc4a92df6bd Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sun, 19 Jul 2026 15:55:07 -0700 Subject: [PATCH 25/49] Update AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3e2380b..ce8f023 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ This is security-critical code. - Don't add edge cases that weren't asked for - Don't over-abstract — abstractions must earn their keep - Don't add "just in case" code -- Every test should be necessary — don't test unlikely edge cases +- Every test should be necessary - Code should be simple enough to explain in a security audit ## Code style From 6e81cc36491140a49fa1fe5e9f84c3d2e197dd81 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 10:28:08 -0700 Subject: [PATCH 26/49] Spec --- .../auth/src/spike/contracts-typecheck.ts | 67 +++++++++---------- packages/auth/src/spike/contracts.ts | 22 ++++-- packages/auth/src/spike/playground.ts | 46 +++++++------ 3 files changed, 72 insertions(+), 63 deletions(-) diff --git a/packages/auth/src/spike/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts index cd110b0..23705ce 100644 --- a/packages/auth/src/spike/contracts-typecheck.ts +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -16,7 +16,7 @@ import type { } from "./contracts"; import { makeAuth } from "./contracts"; -declare const session: MakeAuthConfig; +declare const config: MakeAuthConfig; declare const otp: WithOtpConfig; declare const passkey: WithPasskeyConfig; @@ -25,47 +25,47 @@ function expectType(value: T): T { } /* Methods follow the chain — the four shapes */ -expectType(makeAuth(session)); -expectType(makeAuth(session).withOtp(otp)); -expectType(makeAuth(session).withPasskey(passkey)); -expectType(makeAuth(session).withOtp(otp).withPasskey(passkey)); +expectType(makeAuth(config)); +expectType(makeAuth(config).withOtp(otp)); +expectType(makeAuth(config).withPasskey(passkey)); +expectType(makeAuth(config).withOtp(otp).withPasskey(passkey)); /* Chain order doesn't matter */ -expectType(makeAuth(session).withPasskey(passkey).withOtp(otp)); +expectType(makeAuth(config).withPasskey(passkey).withOtp(otp)); /* The session namespace is present at every step */ -void makeAuth(session).session.get; -void makeAuth(session).withOtp(otp).session.end; -void makeAuth(session).withOtp(otp).withPasskey(passkey).session.create; +void makeAuth(config).session.get; +void makeAuth(config).withOtp(otp).session.end; +void makeAuth(config).withOtp(otp).withPasskey(passkey).session.create; /* Strategy namespaces only exist after their step */ -void makeAuth(session).withOtp(otp).otp.verify; -void makeAuth(session).withPasskey(passkey).passkey.verifyAuthentication; -void makeAuth(session).withOtp(otp).withPasskey(passkey).passkey +void makeAuth(config).withOtp(otp).otp.verify; +void makeAuth(config).withPasskey(passkey).passkey.verifyAuthentication; +void makeAuth(config).withOtp(otp).withPasskey(passkey).passkey .createRegistrationOptions; // @ts-expect-error otp namespace does not exist before withOtp -void makeAuth(session).otp; +void makeAuth(config).otp; // @ts-expect-error otp namespace does not exist on a passkey-only instance -void makeAuth(session).withPasskey(passkey).otp; +void makeAuth(config).withPasskey(passkey).otp; // @ts-expect-error passkey namespace does not exist on an otp-only instance -void makeAuth(session).withOtp(otp).passkey; +void makeAuth(config).withOtp(otp).passkey; /* All methods live in namespaces — the root holds no methods */ // @ts-expect-error getSession is not a root method — it is auth.session.get -void makeAuth(session).getSession; +void makeAuth(config).getSession; // @ts-expect-error createSession is not a root method — it is auth.session.create -void makeAuth(session).withOtp(otp).createSession; +void makeAuth(config).withOtp(otp).createSession; // @ts-expect-error verifyOtp is not a root method — it is auth.otp.verify -void makeAuth(session).withOtp(otp).verifyOtp; +void makeAuth(config).withOtp(otp).verifyOtp; // @ts-expect-error verifyAuthentication is not a root method — it is auth.passkey.verifyAuthentication -void makeAuth(session).withPasskey(passkey).verifyAuthentication; +void makeAuth(config).withPasskey(passkey).verifyAuthentication; /* Passkey verification is pure — it returns the userId, never a session */ @@ -123,41 +123,36 @@ void otpError; /* Duplicate steps are type errors — with* removes itself from the chain */ // @ts-expect-error withOtp cannot be chained twice -void makeAuth(session).withOtp(otp).withOtp(otp); +void makeAuth(config).withOtp(otp).withOtp(otp); // @ts-expect-error withPasskey cannot be chained twice -void makeAuth(session).withPasskey(passkey).withPasskey(passkey); +void makeAuth(config).withPasskey(passkey).withPasskey(passkey); // @ts-expect-error nothing left to chain after both strategies -void makeAuth(session).withOtp(otp).withPasskey(passkey).withOtp(otp); +void makeAuth(config).withOtp(otp).withPasskey(passkey).withOtp(otp); /* Unknown config keys are rejected */ -// @ts-expect-error unknown key in session config -void makeAuth({ ...session, unknown: true }); +// @ts-expect-error unknown key in makeAuth config +void makeAuth({ ...config, unknown: true }); // @ts-expect-error unknown key in otp config -void makeAuth(session).withOtp({ ...otp, unknown: true }); +void makeAuth(config).withOtp({ ...otp, unknown: true }); // @ts-expect-error unknown key in passkey config -void makeAuth(session).withPasskey({ ...passkey, unknown: true }); +void makeAuth(config).withPasskey({ ...passkey, unknown: true }); /* Every field is required */ // @ts-expect-error delivery is required in otp config -void makeAuth(session).withOtp({ storage: otp.storage }); +void makeAuth(config).withOtp({ storage: otp.storage }); -// @ts-expect-error challengeStorage is required in passkey config -void makeAuth(session).withPasskey({ +// @ts-expect-error challenge is required in passkey config +void makeAuth(config).withPasskey({ storage: passkey.storage, registrationCodec: passkey.registrationCodec, webAuthn: passkey.webAuthn, }); -// @ts-expect-error debug is required in session config -void makeAuth({ - storage: session.storage, - codec: session.codec, - transport: session.transport, - ttl: session.ttl, -}); +// @ts-expect-error debug is required in makeAuth config +void makeAuth({ session: config.session }); diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index 55f05bb..bdcbb64 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -139,13 +139,18 @@ export type SessionTransport = { clear: () => void; }; -/** Config for makeAuth — the session core */ -export type MakeAuthConfig = { +/** Config for the session unit */ +export type SessionConfig = { storage: SessionStorage; codec: SessionCodec; transport: SessionTransport; /** Session TTL in ms (Infinity = forever). Inactivity timeout with sliding refresh. */ ttl: number; +}; + +/** Config for makeAuth — the session unit named explicitly, since the function name can't */ +export type MakeAuthConfig = { + session: SessionConfig; /** Log expected auth failures to the console (development aid) */ debug: boolean; }; @@ -248,7 +253,7 @@ export type ChallengeRecord = { challenge: string; /** Set for registration ceremonies, null for authentication */ userId: string | null; - /** Stamped by core from WithPasskeyConfig.challengeTtl */ + /** Stamped by core from WithPasskeyConfig.challenge.ttl */ expiresAt: Date; }; @@ -298,14 +303,19 @@ export type WebAuthnConfig = { allowedOrigins: string[]; }; +/** Config for the passkey unit's challenges */ +export type ChallengeConfig = { + storage: ChallengeStorage; + /** Challenge validity duration in ms — core stamps ChallengeRecord.expiresAt from it */ + ttl: number; +}; + /** Config for withPasskey */ export type WithPasskeyConfig = { storage: CredentialStorage; - challengeStorage: ChallengeStorage; registrationCodec: RegistrationCodec; webAuthn: WebAuthnConfig; - /** Challenge validity duration in ms — core stamps ChallengeRecord.expiresAt from it */ - challengeTtl: number; + challenge: ChallengeConfig; }; /** Success data is the registration token */ diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts index 4670dbd..c02f55a 100644 --- a/packages/auth/src/spike/playground.ts +++ b/packages/auth/src/spike/playground.ts @@ -5,21 +5,23 @@ import { makeAuth } from "./contracts"; export const auth = makeAuth({ - storage: { - get: async () => null, - store: async () => undefined, - delete: async () => undefined, - }, - codec: { - encode: async () => "", - decode: async () => null, + session: { + ttl: 0, + storage: { + get: async () => null, + store: async () => undefined, + delete: async () => undefined, + }, + codec: { + encode: async () => "", + decode: async () => null, + }, + transport: { + get: () => null, + set: () => "", + clear: () => undefined, + }, }, - transport: { - get: () => null, - set: () => "", - clear: () => undefined, - }, - ttl: 0, debug: false, }); @@ -27,6 +29,7 @@ auth.session.create({ userId: "123" }); auth.session.end(); const otpAuth = auth.withOtp({ + ttl: 0, storage: { verify: async () => false, store: async () => undefined, @@ -34,23 +37,25 @@ const otpAuth = auth.withOtp({ delivery: { send: async () => undefined, }, - ttl: 0, }); otpAuth.otp.request({ identifier: "test@example.com" }); otpAuth.otp.verify({ identifier: "test@example.com", otp: "123456" }); export const passkey = auth.withPasskey({ + challenge: { + ttl: 0, + storage: { + store: async () => undefined, + take: async () => null, + }, + }, storage: { store: async () => undefined, get: async () => null, list: async () => [], setCounter: async () => undefined, }, - challengeStorage: { - store: async () => undefined, - take: async () => null, - }, registrationCodec: { encode: async () => "", decode: async () => null, @@ -60,7 +65,6 @@ export const passkey = auth.withPasskey({ rpName: "Spike", allowedOrigins: [], }, - challengeTtl: 0, }); passkey.passkey.createAuthenticationOptions(); @@ -71,6 +75,7 @@ passkey.passkey.createAuthenticationOptions(); // return auth.session.create({ userId: verified.userId }); export const passkeyAndOtp = passkey.withOtp({ + ttl: 0, storage: { verify: async () => false, store: async () => undefined, @@ -78,7 +83,6 @@ export const passkeyAndOtp = passkey.withOtp({ delivery: { send: async () => undefined, }, - ttl: 0, }); passkeyAndOtp.otp.verify({ identifier: "test@example.com", otp: "123456" }); From 623f92ad3523faf79a6da68117d1d47810a42171 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 12:19:09 -0700 Subject: [PATCH 27/49] Tests --- .agents/skills/test-plan/SKILL.md | 78 +++++++++ .../make-session-hmac-codec.test.ts | 149 ++++++++++++++++++ .../mechanisms/make-session-hmac-codec.ts | 16 ++ 3 files changed, 243 insertions(+) create mode 100644 .agents/skills/test-plan/SKILL.md create mode 100644 packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts create mode 100644 packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md new file mode 100644 index 0000000..8c20c60 --- /dev/null +++ b/.agents/skills/test-plan/SKILL.md @@ -0,0 +1,78 @@ +--- +name: test-plan +description: Plan test coverage for one contract unit at a time. Use when identifying covered, partial, missing, unclear, or out-of-scope behavioral claims before writing tests. +--- + +# Test plan + +Produce a compact assurance inventory for one contract unit. A contract unit is +a public factory, adapter, namespace method, parser, or similarly coherent +behavioral boundary; it is not necessarily one physical file. + +## Scope + +1. Require the user to name one target contract unit. If the target is absent + or ambiguous, ask before investigating. +2. Stay within that target. Do not rank gaps across the repository or propose + work in unrelated modules. +3. Record cross-unit discoveries under `Parked follow-ups` without pursuing + them. + +## Sources + +Read only the material relevant to the target: + +1. Requirements supplied by the user. +2. The authoritative contract and mechanism documentation identified by + `AGENTS.md`. +3. Relevant threat-model and work-queue entries. +4. Existing tests for the target. + +Derive expected behavior from these sources, never from the implementation. +After the claim inventory is established, inspect implementation only when +needed to identify the public boundary or a test seam. + +## Inventory + +Build the complete behavioral claim inventory for the target, grouped by +domain behavior. Include positive behavior, denial or failure behavior, +boundaries, side effects, time, and concurrency only where the authoritative +sources require them. + +Give every claim exactly one status: + +- `Covered` — existing evidence directly proves the claim. +- `Partial` — evidence proves only part of the claim. +- `Missing` — the claim has no direct evidence. +- `Unclear` — authoritative sources do not determine the expected behavior. +- `Out of scope` — the sources explicitly place responsibility elsewhere. + +For covered or partial claims, cite the relevant test by name and file. For +missing or partial claims, briefly state the defect the evidence should catch. +Do not invent requirements or add speculative edge cases. + +## Output + +Use this structure: + +```text +Target: + + +- : . + +Questions +- + +Next claim +- + +Parked follow-ups +- +``` + +Keep each entry concise, but do not cap or sample the inventory. Completeness +is local to the target. + +Do not edit files, write test code, or propose implementation. Stop after the +inventory so the user can review and select the next claim. diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts new file mode 100644 index 0000000..049736f --- /dev/null +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeSessionHmacCodec } from "./make-session-hmac-codec"; + +const MINUTE = 60_000; +const T0 = new Date("2026-07-19T12:00:00Z"); + +const record = { + sessionId: "session-1", + userId: "user-1", + expiresAt: new Date(T0.getTime() + 60 * MINUTE), +}; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(T0); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("makeSessionHmacCodec", () => { + /** + * Contract: "Self-contained tokens carry the session record" + */ + it("carries the session record through encode and decode", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const decoded = await codec.decode( + await codec.encode(record, { expiresAt: null }), + ); + + expect(decoded?.record).toEqual(record); + }); + + /** + * Contract: "token.expiresAt: null mints a fresh horizon from the codec's own TTL" + */ + it("mints a fresh horizon from its ttl when the directive is null", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const decoded = await codec.decode( + await codec.encode(record, { expiresAt: null }), + ); + + expect(decoded?.token).toEqual({ + expiresAt: new Date(T0.getTime() + MINUTE), + expired: false, + }); + }); + + /** + * Contract: "a Date preserves the existing horizon (sliding refresh)" + */ + it("preserves a given horizon on sliding refresh", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const horizon = new Date(T0.getTime() + 30_000); + const decoded = await codec.decode( + await codec.encode(record, { expiresAt: horizon }), + ); + + expect(decoded?.token).toEqual({ expiresAt: horizon, expired: false }); + }); + + /** + * Contract: "expiresAt < now, computed by the codec — the codec owns the token clock" + * Why: core's revocation check only runs on expired tokens, so an expired + * token must decode with the record intact. A codec that rejects expired + * tokens (e.g. a naive JWT wrapper) would silently disable revocation. + */ + it("flags an expired horizon but still returns the record", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const past = new Date(T0.getTime() - 1); + const decoded = await codec.decode( + await codec.encode(record, { expiresAt: past }), + ); + + expect(decoded).toEqual({ + record, + token: { expiresAt: past, expired: true }, + }); + }); + + /** + * Contract: "null = never expires" + */ + it("round-trips a never-expiring session", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const forever = { ...record, expiresAt: null }; + const decoded = await codec.decode( + await codec.encode(forever, { expiresAt: null }), + ); + + expect(decoded?.record).toEqual(forever); + }); + + /** + * Contract: "Invalid or forged tokens decode to null" + * Why: shape violations must fail closed, never throw — this test licenses + * the try/catch inside decode (prove-the-error rule). + */ + it("decodes a structurally malformed token to null", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + + expect(await codec.decode("")).toBeNull(); + expect(await codec.decode("not-a-token")).toBeNull(); + expect(await codec.decode("a.b.c")).toBeNull(); + expect(await codec.decode("body.")).toBeNull(); + }); + + /** + * Contract: "Invalid or forged tokens decode to null" + */ + it("decodes a token with an undecodable signature to null", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + + expect(await codec.decode("body.!!!not-base64url!!!")).toBeNull(); + }); + + /** + * Contract: "Invalid or forged tokens decode to null" + * Why: the core forgery property — a signature minted with any other + * secret must not validate. + */ + it("decodes a token signed with a different secret to null", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const foreignCodec = makeSessionHmacCodec({ + secret: "secret-2", + ttl: MINUTE, + }); + const foreign = await foreignCodec.encode(record, { expiresAt: null }); + + expect(await codec.decode(foreign)).toBeNull(); + }); + + /** + * Contract: "Invalid or forged tokens decode to null" + * Why: integrity covers both halves — a flipped bit in the body or in the + * signature must invalidate the token. + */ + it("decodes a tampered token to null", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const token = await codec.encode(record, { expiresAt: null }); + const tamperedBody = (token.startsWith("A") ? "B" : "A") + token.slice(1); + const tamperedSignature = + token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"); + + expect(await codec.decode(tamperedBody)).toBeNull(); + expect(await codec.decode(tamperedSignature)).toBeNull(); + }); +}); diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts new file mode 100644 index 0000000..0c5f0ed --- /dev/null +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts @@ -0,0 +1,16 @@ +import type { SessionCodec } from "../contracts"; + +/** Input for makeSessionHmacCodec */ +export type MakeSessionHmacCodecConfig = { + secret: string; + /** Token TTL in ms — the trust horizon minted on fresh encodes */ + ttl: number; +}; + +/** Builds a self-contained session codec: an HMAC-signed token carrying the record */ +export function makeSessionHmacCodec( + config: MakeSessionHmacCodecConfig, +): SessionCodec { + void config; + throw new Error("not implemented"); +} From c6dbe87806d029ca14697ec9e081fd288a7b09a2 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 13:59:41 -0700 Subject: [PATCH 28/49] Tests --- .agents/skills/test-plan/SKILL.md | 8 +- AGENTS.md | 9 ++ .../make-session-hmac-codec.test.ts | 104 ++++++++---------- 3 files changed, 60 insertions(+), 61 deletions(-) diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md index 8c20c60..ec087aa 100644 --- a/.agents/skills/test-plan/SKILL.md +++ b/.agents/skills/test-plan/SKILL.md @@ -64,8 +64,9 @@ Target: Questions - -Next claim -- +Recommended next test +- Parked follow-ups - @@ -75,4 +76,5 @@ Keep each entry concise, but do not cap or sample the inventory. Completeness is local to the target. Do not edit files, write test code, or propose implementation. Stop after the -inventory so the user can review and select the next claim. +inventory so the user can approve the recommended test target or choose +another. diff --git a/AGENTS.md b/AGENTS.md index ce8f023..e7bdc02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,15 @@ How methods return (commands, queries, adapters, throws) is specified by the `Re - ALWAYS write tests based on expected behavior (spec, requirements, user input) - When unsure about expected behavior, ask the user +## Test organization + +- One contract unit per test file +- The filename identifies the unit; don't repeat it in an outer `describe` +- Use `describe` only for meaningful behavioral groups +- Prefer no more than one `describe` level +- Use plain `it` when grouping adds no orientation +- Split multiple public units into separate test files when practical + ## Prose style - Use sentence case, never title case diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 049736f..b5ea75e 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -19,37 +19,40 @@ afterEach(() => { vi.useRealTimers(); }); -describe("makeSessionHmacCodec", () => { - /** - * Contract: "Self-contained tokens carry the session record" - */ +describe("session record", () => { it("carries the session record through encode and decode", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoded = await codec.decode( await codec.encode(record, { expiresAt: null }), ); - expect(decoded?.record).toEqual(record); + expect(decoded?.record).toStrictEqual(record); }); - /** - * Contract: "token.expiresAt: null mints a fresh horizon from the codec's own TTL" - */ + it("round-trips a never-expiring session", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const forever = { ...record, expiresAt: null }; + const decoded = await codec.decode( + await codec.encode(forever, { expiresAt: null }), + ); + + expect(decoded?.record).toStrictEqual(forever); + }); +}); + +describe("trust horizon", () => { it("mints a fresh horizon from its ttl when the directive is null", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoded = await codec.decode( await codec.encode(record, { expiresAt: null }), ); - expect(decoded?.token).toEqual({ + expect(decoded?.token).toStrictEqual({ expiresAt: new Date(T0.getTime() + MINUTE), expired: false, }); }); - /** - * Contract: "a Date preserves the existing horizon (sliding refresh)" - */ it("preserves a given horizon on sliding refresh", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const horizon = new Date(T0.getTime() + 30_000); @@ -57,14 +60,15 @@ describe("makeSessionHmacCodec", () => { await codec.encode(record, { expiresAt: horizon }), ); - expect(decoded?.token).toEqual({ expiresAt: horizon, expired: false }); + expect(decoded?.token).toStrictEqual({ + expiresAt: horizon, + expired: false, + }); }); /** - * Contract: "expiresAt < now, computed by the codec — the codec owns the token clock" - * Why: core's revocation check only runs on expired tokens, so an expired - * token must decode with the record intact. A codec that rejects expired - * tokens (e.g. a naive JWT wrapper) would silently disable revocation. + * Core checks storage for revocation only after the trust horizon expires. + * Rejecting the token here would silently disable that check. */ it("flags an expired horizon but still returns the record", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); @@ -73,53 +77,35 @@ describe("makeSessionHmacCodec", () => { await codec.encode(record, { expiresAt: past }), ); - expect(decoded).toEqual({ + expect(decoded).toStrictEqual({ record, token: { expiresAt: past, expired: true }, }); }); +}); +describe("invalid or forged tokens", () => { /** - * Contract: "null = never expires" - */ - it("round-trips a never-expiring session", async () => { - const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); - const forever = { ...record, expiresAt: null }; - const decoded = await codec.decode( - await codec.encode(forever, { expiresAt: null }), - ); - - expect(decoded?.record).toEqual(forever); - }); - - /** - * Contract: "Invalid or forged tokens decode to null" - * Why: shape violations must fail closed, never throw — this test licenses - * the try/catch inside decode (prove-the-error rule). + * Shape violations fail closed instead of escaping as parsing errors. + * This evidence licenses decode's error boundary. */ - it("decodes a structurally malformed token to null", async () => { + it.each([ + { name: "an empty token", token: "" }, + { name: "a token without a separator", token: "not-a-token" }, + { name: "a token with too many segments", token: "a.b.c" }, + { name: "a token without a signature", token: "body." }, + ])("decodes $name to null", async ({ token }) => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); - expect(await codec.decode("")).toBeNull(); - expect(await codec.decode("not-a-token")).toBeNull(); - expect(await codec.decode("a.b.c")).toBeNull(); - expect(await codec.decode("body.")).toBeNull(); + expect(await codec.decode(token)).toBeNull(); }); - /** - * Contract: "Invalid or forged tokens decode to null" - */ it("decodes a token with an undecodable signature to null", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); expect(await codec.decode("body.!!!not-base64url!!!")).toBeNull(); }); - /** - * Contract: "Invalid or forged tokens decode to null" - * Why: the core forgery property — a signature minted with any other - * secret must not validate. - */ it("decodes a token signed with a different secret to null", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const foreignCodec = makeSessionHmacCodec({ @@ -131,19 +117,21 @@ describe("makeSessionHmacCodec", () => { expect(await codec.decode(foreign)).toBeNull(); }); - /** - * Contract: "Invalid or forged tokens decode to null" - * Why: integrity covers both halves — a flipped bit in the body or in the - * signature must invalidate the token. - */ - it("decodes a tampered token to null", async () => { + it.each([ + { + part: "body", + tamper: (token: string) => + (token.startsWith("A") ? "B" : "A") + token.slice(1), + }, + { + part: "signature", + tamper: (token: string) => + token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"), + }, + ])("decodes a token with a tampered $part to null", async ({ tamper }) => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const token = await codec.encode(record, { expiresAt: null }); - const tamperedBody = (token.startsWith("A") ? "B" : "A") + token.slice(1); - const tamperedSignature = - token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"); - expect(await codec.decode(tamperedBody)).toBeNull(); - expect(await codec.decode(tamperedSignature)).toBeNull(); + expect(await codec.decode(tamper(token))).toBeNull(); }); }); From 7b675838d8d327e43f4e4169178ae58b609ff4d3 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 14:00:11 -0700 Subject: [PATCH 29/49] Update package.json --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ccac9fe..91806b0 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "test": "vitest run", "test:watch": "vitest", "outdated": "bun outdated --recursive", - "update": "bun update; for dir in packages/*/ examples/*/*/; do (cd \"$dir\" && bun update); done", - "update:latest": "bun update --latest; for dir in packages/*/ examples/*/*/; do (cd \"$dir\" && bun update --latest); done", + "update": "bun update && for dir in packages/*/ examples/*/*/; do (cd \"$dir\" && bun update) || exit 1; done", + "update:latest": "bun update --latest && for dir in packages/*/ examples/*/*/; do (cd \"$dir\" && bun update --latest) || exit 1; done", "clean:soft": "rm -rf node_modules packages/*/node_modules examples/*/*/{node_modules,.next,.output,tsconfig.tsbuildinfo} && bun install", - "clean:hard": "rm -rf node_modules packages/*/node_modules examples/*/*/{node_modules,.next,.output,tsconfig.tsbuildinfo} bun.lock && bun install" + "clean:hard": "rm -rf bun.lock && bun run clean:soft" }, "devDependencies": { "prettier": "^3.9.5", From 284b2433bcc49ddaf7b6c853af8dfab85e5ddd54 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 14:04:43 -0700 Subject: [PATCH 30/49] Tests --- AGENTS.md | 1 + SPEC.md | 2 +- packages/auth/src/spike/contracts.ts | 14 +++++++------- packages/auth/src/spike/mechanisms.ts | 2 +- .../mechanisms/make-session-hmac-codec.test.ts | 18 +++++++++--------- .../mechanisms/make-session-hmac-codec.ts | 2 +- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7bdc02..b8b5195 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ How methods return (commands, queries, adapters, throws) is specified by the `Re - Use `describe` only for meaningful behavioral groups - Prefer no more than one `describe` level - Use plain `it` when grouping adds no orientation +- Test names use API vocabulary and observable behavior, not conceptual terminology - Split multiple public units into separate test files when practical ## Prose style diff --git a/SPEC.md b/SPEC.md index 358e213..b89a150 100644 --- a/SPEC.md +++ b/SPEC.md @@ -298,7 +298,7 @@ Storage is split by concern: `OtpStorage`, `SessionStorage`, `CredentialStorage` > **Decided (2026-07-17): User owns the database; the API is request-scoped.** The library never dictates schema — record types (`SessionRecord`, `OtpRecord`, `CredentialRecord`) are exchange shapes at the adapter boundary, mapped to and from the user's own representation; reads must return records equivalent to what writes received, nothing more. Two placement rules follow. A namespace method exists only for request-scoped protocol operations — state named by the token riding the current request: `session.end` stays because only the library can name "the current session" and the transport pair is its own (create sets the cookie, end clears it). An adapter method exists only where core calls it during a protocol operation. Everything identifier-keyed — sign out everywhere, list sessions, revoke one session, remove a passkey — is plain CRUD on tables the user owns, done storage-direct: correct by construction, and deletion converges within the codec ttl on every token format (immediately with opaque). Accordingly `session.endAll`, `SessionStorage.deleteAll`, and `CredentialStorage.delete` are cut — superseding the shipped `signOutAll`/`deleteAll` noted in the roadmap. The sessionId-keyed `deleteAll` also failed open: a missing current-session record made it a silent no-op exactly when compromise response matters. The README gets a management-recipes table at promotion; management surfaces on convenience adapters are a later layer's question, tabled. -> **Decided (2026-07-17): TTLs — unit policy on unit configs; mechanism TTLs in mechanism factories, never on the SPI.** Core stamps every record deadline (`expiresAt` on session, otp, and challenge records) from unit config (`MakeAuthConfig.ttl`, `WithOtpConfig.ttl`, `WithPasskeyConfig.challengeTtl`). Token TTLs are mechanism-private: only self-contained codecs have a revocation window, so the number lives in the codec factory (`sessionHmac({ secret, ttl })`) — `SessionCodec.ttl` is removed from the SPI, and an opaque user configures no dead knob; the registration-token window is likewise codec-private. `TokenStatus.expiresAt` is the trust horizon — how long the carried record may be trusted without consulting storage: self-contained tokens embed it, lookup codecs report now (per-decode trust, `expired` never true — the zero-length revocation window that is opaque's known trade-off). Encode's directive stays `token: { expiresAt: Date | null }`: null = mint at the codec's own horizon, a Date = preserve it (sliding refresh) — that null branch is the seam that keeps horizon policy inside the only component that has one. Pressure-tested against splitting the codec into self-contained and lookup interfaces: rejected — it forks core into two algorithms (variation belongs at the edges; core stays frozen) and closes the taxonomy to hybrids such as lookup-with-short-cache; if lookup boilerplate ever matters, `makeLookupCodec({ lookup })` is a mechanisms-layer wrapper and the contract never moves. Accepted cost: a delivery template rendering "expires in N minutes" duplicates the otp ttl (config + template) — policy stays off the SPI. +> **Decided (2026-07-17): TTLs — unit policy on unit configs; mechanism TTLs in mechanism factories, never on the SPI.** Core stamps every record deadline (`expiresAt` on session, otp, and challenge records) from unit config (`MakeAuthConfig.ttl`, `WithOtpConfig.ttl`, `WithPasskeyConfig.challengeTtl`). Token TTLs are mechanism-private: only self-contained codecs have a revocation window, so the number lives in the codec factory (`sessionHmac({ secret, ttl })`) — `SessionCodec.ttl` is removed from the SPI, and an opaque user configures no dead knob; the registration-token window is likewise codec-private. `TokenStatus.expiresAt` is the storage-check deadline — the time after which the carried record must be checked against storage: self-contained tokens embed it, lookup codecs report now (per-decode trust, `expired` never true — the zero-length revocation window that is opaque's known trade-off). Encode's directive stays `token: { expiresAt: Date | null }`: null = mint a deadline from the codec's own TTL, a Date = preserve the supplied deadline — that null branch is the seam that keeps deadline policy inside the only component that has one. Pressure-tested against splitting the codec into self-contained and lookup interfaces: rejected — it forks core into two algorithms (variation belongs at the edges; core stays frozen) and closes the taxonomy to hybrids such as lookup-with-short-cache; if lookup boilerplate ever matters, `makeLookupCodec({ lookup })` is a mechanisms-layer wrapper and the contract never moves. Accepted cost: a delivery template rendering "expires in N minutes" duplicates the otp ttl (config + template) — policy stays off the SPI. > **Decided (2026-07-16): One attempt per otp.** A wrong otp consumes it — the user starts over with a fresh request. No attempt budgets, no re-store logic; every failure path fails closed. The typo cost is one email round-trip; acceptable for v1, revisit only on observed user friction. Per-IP rate limiting stays with the app; per-identifier cooldown is mechanism-layer (decided 2026-07-17, below). diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts index bdcbb64..10c34f2 100644 --- a/packages/auth/src/spike/contracts.ts +++ b/packages/auth/src/spike/contracts.ts @@ -71,13 +71,13 @@ export type Result = | ([E] extends [never] ? never : { success: false; error: E }); /** - * The token's trust status, as read by decode. The trust horizon bounds how - * long the carried record may be trusted without a storage check: - * self-contained tokens embed it; lookup codecs report now — their trust is - * per-decode, so expired is never true. + * The token's storage-check status, as read by decode. Expiry means the + * carried data needs a storage check; it does not make decode return null. + * Self-contained tokens embed the expiry; lookup codecs report now — their + * trust is per-decode, so expired is never true. */ export type TokenStatus = { - /** End of the trust horizon — the carried record is trusted without a storage check until this */ + /** When the carried data must be checked against storage */ expiresAt: Date; /** expiresAt < now, computed by the codec — the codec owns the token clock */ expired: boolean; @@ -122,7 +122,7 @@ export type SessionDecoded = { * token TTL is the codec factory's own config — never core's. */ export type SessionCodec = { - /** token.expiresAt: null mints a fresh horizon from the codec's own TTL; a Date preserves the existing horizon (sliding refresh) */ + /** token.expiresAt: null mints a fresh expiry from the codec's own TTL; a Date preserves the supplied expiry */ encode: ( record: SessionRecord, token: { expiresAt: Date | null }, @@ -282,7 +282,7 @@ export type RegistrationDecoded = { /** * Registration codec (short-lived token authorizing passkey registration). * The validity window is the codec factory's own config; encode mints at - * that horizon. + * that expiry. */ export type RegistrationCodec = { encode: (grant: RegistrationGrant) => Promise; diff --git a/packages/auth/src/spike/mechanisms.ts b/packages/auth/src/spike/mechanisms.ts index 04cf490..e56a26e 100644 --- a/packages/auth/src/spike/mechanisms.ts +++ b/packages/auth/src/spike/mechanisms.ts @@ -29,7 +29,7 @@ export declare function makeOtpStorage( /** Input for makeSessionHmacCodec */ export type MakeSessionHmacCodecConfig = { secret: string; - /** Token TTL in ms — the trust horizon minted on fresh encodes */ + /** Token TTL in ms — the token expiry minted on fresh encodes */ ttl: number; }; diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index b5ea75e..9e2e28d 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -40,8 +40,8 @@ describe("session record", () => { }); }); -describe("trust horizon", () => { - it("mints a fresh horizon from its ttl when the directive is null", async () => { +describe("token expiry", () => { + it("sets token expiry from the codec TTL when expiresAt is null", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoded = await codec.decode( await codec.encode(record, { expiresAt: null }), @@ -53,24 +53,24 @@ describe("trust horizon", () => { }); }); - it("preserves a given horizon on sliding refresh", async () => { + it("preserves a supplied token expiry", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); - const horizon = new Date(T0.getTime() + 30_000); + const tokenExpiry = new Date(T0.getTime() + 30_000); const decoded = await codec.decode( - await codec.encode(record, { expiresAt: horizon }), + await codec.encode(record, { expiresAt: tokenExpiry }), ); expect(decoded?.token).toStrictEqual({ - expiresAt: horizon, + expiresAt: tokenExpiry, expired: false, }); }); /** - * Core checks storage for revocation only after the trust horizon expires. - * Rejecting the token here would silently disable that check. + * Core checks storage for revocation when token.expired is true, so decode + * must retain the record. */ - it("flags an expired horizon but still returns the record", async () => { + it("marks an expired token while preserving its record", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const past = new Date(T0.getTime() - 1); const decoded = await codec.decode( diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts index 0c5f0ed..8649080 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.ts @@ -3,7 +3,7 @@ import type { SessionCodec } from "../contracts"; /** Input for makeSessionHmacCodec */ export type MakeSessionHmacCodecConfig = { secret: string; - /** Token TTL in ms — the trust horizon minted on fresh encodes */ + /** Token TTL in ms — the token expiry minted on fresh encodes */ ttl: number; }; From 8dafabc928d9e6a92d12337604af681b3838c59f Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 14:47:27 -0700 Subject: [PATCH 31/49] Create SKILL.md --- .agents/skills/test-one/SKILL.md | 79 ++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 .agents/skills/test-one/SKILL.md diff --git a/.agents/skills/test-one/SKILL.md b/.agents/skills/test-one/SKILL.md new file mode 100644 index 0000000..d8fa134 --- /dev/null +++ b/.agents/skills/test-one/SKILL.md @@ -0,0 +1,79 @@ +--- +name: test-one +description: Write exactly one approved behavioral test for one contract unit, run it, and stop before implementation. Use after test planning when the user has selected a claim. +--- + +# Test one + +Turn one approved behavioral claim into one reviewable test. + +## Scope + +1. Require one target contract unit and one claim explicitly supplied or + approved by the user. If either is absent or ambiguous, ask before working. +2. Stay within that claim. Do not add adjacent cases, refactor unrelated tests, + or change production code. +3. If the authoritative sources do not determine the expected behavior, stop + and ask rather than inventing it. + +## Sources + +Read only the material needed for the selected claim: + +1. Requirements supplied by the user. +2. The authoritative contract and mechanism documentation identified by + `AGENTS.md`. +3. The target test file and directly relevant existing tests. + +Derive the test from expected behavior, never from the implementation. + +## Before writing + +State briefly: + +- The selected claim. +- The observable oracle. +- Where the test will live. +- Why existing tests do not already prove it. + +The user's claim approval is the authorization to proceed; do not add another +approval gate unless expected behavior is unclear. + +## Write the test + +- Follow the test-organization and style rules in `AGENTS.md`. +- Add exactly one `it` or `it.each` declaration. +- Use `it.each` only when every named case is equivalent evidence for the same + claim. +- Match the target file's established behavioral grouping. +- Keep setup local and deterministic. +- Assert the exact public outcome. +- Add a comment only when the security reason cannot be expressed by the test + name and assertion. + +Do not edit contracts, production code, unrelated tests, or work-queue files. + +## Verify + +Run the narrowest command that executes the new test. + +- Fix test syntax, typing, or setup errors until the test reaches the selected + behavior. +- If it fails for the expected behavioral reason, report it as red. +- If it passes immediately, report it as green on arrival; do not weaken the + test or change production code to force red. +- If another failure prevents the selected behavior from being reached, report + the blocker without expanding scope. + +## Output + +Report: + +```text +Claim: +Test: +Result: +Command: +``` + +Stop after reporting so the user can review the test before implementation. From 9c1be98064a2f6071b3e10c14bc947694531dc86 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 16:27:53 -0700 Subject: [PATCH 32/49] Tests --- .agents/skills/test-one/SKILL.md | 23 ++++++++--------------- .agents/skills/test-plan/SKILL.md | 24 +++++++++++------------- AGENTS.md | 13 ++++++++++--- 3 files changed, 29 insertions(+), 31 deletions(-) diff --git a/.agents/skills/test-one/SKILL.md b/.agents/skills/test-one/SKILL.md index d8fa134..c110b9f 100644 --- a/.agents/skills/test-one/SKILL.md +++ b/.agents/skills/test-one/SKILL.md @@ -13,29 +13,21 @@ Turn one approved behavioral claim into one reviewable test. approved by the user. If either is absent or ambiguous, ask before working. 2. Stay within that claim. Do not add adjacent cases, refactor unrelated tests, or change production code. -3. If the authoritative sources do not determine the expected behavior, stop - and ask rather than inventing it. - -## Sources - -Read only the material needed for the selected claim: - -1. Requirements supplied by the user. -2. The authoritative contract and mechanism documentation identified by - `AGENTS.md`. -3. The target test file and directly relevant existing tests. - -Derive the test from expected behavior, never from the implementation. +3. If no authority determines the expected behavior, stop and ask rather than + inventing it. ## Before writing State briefly: -- The selected claim. -- The observable oracle. +- The selected behavioral claim. +- The test oracle. - Where the test will live. - Why existing tests do not already prove it. +Derive the test oracle from an authority, never from the implementation under +test. + The user's claim approval is the authorization to proceed; do not add another approval gate unless expected behavior is unclear. @@ -71,6 +63,7 @@ Report: ```text Claim: +Oracle: Test: Result: Command: diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md index ec087aa..e9224d2 100644 --- a/.agents/skills/test-plan/SKILL.md +++ b/.agents/skills/test-plan/SKILL.md @@ -18,34 +18,32 @@ behavioral boundary; it is not necessarily one physical file. 3. Record cross-unit discoveries under `Parked follow-ups` without pursuing them. -## Sources +## Claims and evidence -Read only the material relevant to the target: +Gather behavioral claims from the authorities relevant to the target: 1. Requirements supplied by the user. 2. The authoritative contract and mechanism documentation identified by `AGENTS.md`. -3. Relevant threat-model and work-queue entries. -4. Existing tests for the target. +3. Relevant threat-model decisions and governing standards. -Derive expected behavior from these sources, never from the implementation. -After the claim inventory is established, inspect implementation only when -needed to identify the public boundary or a test seam. +Use the work queue to find known gaps and existing tests to classify evidence. +Existing tests and implementation do not determine expected behavior. ## Inventory Build the complete behavioral claim inventory for the target, grouped by domain behavior. Include positive behavior, denial or failure behavior, -boundaries, side effects, time, and concurrency only where the authoritative -sources require them. +boundaries, side effects, time, and concurrency only where the authorities +require them. Give every claim exactly one status: - `Covered` — existing evidence directly proves the claim. - `Partial` — evidence proves only part of the claim. - `Missing` — the claim has no direct evidence. -- `Unclear` — authoritative sources do not determine the expected behavior. -- `Out of scope` — the sources explicitly place responsibility elsewhere. +- `Unclear` — no authority determines the expected behavior. +- `Out of scope` — an authority explicitly places responsibility elsewhere. For covered or partial claims, cite the relevant test by name and file. For missing or partial claims, briefly state the defect the evidence should catch. @@ -65,8 +63,8 @@ Questions - Recommended next test -- +- Parked follow-ups - diff --git a/AGENTS.md b/AGENTS.md index b8b5195..cc01346 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,9 +71,16 @@ How methods return (commands, queries, adapters, throws) is specified by the `Re ## TDD (critical) -- NEVER write tests based on implemented code -- ALWAYS write tests based on expected behavior (spec, requirements, user input) -- When unsure about expected behavior, ask the user +Test vocabulary: + +- Authority — the source that determines expected behavior, following the documentation map; e.g. a contract, requirement, governing standard, or user/domain expert + Example: The contract says a token is expired only when `expiresAt < now` +- Behavioral claim — what must be true + Example: A token is not expired when `expiresAt === now` +- Test oracle — the expected result or decision rule for a given case + Example: `expired` is `false`, encoded as `expect(decoded?.token.expired).toBe(false)` + +Every test proves one behavioral claim using a test oracle derived from an authority. The implementation under test is never an authority or test oracle. When no authority determines the expected behavior, ask the user. ## Test organization From 0cb6f4e2ef4614cb5f531fc588cd98fbae78391a Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Mon, 20 Jul 2026 17:40:06 -0700 Subject: [PATCH 33/49] Update make-session-hmac-codec.test.ts --- .../src/spike/mechanisms/make-session-hmac-codec.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 9e2e28d..4132693 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -66,6 +66,15 @@ describe("token expiry", () => { }); }); + it("does not mark a token expired when expiresAt equals now", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const decoded = await codec.decode( + await codec.encode(record, { expiresAt: T0 }), + ); + + expect(decoded?.token.expired).toBe(false); + }); + /** * Core checks storage for revocation when token.expired is true, so decode * must retain the record. From 6cc75aedf6df9b1e565f741a4e3631c133547573 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 08:08:55 -0700 Subject: [PATCH 34/49] Tests --- .agents/skills/test-one/SKILL.md | 8 +-- .agents/skills/test-plan/SKILL.md | 9 ++-- .vscode/settings.json | 2 +- AGENTS.md | 20 +++---- .../make-session-hmac-codec.test.ts | 52 +++++++++++++------ 5 files changed, 56 insertions(+), 35 deletions(-) diff --git a/.agents/skills/test-one/SKILL.md b/.agents/skills/test-one/SKILL.md index c110b9f..0c44881 100644 --- a/.agents/skills/test-one/SKILL.md +++ b/.agents/skills/test-one/SKILL.md @@ -34,14 +34,14 @@ approval gate unless expected behavior is unclear. ## Write the test - Follow the test-organization and style rules in `AGENTS.md`. -- Add exactly one `it` or `it.each` declaration. -- Use `it.each` only when every named case is equivalent evidence for the same +- Add exactly one `test` or `test.each` declaration. +- Use `test.each` only when every named case is equivalent evidence for the same claim. - Match the target file's established behavioral grouping. - Keep setup local and deterministic. - Assert the exact public outcome. -- Add a comment only when the security reason cannot be expressed by the test - name and assertion. +- Use scenario comments when a non-obvious transition or sequence matters to + the claim; state intent, not obvious mechanics. Do not edit contracts, production code, unrelated tests, or work-queue files. diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md index e9224d2..d7be7cc 100644 --- a/.agents/skills/test-plan/SKILL.md +++ b/.agents/skills/test-plan/SKILL.md @@ -32,10 +32,11 @@ Existing tests and implementation do not determine expected behavior. ## Inventory -Build the complete behavioral claim inventory for the target, grouped by -domain behavior. Include positive behavior, denial or failure behavior, -boundaries, side effects, time, and concurrency only where the authorities -require them. +Build the complete behavioral claim inventory for the target. Group claims by +the unit's real behavioral concerns or failure modes so the groups can orient +the test file. Reuse sibling group names where they fit; never impose a fixed +taxonomy. Include positive behavior, denial or failure behavior, boundaries, +side effects, time, and concurrency only where the authorities require them. Give every claim exactly one status: diff --git a/.vscode/settings.json b/.vscode/settings.json index 2b47618..06b2ad3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "chat.disableAIFeatures": true, + // "chat.disableAIFeatures": true, "editor.accessibilitySupport": "off", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "editor.defaultFormatter": "esbenp.prettier-vscode", diff --git a/AGENTS.md b/AGENTS.md index cc01346..2f1c7c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,9 +69,9 @@ How methods return (commands, queries, adapters, throws) is specified by the `Re - Invariants: Never use type assertions (`as`). Throw instead — surfaces bugs immediately. Comment each invariant `Invariant: reasoning` - Must prove the error with a test before adding try-catch -## TDD (critical) +## Tests -Test vocabulary: +### Vocabulary - Authority — the source that determines expected behavior, following the documentation map; e.g. a contract, requirement, governing standard, or user/domain expert Example: The contract says a token is expired only when `expiresAt < now` @@ -80,16 +80,18 @@ Test vocabulary: - Test oracle — the expected result or decision rule for a given case Example: `expired` is `false`, encoded as `expect(decoded?.token.expired).toBe(false)` +### TDD (critical) + Every test proves one behavioral claim using a test oracle derived from an authority. The implementation under test is never an authority or test oracle. When no authority determines the expected behavior, ask the user. -## Test organization +### Organization -- One contract unit per test file -- The filename identifies the unit; don't repeat it in an outer `describe` -- Use `describe` only for meaningful behavioral groups -- Prefer no more than one `describe` level -- Use plain `it` when grouping adds no orientation -- Test names use API vocabulary and observable behavior, not conceptual terminology +- One contract unit per test file; the filename identifies it, so don't repeat it in an outer `describe` +- Group `describe` blocks by the unit's real behavioral concerns or failure modes; reuse sibling group names where they fit, never impose a fixed taxonomy +- Prefer no more than one `describe` level; use an ungrouped `test` when grouping adds no orientation +- Use `test` and `test.each`, not `it` +- Test names state complete behavioral claims using API vocabulary; name the responsible public operation or subject when the group does not +- Use scenario comments when a non-obvious transition or sequence matters to the claim; state intent, never mechanics already clear from the code - Split multiple public units into separate test files when practical ## Prose style diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 4132693..627bf55 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { makeSessionHmacCodec } from "./make-session-hmac-codec"; const MINUTE = 60_000; @@ -20,7 +20,7 @@ afterEach(() => { }); describe("session record", () => { - it("carries the session record through encode and decode", async () => { + test("encode and decode preserve the session record", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoded = await codec.decode( await codec.encode(record, { expiresAt: null }), @@ -29,7 +29,7 @@ describe("session record", () => { expect(decoded?.record).toStrictEqual(record); }); - it("round-trips a never-expiring session", async () => { + test("encode and decode preserve a never-expiring session", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const forever = { ...record, expiresAt: null }; const decoded = await codec.decode( @@ -41,7 +41,7 @@ describe("session record", () => { }); describe("token expiry", () => { - it("sets token expiry from the codec TTL when expiresAt is null", async () => { + test("encode sets token expiry from the codec TTL when expiresAt is null", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoded = await codec.decode( await codec.encode(record, { expiresAt: null }), @@ -53,7 +53,7 @@ describe("token expiry", () => { }); }); - it("preserves a supplied token expiry", async () => { + test("encode preserves a supplied token expiry", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const tokenExpiry = new Date(T0.getTime() + 30_000); const decoded = await codec.decode( @@ -66,7 +66,7 @@ describe("token expiry", () => { }); }); - it("does not mark a token expired when expiresAt equals now", async () => { + test("decode reports the token as unexpired when expiresAt equals now", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoded = await codec.decode( await codec.encode(record, { expiresAt: T0 }), @@ -75,11 +75,26 @@ describe("token expiry", () => { expect(decoded?.token.expired).toBe(false); }); + test("decode evaluates token expiry using the current time", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + + // Encode while the token expiry is still in the future. + const expiresAt = new Date(T0.getTime() + 30_000); + const token = await codec.encode(record, { expiresAt }); + + // Move the clock one millisecond past token expiry before decoding. + vi.setSystemTime(new Date(expiresAt.getTime() + 1)); + + const decoded = await codec.decode(token); + + expect(decoded?.token.expired).toBe(true); + }); + /** * Core checks storage for revocation when token.expired is true, so decode * must retain the record. */ - it("marks an expired token while preserving its record", async () => { + test("decode reports the token as expired while preserving its record", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const past = new Date(T0.getTime() - 1); const decoded = await codec.decode( @@ -98,24 +113,24 @@ describe("invalid or forged tokens", () => { * Shape violations fail closed instead of escaping as parsing errors. * This evidence licenses decode's error boundary. */ - it.each([ + test.each([ { name: "an empty token", token: "" }, { name: "a token without a separator", token: "not-a-token" }, { name: "a token with too many segments", token: "a.b.c" }, { name: "a token without a signature", token: "body." }, - ])("decodes $name to null", async ({ token }) => { + ])("decode returns null for $name", async ({ token }) => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); expect(await codec.decode(token)).toBeNull(); }); - it("decodes a token with an undecodable signature to null", async () => { + test("decode returns null for a token with an undecodable signature", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); expect(await codec.decode("body.!!!not-base64url!!!")).toBeNull(); }); - it("decodes a token signed with a different secret to null", async () => { + test("decode returns null for a token signed with another secret", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const foreignCodec = makeSessionHmacCodec({ secret: "secret-2", @@ -126,7 +141,7 @@ describe("invalid or forged tokens", () => { expect(await codec.decode(foreign)).toBeNull(); }); - it.each([ + test.each([ { part: "body", tamper: (token: string) => @@ -137,10 +152,13 @@ describe("invalid or forged tokens", () => { tamper: (token: string) => token.slice(0, -1) + (token.endsWith("A") ? "B" : "A"), }, - ])("decodes a token with a tampered $part to null", async ({ tamper }) => { - const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); - const token = await codec.encode(record, { expiresAt: null }); + ])( + "decode returns null for a token with a tampered $part", + async ({ tamper }) => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const token = await codec.encode(record, { expiresAt: null }); - expect(await codec.decode(tamper(token))).toBeNull(); - }); + expect(await codec.decode(tamper(token))).toBeNull(); + }, + ); }); From e9f1e0227b38f24646b2e2c0538911e3154e7f7c Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 08:31:30 -0700 Subject: [PATCH 35/49] Update make-session-hmac-codec.test.ts --- .../mechanisms/make-session-hmac-codec.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 627bf55..6c3f7e7 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -29,6 +29,20 @@ describe("session record", () => { expect(decoded?.record).toStrictEqual(record); }); + test("decode returns the session record and token status encoded by another codec with the same secret", async () => { + const encoder = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const decoder = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const token = await encoder.encode(record, { expiresAt: null }); + + expect(await decoder.decode(token)).toStrictEqual({ + record, + token: { + expiresAt: new Date(T0.getTime() + MINUTE), + expired: false, + }, + }); + }); + test("encode and decode preserve a never-expiring session", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const forever = { ...record, expiresAt: null }; From 3f7b4142852dcae0a2c877140919e2a705fc72a9 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 08:48:32 -0700 Subject: [PATCH 36/49] Update make-session-hmac-codec.test.ts --- .../make-session-hmac-codec.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 6c3f7e7..38bf25a 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -175,4 +175,36 @@ describe("invalid or forged tokens", () => { expect(await codec.decode(tamper(token))).toBeNull(); }, ); + + test("decode propagates HMAC verification infrastructure failures", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const token = await codec.encode(record, { expiresAt: null }); + const failure = new Error("HMAC verification unavailable"); + const verification = vi + .spyOn(crypto.subtle, "verify") + .mockRejectedValueOnce(failure); + + try { + await expect(codec.decode(token)).rejects.toBe(failure); + } finally { + verification.mockRestore(); + } + }); +}); + +describe("infrastructure failures", () => { + test("decode propagates an HMAC verification infrastructure failure", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const token = await codec.encode(record, { expiresAt: null }); + const failure = new Error("verification unavailable"); + const verify = vi + .spyOn(crypto.subtle, "verify") + .mockRejectedValueOnce(failure); + + try { + await expect(codec.decode(token)).rejects.toBe(failure); + } finally { + verify.mockRestore(); + } + }); }); From a278eba6afa88a042c447a0d4b6134cd34bbf1b4 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 08:58:13 -0700 Subject: [PATCH 37/49] Update make-session-hmac-codec.test.ts --- .../mechanisms/make-session-hmac-codec.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 38bf25a..7bbe170 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -193,6 +193,22 @@ describe("invalid or forged tokens", () => { }); describe("infrastructure failures", () => { + test("encode propagates an HMAC signing infrastructure failure", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const failure = new Error("signing unavailable"); + const sign = vi + .spyOn(crypto.subtle, "sign") + .mockRejectedValueOnce(failure); + + try { + await expect( + codec.encode(record, { expiresAt: null }), + ).rejects.toBe(failure); + } finally { + sign.mockRestore(); + } + }); + test("decode propagates an HMAC verification infrastructure failure", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const token = await codec.encode(record, { expiresAt: null }); From 9deed35904065a56fea0ce72d51edd7aa12be138 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 08:58:24 -0700 Subject: [PATCH 38/49] Update SKILL.md --- .agents/skills/test-plan/SKILL.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md index d7be7cc..ee62aad 100644 --- a/.agents/skills/test-plan/SKILL.md +++ b/.agents/skills/test-plan/SKILL.md @@ -46,6 +46,9 @@ Give every claim exactly one status: - `Unclear` — no authority determines the expected behavior. - `Out of scope` — an authority explicitly places responsibility elsewhere. +Number every claim sequentially in the report. The numbers are local references +for selecting a claim from that report, not permanent identifiers. + For covered or partial claims, cite the relevant test by name and file. For missing or partial claims, briefly state the defect the evidence should catch. Do not invent requirements or add speculative edge cases. @@ -58,14 +61,14 @@ Use this structure: Target: -- : . +- [] : . Questions - Recommended next test -- +- [] Parked follow-ups - From 475c0ff1078fec1f431df7696966837cc77dd0ca Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 09:03:26 -0700 Subject: [PATCH 39/49] Create skills --- .claude/skills | 1 + 1 file changed, 1 insertion(+) create mode 120000 .claude/skills diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file From 426064d43d8b2d4f3ba8451f2033ec0ceaeee532 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 09:18:36 -0700 Subject: [PATCH 40/49] Deps --- bun.lock | 86 ++++++++++--------- examples/bun-react/otp-memory/package.json | 4 +- examples/nextjs/otp-memory/package.json | 6 +- .../otp-memory/package.json | 4 +- .../otp-passkey-memory/package.json | 4 +- .../otp-passkey-strict-memory/package.json | 4 +- .../passkey-memory/package.json | 4 +- .../passkey-otp-memory/package.json | 4 +- examples/tmp/tanstack-start/package.json | 4 +- package.json | 2 +- packages/auth/package.json | 2 +- 11 files changed, 63 insertions(+), 61 deletions(-) diff --git a/bun.lock b/bun.lock index d3179ad..c30b5ec 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "@repo/monorepo", "devDependencies": { - "prettier": "^3.9.5", + "prettier": "^3.9.6", "prettier-plugin-tailwindcss": "^0.8.1", "typescript": "^6.0.3", "vitest": "^4.1.10", @@ -18,8 +18,8 @@ "@repo/auth-react": "workspace:*", "@starmode/auth": "workspace:*", "bun-plugin-tailwind": "^0.1.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -35,9 +35,9 @@ "dependencies": { "@repo/auth-react": "workspace:*", "@starmode/auth": "workspace:*", - "next": "^16.2.10", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "next": "^16.2.11", + "react": "^19.2.8", + "react-dom": "^19.2.8", "zod": "^4.4.3", }, "devDependencies": { @@ -58,8 +58,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -80,8 +80,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -102,8 +102,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -124,8 +124,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -146,8 +146,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -178,7 +178,7 @@ "@starmode/auth": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", - "@tanstack/react-start": "^1.168.28", + "@tanstack/react-start": "^1.168.32", "react": "^19.2.7", "react-dom": "^19.2.7", "tailwindcss": "^4.3.3", @@ -201,7 +201,7 @@ "eslint": "^10.7.0", "globals": "^17.7.0", "neon-testing": "^3.0.0", - "typescript-eslint": "^8.64.0", + "typescript-eslint": "^8.65.0", }, "peerDependencies": { "@tanstack/react-start": "^1.161.1", @@ -359,23 +359,23 @@ "@neondatabase/api-client": ["@neondatabase/api-client@2.7.1", "", { "dependencies": { "axios": "^1.13.5" } }, "sha512-hEYOJ89xIa2eEXBu9HRKYTJc9lrmszhNc0SIxzJvNE/3Av4xK7vkXWQ3LWy0DTTFY4Kn6wfM2wAjRIjf/jOu6w=="], - "@next/env": ["@next/env@16.2.10", "", {}, "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA=="], + "@next/env": ["@next/env@16.2.11", "", {}, "sha512-0do5A3BJ2gxWr0ZCMcD6BhW+e595jyxdTl3rXTS6lOtD8ektMiW6CO+EPwt1Eca1DBnm90r/7GdiKWBKxH++DA=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wryL4pjKmDwGv2ox6+GZDFxvmtSRLqApBR8kL1j4+vhB7Z5vJC/zAnXpiR9Xkfzl0AS8WLMnsuGV/UKI67/rrw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZl2j4f/fLyjQvOhv0Oe9UaMAQHolYpKhctsoYzplSumKJKPUmgjcf6545aBtysLTcu994TREd0+pSgNE4ohmg=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-5jEriyEnH/LWFy27L2ZG0XaLlyEJIjhsImEsiS9P563PKEVp2BVups/xfOucIrsvVntp11oNcZwjHvaDPYVB5g=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-eIjcpx2fnnFSSkZDbTxy74KnokUXDjfoLClpWelfgHLf621aTqswhwXQ7GkD5K5rplrS6LZ/Bj+mVuvzluBOEg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-8WgzpaWMs46qJT9kiV47cje86L0x/Mu9t8/Gwj+pnbgW3rETVfCnaScPjlYUwNScpOozdcIMHWmAvuZJUonR2w=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.10", "", { "os": "linux", "cpu": "x64" }, "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.11", "", { "os": "linux", "cpu": "x64" }, "sha512-I3UgPds7G4ZYnTb/H+5GBGuUT2DhAk6j0mL6A4s63RjFs74wB2hOWP0vaxsK+3NJraExt3eYEPQ/UtT0x/64Nw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-n89CjtcThnjrwgJMAiI5xbqwLY51zvwC9tSlArmVndAJLYVl9T9UAdlkXTmZvE++idoXe8KdglQlhNRdUp1c6g=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.10", "", { "os": "win32", "cpu": "x64" }, "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.11", "", { "os": "win32", "cpu": "x64" }, "sha512-md8CLNggS1Dx9pUgApzps5uAf+N8GN9xywzmNx9vHAWo94HtBwCCqkSnhIrdfQe83Dhz8Lfo/20Nb1Zxal092w=="], "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], @@ -565,25 +565,25 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.64.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/type-utils": "8.64.0", "@typescript-eslint/utils": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.64.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.64.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.64.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.64.0", "@typescript-eslint/types": "^8.64.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0" } }, "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.64.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/utils": "8.64.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.64.0", "", {}, "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.64.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.64.0", "@typescript-eslint/tsconfig-utils": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/visitor-keys": "8.64.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.64.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/types": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.64.0", "", { "dependencies": { "@typescript-eslint/types": "8.64.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], @@ -837,7 +837,7 @@ "neon-testing": ["neon-testing@3.0.0", "", { "dependencies": { "@neondatabase/api-client": "2.7.1" }, "peerDependencies": { "@neondatabase/serverless": "^1", "vitest": "^3 || ^4" }, "optionalPeers": ["@neondatabase/serverless", "vitest"] }, "sha512-VLXIXI5EEQNCw/dLCXemzBpydwASUMKUnixmJPvOWQsFgkSvzzH4x8vgZJJnh4LD44ADoX4uq+8yZocrCA4sUw=="], - "next": ["next@16.2.10", "", { "dependencies": { "@next/env": "16.2.10", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.10", "@next/swc-darwin-x64": "16.2.10", "@next/swc-linux-arm64-gnu": "16.2.10", "@next/swc-linux-arm64-musl": "16.2.10", "@next/swc-linux-x64-gnu": "16.2.10", "@next/swc-linux-x64-musl": "16.2.10", "@next/swc-win32-arm64-msvc": "16.2.10", "@next/swc-win32-x64-msvc": "16.2.10", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA=="], + "next": ["next@16.2.11", "", { "dependencies": { "@next/env": "16.2.11", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.11", "@next/swc-darwin-x64": "16.2.11", "@next/swc-linux-arm64-gnu": "16.2.11", "@next/swc-linux-arm64-musl": "16.2.11", "@next/swc-linux-x64-gnu": "16.2.11", "@next/swc-linux-x64-musl": "16.2.11", "@next/swc-win32-arm64-msvc": "16.2.11", "@next/swc-win32-x64-msvc": "16.2.11", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-B339zaqbyK8cmxhoAvLrcwoabwCP1wz21zSzfqxqXAemTu2BXnH7tQnfcglKv1vnMUIDBc+Hth7XODQriTZiRQ=="], "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="], @@ -863,7 +863,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.8.1", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw=="], @@ -871,9 +871,9 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -929,7 +929,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.64.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.64.0", "@typescript-eslint/parser": "8.64.0", "@typescript-eslint/typescript-estree": "8.64.0", "@typescript-eslint/utils": "8.64.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ=="], + "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -991,6 +991,8 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "@tanstack/router-generator/prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], diff --git a/examples/bun-react/otp-memory/package.json b/examples/bun-react/otp-memory/package.json index 0ac447b..ceea4b3 100644 --- a/examples/bun-react/otp-memory/package.json +++ b/examples/bun-react/otp-memory/package.json @@ -11,8 +11,8 @@ "@starmode/auth": "workspace:*", "@repo/auth-react": "workspace:*", "bun-plugin-tailwind": "^0.1.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3" }, diff --git a/examples/nextjs/otp-memory/package.json b/examples/nextjs/otp-memory/package.json index 8238ebc..10092b8 100644 --- a/examples/nextjs/otp-memory/package.json +++ b/examples/nextjs/otp-memory/package.json @@ -10,9 +10,9 @@ "dependencies": { "@starmode/auth": "workspace:*", "@repo/auth-react": "workspace:*", - "next": "^16.2.10", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "next": "^16.2.11", + "react": "^19.2.8", + "react-dom": "^19.2.8", "zod": "^4.4.3" }, "devDependencies": { diff --git a/examples/tanstack-start-react/otp-memory/package.json b/examples/tanstack-start-react/otp-memory/package.json index 6b9cb87..7efedb2 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/otp-memory/package.json @@ -13,8 +13,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3" }, diff --git a/examples/tanstack-start-react/otp-passkey-memory/package.json b/examples/tanstack-start-react/otp-passkey-memory/package.json index aa9c02e..0f40969 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-memory/package.json @@ -13,8 +13,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3" }, diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json index 0fdbc4c..c29745e 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json @@ -13,8 +13,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3" }, diff --git a/examples/tanstack-start-react/passkey-memory/package.json b/examples/tanstack-start-react/passkey-memory/package.json index 209da7e..7989d71 100644 --- a/examples/tanstack-start-react/passkey-memory/package.json +++ b/examples/tanstack-start-react/passkey-memory/package.json @@ -13,8 +13,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3" }, diff --git a/examples/tanstack-start-react/passkey-otp-memory/package.json b/examples/tanstack-start-react/passkey-otp-memory/package.json index 7f15941..ba7c0a2 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/package.json +++ b/examples/tanstack-start-react/passkey-otp-memory/package.json @@ -13,8 +13,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3" }, diff --git a/examples/tmp/tanstack-start/package.json b/examples/tmp/tanstack-start/package.json index 4619618..f1ef39e 100644 --- a/examples/tmp/tanstack-start/package.json +++ b/examples/tmp/tanstack-start/package.json @@ -12,8 +12,8 @@ "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.18", "@tanstack/react-start": "^1.168.32", - "react": "^19.2.7", - "react-dom": "^19.2.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3" }, "devDependencies": { diff --git a/package.json b/package.json index 91806b0..0af147a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "clean:hard": "rm -rf bun.lock && bun run clean:soft" }, "devDependencies": { - "prettier": "^3.9.5", + "prettier": "^3.9.6", "prettier-plugin-tailwindcss": "^0.8.1", "typescript": "^6.0.3", "vitest": "^4.1.10" diff --git a/packages/auth/package.json b/packages/auth/package.json index de5fa42..9e2688b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -33,6 +33,6 @@ "eslint": "^10.7.0", "globals": "^17.7.0", "neon-testing": "^3.0.0", - "typescript-eslint": "^8.64.0" + "typescript-eslint": "^8.65.0" } } From fbf3431285f9ca4b2c57c0590d31e5fbf8a0321d Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 09:41:03 -0700 Subject: [PATCH 41/49] Update make-session-hmac-codec.test.ts --- .../make-session-hmac-codec.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 7bbe170..4066126 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -67,6 +67,20 @@ describe("token expiry", () => { }); }); + test("decode uses the token expiry embedded by the encoder", async () => { + const encoder = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const decoder = makeSessionHmacCodec({ + secret: "secret-1", + ttl: 2 * MINUTE, + }); + const token = await encoder.encode(record, { expiresAt: null }); + + expect((await decoder.decode(token))?.token).toStrictEqual({ + expiresAt: new Date(T0.getTime() + MINUTE), + expired: false, + }); + }); + test("encode preserves a supplied token expiry", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const tokenExpiry = new Date(T0.getTime() + 30_000); @@ -196,14 +210,12 @@ describe("infrastructure failures", () => { test("encode propagates an HMAC signing infrastructure failure", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const failure = new Error("signing unavailable"); - const sign = vi - .spyOn(crypto.subtle, "sign") - .mockRejectedValueOnce(failure); + const sign = vi.spyOn(crypto.subtle, "sign").mockRejectedValueOnce(failure); try { - await expect( - codec.encode(record, { expiresAt: null }), - ).rejects.toBe(failure); + await expect(codec.encode(record, { expiresAt: null })).rejects.toBe( + failure, + ); } finally { sign.mockRestore(); } From d975ad489e7aaf69167207df048e4bf223978a33 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 10:43:27 -0700 Subject: [PATCH 42/49] Update make-session-hmac-codec.test.ts --- .../make-session-hmac-codec.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 4066126..3474ad3 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -67,6 +67,30 @@ describe("token expiry", () => { }); }); + test("encode mints each fresh token expiry from its invocation time", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const firstToken = await codec.encode(record, { expiresAt: null }); + const later = new Date(T0.getTime() + 30_000); + + // A later encode must receive a full TTL instead of reusing the first deadline. + vi.setSystemTime(later); + const secondToken = await codec.encode(record, { expiresAt: null }); + + expect({ + first: (await codec.decode(firstToken))?.token, + second: (await codec.decode(secondToken))?.token, + }).toStrictEqual({ + first: { + expiresAt: new Date(T0.getTime() + MINUTE), + expired: false, + }, + second: { + expiresAt: new Date(later.getTime() + MINUTE), + expired: false, + }, + }); + }); + test("decode uses the token expiry embedded by the encoder", async () => { const encoder = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const decoder = makeSessionHmacCodec({ From 55e9bbed4a503e823e7f10e2ac73c5c748af6b78 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 10:52:52 -0700 Subject: [PATCH 43/49] Update make-session-hmac-codec.test.ts --- .../spike/mechanisms/make-session-hmac-codec.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 3474ad3..d16783e 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -182,6 +182,18 @@ describe("invalid or forged tokens", () => { expect(await codec.decode("body.!!!not-base64url!!!")).toBeNull(); }); + test("decode returns null for a signature-valid token with undecodable carried data", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + const verify = vi.spyOn(crypto.subtle, "verify").mockResolvedValueOnce(true); + + try { + // Authenticated but unreadable carried data is still an invalid token. + expect(await codec.decode("ew.AA")).toBeNull(); + } finally { + verify.mockRestore(); + } + }); + test("decode returns null for a token signed with another secret", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const foreignCodec = makeSessionHmacCodec({ From 50ce918c5849b4ca560dd6c2bc93651832daf568 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 10:54:37 -0700 Subject: [PATCH 44/49] Update make-session-hmac-codec.test.ts --- .../make-session-hmac-codec.test.ts | 51 ++++++------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index d16783e..5338497 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -16,6 +16,7 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -184,14 +185,10 @@ describe("invalid or forged tokens", () => { test("decode returns null for a signature-valid token with undecodable carried data", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); - const verify = vi.spyOn(crypto.subtle, "verify").mockResolvedValueOnce(true); - - try { - // Authenticated but unreadable carried data is still an invalid token. - expect(await codec.decode("ew.AA")).toBeNull(); - } finally { - verify.mockRestore(); - } + vi.spyOn(crypto.subtle, "verify").mockResolvedValueOnce(true); + + // Authenticated but unreadable carried data is still an invalid token. + expect(await codec.decode("ew.AA")).toBeNull(); }); test("decode returns null for a token signed with another secret", async () => { @@ -230,15 +227,9 @@ describe("invalid or forged tokens", () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const token = await codec.encode(record, { expiresAt: null }); const failure = new Error("HMAC verification unavailable"); - const verification = vi - .spyOn(crypto.subtle, "verify") - .mockRejectedValueOnce(failure); - - try { - await expect(codec.decode(token)).rejects.toBe(failure); - } finally { - verification.mockRestore(); - } + vi.spyOn(crypto.subtle, "verify").mockRejectedValueOnce(failure); + + await expect(codec.decode(token)).rejects.toBe(failure); }); }); @@ -246,29 +237,19 @@ describe("infrastructure failures", () => { test("encode propagates an HMAC signing infrastructure failure", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const failure = new Error("signing unavailable"); - const sign = vi.spyOn(crypto.subtle, "sign").mockRejectedValueOnce(failure); - - try { - await expect(codec.encode(record, { expiresAt: null })).rejects.toBe( - failure, - ); - } finally { - sign.mockRestore(); - } + vi.spyOn(crypto.subtle, "sign").mockRejectedValueOnce(failure); + + await expect(codec.encode(record, { expiresAt: null })).rejects.toBe( + failure, + ); }); test("decode propagates an HMAC verification infrastructure failure", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const token = await codec.encode(record, { expiresAt: null }); const failure = new Error("verification unavailable"); - const verify = vi - .spyOn(crypto.subtle, "verify") - .mockRejectedValueOnce(failure); - - try { - await expect(codec.decode(token)).rejects.toBe(failure); - } finally { - verify.mockRestore(); - } + vi.spyOn(crypto.subtle, "verify").mockRejectedValueOnce(failure); + + await expect(codec.decode(token)).rejects.toBe(failure); }); }); From 7b440d210cd684e54ff659a71ba106d2b2ef2661 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 11:14:39 -0700 Subject: [PATCH 45/49] Tests --- packages/auth/src/crypto.test.ts | 14 +++++++++----- packages/auth/src/crypto.ts | 9 +++++---- .../mechanisms/make-session-hmac-codec.test.ts | 12 +++++++++++- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/auth/src/crypto.test.ts b/packages/auth/src/crypto.test.ts index 505634a..10039a3 100644 --- a/packages/auth/src/crypto.test.ts +++ b/packages/auth/src/crypto.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, test } from "vitest"; import { base64urlDecode, base64urlEncode, @@ -11,7 +11,7 @@ describe("base64url encoding/decoding", () => { // All major browsers + Node.js + Bun + Deno handle missing padding correctly. // Only Hermes (React Native) still requires padding (not a target runtime). - it("uses URL-safe alphabet (- and _ instead of + and /)", () => { + test("uses URL-safe alphabet (- and _ instead of + and /)", () => { // Base64url uses URL-safe chars: - instead of +, _ instead of / // 0xfb 0xff → standard base64: "+/8" → base64url: "-_8" const data = new Uint8Array([0xfb, 0xff]); @@ -20,19 +20,23 @@ describe("base64url encoding/decoding", () => { expect(base64urlDecode(encoded)).toStrictEqual(data); }); - it("base64urlDecode returns null for invalid base64", () => { + test("base64urlEncode encodes a string as unpadded base64url", () => { + expect(base64urlEncode("{}")).toBe("e30"); + }); + + test("base64urlDecode returns null for invalid base64", () => { expect(base64urlDecode("not!valid!base64!")).toBeNull(); }); }); describe("hmacSign", () => { - it("returns null on empty secret", async () => { + test("returns null on empty secret", async () => { expect(await hmacSign("payload", "")).toBeNull(); }); }); describe("hmacVerify", () => { - it("returns false on empty secret", async () => { + test("returns false on empty secret", async () => { expect(await hmacVerify("payload", "c2lnbmF0dXJl", "")).toBe(false); }); }); diff --git a/packages/auth/src/crypto.ts b/packages/auth/src/crypto.ts index 5351498..af9cf4f 100644 --- a/packages/auth/src/crypto.ts +++ b/packages/auth/src/crypto.ts @@ -7,9 +7,10 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); -/** Encode bytes to base64url string */ -export function base64urlEncode(data: Uint8Array): string { - const binary = String.fromCharCode(...data); +/** Encode bytes or a UTF-8 string as unpadded base64url */ +export function base64urlEncode(data: Uint8Array | string): string { + const bytes = typeof data === "string" ? encoder.encode(data) : data; + const binary = String.fromCharCode(...bytes); return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") @@ -104,7 +105,7 @@ export async function hmacVerify( /** Encode a JSON payload to base64url */ // TODO: Rename to jsonToBase64Url export function encodePayload(payload: T): string { - return base64urlEncode(encoder.encode(JSON.stringify(payload))); + return base64urlEncode(JSON.stringify(payload)); } /** Decode a base64url string to JSON payload */ diff --git a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts index 5338497..95d6d3f 100644 --- a/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -187,10 +187,20 @@ describe("invalid or forged tokens", () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); vi.spyOn(crypto.subtle, "verify").mockResolvedValueOnce(true); - // Authenticated but unreadable carried data is still an invalid token. + // `ew` is base64url for invalid JSON `{`; `AA` is a decodable + // placeholder signature accepted by the mocked verifier. expect(await codec.decode("ew.AA")).toBeNull(); }); + test("decode returns null for signature-valid carried data without the required fields", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + vi.spyOn(crypto.subtle, "verify").mockResolvedValueOnce(true); + + // `e30` is base64url for `{}`; `AA` is a decodable placeholder signature + // accepted by the mocked verifier. + expect(await codec.decode("e30.AA")).toBeNull(); + }); + test("decode returns null for a token signed with another secret", async () => { const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); const foreignCodec = makeSessionHmacCodec({ From 4dbd72ac777a695ed77f4095d5d7bb054ecbae71 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 11:19:49 -0700 Subject: [PATCH 46/49] Update crypto.test.ts --- packages/auth/src/crypto.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/auth/src/crypto.test.ts b/packages/auth/src/crypto.test.ts index 10039a3..ef8c2d4 100644 --- a/packages/auth/src/crypto.test.ts +++ b/packages/auth/src/crypto.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, it, expect } from "vitest"; import { base64urlDecode, base64urlEncode, @@ -11,7 +11,7 @@ describe("base64url encoding/decoding", () => { // All major browsers + Node.js + Bun + Deno handle missing padding correctly. // Only Hermes (React Native) still requires padding (not a target runtime). - test("uses URL-safe alphabet (- and _ instead of + and /)", () => { + it("uses URL-safe alphabet (- and _ instead of + and /)", () => { // Base64url uses URL-safe chars: - instead of +, _ instead of / // 0xfb 0xff → standard base64: "+/8" → base64url: "-_8" const data = new Uint8Array([0xfb, 0xff]); @@ -20,23 +20,23 @@ describe("base64url encoding/decoding", () => { expect(base64urlDecode(encoded)).toStrictEqual(data); }); - test("base64urlEncode encodes a string as unpadded base64url", () => { + it("base64urlEncode encodes a string as unpadded base64url", () => { expect(base64urlEncode("{}")).toBe("e30"); }); - test("base64urlDecode returns null for invalid base64", () => { + it("base64urlDecode returns null for invalid base64", () => { expect(base64urlDecode("not!valid!base64!")).toBeNull(); }); }); describe("hmacSign", () => { - test("returns null on empty secret", async () => { + it("returns null on empty secret", async () => { expect(await hmacSign("payload", "")).toBeNull(); }); }); describe("hmacVerify", () => { - test("returns false on empty secret", async () => { + it("returns false on empty secret", async () => { expect(await hmacVerify("payload", "c2lnbmF0dXJl", "")).toBe(false); }); }); From b0812c3a41e66d640c4855278bfe9b31aa0656e5 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 17:00:03 -0700 Subject: [PATCH 47/49] Skills --- .agents/skills/test-one/SKILL.md | 4 ++++ .agents/skills/test-plan/SKILL.md | 31 ++++++++++++++++++++++++++++--- AGENTS.md | 2 ++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/.agents/skills/test-one/SKILL.md b/.agents/skills/test-one/SKILL.md index 0c44881..da6f48f 100644 --- a/.agents/skills/test-one/SKILL.md +++ b/.agents/skills/test-one/SKILL.md @@ -11,10 +11,14 @@ Turn one approved behavioral claim into one reviewable test. 1. Require one target contract unit and one claim explicitly supplied or approved by the user. If either is absent or ambiguous, ask before working. + If the user supplies multiple claims, state that `/test-one` accepts one + claim and stop without presenting a questionnaire. 2. Stay within that claim. Do not add adjacent cases, refactor unrelated tests, or change production code. 3. If no authority determines the expected behavior, stop and ask rather than inventing it. +4. Follow the approved plan's responsibility boundary and resolved + prerequisites; do not reopen or expand them. ## Before writing diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md index ee62aad..187ef8d 100644 --- a/.agents/skills/test-plan/SKILL.md +++ b/.agents/skills/test-plan/SKILL.md @@ -18,6 +18,23 @@ behavioral boundary; it is not necessarily one physical file. 3. Record cross-unit discoveries under `Parked follow-ups` without pursuing them. +## Responsibility boundary + +Before inventorying claims: + +1. Identify the behavior the target owns and its settled, separately testable + collaborators from user requirements and the authoritative documentation + and type structure identified by `AGENTS.md`. Implementation may reveal + candidate boundaries, but never behavior or a test oracle; ask when + ownership is unclear. +2. Inspect direct collaborator tests for delegated behavior required by the + target. If required behavior lacks direct evidence, report + `/test-plan ` as a prerequisite and stop before the target + inventory. +3. Inventory only target-owned policy, translation, validation, and observable + wiring. Do not count collaborator conformance as target coverage or require + internal call-count evidence. + ## Claims and evidence Gather behavioral claims from the authorities relevant to the target: @@ -27,8 +44,10 @@ Gather behavioral claims from the authorities relevant to the target: `AGENTS.md`. 3. Relevant threat-model decisions and governing standards. -Use the work queue to find known gaps and existing tests to classify evidence. -Existing tests and implementation do not determine expected behavior. +Use the work queue to find known gaps and target tests to classify evidence. +Collaborator tests establish prerequisites and prevent duplication; they never +prove a target-owned claim. Existing tests and implementation do not determine +expected behavior. ## Inventory @@ -55,11 +74,17 @@ Do not invent requirements or add speculative edge cases. ## Output -Use this structure: +Report an unresolved prerequisite after the responsibility boundary as +`Prerequisite: /test-plan `, then stop. Otherwise use +this structure: ```text Target: +Responsibility boundary +- Owns: +- Delegates: + - [] : . diff --git a/AGENTS.md b/AGENTS.md index 2f1c7c4..3a3db95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,8 @@ How methods return (commands, queries, adapters, throws) is specified by the `Re Every test proves one behavioral claim using a test oracle derived from an authority. The implementation under test is never an authority or test oracle. When no authority determines the expected behavior, ask the user. +Place each claim at the lowest contract unit that owns the behavior, and establish required collaborator coverage before wrapper tests. Wrapper tests cover the wrapper's policy, translation, validation, and observable wiring; collaborator conformance belongs in the collaborator's test file. Prove wiring through public outcomes, not internal call counts. + ### Organization - One contract unit per test file; the filename identifies it, so don't repeat it in an outer `describe` From 9868c9de09469044ea0160fe12b695f9495aed3d Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Tue, 21 Jul 2026 17:09:16 -0700 Subject: [PATCH 48/49] Remove hard line breaks --- .agents/skills/test-one/SKILL.md | 35 ++++++----------- .agents/skills/test-plan/SKILL.md | 63 ++++++++----------------------- examples/AGENTS.md | 46 ++++++---------------- 3 files changed, 39 insertions(+), 105 deletions(-) diff --git a/.agents/skills/test-one/SKILL.md b/.agents/skills/test-one/SKILL.md index da6f48f..c98947f 100644 --- a/.agents/skills/test-one/SKILL.md +++ b/.agents/skills/test-one/SKILL.md @@ -9,16 +9,10 @@ Turn one approved behavioral claim into one reviewable test. ## Scope -1. Require one target contract unit and one claim explicitly supplied or - approved by the user. If either is absent or ambiguous, ask before working. - If the user supplies multiple claims, state that `/test-one` accepts one - claim and stop without presenting a questionnaire. -2. Stay within that claim. Do not add adjacent cases, refactor unrelated tests, - or change production code. -3. If no authority determines the expected behavior, stop and ask rather than - inventing it. -4. Follow the approved plan's responsibility boundary and resolved - prerequisites; do not reopen or expand them. +1. Require one target contract unit and one claim explicitly supplied or approved by the user. If either is absent or ambiguous, ask before working. If the user supplies multiple claims, state that `/test-one` accepts one claim and stop without presenting a questionnaire. +2. Stay within that claim. Do not add adjacent cases, refactor unrelated tests, or change production code. +3. If no authority determines the expected behavior, stop and ask rather than inventing it. +4. Follow the approved plan's responsibility boundary and resolved prerequisites; do not reopen or expand them. ## Before writing @@ -29,23 +23,19 @@ State briefly: - Where the test will live. - Why existing tests do not already prove it. -Derive the test oracle from an authority, never from the implementation under -test. +Derive the test oracle from an authority, never from the implementation under test. -The user's claim approval is the authorization to proceed; do not add another -approval gate unless expected behavior is unclear. +The user's claim approval is the authorization to proceed; do not add another approval gate unless expected behavior is unclear. ## Write the test - Follow the test-organization and style rules in `AGENTS.md`. - Add exactly one `test` or `test.each` declaration. -- Use `test.each` only when every named case is equivalent evidence for the same - claim. +- Use `test.each` only when every named case is equivalent evidence for the same claim. - Match the target file's established behavioral grouping. - Keep setup local and deterministic. - Assert the exact public outcome. -- Use scenario comments when a non-obvious transition or sequence matters to - the claim; state intent, not obvious mechanics. +- Use scenario comments when a non-obvious transition or sequence matters to the claim; state intent, never mechanics already clear from the code. Do not edit contracts, production code, unrelated tests, or work-queue files. @@ -53,13 +43,10 @@ Do not edit contracts, production code, unrelated tests, or work-queue files. Run the narrowest command that executes the new test. -- Fix test syntax, typing, or setup errors until the test reaches the selected - behavior. +- Fix test syntax, typing, or setup errors until the test reaches the selected behavior. - If it fails for the expected behavioral reason, report it as red. -- If it passes immediately, report it as green on arrival; do not weaken the - test or change production code to force red. -- If another failure prevents the selected behavior from being reached, report - the blocker without expanding scope. +- If it passes immediately, report it as green on arrival; do not weaken the test or change production code to force red. +- If another failure prevents the selected behavior from being reached, report the blocker without expanding scope. ## Output diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md index 187ef8d..8410c34 100644 --- a/.agents/skills/test-plan/SKILL.md +++ b/.agents/skills/test-plan/SKILL.md @@ -5,57 +5,35 @@ description: Plan test coverage for one contract unit at a time. Use when identi # Test plan -Produce a compact assurance inventory for one contract unit. A contract unit is -a public factory, adapter, namespace method, parser, or similarly coherent -behavioral boundary; it is not necessarily one physical file. +Produce a compact assurance inventory for one contract unit. A contract unit is a public factory, adapter, namespace method, parser, or similarly coherent behavioral boundary; it is not necessarily one physical file. ## Scope -1. Require the user to name one target contract unit. If the target is absent - or ambiguous, ask before investigating. -2. Stay within that target. Do not rank gaps across the repository or propose - work in unrelated modules. -3. Record cross-unit discoveries under `Parked follow-ups` without pursuing - them. +1. Require the user to name one target contract unit. If the target is absent or ambiguous, ask before investigating. +2. Stay within that target. Do not rank gaps across the repository or propose work in unrelated modules. +3. Record cross-unit discoveries under `Parked follow-ups` without pursuing them. ## Responsibility boundary Before inventorying claims: -1. Identify the behavior the target owns and its settled, separately testable - collaborators from user requirements and the authoritative documentation - and type structure identified by `AGENTS.md`. Implementation may reveal - candidate boundaries, but never behavior or a test oracle; ask when - ownership is unclear. -2. Inspect direct collaborator tests for delegated behavior required by the - target. If required behavior lacks direct evidence, report - `/test-plan ` as a prerequisite and stop before the target - inventory. -3. Inventory only target-owned policy, translation, validation, and observable - wiring. Do not count collaborator conformance as target coverage or require - internal call-count evidence. +1. Identify the behavior the target owns and its settled, separately testable collaborators from user requirements and the authoritative documentation and type structure identified by `AGENTS.md`. Implementation may reveal candidate boundaries, but never behavior or a test oracle; ask when ownership is unclear. +2. Inspect direct collaborator tests for delegated behavior required by the target. If required behavior lacks direct evidence, report `/test-plan ` as a prerequisite and stop before the target inventory. +3. Inventory only target-owned policy, translation, validation, and observable wiring. Do not count collaborator conformance as target coverage or require internal call-count evidence. ## Claims and evidence Gather behavioral claims from the authorities relevant to the target: 1. Requirements supplied by the user. -2. The authoritative contract and mechanism documentation identified by - `AGENTS.md`. +2. The authoritative contract and mechanism documentation identified by `AGENTS.md`. 3. Relevant threat-model decisions and governing standards. -Use the work queue to find known gaps and target tests to classify evidence. -Collaborator tests establish prerequisites and prevent duplication; they never -prove a target-owned claim. Existing tests and implementation do not determine -expected behavior. +Use the work queue to find known gaps and target tests to classify evidence. Collaborator tests establish prerequisites and prevent duplication; they never prove a target-owned claim. Existing tests and implementation do not determine expected behavior. ## Inventory -Build the complete behavioral claim inventory for the target. Group claims by -the unit's real behavioral concerns or failure modes so the groups can orient -the test file. Reuse sibling group names where they fit; never impose a fixed -taxonomy. Include positive behavior, denial or failure behavior, boundaries, -side effects, time, and concurrency only where the authorities require them. +Build the complete behavioral claim inventory for the target. Group claims by the unit's real behavioral concerns or failure modes so the groups can orient the test file. Reuse sibling group names where they fit; never impose a fixed taxonomy. Include positive behavior, denial or failure behavior, boundaries, side effects, time, and concurrency only where the authorities require them. Give every claim exactly one status: @@ -65,18 +43,13 @@ Give every claim exactly one status: - `Unclear` — no authority determines the expected behavior. - `Out of scope` — an authority explicitly places responsibility elsewhere. -Number every claim sequentially in the report. The numbers are local references -for selecting a claim from that report, not permanent identifiers. +Number every claim sequentially in the report. The numbers are local references for selecting a claim from that report, not permanent identifiers. -For covered or partial claims, cite the relevant test by name and file. For -missing or partial claims, briefly state the defect the evidence should catch. -Do not invent requirements or add speculative edge cases. +For covered or partial claims, cite the relevant test by name and file. For missing or partial claims, briefly state the defect the evidence should catch. Do not invent requirements or add speculative edge cases. ## Output -Report an unresolved prerequisite after the responsibility boundary as -`Prerequisite: /test-plan `, then stop. Otherwise use -this structure: +Report an unresolved prerequisite after the responsibility boundary as `Prerequisite: /test-plan `, then stop. Otherwise use this structure: ```text Target: @@ -92,16 +65,12 @@ Questions - Recommended next test -- [] +- [] Parked follow-ups - ``` -Keep each entry concise, but do not cap or sample the inventory. Completeness -is local to the target. +Keep each entry concise, but do not cap or sample the inventory. Completeness is local to the target. -Do not edit files, write test code, or propose implementation. Stop after the -inventory so the user can approve the recommended test target or choose -another. +Do not edit files, write test code, or propose implementation. Stop after the inventory so the user can approve the recommended test target or choose another. diff --git a/examples/AGENTS.md b/examples/AGENTS.md index b20d4b1..786b327 100644 --- a/examples/AGENTS.md +++ b/examples/AGENTS.md @@ -1,13 +1,10 @@ # Example conventions -These rules govern how examples are written. They exist so that examples are -readable for both developers and LLMs, and so that new examples stay consistent. +These rules govern how examples are written. They exist so that examples are readable for both developers and LLMs, and so that new examples stay consistent. ## Example organization -Examples are organized in two levels: **framework** (folder) → **flow + storage** -(example). Framework is the primary dimension because developers start with their -stack, then pick an auth flow. +Examples are organized in two levels: **framework** (folder) → **flow + storage** (example). Framework is the primary dimension because developers start with their stack, then pick an auth flow. ``` examples/ @@ -23,9 +20,7 @@ Inside each framework folder, examples are named `{flow}-{storage}`: Example: `examples/tanstack-start-react/otp-postgres/` -The `-react` suffix appears where the platform supports multiple renderers -(TanStack Start → future SolidJS) or doesn't imply one (Bun). Next.js is -always React, so no suffix. +The `-react` suffix appears where the platform supports multiple renderers (TanStack Start → future SolidJS) or doesn't imply one (Bun). Next.js is always React, so no suffix. ### Example matrix @@ -48,8 +43,7 @@ always React, so no suffix. These live in `tmp/` and use older patterns: -- `tmp/tanstack-start` — full OTP → passkey, uses `authClient` direct and local - atoms. Replaced by `tanstack-start-react/otp-passkey-memory`. +- `tmp/tanstack-start` — full OTP → passkey, uses `authClient` direct and local atoms. Replaced by `tanstack-start-react/otp-passkey-memory`. - `tmp/bun-memory` — minimal in-memory test. Delete anytime. ## Framework-native data fetching @@ -60,44 +54,28 @@ Each framework should use its own data-loading pattern: - **Next.js (App Router)** — server components + `router.refresh()` after mutations - **Bun (plain React)** — `useAsync` from `@repo/auth-react` (no meta-framework) -Never use `useAsync` in a meta-framework example. It exists only for -environments that lack framework-provided data loading. +Never use `useAsync` in a meta-framework example. It exists only for environments that lack framework-provided data loading. ## Atoms hide rendering, examples show behavior -UI atoms (`Page`, `Button`, `Header`, `EmailInput`, `OtpInput`, `Toolbar`) -encapsulate Tailwind styling so examples stay focused on auth logic. Atoms are -nouns you can picture — not verbs that hide processes. +UI atoms (`Page`, `Button`, `Header`, `EmailInput`, `OtpInput`, `Toolbar`) encapsulate Tailwind styling so examples stay focused on auth logic. Atoms are nouns you can picture — not verbs that hide processes. -Auth flow logic (state machines, server calls, validation, error handling) stays -inline in the example. The reader should see the full flow without looking up -hooks or compound components. +Auth flow logic (state machines, server calls, validation, error handling) stays inline in the example. The reader should see the full flow without looking up hooks or compound components. ## Hooks for ceremony complexity only -WebAuthn passkey hooks (`usePasskeyRegistration`, `usePasskeyAuthentication`) -are justified — they orchestrate 3-step async ceremonies with the browser -credential API, try/catch, and loading/error state. Inlining them would bury the -example. +WebAuthn passkey hooks (`usePasskeyRegistration`, `usePasskeyAuthentication`) are justified — they orchestrate 3-step async ceremonies with the browser credential API, try/catch, and loading/error state. Inlining them would bury the example. -OTP flow is inline. It is a simple state machine (email → otp steps) with form -handlers that call server functions directly. This IS the example. +OTP flow is inline. It is a simple state machine (email → otp steps) with form handlers that call server functions directly. This IS the example. ## Shared Zod schemas -Server defines validation schemas (e.g. `requestOtpSchema`, `verifyOtpSchema`). -Client imports and reuses the same schemas for form validation. No separate -client-side regex or validation logic — zero drift between server and client. +Server defines validation schemas (e.g. `requestOtpSchema`, `verifyOtpSchema`). Client imports and reuses the same schemas for form validation. No separate client-side regex or validation logic — zero drift between server and client. ## Minimize Tailwind in examples -Push all styling into atoms in `@repo/auth-react`. A small amount of raw -Tailwind is acceptable for one-off layout (e.g. a button group wrapper or -standalone error text), but repeated patterns should become atoms. +Push all styling into atoms in `@repo/auth-react`. A small amount of raw Tailwind is acceptable for one-off layout (e.g. a button group wrapper or standalone error text), but repeated patterns should become atoms. ## Viewer is app data, not auth -The auth library handles sessions, not user profiles. Examples fetch viewer data -using framework patterns (server components, route loaders, `useAsync`), not -auth-specific hooks. This reinforces the library's boundary: auth proves -identity, your app owns user data. +The auth library handles sessions, not user profiles. Examples fetch viewer data using framework patterns (server components, route loaders, `useAsync`), not auth-specific hooks. This reinforces the library's boundary: auth proves identity, your app owns user data. From cbc91fb61fe0409164041ff1a8d6220f8529a7d6 Mon Sep 17 00:00:00 2001 From: Mikael Lirbank Date: Sat, 25 Jul 2026 12:47:57 -0700 Subject: [PATCH 49/49] Session lifecycle spike --- .../src/spike/session-lifecycle/contracts.ts | 84 +++++ .../session-lifecycle/make-session-auth.ts | 41 +++ .../src/spike/session-lifecycle/mechanisms.ts | 292 ++++++++++++++++++ .../spike/session-lifecycle/target-probes.ts | 144 +++++++++ 4 files changed, 561 insertions(+) create mode 100644 packages/auth/src/spike/session-lifecycle/contracts.ts create mode 100644 packages/auth/src/spike/session-lifecycle/make-session-auth.ts create mode 100644 packages/auth/src/spike/session-lifecycle/mechanisms.ts create mode 100644 packages/auth/src/spike/session-lifecycle/target-probes.ts diff --git a/packages/auth/src/spike/session-lifecycle/contracts.ts b/packages/auth/src/spike/session-lifecycle/contracts.ts new file mode 100644 index 0000000..0de0a20 --- /dev/null +++ b/packages/auth/src/spike/session-lifecycle/contracts.ts @@ -0,0 +1,84 @@ +import type { Result } from "../contracts"; + +/** + * Credentials issued when a session is created or refreshed. + * + * Every mechanism issues an access token. Mechanisms that use the access + * token itself as the server-side session handle return a null refresh token. + */ +export type IssuedSessionCredentials = { + accessToken: string; + refreshToken: string | null; +}; + +/** + * Credentials presented by a client. + * + * Either credential may be absent independently. In particular, a short-lived + * access-token cookie may expire while its refresh-token cookie remains. + */ +export type PresentedSessionCredentials = { + accessToken: string | null; + refreshToken: string | null; +}; + +/** The authenticated session data exposed by core */ +export type SessionIdentity = { + userId: string; +}; + +/** + * Session lifecycle adapter. + * + * The adapter owns session policy and credential mechanics. ReadContext need + * only support validation; WriteContext supplies the capabilities required by + * session creation, refresh, and revocation. + */ +export type SessionAdapter = { + create: ( + context: WriteContext, + userId: string, + ) => Promise; + validate: ( + context: ReadContext, + accessToken: string, + ) => Promise; + refresh: ( + context: WriteContext, + credentials: PresentedSessionCredentials, + ) => Promise; + end: ( + context: WriteContext, + credentials: PresentedSessionCredentials, + ) => Promise; +}; + +/** Config for the candidate session-only core */ +export type MakeSessionAuthConfig = { + session: SessionAdapter; +}; + +/** Candidate session namespace shared by every session mechanism */ +export type SessionNamespace = { + create: (args: { + context: WriteContext; + userId: string; + }) => Promise>; + validate: (args: { + context: ReadContext; + accessToken: string | null; + }) => Promise; + refresh: (args: { + context: WriteContext; + credentials: PresentedSessionCredentials; + }) => Promise>; + end: (args: { + context: WriteContext; + credentials: PresentedSessionCredentials; + }) => Promise>; +}; + +/** Candidate auth shape used by the session-lifecycle spike */ +export type SessionAuth = { + session: SessionNamespace; +}; diff --git a/packages/auth/src/spike/session-lifecycle/make-session-auth.ts b/packages/auth/src/spike/session-lifecycle/make-session-auth.ts new file mode 100644 index 0000000..f06e2a2 --- /dev/null +++ b/packages/auth/src/spike/session-lifecycle/make-session-auth.ts @@ -0,0 +1,41 @@ +import type { MakeSessionAuthConfig, SessionAuth } from "./contracts"; + +/** + * Candidate value-oriented session core. + * + * Credential transport remains outside core. Bindings read credentials from + * their environment and persist the credentials returned by commands. + */ +export function makeSessionAuth( + config: MakeSessionAuthConfig, +): SessionAuth { + return { + session: { + async create({ context, userId }) { + const credentials = await config.session.create(context, userId); + return { success: true, data: credentials }; + }, + + async validate({ context, accessToken }) { + if (accessToken === null) { + return null; + } + + return config.session.validate(context, accessToken); + }, + + async refresh({ context, credentials }) { + const refreshed = await config.session.refresh(context, credentials); + + return refreshed === null + ? { success: false, error: "invalid_token" } + : { success: true, data: refreshed }; + }, + + async end({ context, credentials }) { + await config.session.end(context, credentials); + return { success: true }; + }, + }, + }; +} diff --git a/packages/auth/src/spike/session-lifecycle/mechanisms.ts b/packages/auth/src/spike/session-lifecycle/mechanisms.ts new file mode 100644 index 0000000..36f0b75 --- /dev/null +++ b/packages/auth/src/spike/session-lifecycle/mechanisms.ts @@ -0,0 +1,292 @@ +import type { IssuedSessionCredentials, SessionAdapter } from "./contracts"; + +/** Session state persisted by the candidate mechanisms */ +export type SessionRecord = { + sessionId: string; + userId: string; + absoluteExpiresAt: Date | null; + inactiveExpiresAt: Date | null; +}; + +/** The claims carried by a signed access token */ +export type AccessTokenClaims = { + sessionId: string; + userId: string; +}; + +/** + * Signed access-token codec. + * + * A JWT adapter maps signing to encode and full JWT verification, including + * expiration, to validate. Invalid and expired tokens return null. + */ +export type AccessTokenCodec = { + encode: (claims: AccessTokenClaims, expiresAt: Date) => Promise; + validate: (token: string) => Promise; +}; + +/** Session lifetime policy owned by a session mechanism */ +export type SessionLifetime = { + accessTtl: number; + absoluteTtl: number | null; + inactivityTtl: number | null; +}; + +/** + * Refresh-token persistence. + * + * rotate must atomically reject an unknown, previously used, inactive, or + * absolutely expired current token; otherwise it replaces the token, updates + * inactiveExpiresAt, and returns the updated session. + */ +export type RefreshTokenStorage = { + create: ( + context: WriteContext, + refreshToken: string, + record: SessionRecord, + ) => Promise; + rotate: ( + context: WriteContext, + currentRefreshToken: string, + nextRefreshToken: string, + now: Date, + inactiveExpiresAt: Date | null, + ) => Promise; + delete: (context: WriteContext, refreshToken: string) => Promise; +}; + +/** Input for a signed-access, opaque-refresh session mechanism */ +export type MakeRefreshableSessionsConfig = { + accessToken: AccessTokenCodec; + refreshTokenStorage: RefreshTokenStorage; + lifetime: SessionLifetime; + makeSessionId: () => string; + makeRefreshToken: () => string; + now: () => Date; +}; + +/** + * Builds sessions with a short-lived signed access token and a rotating opaque + * refresh token. + */ +export function makeRefreshableSessions( + config: MakeRefreshableSessionsConfig, +): SessionAdapter { + return { + async create(context, userId) { + const now = config.now(); + const record: SessionRecord = { + sessionId: config.makeSessionId(), + userId, + absoluteExpiresAt: deadline(now, config.lifetime.absoluteTtl), + inactiveExpiresAt: deadline(now, config.lifetime.inactivityTtl), + }; + const refreshToken = config.makeRefreshToken(); + const credentials = await issueRefreshableCredentials( + config, + record, + refreshToken, + now, + ); + + await config.refreshTokenStorage.create(context, refreshToken, record); + + return credentials; + }, + + async validate(context, accessToken) { + void context; + + const claims = await config.accessToken.validate(accessToken); + return claims === null ? null : { userId: claims.userId }; + }, + + async refresh(context, credentials) { + if (credentials.refreshToken === null) { + return null; + } + + const now = config.now(); + const nextRefreshToken = config.makeRefreshToken(); + const inactiveExpiresAt = deadline(now, config.lifetime.inactivityTtl); + const record = await config.refreshTokenStorage.rotate( + context, + credentials.refreshToken, + nextRefreshToken, + now, + inactiveExpiresAt, + ); + + if (record === null) { + return null; + } + + return issueRefreshableCredentials(config, record, nextRefreshToken, now); + }, + + async end(context, credentials) { + if (credentials.refreshToken !== null) { + await config.refreshTokenStorage.delete( + context, + credentials.refreshToken, + ); + } + }, + }; +} + +/** + * Opaque-session persistence. + * + * refresh must atomically reject an unknown, inactive, or absolutely expired + * token; otherwise it updates inactiveExpiresAt and returns the session. + */ +export type OpaqueSessionStorage = { + create: ( + context: WriteContext, + accessToken: string, + record: SessionRecord, + ) => Promise; + get: ( + context: ReadContext, + accessToken: string, + ) => Promise; + refresh: ( + context: WriteContext, + accessToken: string, + now: Date, + inactiveExpiresAt: Date | null, + ) => Promise; + delete: (context: WriteContext, accessToken: string) => Promise; +}; + +/** Input for an opaque session mechanism */ +export type MakeOpaqueSessionsConfig = { + storage: OpaqueSessionStorage; + lifetime: { + absoluteTtl: number | null; + inactivityTtl: number | null; + }; + makeSessionId: () => string; + makeAccessToken: () => string; + now: () => Date; +}; + +/** Builds sessions whose access token is an opaque server-side session handle */ +export function makeOpaqueSessions( + config: MakeOpaqueSessionsConfig, +): SessionAdapter { + return { + async create(context, userId) { + const now = config.now(); + const accessToken = config.makeAccessToken(); + const record: SessionRecord = { + sessionId: config.makeSessionId(), + userId, + absoluteExpiresAt: deadline(now, config.lifetime.absoluteTtl), + inactiveExpiresAt: deadline(now, config.lifetime.inactivityTtl), + }; + + await config.storage.create(context, accessToken, record); + + return { + accessToken, + refreshToken: null, + }; + }, + + async validate(context, accessToken) { + const record = await config.storage.get(context, accessToken); + + return record === null || isExpired(record, config.now()) + ? null + : { userId: record.userId }; + }, + + async refresh(context, credentials) { + if (credentials.accessToken === null) { + return null; + } + + const now = config.now(); + const record = await config.storage.refresh( + context, + credentials.accessToken, + now, + deadline(now, config.lifetime.inactivityTtl), + ); + + return record === null + ? null + : { + accessToken: credentials.accessToken, + refreshToken: null, + }; + }, + + async end(context, credentials) { + if (credentials.accessToken !== null) { + await config.storage.delete(context, credentials.accessToken); + } + }, + }; +} + +async function issueRefreshableCredentials( + config: MakeRefreshableSessionsConfig, + record: SessionRecord, + refreshToken: string, + now: Date, +): Promise { + const expiresAt = earliest( + new Date(now.getTime() + config.lifetime.accessTtl), + record.absoluteExpiresAt, + record.inactiveExpiresAt, + ); + const accessToken = await config.accessToken.encode( + { + sessionId: record.sessionId, + userId: record.userId, + }, + expiresAt, + ); + + return { + accessToken, + refreshToken, + }; +} + +function deadline(now: Date, ttl: number | null): Date | null { + return ttl === null ? null : new Date(now.getTime() + ttl); +} + +function earliest( + required: Date, + first: Date | null, + second: Date | null, +): Date { + const deadlines = [required, first, second].filter( + (value): value is Date => value !== null, + ); + let result = required; + + for (const value of deadlines) { + if (value < result) { + result = value; + } + } + + return result; +} + +function isExpired(record: SessionRecord, now: Date): boolean { + return ( + expired(record.absoluteExpiresAt, now) || + expired(record.inactiveExpiresAt, now) + ); +} + +function expired(expiresAt: Date | null, now: Date): boolean { + return expiresAt !== null && expiresAt < now; +} diff --git a/packages/auth/src/spike/session-lifecycle/target-probes.ts b/packages/auth/src/spike/session-lifecycle/target-probes.ts new file mode 100644 index 0000000..1277315 --- /dev/null +++ b/packages/auth/src/spike/session-lifecycle/target-probes.ts @@ -0,0 +1,144 @@ +/** + * Compile-time integration probes for the candidate session lifecycle. + * + * These are deliberately framework-free. They model the capabilities each + * binding can provide and verify that neither core nor a session mechanism + * needs to change between targets. + */ +import type { + IssuedSessionCredentials, + PresentedSessionCredentials, + SessionAuth, + SessionIdentity, +} from "./contracts"; +import { makeSessionAuth } from "./make-session-auth"; +import type { + MakeOpaqueSessionsConfig, + MakeRefreshableSessionsConfig, +} from "./mechanisms"; +import { makeOpaqueSessions, makeRefreshableSessions } from "./mechanisms"; + +type ReadContext = { + sessionReader: unknown; +}; + +type WriteContext = ReadContext & { + sessionWriter: unknown; +}; + +type CandidateAuth = SessionAuth; + +declare const refreshableConfig: MakeRefreshableSessionsConfig; +declare const opaqueConfig: MakeOpaqueSessionsConfig; + +const refreshableAuth: CandidateAuth = makeSessionAuth({ + session: makeRefreshableSessions( + refreshableConfig, + ), +}); + +const opaqueAuth: CandidateAuth = makeSessionAuth({ + session: makeOpaqueSessions(opaqueConfig), +}); + +type ReadTarget = { + auth: CandidateAuth; + context: ReadContext; + accessToken: string | null; +}; + +type WriteTarget = { + auth: CandidateAuth; + context: WriteContext; + credentials: PresentedSessionCredentials; +}; + +async function validateRequest({ + auth, + context, + accessToken, +}: ReadTarget): Promise { + return auth.session.validate({ context, accessToken }); +} + +async function refreshRequest({ + auth, + context, + credentials, +}: WriteTarget): Promise { + const refreshed = await auth.session.refresh({ + context, + credentials, + }); + + return refreshed.success ? refreshed.data : null; +} + +declare const readContext: ReadContext; +declare const writeContext: WriteContext; +declare const accessToken: string | null; +declare const credentials: PresentedSessionCredentials; + +/* + * Next.js RSC and Convex queries validate using read-only capabilities. + * SSR loaders and ordinary API reads have the same shape. + */ +void validateRequest({ + auth: refreshableAuth, + context: readContext, + accessToken, +}); +void validateRequest({ + auth: opaqueAuth, + context: readContext, + accessToken, +}); + +/* + * Next.js route handlers and server actions, TanStack Start and SolidStart + * server functions, Convex mutations, and vanilla handlers refresh using + * write-capable contexts. + */ +void refreshRequest({ + auth: refreshableAuth, + context: writeContext, + credentials, +}); +void refreshRequest({ + auth: opaqueAuth, + context: writeContext, + credentials, +}); + +/* + * Expo and browser clients send the same credential values to a server + * boundary. Their storage and transport choices do not enter core. + */ +declare const persistClientCredentials: ( + credentials: IssuedSessionCredentials, +) => Promise; + +async function refreshClientSession(target: WriteTarget): Promise { + const refreshed = await refreshRequest(target); + + if (refreshed !== null) { + await persistClientCredentials(refreshed); + } +} + +void refreshClientSession({ + auth: refreshableAuth, + context: writeContext, + credentials, +}); +void refreshClientSession({ + auth: opaqueAuth, + context: writeContext, + credentials, +}); + +void refreshableAuth.session.refresh({ + // @ts-expect-error A read-only RSC or query context cannot refresh a session. + context: readContext, + credentials, +});