diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 83d95de..9577d9b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -66,12 +66,43 @@ 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.
+ # The override stops @types/react-dom pulling in newer React types.
- 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
- @tanstack/react-router@1.127.0
+ run: |
+ npm pkg set 'pnpm.overrides.@types/react=18.0.2'
+ 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.132.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 751a63d..40e99bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,155 @@
# Changelog
+## 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
+
+#### 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
+ 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.
+- `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
+ 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`.
+- 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. 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`. 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.
+- 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.
+
+### 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`. `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 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
+ 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:** TanStack React Router `^1.132.0` is required (was
+ `^1.127.0`). Earlier versions include the router's `basepath` in
+ `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.
+- **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.
+
+### 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.
+- 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
### Added
diff --git a/README.md b/README.md
index 37a3a3a..3fcc650 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,76 @@
# @monarcode/session-kit
-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.
+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`.
+
+## 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
@@ -16,39 +80,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).
-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:
+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.132.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. Browser use requires modern APIs including
-`structuredClone`, `AbortController`, and `crypto.randomUUID`.
+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`. 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.
+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
@@ -65,129 +126,123 @@ 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 { createAuth, webStorage } from "@monarcode/session-kit";
import { z } from "zod";
-import { createAuth, createRefreshFn } from "@monarcode/session-kit";
+
+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",
- userSchema,
- refresh: createRefreshFn(async ({ refreshToken, signal }) => {
- // Use plain fetch here to avoid recursive auth refresh.
- const response = await fetch("/api/auth/refresh", {
+ user: userSchema,
+ storage: webStorage(),
+ refresh: async ({ refreshToken, signal }) => {
+ 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 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 });
+}
+
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 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
+
+`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 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.
-### 2. Type the root route
+### 3. Type the root route
`src/routes/__root.tsx`
```tsx
-import {
- createRootRouteWithContext,
- Outlet,
- useRouter,
-} from "@tanstack/react-router";
-import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
+import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import type { AppAuth } from "../auth.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 && (
-
}
>
);
}
```
-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 sign-in route
`src/routes/login.tsx`
@@ -195,167 +250,117 @@ 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 { loginResponseSchema } from "../auth.js";
+import { createFileRoute, useRouter } from "@tanstack/react-router";
+import { safeReturnTo } from "@monarcode/session-kit";
+import { redirectIfSignedIn } from "@monarcode/session-kit/tanstack-router";
+import { signInWithPassword } from "../auth.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,
});
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 (
);
}
```
`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"] })`.
-
-### 4. Add a pathless protected layout
+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.
-The leading underscore makes `_authenticated.tsx` a pathless layout. Its child
-routes inherit the guard without adding `authenticated` to the URL.
+### 5. Guard private routes
-`src/routes/_authenticated.tsx`
+`src/routes/_authenticated.tsx` is a pathless layout: its child routes are
+guarded without `authenticated` appearing in their URLs.
```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 { 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",
- );
- }
- // Return public context only. Never put the token-bearing session here.
- return {
- sessionId: session.id,
- authVersion: context.auth.state.get().version,
- };
- },
+ beforeLoad: ({ context, location }) =>
+ requireSession(context.auth, { location, loginPath: "/login" }),
errorComponent: SessionError,
- component: ProtectedLayout,
+ 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"}
+
);
}
-
-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: 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`
@@ -363,7 +368,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,
@@ -372,17 +377,10 @@ export const Route = createFileRoute("/_authenticated/")({
function Home() {
const user = useAuth((state) => state.user);
const auth = useAuthClient();
-
return (
-
Welcome, {user?.email}
-
@@ -397,35 +395,19 @@ function Home() {
```tsx
-import { createRouter, useRouter } from "@tanstack/react-router";
-import { connectAuth } from "@monarcode/session-kit/react";
+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";
export const router = createRouter({
routeTree,
context: { auth },
- defaultPreload: "intent",
- defaultPreloadStaleTime: 0,
defaultPendingComponent: () =>
- );
-}
-
-// 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" {
@@ -435,20 +417,9 @@ 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.
-
-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.
-
-`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.
### 7. Render the app
@@ -460,26 +431,112 @@ 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();
-}
```
-## Profile updates and authenticated requests
+## Other setups
+
+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 | 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
+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.
+
+**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.
+
+- **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. 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.
+
+## Using the client
+
+### State
-`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:
+```ts
+const state = auth.state.get();
+const { unsubscribe } = auth.state.subscribe((next) => {});
+```
+
+`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:
@@ -487,206 +544,190 @@ This optional example assumes `PATCH /api/profile` returns the complete user:
import { createAuthFetch } from "@monarcode/session-kit/http";
import { auth, userSchema } from "./auth.js";
-export async function updateEmail(email: string) {
+const authFetch = createAuthFetch(auth, "https://dummyjson.com/");
+
+export async function reloadProfile() {
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})`);
+ const response = await authFetch("auth/me");
+ if (!response.ok) throw new Error(`Profile failed (${response.status})`);
return userSchema.parse(await response.json());
});
}
```
-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.
+`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.
-`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.
+### Users from the access token
-## Refresh and errors
+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:
-| 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. |
+```ts
+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(),
+});
-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`.
+await auth.signIn({ accessToken, refreshToken });
+```
-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 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.
-To treat every refresh failure as a sign-out instead, return `null` from the
-refresh callback rather than throwing:
+## Schemas, refresh, and storage
-```ts
-refresh: createRefreshFn(async ({ refreshToken, signal }) => {
- try {
- return await requestRefresh(refreshToken, signal);
- } catch {
- return null;
- }
-}),
-```
+### User schemas
+
+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.
+
+### Refresh
+
+| 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. |
-Expiry comes from the first of these that the tokens provide:
+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.
+
+To treat every refresh failure as a sign-out, return `null` instead of
+throwing.
+
+### 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. `Session.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. |
-| `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`, `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 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.
-- `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; backend revocation belongs to your app.
-- 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.
-- 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.
+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. 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
+ 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`, `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`, `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 runtime tests and `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, 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
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..d5e0945
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,52 @@
+# 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.
+
+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/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 (
+
+ );
+}
+
+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 (
+
+