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 && ( -
-

{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. - -### 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 (

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"] })`. - -### 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: () =>

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" { @@ -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 ( +
+

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 9f70960..54d1d2d 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 and TanStack Router", + "version": "0.1.0-alpha.4", + "description": "Schema-driven browser sessions for React, with TanStack Router and React Router support", "keywords": [ "auth", "react", + "react-router", "router", "standard-schema", "tanstack" @@ -37,6 +38,14 @@ "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" + }, + "./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" @@ -55,9 +64,12 @@ "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": "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", @@ -69,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", @@ -81,13 +93,25 @@ "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": "^19.0.0", - "react-dom": "^19.0.0" + "@tanstack/react-router": "^1.132.0", + "react": "^18.0.0 || ^19.0.0", + "react-router": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-router": { + "optional": true + }, + "react": { + "optional": true + }, + "react-router": { + "optional": true + } }, "devEngines": { "runtime": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5fd154..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 @@ -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 @@ -61,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'} @@ -94,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} @@ -322,6 +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==} @@ -329,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/store@0.11.1': - resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + '@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/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==} @@ -369,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==} @@ -399,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'} @@ -431,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'} @@ -440,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==} @@ -479,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'} @@ -491,10 +1265,36 @@ 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'} + 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'} + 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==} @@ -508,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'} @@ -518,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} @@ -545,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'} @@ -561,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'} @@ -586,25 +1519,135 @@ packages: utf-8-validate: optional: true - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} + xml-name-validator@5.0.0: + 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': + dependencies: + '@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) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@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 - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + '@babel/helper-string-parser@7.29.7': {} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + '@babel/helper-validator-identifier@7.29.7': {} -snapshots: + '@babel/helper-validator-option@7.29.7': {} - '@asamuzakjp/css-color@3.2.0': + '@babel/helpers@7.29.7': dependencies: - '@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) - '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) - '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) - '@csstools/css-tokenizer': 3.0.4 - lru-cache: 10.4.3 + '@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': {} @@ -626,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 @@ -740,34 +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: @@ -791,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: @@ -813,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 @@ -843,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 @@ -870,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: @@ -924,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): @@ -935,8 +2463,43 @@ 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 + cookie-es: 3.1.1 + react: 19.3.0 + optionalDependencies: + react-dom: 19.3.0(react@19.3.0) + 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: {} @@ -947,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: {} @@ -973,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 @@ -1000,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/* diff --git a/src/core/client.ts b/src/core/client.ts index 05e5f4b..07e05ef 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,9 +1,15 @@ -import type { StandardSchemaV1 } from "@standard-schema/spec"; 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 { + 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"; @@ -12,12 +18,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 }; @@ -27,7 +35,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 @@ -66,6 +74,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. * @@ -75,8 +107,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, @@ -87,17 +120,27 @@ 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; - const persistence = createPersistence(options.name, options.maxAge); +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, + options.name, + options.maxAge, + ); const store = atom>({ status: "initializing", user: null, @@ -105,10 +148,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; @@ -122,11 +166,17 @@ 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 // 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; @@ -137,6 +187,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 && @@ -145,30 +202,21 @@ 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; } } - 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"; @@ -185,6 +233,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, @@ -192,7 +241,7 @@ export function createAuth( sessionId, error, version: previous.version + Number(changed), - }), + }) as AuthState, ); } @@ -210,11 +259,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), ); } @@ -238,6 +287,7 @@ export function createAuth( function forget(error: AuthError | null = null) { advanceEpoch(); session = null; + revision++; initialized = true; rejectedToken = undefined; failure = undefined; @@ -245,33 +295,67 @@ 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. 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; + 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); + // 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", "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 +367,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(); } /** @@ -301,18 +383,21 @@ 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. */ async function reload() { advanceEpoch(); session = null; + revision++; initialized = false; rejectedToken = undefined; failure = undefined; @@ -326,121 +411,157 @@ 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() { + 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; } 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; - 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); + const previousUser = session.user; + let user = previousUser; + if (!userUnchanged) { + const expected = epoch; + try { + user = await runTask( + () => users.restore(stored), + 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 || + !sameSaved(current, stored) + ) + 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 || + !sameSaved(now, stored) + ) + 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++; + if (!sameUser(user, previousUser)) 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); - await signOut(); + 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(false); 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 = persistence.read(); - if (stored) { - const user = await runTask( - () => validateUser(options.userSchema, stored.user), - 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 = { ...stored, user }; - } - 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)) { - discardSaved(error); - return; - } + if (rejectsSaved(error)) return discardSaved(error); const previous = store.get(); store.set({ ...previous, status: "unavailable", error, 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; @@ -449,9 +570,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()) { @@ -461,10 +595,14 @@ export function createAuth( ); } const user = await runTask( - () => validateUser(options.userSchema, input.user), - controller.signal, + () => + users.receive( + tokens.accessToken, + (input as { user?: unknown }).user, + ), + signal, ); - assertEpoch(expected); + if (intent !== signInIntent) throw sessionChanged(); if (tokens.expiresAt !== undefined && tokens.expiresAt <= Date.now()) { throw new AuthError( "INVALID_SESSION", @@ -475,43 +613,70 @@ export function createAuth( rejectedToken = undefined; refreshAttempted = false; profileVersion++; - commit({ ...tokens, id: crypto.randomUUID(), user }); + await commit({ ...tokens, id: createId(), user }); } catch (cause) { throw toAuthError( "USER_VALIDATION_FAILED", "Could not accept the user", cause, ); - } finally { - if (epoch === expected) { - schedule(); - if (session && !valid()) { - if (mounts) inBackground(getSession()); - 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 { - persistence.clear(); + await persistence.clear(); } catch (cause) { - const error = toAuthError( + clearFailure = toAuthError( "PERSISTENCE_FAILED", "Could not clear the saved session", cause, ); - 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( @@ -523,41 +688,56 @@ export function createAuth( typeof input === "function" ? await (input as () => Promise)() : input; - return validateUser(options.userSchema, value); + return users.receive(current.accessToken, 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( - 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); 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 ? 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; @@ -565,7 +745,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) => @@ -573,7 +754,7 @@ export function createAuth( current, stored, refresh: refreshHandler, - userSchema: options.userSchema, + users, rejectedToken, signal, }), @@ -581,14 +762,15 @@ 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 && after.accessToken !== (stored ?? current).accessToken) ) return { kind: "stale" }; - await signOut(); + await signOut(false); return { kind: "signedOut" }; } if ( @@ -606,17 +788,26 @@ 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. + // 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; 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: next }; + } + await commit(next); + assertEpoch(expected); + return { kind: "installed", session: next }; }; const outcome = await persistence.exclusive(lifecycle, run); switch (outcome.kind) { @@ -657,45 +848,66 @@ 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) + if (mounts++ === 0) { unwatch = persistence.watch(() => inBackground(sync())); - inBackground(getSession()); + // 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; 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); } }; } - 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); @@ -709,19 +921,37 @@ export function createAuth( }), }, signIn, - signOut, - updateUser, - getSession, - retry: () => resume(true), + signOut: () => { + cancelSignIn(); + return 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); + 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/guard.ts b/src/core/guard.ts new file mode 100644 index 0000000..65aef65 --- /dev/null +++ b/src/core/guard.ts @@ -0,0 +1,48 @@ +import { AuthError } from "./errors.js"; +import type { AuthClient, Session } from "./types.js"; + +/** 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; + +/** + * The signed-in session for a guard, or `null`. A session that changes while + * 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 | null> { + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + let session: Session | 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( + "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, +): Promise | null> { + try { + return await auth.getSession(); + } catch { + return null; + } +} 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/maybe.ts b/src/core/maybe.ts new file mode 100644 index 0000000..e54deee --- /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 `step` on `value`'s result: at once unless `value` is a promise. */ +export function chain( + value: T | PromiseLike, + step: (value: T) => MaybePromise, +): MaybePromise { + return isThenable(value) + ? Promise.resolve(value).then(step) + : step(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 e9a13bd..575b2c5 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -1,15 +1,28 @@ 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"; -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; +/** 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, ) { @@ -23,116 +36,107 @@ export function createPersistence( ); } const key = `${name}:auth:session`; - const legacyKeys = [`${name}:auth:tokens`, `${name}:auth:user`]; const lockName = `${name}:auth:refresh`; - function storageOperation(work: () => T): T { + // 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(); + let pending = 0; + function enqueue(operation: () => Promise): Promise { + pending++; + const result = queue.then(operation).catch((cause: unknown) => { + throw storageError(cause); + }); + queue = result.catch(() => {}).finally(() => pending--); + return result; + } + + function clear(): Promise { + return enqueue(async () => { + await storage.remove(key); + }); + } + + /** Parses a saved entry, rejecting other formats and expired entries. */ + function parse(text: string | null): StoredSession | null { + if (!text) return null; try { - return work(); + 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) { - if (cause instanceof AuthError) throw cause; throw new AuthError( - "PERSISTENCE_FAILED", - "Auth storage is unavailable", - { - cause, - }, + "INVALID_SESSION", + "Saved session cannot be restored", + { cause }, ); } } - function clear() { - storageOperation(() => { - const failures: unknown[] = []; - for (const entry of [key, ...legacyKeys]) { - try { - localStorage.removeItem(entry); - } catch (error) { - failures.push(error); - } - } - if (failures.length) - throw new AggregateError(failures, "Could not clear auth storage"); - }); - } - - function read(): StoredSession | null { - return storageOperation(() => { - const text = localStorage.getItem(key); - if (!text) { - for (const entry of legacyKeys) localStorage.removeItem(entry); - return null; - } - try { - const saved: unknown = JSON.parse(text); - if ( - !isRecord(saved) || - 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() - ) { - 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 }, - ); - } - }); + /** + * 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 read(): MaybePromise { + if (pending) return enqueue(async () => parse(await storage.get(key))); + let text: ReturnType; + try { + 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) { - 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/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/react/return-to.ts b/src/core/return-to.ts similarity index 67% rename from src/react/return-to.ts rename to src/core/return-to.ts index 2431805..15294b4 100644 --- a/src/react/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 "/"; @@ -44,3 +47,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/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/sources.ts b/src/core/sources.ts new file mode 100644 index 0000000..3212627 --- /dev/null +++ b/src/core/sources.ts @@ -0,0 +1,144 @@ +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 { 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) => MaybePromise; + /** + * The user for a saved session. Synchronous when the source is, so a + * session can be restored before the first render. + */ + restore: (saved: StoredSession) => MaybePromise; + /** 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", + ); + } + /** 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", + "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/storage.ts b/src/core/storage.ts new file mode 100644 index 0000000..d45d779 --- /dev/null +++ b/src/core/storage.ts @@ -0,0 +1,109 @@ +/** 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 = () => { + 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), + 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/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/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 b53c41a..070d059 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,13 +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. @@ -45,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: @@ -58,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`. */ @@ -75,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; }>; /** @@ -103,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 @@ -135,54 +200,84 @@ 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`. Prefer + * `typeof auth`, which gets both right. + */ +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 localStorage key `:auth:session` and the Web Lock - * `:auth:refresh`. + * the storage key `:auth:session` and the lock `:auth:refresh`. */ name: string; /** - * 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..a34fdf9 100644 --- a/src/core/user.ts +++ b/src/core/user.ts @@ -2,21 +2,22 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { AuthError } from "./errors.js"; import { isRecord } from "./json.js"; -import type { User } from "./types.js"; +import { attempt, chain, type MaybePromise } from "./maybe.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 +31,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 +39,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,43 +54,92 @@ 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( +/** + * 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 validateUser( schema: S, input: unknown, -): Promise> { - try { - const result = await schema["~standard"].validate(structuredClone(input)); - if (result.issues) { - throw new AuthError("USER_VALIDATION_FAILED", "Invalid user", { - issues: result.issues, - }); - } - if (!isRecord(result.value)) { +): 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 must be an object", + "User validation failed", + { cause }, ); - } - freezeJson(result.value); - return result.value; + }, + ); +} + +/** + * 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) { - if (cause instanceof AuthError) throw cause; - throw new AuthError("USER_VALIDATION_FAILED", "User validation failed", { - 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..1021c7a 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 6650b69..d1e3616 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,30 @@ export { createAuth } from "./core/client.js"; -export { createRefreshFn } from "./core/refresh.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, + UserSource, +} from "./core/sources.js"; +export type { AuthStorage, WebStorageOptions } from "./core/storage.js"; export type { AuthClient, AuthOptions, AuthState, AuthStatus, - Tokens, - SignInInput, - Session, + Credentials, RefreshFn, RefreshResult, + RevokeFn, + Session, + SignInInput, + Tokens, User, UserInput, } from "./core/types.js"; diff --git a/src/react-router/guards.ts b/src/react-router/guards.ts new file mode 100644 index 0000000..1084f53 --- /dev/null +++ b/src/react-router/guards.ts @@ -0,0 +1,96 @@ +import { redirect } from "react-router"; + +import { + guardSession, + sessionOrNull, + 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. */ + 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()`. + * + * @example + * loader: ({ request }) => + * requireSession(auth, { request, loginPath: "/login" }), + */ +export async function requireSession( + auth: GuardClient, + options: RequireSessionOptions, +): Promise<{ session: Session }> { + // 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, path + 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, + 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..a71424c --- /dev/null +++ b/src/react-router/outlet.ts @@ -0,0 +1,138 @@ +import { + createElement, + Fragment, + useContext, + type ReactElement, + type ReactNode, +} from "react"; +import { + Navigate, + Outlet, + UNSAFE_DataRouterStateContext, + useLoaderData, + useLocation, +} from "react-router"; + +import { AuthError } from "../core/errors.js"; +import { loginHref } from "../core/return-to.js"; +import type { AuthClient } from "../core/types.js"; +import { + assertAuthSource, + useAuthState, + useProvidedClient, + type AuthSource, +} from "../react/context.js"; + +type OutletClient = AuthSource & Pick, "retry">; + +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 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` + * 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; +}; + +/** + * 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, 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 + * } />}> + * } /> + * + */ +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); + 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, { + 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..4931c3d --- /dev/null +++ b/src/react-router/revalidation.ts @@ -0,0 +1,35 @@ +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"; + +/** + * 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(() => watchVersion(auth, () => latest.current()), [auth]); +} diff --git a/src/react/connect.ts b/src/react/connect.ts deleted file mode 100644 index 99deb89..0000000 --- a/src/react/connect.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AuthClient } from "../core/types.js"; - -type AuthRouter = { - options: { context: { auth: AuthClient } }; - invalidate: () => Promise; - clearCache: () => void; -}; -const connections = new WeakMap void>(); - -export function connectAuth(router: AuthRouter): () => void { - const existing = connections.get(router); - 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 unmount = auth.mount(); - const disconnect = () => { - if (!active) return; - active = false; - subscription.unsubscribe(); - unmount(); - connections.delete(router); - }; - connections.set(router, disconnect); - return disconnect; -} diff --git a/src/react/context.ts b/src/react/context.ts new file mode 100644 index 0000000..f6f5ddf --- /dev/null +++ b/src/react/context.ts @@ -0,0 +1,67 @@ +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 = Pick, "state" | "mount">; + +/** 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..d47652f 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -1,74 +1,113 @@ -import { useCallback } from "react"; +import { + createContext, + createElement, + useContext, + useEffect, + useLayoutEffect, + 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; +// 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 RegisteredAuthState = RegisteredAuth extends { - state: { get: () => infer State }; -} - ? State - : 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; +}; -type AuthSource = { - state: { - get: () => unknown; - subscribe: (listener: (value: unknown) => void) => { - unsubscribe: () => void; - }; +export type AuthProviderProps = { + /** The client for this tree. On a server, create one per request. */ + client: Client; + children?: ReactNode; +}; + +export type AuthHooks = { + /** + * Provides `client` to the hooks below it and mounts it while rendered, + * 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`. */ + 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) { + useMountEffect(() => 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/tanstack-router/connect.ts b/src/tanstack-router/connect.ts new file mode 100644 index 0000000..ea1ba2a --- /dev/null +++ b/src/tanstack-router/connect.ts @@ -0,0 +1,41 @@ +import { watchVersion } from "../core/revalidate.js"; +import type { AuthSource } from "../react/context.js"; + +type AuthRouter = { + options: { context: { auth: AuthSource } }; + 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; + let active = true; + const unwatch = watchVersion( + auth, + () => router.invalidate(), + () => router.clearCache(), + ); + const unmount = auth.mount(); + const disconnect = () => { + if (!active) return; + active = false; + unwatch(); + unmount(); + connections.delete(router); + }; + connections.set(router, disconnect); + return disconnect; +} diff --git a/src/tanstack-router/guards.ts b/src/tanstack-router/guards.ts new file mode 100644 index 0000000..6caeb0f --- /dev/null +++ b/src/tanstack-router/guards.ts @@ -0,0 +1,73 @@ +import { redirect } from "@tanstack/react-router"; + +import { + guardSession, + sessionOrNull, + 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`. */ + 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: Session }> { + // 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 = { + /** 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, + options: RedirectIfSignedInOptions, +): Promise { + const session = await sessionOrNull(auth); + 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/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..e7e5011 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -5,16 +5,36 @@ import { import { z } from "zod"; import { createAuthFetch } from "../dist/http/index.js"; -import { createAuth, createRefreshFn } from "../dist/index.js"; -import { connectAuth, useAuth, useAuthClient } from "../dist/react/index.js"; +import { + createAuth, + fromAccessToken, + memoryStorage, + webStorage, + type AuthClient, + type AuthState, + type AuthStorage, + 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, + requireSession, + SessionOutlet, + useRouterAuth, +} from "../dist/tanstack-router/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 } }); @@ -27,6 +47,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(); @@ -46,13 +92,130 @@ 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 = { + get: async () => null, + set: async () => {}, + remove: async () => {}, +}; +for (const storage of [ + asyncStorage, + memoryStorage(), + webStorage({ area: "session" }), +]) { + createAuth({ name: "stored", user: userSchema, storage }); +} +// @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({}); + +// `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/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 new file mode 100644 index 0000000..f4374af --- /dev/null +++ b/tests/dom.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach } from "node:test"; + +import * as React from "react"; +import { createElement, Fragment, StrictMode, type ReactNode } from "react"; + +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; + +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: 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; +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(); + }, + }; +} + +/** + * 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/expiry.test.ts b/tests/expiry.test.ts index 06fb89f..86e0654 100644 --- a/tests/expiry.test.ts +++ b/tests/expiry.test.ts @@ -9,7 +9,8 @@ import { client, code, recordStates, - sessionOf, + saved, + credentialsOf, useBrowserMocks, } from "./helpers.ts"; @@ -36,7 +37,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(); }); @@ -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(); @@ -100,12 +138,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 +159,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 +167,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 +180,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 +207,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 4b3f2e2..6c888cd 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,12 +7,18 @@ import { type AuthErrorCode, type AuthOptions, type AuthState, - 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 { 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(), @@ -41,9 +47,11 @@ let locks: ReturnType; let storageEvents: EventTarget; let originalGlobals: Map; const browserGlobals = [ + "crypto", "location", "document", "localStorage", + "sessionStorage", "fetch", "self", "scrollTo", @@ -80,8 +88,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, @@ -90,17 +98,21 @@ export const input = ( 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, + user: userSchema, + storage: testStorage(), ...(options as Partial>), }); } @@ -109,12 +121,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" && @@ -227,7 +254,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 } }, @@ -241,7 +270,9 @@ 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 ea7acff..50d25ed 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -2,18 +2,44 @@ 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, + fromAccessToken, + memoryStorage, + 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 { + requireSession as requireLoaderSession, + SessionOutlet as RouterSessionOutlet, + useAuthRevalidation, +} from "@monarcode/session-kit/react-router"; +import { + connectAuth, + redirectIfSignedIn, + requireSession, + SessionOutlet, + useRouterAuth, +} from "@monarcode/session-kit/tanstack-router"; 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, + createAuthHooks, connectAuth, - useAuth, + requireSession, + redirectIfSignedIn, + SessionOutlet, + useRouterAuth, + requireLoaderSession, + RouterSessionOutlet, + useAuthRevalidation, createAuthFetch, ]) { assert.equal(typeof fn, "function"); @@ -34,15 +60,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/); -}); - -test("refresh definition preserves the callback", () => { - const callback = async () => ({ accessToken: "new-token" }); - assert.equal(createRefreshFn(callback), callback); + assert.match(declarations, /useRouterAuth\(\): RegisteredAuth/); + assert.doesNotMatch(declarations, /useRouterAuth\(\): any/); }); diff --git a/tests/react-router.test.ts b/tests/react-router.test.ts new file mode 100644 index 0000000..117243e --- /dev/null +++ b/tests/react-router.test.ts @@ -0,0 +1,568 @@ +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, + heldRefresh, + newClient, + render, + saveExpiredSession, + 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: 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(); + 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()); + 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"), "/", redirectTo); + } + await assert.rejects( + binding.requireSession(auth, { request, loginPath: "login" }), + /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/react.test.ts b/tests/react.test.ts index 731c078..35d132c 100644 --- a/tests/react.test.ts +++ b/tests/react.test.ts @@ -1,269 +1,391 @@ import assert from "node:assert/strict"; -import { test, beforeEach, afterEach } from "node:test"; +import { beforeEach, test } from "node:test"; import { - act, createElement as h, - StrictMode, + useEffect, type FunctionComponent, + type ReactNode, } from "react"; -import { - createAuth, - type AuthClient, - type AuthState, -} from "@monarcode/session-kit"; +import { webStorage } 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; -/** 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; -}; +import { deferredStorage } from "./deferred-storage.ts"; +import { + act, + dom, + firstPaint, + 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"); -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; +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, - 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, - }); - } - ({ 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 () => { - try { - await act(async () => { - for (const root of roots) root.unmount(); +/** + * 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: ["/"] }), }); - } finally { - dom.window.close(); - for (const [name, descriptor] of originalGlobals) { - if (descriptor) Object.defineProperty(globalThis, name, descriptor); - else delete (globalThis as Record)[name]; + 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"); + }); -const user = { id: "alice", email: "alice@example.com" }; -const signIn = (auth: Client) => auth.signIn({ accessToken: "access-1", user }); + 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"); + }); -async function fixture() { - const auth = createAuth({ - name: "react-test", - userSchema, + 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"); }); - await auth.getSession(); - const router = createRouter({ - routeTree: createRootRoute({}), - context: { auth }, - history: createMemoryHistory({ initialEntries: ["/"] }), + + 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)); }); - return { auth, router }; + + 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); + }); + } } -async function mount( - router: AnyRouter, - Component: FunctionComponent, - { 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), - ); - return { - container, - async unmount() { - await act(async () => root.unmount()); - roots.delete(root); - container.remove(); - }, - }; +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("useAuth renders sign-in, profile updates, and sign-out", async () => { - const { auth, router } = await fixture(); - function Profile() { - const state = useAuth(); +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 }), + ); + 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/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/return-to.test.ts b/tests/return-to.test.ts index 8d9db01..20cc7a3 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"; @@ -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 6580d7b..8fbb558 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -2,10 +2,15 @@ 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, + userSchema, cleanups, deferred, flush, @@ -14,7 +19,7 @@ import { code, connectCounting, recordStates, - sessionOf, + credentialsOf, setGlobal, type TestClient, useBrowserMocks, @@ -90,7 +95,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 +117,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 +140,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); @@ -146,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() {}, @@ -223,7 +230,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,9 +246,184 @@ 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"); assert.equal(privateMatch(router)?.status, "success"); }); + +/** A real Router whose `/private` uses `requireSession` and `/login` `redirectIfSignedIn`. */ +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() {}, + }); + 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, + basepath, + }); + router.startTransition = (async (fn: () => void) => { + fn(); + return true; + }) as unknown as typeof router.startTransition; + await router.load(); + 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(); + 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/", + "/.//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/, + ); +}); + +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; + 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/session.test.ts b/tests/session.test.ts index 1c04785..794c830 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, webStorage, type AuthError } from "@monarcode/session-kit"; import { z } from "zod"; import { @@ -19,8 +19,12 @@ import { input, client, code, + deferredStorage, + recordStates, + credentialsOf, sessionOf, saved, + setGlobal, useBrowserMocks, } from "./helpers.ts"; @@ -33,9 +37,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 +59,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 +73,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 +134,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 +163,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 () => { @@ -186,12 +190,12 @@ 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; const auth = client({ - userSchema: userSchema.refine(async (value) => + user: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), refresh: async () => { @@ -200,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(); @@ -207,11 +212,11 @@ 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 sessionOf(auth)).accessToken, "access-2"); + assert.equal((await credentialsOf(auth)).accessToken, "access-2"); assert.equal(refreshCalls, 1); }); @@ -219,7 +224,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 +243,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 +254,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 +298,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 +339,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 +364,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"), @@ -379,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; @@ -387,14 +392,236 @@ 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 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 () => { + 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/, + ); +}); + +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("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/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..eedea9f --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,168 @@ +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, + credentialsOf, + 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 credentialsOf(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 credentialsOf(auth)).accessToken, "access-2"); + 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 ?? ""); + }); +}); diff --git a/tests/tabs.test.ts b/tests/tabs.test.ts index fccf08c..8128a15 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"); @@ -281,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 new file mode 100644 index 0000000..156e196 --- /dev/null +++ b/tests/users.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + fromAccessToken, + webStorage, + 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); +}); + +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"); +}); + +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); +});