diff --git a/.agents/skills/test-one/SKILL.md b/.agents/skills/test-one/SKILL.md new file mode 100644 index 0000000..c98947f --- /dev/null +++ b/.agents/skills/test-one/SKILL.md @@ -0,0 +1,63 @@ +--- +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. 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 + +State briefly: + +- 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. + +## 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. +- 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, never mechanics already clear from the code. + +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: +Oracle: +Test: +Result: +Command: +``` + +Stop after reporting so the user can review the test before implementation. diff --git a/.agents/skills/test-plan/SKILL.md b/.agents/skills/test-plan/SKILL.md new file mode 100644 index 0000000..8410c34 --- /dev/null +++ b/.agents/skills/test-plan/SKILL.md @@ -0,0 +1,76 @@ +--- +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. + +## 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: + +1. Requirements supplied by the user. +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. + +## 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. + +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` — 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. + +## Output + +Report an unresolved prerequisite after the responsibility boundary as `Prerequisite: /test-plan `, then stop. Otherwise use this structure: + +```text +Target: + +Responsibility boundary +- Owns: +- Delegates: + + +- [] : . + +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. + +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/.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 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 1eba366..3a3db95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,36 +1,40 @@ # Agent guidelines -- Use `bun run check` after edits to type check all workspaces +## Commands -## Development workflow +- Use `bun run check` after edits to type check all workspaces -- Use `packages/auth/README.md` as the source of intent — it documents the target API and wins over code -- 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 +## Session start -## Documentation map +Read before working, in order: -Roles (decided 2026-07-16): README = contract, SPEC = rationale, TODO = queue. +1. `packages/auth/src/spike/contracts.ts` + `spike/mechanisms.ts` +2. `TODO.md` +3. `SPEC.md` -Now: +## Documentation map -- `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` + `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 @@ -40,8 +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 -- Match the style and conventions already in the codebase -- 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 @@ -51,28 +54,55 @@ 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 - Never export local symbols -- Use TS/JS style comments +- 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 -- All public API functions return `Result` — never throw +How methods return (commands, queries, adapters, throws) is specified by the `Result` doc block in the contracts. + - 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 -## TDD (critical) +## Tests + +### 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)` + +### 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. + +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 -- 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 +- 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 - 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 diff --git a/SPEC.md b/SPEC.md index cc227f7..b89a150 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,13 +10,14 @@ 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 - **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 @@ -55,14 +56,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 +72,32 @@ 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). + +> **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. +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): @@ -290,7 +296,13 @@ 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-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 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). + +> **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?** @@ -576,7 +588,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,9 +642,11 @@ _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) +- 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:** @@ -641,7 +655,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 diff --git a/bun.lock b/bun.lock index 0225f99..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": { @@ -57,9 +57,9 @@ "@starmode/auth": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -79,9 +79,9 @@ "@starmode/auth": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -101,9 +101,9 @@ "@starmode/auth": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -123,9 +123,9 @@ "@starmode/auth": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -145,9 +145,9 @@ "@starmode/auth": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3", "zod": "^4.4.3", }, @@ -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.32", "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": { @@ -197,11 +197,11 @@ "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", - "typescript-eslint": "^8.64.0", + "typescript-eslint": "^8.65.0", }, "peerDependencies": { "@tanstack/react-start": "^1.161.1", @@ -221,7 +221,7 @@ "peerDependencies": { "@starmode/auth": "workspace:*", "react": "^19.2.7", - "tailwindcss": "^4.3.2", + "tailwindcss": "^4.3.3", }, }, }, @@ -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=="], @@ -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=="], @@ -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=="], @@ -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,17 +831,17 @@ "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=="], "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=="], - "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,11 +859,11 @@ "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=="], - "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,10 +991,10 @@ "@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=="], - - "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/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. 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 c08a6fe..7efedb2 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/otp-memory/package.json @@ -12,9 +12,9 @@ "@repo/auth-react": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "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 ef9e190..0f40969 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-memory/package.json @@ -12,9 +12,9 @@ "@repo/auth-react": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "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 42975ab..c29745e 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/package.json +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/package.json @@ -12,9 +12,9 @@ "@repo/auth-react": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "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 a704721..7989d71 100644 --- a/examples/tanstack-start-react/passkey-memory/package.json +++ b/examples/tanstack-start-react/passkey-memory/package.json @@ -12,9 +12,9 @@ "@repo/auth-react": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "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 3a4422b..ba7c0a2 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/package.json +++ b/examples/tanstack-start-react/passkey-otp-memory/package.json @@ -12,9 +12,9 @@ "@repo/auth-react": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "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 862a52d..f1ef39e 100644 --- a/examples/tmp/tanstack-start/package.json +++ b/examples/tmp/tanstack-start/package.json @@ -11,9 +11,9 @@ "@starmode/auth": "workspace:*", "@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", + "@tanstack/react-start": "^1.168.32", + "react": "^19.2.8", + "react-dom": "^19.2.8", "tailwindcss": "^4.3.3" }, "devDependencies": { diff --git a/package.json b/package.json index ccac9fe..0af147a 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,13 @@ "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", + "prettier": "^3.9.6", "prettier-plugin-tailwindcss": "^0.8.1", "typescript": "^6.0.3", "vitest": "^4.1.10" 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" diff --git a/packages/auth/package.json b/packages/auth/package.json index fef610a..9e2688b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -29,10 +29,10 @@ }, "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", - "typescript-eslint": "^8.64.0" + "typescript-eslint": "^8.65.0" } } diff --git a/packages/auth/src/crypto.test.ts b/packages/auth/src/crypto.test.ts index 505634a..ef8c2d4 100644 --- a/packages/auth/src/crypto.test.ts +++ b/packages/auth/src/crypto.test.ts @@ -20,6 +20,10 @@ describe("base64url encoding/decoding", () => { expect(base64urlDecode(encoded)).toStrictEqual(data); }); + it("base64urlEncode encodes a string as unpadded base64url", () => { + expect(base64urlEncode("{}")).toBe("e30"); + }); + it("base64urlDecode returns null for invalid base64", () => { expect(base64urlDecode("not!valid!base64!")).toBeNull(); }); 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/contracts-typecheck.ts b/packages/auth/src/spike/contracts-typecheck.ts new file mode 100644 index 0000000..23705ce --- /dev/null +++ b/packages/auth/src/spike/contracts-typecheck.ts @@ -0,0 +1,158 @@ +/** + * 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, + Auth, + AuthOtp, + AuthPasskey, + AuthFull, +} from "./contracts"; +import { makeAuth } from "./contracts"; + +declare const config: 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(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(config).withPasskey(passkey).withOtp(otp)); + +/* The session namespace is present at every step */ +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(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(config).otp; + +// @ts-expect-error otp namespace does not exist on a passkey-only instance +void makeAuth(config).withPasskey(passkey).otp; + +// @ts-expect-error passkey namespace does not exist on an otp-only instance +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(config).getSession; + +// @ts-expect-error createSession is not a root method — it is auth.session.create +void makeAuth(config).withOtp(otp).createSession; + +// @ts-expect-error verifyOtp is not a root method — it is auth.otp.verify +void makeAuth(config).withOtp(otp).verifyOtp; + +// @ts-expect-error verifyAuthentication is not a root method — it is auth.passkey.verifyAuthentication +void makeAuth(config).withPasskey(passkey).verifyAuthentication; + +/* Passkey verification is pure — it returns the userId, never a session */ + +declare const verifyResult: Awaited< + ReturnType +>; + +if (verifyResult.success) { + void verifyResult.data.userId; + // @ts-expect-error verification carries no session — create one explicitly via session.create + void verifyResult.data.session; +} + +declare const registrationResult: Awaited< + ReturnType +>; + +if (registrationResult.success) { + void registrationResult.data.userId; + // @ts-expect-error registration carries no session — create one explicitly via session.create + void registrationResult.data.session; +} + +/* Commands without failure modes collapse — the envelope needs no narrowing */ + +declare const created: Awaited>; +void created.success; +void created.data; + +// @ts-expect-error T rides in data, never spread into the envelope +void created.token; + +/* 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 +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 +void makeAuth(config).withOtp(otp).withOtp(otp); + +// @ts-expect-error withPasskey cannot be chained twice +void makeAuth(config).withPasskey(passkey).withPasskey(passkey); + +// @ts-expect-error nothing left to chain after both strategies +void makeAuth(config).withOtp(otp).withPasskey(passkey).withOtp(otp); + +/* Unknown config keys are rejected */ + +// @ts-expect-error unknown key in makeAuth config +void makeAuth({ ...config, unknown: true }); + +// @ts-expect-error unknown key in otp config +void makeAuth(config).withOtp({ ...otp, unknown: true }); + +// @ts-expect-error unknown key in passkey config +void makeAuth(config).withPasskey({ ...passkey, unknown: true }); + +/* Every field is required */ + +// @ts-expect-error delivery is required in otp config +void makeAuth(config).withOtp({ storage: otp.storage }); + +// @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 makeAuth config +void makeAuth({ session: config.session }); diff --git a/packages/auth/src/spike/contracts.ts b/packages/auth/src/spike/contracts.ts new file mode 100644 index 0000000..10c34f2 --- /dev/null +++ b/packages/auth/src/spike/contracts.ts @@ -0,0 +1,414 @@ +/** + * ΛUTH contracts — the typed API spec. + * + * Everything user code touches: adapter interfaces, config shapes, method + * 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. + */ + +/* ──────────────────────────────────────────────────────────────────────── + * 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 + * - *JSON — WebAuthn wire shapes, ambient from lib.dom; never redeclared here + * + * 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 + * - 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 + */ + +export type AuthErrorCode = + | "invalid_otp" + | "invalid_token" + | "challenge_expired" + | "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"; + +/** + * 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. 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 = + | ([T] extends [void] ? { success: true } : { success: true; data: T }) + | ([E] extends [never] ? never : { success: false; error: E }); + +/** + * 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 = { + /** 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; +}; + +/* ──────────────────────────────────────────────────────────────────────── + * Session — the core. + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * 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; + userId: string; + /** Session expiry — slides on activity; null = never expires */ + expiresAt: Date | null; +}; + +/** Session storage adapter — plain reads and writes; core enforces expiry */ +export type SessionStorage = { + store: (record: SessionRecord) => Promise; + get: (sessionId: string) => Promise; + delete: (sessionId: string) => Promise; +}; + +/** Decoded session token. Invalid or forged tokens decode to null. */ +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). + * Self-contained tokens carry the session record; reference (opaque) tokens + * 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 expiry from the codec's own TTL; a Date preserves the supplied expiry */ + encode: ( + record: SessionRecord, + token: { expiresAt: Date | null }, + ) => Promise; + decode: (token: string) => Promise; +}; + +/** Session transport — how the token rides on requests (cookie, header) */ +export type SessionTransport = { + /** Read token from the incoming request */ + get: () => string | null; + /** Store token and return what goes in the response body */ + set: (token: string) => string; + clear: () => void; +}; + +/** 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; +}; + +/** Session methods — the core namespace, present at every step */ +export type SessionNamespace = { + /** + * 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>; + /** The current session's user; null when signed out */ + get: () => Promise<{ userId: string } | null>; + /** Ends the current session (signs the user out) */ + end: () => Promise>; +}; + +/* ──────────────────────────────────────────────────────────────────────── + * OTP — identity verification, optionally authentication. + * ──────────────────────────────────────────────────────────────────────── */ + +/** OTP record — the shape exchanged with OTP storage, not a stored schema */ +export type OtpRecord = { + /** Identifier (email address, phone number, etc.) */ + identifier: string; + otp: string; + /** Stamped by core from WithOtpConfig.ttl */ + expiresAt: Date; +}; + +/** + * OTP storage adapter. + * + * 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; + /** One attempt per OTP: a wrong guess consumes it */ + verify: (identifier: string, otp: string) => Promise; +}; + +/** OTP delivery adapter (email, SMS, console) */ +export type OtpDelivery = { + send: (identifier: string, otp: string) => Promise; +}; + +/** Config for withOtp */ +export type WithOtpConfig = { + storage: OtpStorage; + delivery: OtpDelivery; + /** OTP validity duration in ms — core stamps OtpRecord.expiresAt from it */ + ttl: number; +}; + +/** OTP methods — added as the `otp` namespace by withOtp */ +export type OtpNamespace = { + /** + * 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 */ + verify: (args: { + identifier: string; + otp: string; + }) => Promise>; +}; + +/* ──────────────────────────────────────────────────────────────────────── + * Passkey — WebAuthn authentication. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Credential record — the shape exchanged with CredentialStorage, not a stored schema */ +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; +}; + +/** 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; + /** Persist the WebAuthn signature counter after authentication (clone detection) */ + setCounter: (credentialId: string, counter: number) => Promise; +}; + +/** WebAuthn challenge record (single-use) */ +export type ChallengeRecord = { + challenge: string; + /** Set for registration ceremonies, null for authentication */ + userId: string | null; + /** Stamped by core from WithPasskeyConfig.challenge.ttl */ + expiresAt: Date; +}; + +/** Challenge storage adapter. Challenges are single-use. */ +export type ChallengeStorage = { + store: (record: ChallengeRecord) => Promise; + /** Atomic fetch-and-delete. Unknown challenge returns null. */ + take: (challenge: string) => Promise; +}; + +/** 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; +}; + +/** Decoded registration token. Invalid or forged tokens decode to null. */ +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). + * The validity window is the codec factory's own config; encode mints at + * that expiry. + */ +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 = { + /** 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"]. + * Matched exactly against clientDataJSON.origin: no wildcards, no subdomain + * logic, never inferred from rpId. + */ + 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; + registrationCodec: RegistrationCodec; + webAuthn: WebAuthnConfig; + challenge: ChallengeConfig; +}; + +/** Success data is the registration token */ +export type CreateRegistrationTokenResult = Result; + +/** Success data is the grant the token carries */ +export type ValidateRegistrationTokenResult = Result< + RegistrationGrant, + "invalid_token" +>; + +/** Success data is the WebAuthn creation options */ +export type CreateRegistrationOptionsResult = Result< + PublicKeyCredentialCreationOptionsJSON, + "invalid_token" +>; + +/** Success data is the WebAuthn request options */ +export type CreateAuthenticationOptionsResult = Result< + PublicKeyCredentialRequestOptionsJSON, + never +>; + +/** Success data is the verified userId */ +export type VerifyRegistrationResult = Result< + { userId: string }, + | "invalid_token" + | "challenge_expired" + | "user_mismatch" + | "verification_failed" +>; + +/** Success data is the verified userId */ +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 = { + createRegistrationToken: ( + args: RegistrationGrant, + ) => Promise; + validateRegistrationToken: (args: { + registrationToken: string; + }) => Promise; + createRegistrationOptions: (args: { + registrationToken: string; + }) => Promise; + /** Verifies and stores the credential. Does not create a session — call session.create. */ + verifyRegistration: (args: { + registrationToken: string; + credential: RegistrationResponseJSON; + }) => Promise; + createAuthenticationOptions: () => Promise; + /** Verifies the assertion against the stored credential. Does not create a session — call session.create. */ + verifyAuthentication: (args: { + credential: AuthenticationResponseJSON; + }) => Promise; +}; + +/* ──────────────────────────────────────────────────────────────────────── + * Composition — the builder. Each configured unit adds its namespace. + * Invalid configurations do not compile. + * ──────────────────────────────────────────────────────────────────────── */ + +/** Session-only auth — both strategies still available to chain */ +export type Auth = { + session: SessionNamespace; + withOtp: (config: WithOtpConfig) => AuthOtp; + withPasskey: (config: WithPasskeyConfig) => AuthPasskey; +}; + +/** Sessions + OTP — only withPasskey remains */ +export type AuthOtp = { + session: SessionNamespace; + otp: OtpNamespace; + withPasskey: (config: WithPasskeyConfig) => AuthFull; +}; + +/** Sessions + passkeys — only withOtp remains */ +export type AuthPasskey = { + session: SessionNamespace; + passkey: PasskeyNamespace; + withOtp: (config: WithOtpConfig) => AuthFull; +}; + +/** Everything configured — nothing left to chain */ +export type AuthFull = { + session: SessionNamespace; + otp: OtpNamespace; + passkey: PasskeyNamespace; +}; + +/** The entry point — builds the session core; chain withOtp and withPasskey to add strategies */ +export declare function makeAuth(config: MakeAuthConfig): Auth; diff --git a/packages/auth/src/spike/mechanisms.ts b/packages/auth/src/spike/mechanisms.ts new file mode 100644 index 0000000..e56a26e --- /dev/null +++ b/packages/auth/src/spike/mechanisms.ts @@ -0,0 +1,51 @@ +/** + * ΛUTH mechanisms — the mechanisms-layer spec: adapter logic shipped by the + * library, environment-free. Factories here build correct adapters. Source + * of intent while the API is finalized, alongside contracts.ts. + */ +import type { + OtpRecord, + OtpStorage, + RegistrationCodec, + SessionCodec, +} 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; + +/** Input for makeSessionHmacCodec */ +export type MakeSessionHmacCodecConfig = { + secret: string; + /** Token TTL in ms — the token expiry 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; 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..95d6d3f --- /dev/null +++ b/packages/auth/src/spike/mechanisms/make-session-hmac-codec.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, test, 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.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("session record", () => { + 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 }), + ); + + 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 }; + const decoded = await codec.decode( + await codec.encode(forever, { expiresAt: null }), + ); + + expect(decoded?.record).toStrictEqual(forever); + }); +}); + +describe("token expiry", () => { + 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 }), + ); + + expect(decoded?.token).toStrictEqual({ + expiresAt: new Date(T0.getTime() + MINUTE), + expired: false, + }); + }); + + 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({ + 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); + const decoded = await codec.decode( + await codec.encode(record, { expiresAt: tokenExpiry }), + ); + + expect(decoded?.token).toStrictEqual({ + expiresAt: tokenExpiry, + expired: false, + }); + }); + + 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 }), + ); + + 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. + */ + 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( + await codec.encode(record, { expiresAt: past }), + ); + + expect(decoded).toStrictEqual({ + record, + token: { expiresAt: past, expired: true }, + }); + }); +}); + +describe("invalid or forged tokens", () => { + /** + * Shape violations fail closed instead of escaping as parsing errors. + * This evidence licenses decode's error boundary. + */ + 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." }, + ])("decode returns null for $name", async ({ token }) => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + + expect(await codec.decode(token)).toBeNull(); + }); + + 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(); + }); + + test("decode returns null for a signature-valid token with undecodable carried data", async () => { + const codec = makeSessionHmacCodec({ secret: "secret-1", ttl: MINUTE }); + vi.spyOn(crypto.subtle, "verify").mockResolvedValueOnce(true); + + // `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({ + secret: "secret-2", + ttl: MINUTE, + }); + const foreign = await foreignCodec.encode(record, { expiresAt: null }); + + expect(await codec.decode(foreign)).toBeNull(); + }); + + test.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"), + }, + ])( + "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(); + }, + ); + + 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"); + vi.spyOn(crypto.subtle, "verify").mockRejectedValueOnce(failure); + + await expect(codec.decode(token)).rejects.toBe(failure); + }); +}); + +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"); + 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"); + vi.spyOn(crypto.subtle, "verify").mockRejectedValueOnce(failure); + + await expect(codec.decode(token)).rejects.toBe(failure); + }); +}); 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..8649080 --- /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 token expiry 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"); +} diff --git a/packages/auth/src/spike/playground.ts b/packages/auth/src/spike/playground.ts new file mode 100644 index 0000000..c02f55a --- /dev/null +++ b/packages/auth/src/spike/playground.ts @@ -0,0 +1,88 @@ +/** + * Playground — exercises the contracts with no-op adapters. + * Note every no-op fails closed: a do-nothing auth denies everything. + */ +import { makeAuth } from "./contracts"; + +export const auth = makeAuth({ + 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, + }, + }, + debug: false, +}); + +auth.session.create({ userId: "123" }); +auth.session.end(); + +const otpAuth = auth.withOtp({ + ttl: 0, + storage: { + verify: async () => false, + store: async () => undefined, + }, + delivery: { + send: async () => undefined, + }, +}); + +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, + }, + registrationCodec: { + encode: async () => "", + decode: async () => null, + }, + webAuthn: { + rpId: "localhost", + rpName: "Spike", + allowedOrigins: [], + }, +}); + +passkey.passkey.createAuthenticationOptions(); + +// 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({ + ttl: 0, + storage: { + verify: async () => false, + store: async () => undefined, + }, + delivery: { + send: async () => undefined, + }, +}); + +passkeyAndOtp.otp.verify({ identifier: "test@example.com", otp: "123456" }); 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, +});