From c5b48d4939dd502798192a46c4450bac987aeb3e Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 00:05:06 +0100 Subject: [PATCH 01/12] feat(core): add pluggable storage with `webStorage` and `memoryStorage` - Add a `storage` option and `AuthStorage` type, with `webStorage()` (`localStorage` default, or `sessionStorage` per tab) and `memoryStorage()` - Apply storage operations serially in issue order and re-check the session after each read, so late or out-of-order async storage cannot erase a newer sign-in - Add storage tests and a `test:async` run that defers every storage operation --- CHANGELOG.md | 14 +++ README.md | 24 ++-- package.json | 4 +- src/core/client.ts | 238 +++++++++++++++++++++++++++------------- src/core/persistence.ts | 88 ++++++++------- src/core/storage.ts | 99 +++++++++++++++++ src/core/types.ts | 9 +- src/index.ts | 2 + tests/async-storage.env | 2 + tests/consumer.ts | 29 ++++- tests/helpers.ts | 37 +++++++ tests/package.test.ts | 9 +- tests/storage.test.ts | 158 ++++++++++++++++++++++++++ 13 files changed, 580 insertions(+), 133 deletions(-) create mode 100644 src/core/storage.ts create mode 100644 tests/async-storage.env create mode 100644 tests/storage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 751a63d..54130f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.1.0-alpha.4 + +### Added + +- `createAuth` accepts a `storage` option. `webStorage()`, the default, saves + the session in `localStorage`; `webStorage({ area: "session" })` uses + `sessionStorage`, so each tab has its own session that ends with the tab. + `memoryStorage()` keeps the session in memory only. Custom storage implements + the new `AuthStorage` type, and its operations may return promises. +- Auth applies storage operations one at a time, in the order it issued them, + and re-checks its own session after every read. Storage that settles late or + out of order cannot erase a newer sign-in, and a profile update cannot + restore tokens this tab refreshed meanwhile. + ## 0.1.0-alpha.3 ### Added diff --git a/README.md b/README.md index 37a3a3a..2622bd7 100644 --- a/README.md +++ b/README.md @@ -594,6 +594,12 @@ the remaining data after storage becomes available. 0.1.0-alpha.1 (`:auth:tokens`, `:auth:user`) are removed, not restored; users sign in once after upgrading. Tokens are never placed in cookies, so the browser does not send them automatically; `createAuthFetch` attaches the access token explicitly. +- The `storage` option chooses where that entry lives. The default, + `webStorage()`, uses `localStorage`; `webStorage({ area: "session" })` keeps + a separate session per tab in `sessionStorage`, ending with the tab; and + `memoryStorage()` never persists it. Any object implementing `AuthStorage` + works, including storage whose operations return promises: auth applies + them one at a time, in the order it issued them. - `maxAge` is in seconds, defaults to 30 days, and renews on successful writes. Expired entries are removed on the next restoration. It does not extend backend token validity. A full storage quota fails visibly with `PERSISTENCE_FAILED`. @@ -631,11 +637,11 @@ the remaining data after storage becomes available. ## Entry points -| Import | Exports | -| ------------------------------ | -------------------------------------------------------------------------------- | -| `@monarcode/session-kit` | `createAuth`, `createRefreshFn`, `AuthError`, public auth types | -| `@monarcode/session-kit/react` | `connectAuth`, `safeReturnTo`, `useAuth`, `useAuthClient`, registered hook types | -| `@monarcode/session-kit/http` | `createAuthFetch` | +| Import | Exports | +| ------------------------------ | ---------------------------------------------------------------------------------------------- | +| `@monarcode/session-kit` | `createAuth`, `createRefreshFn`, `webStorage`, `memoryStorage`, `AuthError`, public auth types | +| `@monarcode/session-kit/react` | `connectAuth`, `safeReturnTo`, `useAuth`, `useAuthClient`, registered hook types | +| `@monarcode/session-kit/http` | `createAuthFetch` | Generated declarations retain schema inference and consumer Router registration. Declaration maps are disabled so declaration navigation targets installed `.d.ts` @@ -656,7 +662,9 @@ React hook, package smoke, and release validation tests, then type-checks the tests, consumer declarations, and every marked TypeScript example in this README under NodeNext and Bundler resolution. The docs checker supplies the generated route tree that a consumer's Router plugin owns. -After building, `pnpm test` runs runtime tests and `pnpm run test:docs` checks +After building, `pnpm test` runs the runtime tests twice: `test:sync` with the +default storage, and `test:async` with storage that settles every operation +late, removals last. `pnpm run test:docs` checks README examples alone. The documentation checker extracts the examples into a temporary project; it does not execute them or contact the example backend. @@ -670,8 +678,8 @@ CI and release workflows disable Husky and enforce these checks through `pnpm run check`. Tests are TypeScript files that Node runs directly, without a build step. Behavior -tests are split by area (sessions, refresh, expiry, Router, HTTP, return URLs, -and tabs) and share the browser mocks in `tests/helpers.ts`. They use mocked +tests are split by area (sessions, storage, refresh, expiry, Router, HTTP, +return URLs, and tabs) and share the browser mocks in `tests/helpers.ts`. They use mocked storage, HTTP, and timers, plus a real Router with memory history. The `development` condition enables its client redirect test. Hook tests mount React DOM in jsdom, including StrictMode subscription cleanup. diff --git a/package.json b/package.json index 9f70960..b15f977 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,9 @@ "build": "pnpm run clean && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --conditions=development --test tests/*.test.ts", + "test": "pnpm run test:sync && pnpm run test:async", + "test:sync": "node --conditions=development --test tests/*.test.ts", + "test:async": "node --env-file=tests/async-storage.env --conditions=development --test tests/*.test.ts", "test:types": "tsc -p tests/tsconfig.tests.json && tsc -p tests/tsconfig.json && tsc -p tests/tsconfig.bundler.json && pnpm run test:docs", "test:docs": "node scripts/check-readme.mjs", "check": "pnpm run lint && pnpm run format:check && pnpm run build && pnpm test && pnpm run test:types", diff --git a/src/core/client.ts b/src/core/client.ts index 05e5f4b..e775035 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -4,6 +4,7 @@ import { atom } from "nanostores"; import { AuthError, toAuthError, sessionChanged } from "./errors.js"; import { createPersistence, type StoredSession } from "./persistence.js"; import { obtainTokens } from "./refresh.js"; +import { webStorage } from "./storage.js"; import { runTask } from "./task.js"; import { setLongTimeout, proactiveRefreshDelay } from "./timers.js"; import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js"; @@ -97,7 +98,11 @@ export function createAuth( ): AuthClient, User> { type U = User; type I = UserInput; - const persistence = createPersistence(options.name, options.maxAge); + const persistence = createPersistence( + options.storage ?? webStorage(), + options.name, + options.maxAge, + ); const store = atom>({ status: "initializing", user: null, @@ -122,6 +127,12 @@ export function createAuth( let initialized = false; let initialization: Promise | undefined; + // Storage operations settle after the decisions that issued them, so this + // tab can change its session while it waits for a read. `revision` + // increments on every such change; work that reads storage compares it + // across the read to learn whether this tab has decided something newer. + let revision = 0; + // Refresh. `flight` is the single refresh in progress. `failure` is its // error, kept only while access is unusable, so `getSession` rethrows it // until `retry()`. `refreshAttempted` allows one proactive attempt per @@ -145,13 +156,15 @@ export function createAuth( /** * Reads what storage holds for session `id`. Every tab shares it, so another * tab may have refreshed this session, signed out, or signed in again. - * Returns the saved session (possibly newer than memory), `null` when this + * Resolves the saved session (possibly newer than memory), `null` when this * tab's session is no longer the saved one, or `undefined` when storage is * unreadable and memory is the best remaining source. */ - function latest(id: string): StoredSession | null | undefined { + async function latest( + id: string, + ): Promise { try { - const stored = persistence.read(); + const stored = await persistence.read(); return stored?.id === id ? stored : null; } catch { return undefined; @@ -238,6 +251,7 @@ export function createAuth( function forget(error: AuthError | null = null) { advanceEpoch(); session = null; + revision++; initialized = true; rejectedToken = undefined; failure = undefined; @@ -245,33 +259,54 @@ export function createAuth( } /** Signs out every tab because the saved session cannot be used, then clears it. */ - function discardSaved(error: AuthError) { + async function discardSaved(error: AuthError) { forget(error); + const expected = epoch; try { - persistence.clear(); + await persistence.clear(); } catch (cleanup) { const failure = new AuthError( "PERSISTENCE_FAILED", "Could not clear the invalid saved session", { cause: new AggregateError([error, cleanup]) }, ); - publish(failure); + // A session started since then is not the one that failed to clear. + if (epoch === expected) publish(failure); throw failure; } } - function commit(next: InternalSession) { + /** Makes `next` current in memory, without publishing it. */ + function adopt(next: InternalSession) { + session = next; + revision++; + initialized = true; + failure = undefined; + schedule(); + } + + /** + * Makes `next` current, saves it, then publishes it. `next` is current from + * the call onward, before the first `await`, so later work in this tab + * builds on it. State shows it only once saved. If saving fails while + * `next` is still current, signs out and clears storage, so memory and + * storage cannot disagree. + */ + async function commit(next: InternalSession) { + adopt(next); try { - persistence.write(next); + await persistence.write(next); } catch (cause) { const error = toAuthError( "PERSISTENCE_FAILED", "Could not save the session", cause, ); + // A newer session already replaced `next`, and saves itself. + if (session !== next) throw error; forget(error); try { - persistence.clear(); + await persistence.clear(); } catch (cleanup) { throw new AuthError( "PERSISTENCE_FAILED", @@ -283,16 +318,14 @@ export function createAuth( } throw error; } - install(next); + // A newer session that replaced `next` publishes itself. + if (session === next) publish(); } /** Uses `next` without saving it, because storage already holds it. */ function install(next: InternalSession) { - session = next; - initialized = true; - failure = undefined; + adopt(next); publish(); - schedule(); } /** @@ -313,6 +346,7 @@ export function createAuth( async function reload() { advanceEpoch(); session = null; + revision++; initialized = false; rejectedToken = undefined; failure = undefined; @@ -329,17 +363,21 @@ export function createAuth( * whose user this tab's schema rejects signs every tab out, as restoring * it would. */ - async function sync() { + async function sync(): Promise { if (!initialized) { if (initialization) await initialization.then(sync, () => {}); return; } + const before = revision; let stored: StoredSession | null; try { - stored = persistence.read(); + stored = await persistence.read(); } catch { return; } + // This tab changed its session during the read. That change is newer + // than what was read, and storage receives it next. + if (revision !== before) return; if (!stored) { if (session) forget(); return; @@ -351,52 +389,71 @@ export function createAuth( stored.refreshToken === session.refreshToken && stored.expiresAt === session.expiresAt; if (userUnchanged && tokensUnchanged) return; - const expected = epoch; - let user: U; - try { - user = userUnchanged - ? session.user - : await runTask( - () => validateUser(options.userSchema, stored.user), - controller.signal, - ); - } catch (cause) { - if (epoch !== expected) return; - const error = toAuthError( - "PERSISTENCE_FAILED", - "Could not apply another tab's session", - cause, - ); - if (error.code === "SESSION_CHANGED") return; - if (!rejectsSaved(error)) { - publish(error); + let user = session.user; + if (!userUnchanged) { + const expected = epoch; + try { + user = await runTask( + () => validateUser(options.userSchema, stored.user), + controller.signal, + ); + } catch (cause) { + if (epoch !== expected) return; + const error = toAuthError( + "PERSISTENCE_FAILED", + "Could not apply another tab's session", + cause, + ); + if (error.code === "SESSION_CHANGED") return; + if (!rejectsSaved(error)) { + publish(error); + return; + } + // A newer save arrived during validation; its own event applies it. + const check = revision; + const current = await latest(stored.id); + if ( + epoch !== expected || + revision !== check || + !current || + !sameUser(current.user, stored.user) + ) + return; + await discardSaved(error); return; } - // A newer save arrived during validation; its own event applies it. - const current = latest(stored.id); - if (!current || !sameUser(current.user, stored.user)) return; - discardSaved(error); - return; + // Apply the user only while storage still holds what was validated. + const check = revision; + const now = await latest(stored.id); + if ( + epoch !== expected || + revision !== check || + !now || + now.accessToken !== stored.accessToken || + !sameUser(now.user, stored.user) + ) + return; } - const now = latest(stored.id); - if ( - epoch !== expected || - !now || - now.accessToken !== stored.accessToken || - !sameUser(now.user, stored.user) - ) - return; if (!tokensUnchanged) refreshAttempted = false; if (!userUnchanged) profileVersion++; install({ ...tokensOf(stored), id: stored.id, user }); } /** - * Signs out after the backend rejected session `id`. If another tab has - * already replaced that session in storage, keeps the replacement. + * Signs out after the backend rejected `accessToken`, session `id`'s token. + * If another tab has already replaced that session in storage, keeps the + * replacement. Resolves `undefined`, ending nothing, if this tab replaced + * the token while storage was read. */ - async function endSession(id: string): Promise | null> { - if (latest(id) === null) return followStorage(id); + async function endSession( + id: string, + accessToken: string, + ): Promise | null | undefined> { + const stored = await latest(id); + if (!session) return null; + if (session.id !== id) throw sessionChanged(); + if (session.accessToken !== accessToken) return undefined; + if (stored === null) return followStorage(id); await signOut(); return null; } @@ -407,7 +464,8 @@ export function createAuth( const expected = epoch; const promise = (async () => { try { - const stored = persistence.read(); + const stored = await persistence.read(); + assertEpoch(expected); if (stored) { const user = await runTask( () => validateUser(options.userSchema, stored.user), @@ -415,6 +473,7 @@ export function createAuth( ); assertEpoch(expected); session = { ...stored, user }; + revision++; } assertEpoch(expected); initialized = true; @@ -428,7 +487,7 @@ export function createAuth( cause, ); if (rejectsSaved(error)) { - discardSaved(error); + await discardSaved(error); return; } const previous = store.get(); @@ -475,7 +534,7 @@ export function createAuth( rejectedToken = undefined; refreshAttempted = false; profileVersion++; - commit({ ...tokens, id: crypto.randomUUID(), user }); + await commit({ ...tokens, id: crypto.randomUUID(), user }); } catch (cause) { throw toAuthError( "USER_VALIDATION_FAILED", @@ -495,15 +554,17 @@ export function createAuth( async function signOut() { forget(); + const expected = epoch; try { - persistence.clear(); + await persistence.clear(); } catch (cause) { const error = toAuthError( "PERSISTENCE_FAILED", "Could not clear the saved session", cause, ); - publish(error); + // A session started since then is not the one that failed to clear. + if (epoch === expected) publish(error); throw error; } } @@ -525,19 +586,32 @@ export function createAuth( : input; return validateUser(options.userSchema, value); }, controller.signal); - assertEpoch(expected); - if (intent !== profileIntent) throw sessionChanged(); - if (!session || session.id !== current.id || !valid()) - throw sessionChanged(); - const stored = latest(session.id); + /** The session to update, unless a newer update or a session change made this one obsolete. */ + const target = () => { + assertEpoch(expected); + if (intent !== profileIntent) throw sessionChanged(); + if (!session || session.id !== current.id || !valid()) + throw sessionChanged(); + return session; + }; + let active = target(); + // Read again if this tab changed its session during the read: the read + // could hold tokens that change already replaced. + let stored: StoredSession | null | undefined; + let before: number; + do { + before = revision; + stored = await latest(active.id); + active = target(); + } while (revision !== before); if (stored === null) { - await followStorage(session.id); + await followStorage(active.id); throw sessionChanged(); } - if (stored && stored.accessToken !== session.accessToken) + if (stored && stored.accessToken !== active.accessToken) refreshAttempted = false; profileVersion++; - commit({ ...session, ...(stored && tokensOf(stored)), user }); + await commit({ ...active, ...(stored && tokensOf(stored)), user }); } async function refreshSession( @@ -552,8 +626,10 @@ export function createAuth( if (!valid()) publish(store.get().error); return flight; } - if (!options.refresh || !session.refreshToken) - return endSession(session.id); + if (!options.refresh || !session.refreshToken) { + const ended = await endSession(session.id, session.accessToken); + return ended === undefined ? getSession() : ended; + } const expected = epoch; const profile = profileVersion; const current = session; @@ -565,7 +641,8 @@ export function createAuth( try { const run = async (): Promise> => { assertEpoch(expected); - const stored = latest(current.id); + const stored = await latest(current.id); + assertEpoch(expected); if (stored === null) return { kind: "stale" }; const result = await runTask( (signal) => @@ -581,7 +658,8 @@ export function createAuth( ); assertEpoch(expected); if (result === null) { - const after = latest(current.id); + const after = await latest(current.id); + assertEpoch(expected); if ( after === null || (after && @@ -606,17 +684,27 @@ export function createAuth( "Refresh returned the rejected access token", ); } + const saved = await latest(current.id); + assertEpoch(expected); + if (saved === null) return { kind: "stale" }; + // Decide the user only now, after the last read: a profile update + // committed meanwhile must win over the user the refresh returned. const user = profile === profileVersion ? (result.user ?? session!.user) : session!.user; rejectedToken = undefined; refreshAttempted = false; - if (latest(current.id) === null) return { kind: "stale" }; const next = { ...result.tokens, id: current.id, user }; - if (result.adopted && profile === profileVersion) install(next); - else commit(next); - return { kind: "installed", session: snapshot() }; + if (result.adopted && profile === profileVersion) { + install(next); + return { kind: "installed", session: snapshot() }; + } + const saving = commit(next); + const installed = snapshot(); + await saving; + assertEpoch(expected); + return { kind: "installed", session: installed }; }; const outcome = await persistence.exclusive(lifecycle, run); switch (outcome.kind) { @@ -721,7 +809,7 @@ export function createAuth( session?.id === captured.id && session.accessToken === captured.accessToken ) - await endSession(captured.id); + await endSession(captured.id, captured.accessToken); }, }; } diff --git a/src/core/persistence.ts b/src/core/persistence.ts index e9a13bd..32a1cb2 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -1,5 +1,6 @@ import { AuthError } from "./errors.js"; import { isRecord } from "./json.js"; +import type { AuthStorage } from "./storage.js"; import { validateTokens, type SessionTokens } from "./tokens.js"; export type StoredSession = SessionTokens & { id: string; user: unknown }; @@ -9,7 +10,17 @@ const STORAGE_VERSION = 3; /** Thirty days. */ const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; +/** Reports a storage failure, keeping auth's own errors as they are. */ +function storageError(cause: unknown): AuthError { + return cause instanceof AuthError + ? cause + : new AuthError("PERSISTENCE_FAILED", "Auth storage is unavailable", { + cause, + }); +} + export function createPersistence( + storage: AuthStorage, name: string, maxAge = DEFAULT_MAX_AGE_SECONDS, ) { @@ -26,27 +37,24 @@ export function createPersistence( const legacyKeys = [`${name}:auth:tokens`, `${name}:auth:user`]; const lockName = `${name}:auth:refresh`; - function storageOperation(work: () => T): T { - try { - return work(); - } catch (cause) { - if (cause instanceof AuthError) throw cause; - throw new AuthError( - "PERSISTENCE_FAILED", - "Auth storage is unavailable", - { - cause, - }, - ); - } + // Operations run one at a time, in the order requested. Storage that settles + // out of order still ends up holding the latest write, and a read sees every + // write requested before it. + let queue: Promise = Promise.resolve(); + function enqueue(operation: () => Promise): Promise { + const result = queue.then(operation).catch((cause: unknown) => { + throw storageError(cause); + }); + queue = result.catch(() => {}); + return result; } - function clear() { - storageOperation(() => { + function clear(): Promise { + return enqueue(async () => { const failures: unknown[] = []; for (const entry of [key, ...legacyKeys]) { try { - localStorage.removeItem(entry); + await storage.remove(entry); } catch (error) { failures.push(error); } @@ -56,11 +64,11 @@ export function createPersistence( }); } - function read(): StoredSession | null { - return storageOperation(() => { - const text = localStorage.getItem(key); + function read(): Promise { + return enqueue(async () => { + const text = await storage.get(key); if (!text) { - for (const entry of legacyKeys) localStorage.removeItem(entry); + for (const entry of legacyKeys) await storage.remove(entry); return null; } try { @@ -92,47 +100,37 @@ export function createPersistence( }); } - function write(session: StoredSession) { - storageOperation(() => { - localStorage.setItem( + function write(session: StoredSession): Promise { + return enqueue(async () => { + const persistUntil = Date.now() + maxAge * 1000; + await storage.set( key, - JSON.stringify({ - v: STORAGE_VERSION, - ...session, - persistUntil: Date.now() + maxAge * 1000, - }), + JSON.stringify({ v: STORAGE_VERSION, ...session, persistUntil }), + { expiresAt: persistUntil }, ); }); } /** - * Runs `work` while holding this auth name's lock, shared by every tab, so - * only one tab at a time spends a refresh token. Without Web Locks it runs - * directly. Aborting `signal` stops waiting for the lock. Once granted, the - * lock is held until `work` settles, so `work` must bound its own duration. + * Runs `work` while holding this auth name's lock, shared by every context + * using the storage, so only one at a time spends a refresh token. Without a + * lock it runs directly. Aborting `signal` stops waiting for the lock. Once + * granted, the lock is held until `work` settles, so `work` must bound its + * own duration. */ function exclusive( signal: AbortSignal, work: () => Promise, ): Promise { - const locks = globalThis.navigator?.locks; - if (!locks) return work(); - return locks.request(lockName, { signal }, work); + return storage.lock ? storage.lock(lockName, signal, work) : work(); } /** - * Calls `listener` when another tab saves or clears the session. Browsers - * fire `storage` events only in other tabs, never in the tab that wrote. - * Returns a function that stops listening. + * Calls `listener` when another context saves or clears the session, and + * returns a function that stops listening. */ function watch(listener: () => void): () => void { - if (typeof globalThis.addEventListener !== "function") return () => {}; - const onStorage = (event: Event) => { - const changed = (event as StorageEvent).key; - if (changed === key || changed === null) listener(); - }; - globalThis.addEventListener("storage", onStorage); - return () => globalThis.removeEventListener("storage", onStorage); + return storage.subscribe?.(key, listener) ?? (() => {}); } return { read, write, clear, exclusive, watch }; diff --git a/src/core/storage.ts b/src/core/storage.ts new file mode 100644 index 0000000..12968b4 --- /dev/null +++ b/src/core/storage.ts @@ -0,0 +1,99 @@ +/** A value, or a promise of it, so storage can be synchronous or not. */ +type MaybePromise = T | Promise; + +/** + * Where auth saves the session. Values are strings whose format auth owns. + * Operations may return promises; auth applies them one at a time, in the + * order it issued them, so storage that settles out of order is safe. + */ +export type AuthStorage = { + get: (key: string) => MaybePromise; + /** + * `meta.expiresAt` is when auth stops restoring the saved session, in Unix + * milliseconds, for storage that can expire entries itself. + */ + set: ( + key: string, + value: string, + meta: { expiresAt: number }, + ) => MaybePromise; + remove: (key: string) => MaybePromise; + /** + * Calls `onChange` when another context, such as another tab, changes `key`, + * and returns a function that stops listening. Omit it where no other + * context shares the storage. + */ + subscribe?: (key: string, onChange: () => void) => () => void; + /** + * Runs `work` while holding lock `name` across every context sharing this + * storage, so only one spends a refresh token at a time. Aborting `signal` + * stops waiting for the lock. Omit it where no other context shares the + * storage. + */ + lock?: ( + name: string, + signal: AbortSignal, + work: () => Promise, + ) => Promise; +}; + +export type WebStorageOptions = { + /** + * `"local"` keeps the session across browser restarts and shares it with + * every tab. `"session"` gives each tab its own session, which ends with the + * tab. Default: `"local"`. + */ + area?: "local" | "session"; +}; + +/** + * Saves the session in `localStorage`, or in `sessionStorage` with + * `{ area: "session" }`. Tabs follow each other's changes through `storage` + * events, and coordinate refresh through Web Locks where the browser has them. + * Browser storage is read only when used, so creating auth on a server is safe. + */ +export function webStorage(options: WebStorageOptions = {}): AuthStorage { + const area = options.area ?? "local"; + if (area !== "local" && area !== "session") { + throw new Error(`webStorage area must be "local" or "session": ${area}`); + } + const target = () => + area === "local" ? globalThis.localStorage : globalThis.sessionStorage; + return { + get: (key) => target().getItem(key), + set: (key, value) => target().setItem(key, value), + remove: (key) => target().removeItem(key), + subscribe(key, onChange) { + if (typeof globalThis.addEventListener !== "function") return () => {}; + const onStorage = (event: Event) => { + // A `null` key means the whole area was cleared. + const changed = (event as StorageEvent).key; + if (changed === key || changed === null) onChange(); + }; + globalThis.addEventListener("storage", onStorage); + return () => globalThis.removeEventListener("storage", onStorage); + }, + lock(name, signal, work) { + const locks = globalThis.navigator?.locks; + if (!locks) return work(); + return locks.request(name, { signal }, work); + }, + }; +} + +/** + * Keeps the session in memory, so it ends when the page unloads and other tabs + * never see it. Useful in tests, and where credentials must not be persisted. + */ +export function memoryStorage(): AuthStorage { + const entries = new Map(); + return { + get: (key) => entries.get(key) ?? null, + set(key, value) { + entries.set(key, value); + }, + remove(key) { + entries.delete(key); + }, + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index b53c41a..5f60fd3 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,6 +1,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { AuthError } from "./errors.js"; +import type { AuthStorage } from "./storage.js"; /** What the user schema accepts: the shape passed to `signIn` and `updateUser`. */ export type UserInput = @@ -169,10 +170,14 @@ export type AuthClient = { export type AuthOptions = { /** * Storage namespace: letters, digits, `_` or `-`, up to 64 characters. Uses - * the localStorage key `:auth:session` and the Web Lock - * `:auth:refresh`. + * the storage key `:auth:session` and the lock `:auth:refresh`. */ name: string; + /** + * Where the session is saved. Default: `webStorage()`, which uses + * `localStorage`. See `AuthStorage` to save it elsewhere. + */ + storage?: AuthStorage; /** * Standard Schema V1 schema for the user. Its output must be a plain JSON * object and must validate again when restored from storage. diff --git a/src/index.ts b/src/index.ts index 6650b69..d4514c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ export { createAuth } from "./core/client.js"; export { createRefreshFn } from "./core/refresh.js"; export { AuthError } from "./core/errors.js"; +export { memoryStorage, webStorage } from "./core/storage.js"; export type { AuthErrorCode, AuthErrorOptions } from "./core/errors.js"; +export type { AuthStorage, WebStorageOptions } from "./core/storage.js"; export type { AuthClient, diff --git a/tests/async-storage.env b/tests/async-storage.env new file mode 100644 index 0000000..2cd342c --- /dev/null +++ b/tests/async-storage.env @@ -0,0 +1,2 @@ +# Loaded by `pnpm run test:async`: test clients defer every storage operation. +SESSION_KIT_TEST_STORAGE=async diff --git a/tests/consumer.ts b/tests/consumer.ts index a0502cd..ff07c38 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -5,7 +5,13 @@ import { import { z } from "zod"; import { createAuthFetch } from "../dist/http/index.js"; -import { createAuth, createRefreshFn } from "../dist/index.js"; +import { + createAuth, + createRefreshFn, + memoryStorage, + webStorage, + type AuthStorage, +} from "../dist/index.js"; import { connectAuth, useAuth, useAuthClient } from "../dist/react/index.js"; const auth = createAuth({ @@ -56,3 +62,24 @@ createAuth({ // @ts-expect-error Refresh results must match the schema input. refresh: wrongRefresh, }); + +// Storage may be synchronous or asynchronous. +const asyncStorage: AuthStorage = { + get: async () => null, + set: async () => {}, + remove: async () => {}, +}; +for (const storage of [ + asyncStorage, + memoryStorage(), + webStorage({ area: "session" }), +]) { + createAuth({ + name: "stored", + userSchema: z.object({ id: z.string() }), + storage, + }); +} +// @ts-expect-error Storage values are strings. +const numericStorage: AuthStorage = { get: () => 1, set() {}, remove() {} }; +void numericStorage; diff --git a/tests/helpers.ts b/tests/helpers.ts index 4b3f2e2..b95fcac 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -3,10 +3,12 @@ import { afterEach, beforeEach } from "node:test"; import { createAuth, + webStorage, type AuthClient, type AuthErrorCode, type AuthOptions, type AuthState, + type AuthStorage, type SignInInput, } from "@monarcode/session-kit"; import { connectAuth } from "@monarcode/session-kit/react"; @@ -44,6 +46,7 @@ const browserGlobals = [ "location", "document", "localStorage", + "sessionStorage", "fetch", "self", "scrollTo", @@ -88,6 +91,39 @@ export const input = ( ...overrides, }); +/** + * Settles every operation after some microtasks, removals last by default, as + * asynchronous storage can. Operations issued in order would then complete + * out of order unless auth waits for each one. + */ +export const deferredStorage = ( + inner: AuthStorage, + delays: Partial> = {}, +): AuthStorage => { + const ticks = { get: 1, set: 2, remove: 3, ...delays }; + const after = async ( + count: number, + operation: () => T | Promise, + ): Promise => { + for (let tick = 0; tick < count; tick++) await Promise.resolve(); + return operation(); + }; + return { + get: (key) => after(ticks.get, () => inner.get(key)), + set: (key, value, meta) => + after(ticks.set, () => inner.set(key, value, meta)), + remove: (key) => after(ticks.remove, () => inner.remove(key)), + subscribe: inner.subscribe, + lock: inner.lock, + }; +}; + +/** + * `pnpm test` runs the suite twice: with the default synchronous storage, and + * with `async-storage.env` setting this so every client defers its storage. + */ +const asyncStorage = process.env.SESSION_KIT_TEST_STORAGE === "async"; + export type TestClient = ReturnType>; /** A client named `test`, using `userSchema` unless another schema is given. */ @@ -101,6 +137,7 @@ export function client(options: object = {}) { return createAuth({ name: "test", userSchema, + ...(asyncStorage && { storage: deferredStorage(webStorage()) }), ...(options as Partial>), }); } diff --git a/tests/package.test.ts b/tests/package.test.ts index ea7acff..b5ef767 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -2,7 +2,12 @@ import assert from "node:assert/strict"; import { access, readFile } from "node:fs/promises"; import test from "node:test"; -import { createAuth, createRefreshFn } from "@monarcode/session-kit"; +import { + createAuth, + createRefreshFn, + memoryStorage, + webStorage, +} from "@monarcode/session-kit"; import { createAuthFetch } from "@monarcode/session-kit/http"; import { connectAuth, useAuth } from "@monarcode/session-kit/react"; @@ -12,6 +17,8 @@ test("all public entry points import without browser globals", () => { for (const fn of [ createAuth, createRefreshFn, + memoryStorage, + webStorage, connectAuth, useAuth, createAuthFetch, diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..461f72a --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + memoryStorage, + webStorage, + type AuthStorage, +} from "@monarcode/session-kit"; + +import { + user, + SESSION, + values, + cleanups, + setGlobal, + flush, + input, + client, + broadcast, + deferredStorage, + sessionOf, + saved, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +/** Storage whose reads, while `holding`, wait until released one at a time. */ +const gatedStorage = (inner: AuthStorage) => { + const waiting: Array<() => void> = []; + const gate = { + holding: false, + get pending() { + return waiting.length; + }, + /** Lets the oldest held read finish, then settles. */ + async release() { + const next = waiting.shift(); + assert.ok(next, "Expected a held storage read"); + next(); + await flush(); + }, + storage: { + ...inner, + async get(key: string) { + if (gate.holding) + await new Promise((resolve) => waiting.push(resolve)); + return inner.get(key); + }, + } satisfies AuthStorage, + }; + return gate; +}; + +test("memory storage keeps the session out of browser storage", async () => { + const storage = memoryStorage(); + await client({ storage }).signIn(input()); + assert.equal(values.size, 0); + assert.equal((await sessionOf(client({ storage }))).user.id, "alice"); + assert.equal(await client().getSession(), null); +}); + +test("web storage can keep each tab's session in sessionStorage", async () => { + const entries = new Map(); + setGlobal("sessionStorage", { + getItem: (key: string) => entries.get(key) ?? null, + setItem: (key: string, value: string) => void entries.set(key, value), + removeItem: (key: string) => void entries.delete(key), + }); + await client({ storage: webStorage({ area: "session" }) }).signIn(input()); + assert.equal(values.size, 0); + assert.ok(entries.has(SESSION)); + const restored = client({ storage: webStorage({ area: "session" }) }); + assert.equal((await sessionOf(restored)).user.id, "alice"); + assert.throws( + // @ts-expect-error -- deliberately invalid area + () => webStorage({ area: "cookie" }), + /area must be "local" or "session"/, + ); +}); + +test("a sign-out followed at once by a sign-in leaves the new session saved", async () => { + // Removals settle long after writes issued later, unless auth waits. + const auth = client({ + storage: deferredStorage(webStorage(), { remove: 50 }), + }); + await auth.signIn(input()); + await Promise.all([ + auth.signOut(), + auth.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ), + ]); + assert.equal(saved().accessToken, "bob-token"); + assert.equal((await sessionOf(client())).user.id, "bob"); +}); + +test("a sign-in in this tab wins over another tab's sign-out still being read", async () => { + const a = client(); + await a.signIn(input()); + const gate = gatedStorage(webStorage()); + const b = client({ storage: gate.storage }); + cleanups.push(b.mount()); + await flush(); + gate.holding = true; + await a.signOut(); + await broadcast(); + // Tab b is reading storage to apply the sign-out when it signs in itself. + assert.equal(gate.pending, 1); + const signingIn = b.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + await flush(); + await gate.release(); + await signingIn; + assert.equal(b.state.get().user?.id, "bob"); + assert.equal(saved().accessToken, "bob-token"); +}); + +test("a profile update keeps tokens this tab refreshed while it read storage", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + const gate = gatedStorage(webStorage()); + const auth = client({ + storage: gate.storage, + refresh: async () => ({ accessToken: "access-2", expiresAt: 1_100_000 }), + }); + await auth.signIn(input({ expiresAt: 1_020_000 })); + cleanups.push(auth.mount()); + await flush(); + gate.holding = true; + // The proactive refresh reads storage before calling the backend. + t.mock.timers.tick(10_000); + await flush(); + assert.equal(gate.pending, 1); + // It gets access-2, then reads storage again before saving it. + await gate.release(); + assert.equal(gate.pending, 1); + // The update's read queues behind that one. + const updating = auth.updateUser({ ...user, email: "new@example.com" }); + await flush(); + await gate.release(); + // The refresh installed access-2, but its save queued behind the update's + // read, which now runs and will return access-1. + assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal(saved().accessToken, "access-1"); + assert.equal(gate.pending, 1); + await gate.release(); + // So the update reads again, after the save. + assert.equal(gate.pending, 1); + await gate.release(); + await updating; + assert.equal(gate.pending, 0); + assert.equal(saved().accessToken, "access-2"); + assert.equal(saved().user.email, "new@example.com"); + assert.equal((await sessionOf(auth)).accessToken, "access-2"); + cleanups.pop()!(); + t.mock.timers.reset(); +}); From ecf8cb05095389d720bbb09f286a02d7f29075d8 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 07:42:57 +0100 Subject: [PATCH 02/12] refactor(core)!: derive users from access tokens and split session from credentials - Add `fromAccessToken`, `auth.refresh()` and a `revoke` callback with the `REVOKE_FAILED` error code, and make `AuthState` a discriminated union on `status` - Breaking: `userSchema` becomes `user`, `storage` is required, and `AuthClient` takes ``. `createRefreshFn` is removed, and `getSession()` returns no tokens, which move to `auth.credentials` (`get`, `renew`, `reject`) - Breaking: sessions are saved in a new format, so users sign in once after upgrading --- CHANGELOG.md | 50 ++++++- README.md | 94 +++++++++---- src/core/client.ts | 284 +++++++++++++++++++++++++++------------- src/core/errors.ts | 1 + src/core/id.ts | 18 +++ src/core/persistence.ts | 23 +--- src/core/refresh.ts | 44 +++---- src/core/sources.ts | 137 +++++++++++++++++++ src/core/tokens.ts | 29 ++-- src/core/types.ts | 207 ++++++++++++++++++++--------- src/core/user.ts | 83 +++++++++--- src/http/index.ts | 22 ++-- src/index.ts | 15 ++- src/react/connect.ts | 6 +- tests/consumer.ts | 99 ++++++++++++-- tests/expiry.test.ts | 18 +-- tests/helpers.ts | 47 +++++-- tests/http.test.ts | 4 +- tests/package.test.ts | 9 +- tests/react.test.ts | 4 +- tests/refresh.test.ts | 76 ++++++++--- tests/router.test.ts | 13 +- tests/session.test.ts | 116 +++++++++++++--- tests/storage.test.ts | 5 +- tests/tabs.test.ts | 64 +++++---- tests/users.test.ts | 171 ++++++++++++++++++++++++ 26 files changed, 1253 insertions(+), 386 deletions(-) create mode 100644 src/core/id.ts create mode 100644 src/core/sources.ts create mode 100644 tests/users.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54130f5..ec0ac84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,55 @@ ### Added -- `createAuth` accepts a `storage` option. `webStorage()`, the default, saves - the session in `localStorage`; `webStorage({ area: "session" })` uses - `sessionStorage`, so each tab has its own session that ends with the tab. - `memoryStorage()` keeps the session in memory only. Custom storage implements - the new `AuthStorage` type, and its operations may return promises. +- `createAuth` takes a `storage` adapter. `webStorage()` saves the session in + `localStorage`; `webStorage({ area: "session" })` uses `sessionStorage`, so + each tab has its own session that ends with the tab. `memoryStorage()` keeps + the session in memory only. Custom storage implements the new `AuthStorage` + type, and its operations may return promises. - Auth applies storage operations one at a time, in the order it issued them, and re-checks its own session after every read. Storage that settles late or out of order cannot erase a newer sign-in, and a profile update cannot restore tokens this tab refreshed meanwhile. +- `fromAccessToken(claims, map?)` reads the user from a JWT access token. Its + claims are validated by a Standard Schema and `map` turns them into the user. + `signIn` and the refresh callback then take tokens only, the user is not + saved, and it changes whenever the token does. A `decode` option replaces the + default, which decodes the JWT without verifying its signature. +- `auth.refresh()` refreshes immediately, even while the access token is still + usable, for example to pick up a changed user. +- An optional `revoke` callback receives the ended tokens after `signOut()` + clears the session. A failed revocation does not undo the sign-out; it is + published as the new `REVOKE_FAILED` error code. +- Checking `state.status` narrows `state.user`: `AuthState` is now a + discriminated union in which `user` is set exactly while `authenticated` or + `refreshing`. +- Session IDs no longer require `crypto.randomUUID`, and user validation no + longer requires `structuredClone`, for environments such as React Native. + +### Changed + +- **Breaking:** `createAuth` takes the user schema as `user` instead of + `userSchema`, and `storage` is required. Pass `storage: webStorage()` to keep + the previous behavior. +- **Breaking:** `getSession()` and `retry()` resolve `{ sessionId, user }` + without tokens, so a session is safe in route context. Access tokens come + from `auth.credentials.get()`, which resolves + `{ sessionId, accessToken, expiresAt }`. `auth.refresh(session)` is now + `auth.credentials.renew(credentials)`, `auth.rejectSession(session)` is now + `auth.credentials.reject(credentials)`, and `isCurrent` takes any object + with a `sessionId`. +- **Breaking:** `createRefreshFn` is removed. Pass the refresh callback inline, + or type a standalone one with `RefreshFn>`. +- **Breaking:** `AuthClient` takes the user type first: `AuthClient`. +- **Breaking:** sign-in, `updateUser`, and a refresh that returns a user reject + with `USER_VALIDATION_FAILED` when the user schema does not accept its own + output unchanged. Such a schema used to pass sign-in, then fail restoration + and sign users out on their next reload. +- **Breaking:** sessions are saved in a new format. Sessions saved by earlier + alphas, including the two-key format of 0.1.0-alpha.1, are not restored, so + users sign in once after upgrading. +- A refresh that returns a user equal to the current one keeps the same user + object and does not change `version`, so guards do not run again. ## 0.1.0-alpha.3 diff --git a/README.md b/README.md index 2622bd7..2291156 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ failures are operational. Adapt that mapping to your backend's contract. See ```ts import { z } from "zod"; -import { createAuth, createRefreshFn } from "@monarcode/session-kit"; +import { createAuth, webStorage } from "@monarcode/session-kit"; export const userSchema = z.object({ id: z.string(), @@ -107,8 +107,9 @@ const refreshResponseSchema = tokensSchema.extend({ export const auth = createAuth({ name: "my-app", - userSchema, - refresh: createRefreshFn(async ({ refreshToken, signal }) => { + user: userSchema, + storage: webStorage(), + refresh: async ({ refreshToken, signal }) => { // Use plain fetch here to avoid recursive auth refresh. const response = await fetch("/api/auth/refresh", { method: "POST", @@ -120,17 +121,48 @@ export const auth = createAuth({ if (response.status === 401) return null; if (!response.ok) throw new Error(`Refresh failed (${response.status})`); return refreshResponseSchema.parse(await response.json()); - }), + }, }); export type AppAuth = typeof auth; ``` -Create one active client per auth name in each tab. A schema supplies both runtime -validation and inferred input/output types. Its output must be a plain JSON object -and must remain valid when restored through the same schema. Avoid one-way -transforms that cannot accept their own output. Accepted user data is cloned and -frozen; use `updateUser` to replace it. +Create one active client per auth name in each tab. The `user` schema supplies +both runtime validation and inferred input/output types. Its output must be a +plain JSON object, saved beside the tokens and validated again when restored, so +the schema must accept its own output and return it unchanged. Sign-in rejects a +schema that does not, such as one with a one-way transform, instead of signing +users out on their next reload. Accepted user data is copied and frozen; use +`updateUser` to replace it. + +If your backend puts the user in a JWT access token instead, read it from there. +`signIn` and the refresh callback then take tokens only, the user changes when +the token does, and there is no `updateUser`; call `auth.refresh()` to pick up a +changed user: + +```ts +import { + createAuth, + fromAccessToken, + webStorage, +} from "@monarcode/session-kit"; + +export const auth = createAuth({ + name: "my-app", + user: fromAccessToken( + z.object({ sub: z.string(), email: z.string() }), + (claims) => ({ id: claims.sub, email: claims.email }), + ), + storage: webStorage(), +}); + +await auth.signIn({ accessToken, refreshToken }); +``` + +The token is decoded without verifying its signature, which is fine in the +browser: a user object in a JSON response is trusted to the same degree. Leave +claims that change on every refresh, such as `exp` or `jti`, out of the user. OAuth +providers may change their access token format; this suits backends you control. ### 2. Type the root route @@ -307,9 +339,9 @@ export const Route = createFileRoute("/_authenticated")({ "Session changed; retry navigation", ); } - // Return public context only. Never put the token-bearing session here. + // Sessions hold no tokens, so they are safe in route context. return { - sessionId: session.id, + sessionId: session.sessionId, authVersion: context.auth.state.get().version, }; }, @@ -444,6 +476,15 @@ Outside React, `auth.state.get()` returns the current snapshot and `auth.state.subscribe(listener)` reports later changes, not the current value; it returns `{ unsubscribe }`. A listener that throws does not interrupt auth: its error is rethrown asynchronously, where error reporting can capture it. +Checking `state.status` narrows `state.user`: it is set exactly while +`authenticated` or `refreshing`. + +`auth.getSession()` resolves who is signed in, `{ sessionId, user }`, and never +holds tokens. Code that attaches tokens to requests uses `auth.credentials`: +`get()` resolves `{ sessionId, accessToken, expiresAt }`, `renew()` recovers +after a 401, and `reject()` ends a session the server keeps refusing. +`createAuthFetch` does this for you. `auth.refresh()` refreshes immediately, +even while the access token is usable. `connectAuth` initializes auth, starts expiry timers, clears inactive Router cache entries on guard-relevant changes, and queues Router revalidation. Call @@ -537,13 +578,13 @@ To treat every refresh failure as a sign-out instead, return `null` from the refresh callback rather than throwing: ```ts -refresh: createRefreshFn(async ({ refreshToken, signal }) => { +refresh: async ({ refreshToken, signal }) => { try { return await requestRefresh(refreshToken, signal); } catch { return null; } -}), +}, ``` Expiry comes from the first of these that the tokens provide: @@ -555,7 +596,7 @@ Expiry comes from the first of these that the tokens provide: Browser clocks can be minutes off. Options 2 and 3 are unaffected; with 1 and 4, a fast clock can make fresh tokens look expired and block sign-in. Options 2 and -3 assume newly issued tokens. `Session.expiresAt` is always on the browser +3 assume newly issued tokens. `Credentials.expiresAt` is always on the browser clock, and restored sessions keep their saved expiry. JWT decoding supplies only a scheduling hint, without verifying signatures. Opaque tokens without expiry cannot be proactively refreshed. While connected, refresh is scheduled at the @@ -574,7 +615,8 @@ Validation and refresh work have a 15-second deadline. `state.error` is an `AuthError` or `null`; user data is hidden outside the `authenticated` and `refreshing` states. Error codes are `USER_VALIDATION_FAILED`, `INVALID_SESSION`, -`PERSISTENCE_FAILED`, `REFRESH_FAILED`, `SESSION_CHANGED`, and `UNAUTHENTICATED`. +`PERSISTENCE_FAILED`, `REFRESH_FAILED`, `REVOKE_FAILED`, `SESSION_CHANGED`, and +`UNAUTHENTICATED`. Validation errors can include `issues`; underlying failures can appear in `cause`. Application callbacks can also throw ordinary errors. @@ -589,13 +631,14 @@ the remaining data after storage becomes available. ## Storage and limits -- Tokens and the profile share the `:auth:session` localStorage entry, so - every save is a single write that other tabs see whole. Entries from - 0.1.0-alpha.1 (`:auth:tokens`, `:auth:user`) are - removed, not restored; users sign in once after upgrading. Tokens are never placed in cookies, so the browser does not send - them automatically; `createAuthFetch` attaches the access token explicitly. -- The `storage` option chooses where that entry lives. The default, - `webStorage()`, uses `localStorage`; `webStorage({ area: "session" })` keeps +- Tokens and the profile share one `:auth:session` entry, so every save + is a single write that other tabs see whole. A user read from the access token + is not saved; restoring reads it again. Sessions saved by earlier alphas are + removed, not restored; users sign in once after upgrading. Tokens are never + placed in cookies, so the browser does not send them automatically; + `createAuthFetch` attaches the access token explicitly. +- The required `storage` option chooses where that entry lives. + `webStorage()` uses `localStorage`; `webStorage({ area: "session" })` keeps a separate session per tab in `sessionStorage`, ending with the tab; and `memoryStorage()` never persists it. Any object implementing `AuthStorage` works, including storage whose operations return promises: auth applies @@ -606,7 +649,10 @@ the remaining data after storage becomes available. - Tokens and profiles are readable by same-origin JavaScript. The backend must independently authenticate requests and authorize private operations. Local profile fields and route guards do not establish server authorization. -- Sign-out clears local credentials; backend revocation belongs to your app. +- Sign-out clears local credentials, then calls the optional `revoke` callback + with the ended tokens so your backend can revoke them. A failed revocation + does not undo the sign-out: `signOut()` still resolves and `state.error` + reports `REVOKE_FAILED`. Sessions the backend already rejected are not revoked. - Tabs coordinate refresh through the `:auth:refresh` Web Lock: one tab at a time spends the refresh token, and a tab that needs a refresh first uses tokens another tab already saved. Rotating refresh tokens therefore work with @@ -639,7 +685,7 @@ the remaining data after storage becomes available. | Import | Exports | | ------------------------------ | ---------------------------------------------------------------------------------------------- | -| `@monarcode/session-kit` | `createAuth`, `createRefreshFn`, `webStorage`, `memoryStorage`, `AuthError`, public auth types | +| `@monarcode/session-kit` | `createAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `AuthError`, public auth types | | `@monarcode/session-kit/react` | `connectAuth`, `safeReturnTo`, `useAuth`, `useAuthClient`, registered hook types | | `@monarcode/session-kit/http` | `createAuthFetch` | diff --git a/src/core/client.ts b/src/core/client.ts index e775035..072361c 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,10 +1,14 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; import { atom } from "nanostores"; import { AuthError, toAuthError, sessionChanged } from "./errors.js"; +import { createId } from "./id.js"; import { createPersistence, type StoredSession } from "./persistence.js"; import { obtainTokens } from "./refresh.js"; -import { webStorage } from "./storage.js"; +import { + resolveUserSource, + type UserResolver, + type UserSource, +} from "./sources.js"; import { runTask } from "./task.js"; import { setLongTimeout, proactiveRefreshDelay } from "./timers.js"; import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js"; @@ -13,12 +17,14 @@ import type { AuthOptions, AuthState, AuthStatus, + Credentials, + RefreshFn, Session, SignInInput, User, UserInput, } from "./types.js"; -import { sameUser, validateUser } from "./user.js"; +import { sameUser } from "./user.js"; type InternalSession = SessionTokens & { id: string; user: U }; @@ -28,7 +34,7 @@ type RefreshOutcome = | { kind: "stale" } /** The backend rejected the refresh token, so the session ended. */ | { kind: "signedOut" } - | { kind: "installed"; session: Session }; + | { kind: "installed"; session: InternalSession }; /** * Starts work nobody awaits. Its failures are already published to @@ -67,6 +73,30 @@ function rejectsSaved(error: AuthError) { ); } +/** Whether two saved entries hold the same access token and user. */ +function sameSaved(a: StoredSession, b: StoredSession) { + return a.accessToken === b.accessToken && sameUser(a.user, b.user); +} + +function toSession(session: InternalSession | null): Session | null { + return ( + session && Object.freeze({ sessionId: session.id, user: session.user }) + ); +} + +function toCredentials( + session: InternalSession | null, +): Credentials | null { + return ( + session && + Object.freeze({ + sessionId: session.id, + accessToken: session.accessToken, + expiresAt: session.expiresAt, + }) + ); +} + /** * Creates the auth client. Put one in Router context per auth `name` per tab. * @@ -76,8 +106,9 @@ function rejectsSaved(error: AuthError) { * @example * const auth = createAuth({ * name: "my-app", - * userSchema: z.object({ id: z.string(), email: z.string() }), - * refresh: createRefreshFn(async ({ refreshToken, signal }) => { + * user: z.object({ id: z.string(), email: z.string() }), + * storage: webStorage(), + * refresh: async ({ refreshToken, signal }) => { * const response = await fetch("/api/auth/refresh", { * method: "POST", * signal, @@ -88,18 +119,24 @@ function rejectsSaved(error: AuthError) { * if (!response.ok) throw new Error(`Refresh failed (${response.status})`); * const body = await response.json(); * return { accessToken: body.access_token, expiresIn: body.expires_in }; - * }), + * }, * }); * * await auth.signIn({ accessToken, refreshToken, expiresIn: 900, user }); */ -export function createAuth( - options: AuthOptions, -): AuthClient, User> { - type U = User; - type I = UserInput; +export function createAuth( + options: AuthOptions, +): AuthClient, UserInput> { + type U = User; + type I = UserInput; + if (!options.storage) { + throw new Error( + "Pass a storage adapter, such as webStorage(), as the storage option", + ); + } + const users = resolveUserSource(options.user) as UserResolver; const persistence = createPersistence( - options.storage ?? webStorage(), + options.storage, options.name, options.maxAge, ); @@ -137,7 +174,7 @@ export function createAuth( // error, kept only while access is unusable, so `getSession` rethrows it // until `retry()`. `refreshAttempted` allows one proactive attempt per // installed token. `rejectedToken` is an access token the backend refused. - let flight: Promise | null> | undefined; + let flight: Promise | null> | undefined; let failure: AuthError | undefined; let refreshAttempted = false; let rejectedToken: string | undefined; @@ -171,17 +208,6 @@ export function createAuth( } } - function snapshot(): Session { - if (!session) - throw new AuthError("UNAUTHENTICATED", "Sign in is required"); - return Object.freeze({ - id: session.id, - accessToken: session.accessToken, - expiresAt: session.expiresAt, - user: session.user, - }); - } - function deriveStatus(): AuthStatus { if (!initialized) return "initializing"; if (!session) return "unauthenticated"; @@ -198,6 +224,7 @@ export function createAuth( GUARD_STATUS[previous.status] !== GUARD_STATUS[status] || previous.user !== user || previous.sessionId !== sessionId; + // `SIGNED_IN` keeps `user` set exactly for the statuses whose type has one. store.set( Object.freeze({ status, @@ -205,7 +232,7 @@ export function createAuth( sessionId, error, version: previous.version + Number(changed), - }), + }) as AuthState, ); } @@ -223,11 +250,11 @@ export function createAuth( cancelExpiry = setLongTimeout(() => { // The browser clock may have moved while the timer waited. if (valid()) schedule(); - else inBackground(getSession()); + else inBackground(resolveSession()); }, remaining); if (options.refresh && session.refreshToken && !refreshAttempted) { cancelProactive = setLongTimeout( - () => inBackground(refreshSession(snapshot(), false)), + () => inBackground(refreshSession(session!, false)), proactiveRefreshDelay(remaining), ); } @@ -276,8 +303,18 @@ export function createAuth( } } - /** Makes `next` current in memory, without publishing it. */ + /** + * Makes `next` current in memory, without publishing it. A user equal to + * the current one keeps the current object, so subscribers comparing users + * by reference, and `version`, see no change. + */ function adopt(next: InternalSession) { + if ( + session?.id === next.id && + next.user !== session.user && + sameUser(next.user, session.user) + ) + next.user = session.user; session = next; revision++; initialized = true; @@ -295,7 +332,10 @@ export function createAuth( async function commit(next: InternalSession) { adopt(next); try { - await persistence.write(next); + // A user read from the access token is not saved; restoring reads it again. + await persistence.write( + users.saved ? next : { ...next, user: undefined }, + ); } catch (cause) { const error = toAuthError( "PERSISTENCE_FAILED", @@ -334,12 +374,14 @@ export function createAuth( * Resolves `null` when storage is empty and rejects with `SESSION_CHANGED` * when it holds another session. */ - async function followStorage(id: string): Promise | null> { + async function followStorage( + id: string, + ): Promise | null> { await reload(); const restored = session as InternalSession | null; if (!restored) return null; if (restored.id !== id) throw sessionChanged(); - return getSession(); + return resolveSession(); } /** Drops this tab's session and restores what storage holds, without writing. */ @@ -360,8 +402,7 @@ export function createAuth( * refresh, or a profile update. Reads storage rather than the event, so a * late or repeated event cannot apply stale data. Unreadable or malformed * storage is ignored, keeping this tab's session; a well-formed session - * whose user this tab's schema rejects signs every tab out, as restoring - * it would. + * whose user this tab rejects signs every tab out, as restoring it would. */ async function sync(): Promise { if (!initialized) { @@ -383,18 +424,19 @@ export function createAuth( return; } if (stored.id !== session?.id) return reload(); - const userUnchanged = sameUser(stored.user, session.user); + const userUnchanged = users.unchanged(stored, session); const tokensUnchanged = stored.accessToken === session.accessToken && stored.refreshToken === session.refreshToken && stored.expiresAt === session.expiresAt; if (userUnchanged && tokensUnchanged) return; - let user = session.user; + const previousUser = session.user; + let user = previousUser; if (!userUnchanged) { const expected = epoch; try { user = await runTask( - () => validateUser(options.userSchema, stored.user), + () => users.restore(stored), controller.signal, ); } catch (cause) { @@ -416,7 +458,7 @@ export function createAuth( epoch !== expected || revision !== check || !current || - !sameUser(current.user, stored.user) + !sameSaved(current, stored) ) return; await discardSaved(error); @@ -429,13 +471,12 @@ export function createAuth( epoch !== expected || revision !== check || !now || - now.accessToken !== stored.accessToken || - !sameUser(now.user, stored.user) + !sameSaved(now, stored) ) return; } if (!tokensUnchanged) refreshAttempted = false; - if (!userUnchanged) profileVersion++; + if (!sameUser(user, previousUser)) profileVersion++; install({ ...tokensOf(stored), id: stored.id, user }); } @@ -448,13 +489,13 @@ export function createAuth( async function endSession( id: string, accessToken: string, - ): Promise | null | undefined> { + ): Promise | null | undefined> { const stored = await latest(id); if (!session) return null; if (session.id !== id) throw sessionChanged(); if (session.accessToken !== accessToken) return undefined; if (stored === null) return followStorage(id); - await signOut(); + await signOut(false); return null; } @@ -468,11 +509,11 @@ export function createAuth( assertEpoch(expected); if (stored) { const user = await runTask( - () => validateUser(options.userSchema, stored.user), + () => users.restore(stored), controller.signal, ); assertEpoch(expected); - session = { ...stored, user }; + session = { ...tokensOf(stored), id: stored.id, user }; revision++; } assertEpoch(expected); @@ -496,7 +537,7 @@ export function createAuth( status: "unavailable", error, version: previous.version + 1, - }); + } as AuthState); throw error; } })(); @@ -520,7 +561,11 @@ export function createAuth( ); } const user = await runTask( - () => validateUser(options.userSchema, input.user), + () => + users.receive( + tokens.accessToken, + (input as { user?: unknown }).user, + ), controller.signal, ); assertEpoch(expected); @@ -534,7 +579,7 @@ export function createAuth( rejectedToken = undefined; refreshAttempted = false; profileVersion++; - await commit({ ...tokens, id: crypto.randomUUID(), user }); + await commit({ ...tokens, id: createId(), user }); } catch (cause) { throw toAuthError( "USER_VALIDATION_FAILED", @@ -545,34 +590,67 @@ export function createAuth( if (epoch === expected) { schedule(); if (session && !valid()) { - if (mounts) inBackground(getSession()); + if (mounts) inBackground(resolveSession()); else publish(store.get().error); } } } } - async function signOut() { + /** + * Ends the session here and clears storage. Then, if `revoke` is set and + * the user asked to sign out, revokes the ended tokens on the backend. A + * session the backend already rejected is not revoked. + */ + async function signOut(revoke: boolean) { + const revokeHandler = options.revoke; + const ended = + revoke && revokeHandler && session + ? { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + } + : undefined; forget(); const expected = epoch; + let clearFailure: AuthError | undefined; try { await persistence.clear(); } catch (cause) { - const error = toAuthError( + clearFailure = toAuthError( "PERSISTENCE_FAILED", "Could not clear the saved session", cause, ); - // A session started since then is not the one that failed to clear. - if (epoch === expected) publish(error); - throw error; } + let revokeFailure: AuthError | undefined; + if (ended) { + try { + // Revoke even if this tab signs in again meanwhile, so no parent abort. + await runTask( + (signal) => revokeHandler!({ ...ended, signal }), + new AbortController().signal, + ); + } catch (cause) { + revokeFailure = new AuthError( + "REVOKE_FAILED", + "Could not revoke the session", + { cause }, + ); + } + } + // A session started since then is not the one these failures belong to. + if (clearFailure) { + if (epoch === expected) publish(clearFailure); + throw clearFailure; + } + if (revokeFailure && epoch === expected) publish(revokeFailure); } async function updateUser(input: I | (() => Promise)) { const expected = epoch; const intent = ++profileIntent; - const current = await getSession(); + const current = await resolveSession(); assertEpoch(expected); if (!current) throw new AuthError( @@ -584,7 +662,7 @@ export function createAuth( typeof input === "function" ? await (input as () => Promise)() : input; - return validateUser(options.userSchema, value); + return users.receive(current.accessToken, value); }, controller.signal); /** The session to update, unless a newer update or a session change made this one obsolete. */ const target = () => { @@ -615,12 +693,12 @@ export function createAuth( } async function refreshSession( - captured: Session, + captured: { id: string; accessToken: string }, rejected: boolean, - ): Promise | null> { + ): Promise | null> { if (!session) return null; if (session.id !== captured.id) throw sessionChanged(); - if (session.accessToken !== captured.accessToken) return getSession(); + if (session.accessToken !== captured.accessToken) return resolveSession(); if (rejected) rejectedToken = captured.accessToken; if (flight) { if (!valid()) publish(store.get().error); @@ -628,12 +706,12 @@ export function createAuth( } if (!options.refresh || !session.refreshToken) { const ended = await endSession(session.id, session.accessToken); - return ended === undefined ? getSession() : ended; + return ended === undefined ? resolveSession() : ended; } const expected = epoch; const profile = profileVersion; const current = session; - const refreshHandler = options.refresh; + const refreshHandler = options.refresh as RefreshFn; const lifecycle = controller.signal; refreshAttempted = true; failure = undefined; @@ -650,7 +728,7 @@ export function createAuth( current, stored, refresh: refreshHandler, - userSchema: options.userSchema, + users, rejectedToken, signal, }), @@ -666,7 +744,7 @@ export function createAuth( after.accessToken !== (stored ?? current).accessToken) ) return { kind: "stale" }; - await signOut(); + await signOut(false); return { kind: "signedOut" }; } if ( @@ -689,8 +767,9 @@ export function createAuth( if (saved === null) return { kind: "stale" }; // Decide the user only now, after the last read: a profile update // committed meanwhile must win over the user the refresh returned. + // A user read from the token always follows the new token. const user = - profile === profileVersion + !users.saved || profile === profileVersion ? (result.user ?? session!.user) : session!.user; rejectedToken = undefined; @@ -698,13 +777,11 @@ export function createAuth( const next = { ...result.tokens, id: current.id, user }; if (result.adopted && profile === profileVersion) { install(next); - return { kind: "installed", session: snapshot() }; + return { kind: "installed", session: next }; } - const saving = commit(next); - const installed = snapshot(); - await saving; + await commit(next); assertEpoch(expected); - return { kind: "installed", session: installed }; + return { kind: "installed", session: next }; }; const outcome = await persistence.exclusive(lifecycle, run); switch (outcome.kind) { @@ -745,24 +822,40 @@ export function createAuth( return promise; } - async function resume(retry: boolean): Promise | null> { + async function resume(retry: boolean): Promise | null> { const expected = epoch; await initialize(); if (epoch !== expected && session) throw sessionChanged(); if (!session) return null; - if (valid()) return snapshot(); + if (valid()) return session; if (failure && !retry) throw failure; - return refreshSession(snapshot(), false); + return refreshSession(session, false); } - function getSession() { + /** The current session, restored or refreshed if needed. */ + function resolveSession() { return resume(false); } + /** Refreshes now, even while the access token is still usable. */ + async function refreshNow(): Promise | null> { + const expected = epoch; + await initialize(); + if (epoch !== expected && session) throw sessionChanged(); + if (!session) return null; + if (!options.refresh || !session.refreshToken) { + throw new AuthError( + "REFRESH_FAILED", + "Refreshing needs a refresh callback and a refresh token", + ); + } + return refreshSession(session, false); + } + function mount() { if (mounts++ === 0) unwatch = persistence.watch(() => inBackground(sync())); - inBackground(getSession()); + inBackground(resolveSession()); schedule(); let active = true; return () => { @@ -778,12 +871,12 @@ export function createAuth( }; } - return { + const client = { state: { get: store.get, // `listen`, not `subscribe`: nanostores' `subscribe` also calls the // listener immediately with the current value. - subscribe: (listener) => ({ + subscribe: (listener: (state: AuthState) => void) => ({ unsubscribe: store.listen((value) => { try { listener(value); @@ -797,19 +890,34 @@ export function createAuth( }), }, signIn, - signOut, - updateUser, - getSession, - retry: () => resume(true), + signOut: () => signOut(true), + getSession: async () => toSession(await resolveSession()), + retry: async () => toSession(await resume(true)), + refresh: async () => toSession(await refreshNow()), + isCurrent: (captured: { sessionId: string }) => + session?.id === captured.sessionId, mount, - refresh: (captured) => refreshSession(captured, true), - isCurrent: (captured) => session?.id === captured.id, - rejectSession: async (captured) => { - if ( - session?.id === captured.id && - session.accessToken === captured.accessToken - ) - await endSession(captured.id, captured.accessToken); + credentials: { + get: async () => toCredentials(await resolveSession()), + renew: async (captured: Credentials) => + toCredentials( + await refreshSession( + { id: captured.sessionId, accessToken: captured.accessToken }, + true, + ), + ), + reject: async (captured: Credentials) => { + if ( + session?.id === captured.sessionId && + session.accessToken === captured.accessToken + ) + await endSession(captured.sessionId, captured.accessToken); + }, }, }; + // A user read from the access token changes only with the token. + return (users.saved ? { ...client, updateUser } : client) as AuthClient< + U, + I + >; } diff --git a/src/core/errors.ts b/src/core/errors.ts index caa4af1..ec59440 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -5,6 +5,7 @@ export type AuthErrorCode = | "INVALID_SESSION" | "PERSISTENCE_FAILED" | "REFRESH_FAILED" + | "REVOKE_FAILED" | "SESSION_CHANGED" | "UNAUTHENTICATED"; diff --git a/src/core/id.ts b/src/core/id.ts new file mode 100644 index 0000000..60732d4 --- /dev/null +++ b/src/core/id.ts @@ -0,0 +1,18 @@ +/** + * A random session ID. It only tells sign-ins apart locally and is not a + * secret, so it falls back to weaker randomness where Web Crypto is missing, + * as in React Native. + */ +export function createId(): string { + const crypto = globalThis.crypto; + if (typeof crypto?.randomUUID === "function") return crypto.randomUUID(); + const bytes = new Uint8Array(16); + if (typeof crypto?.getRandomValues === "function") + crypto.getRandomValues(bytes); + else + for (let index = 0; index < bytes.length; index++) + bytes[index] = Math.floor(Math.random() * 256); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +} diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 32a1cb2..43588d9 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -3,10 +3,11 @@ import { isRecord } from "./json.js"; import type { AuthStorage } from "./storage.js"; import { validateTokens, type SessionTokens } from "./tokens.js"; -export type StoredSession = SessionTokens & { id: string; user: unknown }; +/** A saved session. `user` is absent when the user comes from the access token. */ +export type StoredSession = SessionTokens & { id: string; user?: unknown }; /** Format of the saved entry; entries in any other format are not restored. */ -const STORAGE_VERSION = 3; +const STORAGE_VERSION = 4; /** Thirty days. */ const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; @@ -34,7 +35,6 @@ export function createPersistence( ); } const key = `${name}:auth:session`; - const legacyKeys = [`${name}:auth:tokens`, `${name}:auth:user`]; const lockName = `${name}:auth:refresh`; // Operations run one at a time, in the order requested. Storage that settles @@ -51,26 +51,14 @@ export function createPersistence( function clear(): Promise { return enqueue(async () => { - const failures: unknown[] = []; - for (const entry of [key, ...legacyKeys]) { - try { - await storage.remove(entry); - } catch (error) { - failures.push(error); - } - } - if (failures.length) - throw new AggregateError(failures, "Could not clear auth storage"); + await storage.remove(key); }); } function read(): Promise { return enqueue(async () => { const text = await storage.get(key); - if (!text) { - for (const entry of legacyKeys) await storage.remove(entry); - return null; - } + if (!text) return null; try { const saved: unknown = JSON.parse(text); if ( @@ -78,7 +66,6 @@ export function createPersistence( saved.v !== STORAGE_VERSION || typeof saved.id !== "string" || !saved.id || - !("user" in saved) || typeof saved.persistUntil !== "number" || !Number.isFinite(saved.persistUntil) || saved.persistUntil <= Date.now() diff --git a/src/core/refresh.ts b/src/core/refresh.ts index f24961d..c0e502f 100644 --- a/src/core/refresh.ts +++ b/src/core/refresh.ts @@ -1,22 +1,13 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; - import { AuthError } from "./errors.js"; import { isRecord } from "./json.js"; import type { StoredSession } from "./persistence.js"; +import type { UserResolver } from "./sources.js"; import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js"; -import type { RefreshFn, User, UserInput } from "./types.js"; -import { sameUser, validateUser } from "./user.js"; +import type { RefreshFn } from "./types.js"; /** The shortest remaining lifetime worth installing or adopting a token for. */ const MIN_TOKEN_LIFETIME_MS = 5_000; -/** Types a refresh callback against a user schema before passing it to `createAuth`. */ -export function createRefreshFn( - handler: RefreshFn, -): RefreshFn { - return handler; -} - export type ObtainedTokens = { tokens: SessionTokens; /** The replacement user, or `undefined` to keep the current one. */ @@ -30,22 +21,19 @@ export type ObtainedTokens = { * they stay usable, otherwise the refresh callback's. Resolves `null` when the * backend rejected the refresh token. */ -export async function obtainTokens(input: { - current: SessionTokens & { user: User }; +export async function obtainTokens(input: { + current: SessionTokens & { user: U }; /** What storage holds for this session; `undefined` when unreadable. */ stored: StoredSession | undefined; - refresh: RefreshFn>; - userSchema: S; + refresh: RefreshFn; + users: UserResolver; /** An access token the backend refused, which must not be adopted. */ rejectedToken: string | undefined; signal: AbortSignal; -}): Promise> | null> { - const { current, stored, refresh, userSchema, rejectedToken, signal } = - input; - const savedUser = (saved: StoredSession) => - sameUser(saved.user, current.user) - ? current.user - : validateUser(userSchema, saved.user); +}): Promise | null> { + const { current, stored, refresh, users, rejectedToken, signal } = input; + const savedUser = async (saved: StoredSession) => + users.unchanged(saved, current) ? current.user : users.restore(saved); if ( stored && stored.accessToken !== current.accessToken && @@ -72,12 +60,12 @@ export async function obtainTokens(input: { "Refreshed access token must last at least five seconds", ); } - const user = - isRecord(value) && value.user !== undefined - ? await validateUser(userSchema, value.user) - : stored - ? await savedUser(stored) - : undefined; + let user: U | undefined; + // A derived user always follows the new token. + if (!users.saved) user = await users.receive(tokens.accessToken, undefined); + else if (isRecord(value) && value.user !== undefined) + user = await users.receive(tokens.accessToken, value.user); + else if (stored) user = await savedUser(stored); return { tokens: { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken }, user, diff --git a/src/core/sources.ts b/src/core/sources.ts new file mode 100644 index 0000000..a3716bd --- /dev/null +++ b/src/core/sources.ts @@ -0,0 +1,137 @@ +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +import { AuthError } from "./errors.js"; +import type { StoredSession } from "./persistence.js"; +import { decodeJwtPayload, type SessionTokens } from "./tokens.js"; +import { sameUser, toUser, validateNewUser, validateUser } from "./user.js"; + +/** + * A user read from the access token's claims, made by `fromAccessToken`. + * `C` is the validated claims; `U` is the user mapped from them. + */ +export type AccessTokenUser = { + readonly kind: "accessToken"; + readonly claims: StandardSchemaV1; + readonly map: (claims: C) => U; + readonly decode: (accessToken: string) => unknown; +}; + +/** + * Where the user comes from: a Standard Schema validating the `user` passed to + * `signIn`, or `fromAccessToken(…)` reading it from the access token. + */ +export type UserSource = StandardSchemaV1 | AccessTokenUser; + +export type FromAccessTokenOptions = { + /** + * Turns the access token into the claims to validate. May return a promise. + * Default: the JWT payload, decoded **without verifying its signature**. + * That suits a browser, which trusts its own backend's responses anyway; + * code running on a server must pass a verifier instead. + */ + decode?: (accessToken: string) => unknown; +}; + +function decodeAccessToken(accessToken: string): Record { + const payload = decodeJwtPayload(accessToken); + if (!payload) throw new Error("The access token is not a readable JWT"); + return payload; +} + +/** + * Reads the user from the access token instead of the `signIn` input. The + * token's claims are validated by `claims`, then `map` turns them into the + * user; without `map`, the validated claims are the user. + * + * `signIn` and the refresh callback then take tokens only, `updateUser` is + * unavailable, and the user changes whenever the token does. `map` runs on + * every restore and token change, so keep it pure and synchronous, and leave + * out claims that change on every refresh, such as `exp`, `iat`, or `jti`. + * + * @example + * user: fromAccessToken( + * z.object({ sub: z.string(), email: z.string() }), + * (claims) => ({ id: claims.sub, email: claims.email }), + * ), + */ +export function fromAccessToken< + S extends StandardSchemaV1, + U = StandardSchemaV1.InferOutput, +>( + claims: S, + map?: (claims: StandardSchemaV1.InferOutput) => U, + options: FromAccessTokenOptions = {}, +): AccessTokenUser, U> { + return Object.freeze({ + kind: "accessToken", + claims: claims as StandardSchemaV1< + unknown, + StandardSchemaV1.InferOutput + >, + map: map ?? ((value: StandardSchemaV1.InferOutput) => value as U), + decode: options.decode ?? decodeAccessToken, + }); +} + +/** How the engine obtains users, whichever the source. */ +export type UserResolver = { + /** Whether the user is saved beside the tokens, rather than derived from them. */ + saved: boolean; + /** The user for newly received tokens and `input`, which is ignored when derived. */ + receive: (accessToken: string, input: unknown) => Promise; + /** The user for a saved session. */ + restore: (saved: StoredSession) => Promise; + /** Whether `saved` holds the same user as `current`, without validating it. */ + unchanged: ( + saved: StoredSession, + current: SessionTokens & { user: U }, + ) => boolean; +}; + +export function resolveUserSource(source: UserSource): UserResolver { + if ( + (typeof source === "object" || typeof source === "function") && + source !== null && + "~standard" in source + ) { + return { + saved: true, + receive: (_, input) => validateNewUser(source, input), + restore: (saved) => validateUser(source, saved.user), + unchanged: (saved, current) => sameUser(saved.user, current.user), + }; + } + if (source?.kind !== "accessToken") { + throw new Error( + "Pass a Standard Schema or fromAccessToken(…) as the user option", + ); + } + const derive = async (accessToken: string) => { + try { + const result = await source.claims["~standard"].validate( + await source.decode(accessToken), + ); + if (result.issues) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "Invalid access token claims", + { issues: result.issues }, + ); + } + return toUser(source.map(result.value)); + } catch (cause) { + if (cause instanceof AuthError) throw cause; + throw new AuthError( + "USER_VALIDATION_FAILED", + "Could not read the user from the access token", + { cause }, + ); + } + }; + return { + saved: false, + receive: (accessToken) => derive(accessToken), + restore: (saved) => derive(saved.accessToken), + unchanged: (saved, current) => saved.accessToken === current.accessToken, + }; +} diff --git a/src/core/tokens.ts b/src/core/tokens.ts index acab375..c8f4b82 100644 --- a/src/core/tokens.ts +++ b/src/core/tokens.ts @@ -14,27 +14,38 @@ export function tokensOf(saved: SessionTokens): SessionTokens { }; } -function jwtClaims(token: string): { exp?: number; iat?: number } { +/** + * A JWT's payload, decoded without verifying its signature, or `undefined` + * when `token` is not a JWT with a JSON object payload. + */ +export function decodeJwtPayload( + token: string, +): Record | undefined { try { const parts = token.split("."); - if (parts.length !== 3) return {}; + if (parts.length !== 3) return undefined; const encoded = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const bytes = Uint8Array.from( atob(encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=")), (c) => c.charCodeAt(0), ); const payload: unknown = JSON.parse(new TextDecoder().decode(bytes)); - if (!isRecord(payload)) return {}; - const seconds = (value: unknown) => - typeof value === "number" && Number.isFinite(value * 1000) - ? value - : undefined; - return { exp: seconds(payload.exp), iat: seconds(payload.iat) }; + return isRecord(payload) ? payload : undefined; } catch { - return {}; + return undefined; } } +function jwtClaims(token: string): { exp?: number; iat?: number } { + const payload = decodeJwtPayload(token); + if (!payload) return {}; + const seconds = (value: unknown) => + typeof value === "number" && Number.isFinite(value * 1000) + ? value + : undefined; + return { exp: seconds(payload.exp), iat: seconds(payload.iat) }; +} + function positive(value: unknown, message: string): number | undefined { if ( value !== undefined && diff --git a/src/core/types.ts b/src/core/types.ts index 5f60fd3..649b3e2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,14 +1,31 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { AuthError } from "./errors.js"; +import type { AccessTokenUser, UserSource } from "./sources.js"; import type { AuthStorage } from "./storage.js"; -/** What the user schema accepts: the shape passed to `signIn` and `updateUser`. */ -export type UserInput = - StandardSchemaV1.InferInput; +/** + * What the user source produces: the shape exposed in `state.user` and + * `Session.user`. + */ +export type User = + Src extends AccessTokenUser + ? U + : Src extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : never; -/** What the user schema produces: the shape exposed in `state.user` and `Session.user`. */ -export type User = StandardSchemaV1.InferOutput; +/** + * What the user schema accepts: the `user` passed to `signIn` and + * `updateUser`. `never` when the user comes from the access token, so those + * take no user. + */ +export type UserInput = + Src extends AccessTokenUser + ? never + : Src extends StandardSchemaV1 + ? StandardSchemaV1.InferInput + : never; /** * Tokens from your backend, passed to `signIn` or returned by the refresh callback. @@ -46,11 +63,16 @@ export type Tokens = { expiresAt?: number; }; -/** Input to `signIn`: tokens plus the user, validated by `userSchema`. */ -export type SignInInput = Tokens & { - /** Validated, cloned, and frozen. Must be a plain JSON object. */ - user: I; -}; +/** + * Input to `signIn`: tokens, plus the user when a schema validates it. When the + * user comes from the access token (`I` is `never`), tokens only. + */ +export type SignInInput = [I] extends [never] + ? Tokens & { user?: never } + : Tokens & { + /** Validated, copied, and frozen. Must be a plain JSON object. */ + user: I; + }; /** * What the refresh callback returns: @@ -59,13 +81,17 @@ export type SignInInput = Tokens & { * - `null`: the backend rejected the refresh token. Sign out and clear storage. * * Throw instead for operational failures (network errors, 5xx). Credentials are - * kept and the error is exposed in `state.error`. + * kept and the error is exposed in `state.error`. When the user comes from the + * access token, return tokens only; the user is read from the new token. */ export type RefreshResult = - | (Tokens & { - /** Replaces the profile unless `updateUser` ran during the refresh. */ - user?: I; - }) + | (Tokens & + ([I] extends [never] + ? { user?: never } + : { + /** Replaces the profile unless `updateUser` ran during the refresh. */ + user?: I; + })) | null; /** Exchanges a refresh token for new tokens. See `RefreshResult`. */ @@ -76,18 +102,35 @@ export type RefreshFn = (context: { signal: AbortSignal; }) => Promise>; -/** A frozen snapshot of the current credentials, from `getSession()`. */ +/** + * Revokes the signed-out session's tokens on the backend. Called by `signOut()` + * after the local session is gone, so the UI never waits for it to change. + */ +export type RevokeFn = (context: { + accessToken: string; + refreshToken?: string; + /** Aborted after the 15-second deadline. */ + signal: AbortSignal; +}) => Promise; + +/** Who is signed in, from `getSession()`. Holds no tokens, so it is safe in route context. */ export type Session = Readonly<{ /** Stable across refreshes and profile updates; new on every sign-in. */ - id: string; + sessionId: string; + /** The validated user. */ + user: U; +}>; + +/** The current access token, from `credentials.get()`, for attaching to requests. */ +export type Credentials = Readonly<{ + /** The session these credentials belong to. */ + sessionId: string; accessToken: string; /** * Unix time in milliseconds on **this browser's clock**, normalized from * `expiresIn`, `expiresAt`, or JWT claims. Safe to compare with `Date.now()`. */ expiresAt?: number; - /** The validated user, as produced by `userSchema`. */ - user: U; }>; /** @@ -104,21 +147,42 @@ export type AuthStatus = | "unauthenticated" | "unavailable"; -/** Reactive auth snapshot. Tokens are intentionally excluded. */ -export type AuthState = Readonly<{ - /** See `AuthStatus`. */ - status: AuthStatus; - /** The validated user while `authenticated` or `refreshing`; otherwise `null`. */ - user: U | null; - /** The current `Session.id`, or `null` when signed out. */ - sessionId: string | null; +type StateFields = { /** Changes when guards must run again. */ - version: number; + readonly version: number; /** The latest failure, cleared by the next successful change. */ - error: AuthError | null; -}>; + readonly error: AuthError | null; +}; + +type SignedInState = StateFields & { + readonly status: Status; + readonly user: U; + readonly sessionId: string; +}; + +type SignedOutState = StateFields & { + readonly status: Status; + readonly user: null; + readonly sessionId: null; +}; + +/** + * Reactive auth snapshot. Tokens are intentionally excluded. Checking `status` + * narrows `user`: it is set exactly while `authenticated` or `refreshing`. + */ +export type AuthState = + | SignedInState + | SignedInState + | SignedOutState<"initializing"> + | SignedOutState<"unauthenticated"> + | (StateFields & { + readonly status: "unavailable"; + readonly user: null; + /** The session whose access is unusable, if any. */ + readonly sessionId: string | null; + }); -export type AuthClient = { +type AuthClientBase = { /** * Reactive `AuthState`. Read it in React with `useAuth`. `subscribe` reports * later changes, not the current value. A listener that throws does not @@ -136,58 +200,83 @@ export type AuthClient = { */ signIn: (input: SignInInput) => Promise; /** - * Ends the session in this tab and clears storage. Rejects with - * `PERSISTENCE_FAILED` if storage cannot be cleared; call it again later. + * Ends the session in this tab and clears storage, then calls `revoke`. A + * failed revocation is published as `REVOKE_FAILED` without rejecting. + * Rejects with `PERSISTENCE_FAILED` if storage cannot be cleared; call it + * again later. */ signOut: () => Promise; /** - * Replaces the user and keeps the session ID. Accepts the user or an async - * function returning it. If calls overlap, only the latest commits. - */ - updateUser: (input: I | (() => Promise)) => Promise; - /** - * Resolves the current session, restoring or refreshing it if needed, or - * `null` when signed out. After a failed refresh, rethrows that failure until - * `retry()`, sign-in, or sign-out. + * Resolves who is signed in, restoring or refreshing the session if needed, + * or `null` when signed out. After a failed refresh, rethrows that failure + * until `retry()`, sign-in, or sign-out. */ getSession: () => Promise | null>; /** Like `getSession()`, but tries a failed refresh again. */ retry: () => Promise | null>; /** - * Recovers after the server rejected this session's access token, e.g. a 401. - * Concurrent calls share one refresh. Without a refresh callback or refresh - * token, signs out and resolves `null`. + * Refreshes now, even if the access token is still usable, for example to + * pick up a changed user. Concurrent refreshes share one request. Rejects + * with `REFRESH_FAILED` without a refresh callback or refresh token. */ - refresh: (session: Session) => Promise | null>; - /** Signs out only if this session and access token are still current. */ - rejectSession: (session: Session) => Promise; - /** Whether this snapshot belongs to the current session (same `id`). */ - isCurrent: (session: Session) => boolean; + refresh: () => Promise | null>; + /** Whether this snapshot belongs to the current session (same `sessionId`). */ + isCurrent: (session: { sessionId: string }) => boolean; /** Called by the Router connector. Cleanup stops background timers. */ mount: () => () => void; + /** Access tokens, for code that attaches them to requests, such as `createAuthFetch`. */ + credentials: { + /** Like `getSession()`, but resolves the current access token. */ + get: () => Promise; + /** + * Recovers after the server rejected these credentials, e.g. with a 401. + * Concurrent calls share one refresh. Without a refresh callback or + * refresh token, signs out and resolves `null`. + */ + renew: (credentials: Credentials) => Promise; + /** Signs out only if these credentials are still current. */ + reject: (credentials: Credentials) => Promise; + }; }; -export type AuthOptions = { +/** + * The auth client from `createAuth`. `U` is the user; `I` is what `signIn` and + * `updateUser` accept as the user, or `never` when the user comes from the + * access token, in which case there is no `updateUser`. + */ +export type AuthClient = AuthClientBase & + ([I] extends [never] + ? unknown + : { + /** + * Replaces the user and keeps the session ID. Accepts the user or an + * async function returning it. If calls overlap, only the latest commits. + */ + updateUser: (input: I | (() => Promise)) => Promise; + }); + +export type AuthOptions = { /** * Storage namespace: letters, digits, `_` or `-`, up to 64 characters. Uses * the storage key `:auth:session` and the lock `:auth:refresh`. */ name: string; /** - * Where the session is saved. Default: `webStorage()`, which uses - * `localStorage`. See `AuthStorage` to save it elsewhere. - */ - storage?: AuthStorage; - /** - * Standard Schema V1 schema for the user. Its output must be a plain JSON - * object and must validate again when restored from storage. + * Where the user comes from. A Standard Schema V1 schema validates the + * `user` passed to `signIn`; its output must be a plain JSON object that + * the schema accepts again unchanged, because restoring validates it again. + * `fromAccessToken(…)` reads the user from the access token instead. */ - userSchema: S; + user: Src; + /** Where the session is saved, such as `webStorage()`. See `AuthStorage`. */ + storage: AuthStorage; /** * Exchanges a refresh token for new tokens, at expiry or after a 401. Without * it, expired or rejected access signs the user out. See `RefreshFn`. */ - refresh?: RefreshFn>>; + refresh?: RefreshFn>>; + /** Revokes tokens on the backend after `signOut()`. See `RevokeFn`. */ + revoke?: RevokeFn; /** Saved-session lifetime in seconds, renewed on successful writes. Default: 30 days. */ maxAge?: number; }; diff --git a/src/core/user.ts b/src/core/user.ts index 556d6cd..fbf18e9 100644 --- a/src/core/user.ts +++ b/src/core/user.ts @@ -2,21 +2,21 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { AuthError } from "./errors.js"; import { isRecord } from "./json.js"; -import type { User } from "./types.js"; /** - * Rejects values that JSON would silently drop or change, then deep-freezes the - * value so callers cannot mutate auth state without notifying subscribers. + * Copies `value` as deeply frozen JSON, rejecting values JSON would drop or + * change. Auth state then cannot be mutated without notifying subscribers, + * and data the caller still holds is never frozen. */ -function freezeJson(value: unknown, ancestors = new Set()): void { +function frozenJson(value: unknown, ancestors = new Set()): unknown { if ( value === null || typeof value === "string" || typeof value === "boolean" ) - return; - if (typeof value === "number" && Number.isFinite(value)) return; - if (typeof value !== "object" || value === null || ancestors.has(value)) { + return value; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "object" || ancestors.has(value)) { throw new AuthError( "USER_VALIDATION_FAILED", "User must contain only JSON values", @@ -30,6 +30,7 @@ function freezeJson(value: unknown, ancestors = new Set()): void { ); } ancestors.add(value); + let copy: unknown[] | Record; if (Array.isArray(value)) { if (Object.keys(value).length !== value.length) { throw new AuthError( @@ -37,8 +38,9 @@ function freezeJson(value: unknown, ancestors = new Set()): void { "User arrays must be dense", ); } - for (const item of value) freezeJson(item, ancestors); + copy = value.map((item) => frozenJson(item, ancestors)); } else { + copy = {}; for (const key of Reflect.ownKeys(value)) { const descriptor = Object.getOwnPropertyDescriptor(value, key)!; if ( @@ -51,32 +53,39 @@ function freezeJson(value: unknown, ancestors = new Set()): void { "User properties must be plain JSON data", ); } - freezeJson(descriptor.value, ancestors); + // Defined, not assigned, so a `__proto__` key stays an ordinary key. + Object.defineProperty(copy, key, { + value: frozenJson(descriptor.value, ancestors), + enumerable: true, + writable: true, + configurable: true, + }); } } ancestors.delete(value); - Object.freeze(value); + return Object.freeze(copy); +} + +/** Accepts a plain JSON object as the user, returning a frozen copy. */ +export function toUser(value: unknown): Record { + if (!isRecord(value)) { + throw new AuthError("USER_VALIDATION_FAILED", "User must be an object"); + } + return frozenJson(value) as Record; } export async function validateUser( schema: S, input: unknown, -): Promise> { +): Promise> { try { - const result = await schema["~standard"].validate(structuredClone(input)); + const result = await schema["~standard"].validate(input); if (result.issues) { throw new AuthError("USER_VALIDATION_FAILED", "Invalid user", { issues: result.issues, }); } - if (!isRecord(result.value)) { - throw new AuthError( - "USER_VALIDATION_FAILED", - "User must be an object", - ); - } - freezeJson(result.value); - return result.value; + return toUser(result.value); } catch (cause) { if (cause instanceof AuthError) throw cause; throw new AuthError("USER_VALIDATION_FAILED", "User validation failed", { @@ -85,9 +94,41 @@ export async function validateUser( } } +/** + * Validates a user that is about to be saved. Restoring validates the saved + * output again, so the schema must accept its own output and return it + * unchanged; otherwise users would be signed out on their next reload. + */ +export async function validateNewUser( + schema: S, + input: unknown, +): Promise> { + const user = await validateUser(schema, input); + let again: unknown; + try { + again = await validateUser(schema, user); + } catch (cause) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "The user schema must accept its own output, because saved users are validated again when restored", + { + cause, + issues: cause instanceof AuthError ? cause.issues : undefined, + }, + ); + } + if (!sameUser(user, again)) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "The user schema must return its own output unchanged, because saved users are validated again when restored", + ); + } + return user; +} + /** * Whether two validated users hold the same data. Both are JSON produced by - * the same schema, so serialization compares them reliably. + * the same source, so serialization compares them reliably. */ export function sameUser(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b); diff --git a/src/http/index.ts b/src/http/index.ts index 31939c3..5b8dc5e 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,5 +1,11 @@ import { AuthError, sessionChanged } from "../core/errors.js"; -import type { AuthClient, Session } from "../core/types.js"; +import type { AuthClient, Credentials } from "../core/types.js"; + +/** The part of the auth client that `createAuthFetch` uses. */ +export type AuthFetchClient = Pick< + AuthClient, + "credentials" | "isCurrent" +>; export type AuthFetchOptions = { /** @@ -15,8 +21,8 @@ async function discard(response: Response) { await response.body?.cancel().catch(() => {}); } -export function createAuthFetch( - auth: AuthClient, +export function createAuthFetch( + auth: AuthFetchClient, baseUrl: string, options: AuthFetchOptions = {}, ) { @@ -32,14 +38,14 @@ export function createAuthFetch( "Authenticated requests must stay on the configured API origin", ); } - const captured = await auth.getSession(); + const captured = await auth.credentials.get(); if (!captured) throw new AuthError("UNAUTHENTICATED", "Sign in is required"); const request = new Request(url, init); const canReplay = request.method === "GET" || request.method === "HEAD"; - const send = async (session: Session) => { + const send = async (session: Credentials) => { if (!auth.isCurrent(session)) throw sessionChanged(); const headers = new Headers(request.headers); headers.set("Authorization", `Bearer ${session.accessToken}`); @@ -57,9 +63,9 @@ export function createAuthFetch( if (response.status !== 401) return response; - let fresh: Session | null; + let fresh: Credentials | null; try { - fresh = await auth.refresh(captured); + fresh = await auth.credentials.renew(captured); } catch (error) { await discard(response); throw error; @@ -72,7 +78,7 @@ export function createAuthFetch( const retried = await send(fresh); if (retried.status === 401 && signOutOnRepeated401) - await auth.rejectSession(fresh); + await auth.credentials.reject(fresh); return retried; }; diff --git a/src/index.ts b/src/index.ts index d4514c0..a84bb79 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,14 @@ export { createAuth } from "./core/client.js"; -export { createRefreshFn } from "./core/refresh.js"; export { AuthError } from "./core/errors.js"; +export { fromAccessToken } from "./core/sources.js"; export { memoryStorage, webStorage } from "./core/storage.js"; export type { AuthErrorCode, AuthErrorOptions } from "./core/errors.js"; +export type { + AccessTokenUser, + FromAccessTokenOptions, + UserSource, +} from "./core/sources.js"; export type { AuthStorage, WebStorageOptions } from "./core/storage.js"; export type { @@ -11,11 +16,13 @@ export type { AuthOptions, AuthState, AuthStatus, - Tokens, - SignInInput, - Session, + Credentials, RefreshFn, RefreshResult, + RevokeFn, + Session, + SignInInput, + Tokens, User, UserInput, } from "./core/types.js"; diff --git a/src/react/connect.ts b/src/react/connect.ts index 99deb89..690cba3 100644 --- a/src/react/connect.ts +++ b/src/react/connect.ts @@ -1,13 +1,13 @@ import type { AuthClient } from "../core/types.js"; -type AuthRouter = { - options: { context: { auth: AuthClient } }; +type AuthRouter = { + options: { context: { auth: Pick, "state" | "mount"> } }; invalidate: () => Promise; clearCache: () => void; }; const connections = new WeakMap void>(); -export function connectAuth(router: AuthRouter): () => void { +export function connectAuth(router: AuthRouter): () => void { const existing = connections.get(router); if (existing) return existing; const auth = router.options.context.auth; diff --git a/tests/consumer.ts b/tests/consumer.ts index ff07c38..51df6e7 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -7,20 +7,26 @@ import { z } from "zod"; import { createAuthFetch } from "../dist/http/index.js"; import { createAuth, - createRefreshFn, + fromAccessToken, memoryStorage, webStorage, + type AuthState, type AuthStorage, + type RefreshFn, + type UserInput, } from "../dist/index.js"; import { connectAuth, useAuth, useAuthClient } from "../dist/react/index.js"; +const userSchema = z.object({ id: z.string(), email: z.string() }); + const auth = createAuth({ name: "type-test", - userSchema: z.object({ id: z.string(), email: z.string() }), - refresh: createRefreshFn(async () => ({ + user: userSchema, + storage: webStorage(), + refresh: async () => ({ accessToken: "new-token", expiresIn: 3600, - })), + }), }); const root = createRootRouteWithContext<{ auth: typeof auth }>()({}); const router = createRouter({ routeTree: root, context: { auth } }); @@ -52,16 +58,85 @@ export type ClientMustNotBeAny = AssertFalse< >; type State = ReturnType["state"]["get"]>; export type UserMustNotBeAny = AssertFalse>; -const wrongRefresh = createRefreshFn(async () => ({ + +// Checking `status` narrows `user`. +function greet(state: AuthState<{ email: string }>) { + if (state.status === "authenticated" || state.status === "refreshing") { + const email: string = state.user.email; + const sessionId: string = state.sessionId; + return { email, sessionId }; + } + const signedOut: null = state.user; + return signedOut; +} +void greet; + +// Sessions carry no tokens; credentials do. +async function sessions() { + const session = await auth.getSession(); + // @ts-expect-error Sessions hold no tokens. + void session?.accessToken; + const credentials = await auth.credentials.get(); + const token: string | undefined = credentials?.accessToken; + return { id: session?.sessionId, token }; +} +void sessions; + +// A standalone refresh callback is typed with `RefreshFn`. +const standaloneRefresh: RefreshFn< + UserInput +> = async () => ({ accessToken: "token", - user: { id: 42 }, -})); + user: { id: "a", email: "a@example.com" }, +}); +createAuth({ + name: "standalone", + user: userSchema, + storage: webStorage(), + refresh: standaloneRefresh, +}); createAuth({ name: "bad", - userSchema: z.object({ id: z.string() }), + user: userSchema, + storage: webStorage(), // @ts-expect-error Refresh results must match the schema input. - refresh: wrongRefresh, + refresh: async () => ({ accessToken: "token", user: { id: 42 } }), +}); +// @ts-expect-error Storage is required. +createAuth({ name: "no-storage", user: userSchema }); + +// A user read from the access token: tokens only, and no `updateUser`. +const tokenAuth = createAuth({ + name: "token-user", + user: fromAccessToken( + z.object({ sub: z.string(), role: z.enum(["admin", "member"]) }), + (claims) => ({ id: claims.sub, admin: claims.role === "admin" }), + ), + storage: memoryStorage(), + refresh: async () => ({ accessToken: "token" }), +}); +void tokenAuth.signIn({ accessToken: "token", refreshToken: "refresh" }); +// @ts-expect-error Users come from the token, so signIn takes no user. +void tokenAuth.signIn({ accessToken: "token", user: { id: "a" } }); +// @ts-expect-error There is no updateUser; refresh to get a new token. +void tokenAuth.updateUser; +void tokenAuth.refresh(); +const admin: boolean | undefined = tokenAuth.state.get().user?.admin; +void admin; +createAuth({ + name: "token-refresh", + user: fromAccessToken(z.object({ sub: z.string() })), + storage: memoryStorage(), + // @ts-expect-error Refresh results carry no user when it comes from the token. + refresh: async () => ({ accessToken: "token", user: { sub: "a" } }), +}); +const claimsUser = createAuth({ + name: "claims", + user: fromAccessToken(z.object({ sub: z.string() })), + storage: memoryStorage(), }); +const sub: string | undefined = claimsUser.state.get().user?.sub; +void sub; // Storage may be synchronous or asynchronous. const asyncStorage: AuthStorage = { @@ -74,11 +149,7 @@ for (const storage of [ memoryStorage(), webStorage({ area: "session" }), ]) { - createAuth({ - name: "stored", - userSchema: z.object({ id: z.string() }), - storage, - }); + createAuth({ name: "stored", user: userSchema, storage }); } // @ts-expect-error Storage values are strings. const numericStorage: AuthStorage = { get: () => 1, set() {}, remove() {} }; diff --git a/tests/expiry.test.ts b/tests/expiry.test.ts index 06fb89f..0c54f2b 100644 --- a/tests/expiry.test.ts +++ b/tests/expiry.test.ts @@ -9,7 +9,7 @@ import { client, code, recordStates, - sessionOf, + credentialsOf, useBrowserMocks, } from "./helpers.ts"; @@ -36,7 +36,7 @@ test("timers longer than setTimeout's limit neither fire early nor get lost", as t.mock.timers.tick(10 * day - 60_000); await flush(); assert.equal(refreshes, 1); - assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); cleanups.pop()!(); t.mock.timers.reset(); }); @@ -100,12 +100,12 @@ test("expiry without a refresh handler signs out", async (t) => { test("opaque tokens work and explicit expiry wins over JWT exp", async () => { const auth = client(); await auth.signIn(input()); - assert.equal((await sessionOf(auth)).expiresAt, undefined); + assert.equal((await credentialsOf(auth)).expiresAt, undefined); const jwt = `e30.${Buffer.from(JSON.stringify({ exp: 1 })).toString("base64url")}.x`; await auth.signIn( input({ accessToken: jwt, expiresAt: Date.now() + 60_000 }), ); - assert.ok(((await sessionOf(auth)).expiresAt ?? 0) > Date.now()); + assert.ok(((await credentialsOf(auth)).expiresAt ?? 0) > Date.now()); }); const jwt = (claims: object) => @@ -121,7 +121,7 @@ test("JWT lifetime is measured from receipt when the browser clock runs fast", a await auth.signIn( input({ accessToken: jwt({ iat: serverNow, exp: serverNow + 300 }) }), ); - assert.equal((await sessionOf(auth)).expiresAt, Date.now() + 300_000); + assert.equal((await credentialsOf(auth)).expiresAt, Date.now() + 300_000); t.mock.timers.reset(); }); @@ -129,7 +129,7 @@ test("JWT exp without iat stays absolute", async () => { const auth = client(); const exp = Math.floor(Date.now() / 1000) + 300; await auth.signIn(input({ accessToken: jwt({ exp }) })); - assert.equal((await sessionOf(auth)).expiresAt, exp * 1000); + assert.equal((await credentialsOf(auth)).expiresAt, exp * 1000); await assert.rejects( auth.signIn(input({ accessToken: jwt({ exp: 1 }) })), code("INVALID_SESSION"), @@ -142,9 +142,9 @@ test("expiresIn seconds are measured from receipt for sign-in and refresh", asyn refresh: async () => ({ accessToken: "access-2", expiresIn: 120 }), }); await auth.signIn(input({ expiresIn: 60 })); - assert.equal((await sessionOf(auth)).expiresAt, 1_060_000); + assert.equal((await credentialsOf(auth)).expiresAt, 1_060_000); t.mock.timers.tick(10_000); - const refreshed = await auth.refresh(await sessionOf(auth)); + const refreshed = await auth.credentials.renew(await credentialsOf(auth)); assert.equal(refreshed?.expiresAt, 1_130_000); t.mock.timers.reset(); }); @@ -169,7 +169,7 @@ test("restoration keeps the saved expiry instead of re-deriving JWT lifetime", a const iat = 1_000_000; await client().signIn(input({ accessToken: jwt({ iat, exp: iat + 300 }) })); t.mock.timers.tick(200_000); - const restored = await sessionOf(client()); + const restored = await credentialsOf(client()); assert.equal(restored.expiresAt, 1_000_300_000); t.mock.timers.reset(); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index b95fcac..faa59c2 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -9,10 +9,12 @@ import { type AuthOptions, type AuthState, type AuthStorage, - type SignInInput, + type Credentials, + type Session, + type Tokens, + type UserSource, } from "@monarcode/session-kit"; import { connectAuth } from "@monarcode/session-kit/react"; -import type { StandardSchemaV1 } from "@standard-schema/spec"; import { z } from "zod"; export const userSchema = z.object({ @@ -43,6 +45,7 @@ let locks: ReturnType; let storageEvents: EventTarget; let originalGlobals: Map; const browserGlobals = [ + "crypto", "location", "document", "localStorage", @@ -83,8 +86,8 @@ export const flush = async () => { /** Sign-in input for `user`; override `user` when a test uses another schema. */ export const input = ( - overrides: Partial> = {}, -): SignInInput => ({ + overrides: Partial = {}, +): Tokens & { user: I } => ({ accessToken: "access-1", refreshToken: "refresh-1", user: user as I, @@ -126,18 +129,21 @@ const asyncStorage = process.env.SESSION_KIT_TEST_STORAGE === "async"; export type TestClient = ReturnType>; -/** A client named `test`, using `userSchema` unless another schema is given. */ +/** + * A client named `test` with web storage, using `userSchema` unless another + * user source is given. + */ export function client( options?: Partial>, ): TestClient; -export function client( - options: Partial> & { userSchema: S }, +export function client( + options: Partial> & { user: S }, ): ReturnType>; export function client(options: object = {}) { return createAuth({ name: "test", - userSchema, - ...(asyncStorage && { storage: deferredStorage(webStorage()) }), + user: userSchema, + storage: asyncStorage ? deferredStorage(webStorage()) : webStorage(), ...(options as Partial>), }); } @@ -146,12 +152,27 @@ export function client(options: object = {}) { export const saved = () => JSON.parse(values.get(SESSION) ?? "null"); /** The current session, failing the test when there is none. */ -export const sessionOf = async (auth: AuthClient) => { +export const sessionOf = async (auth: { + getSession: () => Promise | null>; +}) => { const session = await auth.getSession(); assert.ok(session, "Expected a current session"); return session; }; +/** The current credentials, failing the test when there are none. */ +export const credentialsOf = async ( + auth: Pick, "credentials">, +) => { + const credentials: Credentials | null = await auth.credentials.get(); + assert.ok(credentials, "Expected current credentials"); + return credentials; +}; + +/** An unsigned JWT carrying `claims`, as auth only decodes access tokens. */ +export const jwt = (claims: object) => + `e30.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.x`; + /** Matches an `AuthError` with `expected` for `assert.rejects`. */ export const code = (expected: AuthErrorCode) => (error: unknown) => typeof error === "object" && @@ -264,7 +285,9 @@ export const broadcast = async (key = SESSION) => { }; /** A Router stand-in that counts invalidations. */ -export const connectCounting = (auth: AuthClient) => { +export const connectCounting = ( + auth: Pick, "state" | "mount">, +) => { const router = { invalidations: 0, options: { context: { auth } }, @@ -278,7 +301,7 @@ export const connectCounting = (auth: AuthClient) => { }; /** Records every state the client publishes from now on. */ -export const recordStates = (auth: AuthClient) => { +export const recordStates = (auth: Pick, "state">) => { const states: AuthState[] = []; const subscription = auth.state.subscribe((state) => states.push(state)); cleanups.push(() => subscription.unsubscribe()); diff --git a/tests/http.test.ts b/tests/http.test.ts index e9059e1..05205d5 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -7,7 +7,7 @@ import { input, client, code, - sessionOf, + credentialsOf, setGlobal, useBrowserMocks, } from "./helpers.ts"; @@ -74,7 +74,7 @@ test("a repeated 401 can keep the session when configured", async () => { signOutOnRepeated401: false, }); assert.equal((await request("/me")).status, 401); - assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); }); test("a failed refresh after a 401 releases the 401 response body", async () => { diff --git a/tests/package.test.ts b/tests/package.test.ts index b5ef767..4951efc 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { createAuth, - createRefreshFn, + fromAccessToken, memoryStorage, webStorage, } from "@monarcode/session-kit"; @@ -16,7 +16,7 @@ const packageRoot = new URL("../", import.meta.url); test("all public entry points import without browser globals", () => { for (const fn of [ createAuth, - createRefreshFn, + fromAccessToken, memoryStorage, webStorage, connectAuth, @@ -48,8 +48,3 @@ test("React declarations retain consumer Router registration", async () => { assert.match(declarations, /useAuthClient\(\): RegisteredAuth/); assert.doesNotMatch(declarations, /useAuthClient\(\): any/); }); - -test("refresh definition preserves the callback", () => { - const callback = async () => ({ accessToken: "new-token" }); - assert.equal(createRefreshFn(callback), callback); -}); diff --git a/tests/react.test.ts b/tests/react.test.ts index 731c078..ec6bf0d 100644 --- a/tests/react.test.ts +++ b/tests/react.test.ts @@ -10,6 +10,7 @@ import { import { createAuth, + webStorage, type AuthClient, type AuthState, } from "@monarcode/session-kit"; @@ -99,7 +100,8 @@ const signIn = (auth: Client) => auth.signIn({ accessToken: "access-1", user }); async function fixture() { const auth = createAuth({ name: "react-test", - userSchema, + user: userSchema, + storage: webStorage(), }); await auth.getSession(); const router = createRouter({ diff --git a/tests/refresh.test.ts b/tests/refresh.test.ts index 3a8cc40..5b34dad 100644 --- a/tests/refresh.test.ts +++ b/tests/refresh.test.ts @@ -12,6 +12,7 @@ import { input, client, code, + credentialsOf, sessionOf, saved, useBrowserMocks, @@ -29,9 +30,9 @@ test("concurrent refresh requests share one backend operation", async () => { }, }); await auth.signIn(input()); - const session = await sessionOf(auth); - const a = auth.refresh(session), - b = auth.refresh(session); + const session = await credentialsOf(auth); + const a = auth.credentials.renew(session), + b = auth.credentials.renew(session); await flush(); assert.equal(calls, 1); gate.resolve({ accessToken: "access-2" }); @@ -44,7 +45,7 @@ test("late refresh success cannot restore a signed-out session", async () => { const gate = deferred(); const auth = client({ refresh: async () => gate.promise }); await auth.signIn(input()); - const pending = auth.refresh(await sessionOf(auth)); + const pending = auth.credentials.renew(await credentialsOf(auth)); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); await auth.signOut(); @@ -59,7 +60,7 @@ test("late refresh failure cannot clear a new account", async () => { const gate = deferred(); const auth = client({ refresh: async () => gate.promise }); await auth.signIn(input()); - const pending = auth.refresh(await sessionOf(auth)); + const pending = auth.credentials.renew(await credentialsOf(auth)); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); await auth.signIn( @@ -77,28 +78,28 @@ test("a refresh begun while sign-in validates cannot overwrite the new account", u.id === "bob" ? validation.promise : true, ); const auth = client({ - userSchema: schema, + user: schema, refresh: async () => refresh.promise, }); await auth.signIn(input()); - const old = await sessionOf(auth); + const old = await credentialsOf(auth); const login = auth.signIn( input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), ); - const pending = auth.refresh(old); + const pending = auth.credentials.renew(old); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); validation.resolve(true); await login; refresh.resolve({ accessToken: "old-rotated" }); await checked; - assert.equal((await sessionOf(auth)).accessToken, "bob-token"); + assert.equal((await credentialsOf(auth)).accessToken, "bob-token"); }); test("refresh null ends the session; thrown network errors retain credentials", async () => { const auth = client({ refresh: async () => null }); await auth.signIn(input()); - assert.equal(await auth.refresh(await sessionOf(auth)), null); + assert.equal(await auth.credentials.renew(await credentialsOf(auth)), null); const retry = client({ refresh: async () => { throw new Error("offline"); @@ -106,7 +107,7 @@ test("refresh null ends the session; thrown network errors retain credentials", }); await retry.signIn(input()); await assert.rejects( - retry.refresh(await sessionOf(retry)), + retry.credentials.renew(await credentialsOf(retry)), code("REFRESH_FAILED"), ); assert.equal(retry.state.get().status, "unavailable"); @@ -117,7 +118,7 @@ test("a rejected access token cannot be returned as fresh", async () => { const auth = client({ refresh: async () => ({ accessToken: "access-1" }) }); await auth.signIn(input()); await assert.rejects( - auth.refresh(await sessionOf(auth)), + auth.credentials.renew(await credentialsOf(auth)), code("INVALID_SESSION"), ); assert.equal(auth.state.get().user, null); @@ -132,19 +133,19 @@ test("late 401 for a rotated token reuses the newer token", async () => { }, }); await auth.signIn(input()); - const old = await sessionOf(auth); - await auth.refresh(old); - assert.equal((await auth.refresh(old))?.accessToken, "access-2"); + const old = await credentialsOf(auth); + await auth.credentials.renew(old); + assert.equal((await auth.credentials.renew(old))?.accessToken, "access-2"); assert.equal(calls, 1); }); test("an old account rejection cannot sign out the new account", async () => { const auth = client(); await auth.signIn(input()); - const old = await sessionOf(auth); + const old = await credentialsOf(auth); await auth.signIn(input({ user: { ...user, id: "bob" } })); - await assert.rejects(auth.refresh(old), code("SESSION_CHANGED")); - await auth.rejectSession(old); + await assert.rejects(auth.credentials.renew(old), code("SESSION_CHANGED")); + await auth.credentials.reject(old); assert.equal((await sessionOf(auth)).user.id, "bob"); }); @@ -169,7 +170,7 @@ test("a handler that ignores abort still times out", async (t) => { t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); const auth = client({ refresh: async () => new Promise(() => {}) }); await auth.signIn(input()); - const pending = auth.refresh(await sessionOf(auth)); + const pending = auth.credentials.renew(await credentialsOf(auth)); const checked = assert.rejects(pending, code("REFRESH_FAILED")); await flush(); t.mock.timers.tick(15_000); @@ -184,7 +185,7 @@ test("invalid optional refresh user cannot partially replace tokens", async () = }); await auth.signIn(input()); await assert.rejects( - auth.refresh(await sessionOf(auth)), + auth.credentials.renew(await credentialsOf(auth)), code("USER_VALIDATION_FAILED"), ); const entry = saved(); @@ -202,7 +203,7 @@ test("sign-in clears a stored refresh failure", async (t) => { }); await auth.signIn(input()); await assert.rejects( - auth.refresh(await sessionOf(auth)), + auth.credentials.renew(await credentialsOf(auth)), code("REFRESH_FAILED"), ); await auth.signIn(input({ accessToken: "access-2", expiresAt: 1_010_000 })); @@ -211,3 +212,36 @@ test("sign-in clears a stored refresh failure", async (t) => { assert.equal(refreshes, 2); t.mock.timers.reset(); }); + +test("refresh() replaces usable tokens and needs a refresh callback", async () => { + let calls = 0; + const auth = client({ + refresh: async () => { + calls++; + return { accessToken: "access-2" }; + }, + }); + assert.equal(await auth.refresh(), null); + await auth.signIn(input()); + const before = auth.state.get(); + assert.equal((await auth.refresh())?.sessionId, before.sessionId); + assert.equal(calls, 1); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); + assert.equal(saved().accessToken, "access-2"); + assert.equal(auth.state.get().version, before.version); + const without = client(); + await without.signIn(input()); + await assert.rejects(without.refresh(), code("REFRESH_FAILED")); + assert.equal(without.state.get().status, "authenticated"); +}); + +test("a refresh returning an unchanged user keeps the user and version", async () => { + const auth = client({ + refresh: async () => ({ accessToken: "access-2", user }), + }); + await auth.signIn(input()); + const before = auth.state.get(); + await auth.refresh(); + assert.equal(auth.state.get().user, before.user); + assert.equal(auth.state.get().version, before.version); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts index 6580d7b..0578993 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -14,7 +14,7 @@ import { code, connectCounting, recordStates, - sessionOf, + credentialsOf, setGlobal, type TestClient, useBrowserMocks, @@ -90,7 +90,7 @@ test("401-triggered token-only refresh keeps the user and does not invalidate", await flush(); const before = { ...auth.state.get(), invalidations: router.invalidations }; const states = recordStates(auth); - const pending = auth.refresh(await sessionOf(auth)); + const pending = auth.credentials.renew(await credentialsOf(auth)); await flush(); assert.equal(auth.state.get().status, "refreshing"); assert.equal(auth.state.get().user?.id, "alice"); @@ -112,7 +112,7 @@ test("refresh failure after rejection becomes unavailable and invalidates once", await flush(); const before = router.invalidations; const states = recordStates(auth); - const pending = auth.refresh(await sessionOf(auth)); + const pending = auth.credentials.renew(await credentialsOf(auth)); const checked = assert.rejects(pending, code("REFRESH_FAILED")); await flush(); assert.equal(auth.state.get().status, "refreshing"); @@ -135,7 +135,7 @@ test("terminal refresh rejection signs out with one invalidation", async () => { await auth.signIn(input()); await flush(); const before = router.invalidations; - assert.equal(await auth.refresh(await sessionOf(auth)), null); + assert.equal(await auth.credentials.renew(await credentialsOf(auth)), null); await flush(); assert.equal(auth.state.get().status, "unauthenticated"); assert.equal(router.invalidations, before + 1); @@ -223,7 +223,7 @@ test("a failed refresh reruns real Router guards once without retrying until ret const router = await realRouter(auth); const guardCalls = router.counts.guardCalls; await assert.rejects( - auth.refresh(await sessionOf(auth)), + auth.credentials.renew(await credentialsOf(auth)), code("REFRESH_FAILED"), ); await settle(); @@ -239,7 +239,8 @@ test("a failed refresh reruns real Router guards once without retrying until ret assert.equal(refreshes, 1); offline = false; - assert.equal((await auth.retry())?.accessToken, "access-2"); + assert.equal((await auth.retry())?.user.id, "alice"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); await settle(); assert.equal(refreshes, 2); assert.equal(auth.state.get().status, "authenticated"); diff --git a/tests/session.test.ts b/tests/session.test.ts index 1c04785..d276001 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import type { AuthError } from "@monarcode/session-kit"; +import { createAuth, type AuthError } from "@monarcode/session-kit"; import { z } from "zod"; import { @@ -19,8 +19,10 @@ import { input, client, code, + credentialsOf, sessionOf, saved, + setGlobal, useBrowserMocks, } from "./helpers.ts"; @@ -33,9 +35,9 @@ test("schema defaults and custom fields survive restoration; tokens stay out of assert.equal(saved().refreshToken, "refresh-1"); assert.equal(cookies.size, 0); assert.equal(JSON.stringify(auth.state.get()).includes("refresh-1"), false); - const restored = await sessionOf(client()); - assert.equal(restored.user.email, user.email); - assert.equal(restored.accessToken, "access-1"); + const restored = client(); + assert.equal((await sessionOf(restored)).user.email, user.email); + assert.equal((await credentialsOf(restored)).accessToken, "access-1"); }); test("user output is frozen without freezing caller input", async () => { @@ -55,7 +57,7 @@ test("invalid sign-in leaves the current session usable", async () => { auth.signIn(input({ user: { id: 4 } })), code("USER_VALIDATION_FAILED"), ); - assert.equal((await sessionOf(auth)).id, before.id); + assert.equal((await sessionOf(auth))?.sessionId, before.sessionId); }); for (const timeout of [false, true]) { @@ -69,7 +71,7 @@ for (const timeout of [false, true]) { const refresh = deferred(); let refreshCalls = 0; const auth = client({ - userSchema: userSchema.refine(async (value) => + user: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), refresh: canRefresh @@ -130,7 +132,7 @@ test("expiry during validation does not cancel a successful new sign-in", async t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); const validation = deferred(); const auth = client({ - userSchema: userSchema.refine(async (value) => + user: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), }); @@ -159,7 +161,7 @@ test("failed sign-in recovery retains expired credentials without a refresh retr const validation = deferred(); let refreshCalls = 0; const auth = client({ - userSchema: userSchema.refine(async (value) => + user: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), refresh: async () => { @@ -191,7 +193,7 @@ test("failed sign-in hides an expired unmounted session until demand retries ref const validation = deferred(); let refreshCalls = 0; const auth = client({ - userSchema: userSchema.refine(async (value) => + user: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), refresh: async () => { @@ -211,7 +213,7 @@ test("failed sign-in hides an expired unmounted session until demand retries ref assert.equal(auth.state.get().user, null); assert.equal(refreshCalls, 0); assert.ok(values.has(SESSION)); - assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); assert.equal(refreshCalls, 1); }); @@ -219,7 +221,7 @@ test("a superseded sign-in cannot recover or clear the newer account", async (t) t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); const validation = deferred(); const auth = client({ - userSchema: userSchema.refine(async (value) => + user: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), }); @@ -238,7 +240,7 @@ test("a superseded sign-in cannot recover or clear the newer account", async (t) await flush(); assert.equal(auth.state.get().status, "authenticated"); assert.equal(auth.state.get().user?.id, "carol"); - assert.equal((await sessionOf(auth)).accessToken, "carol-token"); + assert.equal((await credentialsOf(auth)).accessToken, "carol-token"); }); test("malformed saved session fails closed and removes saved data", async () => { @@ -249,9 +251,9 @@ test("malformed saved session fails closed and removes saved data", async () => assert.equal(values.size, 0); }); -test("entries from the two-key format are removed, not restored", async () => { - values.set("test:auth:tokens", JSON.stringify({ v: 2, accessToken: "old" })); - values.set("test:auth:user", JSON.stringify({ v: 2, user })); +test("sessions saved in an earlier format are removed, not restored", async () => { + await client().signIn(input()); + values.set(SESSION, JSON.stringify({ ...saved(), v: 3 })); assert.equal(await client().getSession(), null); assert.equal(values.size, 0); }); @@ -293,7 +295,7 @@ for (const invalidUser of [false, true]) { if (invalidUser) assert.ok(restoration.issues.length > 0); else assert.ok(restoration.cause instanceof Error); assert.equal(cleanup.code, "PERSISTENCE_FAILED"); - assert.ok(cleanup.cause instanceof AggregateError); + assert.ok(cleanup.cause instanceof Error); assert.ok(values.has(SESSION)); assert.equal(await auth.getSession(), null); assert.equal(auth.state.get().error, failure); @@ -334,7 +336,7 @@ test("signout stays signed out when token deletion fails", async () => { test("async validation cannot commit after logout", async () => { const gate = deferred(); const schema = z.object({ id: z.string() }).refine(async () => gate.promise); - const auth = client({ userSchema: schema }); + const auth = client({ user: schema }); const pending = auth.signIn(input({ user: { id: "a" } })); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); @@ -359,7 +361,7 @@ test("async updateUser captures the session before running the callback", async test("schemas that return non-JSON output are rejected", async () => { const schema = z.object({ created: z.date() }); - const auth = client({ userSchema: schema }); + const auth = client({ user: schema }); await assert.rejects( auth.signIn(input({ user: { created: new Date() } })), code("USER_VALIDATION_FAILED"), @@ -387,7 +389,7 @@ test("remount while initial validation is pending starts a fresh restoration", a calls++; return gate.promise; }); - const auth = client({ userSchema: schema }); + const auth = client({ user: schema }); const off = auth.mount(); await flush(); off(); @@ -398,3 +400,79 @@ test("remount while initial validation is pending starts a fresh restoration", a assert.equal(auth.state.get().status, "authenticated"); assert.ok(calls >= 2); }); + +test("sessions name the user without tokens; credentials carry the token", async () => { + const auth = client(); + await auth.signIn(input()); + const session = await sessionOf(auth); + assert.deepEqual(Object.keys(session).sort(), ["sessionId", "user"]); + assert.equal(Object.isFrozen(session), true); + const credentials = await credentialsOf(auth); + assert.equal(credentials.accessToken, "access-1"); + assert.equal(credentials.sessionId, session.sessionId); + assert.equal("refreshToken" in credentials, false); + assert.equal(auth.isCurrent(session), true); + assert.equal(auth.isCurrent({ sessionId: "another" }), false); +}); + +test("sign-out revokes the ended tokens after clearing them", async () => { + const calls: Array<{ refreshToken?: string; cleared: boolean }> = []; + const auth = client({ + revoke: async ({ accessToken, refreshToken, signal }): Promise => { + assert.equal(accessToken, "access-1"); + assert.ok(signal instanceof AbortSignal); + calls.push({ refreshToken, cleared: !values.has(SESSION) }); + assert.equal(auth.state.get().status, "unauthenticated"); + }, + }); + await auth.signIn(input()); + await auth.signOut(); + assert.deepEqual(calls, [{ refreshToken: "refresh-1", cleared: true }]); + assert.equal(auth.state.get().error, null); +}); + +test("a failed revocation is reported without failing sign-out", async () => { + const auth = client({ + revoke: async () => { + throw new Error("offline"); + }, + }); + await auth.signIn(input()); + await auth.signOut(); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(auth.state.get().error?.code, "REVOKE_FAILED"); + assert.equal(values.size, 0); +}); + +test("sessions the backend already rejected are not revoked", async () => { + let revoked = 0; + const revoke = async () => { + revoked++; + }; + const rejected = client({ refresh: async () => null, revoke }); + await rejected.signIn(input()); + assert.equal( + await rejected.credentials.renew(await credentialsOf(rejected)), + null, + ); + const repeated = client({ revoke }); + await repeated.signIn(input()); + await repeated.credentials.reject(await credentialsOf(repeated)); + assert.equal(await repeated.getSession(), null); + assert.equal(revoked, 0); +}); + +test("session IDs do not need crypto.randomUUID", async () => { + setGlobal("crypto", undefined); + const auth = client(); + await auth.signIn(input()); + assert.match((await sessionOf(auth)).sessionId, /^[0-9a-f]{32}$/); +}); + +test("createAuth requires storage", () => { + assert.throws( + // @ts-expect-error -- deliberately missing storage + () => createAuth({ name: "test", user: userSchema }), + /storage/, + ); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 461f72a..27e2220 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -18,6 +18,7 @@ import { client, broadcast, deferredStorage, + credentialsOf, sessionOf, saved, useBrowserMocks, @@ -141,7 +142,7 @@ test("a profile update keeps tokens this tab refreshed while it read storage", a await gate.release(); // The refresh installed access-2, but its save queued behind the update's // read, which now runs and will return access-1. - assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); assert.equal(saved().accessToken, "access-1"); assert.equal(gate.pending, 1); await gate.release(); @@ -152,7 +153,7 @@ test("a profile update keeps tokens this tab refreshed while it read storage", a assert.equal(gate.pending, 0); assert.equal(saved().accessToken, "access-2"); assert.equal(saved().user.email, "new@example.com"); - assert.equal((await sessionOf(auth)).accessToken, "access-2"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); cleanups.pop()!(); t.mock.timers.reset(); }); diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts index fccf08c..90c5a19 100644 --- a/tests/tabs.test.ts +++ b/tests/tabs.test.ts @@ -18,7 +18,7 @@ import { code, broadcast, connectCounting, - sessionOf, + credentialsOf, saved, useBrowserMocks, } from "./helpers.ts"; @@ -51,20 +51,26 @@ const twoTabs = async (server: Backend) => { const a = client({ refresh: server.refresh }); await a.signIn(input()); const b = client({ refresh: server.refresh }); - assert.equal((await sessionOf(b)).accessToken, "access-1"); + assert.equal((await credentialsOf(b)).accessToken, "access-1"); return { a, b }; }; test("a tab refreshing after another tab rotated the token adopts the saved tokens", async () => { const server = rotatingServer(); const { a, b } = await twoTabs(server); - const stale = await sessionOf(b); - assert.equal((await a.refresh(await sessionOf(a)))?.accessToken, "access-2"); - assert.equal((await b.refresh(stale))?.accessToken, "access-2"); + const stale = await credentialsOf(b); + assert.equal( + (await a.credentials.renew(await credentialsOf(a)))?.accessToken, + "access-2", + ); + assert.equal((await b.credentials.renew(stale))?.accessToken, "access-2"); assert.equal(server.calls, 1); assert.equal(b.state.get().status, "authenticated"); assert.equal(saved().refreshToken, "refresh-2"); - assert.equal((await b.refresh(await sessionOf(b)))?.accessToken, "access-3"); + assert.equal( + (await b.credentials.renew(await credentialsOf(b)))?.accessToken, + "access-3", + ); assert.equal(saved().refreshToken, "refresh-3"); }); @@ -72,8 +78,8 @@ test("tabs refreshing at once spend the refresh token once", async () => { const server = rotatingServer(); const { a, b } = await twoTabs(server); const [fromA, fromB] = await Promise.all([ - a.refresh(await sessionOf(a)), - b.refresh(await sessionOf(b)), + a.credentials.renew(await credentialsOf(a)), + b.credentials.renew(await credentialsOf(b)), ]); assert.equal(server.calls, 1); assert.equal(fromA?.accessToken, "access-2"); @@ -86,8 +92,8 @@ test("without Web Locks, a rejected refresh adopts newer saved tokens instead of const gates = [deferred(), deferred()]; const server = rotatingServer({ gates: gates.map((gate) => gate.promise) }); const { a, b } = await twoTabs(server); - const fromA = a.refresh(await sessionOf(a)); - const fromB = b.refresh(await sessionOf(b)); + const fromA = a.credentials.renew(await credentialsOf(a)); + const fromB = b.credentials.renew(await credentialsOf(b)); await flush(); assert.equal(server.calls, 2); gates[0].resolve(); @@ -101,11 +107,11 @@ test("without Web Locks, a rejected refresh adopts newer saved tokens instead of test("a stale tab's profile update keeps tokens another tab refreshed", async () => { const server = rotatingServer(); const { a, b } = await twoTabs(server); - await a.refresh(await sessionOf(a)); + await a.credentials.renew(await credentialsOf(a)); await b.updateUser({ ...user, email: "b@example.com" }); assert.equal(saved().refreshToken, "refresh-2"); assert.equal(saved().user.email, "b@example.com"); - assert.equal((await sessionOf(b)).accessToken, "access-2"); + assert.equal((await credentialsOf(b)).accessToken, "access-2"); }); test("a refresh cannot overwrite an account another tab signed in", async () => { @@ -114,7 +120,10 @@ test("a refresh cannot overwrite an account another tab signed in", async () => await a.signIn( input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), ); - await assert.rejects(b.refresh(await sessionOf(b)), code("SESSION_CHANGED")); + await assert.rejects( + b.credentials.renew(await credentialsOf(b)), + code("SESSION_CHANGED"), + ); assert.equal(server.calls, 0); assert.equal(saved().accessToken, "bob-token"); assert.equal(b.state.get().user?.id, "bob"); @@ -124,7 +133,7 @@ test("a refresh after another tab signed out ends this tab's session too", async const server = rotatingServer(); const { a, b } = await twoTabs(server); await a.signOut(); - assert.equal(await b.refresh(await sessionOf(b)), null); + assert.equal(await b.credentials.renew(await credentialsOf(b)), null); assert.equal(server.calls, 0); assert.equal(b.state.get().status, "unauthenticated"); assert.equal(values.size, 0); @@ -132,11 +141,11 @@ test("a refresh after another tab signed out ends this tab's session too", async test("a second 401 does not clear an account another tab signed in", async () => { const { a, b } = await twoTabs(rotatingServer()); - const stale = await sessionOf(b); + const stale = await credentialsOf(b); await a.signIn( input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), ); - await assert.rejects(b.rejectSession(stale), code("SESSION_CHANGED")); + await assert.rejects(b.credentials.reject(stale), code("SESSION_CHANGED")); assert.equal(saved().accessToken, "bob-token"); }); @@ -174,7 +183,7 @@ test("switching accounts in one tab switches the others and cancels their work", const gate = deferred(); const { a, b } = await mountedTabs({ refresh: async () => gate.promise }); const aliceId = b.state.get().sessionId; - const pending = b.refresh(await sessionOf(b)); + const pending = b.credentials.renew(await credentialsOf(b)); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); await a.signIn( @@ -184,7 +193,7 @@ test("switching accounts in one tab switches the others and cancels their work", await checked; assert.equal(b.state.get().user?.id, "bob"); assert.notEqual(b.state.get().sessionId, aliceId); - assert.equal((await sessionOf(b)).accessToken, "bob-token"); + assert.equal((await credentialsOf(b)).accessToken, "bob-token"); gate.resolve({ accessToken: "late" }); await flush(); assert.equal(saved().accessToken, "bob-token"); @@ -194,11 +203,11 @@ test("a refresh in one tab reaches the others without a version change", async ( const server = rotatingServer(); const { a, b } = await mountedTabs(server); const before = b.state.get(); - await a.refresh(await sessionOf(a)); + await a.credentials.renew(await credentialsOf(a)); await broadcast(); assert.equal(b.state.get().version, before.version); assert.equal(b.state.get().user, before.user); - assert.equal((await sessionOf(b)).accessToken, "access-2"); + assert.equal((await credentialsOf(b)).accessToken, "access-2"); assert.equal(server.calls, 1); }); @@ -209,7 +218,7 @@ test("a profile update in one tab reaches the others", async () => { await broadcast(); assert.equal(b.state.get().user?.email, "new@example.com"); assert.equal(b.state.get().version, before + 1); - assert.equal((await sessionOf(b)).accessToken, "access-1"); + assert.equal((await credentialsOf(b)).accessToken, "access-1"); }); test("another tab's successful refresh clears this tab's refresh failure", async () => { @@ -223,19 +232,22 @@ test("another tab's successful refresh clears this tab's refresh failure", async }); cleanups.push(b.mount()); await flush(); - await assert.rejects(b.refresh(await sessionOf(b)), code("REFRESH_FAILED")); + await assert.rejects( + b.credentials.renew(await credentialsOf(b)), + code("REFRESH_FAILED"), + ); await assert.rejects(b.getSession(), code("REFRESH_FAILED")); - await a.refresh(await sessionOf(a)); + await a.credentials.renew(await credentialsOf(a)); await broadcast(); assert.equal(b.state.get().status, "authenticated"); assert.equal(b.state.get().error, null); - assert.equal((await sessionOf(b)).accessToken, "access-2"); + assert.equal((await credentialsOf(b)).accessToken, "access-2"); }); test("a refresh in flight cannot overwrite an account signed in meanwhile", async () => { const gate = deferred(); const { a, b } = await twoTabs({ refresh: async () => gate.promise }); - const pending = b.refresh(await sessionOf(b)); + const pending = b.credentials.renew(await credentialsOf(b)); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); await a.signIn( @@ -269,7 +281,7 @@ test("invalid saved data from another tab leaves this tab's session alone", asyn test("a user another tab saved that this tab's schema rejects signs every tab out", async () => { const a = client(); await a.signIn(input()); - const b = client({ userSchema: userSchema.extend({ email: z.email() }) }); + const b = client({ user: userSchema.extend({ email: z.email() }) }); cleanups.push(a.mount(), b.mount()); await flush(); assert.equal(b.state.get().user?.email, "alice@example.com"); diff --git a/tests/users.test.ts b/tests/users.test.ts new file mode 100644 index 0000000..7db5d63 --- /dev/null +++ b/tests/users.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fromAccessToken, type AuthError } from "@monarcode/session-kit"; +import { z } from "zod"; + +import { + SESSION, + values, + cleanups, + flush, + input, + client, + code, + broadcast, + jwt, + credentialsOf, + sessionOf, + saved, + useBrowserMocks, +} from "./helpers.ts"; + +useBrowserMocks(); + +const claims = z.object({ + sub: z.string(), + email: z.string(), + role: z.enum(["admin", "member"]), +}); +const tokenUser = fromAccessToken(claims, (value) => ({ + id: value.sub, + email: value.email, + admin: value.role === "admin", +})); +const alice = { sub: "alice", email: "alice@example.com", role: "member" }; + +/** A backend issuing a fresh access token on every refresh, with `role`. */ +const tokenBackend = () => { + const backend = { + role: "member", + refreshes: 0, + refresh: async () => ({ + accessToken: jwt({ + ...alice, + role: backend.role, + jti: ++backend.refreshes, + }), + }), + }; + return backend; +}; + +test("a user read from the access token needs no user at sign-in and is not saved", async () => { + const auth = client({ user: tokenUser }); + await auth.signIn({ accessToken: jwt(alice), refreshToken: "refresh-1" }); + const expected = { id: "alice", email: "alice@example.com", admin: false }; + assert.deepEqual(auth.state.get().user, expected); + assert.equal(Object.isFrozen(auth.state.get().user), true); + assert.equal("user" in saved(), false); + assert.equal("updateUser" in auth, false); + const restored = await sessionOf(client({ user: tokenUser })); + assert.deepEqual(restored.user, expected); +}); + +test("without a mapper, the validated claims are the user", async () => { + const auth = client({ + user: fromAccessToken(z.object({ sub: z.string() })), + }); + await auth.signIn({ accessToken: jwt({ sub: "alice", exp: 9e9 }) }); + assert.deepEqual(auth.state.get().user, { sub: "alice" }); +}); + +test("a refresh changes the user only when the token's claims change it", async () => { + const backend = tokenBackend(); + const auth = client({ user: tokenUser, refresh: backend.refresh }); + await auth.signIn({ accessToken: jwt(alice), refreshToken: "refresh-1" }); + const before = auth.state.get(); + await auth.refresh(); + assert.equal(backend.refreshes, 1); + assert.notEqual((await credentialsOf(auth)).accessToken, jwt(alice)); + assert.equal(auth.state.get().user, before.user); + assert.equal(auth.state.get().version, before.version); + backend.role = "admin"; + await auth.refresh(); + assert.equal(auth.state.get().user?.admin, true); + assert.equal(auth.state.get().version, before.version + 1); +}); + +test("other tabs follow token changes, keeping an unchanged user", async () => { + const backend = tokenBackend(); + const a = client({ user: tokenUser, refresh: backend.refresh }); + await a.signIn({ accessToken: jwt(alice), refreshToken: "refresh-1" }); + const b = client({ user: tokenUser, refresh: backend.refresh }); + cleanups.push(b.mount()); + await flush(); + const before = b.state.get(); + await a.refresh(); + await broadcast(); + assert.equal( + (await credentialsOf(b)).accessToken, + (await credentialsOf(a)).accessToken, + ); + assert.equal(b.state.get().user, before.user); + assert.equal(b.state.get().version, before.version); + backend.role = "admin"; + await a.refresh(); + await broadcast(); + assert.equal(b.state.get().user?.admin, true); + assert.equal(b.state.get().version, before.version + 1); +}); + +test("access tokens that are not JWTs, or whose claims fail the schema, are rejected", async () => { + const auth = client({ user: tokenUser }); + await assert.rejects( + auth.signIn({ accessToken: "opaque-token" }), + code("USER_VALIDATION_FAILED"), + ); + await assert.rejects( + auth.signIn({ accessToken: jwt({ ...alice, role: "owner" }) }), + (error: AuthError) => + error.code === "USER_VALIDATION_FAILED" && !!error.issues?.length, + ); + assert.equal(auth.state.get().user, null); + assert.equal(values.size, 0); +}); + +test("a saved token whose claims no longer validate signs out", async () => { + await client({ user: tokenUser }).signIn({ accessToken: jwt(alice) }); + const stricter = fromAccessToken( + claims.extend({ role: z.literal("admin") }), + ); + assert.equal(await client({ user: stricter }).getSession(), null); + assert.equal(values.has(SESSION), false); +}); + +test("a custom decode, which may be asynchronous, reads the claims", async () => { + const decoded: string[] = []; + const auth = client({ + user: fromAccessToken(z.object({ sub: z.string() }), undefined, { + decode: async (accessToken) => { + decoded.push(accessToken); + return { sub: accessToken.replace("opaque:", "") }; + }, + }), + }); + await auth.signIn({ accessToken: "opaque:alice" }); + assert.deepEqual(auth.state.get().user, { sub: "alice" }); + assert.deepEqual(decoded, ["opaque:alice"]); +}); + +test("a user schema that cannot accept its own output fails sign-in", async () => { + const renaming = z + .object({ name: z.string() }) + .transform((value) => ({ displayName: value.name })); + await assert.rejects( + client({ user: renaming }).signIn(input({ user: { name: "Alice" } })), + (error: AuthError) => + error.code === "USER_VALIDATION_FAILED" && + /accept its own output/.test(error.message), + ); + const drifting = z + .object({ visits: z.number() }) + .transform((value) => ({ visits: value.visits + 1 })); + await assert.rejects( + client({ user: drifting }).signIn(input({ user: { visits: 1 } })), + (error: AuthError) => + error.code === "USER_VALIDATION_FAILED" && + /output unchanged/.test(error.message), + ); + assert.equal(values.size, 0); +}); From 4d8e64382b2b16c3bfa19e3b2a65037bc346d183 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 09:35:13 +0100 Subject: [PATCH 03/12] feat(react)!: add TanStack Router entry point and decouple React hooks from Router - Add `/tanstack-router` with `connectAuth`, `requireSession`, `SessionOutlet`, `redirectIfSignedIn` and `useRouterAuth`, and support React 18 - Breaking: `/react` exports only `createAuthHooks` (`useAuth`, `useAuthClient`, `AuthProvider`), `connectAuth` moves to `/tanstack-router`, and `safeReturnTo` moves to the package root - Breaking: make both peer dependencies optional and drop the `react-dom` peer --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 19 + README.md | 153 ++++--- package.json | 17 +- src/{react => core}/return-to.ts | 0 src/index.ts | 2 + src/react/context.ts | 73 ++++ src/react/hooks.ts | 152 ++++--- src/react/index.ts | 12 +- src/{react => tanstack-router}/connect.ts | 0 src/tanstack-router/guards.ts | 91 ++++ src/tanstack-router/index.ts | 40 ++ src/tanstack-router/outlet.ts | 64 +++ tests/consumer.ts | 34 +- tests/helpers.ts | 2 +- tests/package.test.ts | 21 +- tests/react.test.ts | 505 +++++++++++++++------- tests/return-to.test.ts | 2 +- tests/router.test.ts | 111 ++++- 19 files changed, 988 insertions(+), 316 deletions(-) rename src/{react => core}/return-to.ts (100%) create mode 100644 src/react/context.ts rename src/{react => tanstack-router}/connect.ts (100%) create mode 100644 src/tanstack-router/guards.ts create mode 100644 src/tanstack-router/index.ts create mode 100644 src/tanstack-router/outlet.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83d95de..d08dff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,11 +66,13 @@ jobs: run: pnpm install --frozen-lockfile # Keep these at the lower bounds of peerDependencies in package.json. + # Type packages are not peers: these are the first releases whose + # react/jsx-runtime and react-dom/client types resolve under NodeNext. - name: Install oldest supported peers run: >- pnpm add --save-dev --save-exact - react@19.0.0 react-dom@19.0.0 - @types/react@19.0.0 @types/react-dom@19.0.0 + react@18.0.0 react-dom@18.0.0 + @types/react@18.0.2 @types/react-dom@18.0.3 @tanstack/react-router@1.127.0 - name: Build and test diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0ac84..90a3186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,15 @@ `refreshing`. - Session IDs no longer require `crypto.randomUUID`, and user validation no longer requires `structuredClone`, for environments such as React Native. +- A new `/tanstack-router` entry point holds the Router integration: + `connectAuth`, plus guards that replace the protected-layout code apps used + to copy. `requireSession` redirects signed-out users with a `redirectTo` + search param and puts `{ session }` in route context; `SessionOutlet` renders + child routes only while that session is current, remounting them for a new + account; `redirectIfSignedIn` sends signed-in users away from the sign-in + page; `useRouterAuth` reads the client from Router context. +- React 18 is supported (`^18.0.0 || ^19.0.0`). CI tests React 18.0.0 as the + oldest version. ### Changed @@ -44,6 +53,16 @@ - **Breaking:** `createRefreshFn` is removed. Pass the refresh callback inline, or type a standalone one with `RefreshFn>`. - **Breaking:** `AuthClient` takes the user type first: `AuthClient`. +- **Breaking:** `/react` no longer depends on TanStack Router and exports only + `createAuthHooks`, which returns `useAuth`, `useAuthClient`, and an + `AuthProvider` typed for one client. With TanStack Router, keep the + provider-free setup with + `createAuthHooks({ useClient: useRouterAuth })`; elsewhere, render + `AuthProvider`, which also mounts the client. `connectAuth` moved to + `/tanstack-router`, and `safeReturnTo` to the package root. +- **Breaking:** both peer dependencies are optional, and React DOM is no longer + one. Install TanStack Router only for `/tanstack-router`, and React only for + the React entry points. - **Breaking:** sign-in, `updateUser`, and a refresh that returns a user reject with `USER_VALIDATION_FAILED` when the user schema does not accept its own output unchanged. Such a schema used to pass sign-in, then fail restoration diff --git a/README.md b/README.md index 2291156..979dca5 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Schema-driven browser authentication for React and TanStack Router. **Version: 0.1.0-alpha.3. Early development; not ready for production.** -One auth client lives in Router context. Nano Stores makes its state reactive, -with no separate AuthProvider. Tokens and the validated user profile are stored -together in one localStorage entry. +One auth client lives in Router context, and typed React hooks read it from +there, with no separate provider. Outside TanStack Router, the same hooks read +the client from an `AuthProvider`. Nano Stores makes its state reactive. Tokens +and the validated user profile are stored together in one entry. ## Installation @@ -19,10 +20,11 @@ pnpm add @monarcode/session-kit Pin `@monarcode/session-kit@0.1.0-alpha.3` to use this exact version. Alpha releases may change the public API. See [release notes](./CHANGELOG.md). -The declared peer ranges are React and React DOM `^19.0.0`, and TanStack React -Router `^1.127.0`. CI checks both the oldest and the newest versions in those -ranges. Use matching React and React DOM versions. This example uses Zod for its -Standard Schema implementation: +Both peer dependencies are optional, so install only what you use: React +`^18.0.0 || ^19.0.0` for `/react` and `/tanstack-router`, and TanStack React +Router `^1.127.0` for `/tanstack-router`. The package itself never imports +React DOM. CI checks both the oldest and the newest versions in those ranges. +This example uses Zod for its Standard Schema implementation: ```sh pnpm add react@19.3.0 react-dom@19.3.0 @tanstack/react-router@1.170.38 zod@4.4.3 @@ -33,16 +35,16 @@ users. The package is ESM-only and sets no Node.js engine requirement, though it Nano Stores dependency declares Node.js 20 or 22 and newer for server-side rendering and test runners. It is checked with TypeScript 6.0.3 under NodeNext and Bundler resolution. Older TypeScript versions -have not been verified. Browser use requires modern APIs including -`structuredClone`, `AbortController`, and `crypto.randomUUID`. +have not been verified. Auth needs `AbortController`; it uses +`crypto.randomUUID` and Web Locks when present, and falls back without them. ### Migrating from @monarcode/tanstack-auth Replace the dependency with `@monarcode/session-kit`, and change import prefixes from `@monarcode/tanstack-auth` to -`@monarcode/session-kit`. The package root, `/react`, and `/http` entry points -keep their current APIs. TanStack Router is the supported router in this alpha; -additional adapters are planned for later development. +`@monarcode/session-kit`, then follow the breaking changes in the +[release notes](./CHANGELOG.md). TanStack Router is the supported router in +this alpha; additional adapters are planned for later development. ## Quick start @@ -164,7 +166,30 @@ browser: a user object in a JSON response is trusted to the same degree. Leave claims that change on every refresh, such as `exp` or `jti`, out of the user. OAuth providers may change their access token format; this suits backends you control. -### 2. Type the root route +### 2. Create the hooks + +`src/auth-hooks.ts` + + + +```ts +import { createAuthHooks } from "@monarcode/session-kit/react"; +import { useRouterAuth } from "@monarcode/session-kit/tanstack-router"; + +export const { useAuth, useAuthClient } = createAuthHooks({ + useClient: useRouterAuth, +}); +``` + +`useRouterAuth` reads the client from Router context, so these hooks need no +provider, and Router registration in step 7 gives them your user and client +types. `useAuth()` reads the full reactive snapshot; `useAuth(selector)` +subscribes to a selected value. `useAuthClient()` returns the stable client for +actions. Without TanStack Router, call `createAuthHooks()` and +render the `AuthProvider` it returns around your app with `client={auth}`; the +provider also mounts the client, starting its timers and tab sync. + +### 3. Type the root route `src/routes/__root.tsx` @@ -176,8 +201,8 @@ import { Outlet, useRouter, } from "@tanstack/react-router"; -import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; import type { AppAuth } from "../auth.js"; +import { useAuth, useAuthClient } from "../auth-hooks.js"; export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({ component: Root, @@ -219,7 +244,7 @@ The root route defines the Router context shared by every generated file route. Keeping the error banner above `Outlet` makes cleanup failures visible after a protected route unmounts. -### 3. Add the login route +### 4. Add the login route `src/routes/login.tsx` @@ -227,19 +252,18 @@ protected route unmounts. ```tsx import { useState, type FormEvent } from "react"; -import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"; -import { safeReturnTo, useAuthClient } from "@monarcode/session-kit/react"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; +import { safeReturnTo } from "@monarcode/session-kit"; +import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router"; import { loginResponseSchema } from "../auth.js"; +import { useAuthClient } from "../auth-hooks.js"; export const Route = createFileRoute("/login")({ validateSearch: (search: Record) => ({ redirectTo: safeReturnTo(search.redirectTo), }), - beforeLoad: async ({ context, search }) => { - if (await context.auth.getSession()) { - throw redirect({ href: safeReturnTo(search.redirectTo) }); - } - }, + beforeLoad: ({ context, search }) => + redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), component: Login, }); @@ -305,8 +329,10 @@ function Login() { including the sign-in page itself, so a stale `redirectTo` cannot loop. It treats `/login`, `/login/`, and `/LOGIN` alike. If sign-in lives elsewhere, pass its path, or several: `safeReturnTo(value, { loginPath: ["/sign-in", "/signup"] })`. +`redirectIfSignedIn` sends a signed-in user on to `redirectTo`, checked the same +way, and takes the same `loginPath` option. -### 4. Add a pathless protected layout +### 5. Add a pathless protected layout The leading underscore makes `_authenticated.tsx` a pathless layout. Its child routes inherit the guard without adding `authenticated` to the URL. @@ -318,35 +344,20 @@ routes inherit the guard without adding `authenticated` to the URL. ```tsx import { createFileRoute, - Outlet, - redirect, type ErrorComponentProps, } from "@tanstack/react-router"; import { AuthError } from "@monarcode/session-kit"; -import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; +import { + requireSession, + SessionOutlet, +} from "@monarcode/session-kit/tanstack-router"; +import { useAuth, useAuthClient } from "../auth-hooks.js"; export const Route = createFileRoute("/_authenticated")({ - beforeLoad: async ({ context, location }) => { - const session = await context.auth.getSession(); - if (!session) - throw redirect({ - to: "/login", - search: { redirectTo: location.href }, - }); - if (!context.auth.isCurrent(session)) { - throw new AuthError( - "SESSION_CHANGED", - "Session changed; retry navigation", - ); - } - // Sessions hold no tokens, so they are safe in route context. - return { - sessionId: session.sessionId, - authVersion: context.auth.state.get().version, - }; - }, + beforeLoad: ({ context, location }) => + requireSession(context.auth, { location, loginPath: "/login" }), errorComponent: SessionError, - component: ProtectedLayout, + component: () => Checking session…

} />, }); function SessionError({ error }: ErrorComponentProps) { @@ -370,20 +381,21 @@ function SessionError({ error }: ErrorComponentProps) { ); } - -function ProtectedLayout() { - const { authVersion } = Route.useRouteContext(); - // `user` is set exactly while the session is authenticated or refreshing. - const signedIn = useAuth((state) => state.user !== null); - const version = useAuth((state) => state.version); - if (!signedIn || version !== authVersion) { - return

Checking session…

; - } - return ; -} ``` -### 5. Add a protected index route +`requireSession` redirects signed-out users to `loginPath` with `redirectTo` +set to where they were going, and puts `{ session }` in route context for child +routes: `session.user` is typed and never `null`, and a session holds no tokens. +If the session changes while it is checked, it checks again. A failed refresh +reaches the error component, whose Retry button calls `auth.retry()`. + +`SessionOutlet` renders the child routes while the session the guard accepted +is still the signed-in one. After a sign-out or another account signing in, it +shows `pending` until the guard runs again, and it remounts child routes for a +new account, so nothing from the previous account stays on screen. Profile +updates and token refreshes keep child routes mounted. + +### 6. Add a protected index route Placing `index.tsx` inside the `_authenticated` directory makes `/` a child of the protected layout. Add other private routes beside it, such as @@ -395,7 +407,7 @@ the protected layout. Add other private routes beside it, such as ```tsx import { createFileRoute } from "@tanstack/react-router"; -import { useAuth, useAuthClient } from "@monarcode/session-kit/react"; +import { useAuth, useAuthClient } from "../../auth-hooks.js"; export const Route = createFileRoute("/_authenticated/")({ component: Home, @@ -422,7 +434,7 @@ function Home() { } ``` -### 6. Create and connect the Router +### 7. Create and connect the Router `src/router.tsx` @@ -430,7 +442,7 @@ function Home() { ```tsx import { createRouter, useRouter } from "@tanstack/react-router"; -import { connectAuth } from "@monarcode/session-kit/react"; +import { connectAuth } from "@monarcode/session-kit/tanstack-router"; import { auth } from "./auth.js"; import { routeTree } from "./routeTree.gen.js"; @@ -467,10 +479,8 @@ declare module "@tanstack/react-router" { } ``` -`Register` gives the package hooks your application's user and client types. -Keep it in the consuming app. `useAuth()` reads the full reactive snapshot; -`useAuth(selector)` subscribes to a selected value. `useAuthClient()` returns the -stable client for actions. +`Register` gives `useRouterAuth`, and through it your hooks, the +application's user and client types. Keep it in the consuming app. Outside React, `auth.state.get()` returns the current snapshot and `auth.state.subscribe(listener)` reports later changes, not the current value; it @@ -491,7 +501,7 @@ cache entries on guard-relevant changes, and queues Router revalidation. Call `disconnectAuth` during app teardown or your bundler's hot-module disposal. Disconnecting stops background work without signing out. -### 7. Render the app +### 8. Render the app `src/main.tsx` @@ -683,11 +693,12 @@ the remaining data after storage becomes available. ## Entry points -| Import | Exports | -| ------------------------------ | ---------------------------------------------------------------------------------------------- | -| `@monarcode/session-kit` | `createAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `AuthError`, public auth types | -| `@monarcode/session-kit/react` | `connectAuth`, `safeReturnTo`, `useAuth`, `useAuthClient`, registered hook types | -| `@monarcode/session-kit/http` | `createAuthFetch` | +| Import | Exports | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `@monarcode/session-kit` | `createAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `safeReturnTo`, `AuthError`, public auth types | +| `@monarcode/session-kit/react` | `createAuthHooks` and hook types | +| `@monarcode/session-kit/tanstack-router` | `connectAuth`, `requireSession`, `redirectIfSignedIn`, `SessionOutlet`, `useRouterAuth`, `RegisteredAuth` | +| `@monarcode/session-kit/http` | `createAuthFetch` | Generated declarations retain schema inference and consumer Router registration. Declaration maps are disabled so declaration navigation targets installed `.d.ts` diff --git a/package.json b/package.json index b15f977..aa0eefe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@monarcode/session-kit", "version": "0.1.0-alpha.3", - "description": "Schema-driven browser sessions for React and TanStack Router", + "description": "Schema-driven browser sessions for React, with TanStack Router support", "keywords": [ "auth", "react", @@ -37,6 +37,10 @@ "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" }, + "./tanstack-router": { + "types": "./dist/tanstack-router/index.d.ts", + "import": "./dist/tanstack-router/index.js" + }, "./http": { "types": "./dist/http/index.d.ts", "import": "./dist/http/index.js" @@ -88,8 +92,15 @@ }, "peerDependencies": { "@tanstack/react-router": "^1.127.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-router": { + "optional": true + }, + "react": { + "optional": true + } }, "devEngines": { "runtime": { diff --git a/src/react/return-to.ts b/src/core/return-to.ts similarity index 100% rename from src/react/return-to.ts rename to src/core/return-to.ts diff --git a/src/index.ts b/src/index.ts index a84bb79..d1e3616 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ export { createAuth } from "./core/client.js"; export { AuthError } from "./core/errors.js"; +export { safeReturnTo } from "./core/return-to.js"; export { fromAccessToken } from "./core/sources.js"; export { memoryStorage, webStorage } from "./core/storage.js"; export type { AuthErrorCode, AuthErrorOptions } from "./core/errors.js"; +export type { SafeReturnToOptions } from "./core/return-to.js"; export type { AccessTokenUser, FromAccessTokenOptions, diff --git a/src/react/context.ts b/src/react/context.ts new file mode 100644 index 0000000..50740e5 --- /dev/null +++ b/src/react/context.ts @@ -0,0 +1,73 @@ +import { createContext, useCallback, useContext } from "react"; + +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector"; + +/** + * What React bindings need from an auth client: reactive state, and `mount` + * to start background work while rendered. Any `createAuth` client fits. + */ +export type AuthSource = { + state: { + get: () => unknown; + subscribe: (listener: (state: unknown) => void) => { + unsubscribe: () => void; + }; + }; + mount: () => () => void; +}; + +/** The state type of a client. */ +export type StateOf = ReturnType< + Client["state"]["get"] +>; + +/** + * Filled by every `AuthProvider`, whichever `createAuthHooks` made it, so + * components shipped by router adapters can find the nearest client. + */ +export const ClientContext = createContext(null); + +export function assertAuthSource( + value: unknown, + message: string, +): asserts value is AuthSource { + if ( + typeof value !== "object" || + value === null || + !("state" in value) || + typeof value.state !== "object" || + value.state === null || + !("get" in value.state) || + typeof value.state.get !== "function" || + !("subscribe" in value.state) || + typeof value.state.subscribe !== "function" || + !("mount" in value) || + typeof value.mount !== "function" + ) { + throw new Error(message); + } +} + +/** The nearest `AuthProvider`'s client, or `client` when given. */ +export function useProvidedClient(client?: AuthSource): AuthSource | null { + const provided = useContext(ClientContext); + return client ?? provided; +} + +/** Subscribes to `client`'s state, re-rendering only when the selection changes. */ +export function useAuthState( + client: Client, + selector: (state: StateOf) => T, +): T { + const subscribe = useCallback( + (onChange: () => void) => client.state.subscribe(onChange).unsubscribe, + [client], + ); + const getSnapshot = useCallback(() => client.state.get(), [client]); + return useSyncExternalStoreWithSelector( + subscribe, + getSnapshot, + getSnapshot, + (value) => selector(value as StateOf), + ); +} diff --git a/src/react/hooks.ts b/src/react/hooks.ts index dc01fa5..e66654b 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1,74 +1,104 @@ -import { useCallback } from "react"; +import { + createContext, + createElement, + useContext, + useEffect, + type ReactElement, + type ReactNode, +} from "react"; -import { useRouter, type Register } from "@tanstack/react-router"; -import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector"; +import { + assertAuthSource, + ClientContext, + useAuthState, + type AuthSource, + type StateOf, +} from "./context.js"; -export type RegisteredAuth = Register extends { - router: { options: { context: { auth: infer Client } } }; -} - ? Client - : never; +export type AuthHooksOptions = { + /** + * Finds the client instead of reading it from `AuthProvider`, for example + * from router context. Called as a hook, on every render of every hook. + */ + useClient?: () => Client; +}; -export type RegisteredAuthState = RegisteredAuth extends { - state: { get: () => infer State }; -} - ? State - : never; +export type AuthProviderProps = { + /** The client for this tree. On a server, create one per request. */ + client: Client; + children?: ReactNode; +}; -type AuthSource = { - state: { - get: () => unknown; - subscribe: (listener: (value: unknown) => void) => { - unsubscribe: () => void; - }; +export type AuthHooks = { + /** + * Provides `client` to the hooks below it and mounts it while rendered, + * starting expiry timers and cross-tab sync. Effects never run on a server, + * so server rendering starts no background work. + */ + AuthProvider: (props: AuthProviderProps) => ReactElement; + /** The client, for actions such as `signIn` and `signOut`. */ + useAuthClient: () => Client; + /** + * The current auth state, or the part `selector` picks. Re-renders only + * when the selection changes. + */ + useAuth: { + (): StateOf; + (selector: (state: StateOf) => T): T; }; }; -function assertAuthSource(value: unknown): asserts value is AuthSource { - if ( - typeof value !== "object" || - value === null || - !("state" in value) || - typeof value.state !== "object" || - value.state === null || - !("get" in value.state) || - typeof value.state.get !== "function" || - !("subscribe" in value.state) || - typeof value.state.subscribe !== "function" - ) { - throw new Error( - "Provide an auth client in Router context before using auth hooks", +/** + * Creates React hooks typed for one auth client. Each call has its own + * context, so an app with several clients calls it once per client. + * + * @example + * export const { AuthProvider, useAuth, useAuthClient } = + * createAuthHooks(); + * + * root.render(); + */ +export function createAuthHooks( + options: AuthHooksOptions = {}, +): AuthHooks { + const Context = createContext(null); + // Fixed at creation, so every render calls the same hooks. + const useResolvedClient = + options.useClient ?? + function useContextClient(): Client | null { + return useContext(Context); + }; + + function AuthProvider({ client, children }: AuthProviderProps) { + useEffect(() => client.mount(), [client]); + return createElement( + ClientContext.Provider, + { value: client }, + createElement(Context.Provider, { value: client }, children), ); } -} -function useContextAuth(): AuthSource { - const router = useRouter(); - const auth: unknown = router.options.context?.auth; - assertAuthSource(auth); - return auth; -} + function useAuthClient(): Client { + const client = useResolvedClient(); + assertAuthSource( + client, + options.useClient + ? "useClient did not return an auth client" + : "Render AuthProvider with an auth client before using auth hooks", + ); + return client; + } -export function useAuthClient(): RegisteredAuth { - return useContextAuth() as RegisteredAuth; -} + function useAuth(selector?: (state: StateOf) => T) { + return useAuthState( + useAuthClient(), + (state) => (selector ? selector(state) : state) as T, + ); + } -export function useAuth(): RegisteredAuthState; -export function useAuth(selector: (state: RegisteredAuthState) => T): T; -export function useAuth(selector?: (state: RegisteredAuthState) => T) { - const auth = useContextAuth(); - const subscribe = useCallback( - (onChange: () => void) => auth.state.subscribe(onChange).unsubscribe, - [auth], - ); - const getSnapshot = useCallback(() => auth.state.get(), [auth]); - return useSyncExternalStoreWithSelector( - subscribe, - getSnapshot, - getSnapshot, - (value) => { - const state = value as RegisteredAuthState; - return selector ? selector(state) : state; - }, - ); + return { + AuthProvider, + useAuthClient, + useAuth: useAuth as AuthHooks["useAuth"], + }; } diff --git a/src/react/index.ts b/src/react/index.ts index 930e3aa..99953a9 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,6 +1,8 @@ -export { connectAuth } from "./connect.js"; -export { useAuth, useAuthClient } from "./hooks.js"; -export { safeReturnTo } from "./return-to.js"; +export { createAuthHooks } from "./hooks.js"; -export type { SafeReturnToOptions } from "./return-to.js"; -export type { RegisteredAuth, RegisteredAuthState } from "./hooks.js"; +export type { AuthSource, StateOf } from "./context.js"; +export type { + AuthHooks, + AuthHooksOptions, + AuthProviderProps, +} from "./hooks.js"; diff --git a/src/react/connect.ts b/src/tanstack-router/connect.ts similarity index 100% rename from src/react/connect.ts rename to src/tanstack-router/connect.ts diff --git a/src/tanstack-router/guards.ts b/src/tanstack-router/guards.ts new file mode 100644 index 0000000..22fb02d --- /dev/null +++ b/src/tanstack-router/guards.ts @@ -0,0 +1,91 @@ +import { redirect } from "@tanstack/react-router"; + +import { AuthError } from "../core/errors.js"; +import { safeReturnTo } from "../core/return-to.js"; + +/** What the guards need from an auth client. */ +type GuardClient = { + getSession: () => Promise; + isCurrent: (session: { sessionId: string }) => boolean; +}; + +/** How often a guard reads the session again when it changes mid-check. */ +const MAX_ATTEMPTS = 3; + +export type RequireSessionOptions = { + /** The route location, from `beforeLoad`'s `location`. */ + location: { href: string }; + /** Where signed-out users go. A same-origin path, such as `"/login"`. */ + loginPath: string; +}; + +/** + * Guards a route in `beforeLoad`. Signed-out users are redirected to + * `loginPath` with `?redirectTo=` set to where they were going. Signed-in + * users get `{ session }` in route context, typed and never `null`; it holds + * no tokens. A failed refresh is rethrown as `REFRESH_FAILED` for the route's + * error component, which can offer `auth.retry()`. Render the guarded + * route's children through `SessionOutlet`. + * + * @example + * beforeLoad: ({ context, location }) => + * requireSession(context.auth, { location, loginPath: "/login" }), + */ +export async function requireSession( + auth: GuardClient, + options: RequireSessionOptions, +): Promise<{ session: S }> { + if (!options.loginPath.startsWith("/")) + throw new Error(`loginPath must start with "/": ${options.loginPath}`); + // A session that changes while it is read gets read again: the change + // re-runs guards anyway, so an error here would only flash. + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const session = await auth.getSession(); + if (!session) { + const login = new URL(options.loginPath, "https://auth.invalid"); + login.searchParams.set("redirectTo", options.location.href); + throw redirect({ href: login.pathname + login.search + login.hash }); + } + if (auth.isCurrent(session)) return { session }; + } + throw new AuthError( + "SESSION_CHANGED", + "The session kept changing; retry navigation", + ); +} + +export type RedirectIfSignedInOptions = { + /** Where to send a signed-in user, usually the `redirectTo` search param. */ + redirectTo: unknown; + /** The sign-in path, never returned to. Default: `"/login"`. */ + loginPath?: string | readonly string[]; +}; + +/** + * Guards the sign-in route in `beforeLoad`: a signed-in user is redirected to + * `redirectTo`, checked with `safeReturnTo`. If the session cannot be + * resolved, for example after a failed refresh, the sign-in page shows so the + * user can sign in again. + * + * @example + * beforeLoad: ({ context, search }) => + * redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), + */ +export async function redirectIfSignedIn( + auth: GuardClient<{ sessionId: string }>, + options: RedirectIfSignedInOptions, +): Promise { + let session: { sessionId: string } | null; + try { + session = await auth.getSession(); + } catch { + return; + } + if (session) { + throw redirect({ + href: safeReturnTo(options.redirectTo, { + loginPath: options.loginPath, + }), + }); + } +} diff --git a/src/tanstack-router/index.ts b/src/tanstack-router/index.ts new file mode 100644 index 0000000..122929a --- /dev/null +++ b/src/tanstack-router/index.ts @@ -0,0 +1,40 @@ +import { useRouter, type Register } from "@tanstack/react-router"; + +import { assertAuthSource } from "../react/context.js"; + +export { connectAuth } from "./connect.js"; +export { redirectIfSignedIn, requireSession } from "./guards.js"; +export { SessionOutlet } from "./outlet.js"; + +export type { + RedirectIfSignedInOptions, + RequireSessionOptions, +} from "./guards.js"; +export type { SessionOutletProps } from "./outlet.js"; + +/** The auth client type in your registered Router's context. */ +export type RegisteredAuth = Register extends { + router: { options: { context: { auth: infer Client } } }; +} + ? Client + : never; + +/** + * Reads the auth client from Router context. Pass it to `createAuthHooks` so + * the hooks need no `AuthProvider`; `connectAuth` mounts the client instead. + * Router context is per request in TanStack Start, so this stays safe when + * rendering on a server. + * + * @example + * export const { useAuth, useAuthClient } = createAuthHooks({ + * useClient: useRouterAuth, + * }); + */ +export function useRouterAuth(): RegisteredAuth { + const auth: unknown = useRouter().options.context?.auth; + assertAuthSource( + auth, + "Provide an auth client as `auth` in Router context before using auth hooks", + ); + return auth as RegisteredAuth; +} diff --git a/src/tanstack-router/outlet.ts b/src/tanstack-router/outlet.ts new file mode 100644 index 0000000..9db56d5 --- /dev/null +++ b/src/tanstack-router/outlet.ts @@ -0,0 +1,64 @@ +import { + createElement, + Fragment, + type ReactElement, + type ReactNode, +} from "react"; + +import { Outlet, useMatch, useRouter } from "@tanstack/react-router"; + +import { + assertAuthSource, + useAuthState, + useProvidedClient, + type AuthSource, +} from "../react/context.js"; + +export type SessionOutletProps = { + /** Shown while the session is being checked again. Default: nothing. */ + pending?: ReactNode; + /** + * The client to watch. Default: the nearest `AuthProvider`'s, then + * `auth` in Router context. + */ + client?: AuthSource; +}; + +/** + * Renders a route guarded by `requireSession`: its child routes while the + * session the guard accepted is still the signed-in one, and `pending` while + * the guard has yet to check a change, such as a sign-out or another account + * signing in. Child routes are keyed by session, so nothing from one account + * stays mounted for the next. Profile updates and token refreshes keep them + * mounted. + * + * @example + * component: () => Checking session…

} />, + */ +export function SessionOutlet({ + pending = null, + client, +}: SessionOutletProps): ReactElement { + const router = useRouter(); + const auth = useProvidedClient(client) ?? router.options.context?.auth; + assertAuthSource( + auth, + "Provide an auth client in Router context or AuthProvider before rendering SessionOutlet", + ); + const signedIn = useAuthState(auth, (state) => { + const { user, sessionId } = state as { + user: unknown; + sessionId: string | null; + }; + return user === null ? null : sessionId; + }); + // The nearest match is this route's, whose context holds what its guard returned. + const match: { context?: { session?: { sessionId?: unknown } } } = useMatch({ + strict: false, + }); + const guarded = match.context?.session?.sessionId; + // An element, not a bare node, so React 18 type definitions accept the component. + if (signedIn === null || signedIn !== guarded) + return createElement(Fragment, null, pending); + return createElement(Outlet, { key: signedIn }); +} diff --git a/tests/consumer.ts b/tests/consumer.ts index 51df6e7..f485057 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -15,7 +15,13 @@ import { type RefreshFn, type UserInput, } from "../dist/index.js"; -import { connectAuth, useAuth, useAuthClient } from "../dist/react/index.js"; +import { createAuthHooks } from "../dist/react/index.js"; +import { + connectAuth, + requireSession, + SessionOutlet, + useRouterAuth, +} from "../dist/tanstack-router/index.js"; const userSchema = z.object({ id: z.string(), email: z.string() }); @@ -39,6 +45,32 @@ declare module "@tanstack/react-router" { connectAuth(router); createAuthFetch(auth, "https://api.example.com"); +// Provider-free hooks typed by the registered Router. +const { useAuth, useAuthClient } = createAuthHooks({ + useClient: useRouterAuth, +}); +// Hooks for an explicit client type, with a provider. +const provided = createAuthHooks(); +provided.AuthProvider({ client: auth }); +const otherAuth = createAuth({ + name: "other", + user: z.object({ n: z.number() }), + storage: webStorage(), +}); +// @ts-expect-error The provider takes the client these hooks were made for. +provided.AuthProvider({ client: otherAuth }); +SessionOutlet({ pending: "Checking session…" }); + +async function guard() { + const { session } = await requireSession(auth, { + location: { href: "/private" }, + loginPath: "/login", + }); + const email: string = session.user.email; + return email; +} +void guard; + function Component() { const email: string | undefined = useAuth((state) => state.user?.email); const client = useAuthClient(); diff --git a/tests/helpers.ts b/tests/helpers.ts index faa59c2..fbc36c4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -14,7 +14,7 @@ import { type Tokens, type UserSource, } from "@monarcode/session-kit"; -import { connectAuth } from "@monarcode/session-kit/react"; +import { connectAuth } from "@monarcode/session-kit/tanstack-router"; import { z } from "zod"; export const userSchema = z.object({ diff --git a/tests/package.test.ts b/tests/package.test.ts index 4951efc..60e04ac 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -9,7 +9,14 @@ import { webStorage, } from "@monarcode/session-kit"; import { createAuthFetch } from "@monarcode/session-kit/http"; -import { connectAuth, useAuth } from "@monarcode/session-kit/react"; +import { createAuthHooks } from "@monarcode/session-kit/react"; +import { + connectAuth, + redirectIfSignedIn, + requireSession, + SessionOutlet, + useRouterAuth, +} from "@monarcode/session-kit/tanstack-router"; const packageRoot = new URL("../", import.meta.url); @@ -19,8 +26,12 @@ test("all public entry points import without browser globals", () => { fromAccessToken, memoryStorage, webStorage, + createAuthHooks, connectAuth, - useAuth, + requireSession, + redirectIfSignedIn, + SessionOutlet, + useRouterAuth, createAuthFetch, ]) { assert.equal(typeof fn, "function"); @@ -41,10 +52,10 @@ test("each entry exports readable JavaScript and declarations", async () => { test("React declarations retain consumer Router registration", async () => { const declarations = await readFile( - new URL("dist/react/hooks.d.ts", packageRoot), + new URL("dist/tanstack-router/index.d.ts", packageRoot), "utf8", ); assert.match(declarations, /Register extends/); - assert.match(declarations, /useAuthClient\(\): RegisteredAuth/); - assert.doesNotMatch(declarations, /useAuthClient\(\): any/); + assert.match(declarations, /useRouterAuth\(\): RegisteredAuth/); + assert.doesNotMatch(declarations, /useRouterAuth\(\): any/); }); diff --git a/tests/react.test.ts b/tests/react.test.ts index ec6bf0d..ad23dd3 100644 --- a/tests/react.test.ts +++ b/tests/react.test.ts @@ -1,19 +1,21 @@ import assert from "node:assert/strict"; import { test, beforeEach, afterEach } from "node:test"; +import * as React from "react"; import { - act, createElement as h, StrictMode, + useEffect, type FunctionComponent, + type ReactNode, } from "react"; import { createAuth, webStorage, type AuthClient, - type AuthState, } from "@monarcode/session-kit"; +import type { AuthHooks } from "@monarcode/session-kit/react"; import type { AnyRouter } from "@tanstack/react-router"; import { JSDOM } from "jsdom"; import type { Root } from "react-dom/client"; @@ -22,26 +24,19 @@ import { z } from "zod"; const userSchema = z.object({ id: z.string(), email: z.string() }); type TestUser = z.infer; type Client = AuthClient; -/** The hooks as typed for this test's user; it registers no Router types. */ -type Hooks = { - useAuth: { - (): AuthState; - (selector: (state: AuthState) => T): T; - }; - useAuthClient: () => Client; -}; +type ReactBinding = typeof import("@monarcode/session-kit/react"); +type RouterBinding = typeof import("@monarcode/session-kit/tanstack-router"); type RouterModule = typeof import("@tanstack/react-router"); +/** React's `act`, which React 18.0–18.2 exported only from test utilities. */ +let act: (callback: () => unknown) => Promise; let dom: JSDOM; let roots: Set; let originalGlobals: Map; let createRoot: typeof import("react-dom/client").createRoot; -let RouterContextProvider: RouterModule["RouterContextProvider"]; -let createRouter: RouterModule["createRouter"]; -let createRootRoute: RouterModule["createRootRoute"]; -let createMemoryHistory: RouterModule["createMemoryHistory"]; -let useAuth: Hooks["useAuth"]; -let useAuthClient: Hooks["useAuthClient"]; +let routerModule: RouterModule; +let binding: ReactBinding; +let routerBinding: RouterBinding; beforeEach(async () => { dom = new JSDOM("", { @@ -54,6 +49,8 @@ beforeEach(async () => { document: dom.window.document, location: dom.window.location, localStorage: dom.window.localStorage, + // Router scroll restoration calls the global; jsdom only puts it on window. + scrollTo: () => {}, IS_REACT_ACT_ENVIRONMENT: true, }; originalGlobals = new Map( @@ -69,15 +66,19 @@ beforeEach(async () => { value, }); } + const reactAct = (React as { act?: typeof act }).act; + act = + reactAct ?? + ( + (await import( + // @ts-ignore -- React 19's types no longer declare this module. + "react-dom/test-utils" + )) as { act: typeof act } + ).act; ({ createRoot } = await import("react-dom/client")); - ({ - RouterContextProvider, - createRouter, - createRootRoute, - createMemoryHistory, - } = await import("@tanstack/react-router")); - ({ useAuth, useAuthClient } = - (await import("@monarcode/session-kit/react")) as unknown as Hooks); + routerModule = await import("@tanstack/react-router"); + binding = await import("@monarcode/session-kit/react"); + routerBinding = await import("@monarcode/session-kit/tanstack-router"); }); afterEach(async () => { @@ -96,34 +97,16 @@ afterEach(async () => { const user = { id: "alice", email: "alice@example.com" }; const signIn = (auth: Client) => auth.signIn({ accessToken: "access-1", user }); +const newClient = (): Client => + createAuth({ name: "react-test", user: userSchema, storage: webStorage() }); -async function fixture() { - const auth = createAuth({ - name: "react-test", - user: userSchema, - storage: webStorage(), - }); - await auth.getSession(); - const router = createRouter({ - routeTree: createRootRoute({}), - context: { auth }, - history: createMemoryHistory({ initialEntries: ["/"] }), - }); - return { auth, router }; -} - -async function mount( - router: AnyRouter, - Component: FunctionComponent, - { strict = false } = {}, -) { +async function render(element: ReactNode, { strict = false } = {}) { const container = document.createElement("div"); document.body.append(container); const root = createRoot(container); roots.add(root); - const tree = h(RouterContextProvider, { router, children: h(Component) }); await act(async () => - root.render(strict ? h(StrictMode, null, tree) : tree), + root.render(strict ? h(StrictMode, null, element) : element), ); return { container, @@ -135,137 +118,329 @@ async function mount( }; } -test("useAuth renders sign-in, profile updates, and sign-out", async () => { - const { auth, router } = await fixture(); - function Profile() { - const state = useAuth(); +/** + * Hooks for `auth` and a way to render under them, either through + * `AuthProvider` or provider-free from Router context. + */ +type Setup = { + hooks: Pick, "useAuth" | "useAuthClient">; + wrap: (child: ReactNode) => ReactNode; +}; + +const setups: Record Setup> = { + provider(auth) { + const hooks = binding.createAuthHooks(); + return { + hooks, + wrap: (child) => h(hooks.AuthProvider, { client: auth }, child), + }; + }, + router(auth) { + const router = routerModule.createRouter({ + routeTree: routerModule.createRootRoute({}), + context: { auth }, + history: routerModule.createMemoryHistory({ initialEntries: ["/"] }), + }); + const hooks = binding.createAuthHooks({ + useClient: routerBinding.useRouterAuth as () => Client, + }); + return { + hooks, + wrap: (child) => + h(routerModule.RouterContextProvider, { + router: router as AnyRouter, + children: child, + }), + }; + }, +}; + +async function fixture(kind: string) { + const auth = newClient(); + await auth.getSession(); + const setup = setups[kind](auth); + const mount = (Component: FunctionComponent, options = {}) => + render(setup.wrap(h(Component)), options); + return { auth, ...setup.hooks, mount }; +} + +for (const kind of Object.keys(setups)) { + test(`useAuth renders sign-in, profile updates, and sign-out (${kind})`, async () => { + const { auth, useAuth, mount } = await fixture(kind); + function Profile() { + const state = useAuth(); + return h( + "output", + null, + `${state.status}:${state.user?.email ?? "none"}`, + ); + } + const { container } = await mount(Profile); + assert.equal(container.textContent, "unauthenticated:none"); + await act(async () => signIn(auth)); + assert.equal(container.textContent, "authenticated:alice@example.com"); + await act(async () => + auth.updateUser({ ...user, email: "updated@example.com" }), + ); + assert.equal(container.textContent, "authenticated:updated@example.com"); + await act(async () => auth.signOut()); + assert.equal(container.textContent, "unauthenticated:none"); + }); + + test(`useAuth selector skips unrelated updates and renders changed selections (${kind})`, async () => { + const { auth, useAuth, mount } = await fixture(kind); + await signIn(auth); + let renders = 0; + function Identity() { + const id = useAuth((state) => state.user?.id); + renders++; + return h("output", null, id ?? "none"); + } + const { container } = await mount(Identity); + const before = renders; + await act(async () => + auth.updateUser({ ...user, email: "updated@example.com" }), + ); + assert.equal(auth.state.get().user?.email, "updated@example.com"); + assert.equal(renders, before); + assert.equal(container.textContent, "alice"); + await act(async () => + auth.signIn({ + accessToken: "bob-token", + user: { id: "bob", email: "bob@example.com" }, + }), + ); + assert.ok(renders > before); + assert.equal(container.textContent, "bob"); + await act(async () => auth.signOut()); + assert.equal(container.textContent, "none"); + }); + + test(`useAuth accepts inline selectors that return new objects (${kind})`, async () => { + const { auth, useAuth, mount } = await fixture(kind); + function Status() { + const { status } = useAuth((state) => ({ status: state.status })); + return h("output", null, status); + } + const { container } = await mount(Status, { strict: true }); + assert.equal(container.textContent, "unauthenticated"); + await act(async () => signIn(auth)); + assert.equal(container.textContent, "authenticated"); + await act(async () => auth.signOut()); + assert.equal(container.textContent, "unauthenticated"); + }); + + test(`useAuthClient retains the client across reactive renders (${kind})`, async () => { + const { auth, useAuth, useAuthClient, mount } = await fixture(kind); + const clients: Client[] = []; + function Consumer() { + const state = useAuth(); + clients.push(useAuthClient()); + return h("output", null, state.user?.email ?? "none"); + } + const { container } = await mount(Consumer); + for (const action of [ + () => signIn(auth), + () => auth.updateUser({ ...user, email: "updated@example.com" }), + () => auth.signOut(), + ]) { + const before = clients.length; + await act(action); + assert.ok(clients.length > before); + } + assert.equal(container.textContent, "none"); + assert.ok(clients.every((client) => client === auth)); + }); + + for (const strict of [false, true]) { + test(`useAuth cleans up subscriptions${strict ? " under StrictMode" : ""} (${kind})`, async () => { + const { auth, useAuth, mount } = await fixture(kind); + const subscribe = auth.state.subscribe; + let subscriptions = 0, + unsubscriptions = 0, + notifications = 0; + auth.state.subscribe = (listener) => { + subscriptions++; + const subscription = subscribe((value) => { + notifications++; + listener(value); + }); + return { + unsubscribe() { + unsubscriptions++; + subscription.unsubscribe(); + }, + }; + }; + function Profile() { + const state = useAuth(); + return h("output", null, state.user?.email ?? "none"); + } + const mounted = await mount(Profile, { strict }); + assert.equal(subscriptions - unsubscriptions, 1); + if (strict) { + assert.ok(subscriptions >= 2); + assert.ok(unsubscriptions >= 1); + } + await act(async () => signIn(auth)); + assert.equal(mounted.container.textContent, "alice@example.com"); + assert.ok(notifications > 0); + await mounted.unmount(); + assert.equal(subscriptions, unsubscriptions); + const before = notifications; + await act(async () => auth.signOut()); + assert.equal(notifications, before); + const remounted = await mount(Profile, { strict }); + assert.equal(remounted.container.textContent, "none"); + assert.equal(subscriptions - unsubscriptions, 1); + await act(async () => signIn(auth)); + assert.equal(remounted.container.textContent, "alice@example.com"); + await remounted.unmount(); + assert.equal(subscriptions, unsubscriptions); + }); + } +} + +test("AuthProvider mounts its client while rendered", async () => { + const auth = newClient(); + const mount = auth.mount; + let mounted = 0; + auth.mount = () => { + mounted++; + const unmount = mount(); + return () => { + mounted--; + unmount(); + }; + }; + const { AuthProvider, useAuth } = binding.createAuthHooks(); + function Status() { return h( "output", null, - `${state.status}:${state.user?.email ?? "none"}`, + useAuth((state) => state.status), ); } - const { container } = await mount(router, Profile); - assert.equal(container.textContent, "unauthenticated:none"); - await act(async () => signIn(auth)); - assert.equal(container.textContent, "authenticated:alice@example.com"); - await act(async () => - auth.updateUser({ ...user, email: "updated@example.com" }), - ); - assert.equal(container.textContent, "authenticated:updated@example.com"); - await act(async () => auth.signOut()); - assert.equal(container.textContent, "unauthenticated:none"); + const view = await render(h(AuthProvider, { client: auth }, h(Status)), { + strict: true, + }); + assert.equal(mounted, 1); + // Mounting restores the saved session without a separate getSession(). + assert.equal(view.container.textContent, "unauthenticated"); + await view.unmount(); + assert.equal(mounted, 0); }); -test("useAuth selector skips unrelated updates and renders changed selections", async () => { - const { auth, router } = await fixture(); - await signIn(auth); - let renders = 0; - function Identity() { - const id = useAuth((state) => state.user?.id); - renders++; - return h("output", null, id ?? "none"); +test("hooks without a provider explain what is missing", async () => { + const { useAuth } = binding.createAuthHooks(); + function Status() { + return h( + "output", + null, + useAuth((state) => state.status), + ); } - const { container } = await mount(router, Identity); - const before = renders; - await act(async () => - auth.updateUser({ ...user, email: "updated@example.com" }), - ); - assert.equal(auth.state.get().user?.email, "updated@example.com"); - assert.equal(renders, before); - assert.equal(container.textContent, "alice"); - await act(async () => - auth.signIn({ - accessToken: "bob-token", - user: { id: "bob", email: "bob@example.com" }, - }), + const errors: unknown[] = []; + const onError = (event: ErrorEvent) => { + errors.push(event.error); + event.preventDefault(); + }; + dom.window.addEventListener("error", onError); + const consoleError = console.error; + console.error = () => {}; + try { + await render(h(Status)).catch((error) => errors.push(error)); + } finally { + console.error = consoleError; + dom.window.removeEventListener("error", onError); + } + assert.ok( + errors.some((error) => + /Render AuthProvider/.test((error as Error)?.message ?? ""), + ), ); - assert.ok(renders > before); - assert.equal(container.textContent, "bob"); - await act(async () => auth.signOut()); - assert.equal(container.textContent, "none"); }); -test("useAuth accepts inline selectors that return new objects", async () => { - const { auth, router } = await fixture(); - function Status() { - const { status } = useAuth((state) => ({ status: state.status })); - return h("output", null, status); +test("SessionOutlet keeps child routes through profile updates and swaps them on account changes", async () => { + const auth = newClient(); + await signIn(auth); + const { + createRouter, + createRootRouteWithContext, + createRoute, + createMemoryHistory, + RouterProvider, + } = routerModule; + const { requireSession, SessionOutlet, connectAuth } = routerBinding; + const { useAuth } = binding.createAuthHooks({ + useClient: routerBinding.useRouterAuth as () => Client, + }); + let homeMounts = 0; + function Home() { + const email = useAuth((state) => state.user?.email); + useEffect(() => { + homeMounts++; + }, []); + return h("output", null, `home:${email}`); } - const { container } = await mount(router, Status, { strict: true }); - assert.equal(container.textContent, "unauthenticated"); - await act(async () => signIn(auth)); - assert.equal(container.textContent, "authenticated"); - await act(async () => auth.signOut()); - assert.equal(container.textContent, "unauthenticated"); -}); + const root = createRootRouteWithContext<{ auth: Client }>()({}); + const login = createRoute({ + getParentRoute: () => root, + path: "/login", + component: () => h("output", null, "login"), + }); + const guarded = createRoute({ + getParentRoute: () => root, + id: "guarded", + beforeLoad: ({ context, location }) => + requireSession(context.auth, { location, loginPath: "/login" }), + component: () => h(SessionOutlet, { pending: "checking" }), + }); + const home = createRoute({ + getParentRoute: () => guarded, + path: "/", + component: Home, + }); + const router = createRouter({ + routeTree: root.addChildren([login, guarded.addChildren([home])]), + context: { auth }, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const disconnect = connectAuth(router); + try { + await act(() => router.load()); + const view = await render( + h(RouterProvider, { router: router as AnyRouter }), + ); + const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 0))); + await settle(); + assert.equal(view.container.textContent, "home:alice@example.com"); + assert.equal(homeMounts, 1); -test("useAuthClient retains the Router client across reactive renders", async () => { - const { auth, router } = await fixture(); - const clients: Client[] = []; - function Consumer() { - const state = useAuth(); - clients.push(useAuthClient()); - return h("output", null, state.user?.email ?? "none"); - } - const { container } = await mount(router, Consumer); - for (const action of [ - () => signIn(auth), - () => auth.updateUser({ ...user, email: "updated@example.com" }), - () => auth.signOut(), - ]) { - const before = clients.length; - await act(action); - assert.ok(clients.length > before); + await act(() => auth.updateUser({ ...user, email: "new@example.com" })); + await settle(); + assert.equal(view.container.textContent, "home:new@example.com"); + assert.equal(homeMounts, 1); + + await act(() => + auth.signIn({ + accessToken: "bob-token", + user: { id: "bob", email: "bob@example.com" }, + }), + ); + await settle(); + assert.equal(view.container.textContent, "home:bob@example.com"); + assert.equal(homeMounts, 2); + + await act(() => auth.signOut()); + await settle(); + assert.equal(view.container.textContent, "login"); + assert.equal( + (router.state.location.search as { redirectTo?: unknown }).redirectTo, + "/", + ); + } finally { + disconnect(); } - assert.equal(container.textContent, "none"); - assert.ok(clients.every((client) => client === auth)); }); - -for (const strict of [false, true]) { - test(`useAuth cleans up subscriptions${strict ? " under StrictMode" : ""}`, async () => { - const { auth, router } = await fixture(); - const subscribe = auth.state.subscribe; - let subscriptions = 0, - unsubscriptions = 0, - notifications = 0; - auth.state.subscribe = (listener) => { - subscriptions++; - const subscription = subscribe((value) => { - notifications++; - listener(value); - }); - return { - unsubscribe() { - unsubscriptions++; - subscription.unsubscribe(); - }, - }; - }; - function Profile() { - const state = useAuth(); - return h("output", null, state.user?.email ?? "none"); - } - const mounted = await mount(router, Profile, { strict }); - assert.equal(subscriptions - unsubscriptions, 1); - if (strict) { - assert.ok(subscriptions >= 2); - assert.ok(unsubscriptions >= 1); - } - await act(async () => signIn(auth)); - assert.equal(mounted.container.textContent, "alice@example.com"); - assert.ok(notifications > 0); - await mounted.unmount(); - assert.equal(subscriptions, unsubscriptions); - const before = notifications; - await act(async () => auth.signOut()); - assert.equal(notifications, before); - const remounted = await mount(router, Profile, { strict }); - assert.equal(remounted.container.textContent, "none"); - assert.equal(subscriptions - unsubscriptions, 1); - await act(async () => signIn(auth)); - assert.equal(remounted.container.textContent, "alice@example.com"); - await remounted.unmount(); - assert.equal(subscriptions, unsubscriptions); - }); -} diff --git a/tests/return-to.test.ts b/tests/return-to.test.ts index 8d9db01..f6d14b0 100644 --- a/tests/return-to.test.ts +++ b/tests/return-to.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { safeReturnTo } from "@monarcode/session-kit/react"; +import { safeReturnTo } from "@monarcode/session-kit"; import { useBrowserMocks } from "./helpers.ts"; diff --git a/tests/router.test.ts b/tests/router.test.ts index 0578993..51cdba9 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import type { AuthError } from "@monarcode/session-kit"; -import { connectAuth } from "@monarcode/session-kit/react"; +import { + connectAuth, + redirectIfSignedIn, + requireSession, +} from "@monarcode/session-kit/tanstack-router"; import { user, @@ -246,3 +250,108 @@ test("a failed refresh reruns real Router guards once without retrying until ret assert.equal(auth.state.get().status, "authenticated"); assert.equal(privateMatch(router)?.status, "success"); }); + +/** A real Router whose `/private` uses `requireSession` and `/login` `redirectIfSignedIn`. */ +const guardedRouter = async (auth: TestClient, path: string) => { + setGlobal("self", globalThis); + setGlobal("scrollTo", () => {}); + setGlobal("window", { + location: new URL(`https://example.com${path}`), + addEventListener() {}, + removeEventListener() {}, + }); + const { + createRouter, + createRootRouteWithContext, + createRoute, + createMemoryHistory, + } = await import("@tanstack/react-router"); + const root = createRootRouteWithContext<{ auth: TestClient }>()({}); + const login = createRoute({ + getParentRoute: () => root, + path: "/login", + validateSearch: (search: Record) => ({ + redirectTo: search.redirectTo, + }), + beforeLoad: ({ context, search }) => + redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), + }); + const privateRoute = createRoute({ + getParentRoute: () => root, + path: "/private", + beforeLoad: ({ context, location }) => + requireSession(context.auth, { location, loginPath: "/login" }), + }); + const router = createRouter({ + routeTree: root.addChildren([login, privateRoute]), + context: { auth }, + history: createMemoryHistory({ initialEntries: [path] }), + isServer: false, + }); + router.startTransition = (async (fn: () => void) => { + fn(); + return true; + }) as unknown as typeof router.startTransition; + await router.load(); + return router; +}; + +test("requireSession sends signed-out users to sign in, then back", async () => { + const auth = client(); + await auth.getSession(); + const router = await guardedRouter(auth, "/private?tab=2"); + assert.equal(router.state.location.pathname, "/login"); + const { redirectTo } = router.state.location.search as { + redirectTo?: unknown; + }; + assert.equal(redirectTo, "/private?tab=2"); + await auth.signIn(input()); + await router.navigate({ to: "/login", search: { redirectTo } }); + assert.equal(router.state.location.href, "/private?tab=2"); +}); + +test("requireSession puts the session, without tokens, in route context", async () => { + const auth = client(); + await auth.signIn(input()); + const router = await guardedRouter(auth, "/private"); + const match = router.state.matches.find( + (candidate) => candidate.routeId === "/private", + ); + const session = ( + match?.context as { session?: Record } | undefined + )?.session; + assert.deepEqual(session, await auth.getSession()); + assert.equal("accessToken" in (session ?? {}), false); +}); + +test("redirectIfSignedIn never returns to sign-in or leaves the site", async () => { + const auth = client(); + await auth.signIn(input()); + for (const redirectTo of ["https://evil.example/", "/login"]) { + const router = await guardedRouter( + auth, + `/login?redirectTo=${encodeURIComponent(redirectTo)}`, + ); + assert.equal(router.state.location.pathname, "/"); + } +}); + +test("requireSession reads a session that changes mid-check again", async () => { + const session = { sessionId: "a", user }; + let checks = 0; + const settling = { + getSession: async () => session, + isCurrent: () => ++checks > 1, + }; + const options = { location: { href: "/private" }, loginPath: "/login" }; + assert.deepEqual(await requireSession(settling, options), { session }); + const churning = { getSession: async () => session, isCurrent: () => false }; + await assert.rejects( + requireSession(churning, options), + code("SESSION_CHANGED"), + ); + await assert.rejects( + requireSession(churning, { ...options, loginPath: "login" }), + /loginPath must start with/, + ); +}); From 8da15c742c96025116750a31c70066cfbc739cb7 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 11:11:27 +0100 Subject: [PATCH 04/12] feat: add React Router entry point and restore sessions synchronously on mount - Add `/react-router` with `SessionOutlet`, `requireSession`, `redirectIfSignedIn` and `useAuthRevalidation` for React Router 7 and 8, with `react-router` as an optional peer, and share guard logic with the TanStack Router entry point - Restore the saved session at mount when storage and the user source answer synchronously, and mount `AuthProvider` in a layout effect so `initializing` never paints --- .github/workflows/ci.yml | 12 +- CHANGELOG.md | 13 ++ README.md | 156 ++++++++++++-- package.json | 14 +- pnpm-lock.yaml | 26 +++ src/core/client.ts | 24 +++ src/core/guard.ts | 39 ++++ src/core/persistence.ts | 84 +++++--- src/core/return-to.ts | 12 ++ src/core/sources.ts | 33 ++- src/core/user.ts | 31 +++ src/react-router/guards.ts | 73 +++++++ src/react-router/index.ts | 9 + src/react-router/outlet.ts | 100 +++++++++ src/react-router/revalidation.ts | 54 +++++ src/react/hooks.ts | 15 +- src/tanstack-router/guards.ts | 49 ++--- tests/consumer.ts | 18 ++ tests/dom.ts | 116 +++++++++++ tests/package.test.ts | 8 + tests/react-router.test.ts | 337 +++++++++++++++++++++++++++++++ tests/react.test.ts | 110 ++-------- tests/session.test.ts | 42 +++- tests/users.test.ts | 15 +- 24 files changed, 1204 insertions(+), 186 deletions(-) create mode 100644 src/core/guard.ts create mode 100644 src/react-router/guards.ts create mode 100644 src/react-router/index.ts create mode 100644 src/react-router/outlet.ts create mode 100644 src/react-router/revalidation.ts create mode 100644 tests/dom.ts create mode 100644 tests/react-router.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d08dff4..f6ea1dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,12 +68,14 @@ jobs: # Keep these at the lower bounds of peerDependencies in package.json. # Type packages are not peers: these are the first releases whose # react/jsx-runtime and react-dom/client types resolve under NodeNext. + # The override stops @types/react-dom pulling in newer React types. - name: Install oldest supported peers - run: >- - pnpm add --save-dev --save-exact - react@18.0.0 react-dom@18.0.0 - @types/react@18.0.2 @types/react-dom@18.0.3 - @tanstack/react-router@1.127.0 + run: | + npm pkg set 'pnpm.overrides.@types/react=18.0.2' + pnpm add --save-dev --save-exact \ + react@18.0.0 react-dom@18.0.0 \ + @types/react@18.0.2 @types/react-dom@18.0.3 \ + @tanstack/react-router@1.127.0 react-router@7.0.0 - name: Build and test run: pnpm run build && pnpm test && pnpm run test:types diff --git a/CHANGELOG.md b/CHANGELOG.md index 90a3186..5e8ed9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,19 @@ page; `useRouterAuth` reads the client from Router context. - React 18 is supported (`^18.0.0 || ^19.0.0`). CI tests React 18.0.0 as the oldest version. +- A new `/react-router` entry point supports React Router 7 and 8 in + declarative mode, data mode, and framework mode with `ssr: false`. + `SessionOutlet` guards routes in declarative mode, showing `pending` while + restoring, sending signed-out users to `loginPath` with `redirectTo`, and + offering a retry through `unavailable` after a failed refresh. In data mode, + `requireSession` and `redirectIfSignedIn` guard loaders, and + `useAuthRevalidation` re-runs loaders when auth changes. React Router is an + optional peer dependency. +- Mounting restores the saved session at once when storage and the user + source both answer synchronously, as with `webStorage()` and a synchronous + schema, so `initializing` is never rendered. `AuthProvider` mounts in a + layout effect, before the browser paints. Asynchronous storage, schemas, or + token decoding restore as before. ### Changed diff --git a/README.md b/README.md index 979dca5..529c903 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @monarcode/session-kit -Schema-driven browser authentication for React and TanStack Router. +Schema-driven browser authentication for React, TanStack Router, and React Router. **Version: 0.1.0-alpha.3. Early development; not ready for production.** @@ -20,9 +20,10 @@ pnpm add @monarcode/session-kit Pin `@monarcode/session-kit@0.1.0-alpha.3` to use this exact version. Alpha releases may change the public API. See [release notes](./CHANGELOG.md). -Both peer dependencies are optional, so install only what you use: React -`^18.0.0 || ^19.0.0` for `/react` and `/tanstack-router`, and TanStack React -Router `^1.127.0` for `/tanstack-router`. The package itself never imports +All peer dependencies are optional, so install only what you use: React +`^18.0.0 || ^19.0.0` for the React entry points, TanStack React Router +`^1.127.0` for `/tanstack-router`, and React Router `^7.0.0 || ^8.0.0` for +`/react-router`. The package itself never imports React DOM. CI checks both the oldest and the newest versions in those ranges. This example uses Zod for its Standard Schema implementation: @@ -43,8 +44,8 @@ have not been verified. Auth needs `AbortController`; it uses Replace the dependency with `@monarcode/session-kit`, and change import prefixes from `@monarcode/tanstack-auth` to `@monarcode/session-kit`, then follow the breaking changes in the -[release notes](./CHANGELOG.md). TanStack Router is the supported router in -this alpha; additional adapters are planned for later development. +[release notes](./CHANGELOG.md). TanStack Router and React Router are +supported; server rendering and Expo are planned for later development. ## Quick start @@ -526,6 +527,134 @@ export function dispose() { } ``` +## React Router + +`@monarcode/session-kit/react-router` supports React Router 7 and 8 in +declarative mode, data mode, and framework mode with `ssr: false`. Create +hooks for your client and render `AuthProvider` around the router; it mounts +the client, so expiry timers and tab sync run while the app is rendered. + +`src/react-router/hooks.ts` + + + +```ts +import { createAuthHooks } from "@monarcode/session-kit/react"; +import type { AppAuth } from "../auth.js"; + +export const { AuthProvider, useAuth, useAuthClient } = + createAuthHooks(); +``` + +In declarative mode, `SessionOutlet` is the guard. It renders child routes +while someone is signed in, `pending` while the saved session is restored, and +sends signed-out users to `loginPath` with `?redirectTo=` set to where they +were going. Child routes are keyed by session, so nothing from one account +stays mounted for the next. + +`src/react-router/declarative.tsx` + + + +```tsx +import { BrowserRouter, Route, Routes } from "react-router"; +import { SessionOutlet } from "@monarcode/session-kit/react-router"; +import { auth } from "../auth.js"; +import { AuthProvider, useAuth } from "./hooks.js"; + +function Home() { + const email = useAuth((state) => state.user?.email); + return

Welcome, {email}

; +} + +export function App() { + return ( + + + + Sign in here

} /> + Checking session…

} + unavailable={({ retry }) => ( + + )} + /> + } + > + } /> + +
+
+
+ ); +} +``` + +In data mode, guard routes in their loaders with `requireSession`, which +redirects signed-out users the same way and returns `{ session }`. Call +`useAuthRevalidation()` once in the root layout so loaders re-run after a +sign-in, sign-out, account switch, or profile update. `redirectIfSignedIn` +keeps signed-in users off the sign-in page. A failed refresh reaches the +route's error element, which can call `auth.retry()`. Framework mode with +`ssr: false` uses the same functions in `clientLoader`. + +`src/react-router/data.tsx` + + + +```tsx +import { createBrowserRouter, Outlet, RouterProvider } from "react-router"; +import { + redirectIfSignedIn, + requireSession, + SessionOutlet, + useAuthRevalidation, +} from "@monarcode/session-kit/react-router"; +import { auth } from "../auth.js"; +import { AuthProvider } from "./hooks.js"; + +function Root() { + useAuthRevalidation(); + return ; +} + +const router = createBrowserRouter([ + { + element: , + children: [ + { + path: "/login", + loader: async ({ request }) => { + const redirectTo = new URL(request.url).searchParams.get( + "redirectTo", + ); + await redirectIfSignedIn(auth, { redirectTo }); + return null; + }, + element:

Sign in here

, + }, + { + loader: ({ request }) => + requireSession(auth, { request, loginPath: "/login" }), + element: , + children: [{ index: true, element:

Home

}], + }, + ], + }, +]); + +export function App() { + return ( + + + + ); +} +``` + ## Profile updates and authenticated requests `updateUser` replaces the complete profile. For an asynchronous update, use its @@ -615,13 +744,13 @@ There is one proactive attempt per installed token, with no recurring retry loop An attempt cancelled by unmounting or by a new sign-in does not count. Validation and refresh work have a 15-second deadline. -| State status | Meaning | -| ----------------- | ----------------------------------------------------------------------- | -| `initializing` | Restoration has not completed. | -| `authenticated` | Access is locally usable; `user` is available. | -| `refreshing` | A refresh is replacing expired or rejected access; `user` is available. | -| `unauthenticated` | No session; sign-in is needed. | -| `unavailable` | Restoration failed or current credentials cannot supply usable access. | +| State status | Meaning | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `initializing` | Restoration has not completed. With synchronous storage and schemas, mounting restores at once, so this state is never rendered. | +| `authenticated` | Access is locally usable; `user` is available. | +| `refreshing` | A refresh is replacing expired or rejected access; `user` is available. | +| `unauthenticated` | No session; sign-in is needed. | +| `unavailable` | Restoration failed or current credentials cannot supply usable access. | `state.error` is an `AuthError` or `null`; user data is hidden outside the `authenticated` and `refreshing` states. Error codes are `USER_VALIDATION_FAILED`, `INVALID_SESSION`, @@ -698,6 +827,7 @@ the remaining data after storage becomes available. | `@monarcode/session-kit` | `createAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `safeReturnTo`, `AuthError`, public auth types | | `@monarcode/session-kit/react` | `createAuthHooks` and hook types | | `@monarcode/session-kit/tanstack-router` | `connectAuth`, `requireSession`, `redirectIfSignedIn`, `SessionOutlet`, `useRouterAuth`, `RegisteredAuth` | +| `@monarcode/session-kit/react-router` | `SessionOutlet`, `requireSession`, `redirectIfSignedIn`, `useAuthRevalidation` | | `@monarcode/session-kit/http` | `createAuthFetch` | Generated declarations retain schema inference and consumer Router registration. diff --git a/package.json b/package.json index aa0eefe..5e0ada6 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "@monarcode/session-kit", "version": "0.1.0-alpha.3", - "description": "Schema-driven browser sessions for React, with TanStack Router support", + "description": "Schema-driven browser sessions for React, with TanStack Router and React Router support", "keywords": [ "auth", "react", + "react-router", "router", "standard-schema", "tanstack" @@ -41,6 +42,10 @@ "types": "./dist/tanstack-router/index.d.ts", "import": "./dist/tanstack-router/index.js" }, + "./react-router": { + "types": "./dist/react-router/index.d.ts", + "import": "./dist/react-router/index.js" + }, "./http": { "types": "./dist/http/index.d.ts", "import": "./dist/http/index.js" @@ -87,12 +92,14 @@ "oxlint": "1.86.0", "react": "19.3.0", "react-dom": "19.3.0", + "react-router": "8.4.0", "typescript": "6.0.3", "zod": "4.4.3" }, "peerDependencies": { "@tanstack/react-router": "^1.127.0", - "react": "^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-router": "^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@tanstack/react-router": { @@ -100,6 +107,9 @@ }, "react": { "optional": true + }, + "react-router": { + "optional": true } }, "devEngines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5fd154..c8426e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: react-dom: specifier: 19.3.0 version: 19.3.0(react@19.3.0) + react-router: + specifier: 8.4.0 + version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) typescript: specifier: 6.0.3 version: 6.0.3 @@ -322,6 +325,9 @@ packages: cpu: [x64] os: [win32] + '@remix-run/route-pattern@0.22.1': + resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -491,6 +497,16 @@ packages: peerDependencies: react: ^19.3.0 + react-router@8.4.0: + resolution: {integrity: sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -740,6 +756,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.86.0': optional: true + '@remix-run/route-pattern@0.22.1': {} + '@standard-schema/spec@1.1.0': {} '@tanstack/history@1.162.4': {} @@ -935,6 +953,14 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@remix-run/route-pattern': 0.22.1 + cookie-es: 3.1.1 + react: 19.3.0 + optionalDependencies: + react-dom: 19.3.0(react@19.3.0) + react@19.3.0: {} rrweb-cssom@0.8.0: {} diff --git a/src/core/client.ts b/src/core/client.ts index 072361c..356b55a 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -852,9 +852,33 @@ export function createAuth( return refreshSession(session, false); } + /** + * Restores at once when storage and validation both answer synchronously, + * so state is ready before the first render. Anything asynchronous, or + * any failure, is left to `initialize()`, which also reports errors. + */ + function restoreNow() { + if (initialized || initialization) return; + const stored = persistence.readNow(); + if (stored === undefined) return; + let user: U | undefined; + if (stored) { + user = users.restoreNow(stored); + if (user === undefined) return; + } + session = stored + ? { ...tokensOf(stored), id: stored.id, user: user! } + : null; + revision++; + initialized = true; + // An expired session publishes when its refresh starts, as in initialize(). + if (!session || valid()) publish(); + } + function mount() { if (mounts++ === 0) unwatch = persistence.watch(() => inBackground(sync())); + restoreNow(); inBackground(resolveSession()); schedule(); let active = true; diff --git a/src/core/guard.ts b/src/core/guard.ts new file mode 100644 index 0000000..98b9269 --- /dev/null +++ b/src/core/guard.ts @@ -0,0 +1,39 @@ +import { AuthError } from "./errors.js"; + +/** What route guards need from an auth client. */ +export type GuardClient = { + getSession: () => Promise; + isCurrent: (session: { sessionId: string }) => boolean; +}; + +/** How often a guard reads the session again when it changes mid-check. */ +const MAX_ATTEMPTS = 3; + +/** + * The signed-in session for a guard, or `null`. A session that changes while + * it is read is read again: the change re-runs guards anyway, so an error + * would only flash. + */ +export async function guardSession( + auth: GuardClient, +): Promise { + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const session = await auth.getSession(); + if (!session || auth.isCurrent(session)) return session; + } + throw new AuthError( + "SESSION_CHANGED", + "The session kept changing; retry navigation", + ); +} + +/** The session, or `null` when it cannot be resolved, for sign-in pages. */ +export async function sessionOrNull( + auth: GuardClient<{ sessionId: string }>, +): Promise<{ sessionId: string } | null> { + try { + return await auth.getSession(); + } catch { + return null; + } +} diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 43588d9..9072014 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -41,11 +41,13 @@ export function createPersistence( // out of order still ends up holding the latest write, and a read sees every // write requested before it. let queue: Promise = Promise.resolve(); + let pending = 0; function enqueue(operation: () => Promise): Promise { + pending++; const result = queue.then(operation).catch((cause: unknown) => { throw storageError(cause); }); - queue = result.catch(() => {}); + queue = result.catch(() => {}).finally(() => pending--); return result; } @@ -55,36 +57,58 @@ export function createPersistence( }); } + /** Parses a saved entry, rejecting other formats and expired entries. */ + function parse(text: string | null): StoredSession | null { + if (!text) return null; + try { + const saved: unknown = JSON.parse(text); + if ( + !isRecord(saved) || + saved.v !== STORAGE_VERSION || + typeof saved.id !== "string" || + !saved.id || + typeof saved.persistUntil !== "number" || + !Number.isFinite(saved.persistUntil) || + saved.persistUntil <= Date.now() + ) { + throw new Error("Missing, expired, or malformed auth data"); + } + return { + ...validateTokens(saved), + id: saved.id, + user: saved.user, + }; + } catch (cause) { + throw new AuthError( + "INVALID_SESSION", + "Saved session cannot be restored", + { cause }, + ); + } + } + function read(): Promise { - return enqueue(async () => { - const text = await storage.get(key); - if (!text) return null; - try { - const saved: unknown = JSON.parse(text); - if ( - !isRecord(saved) || - saved.v !== STORAGE_VERSION || - typeof saved.id !== "string" || - !saved.id || - typeof saved.persistUntil !== "number" || - !Number.isFinite(saved.persistUntil) || - saved.persistUntil <= Date.now() - ) { - throw new Error("Missing, expired, or malformed auth data"); - } - return { - ...validateTokens(saved), - id: saved.id, - user: saved.user, - }; - } catch (cause) { - throw new AuthError( - "INVALID_SESSION", - "Saved session cannot be restored", - { cause }, - ); + return enqueue(async () => parse(await storage.get(key))); + } + + /** + * Reads the saved session synchronously, when storage can answer at once + * and no operation is queued. Returns `undefined` otherwise, meaning "use + * `read()`", including for malformed entries, which `read()` reports. + */ + function readNow(): StoredSession | null | undefined { + if (pending) return undefined; + try { + const text = storage.get(key); + if (typeof text === "object" && text !== null) { + // Asynchronous storage: drop this read; `read()` will ask again. + void Promise.resolve(text).catch(() => {}); + return undefined; } - }); + return parse(text); + } catch { + return undefined; + } } function write(session: StoredSession): Promise { @@ -120,5 +144,5 @@ export function createPersistence( return storage.subscribe?.(key, listener) ?? (() => {}); } - return { read, write, clear, exclusive, watch }; + return { read, readNow, write, clear, exclusive, watch }; } diff --git a/src/core/return-to.ts b/src/core/return-to.ts index 2431805..3dd0e5f 100644 --- a/src/core/return-to.ts +++ b/src/core/return-to.ts @@ -44,3 +44,15 @@ export function safeReturnTo( return "/"; } } + +/** + * The sign-in URL for `loginPath`, with `?redirectTo=` set to `returnTo` so + * the user comes back after signing in. + */ +export function loginHref(loginPath: string, returnTo: string): string { + if (!loginPath.startsWith("/")) + throw new Error(`loginPath must start with "/": ${loginPath}`); + const login = new URL(loginPath, "https://auth.invalid"); + login.searchParams.set("redirectTo", returnTo); + return login.pathname + login.search + login.hash; +} diff --git a/src/core/sources.ts b/src/core/sources.ts index a3716bd..29f85de 100644 --- a/src/core/sources.ts +++ b/src/core/sources.ts @@ -3,7 +3,14 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { AuthError } from "./errors.js"; import type { StoredSession } from "./persistence.js"; import { decodeJwtPayload, type SessionTokens } from "./tokens.js"; -import { sameUser, toUser, validateNewUser, validateUser } from "./user.js"; +import { + isThenable, + sameUser, + toUser, + validateNewUser, + validateUser, + validateUserNow, +} from "./user.js"; /** * A user read from the access token's claims, made by `fromAccessToken`. @@ -81,6 +88,11 @@ export type UserResolver = { receive: (accessToken: string, input: unknown) => Promise; /** The user for a saved session. */ restore: (saved: StoredSession) => Promise; + /** + * The user for a saved session, when it can be found synchronously; + * otherwise `undefined`, and `restore` decides. + */ + restoreNow: (saved: StoredSession) => U | undefined; /** Whether `saved` holds the same user as `current`, without validating it. */ unchanged: ( saved: StoredSession, @@ -98,6 +110,7 @@ export function resolveUserSource(source: UserSource): UserResolver { saved: true, receive: (_, input) => validateNewUser(source, input), restore: (saved) => validateUser(source, saved.user), + restoreNow: (saved) => validateUserNow(source, saved.user), unchanged: (saved, current) => sameUser(saved.user, current.user), }; } @@ -128,10 +141,28 @@ export function resolveUserSource(source: UserSource): UserResolver { ); } }; + const deriveNow = (accessToken: string) => { + try { + const decoded = source.decode(accessToken); + if (isThenable(decoded)) { + // Asynchronous decode: drop this attempt; derive runs it again. + void Promise.resolve(decoded).catch(() => {}); + return undefined; + } + const claims = validateUserNow( + source.claims as StandardSchemaV1, + decoded, + ); + return claims === undefined ? undefined : toUser(source.map(claims)); + } catch { + return undefined; + } + }; return { saved: false, receive: (accessToken) => derive(accessToken), restore: (saved) => derive(saved.accessToken), + restoreNow: (saved) => deriveNow(saved.accessToken), unchanged: (saved, current) => saved.accessToken === current.accessToken, }; } diff --git a/src/core/user.ts b/src/core/user.ts index fbf18e9..c793821 100644 --- a/src/core/user.ts +++ b/src/core/user.ts @@ -94,6 +94,37 @@ export async function validateUser( } } +/** Whether `value` is a promise or another thenable. */ +export function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} + +/** + * Validates `input` synchronously when the schema can, returning `undefined` + * when it is asynchronous or rejects the input. `validateUser` then decides, + * and reports any failure. + */ +export function validateUserNow( + schema: S, + input: unknown, +): StandardSchemaV1.InferOutput | undefined { + try { + const result = schema["~standard"].validate(input); + if (isThenable(result)) { + // Asynchronous schema: drop this attempt; validateUser runs it again. + void Promise.resolve(result).catch(() => {}); + return undefined; + } + return result.issues ? undefined : toUser(result.value); + } catch { + return undefined; + } +} + /** * Validates a user that is about to be saved. Restoring validates the saved * output again, so the schema must accept its own output and return it diff --git a/src/react-router/guards.ts b/src/react-router/guards.ts new file mode 100644 index 0000000..677d3cb --- /dev/null +++ b/src/react-router/guards.ts @@ -0,0 +1,73 @@ +import { redirect } from "react-router"; + +import { + guardSession, + sessionOrNull, + type GuardClient, +} from "../core/guard.js"; +import { loginHref, safeReturnTo } from "../core/return-to.js"; + +export type RequireSessionOptions = { + /** The loader's `request`, whose URL signed-out users come back to. */ + request: Request; + /** Where signed-out users go. A same-origin path, such as `"/login"`. */ + loginPath: string; +}; + +/** + * Guards a route in its loader, in data or framework mode. Signed-out users + * are redirected to `loginPath` with `?redirectTo=` set to the requested URL. + * Signed-in users get `{ session }`, which holds no tokens. A failed refresh + * is rethrown as `REFRESH_FAILED` for the route's error element, which can + * offer `auth.retry()`. + * + * @example + * loader: ({ request }) => + * requireSession(auth, { request, loginPath: "/login" }), + */ +export async function requireSession( + auth: GuardClient, + options: RequireSessionOptions, +): Promise<{ session: S }> { + // Fails fast on a bad path, before any session is read. + loginHref(options.loginPath, "/"); + const session = await guardSession(auth); + if (!session) { + const url = new URL(options.request.url); + throw redirect( + loginHref(options.loginPath, url.pathname + url.search + url.hash), + ); + } + return { session }; +} + +export type RedirectIfSignedInOptions = { + /** Where to send a signed-in user, usually the `redirectTo` search param. */ + redirectTo: unknown; + /** The sign-in path, never returned to. Default: `"/login"`. */ + loginPath?: string | readonly string[]; +}; + +/** + * Guards the sign-in route in its loader: a signed-in user is redirected to + * `redirectTo`, checked with `safeReturnTo`. If the session cannot be + * resolved, for example after a failed refresh, the sign-in page shows so the + * user can sign in again. + * + * @example + * loader: async ({ request }) => { + * const redirectTo = new URL(request.url).searchParams.get("redirectTo"); + * await redirectIfSignedIn(auth, { redirectTo }); + * return null; + * }, + */ +export async function redirectIfSignedIn( + auth: GuardClient<{ sessionId: string }>, + options: RedirectIfSignedInOptions, +): Promise { + if (await sessionOrNull(auth)) { + throw redirect( + safeReturnTo(options.redirectTo, { loginPath: options.loginPath }), + ); + } +} diff --git a/src/react-router/index.ts b/src/react-router/index.ts new file mode 100644 index 0000000..ec56932 --- /dev/null +++ b/src/react-router/index.ts @@ -0,0 +1,9 @@ +export { redirectIfSignedIn, requireSession } from "./guards.js"; +export { SessionOutlet } from "./outlet.js"; +export { useAuthRevalidation } from "./revalidation.js"; + +export type { + RedirectIfSignedInOptions, + RequireSessionOptions, +} from "./guards.js"; +export type { SessionOutletProps } from "./outlet.js"; diff --git a/src/react-router/outlet.ts b/src/react-router/outlet.ts new file mode 100644 index 0000000..898e858 --- /dev/null +++ b/src/react-router/outlet.ts @@ -0,0 +1,100 @@ +import { + createElement, + Fragment, + type ReactElement, + type ReactNode, +} from "react"; +import { Navigate, Outlet, useLocation } from "react-router"; + +import { AuthError } from "../core/errors.js"; +import { loginHref } from "../core/return-to.js"; +import { + assertAuthSource, + useAuthState, + useProvidedClient, + type AuthSource, +} from "../react/context.js"; + +type OutletClient = AuthSource & { retry: () => Promise }; + +type OutletState = { + status: string; + sessionId: string | null; + error: AuthError | null; +}; + +export type SessionOutletProps = { + /** Where signed-out users go. A same-origin path, such as `"/login"`. */ + loginPath: string; + /** Shown while the saved session is being restored. Default: nothing. */ + pending?: ReactNode; + /** + * Shown when access is unavailable, such as after a failed refresh. `retry` + * tries again; a repeated failure stays in `state.error`. Without it, the + * error is thrown to the nearest error boundary or error element. + */ + unavailable?: (props: { + error: AuthError | null; + retry: () => void; + }) => ReactNode; + /** The client to watch. Default: the nearest `AuthProvider`'s. */ + client?: OutletClient; +}; + +/** + * Renders child routes only while someone is signed in, in any mode. In + * declarative mode it is the guard: signed-out users are sent to `loginPath` + * with `?redirectTo=` set to the current location. In data mode, pair it + * with `requireSession` in loaders. Child routes are keyed by session, so + * nothing from one account stays mounted for the next. Profile updates and + * token refreshes keep them mounted. + * + * @example + * } />}> + * } /> + * + */ +export function SessionOutlet({ + loginPath, + pending = null, + unavailable, + client, +}: SessionOutletProps): ReactElement { + const auth = useProvidedClient(client); + assertAuthSource( + auth, + "Render AuthProvider, or pass a client, before rendering SessionOutlet", + ); + const location = useLocation(); + const state = useAuthState(auth, (value) => value as OutletState); + switch (state.status) { + case "authenticated": + case "refreshing": + return createElement(Outlet, { key: state.sessionId }); + case "unauthenticated": + return createElement(Navigate, { + to: loginHref( + loginPath, + location.pathname + location.search + location.hash, + ), + replace: true, + }); + case "unavailable": { + const error = + state.error ?? + new AuthError("REFRESH_FAILED", "Access is unavailable"); + if (!unavailable) throw error; + const retry = () => { + void (auth as OutletClient).retry().catch(() => {}); + }; + return createElement( + Fragment, + null, + unavailable({ error: state.error, retry }), + ); + } + default: + // An element, not a bare node, so React 18 type definitions accept it. + return createElement(Fragment, null, pending); + } +} diff --git a/src/react-router/revalidation.ts b/src/react-router/revalidation.ts new file mode 100644 index 0000000..ffb079c --- /dev/null +++ b/src/react-router/revalidation.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from "react"; +import { useRevalidator } from "react-router"; + +import { + assertAuthSource, + useProvidedClient, + type AuthSource, +} from "../react/context.js"; + +const versionOf = (state: unknown) => (state as { version: number }).version; + +/** + * Re-runs loaders whenever auth changes in a way guards care about: a + * sign-in, sign-out, account switch, profile update, or failed refresh. Token + * refreshes alone do not. Call it once in the root layout, in data or + * framework mode; declarative mode has no loaders to re-run. + * + * @example + * function Root() { + * useAuthRevalidation(); + * return ; + * } + */ +export function useAuthRevalidation(client?: AuthSource): void { + const auth = useProvidedClient(client); + assertAuthSource( + auth, + "Render AuthProvider, or pass a client, before using useAuthRevalidation", + ); + const { revalidate } = useRevalidator(); + const latest = useRef(revalidate); + useEffect(() => { + latest.current = revalidate; + }, [revalidate]); + useEffect(() => { + let previous = versionOf(auth.state.get()); + let queued = false; + const { unsubscribe } = auth.state.subscribe((state) => { + const next = versionOf(state); + if (next === previous) return; + previous = next; + // Several changes in one turn re-run loaders once. + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + void Promise.resolve(latest.current()).catch((error: unknown) => { + console.error("Auth route revalidation failed", error); + }); + }); + }); + return unsubscribe; + }, [auth]); +} diff --git a/src/react/hooks.ts b/src/react/hooks.ts index e66654b..d47652f 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -3,6 +3,7 @@ import { createElement, useContext, useEffect, + useLayoutEffect, type ReactElement, type ReactNode, } from "react"; @@ -15,6 +16,12 @@ import { type StateOf, } from "./context.js"; +// Layout effects run before the browser paints, so a session restored at mount +// replaces the "initializing" state before anyone sees it. React warns about +// layout effects on a server, where neither kind of effect runs. +const useMountEffect = + typeof document === "undefined" ? useEffect : useLayoutEffect; + export type AuthHooksOptions = { /** * Finds the client instead of reading it from `AuthProvider`, for example @@ -32,8 +39,10 @@ export type AuthProviderProps = { export type AuthHooks = { /** * Provides `client` to the hooks below it and mounts it while rendered, - * starting expiry timers and cross-tab sync. Effects never run on a server, - * so server rendering starts no background work. + * starting expiry timers and cross-tab sync. With synchronous storage and + * schemas, mounting restores the saved session before the first paint. + * Effects never run on a server, so server rendering starts no background + * work. */ AuthProvider: (props: AuthProviderProps) => ReactElement; /** The client, for actions such as `signIn` and `signOut`. */ @@ -70,7 +79,7 @@ export function createAuthHooks( }; function AuthProvider({ client, children }: AuthProviderProps) { - useEffect(() => client.mount(), [client]); + useMountEffect(() => client.mount(), [client]); return createElement( ClientContext.Provider, { value: client }, diff --git a/src/tanstack-router/guards.ts b/src/tanstack-router/guards.ts index 22fb02d..28333aa 100644 --- a/src/tanstack-router/guards.ts +++ b/src/tanstack-router/guards.ts @@ -1,16 +1,11 @@ import { redirect } from "@tanstack/react-router"; -import { AuthError } from "../core/errors.js"; -import { safeReturnTo } from "../core/return-to.js"; - -/** What the guards need from an auth client. */ -type GuardClient = { - getSession: () => Promise; - isCurrent: (session: { sessionId: string }) => boolean; -}; - -/** How often a guard reads the session again when it changes mid-check. */ -const MAX_ATTEMPTS = 3; +import { + guardSession, + sessionOrNull, + type GuardClient, +} from "../core/guard.js"; +import { loginHref, safeReturnTo } from "../core/return-to.js"; export type RequireSessionOptions = { /** The route location, from `beforeLoad`'s `location`. */ @@ -35,23 +30,14 @@ export async function requireSession( auth: GuardClient, options: RequireSessionOptions, ): Promise<{ session: S }> { - if (!options.loginPath.startsWith("/")) - throw new Error(`loginPath must start with "/": ${options.loginPath}`); - // A session that changes while it is read gets read again: the change - // re-runs guards anyway, so an error here would only flash. - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - const session = await auth.getSession(); - if (!session) { - const login = new URL(options.loginPath, "https://auth.invalid"); - login.searchParams.set("redirectTo", options.location.href); - throw redirect({ href: login.pathname + login.search + login.hash }); - } - if (auth.isCurrent(session)) return { session }; - } - throw new AuthError( - "SESSION_CHANGED", - "The session kept changing; retry navigation", - ); + // Fails fast on a bad path, before any session is read. + loginHref(options.loginPath, "/"); + const session = await guardSession(auth); + if (!session) + throw redirect({ + href: loginHref(options.loginPath, options.location.href), + }); + return { session }; } export type RedirectIfSignedInOptions = { @@ -75,12 +61,7 @@ export async function redirectIfSignedIn( auth: GuardClient<{ sessionId: string }>, options: RedirectIfSignedInOptions, ): Promise { - let session: { sessionId: string } | null; - try { - session = await auth.getSession(); - } catch { - return; - } + const session = await sessionOrNull(auth); if (session) { throw redirect({ href: safeReturnTo(options.redirectTo, { diff --git a/tests/consumer.ts b/tests/consumer.ts index f485057..e45b276 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -15,6 +15,7 @@ import { type RefreshFn, type UserInput, } from "../dist/index.js"; +import * as reactRouter from "../dist/react-router/index.js"; import { createAuthHooks } from "../dist/react/index.js"; import { connectAuth, @@ -186,3 +187,20 @@ for (const storage of [ // @ts-expect-error Storage values are strings. const numericStorage: AuthStorage = { get: () => 1, set() {}, remove() {} }; void numericStorage; + +// React Router: loader guards and components share the TanStack shapes. +async function loader({ request }: { request: Request }) { + const { session } = await reactRouter.requireSession(auth, { + request, + loginPath: "/login", + }); + const email: string = session.user.email; + return email; +} +void loader; +reactRouter.SessionOutlet({ + loginPath: "/login", + unavailable: ({ error, retry }) => `${error?.code}${String(retry)}`, +}); +// @ts-expect-error The outlet needs the sign-in path. +reactRouter.SessionOutlet({}); diff --git a/tests/dom.ts b/tests/dom.ts new file mode 100644 index 0000000..556fb4b --- /dev/null +++ b/tests/dom.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach } from "node:test"; + +import * as React from "react"; +import { createElement, Fragment, StrictMode, type ReactNode } from "react"; + +import { + createAuth, + webStorage, + type AuthClient, +} from "@monarcode/session-kit"; +import { JSDOM } from "jsdom"; +import type { Root } from "react-dom/client"; +import { z } from "zod"; + +export const userSchema = z.object({ id: z.string(), email: z.string() }); +type TestUser = z.infer; +export type Client = AuthClient; + +export const user = { id: "alice", email: "alice@example.com" }; +export const signIn = (auth: Client) => + auth.signIn({ accessToken: "access-1", user }); +export const newClient = ( + options: Partial>[0]> = {}, +): Client => + createAuth({ + name: "react-test", + user: userSchema, + storage: webStorage(), + ...options, + }); + +/** React's `act`, which React 18.0–18.2 exported only from test utilities. */ +export let act: (callback: () => unknown) => Promise; +export let dom: JSDOM; +let roots: Set; +let originalGlobals: Map; +let createRoot: typeof import("react-dom/client").createRoot; + +/** Installs a jsdom browser before each test and removes it after. */ +export function useDom() { + beforeEach(async () => { + dom = new JSDOM("", { + url: "https://auth.test/", + }); + roots = new Set(); + const globals = { + window: dom.window, + self: dom.window, + document: dom.window.document, + location: dom.window.location, + localStorage: dom.window.localStorage, + // Router scroll restoration calls the global; jsdom only puts it on window. + scrollTo: () => {}, + IS_REACT_ACT_ENVIRONMENT: true, + }; + originalGlobals = new Map( + Object.keys(globals).map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), + ); + for (const [name, value] of Object.entries(globals)) { + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); + } + const reactAct = (React as { act?: typeof act }).act; + act = + reactAct ?? + ( + (await import( + // @ts-ignore -- React 19's types no longer declare this module. + "react-dom/test-utils" + )) as { act: typeof act } + ).act; + ({ createRoot } = await import("react-dom/client")); + }); + + afterEach(async () => { + try { + await act(async () => { + for (const root of roots) root.unmount(); + }); + } finally { + dom.window.close(); + for (const [name, descriptor] of originalGlobals) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete (globalThis as Record)[name]; + } + } + }); +} + +export async function render(element: ReactNode, { strict = false } = {}) { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + roots.add(root); + const tree = createElement(strict ? StrictMode : Fragment, null, element); + // Old react-dom types can pull in their own copy of React's types. + await act(async () => root.render(tree as Parameters[0])); + return { + container, + async unmount() { + await act(async () => root.unmount()); + roots.delete(root); + container.remove(); + }, + }; +} + +/** Lets timers and promises run, inside `act`. */ +export const settle = () => + act(() => new Promise((resolve) => setTimeout(resolve, 0))); diff --git a/tests/package.test.ts b/tests/package.test.ts index 60e04ac..50d25ed 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -10,6 +10,11 @@ import { } from "@monarcode/session-kit"; import { createAuthFetch } from "@monarcode/session-kit/http"; import { createAuthHooks } from "@monarcode/session-kit/react"; +import { + requireSession as requireLoaderSession, + SessionOutlet as RouterSessionOutlet, + useAuthRevalidation, +} from "@monarcode/session-kit/react-router"; import { connectAuth, redirectIfSignedIn, @@ -32,6 +37,9 @@ test("all public entry points import without browser globals", () => { redirectIfSignedIn, SessionOutlet, useRouterAuth, + requireLoaderSession, + RouterSessionOutlet, + useAuthRevalidation, createAuthFetch, ]) { assert.equal(typeof fn, "function"); diff --git a/tests/react-router.test.ts b/tests/react-router.test.ts new file mode 100644 index 0000000..6da4c30 --- /dev/null +++ b/tests/react-router.test.ts @@ -0,0 +1,337 @@ +import assert from "node:assert/strict"; +import { beforeEach, test } from "node:test"; + +import { createElement as h, useEffect } from "react"; + +import { AuthError } from "@monarcode/session-kit"; + +import { + act, + newClient, + render, + settle, + signIn, + user, + userSchema, + useDom, + type Client, +} from "./dom.ts"; + +type Router = typeof import("react-router"); +type Binding = typeof import("@monarcode/session-kit/react-router"); +type Hooks = typeof import("@monarcode/session-kit/react"); + +let router: Router; +let binding: Binding; +let hooks: Hooks; + +useDom(); +beforeEach(async () => { + router = await import("react-router"); + binding = await import("@monarcode/session-kit/react-router"); + hooks = await import("@monarcode/session-kit/react"); +}); + +/** Shows where the router is, so tests can assert on redirects. */ +function Where() { + const location = router.useLocation(); + return h("output", null, `login${location.search}`); +} + +/** A home page that counts its mounts and shows the user's email. */ +const homePage = ( + useAuth: ( + select: (state: { user: { email: string } | null }) => unknown, + ) => unknown, +) => { + const page = { + mounts: 0, + Home() { + const email = useAuth((state) => state.user?.email); + useEffect(() => { + page.mounts++; + }, []); + return h("output", null, `home:${String(email)}`); + }, + }; + return page; +}; + +test("declarative mode: SessionOutlet guards, keeps, and swaps child routes", async () => { + const auth = newClient(); + await signIn(auth); + const { AuthProvider, useAuth } = hooks.createAuthHooks(); + const page = homePage(useAuth as never); + const { MemoryRouter, Routes, Route } = router; + const view = await render( + h( + AuthProvider, + { client: auth }, + h( + MemoryRouter, + { initialEntries: ["/"] }, + h( + Routes, + null, + h(Route, { path: "/login", element: h(Where) }), + h( + Route, + { + element: h(binding.SessionOutlet, { + loginPath: "/login", + pending: "checking", + }), + }, + h(Route, { index: true, element: h(page.Home) }), + ), + ), + ), + ), + ); + assert.equal(view.container.textContent, "home:alice@example.com"); + await act(() => auth.updateUser({ ...user, email: "new@example.com" })); + assert.equal(view.container.textContent, "home:new@example.com"); + assert.equal(page.mounts, 1); + await act(() => + auth.signIn({ + accessToken: "bob-token", + user: { id: "bob", email: "bob@example.com" }, + }), + ); + assert.equal(view.container.textContent, "home:bob@example.com"); + assert.equal(page.mounts, 2); + await act(() => auth.signOut()); + assert.equal(view.container.textContent, "login?redirectTo=%2F"); +}); + +test("declarative mode: SessionOutlet shows pending while restoring", async () => { + await signIn(newClient()); + let release!: () => void; + const restored = new Promise((resolve) => (release = resolve)); + // The saved user validates asynchronously, so restoring takes a while. + const auth = newClient({ + user: userSchema.refine(async () => { + await restored; + return true; + }), + }); + const { AuthProvider } = hooks.createAuthHooks(); + const { MemoryRouter, Routes, Route } = router; + const view = await render( + h( + AuthProvider, + { client: auth }, + h( + MemoryRouter, + { initialEntries: ["/private"] }, + h( + Routes, + null, + h(Route, { path: "/login", element: h(Where) }), + h( + Route, + { + element: h(binding.SessionOutlet, { + loginPath: "/login", + pending: "checking", + }), + }, + h(Route, { path: "/private", element: "private" }), + ), + ), + ), + ), + ); + assert.equal(view.container.textContent, "checking"); + release(); + await settle(); + assert.equal(view.container.textContent, "private"); +}); + +test("declarative mode: SessionOutlet sends signed-out users to sign in", async () => { + const auth = newClient(); + const { AuthProvider } = hooks.createAuthHooks(); + const { MemoryRouter, Routes, Route } = router; + const view = await render( + h( + AuthProvider, + { client: auth }, + h( + MemoryRouter, + { initialEntries: ["/private?tab=2"] }, + h( + Routes, + null, + h(Route, { path: "/login", element: h(Where) }), + h( + Route, + { + element: h(binding.SessionOutlet, { loginPath: "/login" }), + }, + h(Route, { path: "/private", element: "private" }), + ), + ), + ), + ), + ); + await settle(); + assert.equal( + view.container.textContent, + "login?redirectTo=%2Fprivate%3Ftab%3D2", + ); +}); + +test("SessionOutlet offers a retry when access is unavailable", async () => { + let offline = true; + const auth = newClient({ + refresh: async () => { + if (offline) throw new Error("offline"); + return { accessToken: "access-2" }; + }, + }); + await auth.signIn({ + accessToken: "access-1", + refreshToken: "refresh-1", + user, + }); + const { AuthProvider } = hooks.createAuthHooks(); + const { MemoryRouter, Routes, Route } = router; + let retry: (() => void) | undefined; + const view = await render( + h( + AuthProvider, + { client: auth }, + h( + MemoryRouter, + null, + h( + Routes, + null, + h( + Route, + { + element: h(binding.SessionOutlet, { + loginPath: "/login", + unavailable: (props: { + error: AuthError | null; + retry: () => void; + }) => { + retry = props.retry; + return `unavailable:${props.error?.code}`; + }, + }), + }, + h(Route, { index: true, element: "home" }), + ), + ), + ), + ), + ); + const credentials = await auth.credentials.get(); + await act(() => auth.credentials.renew(credentials!).catch(() => {})); + assert.equal(view.container.textContent, "unavailable:REFRESH_FAILED"); + offline = false; + await act(async () => retry?.()); + await settle(); + assert.equal(view.container.textContent, "home"); +}); + +test("data mode: loaders guard routes and re-run when auth changes", async () => { + const auth = newClient(); + await signIn(auth); + const { AuthProvider, useAuth } = hooks.createAuthHooks(); + const page = homePage(useAuth as never); + const { createMemoryRouter, RouterProvider, Outlet } = router; + let loads = 0; + function Root() { + binding.useAuthRevalidation(); + return h(Outlet); + } + const memory = createMemoryRouter( + [ + { + element: h(Root), + children: [ + { + path: "/login", + loader: async ({ request }: { request: Request }) => { + const redirectTo = new URL(request.url).searchParams.get( + "redirectTo", + ); + await binding.redirectIfSignedIn(auth, { redirectTo }); + return null; + }, + element: h(Where), + }, + { + loader: async ({ request }: { request: Request }) => { + loads++; + return binding.requireSession(auth, { + request, + loginPath: "/login", + }); + }, + element: h(binding.SessionOutlet, { loginPath: "/login" }), + children: [{ index: true, element: h(page.Home) }], + }, + ], + }, + ], + { initialEntries: ["/"] }, + ); + const view = await render( + h(AuthProvider, { client: auth }, h(RouterProvider, { router: memory })), + ); + await settle(); + assert.equal(view.container.textContent, "home:alice@example.com"); + // A profile update re-runs loaders without leaving the page. + const before = loads; + await act(() => auth.updateUser({ ...user, email: "new@example.com" })); + await settle(); + assert.ok(loads > before); + assert.equal(view.container.textContent, "home:new@example.com"); + assert.equal(page.mounts, 1); + await act(() => auth.signOut()); + await settle(); + assert.equal(memory.state.location.pathname, "/login"); + assert.equal(memory.state.location.search, "?redirectTo=%2F"); + await act(() => signIn(auth)); + await act(() => memory.navigate("/login?redirectTo=%2F")); + await settle(); + assert.equal(memory.state.location.pathname, "/"); + assert.equal(view.container.textContent, "home:alice@example.com"); +}); + +test("loader guards redirect with the requested URL and never leave the site", async () => { + const auth = newClient(); + await auth.getSession(); + const request = new Request("https://auth.test/private?tab=2#top"); + const redirected = await binding + .requireSession(auth, { request, loginPath: "/login" }) + .then( + () => assert.fail("Expected a redirect"), + (response: Response) => response, + ); + assert.equal(redirected.status, 302); + assert.equal( + redirected.headers.get("Location"), + "/login?redirectTo=%2Fprivate%3Ftab%3D2%23top", + ); + await signIn(auth); + const { session } = await binding.requireSession(auth, { + request, + loginPath: "/login", + }); + assert.deepEqual(session, await auth.getSession()); + const away = await binding + .redirectIfSignedIn(auth, { redirectTo: "https://evil.example/" }) + .then( + () => assert.fail("Expected a redirect"), + (response: Response) => response, + ); + assert.equal(away.headers.get("Location"), "/"); + await assert.rejects( + binding.requireSession(auth, { request, loginPath: "login" }), + /loginPath must start with/, + ); +}); diff --git a/tests/react.test.ts b/tests/react.test.ts index ad23dd3..97d1862 100644 --- a/tests/react.test.ts +++ b/tests/react.test.ts @@ -1,123 +1,43 @@ import assert from "node:assert/strict"; -import { test, beforeEach, afterEach } from "node:test"; +import { beforeEach, test } from "node:test"; -import * as React from "react"; import { createElement as h, - StrictMode, useEffect, type FunctionComponent, type ReactNode, } from "react"; -import { - createAuth, - webStorage, - type AuthClient, -} from "@monarcode/session-kit"; import type { AuthHooks } from "@monarcode/session-kit/react"; import type { AnyRouter } from "@tanstack/react-router"; -import { JSDOM } from "jsdom"; -import type { Root } from "react-dom/client"; -import { z } from "zod"; -const userSchema = z.object({ id: z.string(), email: z.string() }); -type TestUser = z.infer; -type Client = AuthClient; +import { + act, + dom, + newClient, + render, + settle, + signIn, + user, + useDom, + type Client, +} from "./dom.ts"; + type ReactBinding = typeof import("@monarcode/session-kit/react"); type RouterBinding = typeof import("@monarcode/session-kit/tanstack-router"); type RouterModule = typeof import("@tanstack/react-router"); -/** React's `act`, which React 18.0–18.2 exported only from test utilities. */ -let act: (callback: () => unknown) => Promise; -let dom: JSDOM; -let roots: Set; -let originalGlobals: Map; -let createRoot: typeof import("react-dom/client").createRoot; let routerModule: RouterModule; let binding: ReactBinding; let routerBinding: RouterBinding; +useDom(); beforeEach(async () => { - dom = new JSDOM("", { - url: "https://auth.test/", - }); - roots = new Set(); - const globals = { - window: dom.window, - self: dom.window, - document: dom.window.document, - location: dom.window.location, - localStorage: dom.window.localStorage, - // Router scroll restoration calls the global; jsdom only puts it on window. - scrollTo: () => {}, - IS_REACT_ACT_ENVIRONMENT: true, - }; - originalGlobals = new Map( - Object.keys(globals).map((name) => [ - name, - Object.getOwnPropertyDescriptor(globalThis, name), - ]), - ); - for (const [name, value] of Object.entries(globals)) { - Object.defineProperty(globalThis, name, { - configurable: true, - writable: true, - value, - }); - } - const reactAct = (React as { act?: typeof act }).act; - act = - reactAct ?? - ( - (await import( - // @ts-ignore -- React 19's types no longer declare this module. - "react-dom/test-utils" - )) as { act: typeof act } - ).act; - ({ createRoot } = await import("react-dom/client")); routerModule = await import("@tanstack/react-router"); binding = await import("@monarcode/session-kit/react"); routerBinding = await import("@monarcode/session-kit/tanstack-router"); }); -afterEach(async () => { - try { - await act(async () => { - for (const root of roots) root.unmount(); - }); - } finally { - dom.window.close(); - for (const [name, descriptor] of originalGlobals) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else delete (globalThis as Record)[name]; - } - } -}); - -const user = { id: "alice", email: "alice@example.com" }; -const signIn = (auth: Client) => auth.signIn({ accessToken: "access-1", user }); -const newClient = (): Client => - createAuth({ name: "react-test", user: userSchema, storage: webStorage() }); - -async function render(element: ReactNode, { strict = false } = {}) { - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - roots.add(root); - await act(async () => - root.render(strict ? h(StrictMode, null, element) : element), - ); - return { - container, - async unmount() { - await act(async () => root.unmount()); - roots.delete(root); - container.remove(); - }, - }; -} - /** * Hooks for `auth` and a way to render under them, either through * `AuthProvider` or provider-free from Router context. @@ -412,8 +332,6 @@ test("SessionOutlet keeps child routes through profile updates and swaps them on const view = await render( h(RouterProvider, { router: router as AnyRouter }), ); - const settle = () => - act(() => new Promise((resolve) => setTimeout(resolve, 0))); await settle(); assert.equal(view.container.textContent, "home:alice@example.com"); assert.equal(homeMounts, 1); diff --git a/tests/session.test.ts b/tests/session.test.ts index d276001..6c8d344 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { createAuth, type AuthError } from "@monarcode/session-kit"; +import { createAuth, webStorage, type AuthError } from "@monarcode/session-kit"; import { z } from "zod"; import { @@ -19,6 +19,8 @@ import { input, client, code, + deferredStorage, + recordStates, credentialsOf, sessionOf, saved, @@ -476,3 +478,41 @@ test("createAuth requires storage", () => { /storage/, ); }); + +test("mounting restores at once when storage and the schema are synchronous", async () => { + await client().signIn(input()); + const auth = client({ storage: webStorage() }); + const states = recordStates(auth); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().user?.id, "alice"); + await flush(); + assert.deepEqual( + states.map((state) => state.status), + ["authenticated"], + ); +}); + +test("restoring waits for asynchronous storage or schemas", async () => { + await client().signIn(input()); + for (const options of [ + { storage: deferredStorage(webStorage()) }, + { user: userSchema.refine(async () => true) }, + ]) { + const auth = client({ storage: webStorage(), ...options }); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "initializing"); + await flush(); + assert.equal(auth.state.get().status, "authenticated"); + } +}); + +test("an invalid saved session is still discarded when mounting", async () => { + values.set(SESSION, "invalid-json"); + const auth = client({ storage: webStorage() }); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "initializing"); + await flush(); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(values.size, 0); +}); diff --git a/tests/users.test.ts b/tests/users.test.ts index 7db5d63..f960511 100644 --- a/tests/users.test.ts +++ b/tests/users.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { fromAccessToken, type AuthError } from "@monarcode/session-kit"; +import { + fromAccessToken, + webStorage, + type AuthError, +} from "@monarcode/session-kit"; import { z } from "zod"; import { @@ -169,3 +173,12 @@ test("a user schema that cannot accept its own output fails sign-in", async () = ); assert.equal(values.size, 0); }); + +test("a user read from the token restores at once when mounting", async () => { + await client({ user: tokenUser, storage: webStorage() }).signIn({ + accessToken: jwt(alice), + }); + const auth = client({ user: tokenUser, storage: webStorage() }); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().user?.id, "alice"); +}); From 930cb6311775f8d19bef91fff050c9ef8b630576 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 14:14:25 +0100 Subject: [PATCH 05/12] feat: support TanStack Start in SPA mode Start renders its SPA shell on the server at build time. connectAuth mounted the client there, found no storage, and published PERSISTENCE_FAILED, which could end up in the shell HTML and break hydration. - connectAuth returns a no-op disconnect when router.isServer is set, so the shell keeps the initializing state and renders the pending UI - webStorage() explains when localStorage or sessionStorage does not exist, pointing to defaultSsr: false in Start, instead of a bare TypeError - README: replace the "TanStack Start is not implemented" note; Start works in SPA mode with defaultSsr: false, React Router framework mode with ssr: false - Add CHANGELOG entries and tests for both behaviors --- CHANGELOG.md | 6 ++++++ README.md | 10 +++++++--- src/core/storage.ts | 14 ++++++++++++-- src/tanstack-router/connect.ts | 10 ++++++++++ tests/router.test.ts | 18 ++++++++++++++++++ tests/storage.test.ts | 9 +++++++++ 6 files changed, 62 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8ed9c..44d8784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,12 @@ `requireSession` and `redirectIfSignedIn` guard loaders, and `useAuthRevalidation` re-runs loaders when auth changes. React Router is an optional peer dependency. +- TanStack Start in SPA mode is supported. `connectAuth` does nothing while + the Router renders on a server, so Start's build-time shell shows the pending + UI instead of a storage error. Apps served by Start's own server should set + `defaultSsr: false`, so guards run in the browser. +- `webStorage()` explains when browser storage does not exist at all, as on a + server, instead of reporting a bare `TypeError`. - Mounting restores the saved session at once when storage and the user source both answer synchronously, as with `webStorage()` and a synchronous schema, so `initializing` is never rendered. `AuthProvider` mounts in a diff --git a/README.md b/README.md index 529c903..9b2dce2 100644 --- a/README.md +++ b/README.md @@ -816,9 +816,13 @@ the remaining data after storage becomes available. - Query cache management belongs to your app. Scope private data to the sign-in session ID, and reject late results from an old session. Router revalidation does not clear a separate TanStack Query cache. -- SSR and TanStack Start are not implemented. Import safety does not make the - browser client a server session adapter. Start needs a separate request-scoped - design. Never put tokens in route loader results or hydrated page data. +- Server rendering is not implemented; sessions live in the browser. TanStack + Start works in SPA mode with `defaultSsr: false` in `src/start.ts`: Start's + server still renders requests in SPA mode, and guards need the browser's + saved session. `connectAuth` does nothing while the Router renders on a + server, such as Start's build-time shell, which then shows your pending UI. + React Router framework mode works with `ssr: false`. Never put tokens in + route loader results or hydrated page data. ## Entry points diff --git a/src/core/storage.ts b/src/core/storage.ts index 12968b4..d45d779 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -57,8 +57,18 @@ export function webStorage(options: WebStorageOptions = {}): AuthStorage { if (area !== "local" && area !== "session") { throw new Error(`webStorage area must be "local" or "session": ${area}`); } - const target = () => - area === "local" ? globalThis.localStorage : globalThis.sessionStorage; + const target = () => { + const storage = + area === "local" ? globalThis.localStorage : globalThis.sessionStorage; + // Missing entirely means code is running outside a browser, such as a + // server rendering a route whose guard needs the saved session. + if (!storage) { + throw new Error( + `${area}Storage is not available here. Sessions live in the browser, so run auth in browser code; in TanStack Start, set defaultSsr: false.`, + ); + } + return storage; + }; return { get: (key) => target().getItem(key), set: (key, value) => target().setItem(key, value), diff --git a/src/tanstack-router/connect.ts b/src/tanstack-router/connect.ts index 690cba3..9971c77 100644 --- a/src/tanstack-router/connect.ts +++ b/src/tanstack-router/connect.ts @@ -4,10 +4,20 @@ type AuthRouter = { options: { context: { auth: Pick, "state" | "mount"> } }; invalidate: () => Promise; clearCache: () => void; + /** Set while rendering on a server, such as Start's build-time shell. */ + isServer?: boolean; }; const connections = new WeakMap void>(); +/** + * Starts auth for a Router: restores the session, runs expiry timers and tab + * sync, and re-runs guards when auth changes. Returns a function that stops. + * On a server, such as TanStack Start rendering its SPA shell, it does + * nothing: there is no browser storage to restore from, and the shell keeps + * the `initializing` state, which renders your pending UI. + */ export function connectAuth(router: AuthRouter): () => void { + if (router.isServer) return () => {}; const existing = connections.get(router); if (existing) return existing; const auth = router.options.context.auth; diff --git a/tests/router.test.ts b/tests/router.test.ts index 51cdba9..2be1613 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -355,3 +355,21 @@ test("requireSession reads a session that changes mid-check again", async () => /loginPath must start with/, ); }); + +test("connectAuth does nothing while a Router renders on a server", () => { + const auth = client(); + let mounted = 0; + auth.mount = () => { + mounted++; + return () => {}; + }; + const disconnect = connectAuth({ + isServer: true, + options: { context: { auth } }, + clearCache() {}, + async invalidate() {}, + }); + disconnect(); + assert.equal(mounted, 0); + assert.equal(auth.state.get().status, "initializing"); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 27e2220..eedea9f 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -157,3 +157,12 @@ test("a profile update keeps tokens this tab refreshed while it read storage", a cleanups.pop()!(); t.mock.timers.reset(); }); + +test("web storage outside a browser says why it cannot restore", async () => { + setGlobal("localStorage", undefined); + const auth = client({ storage: webStorage() }); + await assert.rejects(auth.getSession(), (error: Error) => { + const cause = (error as Error & { cause?: Error }).cause; + return /localStorage is not available here/.test(cause?.message ?? ""); + }); +}); From 5e3a4281268449dd029b8380b8c911a13d34f907 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 14:16:52 +0100 Subject: [PATCH 06/12] docs(examples): add example apps for each supported router setup - Add five apps under `examples/` (TanStack Router, TanStack Start SPA, and React Router in declarative, data and framework `ssr: false` modes). They share `/login` and a protected `/` page, sign in against DummyJSON, and read the user from the login response or the access token - Turn the repo into a pnpm workspace over `examples/*`, add `build:examples` and a CI job that builds every app, and pass `--workspace-root` to the oldest-peers install. Bump the `@tanstack/react-router` dev dependency to 1.170.41 - Document the apps in the README, `examples/README.md` and the CHANGELOG, and ignore generated build output in `.gitignore` --- .github/workflows/ci.yml | 29 +- .gitignore | 9 + CHANGELOG.md | 2 + README.md | 7 + examples/README.md | 46 + examples/react-router-data/index.html | 12 + examples/react-router-data/package.json | 25 + examples/react-router-data/src/auth.ts | 109 ++ examples/react-router-data/src/hooks.ts | 6 + examples/react-router-data/src/main.tsx | 79 + examples/react-router-data/src/ui.tsx | 113 ++ examples/react-router-data/tsconfig.json | 15 + examples/react-router-data/vite.config.ts | 8 + examples/react-router-declarative/index.html | 12 + .../react-router-declarative/package.json | 25 + examples/react-router-declarative/src/auth.ts | 91 + .../react-router-declarative/src/hooks.ts | 6 + .../react-router-declarative/src/main.tsx | 50 + examples/react-router-declarative/src/ui.tsx | 113 ++ .../react-router-declarative/tsconfig.json | 15 + .../react-router-declarative/vite.config.ts | 8 + .../react-router-framework-spa/app/auth.ts | 91 + .../react-router-framework-spa/app/hooks.ts | 6 + .../react-router-framework-spa/app/root.tsx | 51 + .../react-router-framework-spa/app/routes.ts | 11 + .../app/routes/home.tsx | 1 + .../app/routes/login.tsx | 25 + .../app/routes/protected.tsx | 21 + .../react-router-framework-spa/app/ui.tsx | 113 ++ .../react-router-framework-spa/package.json | 26 + .../react-router.config.ts | 4 + .../react-router-framework-spa/tsconfig.json | 15 + .../react-router-framework-spa/vite.config.ts | 8 + examples/tanstack-router/index.html | 12 + examples/tanstack-router/package.json | 26 + examples/tanstack-router/src/auth.ts | 91 + examples/tanstack-router/src/hooks.ts | 7 + examples/tanstack-router/src/main.tsx | 12 + examples/tanstack-router/src/router.tsx | 21 + .../tanstack-router/src/routes/__root.tsx | 7 + .../src/routes/_authenticated.tsx | 14 + .../src/routes/_authenticated/index.tsx | 7 + examples/tanstack-router/src/routes/login.tsx | 24 + examples/tanstack-router/src/ui.tsx | 113 ++ examples/tanstack-router/tsconfig.json | 15 + examples/tanstack-router/vite.config.ts | 12 + examples/tanstack-start-spa/package.json | 26 + examples/tanstack-start-spa/src/auth.ts | 109 ++ examples/tanstack-start-spa/src/hooks.ts | 7 + examples/tanstack-start-spa/src/router.tsx | 24 + .../tanstack-start-spa/src/routes/__root.tsx | 37 + .../src/routes/_authenticated.tsx | 14 + .../src/routes/_authenticated/index.tsx | 7 + .../tanstack-start-spa/src/routes/login.tsx | 24 + examples/tanstack-start-spa/src/start.ts | 7 + examples/tanstack-start-spa/src/ui.tsx | 113 ++ examples/tanstack-start-spa/tsconfig.json | 15 + examples/tanstack-start-spa/vite.config.ts | 10 + package.json | 3 +- pnpm-lock.yaml | 1636 ++++++++++++++++- pnpm-workspace.yaml | 2 + 61 files changed, 3528 insertions(+), 19 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/react-router-data/index.html create mode 100644 examples/react-router-data/package.json create mode 100644 examples/react-router-data/src/auth.ts create mode 100644 examples/react-router-data/src/hooks.ts create mode 100644 examples/react-router-data/src/main.tsx create mode 100644 examples/react-router-data/src/ui.tsx create mode 100644 examples/react-router-data/tsconfig.json create mode 100644 examples/react-router-data/vite.config.ts create mode 100644 examples/react-router-declarative/index.html create mode 100644 examples/react-router-declarative/package.json create mode 100644 examples/react-router-declarative/src/auth.ts create mode 100644 examples/react-router-declarative/src/hooks.ts create mode 100644 examples/react-router-declarative/src/main.tsx create mode 100644 examples/react-router-declarative/src/ui.tsx create mode 100644 examples/react-router-declarative/tsconfig.json create mode 100644 examples/react-router-declarative/vite.config.ts create mode 100644 examples/react-router-framework-spa/app/auth.ts create mode 100644 examples/react-router-framework-spa/app/hooks.ts create mode 100644 examples/react-router-framework-spa/app/root.tsx create mode 100644 examples/react-router-framework-spa/app/routes.ts create mode 100644 examples/react-router-framework-spa/app/routes/home.tsx create mode 100644 examples/react-router-framework-spa/app/routes/login.tsx create mode 100644 examples/react-router-framework-spa/app/routes/protected.tsx create mode 100644 examples/react-router-framework-spa/app/ui.tsx create mode 100644 examples/react-router-framework-spa/package.json create mode 100644 examples/react-router-framework-spa/react-router.config.ts create mode 100644 examples/react-router-framework-spa/tsconfig.json create mode 100644 examples/react-router-framework-spa/vite.config.ts create mode 100644 examples/tanstack-router/index.html create mode 100644 examples/tanstack-router/package.json create mode 100644 examples/tanstack-router/src/auth.ts create mode 100644 examples/tanstack-router/src/hooks.ts create mode 100644 examples/tanstack-router/src/main.tsx create mode 100644 examples/tanstack-router/src/router.tsx create mode 100644 examples/tanstack-router/src/routes/__root.tsx create mode 100644 examples/tanstack-router/src/routes/_authenticated.tsx create mode 100644 examples/tanstack-router/src/routes/_authenticated/index.tsx create mode 100644 examples/tanstack-router/src/routes/login.tsx create mode 100644 examples/tanstack-router/src/ui.tsx create mode 100644 examples/tanstack-router/tsconfig.json create mode 100644 examples/tanstack-router/vite.config.ts create mode 100644 examples/tanstack-start-spa/package.json create mode 100644 examples/tanstack-start-spa/src/auth.ts create mode 100644 examples/tanstack-start-spa/src/hooks.ts create mode 100644 examples/tanstack-start-spa/src/router.tsx create mode 100644 examples/tanstack-start-spa/src/routes/__root.tsx create mode 100644 examples/tanstack-start-spa/src/routes/_authenticated.tsx create mode 100644 examples/tanstack-start-spa/src/routes/_authenticated/index.tsx create mode 100644 examples/tanstack-start-spa/src/routes/login.tsx create mode 100644 examples/tanstack-start-spa/src/start.ts create mode 100644 examples/tanstack-start-spa/src/ui.tsx create mode 100644 examples/tanstack-start-spa/tsconfig.json create mode 100644 examples/tanstack-start-spa/vite.config.ts create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6ea1dd..20da443 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,10 +72,37 @@ jobs: - name: Install oldest supported peers run: | npm pkg set 'pnpm.overrides.@types/react=18.0.2' - pnpm add --save-dev --save-exact \ + pnpm add --workspace-root --save-dev --save-exact \ react@18.0.0 react-dom@18.0.0 \ @types/react@18.0.2 @types/react-dom@18.0.3 \ @tanstack/react-router@1.127.0 react-router@7.0.0 - name: Build and test run: pnpm run build && pnpm test && pnpm run test:types + + examples: + name: build example apps + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.12.1 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Each app builds and type-checks against the package built here. + # Builds never call DummyJSON; only the running apps do. + - name: Build the package and the example apps + run: pnpm run build:examples diff --git a/.gitignore b/.gitignore index 38b4c00..f7d4453 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,12 @@ node_modules dist + +.idea + +# Example apps +build +.react-router +.tanstack +.output +routeTree.gen.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d8784..7bd9851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ `defaultSsr: false`, so guards run in the browser. - `webStorage()` explains when browser storage does not exist at all, as on a server, instead of reporting a bare `TypeError`. +- `examples/` holds one app per supported setup, built in CI, all signing in + against DummyJSON's auth API. - Mounting restores the saved session at once when storage and the user source both answer synchronously, as with `webStorage()` and a synchronous schema, so `initializing` is never rendered. `AuthProvider` mounts in a diff --git a/README.md b/README.md index 9b2dce2..a273a7e 100644 --- a/README.md +++ b/README.md @@ -824,6 +824,13 @@ the remaining data after storage becomes available. React Router framework mode works with `ssr: false`. Never put tokens in route loader results or hydrated page data. +## Examples + +[`examples/`](./examples) has one small app per supported setup: TanStack +Router, TanStack Start in SPA mode, and React Router in declarative, data, and +framework mode. All sign in against DummyJSON's auth API. `pnpm run +build:examples` builds the package and every app. + ## Entry points | Import | Exports | diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..164f80a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,46 @@ +# session-kit examples + +Five small apps, one per supported setup, all signing in against +[DummyJSON's auth API](https://dummyjson.com/docs/auth). They share the same +pages: `/login`, prefilled with DummyJSON's test user, and a protected `/` +that shows the user, calls `auth/me` with the access token, and signs out. + +| App | Setup | User comes from | +| ------------------------------------------------------------ | --------------------------------------------- | --------------- | +| [`tanstack-router`](./tanstack-router) | TanStack Router, file-based routes | Login response | +| [`tanstack-start-spa`](./tanstack-start-spa) | TanStack Start in SPA mode | Access token | +| [`react-router-declarative`](./react-router-declarative) | React Router, `` | Login response | +| [`react-router-data`](./react-router-data) | React Router, `createBrowserRouter` | Access token | +| [`react-router-framework-spa`](./react-router-framework-spa) | React Router framework mode with `ssr: false` | Login response | + +## Run one + +From the repository root, build the package once, then start an app: + +```sh +pnpm install +pnpm run build +pnpm --dir examples/tanstack-router run dev +``` + +The apps use the package from this repository, so rebuild it after changing +`src`. `pnpm run build:examples` builds the package and every app, which is +what CI checks. + +## What each app shows + +- **`auth.ts`**: the DummyJSON client. Its refresh callback maps both 401 and + 403 to `null`, because DummyJSON answers 403 for an invalid refresh token. + Tokens last one minute, so the early refresh runs after about 30 seconds. + Requests omit `credentials: "include"`, which DummyJSON's own snippets use, + because session-kit keeps tokens out of cookies. +- **Guards**: `requireSession` and `SessionOutlet` for TanStack Router and + Start, `SessionOutlet` alone in React Router's declarative mode, and loader + guards with `useAuthRevalidation` in data and framework mode. +- **TanStack Start** sets `defaultSsr: false` in `src/start.ts`. Start's own + server still renders requests in SPA mode, and the guards need the browser's + saved session. + +DummyJSON has no logout endpoint, so the apps do not pass `revoke`. Its refresh +tokens stay valid after rotation, so it cannot show the cross-tab rotation race +that session-kit's tests cover. diff --git a/examples/react-router-data/index.html b/examples/react-router-data/index.html new file mode 100644 index 0000000..da2b33b --- /dev/null +++ b/examples/react-router-data/index.html @@ -0,0 +1,12 @@ + + + + + + session-kit with React Router (data mode) + + +
+ + + diff --git a/examples/react-router-data/package.json b/examples/react-router-data/package.json new file mode 100644 index 0000000..af0ac20 --- /dev/null +++ b/examples/react-router-data/package.json @@ -0,0 +1,25 @@ +{ + "name": "session-kit-example-react-router-data", + "private": true, + "description": "session-kit with React Router (data mode), against DummyJSON's auth API", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@monarcode/session-kit": "workspace:*", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-router": "8.4.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "typescript": "6.0.3", + "vite": "8.3.2" + } +} diff --git a/examples/react-router-data/src/auth.ts b/examples/react-router-data/src/auth.ts new file mode 100644 index 0000000..9647d49 --- /dev/null +++ b/examples/react-router-data/src/auth.ts @@ -0,0 +1,109 @@ +import { + createAuth, + fromAccessToken, + webStorage, +} from "@monarcode/session-kit"; +import { createAuthFetch } from "@monarcode/session-kit/http"; +import { z } from "zod"; + +/** DummyJSON's auth API: https://dummyjson.com/docs/auth */ +const API = "https://dummyjson.com"; + +/** One-minute tokens make the early refresh visible after about 30 seconds. */ +const expiresInMins = 1; + +/** DummyJSON returns the user's fields beside the tokens. */ +const loginResponse = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); +const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); + +/** + * DummyJSON's access tokens are JWTs carrying the user, so this app reads the + * user from the token instead of the login response. + */ +const claims = z.object({ + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); + +export const auth = createAuth({ + name: "session-kit-example", + user: fromAccessToken( + claims, + ({ id, username, email, firstName, lastName, image }) => ({ + id, + username, + email, + firstName, + lastName, + image, + }), + ), + storage: webStorage(), + refresh, +}); + +/** Signs in with DummyJSON, then saves the tokens; the user comes from the token. */ +export async function signInWithPassword(username: string, password: string) { + const { accessToken, refreshToken } = loginResponse.parse( + await login(username, password), + ); + await auth.signIn({ accessToken, refreshToken }); +} + +/** + * Exchanges the refresh token for new tokens. DummyJSON answers 403 for an + * invalid or expired refresh token and 401 for a missing one: both mean + * "sign in again", so return `null`. Other failures are operational. + * There is no `credentials: "include"`: session-kit keeps tokens out of cookies. + */ +async function refresh({ + refreshToken, + signal, +}: { + refreshToken: string; + signal: AbortSignal; +}) { + const response = await fetch(`${API}/auth/refresh`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken, expiresInMins }), + }); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + return tokens.parse(await response.json()); +} + +async function login(username: string, password: string): Promise { + const response = await fetch(`${API}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, expiresInMins }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = z.object({ message: z.string() }).safeParse(body); + throw new Error( + message.success + ? message.data.message + : `Sign-in failed (${response.status})`, + ); + } + return body; +} + +/** Requests to DummyJSON with the access token, refreshing after a 401. */ +export const authFetch = createAuthFetch(auth, `${API}/`); diff --git a/examples/react-router-data/src/hooks.ts b/examples/react-router-data/src/hooks.ts new file mode 100644 index 0000000..615afc3 --- /dev/null +++ b/examples/react-router-data/src/hooks.ts @@ -0,0 +1,6 @@ +import { createAuthHooks } from "@monarcode/session-kit/react"; + +import type { auth } from "./auth"; + +export const { AuthProvider, useAuth, useAuthClient } = + createAuthHooks(); diff --git a/examples/react-router-data/src/main.tsx b/examples/react-router-data/src/main.tsx new file mode 100644 index 0000000..ab90572 --- /dev/null +++ b/examples/react-router-data/src/main.tsx @@ -0,0 +1,79 @@ +import { StrictMode } from "react"; +import { + createBrowserRouter, + Outlet, + RouterProvider, + useNavigate, + useRouteError, + useSearchParams, +} from "react-router"; + +import { safeReturnTo } from "@monarcode/session-kit"; +import { + redirectIfSignedIn, + requireSession, + SessionOutlet, + useAuthRevalidation, +} from "@monarcode/session-kit/react-router"; +import { createRoot } from "react-dom/client"; + +import { auth } from "./auth"; +import { AuthProvider } from "./hooks"; +import { LoginForm, Pending, Profile, Unavailable } from "./ui"; + +/** Re-runs loaders after sign-in, sign-out, account switches, and profile updates. */ +function Root() { + useAuthRevalidation(); + return ; +} + +function Login() { + const navigate = useNavigate(); + const [search] = useSearchParams(); + return ( + + navigate(safeReturnTo(search.get("redirectTo")), { replace: true }) + } + /> + ); +} + +function RouteError() { + return ; +} + +const router = createBrowserRouter([ + { + element: , + HydrateFallback: Pending, + children: [ + { + path: "/login", + loader: async ({ request }) => { + const redirectTo = new URL(request.url).searchParams.get( + "redirectTo", + ); + await redirectIfSignedIn(auth, { redirectTo }); + return null; + }, + element: , + }, + { + loader: ({ request }) => + requireSession(auth, { request, loginPath: "/login" }), + errorElement: , + element: } />, + children: [{ index: true, element: }], + }, + ], + }, +]); + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/examples/react-router-data/src/ui.tsx b/examples/react-router-data/src/ui.tsx new file mode 100644 index 0000000..3dfc50f --- /dev/null +++ b/examples/react-router-data/src/ui.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from "react"; + +import { authFetch, signInWithPassword } from "./auth"; +import { useAuth, useAuthClient } from "./hooks"; + +/** The sign-in form, prefilled with DummyJSON's documented test user. */ +export function LoginForm({ onSignedIn }: { onSignedIn: () => unknown }) { + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setPending(true); + setError(""); + try { + await signInWithPassword( + String(form.get("username")), + String(form.get("password")), + ); + await onSignedIn(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not sign in"); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); +} + +const me = (value: unknown) => + typeof value === "object" && value !== null && "email" in value + ? String(value.email) + : ""; + +/** The signed-in user, a request using the access token, and sign-out. */ +export function Profile() { + const user = useAuth((state) => state.user); + const auth = useAuthClient(); + const [fromApi, setFromApi] = useState(""); + + async function loadProfile() { + const response = await authFetch("auth/me"); + setFromApi( + response.ok ? me(await response.json()) : `HTTP ${response.status}`, + ); + } + + return ( +
+

Welcome, {user?.firstName}

+

Signed in as {user?.username}

+ + {fromApi &&

auth/me: {fromApi}

} + +
+ ); +} + +export function Pending() { + return

Checking session…

; +} + +/** Shown after a failed refresh; retrying tries the refresh again. */ +export function Unavailable({ error }: { error: unknown }) { + const auth = useAuthClient(); + return ( +
+

+ {error instanceof Error ? error.message : "Something went wrong"} +

+ +
+ ); +} diff --git a/examples/react-router-data/tsconfig.json b/examples/react-router-data/tsconfig.json new file mode 100644 index 0000000..d9c9cdd --- /dev/null +++ b/examples/react-router-data/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["src", "*.ts"] +} diff --git a/examples/react-router-data/vite.config.ts b/examples/react-router-data/vite.config.ts new file mode 100644 index 0000000..1e1bb89 --- /dev/null +++ b/examples/react-router-data/vite.config.ts @@ -0,0 +1,8 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + // session-kit is linked from the workspace: keep one copy of React and the router. + resolve: { dedupe: ["react", "react-dom", "react-router"] }, +}); diff --git a/examples/react-router-declarative/index.html b/examples/react-router-declarative/index.html new file mode 100644 index 0000000..e372402 --- /dev/null +++ b/examples/react-router-declarative/index.html @@ -0,0 +1,12 @@ + + + + + + session-kit with React Router (declarative mode) + + +
+ + + diff --git a/examples/react-router-declarative/package.json b/examples/react-router-declarative/package.json new file mode 100644 index 0000000..abd6684 --- /dev/null +++ b/examples/react-router-declarative/package.json @@ -0,0 +1,25 @@ +{ + "name": "session-kit-example-react-router-declarative", + "private": true, + "description": "session-kit with React Router (declarative mode), against DummyJSON's auth API", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@monarcode/session-kit": "workspace:*", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-router": "8.4.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "typescript": "6.0.3", + "vite": "8.3.2" + } +} diff --git a/examples/react-router-declarative/src/auth.ts b/examples/react-router-declarative/src/auth.ts new file mode 100644 index 0000000..390e5e3 --- /dev/null +++ b/examples/react-router-declarative/src/auth.ts @@ -0,0 +1,91 @@ +import { createAuth, webStorage } from "@monarcode/session-kit"; +import { createAuthFetch } from "@monarcode/session-kit/http"; +import { z } from "zod"; + +/** DummyJSON's auth API: https://dummyjson.com/docs/auth */ +const API = "https://dummyjson.com"; + +/** One-minute tokens make the early refresh visible after about 30 seconds. */ +const expiresInMins = 1; + +/** DummyJSON returns the user's fields beside the tokens. */ +const loginResponse = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); +const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); + +export const userSchema = z.object({ + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); + +export const auth = createAuth({ + name: "session-kit-example", + user: userSchema, + storage: webStorage(), + refresh, +}); + +/** Signs in with DummyJSON, then saves the tokens and the user it returned. */ +export async function signInWithPassword(username: string, password: string) { + const { accessToken, refreshToken, ...user } = loginResponse.parse( + await login(username, password), + ); + await auth.signIn({ accessToken, refreshToken, user }); +} + +/** + * Exchanges the refresh token for new tokens. DummyJSON answers 403 for an + * invalid or expired refresh token and 401 for a missing one: both mean + * "sign in again", so return `null`. Other failures are operational. + * There is no `credentials: "include"`: session-kit keeps tokens out of cookies. + */ +async function refresh({ + refreshToken, + signal, +}: { + refreshToken: string; + signal: AbortSignal; +}) { + const response = await fetch(`${API}/auth/refresh`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken, expiresInMins }), + }); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + return tokens.parse(await response.json()); +} + +async function login(username: string, password: string): Promise { + const response = await fetch(`${API}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, expiresInMins }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = z.object({ message: z.string() }).safeParse(body); + throw new Error( + message.success + ? message.data.message + : `Sign-in failed (${response.status})`, + ); + } + return body; +} + +/** Requests to DummyJSON with the access token, refreshing after a 401. */ +export const authFetch = createAuthFetch(auth, `${API}/`); diff --git a/examples/react-router-declarative/src/hooks.ts b/examples/react-router-declarative/src/hooks.ts new file mode 100644 index 0000000..615afc3 --- /dev/null +++ b/examples/react-router-declarative/src/hooks.ts @@ -0,0 +1,6 @@ +import { createAuthHooks } from "@monarcode/session-kit/react"; + +import type { auth } from "./auth"; + +export const { AuthProvider, useAuth, useAuthClient } = + createAuthHooks(); diff --git a/examples/react-router-declarative/src/main.tsx b/examples/react-router-declarative/src/main.tsx new file mode 100644 index 0000000..36d3440 --- /dev/null +++ b/examples/react-router-declarative/src/main.tsx @@ -0,0 +1,50 @@ +import { StrictMode } from "react"; +import { + BrowserRouter, + Navigate, + Route, + Routes, + useSearchParams, +} from "react-router"; + +import { safeReturnTo } from "@monarcode/session-kit"; +import { SessionOutlet } from "@monarcode/session-kit/react-router"; +import { createRoot } from "react-dom/client"; + +import { auth } from "./auth"; +import { AuthProvider, useAuth } from "./hooks"; +import { LoginForm, Pending, Profile, Unavailable } from "./ui"; + +/** Signing in changes state, and this page then navigates away. */ +function Login() { + const signedIn = useAuth((state) => state.user !== null); + const [search] = useSearchParams(); + if (signedIn) + return ; + return {}} />; +} + +createRoot(document.getElementById("root")!).render( + + + + + } /> + } + unavailable={({ error }) => ( + + )} + /> + } + > + } /> + + + + + , +); diff --git a/examples/react-router-declarative/src/ui.tsx b/examples/react-router-declarative/src/ui.tsx new file mode 100644 index 0000000..3dfc50f --- /dev/null +++ b/examples/react-router-declarative/src/ui.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from "react"; + +import { authFetch, signInWithPassword } from "./auth"; +import { useAuth, useAuthClient } from "./hooks"; + +/** The sign-in form, prefilled with DummyJSON's documented test user. */ +export function LoginForm({ onSignedIn }: { onSignedIn: () => unknown }) { + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setPending(true); + setError(""); + try { + await signInWithPassword( + String(form.get("username")), + String(form.get("password")), + ); + await onSignedIn(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not sign in"); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); +} + +const me = (value: unknown) => + typeof value === "object" && value !== null && "email" in value + ? String(value.email) + : ""; + +/** The signed-in user, a request using the access token, and sign-out. */ +export function Profile() { + const user = useAuth((state) => state.user); + const auth = useAuthClient(); + const [fromApi, setFromApi] = useState(""); + + async function loadProfile() { + const response = await authFetch("auth/me"); + setFromApi( + response.ok ? me(await response.json()) : `HTTP ${response.status}`, + ); + } + + return ( +
+

Welcome, {user?.firstName}

+

Signed in as {user?.username}

+ + {fromApi &&

auth/me: {fromApi}

} + +
+ ); +} + +export function Pending() { + return

Checking session…

; +} + +/** Shown after a failed refresh; retrying tries the refresh again. */ +export function Unavailable({ error }: { error: unknown }) { + const auth = useAuthClient(); + return ( +
+

+ {error instanceof Error ? error.message : "Something went wrong"} +

+ +
+ ); +} diff --git a/examples/react-router-declarative/tsconfig.json b/examples/react-router-declarative/tsconfig.json new file mode 100644 index 0000000..d9c9cdd --- /dev/null +++ b/examples/react-router-declarative/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["src", "*.ts"] +} diff --git a/examples/react-router-declarative/vite.config.ts b/examples/react-router-declarative/vite.config.ts new file mode 100644 index 0000000..1e1bb89 --- /dev/null +++ b/examples/react-router-declarative/vite.config.ts @@ -0,0 +1,8 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + // session-kit is linked from the workspace: keep one copy of React and the router. + resolve: { dedupe: ["react", "react-dom", "react-router"] }, +}); diff --git a/examples/react-router-framework-spa/app/auth.ts b/examples/react-router-framework-spa/app/auth.ts new file mode 100644 index 0000000..390e5e3 --- /dev/null +++ b/examples/react-router-framework-spa/app/auth.ts @@ -0,0 +1,91 @@ +import { createAuth, webStorage } from "@monarcode/session-kit"; +import { createAuthFetch } from "@monarcode/session-kit/http"; +import { z } from "zod"; + +/** DummyJSON's auth API: https://dummyjson.com/docs/auth */ +const API = "https://dummyjson.com"; + +/** One-minute tokens make the early refresh visible after about 30 seconds. */ +const expiresInMins = 1; + +/** DummyJSON returns the user's fields beside the tokens. */ +const loginResponse = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); +const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); + +export const userSchema = z.object({ + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); + +export const auth = createAuth({ + name: "session-kit-example", + user: userSchema, + storage: webStorage(), + refresh, +}); + +/** Signs in with DummyJSON, then saves the tokens and the user it returned. */ +export async function signInWithPassword(username: string, password: string) { + const { accessToken, refreshToken, ...user } = loginResponse.parse( + await login(username, password), + ); + await auth.signIn({ accessToken, refreshToken, user }); +} + +/** + * Exchanges the refresh token for new tokens. DummyJSON answers 403 for an + * invalid or expired refresh token and 401 for a missing one: both mean + * "sign in again", so return `null`. Other failures are operational. + * There is no `credentials: "include"`: session-kit keeps tokens out of cookies. + */ +async function refresh({ + refreshToken, + signal, +}: { + refreshToken: string; + signal: AbortSignal; +}) { + const response = await fetch(`${API}/auth/refresh`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken, expiresInMins }), + }); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + return tokens.parse(await response.json()); +} + +async function login(username: string, password: string): Promise { + const response = await fetch(`${API}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, expiresInMins }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = z.object({ message: z.string() }).safeParse(body); + throw new Error( + message.success + ? message.data.message + : `Sign-in failed (${response.status})`, + ); + } + return body; +} + +/** Requests to DummyJSON with the access token, refreshing after a 401. */ +export const authFetch = createAuthFetch(auth, `${API}/`); diff --git a/examples/react-router-framework-spa/app/hooks.ts b/examples/react-router-framework-spa/app/hooks.ts new file mode 100644 index 0000000..615afc3 --- /dev/null +++ b/examples/react-router-framework-spa/app/hooks.ts @@ -0,0 +1,6 @@ +import { createAuthHooks } from "@monarcode/session-kit/react"; + +import type { auth } from "./auth"; + +export const { AuthProvider, useAuth, useAuthClient } = + createAuthHooks(); diff --git a/examples/react-router-framework-spa/app/root.tsx b/examples/react-router-framework-spa/app/root.tsx new file mode 100644 index 0000000..866c884 --- /dev/null +++ b/examples/react-router-framework-spa/app/root.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +import { useAuthRevalidation } from "@monarcode/session-kit/react-router"; + +import { auth } from "./auth"; +import { AuthProvider } from "./hooks"; +import { Pending } from "./ui"; + +export function Layout({ children }: { children: ReactNode }) { + return ( + + + + + session-kit with React Router (framework mode) + + + + + {children} + + + + + ); +} + +/** Re-runs client loaders after sign-in, sign-out, and profile updates. */ +function Revalidate() { + useAuthRevalidation(); + return ; +} + +// AuthProvider mounts the client in an effect, so the build-time prerender of +// this route starts nothing; the browser restores the session. +export default function App() { + return ( + + + + ); +} + +/** Prerendered into index.html, and shown until client loaders finish. */ +export function HydrateFallback() { + return ; +} diff --git a/examples/react-router-framework-spa/app/routes.ts b/examples/react-router-framework-spa/app/routes.ts new file mode 100644 index 0000000..b536477 --- /dev/null +++ b/examples/react-router-framework-spa/app/routes.ts @@ -0,0 +1,11 @@ +import { + index, + layout, + route, + type RouteConfig, +} from "@react-router/dev/routes"; + +export default [ + route("login", "routes/login.tsx"), + layout("routes/protected.tsx", [index("routes/home.tsx")]), +] satisfies RouteConfig; diff --git a/examples/react-router-framework-spa/app/routes/home.tsx b/examples/react-router-framework-spa/app/routes/home.tsx new file mode 100644 index 0000000..aa72c6c --- /dev/null +++ b/examples/react-router-framework-spa/app/routes/home.tsx @@ -0,0 +1 @@ +export { Profile as default } from "../ui"; diff --git a/examples/react-router-framework-spa/app/routes/login.tsx b/examples/react-router-framework-spa/app/routes/login.tsx new file mode 100644 index 0000000..9494bd1 --- /dev/null +++ b/examples/react-router-framework-spa/app/routes/login.tsx @@ -0,0 +1,25 @@ +import { useNavigate, useSearchParams } from "react-router"; + +import { safeReturnTo } from "@monarcode/session-kit"; +import { redirectIfSignedIn } from "@monarcode/session-kit/react-router"; + +import { auth } from "../auth"; +import { LoginForm } from "../ui"; + +export async function clientLoader({ request }: { request: Request }) { + const redirectTo = new URL(request.url).searchParams.get("redirectTo"); + await redirectIfSignedIn(auth, { redirectTo }); + return null; +} + +export default function Login() { + const navigate = useNavigate(); + const [search] = useSearchParams(); + return ( + + navigate(safeReturnTo(search.get("redirectTo")), { replace: true }) + } + /> + ); +} diff --git a/examples/react-router-framework-spa/app/routes/protected.tsx b/examples/react-router-framework-spa/app/routes/protected.tsx new file mode 100644 index 0000000..f40eea3 --- /dev/null +++ b/examples/react-router-framework-spa/app/routes/protected.tsx @@ -0,0 +1,21 @@ +import { useRouteError } from "react-router"; + +import { + requireSession, + SessionOutlet, +} from "@monarcode/session-kit/react-router"; + +import { auth } from "../auth"; +import { Pending, Unavailable } from "../ui"; + +export function clientLoader({ request }: { request: Request }) { + return requireSession(auth, { request, loginPath: "/login" }); +} + +export default function Protected() { + return } />; +} + +export function ErrorBoundary() { + return ; +} diff --git a/examples/react-router-framework-spa/app/ui.tsx b/examples/react-router-framework-spa/app/ui.tsx new file mode 100644 index 0000000..3dfc50f --- /dev/null +++ b/examples/react-router-framework-spa/app/ui.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from "react"; + +import { authFetch, signInWithPassword } from "./auth"; +import { useAuth, useAuthClient } from "./hooks"; + +/** The sign-in form, prefilled with DummyJSON's documented test user. */ +export function LoginForm({ onSignedIn }: { onSignedIn: () => unknown }) { + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setPending(true); + setError(""); + try { + await signInWithPassword( + String(form.get("username")), + String(form.get("password")), + ); + await onSignedIn(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not sign in"); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); +} + +const me = (value: unknown) => + typeof value === "object" && value !== null && "email" in value + ? String(value.email) + : ""; + +/** The signed-in user, a request using the access token, and sign-out. */ +export function Profile() { + const user = useAuth((state) => state.user); + const auth = useAuthClient(); + const [fromApi, setFromApi] = useState(""); + + async function loadProfile() { + const response = await authFetch("auth/me"); + setFromApi( + response.ok ? me(await response.json()) : `HTTP ${response.status}`, + ); + } + + return ( +
+

Welcome, {user?.firstName}

+

Signed in as {user?.username}

+ + {fromApi &&

auth/me: {fromApi}

} + +
+ ); +} + +export function Pending() { + return

Checking session…

; +} + +/** Shown after a failed refresh; retrying tries the refresh again. */ +export function Unavailable({ error }: { error: unknown }) { + const auth = useAuthClient(); + return ( +
+

+ {error instanceof Error ? error.message : "Something went wrong"} +

+ +
+ ); +} diff --git a/examples/react-router-framework-spa/package.json b/examples/react-router-framework-spa/package.json new file mode 100644 index 0000000..40a5123 --- /dev/null +++ b/examples/react-router-framework-spa/package.json @@ -0,0 +1,26 @@ +{ + "name": "session-kit-example-react-router-framework-spa", + "private": true, + "description": "session-kit with React Router (framework mode, ssr: false), against DummyJSON's auth API", + "type": "module", + "scripts": { + "dev": "react-router dev", + "build": "react-router build && tsc --noEmit", + "preview": "vite preview --outDir build/client" + }, + "dependencies": { + "@monarcode/session-kit": "workspace:*", + "isbot": "^5", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-router": "8.4.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@react-router/dev": "8.4.0", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "typescript": "6.0.3", + "vite": "8.3.2" + } +} diff --git a/examples/react-router-framework-spa/react-router.config.ts b/examples/react-router-framework-spa/react-router.config.ts new file mode 100644 index 0000000..6d1148b --- /dev/null +++ b/examples/react-router-framework-spa/react-router.config.ts @@ -0,0 +1,4 @@ +import type { Config } from "@react-router/dev/config"; + +// No server at runtime: the build prerenders index.html from the root route. +export default { ssr: false } satisfies Config; diff --git a/examples/react-router-framework-spa/tsconfig.json b/examples/react-router-framework-spa/tsconfig.json new file mode 100644 index 0000000..a10bd69 --- /dev/null +++ b/examples/react-router-framework-spa/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["app", "*.ts"] +} diff --git a/examples/react-router-framework-spa/vite.config.ts b/examples/react-router-framework-spa/vite.config.ts new file mode 100644 index 0000000..4ad71f9 --- /dev/null +++ b/examples/react-router-framework-spa/vite.config.ts @@ -0,0 +1,8 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [reactRouter()], + // session-kit is linked from the workspace: keep one copy of React and the router. + resolve: { dedupe: ["react", "react-dom", "react-router"] }, +}); diff --git a/examples/tanstack-router/index.html b/examples/tanstack-router/index.html new file mode 100644 index 0000000..4147a52 --- /dev/null +++ b/examples/tanstack-router/index.html @@ -0,0 +1,12 @@ + + + + + + session-kit with TanStack Router + + +
+ + + diff --git a/examples/tanstack-router/package.json b/examples/tanstack-router/package.json new file mode 100644 index 0000000..e877192 --- /dev/null +++ b/examples/tanstack-router/package.json @@ -0,0 +1,26 @@ +{ + "name": "session-kit-example-tanstack-router", + "private": true, + "description": "session-kit with TanStack Router, against DummyJSON's auth API", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@monarcode/session-kit": "workspace:*", + "@tanstack/react-router": "1.170.41", + "react": "19.3.0", + "react-dom": "19.3.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@tanstack/router-plugin": "1.168.42", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "typescript": "6.0.3", + "vite": "8.3.2" + } +} diff --git a/examples/tanstack-router/src/auth.ts b/examples/tanstack-router/src/auth.ts new file mode 100644 index 0000000..390e5e3 --- /dev/null +++ b/examples/tanstack-router/src/auth.ts @@ -0,0 +1,91 @@ +import { createAuth, webStorage } from "@monarcode/session-kit"; +import { createAuthFetch } from "@monarcode/session-kit/http"; +import { z } from "zod"; + +/** DummyJSON's auth API: https://dummyjson.com/docs/auth */ +const API = "https://dummyjson.com"; + +/** One-minute tokens make the early refresh visible after about 30 seconds. */ +const expiresInMins = 1; + +/** DummyJSON returns the user's fields beside the tokens. */ +const loginResponse = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); +const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); + +export const userSchema = z.object({ + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); + +export const auth = createAuth({ + name: "session-kit-example", + user: userSchema, + storage: webStorage(), + refresh, +}); + +/** Signs in with DummyJSON, then saves the tokens and the user it returned. */ +export async function signInWithPassword(username: string, password: string) { + const { accessToken, refreshToken, ...user } = loginResponse.parse( + await login(username, password), + ); + await auth.signIn({ accessToken, refreshToken, user }); +} + +/** + * Exchanges the refresh token for new tokens. DummyJSON answers 403 for an + * invalid or expired refresh token and 401 for a missing one: both mean + * "sign in again", so return `null`. Other failures are operational. + * There is no `credentials: "include"`: session-kit keeps tokens out of cookies. + */ +async function refresh({ + refreshToken, + signal, +}: { + refreshToken: string; + signal: AbortSignal; +}) { + const response = await fetch(`${API}/auth/refresh`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken, expiresInMins }), + }); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + return tokens.parse(await response.json()); +} + +async function login(username: string, password: string): Promise { + const response = await fetch(`${API}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, expiresInMins }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = z.object({ message: z.string() }).safeParse(body); + throw new Error( + message.success + ? message.data.message + : `Sign-in failed (${response.status})`, + ); + } + return body; +} + +/** Requests to DummyJSON with the access token, refreshing after a 401. */ +export const authFetch = createAuthFetch(auth, `${API}/`); diff --git a/examples/tanstack-router/src/hooks.ts b/examples/tanstack-router/src/hooks.ts new file mode 100644 index 0000000..a6294ae --- /dev/null +++ b/examples/tanstack-router/src/hooks.ts @@ -0,0 +1,7 @@ +import { createAuthHooks } from "@monarcode/session-kit/react"; +import { useRouterAuth } from "@monarcode/session-kit/tanstack-router"; + +// Reads the client from Router context, so no provider is needed. +export const { useAuth, useAuthClient } = createAuthHooks({ + useClient: useRouterAuth, +}); diff --git a/examples/tanstack-router/src/main.tsx b/examples/tanstack-router/src/main.tsx new file mode 100644 index 0000000..9e2e532 --- /dev/null +++ b/examples/tanstack-router/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; + +import { RouterProvider } from "@tanstack/react-router"; +import { createRoot } from "react-dom/client"; + +import { router } from "./router"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/tanstack-router/src/router.tsx b/examples/tanstack-router/src/router.tsx new file mode 100644 index 0000000..2465c7f --- /dev/null +++ b/examples/tanstack-router/src/router.tsx @@ -0,0 +1,21 @@ +import { connectAuth } from "@monarcode/session-kit/tanstack-router"; +import { createRouter } from "@tanstack/react-router"; + +import { auth } from "./auth"; +import { routeTree } from "./routeTree.gen"; +import { Pending } from "./ui"; + +export const router = createRouter({ + routeTree, + context: { auth }, + defaultPendingComponent: Pending, +}); + +// Restores the session, runs expiry timers and tab sync, and re-runs guards. +connectAuth(router); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/examples/tanstack-router/src/routes/__root.tsx b/examples/tanstack-router/src/routes/__root.tsx new file mode 100644 index 0000000..9afe175 --- /dev/null +++ b/examples/tanstack-router/src/routes/__root.tsx @@ -0,0 +1,7 @@ +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; + +import type { auth } from "../auth"; + +export const Route = createRootRouteWithContext<{ auth: typeof auth }>()({ + component: Outlet, +}); diff --git a/examples/tanstack-router/src/routes/_authenticated.tsx b/examples/tanstack-router/src/routes/_authenticated.tsx new file mode 100644 index 0000000..eef8802 --- /dev/null +++ b/examples/tanstack-router/src/routes/_authenticated.tsx @@ -0,0 +1,14 @@ +import { + requireSession, + SessionOutlet, +} from "@monarcode/session-kit/tanstack-router"; +import { createFileRoute } from "@tanstack/react-router"; + +import { Pending, Unavailable } from "../ui"; + +export const Route = createFileRoute("/_authenticated")({ + beforeLoad: ({ context, location }) => + requireSession(context.auth, { location, loginPath: "/login" }), + errorComponent: ({ error }) => , + component: () => } />, +}); diff --git a/examples/tanstack-router/src/routes/_authenticated/index.tsx b/examples/tanstack-router/src/routes/_authenticated/index.tsx new file mode 100644 index 0000000..d2640f7 --- /dev/null +++ b/examples/tanstack-router/src/routes/_authenticated/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { Profile } from "../../ui"; + +export const Route = createFileRoute("/_authenticated/")({ + component: Profile, +}); diff --git a/examples/tanstack-router/src/routes/login.tsx b/examples/tanstack-router/src/routes/login.tsx new file mode 100644 index 0000000..06976a2 --- /dev/null +++ b/examples/tanstack-router/src/routes/login.tsx @@ -0,0 +1,24 @@ +import { safeReturnTo } from "@monarcode/session-kit"; +import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; + +import { LoginForm } from "../ui"; + +export const Route = createFileRoute("/login")({ + validateSearch: (search: Record) => ({ + redirectTo: safeReturnTo(search.redirectTo), + }), + beforeLoad: ({ context, search }) => + redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), + component: Login, +}); + +function Login() { + const router = useRouter(); + const { redirectTo } = Route.useSearch(); + return ( + router.navigate({ href: redirectTo, replace: true })} + /> + ); +} diff --git a/examples/tanstack-router/src/ui.tsx b/examples/tanstack-router/src/ui.tsx new file mode 100644 index 0000000..3dfc50f --- /dev/null +++ b/examples/tanstack-router/src/ui.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from "react"; + +import { authFetch, signInWithPassword } from "./auth"; +import { useAuth, useAuthClient } from "./hooks"; + +/** The sign-in form, prefilled with DummyJSON's documented test user. */ +export function LoginForm({ onSignedIn }: { onSignedIn: () => unknown }) { + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setPending(true); + setError(""); + try { + await signInWithPassword( + String(form.get("username")), + String(form.get("password")), + ); + await onSignedIn(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not sign in"); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); +} + +const me = (value: unknown) => + typeof value === "object" && value !== null && "email" in value + ? String(value.email) + : ""; + +/** The signed-in user, a request using the access token, and sign-out. */ +export function Profile() { + const user = useAuth((state) => state.user); + const auth = useAuthClient(); + const [fromApi, setFromApi] = useState(""); + + async function loadProfile() { + const response = await authFetch("auth/me"); + setFromApi( + response.ok ? me(await response.json()) : `HTTP ${response.status}`, + ); + } + + return ( +
+

Welcome, {user?.firstName}

+

Signed in as {user?.username}

+ + {fromApi &&

auth/me: {fromApi}

} + +
+ ); +} + +export function Pending() { + return

Checking session…

; +} + +/** Shown after a failed refresh; retrying tries the refresh again. */ +export function Unavailable({ error }: { error: unknown }) { + const auth = useAuthClient(); + return ( +
+

+ {error instanceof Error ? error.message : "Something went wrong"} +

+ +
+ ); +} diff --git a/examples/tanstack-router/tsconfig.json b/examples/tanstack-router/tsconfig.json new file mode 100644 index 0000000..d9c9cdd --- /dev/null +++ b/examples/tanstack-router/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["src", "*.ts"] +} diff --git a/examples/tanstack-router/vite.config.ts b/examples/tanstack-router/vite.config.ts new file mode 100644 index 0000000..e1384c0 --- /dev/null +++ b/examples/tanstack-router/vite.config.ts @@ -0,0 +1,12 @@ +import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + tanstackRouter({ target: "react", autoCodeSplitting: true }), + react(), + ], + // session-kit is linked from the workspace: keep one copy of React and the router. + resolve: { dedupe: ["react", "react-dom", "@tanstack/react-router"] }, +}); diff --git a/examples/tanstack-start-spa/package.json b/examples/tanstack-start-spa/package.json new file mode 100644 index 0000000..f26de4d --- /dev/null +++ b/examples/tanstack-start-spa/package.json @@ -0,0 +1,26 @@ +{ + "name": "session-kit-example-tanstack-start-spa", + "private": true, + "description": "session-kit with TanStack Start (SPA mode), against DummyJSON's auth API", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@monarcode/session-kit": "workspace:*", + "@tanstack/react-router": "1.170.41", + "@tanstack/react-start": "1.168.60", + "react": "19.3.0", + "react-dom": "19.3.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@vitejs/plugin-react": "6.1.2", + "typescript": "6.0.3", + "vite": "8.3.2" + } +} diff --git a/examples/tanstack-start-spa/src/auth.ts b/examples/tanstack-start-spa/src/auth.ts new file mode 100644 index 0000000..9647d49 --- /dev/null +++ b/examples/tanstack-start-spa/src/auth.ts @@ -0,0 +1,109 @@ +import { + createAuth, + fromAccessToken, + webStorage, +} from "@monarcode/session-kit"; +import { createAuthFetch } from "@monarcode/session-kit/http"; +import { z } from "zod"; + +/** DummyJSON's auth API: https://dummyjson.com/docs/auth */ +const API = "https://dummyjson.com"; + +/** One-minute tokens make the early refresh visible after about 30 seconds. */ +const expiresInMins = 1; + +/** DummyJSON returns the user's fields beside the tokens. */ +const loginResponse = z.object({ + accessToken: z.string(), + refreshToken: z.string(), + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); +const tokens = z.object({ accessToken: z.string(), refreshToken: z.string() }); + +/** + * DummyJSON's access tokens are JWTs carrying the user, so this app reads the + * user from the token instead of the login response. + */ +const claims = z.object({ + id: z.number(), + username: z.string(), + email: z.string(), + firstName: z.string(), + lastName: z.string(), + image: z.string(), +}); + +export const auth = createAuth({ + name: "session-kit-example", + user: fromAccessToken( + claims, + ({ id, username, email, firstName, lastName, image }) => ({ + id, + username, + email, + firstName, + lastName, + image, + }), + ), + storage: webStorage(), + refresh, +}); + +/** Signs in with DummyJSON, then saves the tokens; the user comes from the token. */ +export async function signInWithPassword(username: string, password: string) { + const { accessToken, refreshToken } = loginResponse.parse( + await login(username, password), + ); + await auth.signIn({ accessToken, refreshToken }); +} + +/** + * Exchanges the refresh token for new tokens. DummyJSON answers 403 for an + * invalid or expired refresh token and 401 for a missing one: both mean + * "sign in again", so return `null`. Other failures are operational. + * There is no `credentials: "include"`: session-kit keeps tokens out of cookies. + */ +async function refresh({ + refreshToken, + signal, +}: { + refreshToken: string; + signal: AbortSignal; +}) { + const response = await fetch(`${API}/auth/refresh`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken, expiresInMins }), + }); + if (response.status === 401 || response.status === 403) return null; + if (!response.ok) throw new Error(`Refresh failed (${response.status})`); + return tokens.parse(await response.json()); +} + +async function login(username: string, password: string): Promise { + const response = await fetch(`${API}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, expiresInMins }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = z.object({ message: z.string() }).safeParse(body); + throw new Error( + message.success + ? message.data.message + : `Sign-in failed (${response.status})`, + ); + } + return body; +} + +/** Requests to DummyJSON with the access token, refreshing after a 401. */ +export const authFetch = createAuthFetch(auth, `${API}/`); diff --git a/examples/tanstack-start-spa/src/hooks.ts b/examples/tanstack-start-spa/src/hooks.ts new file mode 100644 index 0000000..a6294ae --- /dev/null +++ b/examples/tanstack-start-spa/src/hooks.ts @@ -0,0 +1,7 @@ +import { createAuthHooks } from "@monarcode/session-kit/react"; +import { useRouterAuth } from "@monarcode/session-kit/tanstack-router"; + +// Reads the client from Router context, so no provider is needed. +export const { useAuth, useAuthClient } = createAuthHooks({ + useClient: useRouterAuth, +}); diff --git a/examples/tanstack-start-spa/src/router.tsx b/examples/tanstack-start-spa/src/router.tsx new file mode 100644 index 0000000..a2a7b44 --- /dev/null +++ b/examples/tanstack-start-spa/src/router.tsx @@ -0,0 +1,24 @@ +import { connectAuth } from "@monarcode/session-kit/tanstack-router"; +import { createRouter } from "@tanstack/react-router"; + +import { auth } from "./auth"; +import { routeTree } from "./routeTree.gen"; +import { Pending } from "./ui"; + +export function getRouter() { + const router = createRouter({ + routeTree, + context: { auth }, + defaultPendingComponent: Pending, + scrollRestoration: true, + }); + // Does nothing while the build prerenders the shell, which then shows Pending. + connectAuth(router); + return router; +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType; + } +} diff --git a/examples/tanstack-start-spa/src/routes/__root.tsx b/examples/tanstack-start-spa/src/routes/__root.tsx new file mode 100644 index 0000000..41fa74c --- /dev/null +++ b/examples/tanstack-start-spa/src/routes/__root.tsx @@ -0,0 +1,37 @@ +/// +import type { ReactNode } from "react"; + +import { + createRootRouteWithContext, + HeadContent, + Outlet, + Scripts, +} from "@tanstack/react-router"; + +import type { auth } from "../auth"; + +export const Route = createRootRouteWithContext<{ auth: typeof auth }>()({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { title: "session-kit with TanStack Start" }, + ], + }), + shellComponent: Document, + component: Outlet, +}); + +function Document({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + + ); +} diff --git a/examples/tanstack-start-spa/src/routes/_authenticated.tsx b/examples/tanstack-start-spa/src/routes/_authenticated.tsx new file mode 100644 index 0000000..eef8802 --- /dev/null +++ b/examples/tanstack-start-spa/src/routes/_authenticated.tsx @@ -0,0 +1,14 @@ +import { + requireSession, + SessionOutlet, +} from "@monarcode/session-kit/tanstack-router"; +import { createFileRoute } from "@tanstack/react-router"; + +import { Pending, Unavailable } from "../ui"; + +export const Route = createFileRoute("/_authenticated")({ + beforeLoad: ({ context, location }) => + requireSession(context.auth, { location, loginPath: "/login" }), + errorComponent: ({ error }) => , + component: () => } />, +}); diff --git a/examples/tanstack-start-spa/src/routes/_authenticated/index.tsx b/examples/tanstack-start-spa/src/routes/_authenticated/index.tsx new file mode 100644 index 0000000..d2640f7 --- /dev/null +++ b/examples/tanstack-start-spa/src/routes/_authenticated/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { Profile } from "../../ui"; + +export const Route = createFileRoute("/_authenticated/")({ + component: Profile, +}); diff --git a/examples/tanstack-start-spa/src/routes/login.tsx b/examples/tanstack-start-spa/src/routes/login.tsx new file mode 100644 index 0000000..06976a2 --- /dev/null +++ b/examples/tanstack-start-spa/src/routes/login.tsx @@ -0,0 +1,24 @@ +import { safeReturnTo } from "@monarcode/session-kit"; +import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router"; +import { createFileRoute, useRouter } from "@tanstack/react-router"; + +import { LoginForm } from "../ui"; + +export const Route = createFileRoute("/login")({ + validateSearch: (search: Record) => ({ + redirectTo: safeReturnTo(search.redirectTo), + }), + beforeLoad: ({ context, search }) => + redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), + component: Login, +}); + +function Login() { + const router = useRouter(); + const { redirectTo } = Route.useSearch(); + return ( + router.navigate({ href: redirectTo, replace: true })} + /> + ); +} diff --git a/examples/tanstack-start-spa/src/start.ts b/examples/tanstack-start-spa/src/start.ts new file mode 100644 index 0000000..34bb58f --- /dev/null +++ b/examples/tanstack-start-spa/src/start.ts @@ -0,0 +1,7 @@ +import { createStart } from "@tanstack/react-start"; + +// Auth lives in the browser: no route's guards or loaders run on a server, +// whichever server answers the request. The shell still prerenders. +export const startInstance = createStart(() => ({ + defaultSsr: false, +})); diff --git a/examples/tanstack-start-spa/src/ui.tsx b/examples/tanstack-start-spa/src/ui.tsx new file mode 100644 index 0000000..3dfc50f --- /dev/null +++ b/examples/tanstack-start-spa/src/ui.tsx @@ -0,0 +1,113 @@ +import { useState, type FormEvent } from "react"; + +import { authFetch, signInWithPassword } from "./auth"; +import { useAuth, useAuthClient } from "./hooks"; + +/** The sign-in form, prefilled with DummyJSON's documented test user. */ +export function LoginForm({ onSignedIn }: { onSignedIn: () => unknown }) { + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setPending(true); + setError(""); + try { + await signInWithPassword( + String(form.get("username")), + String(form.get("password")), + ); + await onSignedIn(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not sign in"); + } finally { + setPending(false); + } + } + + return ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); +} + +const me = (value: unknown) => + typeof value === "object" && value !== null && "email" in value + ? String(value.email) + : ""; + +/** The signed-in user, a request using the access token, and sign-out. */ +export function Profile() { + const user = useAuth((state) => state.user); + const auth = useAuthClient(); + const [fromApi, setFromApi] = useState(""); + + async function loadProfile() { + const response = await authFetch("auth/me"); + setFromApi( + response.ok ? me(await response.json()) : `HTTP ${response.status}`, + ); + } + + return ( +
+

Welcome, {user?.firstName}

+

Signed in as {user?.username}

+ + {fromApi &&

auth/me: {fromApi}

} + +
+ ); +} + +export function Pending() { + return

Checking session…

; +} + +/** Shown after a failed refresh; retrying tries the refresh again. */ +export function Unavailable({ error }: { error: unknown }) { + const auth = useAuthClient(); + return ( +
+

+ {error instanceof Error ? error.message : "Something went wrong"} +

+ +
+ ); +} diff --git a/examples/tanstack-start-spa/tsconfig.json b/examples/tanstack-start-spa/tsconfig.json new file mode 100644 index 0000000..d9c9cdd --- /dev/null +++ b/examples/tanstack-start-spa/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "types": ["vite/client"] + }, + "include": ["src", "*.ts"] +} diff --git a/examples/tanstack-start-spa/vite.config.ts b/examples/tanstack-start-spa/vite.config.ts new file mode 100644 index 0000000..f497b52 --- /dev/null +++ b/examples/tanstack-start-spa/vite.config.ts @@ -0,0 +1,10 @@ +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + // SPA mode: no server rendering; the build prerenders only an HTML shell. + plugins: [tanstackStart({ spa: { enabled: true } }), react()], + // session-kit is linked from the workspace: keep one copy of React and the router. + resolve: { dedupe: ["react", "react-dom", "@tanstack/react-router"] }, +}); diff --git a/package.json b/package.json index 5e0ada6..200c1bb 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "format:check": "oxfmt --check .", "clean": "node scripts/clean.mjs", "build": "pnpm run clean && tsc -p tsconfig.json", + "build:examples": "pnpm run build && pnpm --filter \"./examples/*\" run build", "dev": "tsc -p tsconfig.json --watch", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "pnpm run test:sync && pnpm run test:async", @@ -80,7 +81,7 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@tanstack/react-router": "1.170.38", + "@tanstack/react-router": "1.170.41", "@types/jsdom": "21.1.7", "@types/node": "22.20.4", "@types/react": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8426e7..989a4ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: version: 1.7.0(react@19.3.0) devDependencies: '@tanstack/react-router': - specifier: 1.170.38 - version: 1.170.38(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + specifier: 1.170.41 + version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@types/jsdom': specifier: 21.1.7 version: 21.1.7 @@ -64,11 +64,257 @@ importers: specifier: 4.4.3 version: 4.4.3 + examples/react-router-data: + dependencies: + '@monarcode/session-kit': + specifier: workspace:* + version: link:../.. + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + react-router: + specifier: 8.4.0 + version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.2 + version: 6.1.2(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + + examples/react-router-declarative: + dependencies: + '@monarcode/session-kit': + specifier: workspace:* + version: link:../.. + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + react-router: + specifier: 8.4.0 + version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.2 + version: 6.1.2(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + + examples/react-router-framework-spa: + dependencies: + '@monarcode/session-kit': + specifier: workspace:* + version: link:../.. + isbot: + specifier: ^5 + version: 5.2.2 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + react-router: + specifier: 8.4.0 + version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@react-router/dev': + specifier: 8.4.0 + version: 8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + + examples/tanstack-router: + dependencies: + '@monarcode/session-kit': + specifier: workspace:* + version: link:../.. + '@tanstack/react-router': + specifier: 1.170.41 + version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@tanstack/router-plugin': + specifier: 1.168.42 + version: 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(rolldown@1.2.12)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.2 + version: 6.1.2(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + + examples/tanstack-start-spa: + dependencies: + '@monarcode/session-kit': + specifier: workspace:* + version: link:../.. + '@tanstack/react-router': + specifier: 1.170.41 + version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-start': + specifier: 1.168.60 + version: 1.168.60(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/react': + specifier: 19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: 19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.2 + version: 6.1.2(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.3.2 + version: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -97,6 +343,41 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oozcitak/dom@2.0.2': + resolution: {integrity: sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==} + engines: {node: '>=20.0'} + + '@oozcitak/infra@2.0.2': + resolution: {integrity: sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==} + engines: {node: '>=20.0'} + + '@oozcitak/url@3.0.0': + resolution: {integrity: sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==} + engines: {node: '>=20.0'} + + '@oozcitak/util@10.0.0': + resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} + engines: {node: '>=20.0'} + + '@oxc-project/types@0.152.0': + resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} + '@oxfmt/binding-android-arm-eabi@0.71.0': resolution: {integrity: sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -325,9 +606,139 @@ packages: cpu: [x64] os: [win32] + '@react-router/dev@8.4.0': + resolution: {integrity: sha512-GKznpn6h8c52xGXwtsypD5ZwNiWFSeqoKPdyGq7SfG+T9rFjGO8F0i/R/yECfZqHXaNz1NVmpODuBamBy9FfMg==} + engines: {node: '>=22.22.0'} + hasBin: true + peerDependencies: + '@react-router/serve': ^8.4.0 + '@vitejs/plugin-rsc': ~0.5.26 + react-router: ^8.4.0 + react-server-dom-webpack: ^19.2.7 + typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 + vite: ^7.0.0 || ^8.0.0 + wrangler: ^4.0.0 + peerDependenciesMeta: + '@react-router/serve': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-webpack: + optional: true + typescript: + optional: true + wrangler: + optional: true + + '@react-router/node@8.4.0': + resolution: {integrity: sha512-1teZHtk7XDm1pteQ2Plgtvtjjup5C5sIwFlOMYVIyoOH7X3bId3Qb//kUdbydX6NRIxcGK28UnfZlz4SrD/mkg==} + engines: {node: '>=22.22.0'} + peerDependencies: + react-router: 8.4.0 + typescript: ^5.1.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@remix-run/node-fetch-server@0.14.2': + resolution: {integrity: sha512-1cpM/f5i1ka/9CUMBwZXZp7Bn92x8VQjzg+F4eEe88DlVdNHkAKrf/vvbZoYgdkfy2bWHtGC6LQGLmojEqOF4Q==} + '@remix-run/route-pattern@0.22.1': resolution: {integrity: sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==} + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: {integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: {integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: {integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: {integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: {integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: {integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: {integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: {integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: {integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: {integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: {integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: {integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: {integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: {integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: {integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -335,25 +746,134 @@ packages: resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} engines: {node: '>=20.19'} - '@tanstack/react-router@1.170.38': - resolution: {integrity: sha512-iHM9b0aDJbuvftmkiQXawzoqsdV68p2Re2safbVLCnxafe+iLKV++2i3hI/BMAP6uR92/Mjw94JKJJfD7pbwHQ==} + '@tanstack/react-router@1.170.41': + resolution: {integrity: sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.11.1': - resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + '@tanstack/react-start-client@1.168.39': + resolution: {integrity: sha512-Uby0uo37JlQgUKHU0snDwH9nYiEeAjm9+7pUOFgycU/UszEILC2SO2DSCoNnCMuHKj9z+4URKzBYIP2exT9+wg==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-start-rsc@0.1.59': + resolution: {integrity: sha512-M2Lqxkk5C1zQncQs5paqX+/OLdOht51pg6D84YfLMrNsjhU/nzb6wd92MIja0mi5i23+L+fxLLjQYR2DObjBlw==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@rspack/core': '>=2.0.0-0' + '@vitejs/plugin-rsc': '>=0.5.30' + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + react-server-dom-rspack: '>=0.0.2' + peerDependenciesMeta: + '@rspack/core': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-rspack: + optional: true + + '@tanstack/react-start-server@1.167.46': + resolution: {integrity: sha512-K7z8xBOEdp8u5CZCxWrbS0tFtk5XZGMdhssLeZlRZKlLDbX7BKLdMtJ7th5sR6QVt9MTUAg3LcoqvJMPSFF+lg==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-start@1.168.60': + resolution: {integrity: sha512-WMOM0Xlh2lPbVlbjYM6uOxOpiaWRPvODm/dwkLsGKq5tjyHOHZmKckJgwlrZl7OQUqFlOZHYj8bKUTgoeg8cUQ==} + engines: {node: '>=22.12.0'} + 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' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@vitejs/plugin-rsc': + optional: true + vite: + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.171.32': - resolution: {integrity: sha512-X86Jqk3vB2KJcUfS6oLi7B7yxbaa59QEhZRPPP1fRx3k+Jw/Fu1UYVBcRtdwK/OccQnrlQdVoKvNO3QXmyEBOw==} + '@tanstack/router-core@1.171.34': + resolution: {integrity: sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw==} + engines: {node: '>=20.19'} + + '@tanstack/router-generator@1.167.40': + resolution: {integrity: sha512-WkEueqEvsBBudzO0fx1nWfOmRNW8OAvBoVABTGls9bAWzjdC+KDGHvjCNrvn88ofs1F6gqL8aWnzKCbxW3dCyQ==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.42': + resolution: {integrity: sha512-vAR5WXJh/ErseEWQftDhsJcr2c++ucRQ6FbqN3ygMO44+ZxxRfW3sIpZmddHROVSmBk8jc6MrwjTPNjVhd62Vw==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.41 + 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' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.3': + resolution: {integrity: sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA==} engines: {node: '>=20.19'} - '@tanstack/store@0.11.1': - resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + '@tanstack/start-client-core@1.170.34': + resolution: {integrity: sha512-LfwZSbau2MLLZ/uPAoO+NC9fK3J66c0wLhnNQqIa+Goqw7bXPjkHwlsjY7evlUz1AnyhhQ1Qab95v+VmP0TZ9A==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-fn-stubs@1.162.0': + resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-plugin-core@1.171.49': + resolution: {integrity: sha512-HPOb7LnMc71k38cd5uvMybPPUU/Q/56KvSqQzSWz7cps1ZDFXaxUM6EFaFHLCdgTWqGexNGDdRE+sUVPVBM4Xg==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@rsbuild/core': ^2.0.0 + vite: '>=7.0.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + vite: + optional: true + + '@tanstack/start-server-core@1.169.39': + resolution: {integrity: sha512-pqqkxhXY46nuFSYt6TsMGyABiplUzHFHndKAlRRGC0fWDrJCH1koWxVMoxySa51ZETV79f/z73b5UZ1jfIkXwQ==} + engines: {node: '>=22.12.0'} + + '@tanstack/start-storage-context@1.167.36': + resolution: {integrity: sha512-x+xCXK2GtFzY6NbXqAg8YpU6cwAjDfMeYSNzxfGzntpjbwBRHHrXeC/kYUnBBQCV7wK4kJ8vBBQGP0OyYTasUg==} + engines: {node: '>=22.12.0'} + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -375,10 +895,59 @@ packages: '@types/use-sync-external-store@1.7.0': resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.152.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ansis@4.4.0: + resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} + engines: {node: '>=14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + baseline-browser-mapping@2.11.27: + resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.3: + resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001814: + resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} @@ -405,10 +974,74 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + electron-to-chromium@1.5.444: + resolution: {integrity: sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + exit-hook@5.1.0: + resolution: {integrity: sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog==} + engines: {node: '>=20'} + + exsolve@1.1.3: + resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetchdts@0.1.7: + resolution: {integrity: sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + h3@2.0.1-rc.20: + resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -437,6 +1070,17 @@ packages: resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} engines: {node: '>=18'} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsdom@26.1.0: resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} engines: {node: '>=18'} @@ -446,16 +1090,114 @@ packages: canvas: optional: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanostores@1.5.4: resolution: {integrity: sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==} engines: {node: ^20.0.0 || >=22.0.0} + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + nwsapi@2.2.28: resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} @@ -485,9 +1227,35 @@ packages: vite-plus: optional: true + p-map@7.0.8: + resolution: {integrity: sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -497,6 +1265,10 @@ packages: peerDependencies: react: ^19.3.0 + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + react-router@8.4.0: resolution: {integrity: sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==} engines: {node: '>=22.22.0'} @@ -511,6 +1283,18 @@ packages: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + rolldown@1.2.12: + resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -524,6 +1308,15 @@ packages: scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + seroval-plugins@1.6.7: resolution: {integrity: sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==} engines: {node: '>=10'} @@ -534,9 +1327,26 @@ packages: resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==} engines: {node: '>=10'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + srvx@0.11.22: + resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} + engines: {node: '>=20.16.0'} + hasBin: true + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + tinypool@2.2.0: resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} engines: {node: ^20.0.0 || >=22.0.0} @@ -561,14 +1371,118 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unplugin@3.4.0: + resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rsbuild/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rsbuild/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + use-sync-external-store@1.7.0: resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + valibot@1.5.0: + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vite@8.3.2: + resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + 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 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -577,6 +1491,9 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -606,12 +1523,22 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xmlbuilder2@4.0.3: + resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} + engines: {node: '>=20.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + snapshots: '@asamuzakjp/css-color@3.2.0': @@ -622,6 +1549,106 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.3 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -642,6 +1669,44 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oozcitak/dom@2.0.2': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/url': 3.0.0 + '@oozcitak/util': 10.0.0 + + '@oozcitak/infra@2.0.2': + dependencies: + '@oozcitak/util': 10.0.0 + + '@oozcitak/url@3.0.0': + dependencies: + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + + '@oozcitak/util@10.0.0': {} + + '@oxc-project/types@0.152.0': {} + '@oxfmt/binding-android-arm-eabi@0.71.0': optional: true @@ -756,36 +1821,314 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.86.0': optional: true + '@react-router/dev@8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@react-router/node': 8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3) + '@remix-run/node-fetch-server': 0.14.2 + babel-dead-code-elimination: 1.0.12 + chokidar: 5.0.0 + dedent: 1.7.2 + es-module-lexer: 2.3.2 + exit-hook: 5.1.0 + isbot: 5.2.2 + jsesc: 3.1.0 + lodash: 4.18.1 + p-map: 7.0.8 + pathe: 2.0.3 + picocolors: 1.1.1 + pkg-types: 2.3.3 + react-refresh: 0.18.0 + react-router: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + semver: 7.8.5 + tinyglobby: 0.2.17 + valibot: 1.5.0(typescript@6.0.3) + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + '@react-router/node@8.4.0(react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(typescript@6.0.3)': + dependencies: + '@remix-run/node-fetch-server': 0.14.2 + react-router: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + optionalDependencies: + typescript: 6.0.3 + + '@remix-run/node-fetch-server@0.14.2': {} + '@remix-run/route-pattern@0.22.1': {} + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@tanstack/history@1.162.4': {} - '@tanstack/react-router@1.170.38(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@tanstack/history': 1.162.4 - '@tanstack/react-store': 0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@tanstack/router-core': 1.171.32 + '@tanstack/react-store': 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.171.34 isbot: 5.2.2 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@tanstack/react-store@0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@tanstack/react-start-client@1.168.39(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.171.34 + '@tanstack/start-client-core': 1.170.34 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@tanstack/react-start-rsc@0.1.59(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.171.34 + '@tanstack/router-utils': 1.162.3 + '@tanstack/start-client-core': 1.170.34 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + '@tanstack/start-storage-context': 1.167.36 + pathe: 2.0.3 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - bun-types-no-globals + - crossws + - esbuild + - rolldown + - rollup + - supports-color + - unloader + - vite + - vite-plugin-solid + - webpack + + '@tanstack/react-start-server@1.167.46(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.171.34 + '@tanstack/start-server-core': 1.169.39 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - crossws + + '@tanstack/react-start@1.168.60(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-start-client': 1.168.39(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-start-rsc': 0.1.59(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + '@tanstack/react-start-server': 1.167.46(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-utils': 1.162.3 + '@tanstack/start-client-core': 1.170.34 + '@tanstack/start-plugin-core': 1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + '@tanstack/start-server-core': 1.169.39 + pathe: 2.0.3 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - crossws + - esbuild + - react-server-dom-rspack + - rolldown + - rollup + - supports-color + - unloader + - vite-plugin-solid + - webpack + + '@tanstack/react-store@0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: - '@tanstack/store': 0.11.1 + '@tanstack/store': 0.11.2 react: 19.3.0 react-dom: 19.3.0(react@19.3.0) use-sync-external-store: 1.7.0(react@19.3.0) - '@tanstack/router-core@1.171.32': + '@tanstack/router-core@1.171.34': dependencies: '@tanstack/history': 1.162.4 cookie-es: 3.1.1 seroval: 1.6.7 seroval-plugins: 1.6.7(seroval@1.6.7) - '@tanstack/store@0.11.1': {} + '@tanstack/router-generator@1.167.40': + dependencies: + '@babel/types': 7.29.8 + '@tanstack/router-core': 1.171.34 + '@tanstack/router-utils': 1.162.3 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.9.9 + zod: 4.6.5 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(rolldown@1.2.12)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@tanstack/router-core': 1.171.34 + '@tanstack/router-generator': 1.167.40 + '@tanstack/router-utils': 1.162.3 + chokidar: 5.0.0 + unplugin: 3.4.0(rolldown@1.2.12)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + zod: 4.6.5 + optionalDependencies: + '@tanstack/react-router': 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - supports-color + - unloader + + '@tanstack/router-utils@1.162.3': + dependencies: + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + ansis: 4.4.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.4 + pathe: 2.0.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - supports-color + + '@tanstack/start-client-core@1.170.34': + dependencies: + '@tanstack/router-core': 1.171.34 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-storage-context': 1.167.36 + seroval: 1.6.7 + + '@tanstack/start-fn-stubs@1.162.0': {} + + '@tanstack/start-plugin-core@1.171.49(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + '@tanstack/router-core': 1.171.34 + '@tanstack/router-generator': 1.167.40 + '@tanstack/router-plugin': 1.168.42(@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(rolldown@1.2.12)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + '@tanstack/router-utils': 1.162.3 + '@tanstack/start-server-core': 1.169.39 + exsolve: 1.1.3 + lightningcss: 1.33.0 + pathe: 2.0.3 + picomatch: 4.0.7 + seroval: 1.6.7 + source-map: 0.7.6 + srvx: 0.11.22 + tinyglobby: 0.2.17 + ufo: 1.6.4 + vitefu: 1.1.3(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)) + xmlbuilder2: 4.0.3 + zod: 4.6.5 + optionalDependencies: + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - '@tanstack/react-router' + - bun-types-no-globals + - crossws + - esbuild + - rolldown + - rollup + - supports-color + - unloader + - vite-plugin-solid + - webpack + + '@tanstack/start-server-core@1.169.39': + dependencies: + '@tanstack/history': 1.162.4 + '@tanstack/router-core': 1.171.34 + '@tanstack/start-client-core': 1.170.34 + '@tanstack/start-storage-context': 1.167.36 + fetchdts: 0.1.7 + h3-v2: h3@2.0.1-rc.20 + seroval: 1.6.7 + transitivePeerDependencies: + - crossws + + '@tanstack/start-storage-context@1.167.36': + dependencies: + '@tanstack/router-core': 1.171.34 + + '@tanstack/store@0.11.2': {} + + '@tanstack/virtual-file-routes@1.162.0': {} '@types/jsdom@21.1.7': dependencies: @@ -809,8 +2152,46 @@ snapshots: '@types/use-sync-external-store@1.7.0': {} + '@vitejs/plugin-react@6.1.2(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + agent-base@7.1.4: {} + ansis@4.4.0: {} + + argparse@2.0.1: {} + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.11.27: {} + + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 + electron-to-chromium: 1.5.444 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.3) + + caniuse-lite@1.0.30001814: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + confbox@0.3.1: {} + + convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} cssstyle@4.6.0: @@ -831,8 +2212,40 @@ snapshots: decimal.js@10.6.0: {} + dedent@1.7.2: {} + + detect-libc@2.1.2: {} + + diff@8.0.4: {} + + electron-to-chromium@1.5.444: {} + entities@6.0.1: {} + es-module-lexer@2.3.2: {} + + escalade@3.2.0: {} + + exit-hook@5.1.0: {} + + exsolve@1.1.3: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fetchdts@0.1.7: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + h3@2.0.1-rc.20: + dependencies: + rou3: 0.8.1 + srvx: 0.11.22 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -861,6 +2274,14 @@ snapshots: isbot@5.2.2: {} + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsdom@26.1.0: dependencies: cssstyle: 4.6.0 @@ -888,12 +2309,79 @@ snapshots: - supports-color - utf-8-validate + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lodash@4.18.1: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + ms@2.1.3: {} + nanoid@3.3.20: {} + nanostores@1.5.4: {} + node-releases@2.0.57: {} + nwsapi@2.2.28: {} oxfmt@0.71.0: @@ -942,10 +2430,32 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.86.0 '@oxlint/binding-win32-x64-msvc': 1.86.0 + p-map@7.0.8: {} + parse5@7.3.0: dependencies: entities: 6.0.1 + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.3 + pathe: 2.0.3 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prettier@3.9.9: {} + punycode@2.3.1: {} react-dom@19.3.0(react@19.3.0): @@ -953,6 +2463,8 @@ snapshots: react: 19.3.0 scheduler: 0.28.0 + react-refresh@0.18.0: {} + react-router@8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: '@remix-run/route-pattern': 0.22.1 @@ -963,6 +2475,31 @@ snapshots: react@19.3.0: {} + readdirp@5.1.1: {} + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + rou3@0.8.1: {} + rrweb-cssom@0.8.0: {} safer-buffer@2.1.2: {} @@ -973,14 +2510,29 @@ snapshots: scheduler@0.28.0: {} + semver@6.3.1: {} + + semver@7.8.5: {} + seroval-plugins@1.6.7(seroval@1.6.7): dependencies: seroval: 1.6.7 seroval@1.6.7: {} + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + srvx@0.11.22: {} + symbol-tree@3.2.4: {} + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + tinypool@2.2.0: {} tldts-core@6.1.86: {} @@ -999,18 +2551,57 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + undici-types@6.21.0: {} + unplugin@3.4.0(rolldown@1.2.12)(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.12 + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + + update-browserslist-db@1.3.3(browserslist@4.29.3): + dependencies: + browserslist: 4.29.3 + escalade: 3.2.0 + picocolors: 1.1.1 + use-sync-external-store@1.7.0(react@19.3.0): dependencies: react: 19.3.0 + valibot@1.5.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.4 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitefu@1.1.3(vite@8.3.2(@types/node@22.20.4)(jiti@2.7.0)): + optionalDependencies: + vite: 8.3.2(@types/node@22.20.4)(jiti@2.7.0) + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 webidl-conversions@7.0.0: {} + webpack-virtual-modules@0.6.2: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -1026,6 +2617,17 @@ snapshots: xml-name-validator@5.0.0: {} + xmlbuilder2@4.0.3: + dependencies: + '@oozcitak/dom': 2.0.2 + '@oozcitak/infra': 2.0.2 + '@oozcitak/util': 10.0.0 + js-yaml: 4.3.2 + xmlchars@2.2.0: {} + yallist@3.1.1: {} + zod@4.4.3: {} + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ab5f198 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - examples/* From 3d08979e0b2add9af914a97f9b25e00353b21a6a Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 14:33:55 +0100 Subject: [PATCH 07/12] docs: rewrite the README around a runnable DummyJSON quick start - Replace the quick start with a TanStack Router walkthrough that signs in against DummyJSON, and link the matching example app for each other supported setup - Reorganize the reference into "Using the client" and "Schemas, refresh, and storage", with a peer dependency table, and drop the migration section - Update the version line to `0.1.0-alpha.4` and add a CHANGELOG entry --- CHANGELOG.md | 2 + README.md | 955 ++++++++++++++++++++------------------------------- 2 files changed, 368 insertions(+), 589 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd9851..61de910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,8 @@ `defaultSsr: false`, so guards run in the browser. - `webStorage()` explains when browser storage does not exist at all, as on a server, instead of reporting a bare `TypeError`. +- The README's quick start signs in against DummyJSON with TanStack Router, + and links to an example app for every other supported setup. - `examples/` holds one app per supported setup, built in CI, all signing in against DummyJSON's auth API. - Mounting restores the saved session at once when storage and the user diff --git a/README.md b/README.md index a273a7e..8a1bc4a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,27 @@ # @monarcode/session-kit -Schema-driven browser authentication for React, TanStack Router, and React Router. - -**Version: 0.1.0-alpha.3. Early development; not ready for production.** - -One auth client lives in Router context, and typed React hooks read it from -there, with no separate provider. Outside TanStack Router, the same hooks read -the client from an `AuthProvider`. Nano Stores makes its state reactive. Tokens -and the validated user profile are stored together in one entry. +Schema-driven browser sessions for React, with TanStack Router and React Router +support. + +**Version: 0.1.0-alpha.4. Early development; not ready for production.** + +session-kit keeps the tokens your backend issues and manages the session around +them. Your backend does the authentication; session-kit does the rest: + +- **Schema-driven users.** A [Standard Schema](https://standardschema.dev) + validates the user at sign-in, on refresh, on restore, and when another tab + saves one, and supplies its TypeScript types. Or read the user from a JWT + access token with `fromAccessToken`. +- **Refresh you can trust.** Early refresh before expiry, one refresh at a time + across tabs, recovery after a 401, and a clear split between "sign in again" + and "try again later". +- **Tabs that agree.** Sign-in, sign-out, account switches, profile updates, + and refreshes reach every open tab. +- **Router guards.** Redirect signed-out users with a return URL, keep guarded + pages in sync with the session, and re-run guards when auth changes. +- **Tokens stay out of reach.** Reactive state and sessions hold no tokens, so + they are safe to render and to put in route context. Tokens go only to code + that attaches them to requests, such as `createAuthFetch`. ## Installation @@ -17,41 +31,36 @@ Alpha releases are published to the `latest` distribution tag: pnpm add @monarcode/session-kit ``` -Pin `@monarcode/session-kit@0.1.0-alpha.3` to use this exact version. Alpha -releases may change the public API. See [release notes](./CHANGELOG.md). +Pin `@monarcode/session-kit@0.1.0-alpha.4` to use this exact version. Alpha +releases may change the public API; see the [release notes](./CHANGELOG.md). -All peer dependencies are optional, so install only what you use: React -`^18.0.0 || ^19.0.0` for the React entry points, TanStack React Router -`^1.127.0` for `/tanstack-router`, and React Router `^7.0.0 || ^8.0.0` for -`/react-router`. The package itself never imports -React DOM. CI checks both the oldest and the newest versions in those ranges. -This example uses Zod for its Standard Schema implementation: +All peer dependencies are optional, so install only what you use: -```sh -pnpm add react@19.3.0 react-dom@19.3.0 @tanstack/react-router@1.170.38 zod@4.4.3 -``` +| Peer | Range | Needed for | +| ------------------------ | ---------------------- | --------------------------------------------- | +| `react` | `^18.0.0 \|\| ^19.0.0` | `/react`, `/tanstack-router`, `/react-router` | +| `@tanstack/react-router` | `^1.127.0` | `/tanstack-router` | +| `react-router` | `^7.0.0 \|\| ^8.0.0` | `/react-router` | -Zod is optional: any compatible Standard Schema V1 implementation can describe -users. The package is ESM-only and sets no Node.js engine requirement, though its -Nano Stores dependency declares Node.js 20 or 22 and newer for server-side -rendering and test runners. It is checked with -TypeScript 6.0.3 under NodeNext and Bundler resolution. Older TypeScript versions -have not been verified. Auth needs `AbortController`; it uses -`crypto.randomUUID` and Web Locks when present, and falls back without them. +CI tests the oldest and newest versions in each range. session-kit never +imports React DOM. Any Standard Schema V1 library describes users; the +examples use Zod: -### Migrating from @monarcode/tanstack-auth +```sh +pnpm add react react-dom @tanstack/react-router zod +``` -Replace the dependency with `@monarcode/session-kit`, and -change import prefixes from `@monarcode/tanstack-auth` to -`@monarcode/session-kit`, then follow the breaking changes in the -[release notes](./CHANGELOG.md). TanStack Router and React Router are -supported; server rendering and Expo are planned for later development. +The package is ESM-only and checked with TypeScript 6.0.3 under NodeNext and +Bundler resolution. It needs `AbortController`, and uses `crypto.randomUUID` and +Web Locks when present, falling back without them. -## Quick start +## Quick start: TanStack Router -This example uses TanStack Router's file-based routing in a React browser app. -It assumes the app already has a `root` HTML element and the Router Vite plugin. -If file routing is not configured yet, add the plugin before the React plugin: +This example signs in against [DummyJSON's auth API](https://dummyjson.com/docs/auth), +a public test backend, so it runs as written. It is the +[`tanstack-router` example app](./examples/tanstack-router) trimmed to its +essentials. It uses file-based routing, so add the Router plugin before the +React plugin: ```ts // vite.config.ts @@ -68,104 +77,73 @@ export default defineConfig({ ``` The plugin generates `src/routeTree.gen.ts` from the files under `src/routes`. -Keep that generated file in the Router import below, and never edit it by hand. - -The example expects these same-origin backend endpoints. Supply them in your -application; the package does not implement a login server: - -| Endpoint | Request | Successful response | -| ------------------------ | -------------------------- | --------------------------------------------------- | -| `POST /api/auth/login` | JSON `{ email, password }` | `{ accessToken, refreshToken?, expiresIn?, user }` | -| `POST /api/auth/refresh` | JSON `{ refreshToken }` | `{ accessToken, refreshToken?, expiresIn?, user? }` | -`expiresIn` is the access token lifetime in **seconds**, as in OAuth -`expires_in`. In this example, a refresh 401 means terminal rejection; other -failures are operational. Adapt that mapping to your backend's contract. See -[Refresh and errors](#refresh-and-errors) for other ways to supply expiry. - -### 1. Define the user and client +### 1. Create the client `src/auth.ts` ```ts -import { z } from "zod"; import { createAuth, webStorage } from "@monarcode/session-kit"; +import { z } from "zod"; + +const API = "https://dummyjson.com"; export const userSchema = z.object({ - id: z.string(), + id: z.number(), + username: z.string(), email: z.string(), + firstName: z.string(), }); const tokensSchema = z.object({ - accessToken: z.string().min(1), - refreshToken: z.string().min(1).optional(), - expiresIn: z.number().positive().optional(), -}); -export const loginResponseSchema = tokensSchema.extend({ user: userSchema }); -const refreshResponseSchema = tokensSchema.extend({ - user: userSchema.optional(), + accessToken: z.string(), + refreshToken: z.string(), }); +// DummyJSON returns the user's fields beside the tokens. +const loginResponseSchema = tokensSchema.extend(userSchema.shape); export const auth = createAuth({ name: "my-app", user: userSchema, storage: webStorage(), refresh: async ({ refreshToken, signal }) => { - // Use plain fetch here to avoid recursive auth refresh. - const response = await fetch("/api/auth/refresh", { + const response = await fetch(`${API}/auth/refresh`, { method: "POST", signal, - redirect: "error", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); - if (response.status === 401) return null; + // Rejected refresh tokens mean "sign in again": DummyJSON answers 403 + // for an invalid one and 401 for a missing one. + if (response.status === 401 || response.status === 403) return null; + // Anything else is operational: keep the session and report the error. if (!response.ok) throw new Error(`Refresh failed (${response.status})`); - return refreshResponseSchema.parse(await response.json()); + return tokensSchema.parse(await response.json()); }, }); -export type AppAuth = typeof auth; -``` - -Create one active client per auth name in each tab. The `user` schema supplies -both runtime validation and inferred input/output types. Its output must be a -plain JSON object, saved beside the tokens and validated again when restored, so -the schema must accept its own output and return it unchanged. Sign-in rejects a -schema that does not, such as one with a one-way transform, instead of signing -users out on their next reload. Accepted user data is copied and frozen; use -`updateUser` to replace it. - -If your backend puts the user in a JWT access token instead, read it from there. -`signIn` and the refresh callback then take tokens only, the user changes when -the token does, and there is no `updateUser`; call `auth.refresh()` to pick up a -changed user: - -```ts -import { - createAuth, - fromAccessToken, - webStorage, -} from "@monarcode/session-kit"; - -export const auth = createAuth({ - name: "my-app", - user: fromAccessToken( - z.object({ sub: z.string(), email: z.string() }), - (claims) => ({ id: claims.sub, email: claims.email }), - ), - storage: webStorage(), -}); +export async function signInWithPassword(username: string, password: string) { + const response = await fetch(`${API}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!response.ok) throw new Error(`Sign-in failed (${response.status})`); + const { accessToken, refreshToken, ...user } = loginResponseSchema.parse( + await response.json(), + ); + await auth.signIn({ accessToken, refreshToken, user }); +} -await auth.signIn({ accessToken, refreshToken }); +export type AppAuth = typeof auth; ``` -The token is decoded without verifying its signature, which is fine in the -browser: a user object in a JSON response is trusted to the same degree. Leave -claims that change on every refresh, such as `exp` or `jti`, out of the user. OAuth -providers may change their access token format; this suits backends you control. +Create one client per auth `name` in each tab. DummyJSON's tokens are JWTs, so +expiry comes from their `exp` and `iat` claims; see +[Expiry](#expiry). Requests omit `credentials: "include"`: session-kit keeps +tokens out of cookies and attaches them itself. ### 2. Create the hooks @@ -182,13 +160,10 @@ export const { useAuth, useAuthClient } = createAuthHooks({ }); ``` -`useRouterAuth` reads the client from Router context, so these hooks need no -provider, and Router registration in step 7 gives them your user and client -types. `useAuth()` reads the full reactive snapshot; `useAuth(selector)` -subscribes to a selected value. `useAuthClient()` returns the stable client for -actions. Without TanStack Router, call `createAuthHooks()` and -render the `AuthProvider` it returns around your app with `client={auth}`; the -provider also mounts the client, starting its timers and tab sync. +`useRouterAuth` reads the client from Router context, so the hooks need no +provider, and Router registration in step 6 gives them your user type. +`useAuth()` returns the reactive state; `useAuth(selector)` re-renders only when +the selection changes. `useAuthClient()` returns the client for actions. ### 3. Type the root route @@ -197,55 +172,28 @@ provider also mounts the client, starting its timers and tab sync. ```tsx -import { - createRootRouteWithContext, - Outlet, - useRouter, -} from "@tanstack/react-router"; +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import type { AppAuth } from "../auth.js"; -import { useAuth, useAuthClient } from "../auth-hooks.js"; +import { useAuth } from "../auth-hooks.js"; export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({ component: Root, }); function Root() { + // Shows failures that outlive the page that caused them, such as a sign-out + // that could not clear storage. const error = useAuth((state) => state.error); - const auth = useAuthClient(); - const router = useRouter(); - return ( <> - {error && ( -
-

{error.message}

- {error.code === "PERSISTENCE_FAILED" && ( - - )} -
- )} + {error &&

{error.message}

} ); } ``` -The root route defines the Router context shared by every generated file route. -Keeping the error banner above `Outlet` makes cleanup failures visible after a -protected route unmounts. - -### 4. Add the login route +### 4. Add the sign-in route `src/routes/login.tsx` @@ -256,8 +204,7 @@ import { useState, type FormEvent } from "react"; import { createFileRoute, useRouter } from "@tanstack/react-router"; import { safeReturnTo } from "@monarcode/session-kit"; import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router"; -import { loginResponseSchema } from "../auth.js"; -import { useAuthClient } from "../auth-hooks.js"; +import { signInWithPassword } from "../auth.js"; export const Route = createFileRoute("/login")({ validateSearch: (search: Record) => ({ @@ -269,76 +216,59 @@ export const Route = createFileRoute("/login")({ }); function Login() { - const auth = useAuthClient(); const router = useRouter(); const { redirectTo } = Route.useSearch(); const [error, setError] = useState(""); - const [pending, setPending] = useState(false); async function submit(event: FormEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); - setPending(true); setError(""); try { - const response = await fetch("/api/auth/login", { - method: "POST", - redirect: "error", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: form.get("email"), - password: form.get("password"), - }), - }); - if (!response.ok) throw new Error(`Sign-in failed (${response.status})`); - await auth.signIn(loginResponseSchema.parse(await response.json())); - await router.navigate({ - href: safeReturnTo(redirectTo), - replace: true, - }); + await signInWithPassword( + String(form.get("username")), + String(form.get("password")), + ); + await router.navigate({ href: redirectTo, replace: true }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not sign in"); - } finally { - setPending(false); } } return (

Sign in

- - + {/* DummyJSON's documented test user. */} + + {error &&

{error}

} - +
); } ``` `safeReturnTo` accepts only same-origin paths and returns `/` for anything else, -including the sign-in page itself, so a stale `redirectTo` cannot loop. It -treats `/login`, `/login/`, and `/LOGIN` alike. If sign-in lives elsewhere, pass -its path, or several: `safeReturnTo(value, { loginPath: ["/sign-in", "/signup"] })`. -`redirectIfSignedIn` sends a signed-in user on to `redirectTo`, checked the same -way, and takes the same `loginPath` option. +including the sign-in page itself, so a stale `redirectTo` cannot loop. Pass +`{ loginPath }` if sign-in lives somewhere other than `/login`. +`redirectIfSignedIn` sends signed-in users on to `redirectTo`, checked the +same way. -### 5. Add a pathless protected layout +### 5. Guard private routes -The leading underscore makes `_authenticated.tsx` a pathless layout. Its child -routes inherit the guard without adding `authenticated` to the URL. - -`src/routes/_authenticated.tsx` +`src/routes/_authenticated.tsx` is a pathless layout: its child routes are +guarded without `authenticated` appearing in their URLs. @@ -347,12 +277,11 @@ import { createFileRoute, type ErrorComponentProps, } from "@tanstack/react-router"; -import { AuthError } from "@monarcode/session-kit"; import { requireSession, SessionOutlet, } from "@monarcode/session-kit/tanstack-router"; -import { useAuth, useAuthClient } from "../auth-hooks.js"; +import { useAuthClient } from "../auth-hooks.js"; export const Route = createFileRoute("/_authenticated")({ beforeLoad: ({ context, location }) => @@ -361,46 +290,28 @@ export const Route = createFileRoute("/_authenticated")({ component: () => Checking session…

} />, }); +/** Shown after a failed refresh; a repeated failure stays in state.error. */ function SessionError({ error }: ErrorComponentProps) { const auth = useAuthClient(); - const retrying = useAuth((state) => state.status === "refreshing"); - if (!(error instanceof AuthError) || error.code !== "REFRESH_FAILED") { - return

Something went wrong.

; - } return (
-

We couldn't reach the sign-in service.

- +

{error instanceof Error ? error.message : "Something went wrong"}

+
); } ``` `requireSession` redirects signed-out users to `loginPath` with `redirectTo` -set to where they were going, and puts `{ session }` in route context for child -routes: `session.user` is typed and never `null`, and a session holds no tokens. -If the session changes while it is checked, it checks again. A failed refresh -reaches the error component, whose Retry button calls `auth.retry()`. - -`SessionOutlet` renders the child routes while the session the guard accepted -is still the signed-in one. After a sign-out or another account signing in, it -shows `pending` until the guard runs again, and it remounts child routes for a -new account, so nothing from the previous account stays on screen. Profile -updates and token refreshes keep child routes mounted. - -### 6. Add a protected index route +set to where they were going, and puts `{ session }` in route context: child +routes get `session.user`, typed and never `null`. A failed refresh reaches the +error component instead of a redirect, because the user is still signed in. -Placing `index.tsx` inside the `_authenticated` directory makes `/` a child of -the protected layout. Add other private routes beside it, such as -`src/routes/_authenticated/settings.tsx`. +`SessionOutlet` renders child routes while the session the guard accepted is +still the signed-in one. After a sign-out or an account switch it shows +`pending` until the guard runs again, and remounts child routes for the new +account, so nothing from the previous one stays on screen. Profile updates and +token refreshes keep child routes mounted. `src/routes/_authenticated/index.tsx` @@ -417,17 +328,10 @@ export const Route = createFileRoute("/_authenticated/")({ function Home() { const user = useAuth((state) => state.user); const auth = useAuthClient(); - return (
-

Welcome, {user?.email}

-
@@ -435,14 +339,14 @@ function Home() { } ``` -### 7. Create and connect the Router +### 6. Create and connect the Router `src/router.tsx` ```tsx -import { createRouter, useRouter } from "@tanstack/react-router"; +import { createRouter } from "@tanstack/react-router"; import { connectAuth } from "@monarcode/session-kit/tanstack-router"; import { auth } from "./auth.js"; import { routeTree } from "./routeTree.gen.js"; @@ -450,27 +354,11 @@ import { routeTree } from "./routeTree.gen.js"; export const router = createRouter({ routeTree, context: { auth }, - defaultPreload: "intent", - defaultPreloadStaleTime: 0, defaultPendingComponent: () =>

Loading…

, - defaultErrorComponent: ({ error }) => , }); -function RouteError({ error }: { error: unknown }) { - const router = useRouter(); - return ( -
-

- {error instanceof Error ? error.message : "Could not load this page"} -

- -
- ); -} - -// Call once beside the stable Router, outside component rendering. +// Restores the session, runs expiry timers and tab sync, and re-runs guards +// when auth changes. Call it once, beside the Router. export const disconnectAuth = connectAuth(router); declare module "@tanstack/react-router" { @@ -480,29 +368,11 @@ declare module "@tanstack/react-router" { } ``` -`Register` gives `useRouterAuth`, and through it your hooks, the -application's user and client types. Keep it in the consuming app. - -Outside React, `auth.state.get()` returns the current snapshot and -`auth.state.subscribe(listener)` reports later changes, not the current value; it -returns `{ unsubscribe }`. A listener that throws does not interrupt auth: its -error is rethrown asynchronously, where error reporting can capture it. -Checking `state.status` narrows `state.user`: it is set exactly while -`authenticated` or `refreshing`. - -`auth.getSession()` resolves who is signed in, `{ sessionId, user }`, and never -holds tokens. Code that attaches tokens to requests uses `auth.credentials`: -`get()` resolves `{ sessionId, accessToken, expiresAt }`, `renew()` recovers -after a 401, and `reject()` ends a session the server keeps refusing. -`createAuthFetch` does this for you. `auth.refresh()` refreshes immediately, -even while the access token is usable. - -`connectAuth` initializes auth, starts expiry timers, clears inactive Router -cache entries on guard-relevant changes, and queues Router revalidation. Call -`disconnectAuth` during app teardown or your bundler's hot-module disposal. -Disconnecting stops background work without signing out. +With synchronous storage and a synchronous schema, `connectAuth` restores the +saved session before the first render. Call `disconnectAuth` on app teardown or +hot-module disposal; it stops background work without signing out. -### 8. Render the app +### 7. Render the app `src/main.tsx` @@ -512,387 +382,294 @@ Disconnecting stops background work without signing out. import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "@tanstack/react-router"; -import { router, disconnectAuth } from "./router.js"; +import { router } from "./router.js"; -const root = createRoot(document.getElementById("root")!); -root.render( +createRoot(document.getElementById("root")!).render( , ); - -export function dispose() { - root.unmount(); - disconnectAuth(); -} ``` -## React Router - -`@monarcode/session-kit/react-router` supports React Router 7 and 8 in -declarative mode, data mode, and framework mode with `ssr: false`. Create -hooks for your client and render `AuthProvider` around the router; it mounts -the client, so expiry timers and tab sync run while the app is rendered. +## Other setups -`src/react-router/hooks.ts` +The same client works everywhere; only the router wiring changes. Each setup has +a complete app in [`examples/`](./examples), all signing in against DummyJSON. - +| Setup | Example app | Guards | +| ------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| TanStack Router | [`tanstack-router`](./examples/tanstack-router) | `requireSession` in `beforeLoad`, `SessionOutlet` | +| TanStack Start, SPA mode | [`tanstack-start-spa`](./examples/tanstack-start-spa) | Same as TanStack Router | +| React Router, declarative mode | [`react-router-declarative`](./examples/react-router-declarative) | `SessionOutlet` alone | +| React Router, data mode | [`react-router-data`](./examples/react-router-data) | `requireSession` in loaders, `SessionOutlet`, `useAuthRevalidation` | +| React Router, framework mode, `ssr: false` | [`react-router-framework-spa`](./examples/react-router-framework-spa) | `requireSession` in `clientLoader`, `SessionOutlet`, `useAuthRevalidation` | -```ts -import { createAuthHooks } from "@monarcode/session-kit/react"; -import type { AppAuth } from "../auth.js"; - -export const { AuthProvider, useAuth, useAuthClient } = - createAuthHooks(); -``` +**TanStack Start in SPA mode.** Create the client and call `connectAuth` inside +`getRouter()`. Set `defaultSsr: false` in `src/start.ts`: Start's own server +still renders requests in SPA mode, and guards need the browser's saved +session. `connectAuth` does nothing while the Router renders on a server, so +the build-time shell shows your pending component. -In declarative mode, `SessionOutlet` is the guard. It renders child routes -while someone is signed in, `pending` while the saved session is restored, and -sends signed-out users to `loginPath` with `?redirectTo=` set to where they -were going. Child routes are keyed by session, so nothing from one account -stays mounted for the next. +**React Router.** Import from `@monarcode/session-kit/react-router`, and create +hooks with a provider: `createAuthHooks()` returns `AuthProvider`, +`useAuth`, and `useAuthClient`. Render `` around +the router; it mounts the client in a layout effect, so a synchronous restore +happens before the first paint, and never on a server. -`src/react-router/declarative.tsx` +- **Declarative mode**: `` is the guard. It + shows `pending` while restoring, sends signed-out users to `loginPath` with + `redirectTo`, and renders `unavailable({ error, retry })` after a failed + refresh, or throws to the nearest error boundary without it. +- **Data and framework mode**: `requireSession(auth, { request, loginPath })` + guards loaders and returns `{ session }`, and + `redirectIfSignedIn(auth, { redirectTo })` guards the sign-in loader. Call + `useAuthRevalidation()` once in the root layout so loaders re-run when auth + changes. - +**Without a router.** `createAuthHooks()` and `AuthProvider` work +in any React app. -```tsx -import { BrowserRouter, Route, Routes } from "react-router"; -import { SessionOutlet } from "@monarcode/session-kit/react-router"; -import { auth } from "../auth.js"; -import { AuthProvider, useAuth } from "./hooks.js"; +## Using the client -function Home() { - const email = useAuth((state) => state.user?.email); - return

Welcome, {email}

; -} +### State -export function App() { - return ( - - - - Sign in here

} /> - Checking session…

} - unavailable={({ retry }) => ( - - )} - /> - } - > - } /> - -
-
-
- ); -} +```ts +const state = auth.state.get(); +const { unsubscribe } = auth.state.subscribe((next) => {}); ``` -In data mode, guard routes in their loaders with `requireSession`, which -redirects signed-out users the same way and returns `{ session }`. Call -`useAuthRevalidation()` once in the root layout so loaders re-run after a -sign-in, sign-out, account switch, or profile update. `redirectIfSignedIn` -keeps signed-in users off the sign-in page. A failed refresh reaches the -route's error element, which can call `auth.retry()`. Framework mode with -`ssr: false` uses the same functions in `clientLoader`. +`subscribe` reports later changes, not the current value. A listener that +throws does not interrupt auth; its error is rethrown asynchronously, where +error reporting can catch it. Checking `status` narrows `user`: + +| `status` | Meaning | `user` | +| ----------------- | ------------------------------------------------------------------------- | ------ | +| `initializing` | The saved session is being restored. | `null` | +| `authenticated` | Access is usable. | Set | +| `refreshing` | A refresh is replacing expired or rejected access. | Set | +| `unauthenticated` | No session; sign-in is needed. | `null` | +| `unavailable` | Restoring failed, or a failed refresh left no usable access; see `error`. | `null` | + +`state.sessionId` is new on every sign-in and stable across refreshes and +profile updates; scope private cached data, such as TanStack Query results, to +it. `state.version` changes when guards must run again, which is what +`connectAuth` and `useAuthRevalidation` watch. `state.error` holds the latest +failure. + +### Sessions and credentials + +- `auth.getSession()` resolves who is signed in, `{ sessionId, user }`, or + `null`, restoring or refreshing first if needed. It never holds tokens. +- `auth.credentials.get()` resolves `{ sessionId, accessToken, expiresAt }` for + code that attaches tokens to requests. `credentials.renew(credentials)` + recovers after a 401, and `credentials.reject(credentials)` ends a session + the server keeps refusing. +- `auth.isCurrent({ sessionId })` tells whether a session is still the current + one. + +### Signing in and out + +- `auth.signIn({ accessToken, refreshToken?, expiresIn?, user })` validates and + saves a new session, replacing any current one. The latest call wins, and + invalid input keeps the previous session. +- `auth.signOut()` ends the session in every tab and clears storage. The + optional `revoke` callback then receives the ended tokens, so your backend can + revoke them; a failed revocation is reported as `REVOKE_FAILED` without + undoing the sign-out. +- `auth.refresh()` refreshes now, even while the access token is usable. + `auth.retry()` tries again after a failed refresh. + +### Profile updates and authenticated requests + +`updateUser` replaces the user and keeps the session. Pass a function to fetch +the new user: if the session changes meanwhile, the result is discarded instead +of landing on another account. `createAuthFetch` attaches the access token: -`src/react-router/data.tsx` - - - -```tsx -import { createBrowserRouter, Outlet, RouterProvider } from "react-router"; -import { - redirectIfSignedIn, - requireSession, - SessionOutlet, - useAuthRevalidation, -} from "@monarcode/session-kit/react-router"; -import { auth } from "../auth.js"; -import { AuthProvider } from "./hooks.js"; + -function Root() { - useAuthRevalidation(); - return ; -} +```ts +import { createAuthFetch } from "@monarcode/session-kit/http"; +import { auth, userSchema } from "./auth.js"; -const router = createBrowserRouter([ - { - element: , - children: [ - { - path: "/login", - loader: async ({ request }) => { - const redirectTo = new URL(request.url).searchParams.get( - "redirectTo", - ); - await redirectIfSignedIn(auth, { redirectTo }); - return null; - }, - element:

Sign in here

, - }, - { - loader: ({ request }) => - requireSession(auth, { request, loginPath: "/login" }), - element: , - children: [{ index: true, element:

Home

}], - }, - ], - }, -]); +const authFetch = createAuthFetch(auth, "https://dummyjson.com/"); -export function App() { - return ( - - - - ); +export async function reloadProfile() { + await auth.updateUser(async () => { + const response = await authFetch("auth/me"); + if (!response.ok) throw new Error(`Profile failed (${response.status})`); + return userSchema.parse(await response.json()); + }); } ``` -## Profile updates and authenticated requests +`createAuthFetch` restricts requests to the configured origin and rejects +redirects. After a 401 it refreshes, then replays GET and HEAD once; other +methods are never replayed. A second 401 ends that session, unless you pass +`{ signOutOnRepeated401: false }` for APIs that answer 401 for other reasons. -`updateUser` replaces the complete profile. For an asynchronous update, use its -callback form so a response from an old session cannot overwrite a new account. -This optional example assumes `PATCH /api/profile` returns the complete user: +### Users from the access token - +If your backend puts the user in a JWT access token, read it from there. +`signIn` and the refresh callback then take tokens only, the user changes +whenever the token does, and there is no `updateUser`; call `auth.refresh()` to +pick up a changed user: ```ts -import { createAuthFetch } from "@monarcode/session-kit/http"; -import { auth, userSchema } from "./auth.js"; +const auth = createAuth({ + name: "my-app", + user: fromAccessToken( + z.object({ sub: z.string(), email: z.string() }), + (claims) => ({ id: claims.sub, email: claims.email }), + ), + storage: webStorage(), +}); -export async function updateEmail(email: string) { - await auth.updateUser(async () => { - const request = createAuthFetch(auth, `${location.origin}/api/`); - const response = await request("profile", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), - }); - if (!response.ok) - throw new Error(`Profile update failed (${response.status})`); - return userSchema.parse(await response.json()); - }); -} +await auth.signIn({ accessToken, refreshToken }); ``` -Handle the returned promise in your UI. Account switching uses `signIn`, which -creates a new session ID. A profile update keeps the current session ID. +The token is decoded without verifying its signature, which suits a browser: a +user object in a JSON response is trusted to the same degree. Pass `{ decode }` +to verify or decode it yourself. Leave claims that change on every refresh, +such as `exp` or `jti`, out of the user. OAuth providers may change their +access token format, so this suits backends you control. The +[`tanstack-start-spa`](./examples/tanstack-start-spa) and +[`react-router-data`](./examples/react-router-data) examples use this source. -`createAuthFetch` attaches a Bearer token, restricts requests to the configured -origin, and rejects redirects. On a 401 it attempts refresh. GET and HEAD are -replayed at most once; mutations are never automatically replayed. A second 401 -ends only the matching session. If some endpoints answer 401 for reasons other -than a rejected token, pass `{ signOutOnRepeated401: false }` as the third -argument so they return the 401 without signing out. Network or refresh errors -reject the request. +## Schemas, refresh, and storage -## Refresh and errors +### User schemas -| Refresh callback outcome | Meaning | -| ------------------------ | ---------------------------------------------------------------------------------- | -| Return tokens | Install validated tokens; omitted refresh token or user retains the current value. | -| Return `null` | Terminal rejection: sign out and clear persistence. | -| Throw | Operational failure: retain recoverable credentials and expose the error. | +The user schema's output must be a plain JSON object. It is saved beside the +tokens and validated again when restored, so the schema must accept its own +output and return it unchanged; sign-in rejects one that does not, such as a +one-way transform, instead of signing users out on their next reload. Accepted +users are copied and frozen. A user another tab saved that this tab's schema +rejects signs every tab out with `USER_VALIDATION_FAILED`, as restoring it +would. -A failed proactive refresh leaves access usable until its expiry. Expired or -explicitly rejected access is `refreshing` while a refresh runs: `user` stays -visible and guards wait for the outcome without re-running. A token-only refresh -does not change `version`. +### Refresh -If the refresh fails, access becomes `unavailable` and guards run once more. -Until `auth.retry()`, a sign-in, or a sign-out, `getSession()` rethrows that -`REFRESH_FAILED` error without contacting the backend, so the guard re-run cannot -start another refresh. Guards should let the error reach an error component -instead of turning it into a login redirect; the protected layout example offers -a Retry button that calls `auth.retry()`. +| The refresh callback… | Meaning | +| --------------------- | ------------------------------------------------------------------------------------- | +| Returns tokens | Install them. An omitted refresh token or user keeps the current one. | +| Returns `null` | The backend rejected the refresh token: sign out and clear storage. | +| Throws | Operational failure: keep the session, report `REFRESH_FAILED`, and wait for a retry. | -To treat every refresh failure as a sign-out instead, return `null` from the -refresh callback rather than throwing: +While connected, the early refresh runs at the earlier of 60 seconds before +expiry or halfway through the token's lifetime, once per token. If it fails, +access stays usable until expiry. Expired or rejected access shows +`refreshing`, with the user still visible, while a refresh runs. If that +refresh fails, access becomes `unavailable` and `getSession()` rethrows the +failure until `auth.retry()`, a sign-in, or a sign-out, so re-running guards +cannot start a refresh loop. Validation and refresh have a 15-second deadline. -```ts -refresh: async ({ refreshToken, signal }) => { - try { - return await requestRefresh(refreshToken, signal); - } catch { - return null; - } -}, -``` +To treat every refresh failure as a sign-out, return `null` instead of +throwing. -Expiry comes from the first of these that the tokens provide: +### Expiry + +Expiry comes from the first of these the tokens provide: 1. `expiresAt`: Unix time in milliseconds, compared with the browser clock. -2. `expiresIn`: lifetime in seconds, counted from when auth receives the tokens. +2. `expiresIn`: lifetime in seconds, as in OAuth `expires_in`, counted from + receipt. 3. A JWT's `exp - iat` lifetime, counted from receipt. 4. A JWT's `exp` alone, compared with the browser clock. -Browser clocks can be minutes off. Options 2 and 3 are unaffected; with 1 and 4, -a fast clock can make fresh tokens look expired and block sign-in. Options 2 and -3 assume newly issued tokens. `Credentials.expiresAt` is always on the browser -clock, and restored sessions keep their saved expiry. JWT decoding supplies only -a scheduling hint, without verifying signatures. Opaque tokens without expiry -cannot be proactively refreshed. While connected, refresh is scheduled at the -earlier of 60 seconds before expiry or halfway through the remaining lifetime. -There is one proactive attempt per installed token, with no recurring retry loop. -An attempt cancelled by unmounting or by a new sign-in does not count. -Validation and refresh work have a 15-second deadline. - -| State status | Meaning | -| ----------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `initializing` | Restoration has not completed. With synchronous storage and schemas, mounting restores at once, so this state is never rendered. | -| `authenticated` | Access is locally usable; `user` is available. | -| `refreshing` | A refresh is replacing expired or rejected access; `user` is available. | -| `unauthenticated` | No session; sign-in is needed. | -| `unavailable` | Restoration failed or current credentials cannot supply usable access. | - -`state.error` is an `AuthError` or `null`; user data is hidden outside the -`authenticated` and `refreshing` states. Error codes are `USER_VALIDATION_FAILED`, `INVALID_SESSION`, -`PERSISTENCE_FAILED`, `REFRESH_FAILED`, `REVOKE_FAILED`, `SESSION_CHANGED`, and -`UNAUTHENTICATED`. -Validation errors can include `issues`; underlying failures can appear in `cause`. -Application callbacks can also throw ordinary errors. - -A storage write failure clears the in-memory session and attempts cleanup. -Sign-out takes effect in the current tab even if storage deletion fails; its -promise rejects and the error remains visible. Retry `signOut()` after storage -becomes available, because undeleted credentials can survive a reload. -If restoration rejects saved data and cleanup fails, `getSession()` rejects -with `PERSISTENCE_FAILED` and `state.error` exposes the same error. Its `cause` -retains both the restoration and cleanup failures. Retry `signOut()` to clear -the remaining data after storage becomes available. - -## Storage and limits - -- Tokens and the profile share one `:auth:session` entry, so every save - is a single write that other tabs see whole. A user read from the access token - is not saved; restoring reads it again. Sessions saved by earlier alphas are - removed, not restored; users sign in once after upgrading. Tokens are never - placed in cookies, so the browser does not send them automatically; - `createAuthFetch` attaches the access token explicitly. -- The required `storage` option chooses where that entry lives. - `webStorage()` uses `localStorage`; `webStorage({ area: "session" })` keeps - a separate session per tab in `sessionStorage`, ending with the tab; and - `memoryStorage()` never persists it. Any object implementing `AuthStorage` - works, including storage whose operations return promises: auth applies - them one at a time, in the order it issued them. -- `maxAge` is in seconds, defaults to 30 days, and renews on successful writes. - Expired entries are removed on the next restoration. It does not extend backend - token validity. A full storage quota fails visibly with `PERSISTENCE_FAILED`. -- Tokens and profiles are readable by same-origin JavaScript. The backend must - independently authenticate requests and authorize private operations. Local - profile fields and route guards do not establish server authorization. -- Sign-out clears local credentials, then calls the optional `revoke` callback - with the ended tokens so your backend can revoke them. A failed revocation - does not undo the sign-out: `signOut()` still resolves and `state.error` - reports `REVOKE_FAILED`. Sessions the backend already rejected are not revoked. -- Tabs coordinate refresh through the `:auth:refresh` Web Lock: one tab at - a time spends the refresh token, and a tab that needs a refresh first uses - tokens another tab already saved. Rotating refresh tokens therefore work with - several tabs open. Profile updates keep tokens another tab refreshed, and a - refresh or rejection never overwrites or clears an account another tab signed - in; this tab switches to whatever storage holds instead. -- Browsers without Web Locks still check storage before refreshing and before - signing out, but two tabs refreshing at the same instant can both spend the - token. A short refresh-token reuse window on the backend covers that case and - a refresh response lost to a timeout. -- While connected through `connectAuth`, tabs update each other live through - `storage` events. A sign-out in one tab signs out the others, a sign-in or - account switch switches them (with a new `sessionId`), and profile updates - appear everywhere. Each of these changes `version` once, so guards rerun. - A refresh elsewhere replaces this tab's tokens without changing `version`, - and clears a refresh failure this tab was showing. An account change cancels - this tab's pending auth work with `SESSION_CHANGED`. A user another tab saved - that this tab's schema rejects signs every tab out with - `USER_VALIDATION_FAILED`, as restoring it would. Scope private Query data - to `sessionId`, so another tab's account switch cannot show the old account's - data. -- Query cache management belongs to your app. Scope private data to the sign-in - session ID, and reject late results from an old session. Router revalidation - does not clear a separate TanStack Query cache. -- Server rendering is not implemented; sessions live in the browser. TanStack - Start works in SPA mode with `defaultSsr: false` in `src/start.ts`: Start's - server still renders requests in SPA mode, and guards need the browser's - saved session. `connectAuth` does nothing while the Router renders on a - server, such as Start's build-time shell, which then shows your pending UI. - React Router framework mode works with `ssr: false`. Never put tokens in - route loader results or hydrated page data. - -## Examples - -[`examples/`](./examples) has one small app per supported setup: TanStack -Router, TanStack Start in SPA mode, and React Router in declarative, data, and -framework mode. All sign in against DummyJSON's auth API. `pnpm run -build:examples` builds the package and every app. +Browser clocks can be minutes off; options 2 and 3 are unaffected by that. +Opaque tokens without expiry are refreshed only after a 401. + +### Storage + +The required `storage` option chooses where the session is saved: + +- `webStorage()` uses `localStorage`, shared by every tab. +- `webStorage({ area: "session" })` uses `sessionStorage`: one session per tab, + ending with the tab. +- `memoryStorage()` never persists the session. +- Any object implementing `AuthStorage` works, including one whose operations + return promises, such as a native secure store. Auth applies operations one + at a time, in the order it issued them. + +Tokens and the user share one `:auth:session` entry, so every save is a +single write other tabs see whole; a user read from the access token is not +saved. `maxAge` (seconds, default 30 days, renewed on each save) bounds how +long a saved session is restored. Sessions saved by earlier alphas are not +restored, so users sign in once after upgrading. + +### Tabs + +While connected, tabs follow each other through `storage` events: a sign-out +signs out every tab, a sign-in or account switch switches them, and profile +updates and refreshes appear everywhere. Refresh runs under the +`:auth:refresh` Web Lock, so one tab at a time spends the refresh token +and the others reuse what it saved; rotating refresh tokens work with several +tabs open. Without Web Locks, two tabs refreshing at the same instant can both +spend the token; a short reuse window on the backend covers that. + +### Errors + +`state.error` and rejected promises carry an `AuthError` whose `code` is one of +`USER_VALIDATION_FAILED`, `INVALID_SESSION`, `PERSISTENCE_FAILED`, +`REFRESH_FAILED`, `REVOKE_FAILED`, `SESSION_CHANGED`, or `UNAUTHENTICATED`. +Validation errors include the schema's `issues`, and underlying failures appear +in `cause`. Work superseded by a newer sign-in or sign-out rejects with +`SESSION_CHANGED`. + +If storage cannot be written, the session ends and the error is published. If +it cannot be cleared, sign-out still takes effect in this tab, but the promise +rejects and the error stays visible: retry `signOut()` once storage is +available, because undeleted credentials could survive a reload. + +## Security notes + +- Tokens and users are readable by same-origin JavaScript, as with any + browser-stored credential. Your backend must authenticate every request and + authorize private operations; route guards and local user fields do not. +- Tokens never go in cookies, so browsers do not send them automatically. +- Server rendering is not supported yet: sessions live in the browser. Never put + tokens in loader results or hydrated page data. ## Entry points -| Import | Exports | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `@monarcode/session-kit` | `createAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `safeReturnTo`, `AuthError`, public auth types | -| `@monarcode/session-kit/react` | `createAuthHooks` and hook types | -| `@monarcode/session-kit/tanstack-router` | `connectAuth`, `requireSession`, `redirectIfSignedIn`, `SessionOutlet`, `useRouterAuth`, `RegisteredAuth` | -| `@monarcode/session-kit/react-router` | `SessionOutlet`, `requireSession`, `redirectIfSignedIn`, `useAuthRevalidation` | -| `@monarcode/session-kit/http` | `createAuthFetch` | +| Import | Exports | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `@monarcode/session-kit` | `createAuth`, `fromAccessToken`, `webStorage`, `memoryStorage`, `safeReturnTo`, `AuthError`, types | +| `@monarcode/session-kit/react` | `createAuthHooks`, hook types | +| `@monarcode/session-kit/tanstack-router` | `connectAuth`, `requireSession`, `redirectIfSignedIn`, `SessionOutlet`, `useRouterAuth`, `RegisteredAuth` | +| `@monarcode/session-kit/react-router` | `SessionOutlet`, `requireSession`, `redirectIfSignedIn`, `useAuthRevalidation` | +| `@monarcode/session-kit/http` | `createAuthFetch` | -Generated declarations retain schema inference and consumer Router registration. -Declaration maps are disabled so declaration navigation targets installed `.d.ts` -files. Do not import internal `dist` paths. +Do not import internal `dist` paths. ## Development -Use Node.js 24 or newer and pnpm. `devEngines` enforces the Node.js version for -contributors only; it does not affect installing the package: +Use Node.js 24 or newer and pnpm. The repository is a pnpm workspace: the +package at the root, and the example apps in `examples/`. ```sh pnpm install pnpm run check ``` -`check` runs Oxlint and Oxfmt checks, builds the package, runs the auth behavior, -React hook, package smoke, and release validation tests, then type-checks the -tests, consumer declarations, and every marked TypeScript example in this README -under NodeNext and Bundler resolution. The docs -checker supplies the generated route tree that a consumer's Router plugin owns. -After building, `pnpm test` runs the runtime tests twice: `test:sync` with the -default storage, and `test:async` with storage that settles every operation -late, removals last. `pnpm run test:docs` checks -README examples alone. The documentation checker extracts the examples into a -temporary project; it does not execute them or contact the example backend. - -`pnpm install` activates Husky locally. Before each commit, the hook runs -`pnpm run lint` and `pnpm run format:check` across the repository. These checks -do not modify files; lint errors or formatting differences block the commit, -while lint warnings remain non-blocking. Run `pnpm run lint:fix` to apply safe -lint fixes and `pnpm run format` to format eligible files. Both tools exclude -generated files and build output using their committed configs. -CI and release workflows disable Husky and enforce these checks through -`pnpm run check`. - -Tests are TypeScript files that Node runs directly, without a build step. Behavior -tests are split by area (sessions, storage, refresh, expiry, Router, HTTP, -return URLs, and tabs) and share the browser mocks in `tests/helpers.ts`. They use mocked -storage, HTTP, and timers, plus a real Router with memory history. The -`development` condition enables its client redirect test. -Hook tests mount React DOM in jsdom, including StrictMode subscription cleanup. -These tests do not cover a live backend or a real browser. `pnpm pack` runs the -package checks before creating a local tarball; it does not publish to npm. -GitHub Actions installs from the frozen pnpm lockfile, runs the same checks, and -creates a local tarball in the runner's temporary directory on pushes to `main` -and pull requests. The CI workflow does not publish or create a release. -The separate manual alpha release workflow is documented in -[RELEASING.md](https://github.com/monarcode/session-kit/blob/main/RELEASING.md). +`check` runs Oxlint and Oxfmt, builds the package, runs the tests, type-checks +the tests, the consumer declarations, and every TypeScript example in this +README marked with a `file:` comment, under NodeNext and Bundler resolution. +`pnpm test` runs the runtime tests twice: with synchronous storage, and with +storage that settles every operation late, removals last, to catch ordering +bugs. `pnpm run build:examples` builds the package and every example app. + +Tests are TypeScript files that Node runs directly. Behavior tests are split by +area and share the browser mocks in `tests/helpers.ts`; React tests mount real +React DOM, TanStack Router, and React Router in jsdom. CI also runs the tests +and type tests against the oldest supported peers, and builds the examples. +Before each commit, Husky runs the lint and format checks. The manual alpha +release workflow is documented in [RELEASING.md](./RELEASING.md). ### Try a local build -Run `pnpm pack` in this repository, then install the resulting tarball in your -app. Replace `` with the version in `package.json`: +Run `pnpm pack`, then install the tarball in your app, replacing `` +with the version in `package.json`: ```sh pnpm add /absolute/path/to/monarcode-session-kit-.tgz From ff82cd14c73db06c08c285f3e8b4f948223afafa Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 16:43:18 +0100 Subject: [PATCH 08/12] fix(security): reject return paths that resolve to another origin safeReturnTo turned "/.//evil.com" and "/%2e//evil.com" into "//evil.com". Check the parsed path, not the raw input. Bump to 0.1.0-alpha.4. --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- src/core/return-to.ts | 3 +++ tests/react-router.test.ts | 8 ++++---- tests/return-to.test.ts | 15 +++++++++++++++ tests/router.test.ts | 6 +++++- 6 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61de910..489ab38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 0.1.0-alpha.4 +### Security + +- `safeReturnTo` no longer returns paths whose dot segments resolve to + another origin. Earlier versions turned inputs such as `/.//evil.com` and + `/%2e//evil.com` into `//evil.com`, which browsers and routers follow to + `evil.com`. Apps that pass `safeReturnTo`'s result to a redirect on alpha.3 + or earlier should upgrade. The new `redirectIfSignedIn` guards use the fixed + check. + ### Added - `createAuth` takes a `storage` adapter. `webStorage()` saves the session in diff --git a/package.json b/package.json index 200c1bb..bfd44e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/session-kit", - "version": "0.1.0-alpha.3", + "version": "0.1.0-alpha.4", "description": "Schema-driven browser sessions for React, with TanStack Router and React Router support", "keywords": [ "auth", diff --git a/src/core/return-to.ts b/src/core/return-to.ts index 3dd0e5f..15294b4 100644 --- a/src/core/return-to.ts +++ b/src/core/return-to.ts @@ -36,6 +36,9 @@ export function safeReturnTo( const key = routeKey(url.pathname); if ( url.origin !== base || + // Dot segments can resolve to `//host`, which browsers and routers + // read as another origin: `/.//evil.com` becomes `//evil.com`. + url.pathname.startsWith("//") || loginPaths.some((path) => routeKey(path) === key) ) return "/"; diff --git a/tests/react-router.test.ts b/tests/react-router.test.ts index 6da4c30..0a319e8 100644 --- a/tests/react-router.test.ts +++ b/tests/react-router.test.ts @@ -323,13 +323,13 @@ test("loader guards redirect with the requested URL and never leave the site", a loginPath: "/login", }); assert.deepEqual(session, await auth.getSession()); - const away = await binding - .redirectIfSignedIn(auth, { redirectTo: "https://evil.example/" }) - .then( + for (const redirectTo of ["https://evil.example/", "/.//evil.example"]) { + const away = await binding.redirectIfSignedIn(auth, { redirectTo }).then( () => assert.fail("Expected a redirect"), (response: Response) => response, ); - assert.equal(away.headers.get("Location"), "/"); + assert.equal(away.headers.get("Location"), "/", redirectTo); + } await assert.rejects( binding.requireSession(auth, { request, loginPath: "login" }), /loginPath must start with/, diff --git a/tests/return-to.test.ts b/tests/return-to.test.ts index f6d14b0..20cc7a3 100644 --- a/tests/return-to.test.ts +++ b/tests/return-to.test.ts @@ -23,6 +23,21 @@ test("return URL rejects external origins, backslashes, controls, and login loop ); }); +test("return URL rejects paths whose dot segments resolve to another origin", () => { + for (const value of [ + "/.//evil.com", + "/a/..//evil.com", + "/a/b/../..//evil.com", + "/%2e//evil.com", + "/%2E%2E//evil.com", + "/a/%2e%2e//evil.com?x=1#y", + "/.///evil.com", + ]) + assert.equal(safeReturnTo(value), "/", value); + assert.equal(safeReturnTo("/a/../b?x=1"), "/b?x=1"); + assert.equal(safeReturnTo("/a/.//b"), "/a//b"); +}); + test("return URL treats login path variants and custom login paths as loops", () => { for (const value of [ "/login/", diff --git a/tests/router.test.ts b/tests/router.test.ts index 2be1613..bef28a0 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -327,7 +327,11 @@ test("requireSession puts the session, without tokens, in route context", async test("redirectIfSignedIn never returns to sign-in or leaves the site", async () => { const auth = client(); await auth.signIn(input()); - for (const redirectTo of ["https://evil.example/", "/login"]) { + for (const redirectTo of [ + "https://evil.example/", + "/.//evil.example", + "/login", + ]) { const router = await guardedRouter( auth, `/login?redirectTo=${encodeURIComponent(redirectTo)}`, From e49eb9d034655d2167e2c9ce92013ca0f7cb1328 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 17:21:03 +0100 Subject: [PATCH 09/12] fix(core): keep restores and refreshes alive across unmount and failed sign-ins - Stop cancelling a restore or refresh in progress on unmount, so a StrictMode remount joins it. This fixes expired sessions flashing `unavailable`, loaders rejecting with `SESSION_CHANGED`, and a rotated refresh token being sent twice - Track sign-in cancellation separately, so a failed `signIn` no longer aborts a restore or refresh, and only a newer `signIn` or `signOut()` cancels one still validating - Make route guards read the session again when `getSession()` rejects with `SESSION_CHANGED` - Move the deferred-storage test helpers into `tests/deferred-storage.ts`, and add regression tests for StrictMode, sign-in cancellation and guards --- CHANGELOG.md | 14 +++++++ src/core/client.ts | 55 ++++++++++++++++--------- src/core/guard.ts | 14 +++++-- tests/deferred-storage.ts | 38 +++++++++++++++++ tests/dom.ts | 47 ++++++++++++++++++--- tests/expiry.test.ts | 54 +++++++++++++++++++++---- tests/helpers.ts | 41 +++---------------- tests/react-router.test.ts | 82 +++++++++++++++++++++++++++++++++++++ tests/router.test.ts | 23 +++++++++++ tests/session.test.ts | 83 +++++++++++++++++++++++++++++++++++--- 10 files changed, 374 insertions(+), 77 deletions(-) create mode 100644 tests/deferred-storage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 489ab38..b5932a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,20 @@ users sign in once after upgrading. - A refresh that returns a user equal to the current one keeps the same user object and does not change `version`, so guards do not run again. +- Unmounting stops timers and the storage watch but no longer cancels a + restore or refresh in progress; mounting again joins it. React's StrictMode + remount used to show an expired session as `unavailable`, reject loaders + waiting on `getSession()` with `SESSION_CHANGED`, and send the same refresh + token twice. With refresh token rotation, the second send could sign the + user out. +- A `signIn` whose validation fails no longer disturbs the current session. + It used to cancel a restore in progress, leaving `status` at + `initializing`, and abort a refresh in flight. While a sign-in validates, + only a newer `signIn` or `signOut()` cancels it; expiry and other tabs do + not. +- Route guards read the session again when `getSession()` rejects with + `SESSION_CHANGED`, as they already did when it resolved a session that was + no longer current. ## 0.1.0-alpha.3 diff --git a/src/core/client.ts b/src/core/client.ts index 356b55a..1057f45 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -147,10 +147,11 @@ export function createAuth( version: 0, error: null, }); - // Lifecycle. `epoch` increments whenever the session is replaced or pending - // work is cancelled; async work captures it and stops once it changes, and - // `controller` aborts that work. Timers and the storage watch run only - // while `mounts`, the number of Router connections, is above zero. + // Lifecycle. `epoch` increments whenever the session is replaced; async + // work captures it and stops once it changes, and `controller` aborts that + // work. Timers and the storage watch run only while `mounts`, the number + // of connections, is above zero. Unmounting cancels neither: a restore or + // refresh already started finishes, and a remount joins it. let epoch = 0; let controller = new AbortController(); let mounts = 0; @@ -185,6 +186,13 @@ export function createAuth( let profileVersion = 0; let profileIntent = 0; + // Sign-in. A pending sign-in is cancelled only by a newer `signIn` or by + // `signOut()`, through `signInController`; `signInIntent` identifies the + // latest call. Expiry and other tabs do not cancel it: it replaces + // whatever session they leave. + let signInIntent = 0; + let signInController = new AbortController(); + const valid = () => session !== null && session.accessToken !== rejectedToken && @@ -549,9 +557,22 @@ export function createAuth( return promise; } + function cancelSignIn() { + signInIntent++; + signInController.abort(sessionChanged()); + signInController = new AbortController(); + } + + /** + * Validates `input`, then replaces the session with it. Validation leaves + * the current session alone, so a sign-in that fails cancels no restore or + * refresh. A newer `signIn` or `signOut()` during validation makes this + * one reject with `SESSION_CHANGED`. + */ async function signIn(input: SignInInput) { - advanceEpoch(); - const expected = epoch; + cancelSignIn(); + const intent = signInIntent; + const signal = signInController.signal; try { const tokens = receiveTokens(input); if (tokens.expiresAt !== undefined && tokens.expiresAt <= Date.now()) { @@ -566,9 +587,9 @@ export function createAuth( tokens.accessToken, (input as { user?: unknown }).user, ), - controller.signal, + signal, ); - assertEpoch(expected); + if (intent !== signInIntent) throw sessionChanged(); if (tokens.expiresAt !== undefined && tokens.expiresAt <= Date.now()) { throw new AuthError( "INVALID_SESSION", @@ -586,14 +607,6 @@ export function createAuth( "Could not accept the user", cause, ); - } finally { - if (epoch === expected) { - schedule(); - if (session && !valid()) { - if (mounts) inBackground(resolveSession()); - else publish(store.get().error); - } - } } } @@ -885,12 +898,13 @@ export function createAuth( return () => { if (!active) return; active = false; + // Work in progress continues. A refresh the backend already answered + // may have rotated the refresh token, so cancelling it would lose the + // new one and send the spent one next time. if (--mounts === 0) { unwatch?.(); unwatch = undefined; stopTimers(); - advanceEpoch(); - if (session && !valid()) publish(store.get().error); } }; } @@ -914,7 +928,10 @@ export function createAuth( }), }, signIn, - signOut: () => signOut(true), + signOut: () => { + cancelSignIn(); + return signOut(true); + }, getSession: async () => toSession(await resolveSession()), retry: async () => toSession(await resume(true)), refresh: async () => toSession(await refreshNow()), diff --git a/src/core/guard.ts b/src/core/guard.ts index 98b9269..6c119d6 100644 --- a/src/core/guard.ts +++ b/src/core/guard.ts @@ -11,14 +11,22 @@ const MAX_ATTEMPTS = 3; /** * The signed-in session for a guard, or `null`. A session that changes while - * it is read is read again: the change re-runs guards anyway, so an error - * would only flash. + * it is read, whether `getSession` rejects with `SESSION_CHANGED` or resolves + * a session no longer current, is read again: the change re-runs guards + * anyway, so an error would only flash. */ export async function guardSession( auth: GuardClient, ): Promise { for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - const session = await auth.getSession(); + let session: S | null; + try { + session = await auth.getSession(); + } catch (error) { + if (error instanceof AuthError && error.code === "SESSION_CHANGED") + continue; + throw error; + } if (!session || auth.isCurrent(session)) return session; } throw new AuthError( diff --git a/tests/deferred-storage.ts b/tests/deferred-storage.ts new file mode 100644 index 0000000..febba79 --- /dev/null +++ b/tests/deferred-storage.ts @@ -0,0 +1,38 @@ +import { webStorage, type AuthStorage } from "@monarcode/session-kit"; + +/** + * Settles every operation after some microtasks, removals last by default, as + * asynchronous storage can. Operations issued in order would then complete + * out of order unless auth waits for each one. + */ +export const deferredStorage = ( + inner: AuthStorage, + delays: Partial> = {}, +): AuthStorage => { + const ticks = { get: 1, set: 2, remove: 3, ...delays }; + const after = async ( + count: number, + operation: () => T | Promise, + ): Promise => { + for (let tick = 0; tick < count; tick++) await Promise.resolve(); + return operation(); + }; + return { + get: (key) => after(ticks.get, () => inner.get(key)), + set: (key, value, meta) => + after(ticks.set, () => inner.set(key, value, meta)), + remove: (key) => after(ticks.remove, () => inner.remove(key)), + subscribe: inner.subscribe, + lock: inner.lock, + }; +}; + +/** + * `pnpm test` runs the suite twice: with the default synchronous storage, and + * with `async-storage.env` setting `SESSION_KIT_TEST_STORAGE` so every client + * defers its storage. + */ +export const testStorage = (): AuthStorage => + process.env.SESSION_KIT_TEST_STORAGE === "async" + ? deferredStorage(webStorage()) + : webStorage(); diff --git a/tests/dom.ts b/tests/dom.ts index 556fb4b..89518d4 100644 --- a/tests/dom.ts +++ b/tests/dom.ts @@ -3,15 +3,13 @@ import { afterEach, beforeEach } from "node:test"; import * as React from "react"; import { createElement, Fragment, StrictMode, type ReactNode } from "react"; -import { - createAuth, - webStorage, - type AuthClient, -} from "@monarcode/session-kit"; +import { createAuth, type AuthClient } from "@monarcode/session-kit"; import { JSDOM } from "jsdom"; import type { Root } from "react-dom/client"; import { z } from "zod"; +import { testStorage } from "./deferred-storage.ts"; + export const userSchema = z.object({ id: z.string(), email: z.string() }); type TestUser = z.infer; export type Client = AuthClient; @@ -25,10 +23,47 @@ export const newClient = ( createAuth({ name: "react-test", user: userSchema, - storage: webStorage(), + storage: testStorage(), ...options, }); +/** + * Saves a session whose access token has expired, so restoring it needs a + * refresh. + */ +export async function saveExpiredSession() { + await newClient().signIn({ + accessToken: "access-1", + refreshToken: "refresh-1", + user, + }); + const key = "react-test:auth:session"; + const entry = JSON.parse(localStorage.getItem(key) ?? "null"); + localStorage.setItem( + key, + JSON.stringify({ ...entry, expiresAt: Date.now() - 1_000 }), + ); +} + +/** + * A refresh callback that records each refresh token it sends and answers + * once `respond` is called. + */ +export function heldRefresh() { + let respond!: () => void; + const answered = new Promise((resolve) => (respond = resolve)); + const held = { + sent: [] as string[], + respond, + refresh: async ({ refreshToken }: { refreshToken: string }) => { + held.sent.push(refreshToken); + await answered; + return { accessToken: "access-2", expiresIn: 3_600 }; + }, + }; + return held; +} + /** React's `act`, which React 18.0–18.2 exported only from test utilities. */ export let act: (callback: () => unknown) => Promise; export let dom: JSDOM; diff --git a/tests/expiry.test.ts b/tests/expiry.test.ts index 0c54f2b..86e0654 100644 --- a/tests/expiry.test.ts +++ b/tests/expiry.test.ts @@ -9,6 +9,7 @@ import { client, code, recordStates, + saved, credentialsOf, useBrowserMocks, } from "./helpers.ts"; @@ -61,13 +62,14 @@ test("proactive failure keeps unexpired access but expiry hides the user", async t.mock.timers.reset(); }); -test("a proactive refresh cancelled by unmount is attempted again after remount", async (t) => { +test("unmount keeps a refresh in flight, so a rotated refresh token is saved and sent once", async (t) => { t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); - let calls = 0; + const sent: Array = []; + const response = deferred(); const auth = client({ - refresh: async () => { - calls++; - return new Promise(() => {}); + refresh: async ({ refreshToken }) => { + sent.push(refreshToken); + return response.promise; }, }); await auth.signIn(input({ expiresAt: 1_020_000 })); @@ -75,14 +77,50 @@ test("a proactive refresh cancelled by unmount is attempted again after remount" await flush(); t.mock.timers.tick(10_000); await flush(); - assert.equal(calls, 1); + assert.deepEqual(sent, ["refresh-1"]); + // The backend has rotated the refresh token by the time this tab unmounts. unmount(); await flush(); cleanups.push(auth.mount()); await flush(); - t.mock.timers.tick(5_000); + response.resolve({ + accessToken: "access-2", + refreshToken: "refresh-2", + expiresAt: 1_100_000, + }); + await flush(); + assert.deepEqual(sent, ["refresh-1"]); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(saved().refreshToken, "refresh-2"); + t.mock.timers.tick(60_000); + await flush(); + assert.deepEqual(sent, ["refresh-1", "refresh-2"]); + cleanups.pop()!(); + t.mock.timers.reset(); +}); + +test("a remount while an expired session refreshes neither shows it unavailable nor rejects waiting guards", async (t) => { + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + await client().signIn(input({ expiresAt: 1_001_000 })); + t.mock.timers.tick(2_000); + const sent: Array = []; + const response = deferred(); + const auth = client({ + refresh: async ({ refreshToken }) => { + sent.push(refreshToken); + return response.promise; + }, + }); + const states = recordStates(auth); + const waiting = auth.getSession(); + // StrictMode mounts, unmounts, and mounts again at once. + auth.mount()(); + cleanups.push(auth.mount()); await flush(); - assert.equal(calls, 2); + response.resolve({ accessToken: "access-2", expiresAt: 1_100_000 }); + assert.equal((await waiting)?.user.id, "alice"); + assert.deepEqual(sent, ["refresh-1"]); + assert.ok(states.every((state) => state.status !== "unavailable")); assert.equal(auth.state.get().status, "authenticated"); cleanups.pop()!(); t.mock.timers.reset(); diff --git a/tests/helpers.ts b/tests/helpers.ts index fbc36c4..2b4c604 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -3,12 +3,10 @@ import { afterEach, beforeEach } from "node:test"; import { createAuth, - webStorage, type AuthClient, type AuthErrorCode, type AuthOptions, type AuthState, - type AuthStorage, type Credentials, type Session, type Tokens, @@ -17,6 +15,10 @@ import { import { connectAuth } from "@monarcode/session-kit/tanstack-router"; import { z } from "zod"; +import { deferredStorage, testStorage } from "./deferred-storage.ts"; + +export { deferredStorage }; + export const userSchema = z.object({ id: z.string(), email: z.string(), @@ -94,39 +96,6 @@ export const input = ( ...overrides, }); -/** - * Settles every operation after some microtasks, removals last by default, as - * asynchronous storage can. Operations issued in order would then complete - * out of order unless auth waits for each one. - */ -export const deferredStorage = ( - inner: AuthStorage, - delays: Partial> = {}, -): AuthStorage => { - const ticks = { get: 1, set: 2, remove: 3, ...delays }; - const after = async ( - count: number, - operation: () => T | Promise, - ): Promise => { - for (let tick = 0; tick < count; tick++) await Promise.resolve(); - return operation(); - }; - return { - get: (key) => after(ticks.get, () => inner.get(key)), - set: (key, value, meta) => - after(ticks.set, () => inner.set(key, value, meta)), - remove: (key) => after(ticks.remove, () => inner.remove(key)), - subscribe: inner.subscribe, - lock: inner.lock, - }; -}; - -/** - * `pnpm test` runs the suite twice: with the default synchronous storage, and - * with `async-storage.env` setting this so every client defers its storage. - */ -const asyncStorage = process.env.SESSION_KIT_TEST_STORAGE === "async"; - export type TestClient = ReturnType>; /** @@ -143,7 +112,7 @@ export function client(options: object = {}) { return createAuth({ name: "test", user: userSchema, - storage: asyncStorage ? deferredStorage(webStorage()) : webStorage(), + storage: testStorage(), ...(options as Partial>), }); } diff --git a/tests/react-router.test.ts b/tests/react-router.test.ts index 0a319e8..a405e38 100644 --- a/tests/react-router.test.ts +++ b/tests/react-router.test.ts @@ -7,8 +7,10 @@ import { AuthError } from "@monarcode/session-kit"; import { act, + heldRefresh, newClient, render, + saveExpiredSession, settle, signIn, user, @@ -148,6 +150,86 @@ test("declarative mode: SessionOutlet shows pending while restoring", async () = assert.equal(view.container.textContent, "private"); }); +test("declarative mode: StrictMode keeps the route while an expired session refreshes once", async () => { + await saveExpiredSession(); + const held = heldRefresh(); + const auth = newClient({ refresh: held.refresh }); + const statuses: string[] = []; + auth.state.subscribe((state) => statuses.push(state.status)); + const { AuthProvider } = hooks.createAuthHooks(); + const { MemoryRouter, Routes, Route } = router; + const view = await render( + h( + AuthProvider, + { client: auth }, + h( + MemoryRouter, + { initialEntries: ["/private"] }, + h( + Routes, + null, + h( + Route, + { + element: h(binding.SessionOutlet, { + loginPath: "/login", + pending: "checking", + }), + }, + h(Route, { path: "/private", element: "private" }), + ), + ), + ), + ), + { strict: true }, + ); + await act(async () => held.respond()); + await settle(); + assert.equal(view.container.textContent, "private"); + assert.deepEqual(held.sent, ["refresh-1"]); + assert.equal(statuses.includes("unavailable"), false); +}); + +test("data mode: StrictMode lets a loader's refresh finish and sends the token once", async () => { + await saveExpiredSession(); + const held = heldRefresh(); + const auth = newClient({ refresh: held.refresh }); + const { AuthProvider } = hooks.createAuthHooks(); + const { createMemoryRouter, RouterProvider, Outlet } = router; + function Root() { + binding.useAuthRevalidation(); + return h(Outlet); + } + const memory = createMemoryRouter( + [ + { + element: h(Root), + errorElement: h("output", null, "error"), + children: [ + { + loader: ({ request }: { request: Request }) => + binding.requireSession(auth, { + request, + loginPath: "/login", + }), + element: h(binding.SessionOutlet, { loginPath: "/login" }), + children: [{ index: true, element: "home" }], + }, + ], + }, + ], + { initialEntries: ["/"] }, + ); + const view = await render( + h(AuthProvider, { client: auth }, h(RouterProvider, { router: memory })), + { strict: true }, + ); + await act(async () => held.respond()); + await settle(); + assert.equal(view.container.textContent, "home"); + assert.deepEqual(held.sent, ["refresh-1"]); +}); + test("declarative mode: SessionOutlet sends signed-out users to sign in", async () => { const auth = newClient(); const { AuthProvider } = hooks.createAuthHooks(); diff --git a/tests/router.test.ts b/tests/router.test.ts index bef28a0..b10acd7 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -10,6 +10,7 @@ import { import { user, + userSchema, cleanups, deferred, flush, @@ -360,6 +361,28 @@ test("requireSession reads a session that changes mid-check again", async () => ); }); +test("requireSession reads again when a sign-in replaces the session it was restoring", async () => { + await client().signIn(input()); + const restoring = deferred(); + const auth = client({ + user: userSchema.refine(async (value) => + value.id === "alice" ? restoring.promise : true, + ), + }); + const guarding = requireSession(auth, { + location: { href: "/private" }, + loginPath: "/login", + }); + await flush(); + await auth.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ); + restoring.resolve(true); + const { session } = await guarding; + assert.equal(session.user.id, "bob"); + assert.equal(auth.isCurrent(session), true); +}); + test("connectAuth does nothing while a Router renders on a server", () => { const auth = client(); let mounted = 0; diff --git a/tests/session.test.ts b/tests/session.test.ts index 6c8d344..1fb8b8a 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -190,7 +190,7 @@ test("failed sign-in recovery retains expired credentials without a refresh retr assert.equal(refreshCalls, 1); }); -test("failed sign-in hides an expired unmounted session until demand retries refresh", async (t) => { +test("a failed sign-in leaves an expired unmounted session to be refreshed on demand", async (t) => { t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); const validation = deferred(); let refreshCalls = 0; @@ -204,6 +204,7 @@ test("failed sign-in hides an expired unmounted session until demand retries ref }, }); await auth.signIn(input({ expiresAt: 1_001_000 })); + const states = recordStates(auth); const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); const checked = assert.rejects(pending, code("USER_VALIDATION_FAILED")); await flush(); @@ -211,8 +212,8 @@ test("failed sign-in hides an expired unmounted session until demand retries ref validation.resolve(false); await checked; await flush(); - assert.equal(auth.state.get().status, "unavailable"); - assert.equal(auth.state.get().user, null); + // Nothing watches expiry while unmounted, and the failure changes nothing. + assert.deepEqual(states, []); assert.equal(refreshCalls, 0); assert.ok(values.has(SESSION)); assert.equal((await credentialsOf(auth)).accessToken, "access-2"); @@ -383,7 +384,7 @@ test("overlapping profile updates commit only the most recent request", async () assert.equal(auth.state.get().user?.email, "newest"); }); -test("remount while initial validation is pending starts a fresh restoration", async () => { +test("remount while initial validation is pending joins that restoration", async () => { await client().signIn(input()); const gate = deferred(); let calls = 0; @@ -392,15 +393,87 @@ test("remount while initial validation is pending starts a fresh restoration", a return gate.promise; }); const auth = client({ user: schema }); + const states = recordStates(auth); const off = auth.mount(); await flush(); + const validations = calls; off(); cleanups.push(auth.mount()); await flush(); gate.resolve(true); await flush(); assert.equal(auth.state.get().status, "authenticated"); - assert.ok(calls >= 2); + assert.equal(calls, validations); + assert.deepEqual( + states.map((state) => state.status), + ["authenticated"], + ); +}); + +test("a failed sign-in during restoration lets the restoration finish", async () => { + await client().signIn(input()); + const restoring = deferred(); + const auth = client({ + user: userSchema.refine(async (value) => + value.id === "alice" ? restoring.promise : false, + ), + }); + const waiting = auth.getSession(); + await flush(); + await assert.rejects( + auth.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ), + code("USER_VALIDATION_FAILED"), + ); + restoring.resolve(true); + assert.equal((await waiting)?.user.id, "alice"); + assert.equal(auth.state.get().status, "authenticated"); +}); + +test("a failed sign-in does not abort a refresh in flight", async () => { + const response = deferred(); + let calls = 0; + const auth = client({ + user: userSchema.refine(async (value) => value.id !== "bob"), + refresh: async () => { + calls++; + return response.promise; + }, + }); + await auth.signIn(input()); + const renewing = auth.credentials.renew(await credentialsOf(auth)); + await flush(); + await assert.rejects( + auth.signIn( + input({ accessToken: "bob-token", user: { ...user, id: "bob" } }), + ), + code("USER_VALIDATION_FAILED"), + ); + response.resolve({ accessToken: "access-2" }); + assert.equal((await renewing)?.accessToken, "access-2"); + assert.equal(calls, 1); +}); + +test("signOut cancels a sign-in still validating; a newer sign-in supersedes it", async () => { + const validation = deferred(); + const auth = client({ + user: userSchema.refine(async (value) => + value.id === "bob" ? validation.promise : true, + ), + }); + await auth.signIn(input()); + const cancelled = auth.signIn(input({ user: { ...user, id: "bob" } })); + const checked = assert.rejects(cancelled, code("SESSION_CHANGED")); + await flush(); + await auth.signOut(); + validation.resolve(true); + await checked; + assert.equal(await auth.getSession(), null); + const superseded = auth.signIn(input({ user: { ...user, id: "bob" } })); + await auth.signIn(input({ user: { ...user, id: "carol" } })); + await assert.rejects(superseded, code("SESSION_CHANGED")); + assert.equal(auth.state.get().user?.id, "carol"); }); test("sessions name the user without tokens; credentials carry the token", async () => { From df753cfb16a7516be63f294febd0e90ba74c81fe Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 18:00:52 +0100 Subject: [PATCH 10/12] fix(react-router): guard the outlet by the loader's session and support basename In data and framework mode, SessionOutlet shows pending until the loader accepts a new account, so child routes never see the previous account's loader data. requireSession takes the router's basename and leaves it out of redirectTo. BREAKING CHANGE: @tanstack/react-router ^1.132.0 is required; earlier versions include the basepath in location.href. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 15 ++++ README.md | 7 +- package.json | 2 +- src/react-router/guards.ts | 24 +++++- src/react-router/outlet.ts | 49 ++++++++++-- tests/react-router.test.ts | 149 +++++++++++++++++++++++++++++++++++++ tests/router.test.ts | 29 +++++++- 8 files changed, 265 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20da443..9577d9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: pnpm add --workspace-root --save-dev --save-exact \ react@18.0.0 react-dom@18.0.0 \ @types/react@18.0.2 @types/react-dom@18.0.3 \ - @tanstack/react-router@1.127.0 react-router@7.0.0 + @tanstack/react-router@1.132.0 react-router@7.0.0 - name: Build and test run: pnpm run build && pnpm test && pnpm run test:types diff --git a/CHANGELOG.md b/CHANGELOG.md index b5932a9..92b62da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -92,6 +92,11 @@ `createAuthHooks({ useClient: useRouterAuth })`; elsewhere, render `AuthProvider`, which also mounts the client. `connectAuth` moved to `/tanstack-router`, and `safeReturnTo` to the package root. +- **Breaking:** TanStack React Router `^1.132.0` is required (was + `^1.127.0`). Earlier versions include the router's `basepath` in + `location.href`, so `requireSession` put it in `redirectTo` and users came + back to a path with the `basepath` twice. CI tests 1.132.0 as the oldest + version. - **Breaking:** both peer dependencies are optional, and React DOM is no longer one. Install TanStack Router only for `/tanstack-router`, and React only for the React entry points. @@ -115,6 +120,16 @@ `initializing`, and abort a refresh in flight. While a sign-in validates, only a newer `signIn` or `signOut()` cancels it; expiry and other tabs do not. +- In React Router's data and framework mode, `SessionOutlet` renders child + routes only while the session its route's loader accepted is still the + signed-in one, as the TanStack Router `SessionOutlet` does. After another + account signs in, it shows `pending` until the loader runs again, instead + of rendering child routes with the previous account's loader data. A route + whose loader returns no session, and declarative mode, keep following the + signed-in session. +- React Router's `requireSession` takes the router's `basename`, which it + leaves out of `redirectTo`. Without it, an app with a `basename` returned + users to a path with the `basename` twice. - Route guards read the session again when `getSession()` rejects with `SESSION_CHANGED`, as they already did when it resolved a session that was no longer current. diff --git a/README.md b/README.md index 8a1bc4a..5f15aa3 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ All peer dependencies are optional, so install only what you use: | Peer | Range | Needed for | | ------------------------ | ---------------------- | --------------------------------------------- | | `react` | `^18.0.0 \|\| ^19.0.0` | `/react`, `/tanstack-router`, `/react-router` | -| `@tanstack/react-router` | `^1.127.0` | `/tanstack-router` | +| `@tanstack/react-router` | `^1.132.0` | `/tanstack-router` | | `react-router` | `^7.0.0 \|\| ^8.0.0` | `/react-router` | CI tests the oldest and newest versions in each range. session-kit never @@ -424,7 +424,10 @@ happens before the first paint, and never on a server. guards loaders and returns `{ session }`, and `redirectIfSignedIn(auth, { redirectTo })` guards the sign-in loader. Call `useAuthRevalidation()` once in the root layout so loaders re-run when auth - changes. + changes. Render `SessionOutlet` as the element of the guarded route: when + another account signs in, it shows `pending` until the loader accepts the + new session, so child routes never see the previous account's loader data. + With a router `basename`, pass the same `basename` to `requireSession`. **Without a router.** `createAuthHooks()` and `AuthProvider` work in any React app. diff --git a/package.json b/package.json index bfd44e2..54d1d2d 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "zod": "4.4.3" }, "peerDependencies": { - "@tanstack/react-router": "^1.127.0", + "@tanstack/react-router": "^1.132.0", "react": "^18.0.0 || ^19.0.0", "react-router": "^7.0.0 || ^8.0.0" }, diff --git a/src/react-router/guards.ts b/src/react-router/guards.ts index 677d3cb..cc7aa3f 100644 --- a/src/react-router/guards.ts +++ b/src/react-router/guards.ts @@ -12,11 +12,31 @@ export type RequireSessionOptions = { request: Request; /** Where signed-out users go. A same-origin path, such as `"/login"`. */ loginPath: string; + /** + * The router's `basename`, if it has one. Loaders see URLs that include + * it, but redirects add it again, so `redirectTo` must leave it out. + */ + basename?: string; }; +/** `pathname` without `basename`, matched ignoring case as React Router does. */ +function stripBasename(pathname: string, basename = "/"): string { + if (!basename.startsWith("/")) + throw new Error(`basename must start with "/": ${basename}`); + const base = basename.replace(/\/+$/, ""); + if (!base) return pathname; + const lower = pathname.toLowerCase(); + const prefix = base.toLowerCase(); + if (lower === prefix) return "/"; + return lower.startsWith(`${prefix}/`) + ? pathname.slice(base.length) + : pathname; +} + /** * Guards a route in its loader, in data or framework mode. Signed-out users * are redirected to `loginPath` with `?redirectTo=` set to the requested URL. + * Pass the router's `basename`, if any, so `redirectTo` leaves it out. * Signed-in users get `{ session }`, which holds no tokens. A failed refresh * is rethrown as `REFRESH_FAILED` for the route's error element, which can * offer `auth.retry()`. @@ -31,11 +51,13 @@ export async function requireSession( ): Promise<{ session: S }> { // Fails fast on a bad path, before any session is read. loginHref(options.loginPath, "/"); + stripBasename("/", options.basename); const session = await guardSession(auth); if (!session) { const url = new URL(options.request.url); + const path = stripBasename(url.pathname, options.basename); throw redirect( - loginHref(options.loginPath, url.pathname + url.search + url.hash), + loginHref(options.loginPath, path + url.search + url.hash), ); } return { session }; diff --git a/src/react-router/outlet.ts b/src/react-router/outlet.ts index 898e858..4f35374 100644 --- a/src/react-router/outlet.ts +++ b/src/react-router/outlet.ts @@ -1,10 +1,17 @@ import { createElement, Fragment, + useContext, type ReactElement, type ReactNode, } from "react"; -import { Navigate, Outlet, useLocation } from "react-router"; +import { + Navigate, + Outlet, + UNSAFE_DataRouterStateContext, + useLoaderData, + useLocation, +} from "react-router"; import { AuthError } from "../core/errors.js"; import { loginHref } from "../core/return-to.js"; @@ -26,7 +33,10 @@ type OutletState = { export type SessionOutletProps = { /** Where signed-out users go. A same-origin path, such as `"/login"`. */ loginPath: string; - /** Shown while the saved session is being restored. Default: nothing. */ + /** + * Shown while the saved session is being restored and, in data mode, + * while the loader has yet to accept a new session. Default: nothing. + */ pending?: ReactNode; /** * Shown when access is unavailable, such as after a failed refresh. `retry` @@ -41,13 +51,37 @@ export type SessionOutletProps = { client?: OutletClient; }; +/** + * The session ID that this route's loader accepted, when it returned + * `requireSession`'s `{ session }`. `undefined` outside a data router, or when + * the loader returned no session. + */ +function useGuardedSessionId(): string | undefined { + // Data and framework mode provide this context; declarative mode does not. + // That never changes while a component is mounted, so `useLoaderData` + // below runs on every render or on none. + const dataRouter = useContext(UNSAFE_DataRouterStateContext); + if (!dataRouter) return undefined; + // oxlint-disable-next-line react/rules-of-hooks + const data = useLoaderData() as + | { session?: { sessionId?: unknown } } + | null + | undefined; + const id = data?.session?.sessionId; + return typeof id === "string" ? id : undefined; +} + /** * Renders child routes only while someone is signed in, in any mode. In * declarative mode it is the guard: signed-out users are sent to `loginPath` - * with `?redirectTo=` set to the current location. In data mode, pair it - * with `requireSession` in loaders. Child routes are keyed by session, so - * nothing from one account stays mounted for the next. Profile updates and - * token refreshes keep them mounted. + * with `?redirectTo=` set to the current location. In data mode, render it + * as the element of the route whose loader returns `requireSession`'s + * result: child routes then render only while that session is still the + * signed-in one, and `pending` shows until `useAuthRevalidation` has re-run + * the loader for a new one, so no child sees the previous account's loader + * data. Child routes are keyed by session, so nothing from one account stays + * mounted for the next. Profile updates and token refreshes keep them + * mounted. * * @example * } />}> @@ -67,9 +101,12 @@ export function SessionOutlet({ ); const location = useLocation(); const state = useAuthState(auth, (value) => value as OutletState); + const guarded = useGuardedSessionId(); switch (state.status) { case "authenticated": case "refreshing": + if (guarded !== undefined && guarded !== state.sessionId) + return createElement(Fragment, null, pending); return createElement(Outlet, { key: state.sessionId }); case "unauthenticated": return createElement(Navigate, { diff --git a/tests/react-router.test.ts b/tests/react-router.test.ts index a405e38..117243e 100644 --- a/tests/react-router.test.ts +++ b/tests/react-router.test.ts @@ -417,3 +417,152 @@ test("loader guards redirect with the requested URL and never leave the site", a /loginPath must start with/, ); }); + +test("data mode: SessionOutlet waits for the loader to accept a new account", async () => { + const auth = newClient(); + await signIn(auth); + const { AuthProvider, useAuth } = hooks.createAuthHooks(); + const { createMemoryRouter, RouterProvider, Outlet, useRouteLoaderData } = + router; + let hold: Promise | undefined; + const mismatches: string[] = []; + function Root() { + binding.useAuthRevalidation(); + return h(Outlet); + } + function Home() { + const id = useAuth((state) => state.user?.id); + const data = useRouteLoaderData("guarded") as { + session: { user: { id: string } }; + }; + if (data.session.user.id !== id) + mismatches.push(`${String(id)} saw ${data.session.user.id}'s data`); + return h("output", null, `home:${data.session.user.id}`); + } + const memory = createMemoryRouter( + [ + { + element: h(Root), + children: [ + { + id: "guarded", + loader: async ({ request }: { request: Request }) => { + const guarded = await binding.requireSession(auth, { + request, + loginPath: "/login", + }); + await hold; + return guarded; + }, + element: h(binding.SessionOutlet, { + loginPath: "/login", + pending: "checking", + }), + children: [{ index: true, element: h(Home) }], + }, + ], + }, + ], + { initialEntries: ["/"] }, + ); + const view = await render( + h(AuthProvider, { client: auth }, h(RouterProvider, { router: memory })), + ); + await settle(); + assert.equal(view.container.textContent, "home:alice"); + let release!: () => void; + hold = new Promise((resolve) => (release = resolve)); + // Another account signs in, as from another tab; the loader has yet to run again. + await act(() => + auth.signIn({ + accessToken: "bob-token", + user: { id: "bob", email: "bob@example.com" }, + }), + ); + await settle(); + assert.equal(view.container.textContent, "checking"); + release(); + await settle(); + assert.equal(view.container.textContent, "home:bob"); + assert.deepEqual(mismatches, []); +}); + +test("data mode: requireSession leaves the basename out of redirectTo", async () => { + const auth = newClient(); + const { AuthProvider } = hooks.createAuthHooks(); + const { createMemoryRouter, RouterProvider } = router; + const memory = createMemoryRouter( + [ + { + path: "/login", + loader: async ({ request }: { request: Request }) => { + const redirectTo = new URL(request.url).searchParams.get( + "redirectTo", + ); + await binding.redirectIfSignedIn(auth, { redirectTo }); + return null; + }, + element: "login", + }, + { + path: "/private", + loader: ({ request }: { request: Request }) => + binding.requireSession(auth, { + request, + loginPath: "/login", + basename: "/app", + }), + element: "private", + }, + ], + { basename: "/app", initialEntries: ["/app/private?tab=2"] }, + ); + await render( + h(AuthProvider, { client: auth }, h(RouterProvider, { router: memory })), + ); + await settle(); + const where = () => + memory.state.location.pathname + memory.state.location.search; + assert.equal(where(), "/app/login?redirectTo=%2Fprivate%3Ftab%3D2"); + await act(() => signIn(auth)); + await act(() => memory.revalidate()); + await settle(); + assert.equal(where(), "/app/private?tab=2"); +}); + +test("requireSession strips the basename ignoring case and trailing slashes", async () => { + const auth = newClient(); + await auth.getSession(); + const location = async (url: string, basename?: string) => { + const request = new Request(`https://auth.test${url}`); + const response = await binding + .requireSession(auth, { request, loginPath: "/login", basename }) + .then( + () => assert.fail("Expected a redirect"), + (thrown: Response) => thrown, + ); + return response.headers.get("Location"); + }; + assert.equal( + await location("/app/private", "/app/"), + "/login?redirectTo=%2Fprivate", + ); + assert.equal( + await location("/APP/private", "/app"), + "/login?redirectTo=%2Fprivate", + ); + assert.equal(await location("/app", "/app"), "/login?redirectTo=%2F"); + assert.equal(await location("/apple", "/app"), "/login?redirectTo=%2Fapple"); + assert.equal( + await location("/private", "/"), + "/login?redirectTo=%2Fprivate", + ); + await assert.rejects( + binding.requireSession(auth, { + request: new Request("https://auth.test/app"), + loginPath: "/login", + basename: "app", + }), + /basename must start with/, + ); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts index b10acd7..8fbb558 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -151,6 +151,8 @@ const realRouter = async (auth: TestClient) => { setGlobal("self", globalThis); setGlobal("scrollTo", () => {}); setGlobal("window", { + // Router 1.132 and later resolve locations against `window.origin`. + origin: "https://example.com", location: new URL("https://example.com/private"), addEventListener() {}, removeEventListener() {}, @@ -253,10 +255,16 @@ test("a failed refresh reruns real Router guards once without retrying until ret }); /** A real Router whose `/private` uses `requireSession` and `/login` `redirectIfSignedIn`. */ -const guardedRouter = async (auth: TestClient, path: string) => { +const guardedRouter = async ( + auth: TestClient, + path: string, + { basepath }: { basepath?: string } = {}, +) => { setGlobal("self", globalThis); setGlobal("scrollTo", () => {}); setGlobal("window", { + // Router 1.132 and later resolve locations against `window.origin`. + origin: "https://example.com", location: new URL(`https://example.com${path}`), addEventListener() {}, removeEventListener() {}, @@ -288,6 +296,7 @@ const guardedRouter = async (auth: TestClient, path: string) => { context: { auth }, history: createMemoryHistory({ initialEntries: [path] }), isServer: false, + basepath, }); router.startTransition = (async (fn: () => void) => { fn(); @@ -297,6 +306,24 @@ const guardedRouter = async (auth: TestClient, path: string) => { return router; }; +test("guards keep a basepath out of redirectTo and in every redirect", async () => { + const auth = client(); + await auth.getSession(); + const router = await guardedRouter(auth, "/app/private?tab=2", { + basepath: "/app", + }); + const where = () => + router.history.location.pathname + router.history.location.search; + assert.equal(where(), "/app/login?redirectTo=%2Fprivate%3Ftab%3D2"); + const { redirectTo } = router.state.location.search as { + redirectTo?: unknown; + }; + assert.equal(redirectTo, "/private?tab=2"); + await auth.signIn(input()); + await router.navigate({ to: "/login", search: { redirectTo } }); + assert.equal(where(), "/app/private?tab=2"); +}); + test("requireSession sends signed-out users to sign in, then back", async () => { const auth = client(); await auth.getSession(); From 9a1500dc3a9744beaad3e0ec64a6a49743d5dea0 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 18:08:28 +0100 Subject: [PATCH 11/12] refactor(core): one restore path for synchronous and asynchronous sources Storage reads, user validation, and token claims each have one implementation that finishes synchronously when every step does, so the synchronous restore can no longer drift from the asynchronous one. Claims that are not JSON now restore synchronously, async sources validate once, and a remount applies changes other tabs saved while unmounted. --- CHANGELOG.md | 11 +++-- src/core/client.ts | 89 +++++++++++++++++++---------------------- src/core/maybe.ts | 43 ++++++++++++++++++++ src/core/persistence.ts | 33 +++++++-------- src/core/sources.ts | 80 +++++++++++++----------------------- src/core/task.ts | 2 +- src/core/user.ts | 70 ++++++++++++-------------------- tests/dom.ts | 18 +++++++++ tests/react.test.ts | 27 +++++++++++++ tests/session.test.ts | 40 +++++++++++++++++- tests/tabs.test.ts | 17 ++++++++ tests/users.test.ts | 38 ++++++++++++++++++ 12 files changed, 298 insertions(+), 170 deletions(-) create mode 100644 src/core/maybe.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b62da..031b6d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,9 +66,14 @@ against DummyJSON's auth API. - Mounting restores the saved session at once when storage and the user source both answer synchronously, as with `webStorage()` and a synchronous - schema, so `initializing` is never rendered. `AuthProvider` mounts in a - layout effect, before the browser paints. Asynchronous storage, schemas, or - token decoding restore as before. + schema. `AuthProvider` mounts in a layout effect, so the browser's first + paint shows the restored session rather than `initializing`, although + components may render once with `initializing` before that paint. A saved + session that cannot be restored signs out at once. Asynchronous storage, + schemas, or token decoding restore as before, and validate the saved user + once. +- Mounting again after an unmount applies what another tab saved meanwhile, + such as a sign-out. ### Changed diff --git a/src/core/client.ts b/src/core/client.ts index 1057f45..07e05ef 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -2,6 +2,7 @@ import { atom } from "nanostores"; import { AuthError, toAuthError, sessionChanged } from "./errors.js"; import { createId } from "./id.js"; +import { attempt, chain, isThenable, type MaybePromise } from "./maybe.js"; import { createPersistence, type StoredSession } from "./persistence.js"; import { obtainTokens } from "./refresh.js"; import { @@ -507,38 +508,36 @@ export function createAuth( return null; } - async function initialize() { + /** + * Restores the saved session. Finishes synchronously when storage and the + * user source both answer at once, as with `webStorage()` and a + * synchronous schema, so mounting can restore before the first render. + * Otherwise returns the restore in progress, which later calls join. + */ + function initialize(): MaybePromise { if (initialized) return; if (initialization) return initialization; const expected = epoch; - const promise = (async () => { - try { - const stored = await persistence.read(); - assertEpoch(expected); - if (stored) { - const user = await runTask( - () => users.restore(stored), - controller.signal, - ); + /** Restoring work that is asynchronous can be aborted and times out. */ + const bounded = (value: MaybePromise): MaybePromise => + isThenable(value) ? runTask(() => value, controller.signal) : value; + const restored = attempt( + () => + chain(persistence.read(), (stored) => { assertEpoch(expected); - session = { ...tokensOf(stored), id: stored.id, user }; - revision++; - } - assertEpoch(expected); - initialized = true; - if (!session || valid()) publish(); - schedule(); - } catch (cause) { + if (!stored) return finish(null); + return chain(bounded(users.restore(stored)), (user) => + finish({ ...tokensOf(stored), id: stored.id, user }), + ); + }), + (cause): MaybePromise => { assertEpoch(expected); const error = toAuthError( "PERSISTENCE_FAILED", "Could not restore auth", cause, ); - if (rejectsSaved(error)) { - await discardSaved(error); - return; - } + if (rejectsSaved(error)) return discardSaved(error); const previous = store.get(); store.set({ ...previous, @@ -547,8 +546,22 @@ export function createAuth( version: previous.version + 1, } as AuthState); throw error; + }, + ); + /** Installs what storage held, once this restore is still current. */ + function finish(restoredSession: InternalSession | null) { + assertEpoch(expected); + if (restoredSession) { + session = restoredSession; + revision++; } - })(); + initialized = true; + // An expired session publishes when its refresh starts. + if (!session || valid()) publish(); + schedule(); + } + if (!isThenable(restored)) return; + const promise = restored; initialization = promise; const clearInitialization = () => { if (initialization === promise) initialization = undefined; @@ -865,33 +878,13 @@ export function createAuth( return refreshSession(session, false); } - /** - * Restores at once when storage and validation both answer synchronously, - * so state is ready before the first render. Anything asynchronous, or - * any failure, is left to `initialize()`, which also reports errors. - */ - function restoreNow() { - if (initialized || initialization) return; - const stored = persistence.readNow(); - if (stored === undefined) return; - let user: U | undefined; - if (stored) { - user = users.restoreNow(stored); - if (user === undefined) return; - } - session = stored - ? { ...tokensOf(stored), id: stored.id, user: user! } - : null; - revision++; - initialized = true; - // An expired session publishes when its refresh starts, as in initialize(). - if (!session || valid()) publish(); - } - function mount() { - if (mounts++ === 0) + if (mounts++ === 0) { unwatch = persistence.watch(() => inBackground(sync())); - restoreNow(); + // Another tab may have changed the session while nothing watched. + if (initialized) inBackground(sync()); + } + // Restores synchronously when it can, before returning. inBackground(resolveSession()); schedule(); let active = true; diff --git a/src/core/maybe.ts b/src/core/maybe.ts new file mode 100644 index 0000000..8553aea --- /dev/null +++ b/src/core/maybe.ts @@ -0,0 +1,43 @@ +/** + * Steps that may or may not be asynchronous. Storage, schemas, and token + * decoders may each answer at once or return a promise; chaining them with + * these helpers keeps one implementation that finishes synchronously when + * every step does, as restoring a session before the first render needs. + */ +export type MaybePromise = T | Promise; + +/** Whether `value` is a promise or another thenable. */ +export function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} + +/** Runs `next` on `value`'s result: at once unless `value` is a promise. */ +export function chain( + value: T | PromiseLike, + next: (value: T) => MaybePromise, +): MaybePromise { + return isThenable(value) + ? Promise.resolve(value).then(next) + : next(value as T); +} + +/** + * Runs `work`, sending both a synchronous throw and an asynchronous rejection + * to `fail`, which may recover or throw. + */ +export function attempt( + work: () => MaybePromise, + fail: (cause: unknown) => MaybePromise, +): MaybePromise { + let result: MaybePromise; + try { + result = work(); + } catch (cause) { + return fail(cause); + } + return isThenable(result) ? Promise.resolve(result).catch(fail) : result; +} diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 9072014..575b2c5 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -1,5 +1,6 @@ import { AuthError } from "./errors.js"; import { isRecord } from "./json.js"; +import { isThenable, type MaybePromise } from "./maybe.js"; import type { AuthStorage } from "./storage.js"; import { validateTokens, type SessionTokens } from "./tokens.js"; @@ -87,28 +88,22 @@ export function createPersistence( } } - function read(): Promise { - return enqueue(async () => parse(await storage.get(key))); - } - /** - * Reads the saved session synchronously, when storage can answer at once - * and no operation is queued. Returns `undefined` otherwise, meaning "use - * `read()`", including for malformed entries, which `read()` reports. + * Reads the saved session. With nothing queued, storage is asked at once, + * and a synchronous answer is returned synchronously, so a session can be + * restored before the first render. Otherwise the read waits its turn. */ - function readNow(): StoredSession | null | undefined { - if (pending) return undefined; + function read(): MaybePromise { + if (pending) return enqueue(async () => parse(await storage.get(key))); + let text: ReturnType; try { - const text = storage.get(key); - if (typeof text === "object" && text !== null) { - // Asynchronous storage: drop this read; `read()` will ask again. - void Promise.resolve(text).catch(() => {}); - return undefined; - } - return parse(text); - } catch { - return undefined; + text = storage.get(key); + } catch (cause) { + throw storageError(cause); } + if (!isThenable(text)) return parse(text); + // Queued, so operations requested meanwhile wait for this read. + return enqueue(async () => parse(await text)); } function write(session: StoredSession): Promise { @@ -144,5 +139,5 @@ export function createPersistence( return storage.subscribe?.(key, listener) ?? (() => {}); } - return { read, readNow, write, clear, exclusive, watch }; + return { read, write, clear, exclusive, watch }; } diff --git a/src/core/sources.ts b/src/core/sources.ts index 29f85de..3212627 100644 --- a/src/core/sources.ts +++ b/src/core/sources.ts @@ -1,16 +1,10 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { AuthError } from "./errors.js"; +import { attempt, chain, type MaybePromise } from "./maybe.js"; import type { StoredSession } from "./persistence.js"; import { decodeJwtPayload, type SessionTokens } from "./tokens.js"; -import { - isThenable, - sameUser, - toUser, - validateNewUser, - validateUser, - validateUserNow, -} from "./user.js"; +import { sameUser, toUser, validateNewUser, validateUser } from "./user.js"; /** * A user read from the access token's claims, made by `fromAccessToken`. @@ -85,14 +79,12 @@ export type UserResolver = { /** Whether the user is saved beside the tokens, rather than derived from them. */ saved: boolean; /** The user for newly received tokens and `input`, which is ignored when derived. */ - receive: (accessToken: string, input: unknown) => Promise; - /** The user for a saved session. */ - restore: (saved: StoredSession) => Promise; + receive: (accessToken: string, input: unknown) => MaybePromise; /** - * The user for a saved session, when it can be found synchronously; - * otherwise `undefined`, and `restore` decides. + * The user for a saved session. Synchronous when the source is, so a + * session can be restored before the first render. */ - restoreNow: (saved: StoredSession) => U | undefined; + restore: (saved: StoredSession) => MaybePromise; /** Whether `saved` holds the same user as `current`, without validating it. */ unchanged: ( saved: StoredSession, @@ -110,7 +102,6 @@ export function resolveUserSource(source: UserSource): UserResolver { saved: true, receive: (_, input) => validateNewUser(source, input), restore: (saved) => validateUser(source, saved.user), - restoreNow: (saved) => validateUserNow(source, saved.user), unchanged: (saved, current) => sameUser(saved.user, current.user), }; } @@ -119,50 +110,35 @@ export function resolveUserSource(source: UserSource): UserResolver { "Pass a Standard Schema or fromAccessToken(…) as the user option", ); } - const derive = async (accessToken: string) => { - try { - const result = await source.claims["~standard"].validate( - await source.decode(accessToken), - ); - if (result.issues) { + /** Decodes and validates the claims, then maps them to the user. */ + const derive = (accessToken: string) => + attempt( + () => + chain(source.decode(accessToken), (decoded) => + chain(source.claims["~standard"].validate(decoded), (result) => { + if (result.issues) { + throw new AuthError( + "USER_VALIDATION_FAILED", + "Invalid access token claims", + { issues: result.issues }, + ); + } + return toUser(source.map(result.value)); + }), + ), + (cause) => { + if (cause instanceof AuthError) throw cause; throw new AuthError( "USER_VALIDATION_FAILED", - "Invalid access token claims", - { issues: result.issues }, + "Could not read the user from the access token", + { cause }, ); - } - return toUser(source.map(result.value)); - } catch (cause) { - if (cause instanceof AuthError) throw cause; - throw new AuthError( - "USER_VALIDATION_FAILED", - "Could not read the user from the access token", - { cause }, - ); - } - }; - const deriveNow = (accessToken: string) => { - try { - const decoded = source.decode(accessToken); - if (isThenable(decoded)) { - // Asynchronous decode: drop this attempt; derive runs it again. - void Promise.resolve(decoded).catch(() => {}); - return undefined; - } - const claims = validateUserNow( - source.claims as StandardSchemaV1, - decoded, - ); - return claims === undefined ? undefined : toUser(source.map(claims)); - } catch { - return undefined; - } - }; + }, + ); return { saved: false, receive: (accessToken) => derive(accessToken), restore: (saved) => derive(saved.accessToken), - restoreNow: (saved) => deriveNow(saved.accessToken), unchanged: (saved, current) => saved.accessToken === current.accessToken, }; } diff --git a/src/core/task.ts b/src/core/task.ts index 2820dc3..62614a0 100644 --- a/src/core/task.ts +++ b/src/core/task.ts @@ -6,7 +6,7 @@ const TASK_TIMEOUT_MS = 15_000; * timeout even when `work` ignores the signal, so late results never commit. */ export function runTask( - work: (signal: AbortSignal) => Promise, + work: (signal: AbortSignal) => T | PromiseLike, parent: AbortSignal, timeoutMs = TASK_TIMEOUT_MS, ): Promise { diff --git a/src/core/user.ts b/src/core/user.ts index c793821..a34fdf9 100644 --- a/src/core/user.ts +++ b/src/core/user.ts @@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { AuthError } from "./errors.js"; import { isRecord } from "./json.js"; +import { attempt, chain, type MaybePromise } from "./maybe.js"; /** * Copies `value` as deeply frozen JSON, rejecting values JSON would drop or @@ -74,55 +75,34 @@ export function toUser(value: unknown): Record { return frozenJson(value) as Record; } -export async function validateUser( - schema: S, - input: unknown, -): Promise> { - try { - const result = await schema["~standard"].validate(input); - if (result.issues) { - throw new AuthError("USER_VALIDATION_FAILED", "Invalid user", { - issues: result.issues, - }); - } - return toUser(result.value); - } catch (cause) { - if (cause instanceof AuthError) throw cause; - throw new AuthError("USER_VALIDATION_FAILED", "User validation failed", { - cause, - }); - } -} - -/** Whether `value` is a promise or another thenable. */ -export function isThenable(value: unknown): value is PromiseLike { - return ( - (typeof value === "object" || typeof value === "function") && - value !== null && - typeof (value as { then?: unknown }).then === "function" - ); -} - /** - * Validates `input` synchronously when the schema can, returning `undefined` - * when it is asynchronous or rejects the input. `validateUser` then decides, - * and reports any failure. + * Validates `input` with `schema`, returning a frozen copy of its output. + * Synchronous when the schema is, so a saved session can be restored before + * the first render. */ -export function validateUserNow( +export function validateUser( schema: S, input: unknown, -): StandardSchemaV1.InferOutput | undefined { - try { - const result = schema["~standard"].validate(input); - if (isThenable(result)) { - // Asynchronous schema: drop this attempt; validateUser runs it again. - void Promise.resolve(result).catch(() => {}); - return undefined; - } - return result.issues ? undefined : toUser(result.value); - } catch { - return undefined; - } +): MaybePromise> { + return attempt( + () => + chain(schema["~standard"].validate(input), (result) => { + if (result.issues) { + throw new AuthError("USER_VALIDATION_FAILED", "Invalid user", { + issues: result.issues, + }); + } + return toUser(result.value); + }), + (cause) => { + if (cause instanceof AuthError) throw cause; + throw new AuthError( + "USER_VALIDATION_FAILED", + "User validation failed", + { cause }, + ); + }, + ); } /** diff --git a/tests/dom.ts b/tests/dom.ts index 89518d4..f4374af 100644 --- a/tests/dom.ts +++ b/tests/dom.ts @@ -146,6 +146,24 @@ export async function render(element: ReactNode, { strict = false } = {}) { }; } +/** + * Renders `element` synchronously and returns the text React committed + * before any promise could settle: what the browser would paint first. + */ +export async function firstPaint(element: ReactNode): Promise { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + roots.add(root); + // A synchronous `act` flushes renders and effects, but not promises. + const done = act(() => { + root.render(element as Parameters[0]); + }); + const text = container.textContent ?? ""; + await done; + return text; +} + /** Lets timers and promises run, inside `act`. */ export const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); diff --git a/tests/react.test.ts b/tests/react.test.ts index 97d1862..35d132c 100644 --- a/tests/react.test.ts +++ b/tests/react.test.ts @@ -8,12 +8,15 @@ import { type ReactNode, } from "react"; +import { webStorage } from "@monarcode/session-kit"; import type { AuthHooks } from "@monarcode/session-kit/react"; import type { AnyRouter } from "@tanstack/react-router"; +import { deferredStorage } from "./deferred-storage.ts"; import { act, dom, + firstPaint, newClient, render, settle, @@ -221,6 +224,30 @@ for (const kind of Object.keys(setups)) { } } +for (const synchronous of [true, false]) { + test(`AuthProvider's first paint ${synchronous ? "shows the restored session from synchronous storage" : "is pending with asynchronous storage"}`, async () => { + await signIn(newClient()); + const storage = synchronous + ? webStorage() + : deferredStorage(webStorage()); + const auth = newClient({ storage }); + const { AuthProvider, useAuth } = binding.createAuthHooks(); + function Status() { + return h( + "output", + null, + useAuth((state) => state.status), + ); + } + const painted = await firstPaint( + h(AuthProvider, { client: auth }, h(Status)), + ); + assert.equal(painted, synchronous ? "authenticated" : "initializing"); + await settle(); + assert.equal(auth.state.get().status, "authenticated"); + }); +} + test("AuthProvider mounts its client while rendered", async () => { const auth = newClient(); const mount = auth.mount; diff --git a/tests/session.test.ts b/tests/session.test.ts index 1fb8b8a..794c830 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -580,11 +580,47 @@ test("restoring waits for asynchronous storage or schemas", async () => { } }); -test("an invalid saved session is still discarded when mounting", async () => { - values.set(SESSION, "invalid-json"); +test("mounting restores at once from synchronous storage and a synchronous schema", async () => { + await client().signIn(input()); const auth = client({ storage: webStorage() }); + const states = recordStates(auth); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().user?.id, "alice"); + await flush(); + assert.deepEqual( + states.map((state) => state.status), + ["authenticated"], + ); +}); + +test("mounting validates a saved user once with an asynchronous schema", async () => { + await client().signIn(input()); + let calls = 0; + // Zod runs an async refinement twice, so count at the Standard Schema level. + const schema = userSchema["~standard"]; + const asynchronous = { + "~standard": { + ...schema, + validate: (value: unknown) => { + calls++; + return Promise.resolve(schema.validate(value)); + }, + }, + }; + const auth = client({ storage: webStorage(), user: asynchronous }); cleanups.push(auth.mount()); assert.equal(auth.state.get().status, "initializing"); + assert.equal((await sessionOf(auth)).user.id, "alice"); + assert.equal(calls, 1); +}); + +test("mounting signs out of an invalid saved session at once, then discards it", async () => { + values.set(SESSION, "invalid-json"); + const auth = client({ storage: webStorage() }); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "unauthenticated"); + assert.equal(auth.state.get().error?.code, "INVALID_SESSION"); await flush(); assert.equal(auth.state.get().status, "unauthenticated"); assert.equal(values.size, 0); diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts index 90c5a19..8128a15 100644 --- a/tests/tabs.test.ts +++ b/tests/tabs.test.ts @@ -293,3 +293,20 @@ test("a user another tab saved that this tab's schema rejects signs every tab ou await broadcast(); assert.equal(a.state.get().status, "unauthenticated"); }); + +test("a remount applies what another tab saved while this one was unmounted", async () => { + const a = client(); + await a.signIn(input()); + const b = client(); + const off = b.mount(); + await flush(); + assert.equal(b.state.get().status, "authenticated"); + off(); + await a.signOut(); + await broadcast(); + // Unmounted, b missed the sign-out. + assert.equal(b.state.get().status, "authenticated"); + cleanups.push(b.mount()); + await flush(); + assert.equal(b.state.get().status, "unauthenticated"); +}); diff --git a/tests/users.test.ts b/tests/users.test.ts index f960511..156e196 100644 --- a/tests/users.test.ts +++ b/tests/users.test.ts @@ -182,3 +182,41 @@ test("a user read from the token restores at once when mounting", async () => { cleanups.push(auth.mount()); assert.equal(auth.state.get().user?.id, "alice"); }); + +test("a token user mapped from claims that are not JSON restores synchronously", async () => { + // The claims hold a Date; only the mapped user must be JSON. + const dated = fromAccessToken( + z.object({ + sub: z.string(), + exp: z.number().transform((seconds) => new Date(seconds * 1000)), + }), + (value) => ({ id: value.sub, expires: value.exp.toISOString() }), + ); + const storage = webStorage(); + await client({ user: dated, storage }).signIn({ + accessToken: jwt({ sub: "alice", exp: 9e9 }), + }); + const auth = client({ user: dated, storage }); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "authenticated"); + assert.equal(auth.state.get().user?.id, "alice"); +}); + +test("an asynchronous decoder runs once per restore", async () => { + let decodes = 0; + const decoded = fromAccessToken(claims, undefined, { + decode: async (token) => { + decodes++; + return JSON.parse( + Buffer.from(token.split(".")[1]!, "base64url").toString(), + ); + }, + }); + await client({ user: decoded }).signIn({ accessToken: jwt(alice) }); + decodes = 0; + const auth = client({ user: decoded }); + cleanups.push(auth.mount()); + assert.equal(auth.state.get().status, "initializing"); + assert.equal((await sessionOf(auth)).user.sub, "alice"); + assert.equal(decodes, 1); +}); From e79b27ea954e31ef87cd640cad8e701850a119a9 Mon Sep 17 00:00:00 2001 From: monarcode Date: Mon, 5 Oct 2026 18:44:21 +0100 Subject: [PATCH 12/12] refactor: derive client types from AuthClient and share route revalidation AuthClient requires both type arguments, so leaving out the input no longer drops updateUser silently. Guard, React, and outlet client shapes are picked from AuthClient. connectAuth and useAuthRevalidation share one version watcher, which no longer revalidates after unmount. Regroup the alpha.4 changelog and document sessionStorage in duplicated tabs. --- CHANGELOG.md | 103 +++++++++++++++++-------------- README.md | 75 +++++++++++++++++++--- examples/README.md | 6 ++ src/core/guard.ts | 23 +++---- src/core/maybe.ts | 8 +-- src/core/revalidate.ts | 38 ++++++++++++ src/core/types.ts | 5 +- src/http/index.ts | 2 +- src/react-router/guards.ts | 9 +-- src/react-router/outlet.ts | 3 +- src/react-router/revalidation.ts | 23 +------ src/react/context.ts | 12 +--- src/tanstack-router/connect.ts | 28 +++------ src/tanstack-router/guards.ts | 9 +-- tests/consumer.ts | 15 +++++ tests/helpers.ts | 8 ++- 16 files changed, 232 insertions(+), 135 deletions(-) create mode 100644 src/core/revalidate.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 031b6d8..40e99bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ ### Added +#### Storage + - `createAuth` takes a `storage` adapter. `webStorage()` saves the session in `localStorage`; `webStorage({ area: "session" })` uses `sessionStorage`, so each tab has its own session that ends with the tab. `memoryStorage()` keeps @@ -22,6 +24,11 @@ and re-checks its own session after every read. Storage that settles late or out of order cannot erase a newer sign-in, and a profile update cannot restore tokens this tab refreshed meanwhile. +- `webStorage()` explains when browser storage does not exist at all, as on a + server, instead of reporting a bare `TypeError`. + +#### Users and sessions + - `fromAccessToken(claims, map?)` reads the user from a JWT access token. Its claims are validated by a Standard Schema and `map` turns them into the user. `signIn` and the refresh callback then take tokens only, the user is not @@ -35,45 +42,53 @@ - Checking `state.status` narrows `state.user`: `AuthState` is now a discriminated union in which `user` is set exactly while `authenticated` or `refreshing`. +- Mounting restores the saved session at once when storage and the user + source both answer synchronously, as with `webStorage()` and a synchronous + schema. `AuthProvider` mounts in a layout effect, so the browser's first + paint shows the restored session rather than `initializing`, although + components may render once with `initializing` before that paint. A saved + session that cannot be restored signs out at once. Asynchronous storage, + schemas, or token decoding restore as before, and validate the saved user + once. - Session IDs no longer require `crypto.randomUUID`, and user validation no longer requires `structuredClone`, for environments such as React Native. + +#### React and routers + - A new `/tanstack-router` entry point holds the Router integration: `connectAuth`, plus guards that replace the protected-layout code apps used to copy. `requireSession` redirects signed-out users with a `redirectTo` search param and puts `{ session }` in route context; `SessionOutlet` renders child routes only while that session is current, remounting them for a new account; `redirectIfSignedIn` sends signed-in users away from the sign-in - page; `useRouterAuth` reads the client from Router context. -- React 18 is supported (`^18.0.0 || ^19.0.0`). CI tests React 18.0.0 as the - oldest version. + page; `useRouterAuth` reads the client from Router context. Guards read the + session again when it changes while they check it. - A new `/react-router` entry point supports React Router 7 and 8 in - declarative mode, data mode, and framework mode with `ssr: false`. - `SessionOutlet` guards routes in declarative mode, showing `pending` while - restoring, sending signed-out users to `loginPath` with `redirectTo`, and - offering a retry through `unavailable` after a failed refresh. In data mode, - `requireSession` and `redirectIfSignedIn` guard loaders, and - `useAuthRevalidation` re-runs loaders when auth changes. React Router is an - optional peer dependency. + declarative mode, data mode, and framework mode with `ssr: false`. React + Router is an optional peer dependency. + - In declarative mode, `SessionOutlet` is the guard: it shows `pending` + while restoring, sends signed-out users to `loginPath` with `redirectTo`, + and offers a retry through `unavailable` after a failed refresh. + - In data and framework mode, `requireSession` and `redirectIfSignedIn` + guard loaders, and `useAuthRevalidation` re-runs loaders when auth + changes. `SessionOutlet`, rendered by the guarded route, shows child + routes only while the session its loader accepted is still the signed-in + one, so they never see the previous account's loader data. Pass the + router's `basename` to `requireSession`, which leaves it out of + `redirectTo`. - TanStack Start in SPA mode is supported. `connectAuth` does nothing while the Router renders on a server, so Start's build-time shell shows the pending UI instead of a storage error. Apps served by Start's own server should set `defaultSsr: false`, so guards run in the browser. -- `webStorage()` explains when browser storage does not exist at all, as on a - server, instead of reporting a bare `TypeError`. +- React 18 is supported (`^18.0.0 || ^19.0.0`). CI tests React 18.0.0 as the + oldest version. + +#### Documentation + - The README's quick start signs in against DummyJSON with TanStack Router, and links to an example app for every other supported setup. - `examples/` holds one app per supported setup, built in CI, all signing in against DummyJSON's auth API. -- Mounting restores the saved session at once when storage and the user - source both answer synchronously, as with `webStorage()` and a synchronous - schema. `AuthProvider` mounts in a layout effect, so the browser's first - paint shows the restored session rather than `initializing`, although - components may render once with `initializing` before that paint. A saved - session that cannot be restored signs out at once. Asynchronous storage, - schemas, or token decoding restore as before, and validate the saved user - once. -- Mounting again after an unmount applies what another tab saved meanwhile, - such as a sign-out. ### Changed @@ -86,10 +101,14 @@ `{ sessionId, accessToken, expiresAt }`. `auth.refresh(session)` is now `auth.credentials.renew(credentials)`, `auth.rejectSession(session)` is now `auth.credentials.reject(credentials)`, and `isCurrent` takes any object - with a `sessionId`. + with a `sessionId`. `auth.refresh()` still exists but now takes no argument + and forces a refresh; TypeScript flags calls that still pass a session. - **Breaking:** `createRefreshFn` is removed. Pass the refresh callback inline, or type a standalone one with `RefreshFn>`. -- **Breaking:** `AuthClient` takes the user type first: `AuthClient`. +- **Breaking:** `AuthClient` takes the user type first and requires both type + arguments: `AuthClient`, with `never` as the input when the user + comes from the access token. Leaving out the input used to drop + `updateUser` silently. Prefer `typeof auth`. - **Breaking:** `/react` no longer depends on TanStack Router and exports only `createAuthHooks`, which returns `useAuth`, `useAuthClient`, and an `AuthProvider` typed for one client. With TanStack Router, keep the @@ -99,9 +118,9 @@ `/tanstack-router`, and `safeReturnTo` to the package root. - **Breaking:** TanStack React Router `^1.132.0` is required (was `^1.127.0`). Earlier versions include the router's `basepath` in - `location.href`, so `requireSession` put it in `redirectTo` and users came - back to a path with the `basepath` twice. CI tests 1.132.0 as the oldest - version. + `location.href`, so `requireSession` would put it in `redirectTo` and users + came back to a path with the `basepath` twice. CI tests 1.132.0 as the + oldest version. - **Breaking:** both peer dependencies are optional, and React DOM is no longer one. Install TanStack Router only for `/tanstack-router`, and React only for the React entry points. @@ -114,30 +133,22 @@ users sign in once after upgrading. - A refresh that returns a user equal to the current one keeps the same user object and does not change `version`, so guards do not run again. -- Unmounting stops timers and the storage watch but no longer cancels a - restore or refresh in progress; mounting again joins it. React's StrictMode - remount used to show an expired session as `unavailable`, reject loaders - waiting on `getSession()` with `SESSION_CHANGED`, and send the same refresh - token twice. With refresh token rotation, the second send could sign the - user out. + +### Fixed + +- Unmounting, as React's StrictMode does after the first mount, no longer + cancels a restore or refresh in progress; mounting again joins it. It used + to show an expired session as `unavailable`, reject callers waiting on + `getSession()` with `SESSION_CHANGED`, and send the same refresh token + twice, which signed the user out when the backend rotates refresh tokens. - A `signIn` whose validation fails no longer disturbs the current session. It used to cancel a restore in progress, leaving `status` at `initializing`, and abort a refresh in flight. While a sign-in validates, only a newer `signIn` or `signOut()` cancels it; expiry and other tabs do not. -- In React Router's data and framework mode, `SessionOutlet` renders child - routes only while the session its route's loader accepted is still the - signed-in one, as the TanStack Router `SessionOutlet` does. After another - account signs in, it shows `pending` until the loader runs again, instead - of rendering child routes with the previous account's loader data. A route - whose loader returns no session, and declarative mode, keep following the - signed-in session. -- React Router's `requireSession` takes the router's `basename`, which it - leaves out of `redirectTo`. Without it, an app with a `basename` returned - users to a path with the `basename` twice. -- Route guards read the session again when `getSession()` rejects with - `SESSION_CHANGED`, as they already did when it resolved a session that was - no longer current. +- Mounting again after an unmount applies what another tab saved meanwhile, + such as a sign-out. Changes made while nothing was mounted used to go + unnoticed until the next one. ## 0.1.0-alpha.3 diff --git a/README.md b/README.md index 5f15aa3..3fcc650 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,55 @@ them. Your backend does the authentication; session-kit does the rest: they are safe to render and to put in route context. Tokens go only to code that attaches them to requests, such as `createAuthFetch`. +## Contents + +- [Supported setups](#supported-setups) +- [Installation](#installation) +- [Quick start: TanStack Router](#quick-start-tanstack-router) +- [Other setups](#other-setups) +- [Using the client](#using-the-client) + - [State](#state) + - [Sessions and credentials](#sessions-and-credentials) + - [Signing in and out](#signing-in-and-out) + - [Profile updates and authenticated requests](#profile-updates-and-authenticated-requests) + - [Users from the access token](#users-from-the-access-token) +- [Schemas, refresh, and storage](#schemas-refresh-and-storage) + - [User schemas](#user-schemas) + - [Refresh](#refresh) + - [Expiry](#expiry) + - [Storage](#storage) + - [Tabs](#tabs) + - [Errors](#errors) +- [Security notes](#security-notes) +- [Entry points](#entry-points) +- [Development](#development) + +## Supported setups + +Each router setup has a runnable app in [`examples/`](./examples), all signing +in against DummyJSON's public test API. + +| Setup | Import | Example app | +| ------------------------------------------ | ---------------------------------------- | --------------------------------------------------------------------- | +| TanStack Router | `@monarcode/session-kit/tanstack-router` | [`tanstack-router`](./examples/tanstack-router) | +| TanStack Start, SPA mode | `@monarcode/session-kit/tanstack-router` | [`tanstack-start-spa`](./examples/tanstack-start-spa) | +| React Router, declarative mode | `@monarcode/session-kit/react-router` | [`react-router-declarative`](./examples/react-router-declarative) | +| React Router, data mode | `@monarcode/session-kit/react-router` | [`react-router-data`](./examples/react-router-data) | +| React Router, framework mode, `ssr: false` | `@monarcode/session-kit/react-router` | [`react-router-framework-spa`](./examples/react-router-framework-spa) | +| React without a router | `@monarcode/session-kit/react` | None | + +Storage adapters: `webStorage()` for `localStorage` or `sessionStorage`, +`memoryStorage()`, or your own [`AuthStorage`](#storage), which may be +asynchronous. + +Planned for releases after 0.1.0, not yet available: + +- **Server rendering:** TanStack Start with SSR, and React Router framework + mode with SSR, keeping tokens in an encrypted `httpOnly` cookie on the server. + A `cookieStorage()` adapter would read and write that cookie. +- **Expo (React Native):** a `secureStorage()` adapter for + `expo-secure-store`, and refreshing when the app returns to the foreground. + ## Installation Alpha releases are published to the `latest` distribution tag: @@ -393,16 +442,17 @@ createRoot(document.getElementById("root")!).render( ## Other setups -The same client works everywhere; only the router wiring changes. Each setup has -a complete app in [`examples/`](./examples), all signing in against DummyJSON. +The same client works everywhere; only the router wiring changes. The +[example apps](./examples) show each setup complete; these are the guards each +one uses: -| Setup | Example app | Guards | -| ------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| TanStack Router | [`tanstack-router`](./examples/tanstack-router) | `requireSession` in `beforeLoad`, `SessionOutlet` | -| TanStack Start, SPA mode | [`tanstack-start-spa`](./examples/tanstack-start-spa) | Same as TanStack Router | -| React Router, declarative mode | [`react-router-declarative`](./examples/react-router-declarative) | `SessionOutlet` alone | -| React Router, data mode | [`react-router-data`](./examples/react-router-data) | `requireSession` in loaders, `SessionOutlet`, `useAuthRevalidation` | -| React Router, framework mode, `ssr: false` | [`react-router-framework-spa`](./examples/react-router-framework-spa) | `requireSession` in `clientLoader`, `SessionOutlet`, `useAuthRevalidation` | +| Setup | Guards | +| ------------------------------------------ | -------------------------------------------------------------------------- | +| TanStack Router | `requireSession` in `beforeLoad`, `SessionOutlet` | +| TanStack Start, SPA mode | Same as TanStack Router | +| React Router, declarative mode | `SessionOutlet` alone | +| React Router, data mode | `requireSession` in loaders, `SessionOutlet`, `useAuthRevalidation` | +| React Router, framework mode, `ssr: false` | `requireSession` in `clientLoader`, `SessionOutlet`, `useAuthRevalidation` | **TanStack Start in SPA mode.** Create the client and call `connectAuth` inside `getRouter()`. Set `defaultSsr: false` in `src/start.ts`: Start's own server @@ -588,7 +638,12 @@ The required `storage` option chooses where the session is saved: - `webStorage()` uses `localStorage`, shared by every tab. - `webStorage({ area: "session" })` uses `sessionStorage`: one session per tab, - ending with the tab. + ending with the tab. Browsers copy `sessionStorage` into a duplicated tab + and into windows opened with `window.open`, so both start with the same + refresh token but never see each other's saves. If the backend rotates + refresh tokens, whichever tab refreshes second sends a spent token and is + signed out, or, with reuse detection, both are. Prefer `localStorage` with + rotating refresh tokens. - `memoryStorage()` never persists the session. - Any object implementing `AuthStorage` works, including one whose operations return promises, such as a native secure store. Auth applies operations one diff --git a/examples/README.md b/examples/README.md index 164f80a..d5e0945 100644 --- a/examples/README.md +++ b/examples/README.md @@ -41,6 +41,12 @@ what CI checks. server still renders requests in SPA mode, and the guards need the browser's saved session. +Each app is self-contained, so it can be copied out on its own. That is why +every app has the same `ui.tsx` rather than sharing one. The apps differ in +where the user comes from on purpose, so both user sources appear: the login +response, validated by a schema, or the access token's claims, read with +`fromAccessToken`. DummyJSON supports both. + DummyJSON has no logout endpoint, so the apps do not pass `revoke`. Its refresh tokens stay valid after rotation, so it cannot show the cross-tab rotation race that session-kit's tests cover. diff --git a/src/core/guard.ts b/src/core/guard.ts index 6c119d6..65aef65 100644 --- a/src/core/guard.ts +++ b/src/core/guard.ts @@ -1,10 +1,11 @@ import { AuthError } from "./errors.js"; +import type { AuthClient, Session } from "./types.js"; -/** What route guards need from an auth client. */ -export type GuardClient = { - getSession: () => Promise; - isCurrent: (session: { sessionId: string }) => boolean; -}; +/** What route guards need from an auth client with user `U`. */ +export type GuardClient = Pick< + AuthClient, + "getSession" | "isCurrent" +>; /** How often a guard reads the session again when it changes mid-check. */ const MAX_ATTEMPTS = 3; @@ -15,11 +16,11 @@ const MAX_ATTEMPTS = 3; * a session no longer current, is read again: the change re-runs guards * anyway, so an error would only flash. */ -export async function guardSession( - auth: GuardClient, -): Promise { +export async function guardSession( + auth: GuardClient, +): Promise | null> { for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - let session: S | null; + let session: Session | null; try { session = await auth.getSession(); } catch (error) { @@ -37,8 +38,8 @@ export async function guardSession( /** The session, or `null` when it cannot be resolved, for sign-in pages. */ export async function sessionOrNull( - auth: GuardClient<{ sessionId: string }>, -): Promise<{ sessionId: string } | null> { + auth: GuardClient, +): Promise | null> { try { return await auth.getSession(); } catch { diff --git a/src/core/maybe.ts b/src/core/maybe.ts index 8553aea..e54deee 100644 --- a/src/core/maybe.ts +++ b/src/core/maybe.ts @@ -15,14 +15,14 @@ export function isThenable(value: unknown): value is PromiseLike { ); } -/** Runs `next` on `value`'s result: at once unless `value` is a promise. */ +/** Runs `step` on `value`'s result: at once unless `value` is a promise. */ export function chain( value: T | PromiseLike, - next: (value: T) => MaybePromise, + step: (value: T) => MaybePromise, ): MaybePromise { return isThenable(value) - ? Promise.resolve(value).then(next) - : next(value as T); + ? Promise.resolve(value).then(step) + : step(value as T); } /** diff --git a/src/core/revalidate.ts b/src/core/revalidate.ts new file mode 100644 index 0000000..43878f4 --- /dev/null +++ b/src/core/revalidate.ts @@ -0,0 +1,38 @@ +import type { AuthClient } from "./types.js"; + +/** + * Calls `revalidate` after auth changes in a way guards care about, when + * `version` changes: a sign-in, sign-out, account switch, profile update, or + * failed refresh. Several changes in one turn call it once. `onChange` runs at + * once on every such change. Returns a function that stops watching and + * cancels a call not yet made. + */ +export function watchVersion( + auth: Pick, "state">, + revalidate: () => unknown, + onChange?: () => void, +): () => void { + let active = true; + let queued = false; + let previous = auth.state.get().version; + const { unsubscribe } = auth.state.subscribe((state) => { + if (state.version === previous) return; + previous = state.version; + onChange?.(); + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + if (!active) return; + void Promise.resolve() + .then(revalidate) + .catch((error: unknown) => { + console.error("Auth route revalidation failed", error); + }); + }); + }); + return () => { + active = false; + unsubscribe(); + }; +} diff --git a/src/core/types.ts b/src/core/types.ts index 649b3e2..070d059 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -242,9 +242,10 @@ type AuthClientBase = { /** * The auth client from `createAuth`. `U` is the user; `I` is what `signIn` and * `updateUser` accept as the user, or `never` when the user comes from the - * access token, in which case there is no `updateUser`. + * access token, in which case there is no `updateUser`. Prefer + * `typeof auth`, which gets both right. */ -export type AuthClient = AuthClientBase & +export type AuthClient = AuthClientBase & ([I] extends [never] ? unknown : { diff --git a/src/http/index.ts b/src/http/index.ts index 5b8dc5e..1021c7a 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -3,7 +3,7 @@ import type { AuthClient, Credentials } from "../core/types.js"; /** The part of the auth client that `createAuthFetch` uses. */ export type AuthFetchClient = Pick< - AuthClient, + AuthClient, "credentials" | "isCurrent" >; diff --git a/src/react-router/guards.ts b/src/react-router/guards.ts index cc7aa3f..1084f53 100644 --- a/src/react-router/guards.ts +++ b/src/react-router/guards.ts @@ -6,6 +6,7 @@ import { type GuardClient, } from "../core/guard.js"; import { loginHref, safeReturnTo } from "../core/return-to.js"; +import type { Session } from "../core/types.js"; export type RequireSessionOptions = { /** The loader's `request`, whose URL signed-out users come back to. */ @@ -45,10 +46,10 @@ function stripBasename(pathname: string, basename = "/"): string { * loader: ({ request }) => * requireSession(auth, { request, loginPath: "/login" }), */ -export async function requireSession( - auth: GuardClient, +export async function requireSession( + auth: GuardClient, options: RequireSessionOptions, -): Promise<{ session: S }> { +): Promise<{ session: Session }> { // Fails fast on a bad path, before any session is read. loginHref(options.loginPath, "/"); stripBasename("/", options.basename); @@ -84,7 +85,7 @@ export type RedirectIfSignedInOptions = { * }, */ export async function redirectIfSignedIn( - auth: GuardClient<{ sessionId: string }>, + auth: GuardClient, options: RedirectIfSignedInOptions, ): Promise { if (await sessionOrNull(auth)) { diff --git a/src/react-router/outlet.ts b/src/react-router/outlet.ts index 4f35374..a71424c 100644 --- a/src/react-router/outlet.ts +++ b/src/react-router/outlet.ts @@ -15,6 +15,7 @@ import { import { AuthError } from "../core/errors.js"; import { loginHref } from "../core/return-to.js"; +import type { AuthClient } from "../core/types.js"; import { assertAuthSource, useAuthState, @@ -22,7 +23,7 @@ import { type AuthSource, } from "../react/context.js"; -type OutletClient = AuthSource & { retry: () => Promise }; +type OutletClient = AuthSource & Pick, "retry">; type OutletState = { status: string; diff --git a/src/react-router/revalidation.ts b/src/react-router/revalidation.ts index ffb079c..4931c3d 100644 --- a/src/react-router/revalidation.ts +++ b/src/react-router/revalidation.ts @@ -1,14 +1,13 @@ import { useEffect, useRef } from "react"; import { useRevalidator } from "react-router"; +import { watchVersion } from "../core/revalidate.js"; import { assertAuthSource, useProvidedClient, type AuthSource, } from "../react/context.js"; -const versionOf = (state: unknown) => (state as { version: number }).version; - /** * Re-runs loaders whenever auth changes in a way guards care about: a * sign-in, sign-out, account switch, profile update, or failed refresh. Token @@ -32,23 +31,5 @@ export function useAuthRevalidation(client?: AuthSource): void { useEffect(() => { latest.current = revalidate; }, [revalidate]); - useEffect(() => { - let previous = versionOf(auth.state.get()); - let queued = false; - const { unsubscribe } = auth.state.subscribe((state) => { - const next = versionOf(state); - if (next === previous) return; - previous = next; - // Several changes in one turn re-run loaders once. - if (queued) return; - queued = true; - queueMicrotask(() => { - queued = false; - void Promise.resolve(latest.current()).catch((error: unknown) => { - console.error("Auth route revalidation failed", error); - }); - }); - }); - return unsubscribe; - }, [auth]); + useEffect(() => watchVersion(auth, () => latest.current()), [auth]); } diff --git a/src/react/context.ts b/src/react/context.ts index 50740e5..f6f5ddf 100644 --- a/src/react/context.ts +++ b/src/react/context.ts @@ -2,19 +2,13 @@ import { createContext, useCallback, useContext } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector"; +import type { AuthClient } from "../core/types.js"; + /** * What React bindings need from an auth client: reactive state, and `mount` * to start background work while rendered. Any `createAuth` client fits. */ -export type AuthSource = { - state: { - get: () => unknown; - subscribe: (listener: (state: unknown) => void) => { - unsubscribe: () => void; - }; - }; - mount: () => () => void; -}; +export type AuthSource = Pick, "state" | "mount">; /** The state type of a client. */ export type StateOf = ReturnType< diff --git a/src/tanstack-router/connect.ts b/src/tanstack-router/connect.ts index 9971c77..ea1ba2a 100644 --- a/src/tanstack-router/connect.ts +++ b/src/tanstack-router/connect.ts @@ -1,7 +1,8 @@ -import type { AuthClient } from "../core/types.js"; +import { watchVersion } from "../core/revalidate.js"; +import type { AuthSource } from "../react/context.js"; type AuthRouter = { - options: { context: { auth: Pick, "state" | "mount"> } }; + options: { context: { auth: AuthSource } }; invalidate: () => Promise; clearCache: () => void; /** Set while rendering on a server, such as Start's build-time shell. */ @@ -22,27 +23,16 @@ export function connectAuth(router: AuthRouter): () => void { if (existing) return existing; const auth = router.options.context.auth; let active = true; - let queued = false; - let previous = auth.state.get(); - const subscription = auth.state.subscribe((next) => { - if (next.version === previous.version) return; - previous = next; - router.clearCache(); - if (queued) return; - queued = true; - queueMicrotask(() => { - queued = false; - if (active) - void router.invalidate().catch((error) => { - console.error("Auth route revalidation failed", error); - }); - }); - }); + const unwatch = watchVersion( + auth, + () => router.invalidate(), + () => router.clearCache(), + ); const unmount = auth.mount(); const disconnect = () => { if (!active) return; active = false; - subscription.unsubscribe(); + unwatch(); unmount(); connections.delete(router); }; diff --git a/src/tanstack-router/guards.ts b/src/tanstack-router/guards.ts index 28333aa..6caeb0f 100644 --- a/src/tanstack-router/guards.ts +++ b/src/tanstack-router/guards.ts @@ -6,6 +6,7 @@ import { type GuardClient, } from "../core/guard.js"; import { loginHref, safeReturnTo } from "../core/return-to.js"; +import type { Session } from "../core/types.js"; export type RequireSessionOptions = { /** The route location, from `beforeLoad`'s `location`. */ @@ -26,10 +27,10 @@ export type RequireSessionOptions = { * beforeLoad: ({ context, location }) => * requireSession(context.auth, { location, loginPath: "/login" }), */ -export async function requireSession( - auth: GuardClient, +export async function requireSession( + auth: GuardClient, options: RequireSessionOptions, -): Promise<{ session: S }> { +): Promise<{ session: Session }> { // Fails fast on a bad path, before any session is read. loginHref(options.loginPath, "/"); const session = await guardSession(auth); @@ -58,7 +59,7 @@ export type RedirectIfSignedInOptions = { * redirectIfSignedIn(context.auth, { redirectTo: search.redirectTo }), */ export async function redirectIfSignedIn( - auth: GuardClient<{ sessionId: string }>, + auth: GuardClient, options: RedirectIfSignedInOptions, ): Promise { const session = await sessionOrNull(auth); diff --git a/tests/consumer.ts b/tests/consumer.ts index e45b276..e7e5011 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -10,6 +10,7 @@ import { fromAccessToken, memoryStorage, webStorage, + type AuthClient, type AuthState, type AuthStorage, type RefreshFn, @@ -204,3 +205,17 @@ reactRouter.SessionOutlet({ }); // @ts-expect-error The outlet needs the sign-in path. reactRouter.SessionOutlet({}); + +// `AuthClient` needs both the user and its input, so `updateUser` is never +// silently dropped; `typeof auth` gets both right. +// @ts-expect-error Pass the input type too, or `never` for a token user. +declare const missingInput: AuthClient<{ id: string }>; +void missingInput; +const typed: AuthClient< + z.infer, + z.input +> = auth; +void typed.updateUser; +const tokenTyped: AuthClient<{ id: string; admin: boolean }, never> = tokenAuth; +// @ts-expect-error A token user has no `updateUser`. +void tokenTyped.updateUser; diff --git a/tests/helpers.ts b/tests/helpers.ts index 2b4c604..6c888cd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -131,7 +131,7 @@ export const sessionOf = async (auth: { /** The current credentials, failing the test when there are none. */ export const credentialsOf = async ( - auth: Pick, "credentials">, + auth: Pick, "credentials">, ) => { const credentials: Credentials | null = await auth.credentials.get(); assert.ok(credentials, "Expected current credentials"); @@ -255,7 +255,7 @@ export const broadcast = async (key = SESSION) => { /** A Router stand-in that counts invalidations. */ export const connectCounting = ( - auth: Pick, "state" | "mount">, + auth: Pick, "state" | "mount">, ) => { const router = { invalidations: 0, @@ -270,7 +270,9 @@ export const connectCounting = ( }; /** Records every state the client publishes from now on. */ -export const recordStates = (auth: Pick, "state">) => { +export const recordStates = ( + auth: Pick, "state">, +) => { const states: AuthState[] = []; const subscription = auth.state.subscribe((state) => states.push(state)); cleanups.push(() => subscription.unsubscribe());