diff --git a/.editorconfig b/.editorconfig
index 63254bc..a7f1275 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -12,3 +12,6 @@ max_line_length = 80
[*.md]
trim_trailing_whitespace = false
+# GitHub and npm render tabs 8 columns wide, so code examples use spaces.
+indent_style = space
+indent_size = 2
diff --git a/.oxfmtrc.json b/.oxfmtrc.json
index f9355b8..bb60ff6 100644
--- a/.oxfmtrc.json
+++ b/.oxfmtrc.json
@@ -11,6 +11,12 @@
"endOfLine": "lf",
"bracketSpacing": true,
"insertFinalNewline": true,
+ "overrides": [
+ {
+ "files": ["*.md"],
+ "options": { "useTabs": false, "tabWidth": 2 }
+ }
+ ],
"ignorePatterns": [
"node_modules",
"dist",
@@ -19,8 +25,7 @@
"routeTree.gen.ts",
"*.gen.ts",
"coverage",
- "templates/",
- "scripts/"
+ "templates/"
],
"sortImports": {
"customGroups": [
diff --git a/.oxlintrc.json b/.oxlintrc.json
index a49ab01..b7d9d2a 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -21,8 +21,7 @@
"**/routeTree.gen.ts",
"**/*.gen.ts",
"**/coverage/**",
- "**/templates/**",
- "**/scripts/**"
+ "**/templates/**"
],
"rules": {
"no-unused-vars": "off",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 706a370..751a63d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
# Changelog
+## 0.1.0-alpha.3
+
+### Added
+
+- `AuthStatus` names the union of `AuthState.status` values, for helpers and
+ exhaustive `switch` statements.
+
+### Changed
+
+- Reactive state uses Nano Stores instead of TanStack Store. Apps on Router
+ versions that depend on an older TanStack Store no longer bundle two copies.
+- **Breaking:** `AuthClient.state` is typed by this package rather than
+ `@tanstack/store`. `state.subscribe` takes a listener function; observer
+ objects (`{ next, error, complete }`) are no longer accepted.
+- **Breaking:** the `AuthError` constructor takes schema `issues` in its options
+ object, `new AuthError(code, message, { cause, issues })`, instead of as a
+ fourth argument. The new `AuthErrorOptions` type describes those options.
+
+### Fixed
+
+- A `state.subscribe` listener that throws no longer makes `signIn` (or another
+ operation) reject after it succeeded, or stops later listeners, including
+ `useAuth` and `connectAuth`, from seeing the change. The error is rethrown
+ asynchronously instead.
+- When another tab saves a user that this tab's schema rejects, for example
+ while two deploys are open, this tab no longer keeps the old profile with no
+ error. It signs every tab out with `USER_VALIDATION_FAILED`, as restoring
+ that session would.
+
## 0.1.0-alpha.2
### Changed
diff --git a/README.md b/README.md
index 2bc30dc..37a3a3a 100644
--- a/README.md
+++ b/README.md
@@ -2,21 +2,21 @@
Schema-driven browser authentication for React and TanStack Router.
-**Version: 0.1.0-alpha.0. Early development; not ready for production.**
+**Version: 0.1.0-alpha.3. Early development; not ready for production.**
-One auth client lives in Router context. TanStack Store makes its state reactive,
+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.
## Installation
-Install the alpha using the `next` distribution tag:
+Alpha releases are published to the `latest` distribution tag:
```sh
-pnpm add @monarcode/session-kit@next
+pnpm add @monarcode/session-kit
```
-Pin `@monarcode/session-kit@0.1.0-alpha.0` to use this exact version. Alpha
+Pin `@monarcode/session-kit@0.1.0-alpha.3` to use this exact version. Alpha
releases may change the public API. See [release notes](./CHANGELOG.md).
The declared peer ranges are React and React DOM `^19.0.0`, and TanStack React
@@ -29,7 +29,9 @@ pnpm add react@19.3.0 react-dom@19.3.0 @tanstack/react-router@1.170.38 zod@4.4.3
```
Zod is optional: any compatible Standard Schema V1 implementation can describe
-users. The package is ESM-only, sets no Node.js engine requirement, and is checked with
+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`.
@@ -55,10 +57,10 @@ import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
export default defineConfig({
- plugins: [
- tanstackRouter({ target: "react", autoCodeSplitting: true }),
- react(),
- ],
+ plugins: [
+ tanstackRouter({ target: "react", autoCodeSplitting: true }),
+ react(),
+ ],
});
```
@@ -89,36 +91,36 @@ import { z } from "zod";
import { createAuth, createRefreshFn } from "@monarcode/session-kit";
export const userSchema = z.object({
- id: z.string(),
- email: z.string(),
+ id: z.string(),
+ email: z.string(),
});
const tokensSchema = z.object({
- accessToken: z.string().min(1),
- refreshToken: z.string().min(1).optional(),
- expiresIn: z.number().positive().optional(),
+ 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(),
+ user: userSchema.optional(),
});
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", {
- method: "POST",
- signal,
- redirect: "error",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ refreshToken }),
- });
- if (response.status === 401) return null;
- if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
- return refreshResponseSchema.parse(await response.json());
- }),
+ 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", {
+ method: "POST",
+ signal,
+ redirect: "error",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refreshToken }),
+ });
+ if (response.status === 401) return null;
+ if (!response.ok) throw new Error(`Refresh failed (${response.status})`);
+ return refreshResponseSchema.parse(await response.json());
+ }),
});
export type AppAuth = typeof auth;
@@ -138,46 +140,46 @@ frozen; use `updateUser` to replace it.
```tsx
import {
- createRootRouteWithContext,
- Outlet,
- useRouter,
+ createRootRouteWithContext,
+ Outlet,
+ useRouter,
} from "@tanstack/react-router";
import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
import type { AppAuth } from "../auth.js";
export const Route = createRootRouteWithContext<{ auth: AppAuth }>()({
- component: Root,
+ component: Root,
});
function Root() {
- const error = useAuth((state) => state.error);
- const auth = useAuthClient();
- const router = useRouter();
-
- return (
- <>
- {error && (
-
-
{error.message}
- {error.code === "PERSISTENCE_FAILED" && (
-
- )}
-
- )}
-
- >
- );
+ const error = useAuth((state) => state.error);
+ const auth = useAuthClient();
+ const router = useRouter();
+
+ return (
+ <>
+ {error && (
+
+
{error.message}
+ {error.code === "PERSISTENCE_FAILED" && (
+
+ )}
+
+ )}
+
+ >
+ );
}
```
@@ -198,75 +200,72 @@ import { safeReturnTo, useAuthClient } from "@monarcode/session-kit/react";
import { loginResponseSchema } 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) });
- }
- },
- component: Login,
+ validateSearch: (search: Record) => ({
+ redirectTo: safeReturnTo(search.redirectTo),
+ }),
+ beforeLoad: async ({ context, search }) => {
+ if (await context.auth.getSession()) {
+ throw redirect({ href: safeReturnTo(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,
- });
- } catch (cause) {
- setError(cause instanceof Error ? cause.message : "Could not sign in");
- } finally {
- setPending(false);
- }
- }
-
- return (
-
- );
+ 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,
+ });
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Could not sign in");
+ } finally {
+ setPending(false);
+ }
+ }
+
+ return (
+
+ );
}
```
@@ -286,69 +285,69 @@ routes inherit the guard without adding `authenticated` to the URL.
```tsx
import {
- createFileRoute,
- Outlet,
- redirect,
- type ErrorComponentProps,
+ createFileRoute,
+ Outlet,
+ redirect,
+ type ErrorComponentProps,
} from "@tanstack/react-router";
import { AuthError } from "@monarcode/session-kit";
import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
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,
- };
- },
- errorComponent: SessionError,
- component: ProtectedLayout,
+ 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,
+ };
+ },
+ errorComponent: SessionError,
+ component: ProtectedLayout,
});
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.
-
-
- );
+ 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.
+
+
+ );
}
function ProtectedLayout() {
- const { authVersion } = Route.useRouteContext();
- const status = useAuth((state) => state.status);
- const version = useAuth((state) => state.version);
- const usable = status === "authenticated" || status === "refreshing";
- if (!usable || version !== authVersion) {
- return Checking session…
;
- }
- return ;
+ 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 ;
}
```
@@ -367,27 +366,27 @@ import { createFileRoute } from "@tanstack/react-router";
import { useAuth, useAuthClient } from "@monarcode/session-kit/react";
export const Route = createFileRoute("/_authenticated/")({
- component: Home,
+ component: Home,
});
function Home() {
- const user = useAuth((state) => state.user);
- const auth = useAuthClient();
-
- return (
-
- Welcome, {user?.email}
-
-
- );
+ const user = useAuth((state) => state.user);
+ const auth = useAuthClient();
+
+ return (
+
+ Welcome, {user?.email}
+
+
+ );
}
```
@@ -404,37 +403,35 @@ 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 }) => ,
+ 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"}
-
-
-
- );
+ const router = useRouter();
+ return (
+
+
+ {error instanceof Error ? error.message : "Could not load this page"}
+
+
+
+ );
}
// Call once beside the stable Router, outside component rendering.
export const disconnectAuth = connectAuth(router);
declare module "@tanstack/react-router" {
- interface Register {
- router: typeof router;
- }
+ interface Register {
+ router: typeof router;
+ }
}
```
@@ -443,6 +440,11 @@ 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.
@@ -462,14 +464,14 @@ import { router, disconnectAuth } from "./router.js";
const root = createRoot(document.getElementById("root")!);
root.render(
-
-
- ,
+
+
+ ,
);
export function dispose() {
- root.unmount();
- disconnectAuth();
+ root.unmount();
+ disconnectAuth();
}
```
@@ -486,17 +488,17 @@ import { createAuthFetch } from "@monarcode/session-kit/http";
import { auth, userSchema } from "./auth.js";
export async function updateEmail(email: string) {
- await auth.updateUser(async () => {
- const request = createAuthFetch(auth, `${location.origin}/api/`);
- const response = await request("profile", {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email }),
- });
- if (!response.ok)
- throw new Error(`Profile update failed (${response.status})`);
- return userSchema.parse(await response.json());
- });
+ await auth.updateUser(async () => {
+ const request = createAuthFetch(auth, `${location.origin}/api/`);
+ const response = await request("profile", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ });
+ if (!response.ok)
+ throw new Error(`Profile update failed (${response.status})`);
+ return userSchema.parse(await response.json());
+ });
}
```
@@ -536,11 +538,11 @@ refresh callback rather than throwing:
```ts
refresh: createRefreshFn(async ({ refreshToken, signal }) => {
- try {
- return await requestRefresh(refreshToken, signal);
- } catch {
- return null;
- }
+ try {
+ return await requestRefresh(refreshToken, signal);
+ } catch {
+ return null;
+ }
}),
```
@@ -615,7 +617,9 @@ the remaining data after storage becomes available.
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`. Scope private Query data
+ 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
@@ -647,10 +651,10 @@ pnpm install
pnpm run check
```
-`check` runs Oxlint and Oxfmt checks, builds the package, runs 45 auth behavior tests, five React hook tests,
-four package smoke tests, and five release validation tests, then checks consumer
-declarations and every marked TypeScript example in this README under NodeNext
-and Bundler resolution. The docs
+`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
@@ -661,12 +665,15 @@ temporary project; it does not execute them or contact the example backend.
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, build output, and `scripts/` using their committed configs.
+generated files and build output using their committed configs.
CI and release workflows disable Husky and enforce these checks through
`pnpm run check`.
-Behavior tests use mocked storage, HTTP, and timers, plus a real Router with
-memory history. The `development` condition enables its client redirect test.
+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.
@@ -678,8 +685,9 @@ The separate manual alpha release workflow is documented in
### Try a local build
-Run `pnpm pack` in this repository, then install the resulting tarball in your app:
+Run `pnpm pack` in this repository, then install the resulting tarball in your
+app. Replace `` with the version in `package.json`:
```sh
-pnpm add /absolute/path/to/monarcode-session-kit-0.1.0-alpha.0.tgz
+pnpm add /absolute/path/to/monarcode-session-kit-.tgz
```
diff --git a/package.json b/package.json
index 7631dcd..9f70960 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@monarcode/session-kit",
- "version": "0.1.0-alpha.2",
+ "version": "0.1.0-alpha.3",
"description": "Schema-driven browser sessions for React and TanStack Router",
"keywords": [
"auth",
@@ -57,22 +57,24 @@
"build": "pnpm run clean && tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch",
"typecheck": "tsc -p tsconfig.json --noEmit",
- "test": "node --conditions=development --test tests/*.test.mjs",
- "test:types": "tsc -p tests/tsconfig.json && tsc -p tests/tsconfig.bundler.json && pnpm run test:docs",
+ "test": "node --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",
"prepack": "pnpm run check"
},
"dependencies": {
"@standard-schema/spec": "^1.1.0",
- "@tanstack/react-store": "^0.11.1",
- "@tanstack/store": "^0.11.1"
+ "nanostores": "^1.5.4",
+ "use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@tanstack/react-router": "1.170.38",
+ "@types/jsdom": "21.1.7",
"@types/node": "22.20.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
+ "@types/use-sync-external-store": "1.7.0",
"husky": "9.1.7",
"jsdom": "26.1.0",
"oxfmt": "0.71.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fdf10cd..f5fd154 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,16 +11,19 @@ importers:
'@standard-schema/spec':
specifier: ^1.1.0
version: 1.1.0
- '@tanstack/react-store':
- specifier: ^0.11.1
- version: 0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
- '@tanstack/store':
- specifier: ^0.11.1
- version: 0.11.1
+ nanostores:
+ specifier: ^1.5.4
+ version: 1.5.4
+ use-sync-external-store:
+ specifier: ^1.6.0
+ 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)
+ '@types/jsdom':
+ specifier: 21.1.7
+ version: 21.1.7
'@types/node':
specifier: 22.20.4
version: 22.20.4
@@ -30,6 +33,9 @@ importers:
'@types/react-dom':
specifier: 19.3.0
version: 19.3.0(@types/react@19.3.0)
+ '@types/use-sync-external-store':
+ specifier: 1.7.0
+ version: 1.7.0
husky:
specifier: 9.1.7
version: 9.1.7
@@ -343,6 +349,9 @@ packages:
'@tanstack/store@0.11.1':
resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==}
+ '@types/jsdom@21.1.7':
+ resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
+
'@types/node@22.20.4':
resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==}
@@ -354,6 +363,12 @@ packages:
'@types/react@19.3.0':
resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
+ '@types/tough-cookie@4.0.5':
+ resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+
+ '@types/use-sync-external-store@1.7.0':
+ resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==}
+
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -431,6 +446,10 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ nanostores@1.5.4:
+ resolution: {integrity: sha512-4UXY7OAJEK0Y6V3SudBU7cXjAzTDc189R78dDOh0Xbm19gpKYto6WizSg58LJf12lTsRxKSLGyE117OiLpM4DQ==}
+ engines: {node: ^20.0.0 || >=22.0.0}
+
nwsapi@2.2.28:
resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==}
@@ -750,6 +769,12 @@ snapshots:
'@tanstack/store@0.11.1': {}
+ '@types/jsdom@21.1.7':
+ dependencies:
+ '@types/node': 22.20.4
+ '@types/tough-cookie': 4.0.5
+ parse5: 7.3.0
+
'@types/node@22.20.4':
dependencies:
undici-types: 6.21.0
@@ -762,6 +787,10 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/tough-cookie@4.0.5': {}
+
+ '@types/use-sync-external-store@1.7.0': {}
+
agent-base@7.1.4: {}
cookie-es@3.1.1: {}
@@ -845,6 +874,8 @@ snapshots:
ms@2.1.3: {}
+ nanostores@1.5.4: {}
+
nwsapi@2.2.28: {}
oxfmt@0.71.0:
diff --git a/scripts/check-readme.mjs b/scripts/check-readme.mjs
index 22c8380..ec4f5c3 100644
--- a/scripts/check-readme.mjs
+++ b/scripts/check-readme.mjs
@@ -1,14 +1,16 @@
+import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
+import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
-import { createRequire } from "node:module";
-import { spawnSync } from "node:child_process";
const root = fileURLToPath(new URL("../", import.meta.url));
const readme = await readFile(join(root, "README.md"), "utf8");
-const examples = [...readme.matchAll(
- /\s*```tsx?\n([\s\S]*?)\n```/g,
-)];
+const examples = [
+ ...readme.matchAll(
+ /\s*```tsx?\n([\s\S]*?)\n```/g,
+ ),
+];
if (!examples.length) throw new Error("No README TypeScript examples found");
const directory = await mkdtemp(join(root, "tests", ".readme-"));
const require = createRequire(import.meta.url);
@@ -29,7 +31,9 @@ try {
"src/routes/_authenticated/index.tsx",
];
if (fileRoutePaths.every((path) => paths.has(path))) {
- await writeFile(join(directory, "src/routeTree.gen.ts"), `
+ await writeFile(
+ join(directory, "src/routeTree.gen.ts"),
+ `
import { Route as rootRoute } from "./routes/__root.js";
import { Route as loginRouteImport } from "./routes/login.js";
import { Route as authenticatedRouteImport } from "./routes/_authenticated.js";
@@ -80,26 +84,41 @@ declare module "@tanstack/react-router" {
};
}
}
-`);
+`,
+ );
}
for (const [module, moduleResolution] of [
- ["NodeNext", "NodeNext"], ["ESNext", "Bundler"],
+ ["NodeNext", "NodeNext"],
+ ["ESNext", "Bundler"],
]) {
const config = join(directory, "tsconfig.json");
- await writeFile(config, JSON.stringify({
- compilerOptions: {
- target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"],
- module, moduleResolution, jsx: "react-jsx", strict: true,
- noEmit: true, skipLibCheck: true, types: [],
- },
- include: ["src/**/*.ts", "src/**/*.tsx"],
- }));
+ await writeFile(
+ config,
+ JSON.stringify({
+ compilerOptions: {
+ target: "ES2022",
+ lib: ["ES2022", "DOM", "DOM.Iterable"],
+ module,
+ moduleResolution,
+ jsx: "react-jsx",
+ strict: true,
+ noEmit: true,
+ skipLibCheck: true,
+ types: [],
+ },
+ include: ["src/**/*.ts", "src/**/*.tsx"],
+ }),
+ );
const result = spawnSync(process.execPath, [compiler, "-p", config], {
- cwd: root, stdio: "inherit",
+ cwd: root,
+ stdio: "inherit",
});
if (result.error) throw result.error;
- if (result.status !== 0) throw new Error(`README ${moduleResolution} check failed`);
- console.log(`README: ${examples.length} examples passed (${moduleResolution})`);
+ if (result.status !== 0)
+ throw new Error(`README ${moduleResolution} check failed`);
+ console.log(
+ `README: ${examples.length} examples passed (${moduleResolution})`,
+ );
}
} finally {
await rm(directory, { recursive: true, force: true });
diff --git a/scripts/clean.mjs b/scripts/clean.mjs
index 3d0f3e2..8f99dba 100644
--- a/scripts/clean.mjs
+++ b/scripts/clean.mjs
@@ -1,6 +1,6 @@
-import { rm } from 'node:fs/promises';
+import { rm } from "node:fs/promises";
-await rm(new URL('../dist/', import.meta.url), {
- recursive: true,
- force: true,
+await rm(new URL("../dist/", import.meta.url), {
+ recursive: true,
+ force: true,
});
diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs
index f412be6..5dc2271 100644
--- a/scripts/prepare-release.mjs
+++ b/scripts/prepare-release.mjs
@@ -1,47 +1,79 @@
-import { readFile, mkdir, writeFile } from 'node:fs/promises';
-import { resolve } from 'node:path';
-import { pathToFileURL } from 'node:url';
+import { readFile, mkdir, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { pathToFileURL } from "node:url";
export function prepareRelease(manifest, changelog, version) {
- const number = '(?:0|[1-9][0-9]*)';
- const alpha = new RegExp(`^${number}\\.${number}\\.${number}-alpha\\.${number}$`);
- if (typeof version !== 'string' || version.trim() !== version || !alpha.test(version)) {
- throw new Error('Use an alpha version such as 0.1.0-alpha.1, without a v prefix.');
- }
- if (manifest.version !== version) throw new Error('Version must match package.json.');
- if (manifest.name !== '@monarcode/session-kit'
- || manifest.repository?.url !== 'git+https://github.com/monarcode/session-kit.git'
- || manifest.publishConfig?.access !== 'public'
- || manifest.publishConfig?.registry !== 'https://registry.npmjs.org/') {
- throw new Error('Package identity or publishing configuration does not match session-kit.');
- }
- const sections = [...changelog.matchAll(/^## (.+)\r?$/gm)];
- const matching = sections.filter((section) => section[1].trim() === version);
- if (matching.length !== 1) throw new Error('CHANGELOG.md must contain exactly one heading for this version.');
- const section = matching[0];
- const next = sections[sections.indexOf(section) + 1];
- const notes = changelog.slice(section.index + section[0].length, next?.index).trim();
- if (!notes) throw new Error('Release notes must not be empty.');
- return { version, tag: `v${version}`, notes };
+ const number = "(?:0|[1-9][0-9]*)";
+ const alpha = new RegExp(
+ `^${number}\\.${number}\\.${number}-alpha\\.${number}$`,
+ );
+ if (
+ typeof version !== "string" ||
+ version.trim() !== version ||
+ !alpha.test(version)
+ ) {
+ throw new Error(
+ "Use an alpha version such as 0.1.0-alpha.1, without a v prefix.",
+ );
+ }
+ if (manifest.version !== version)
+ throw new Error("Version must match package.json.");
+ if (
+ manifest.name !== "@monarcode/session-kit" ||
+ manifest.repository?.url !==
+ "git+https://github.com/monarcode/session-kit.git" ||
+ manifest.publishConfig?.access !== "public" ||
+ manifest.publishConfig?.registry !== "https://registry.npmjs.org/"
+ ) {
+ throw new Error(
+ "Package identity or publishing configuration does not match session-kit.",
+ );
+ }
+ const sections = [...changelog.matchAll(/^## (.+)\r?$/gm)];
+ const matching = sections.filter((section) => section[1].trim() === version);
+ if (matching.length !== 1)
+ throw new Error(
+ "CHANGELOG.md must contain exactly one heading for this version.",
+ );
+ const section = matching[0];
+ const next = sections[sections.indexOf(section) + 1];
+ const notes = changelog
+ .slice(section.index + section[0].length, next?.index)
+ .trim();
+ if (!notes) throw new Error("Release notes must not be empty.");
+ return { version, tag: `v${version}`, notes };
}
export async function checkRegistry(version, request = fetch) {
- const response = await request(`https://registry.npmjs.org/@monarcode%2Fsession-kit/${version}`);
- if (response.status === 404) return;
- if (response.ok) throw new Error(`Version ${version} is already published.`);
- throw new Error(`npm registry check failed with HTTP ${response.status}.`);
+ const response = await request(
+ `https://registry.npmjs.org/@monarcode%2Fsession-kit/${version}`,
+ );
+ if (response.status === 404) return;
+ if (response.ok) throw new Error(`Version ${version} is already published.`);
+ throw new Error(`npm registry check failed with HTTP ${response.status}.`);
}
-if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
- const [version, directory, registryFlag] = process.argv.slice(2);
- if (!directory || (registryFlag && registryFlag !== '--check-registry')) {
- throw new Error('Usage: node scripts/prepare-release.mjs VERSION OUTPUT_DIRECTORY [--check-registry]');
- }
- const manifest = JSON.parse(await readFile('package.json', 'utf8'));
- const changelog = await readFile('CHANGELOG.md', 'utf8');
- const release = prepareRelease(manifest, changelog, version);
- if (registryFlag) await checkRegistry(release.version);
- await mkdir(directory, { recursive: true });
- await writeFile(resolve(directory, 'release-notes.md'), `${release.notes}\n`);
- await writeFile(resolve(directory, 'release.json'), `${JSON.stringify(release, null, 2)}\n`);
+if (
+ process.argv[1] &&
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
+) {
+ const [version, directory, registryFlag] = process.argv.slice(2);
+ if (!directory || (registryFlag && registryFlag !== "--check-registry")) {
+ throw new Error(
+ "Usage: node scripts/prepare-release.mjs VERSION OUTPUT_DIRECTORY [--check-registry]",
+ );
+ }
+ const manifest = JSON.parse(await readFile("package.json", "utf8"));
+ const changelog = await readFile("CHANGELOG.md", "utf8");
+ const release = prepareRelease(manifest, changelog, version);
+ if (registryFlag) await checkRegistry(release.version);
+ await mkdir(directory, { recursive: true });
+ await writeFile(
+ resolve(directory, "release-notes.md"),
+ `${release.notes}\n`,
+ );
+ await writeFile(
+ resolve(directory, "release.json"),
+ `${JSON.stringify(release, null, 2)}\n`,
+ );
}
diff --git a/src/core/client.ts b/src/core/client.ts
index 59413a9..05e5f4b 100644
--- a/src/core/client.ts
+++ b/src/core/client.ts
@@ -1,38 +1,69 @@
import type { StandardSchemaV1 } from "@standard-schema/spec";
-import { Store } from "@tanstack/store";
+import { atom } from "nanostores";
-import { AuthError, authError, sessionChanged } from "./errors.js";
+import { AuthError, toAuthError, sessionChanged } from "./errors.js";
import { createPersistence, type StoredSession } from "./persistence.js";
+import { obtainTokens } from "./refresh.js";
+import { runTask } from "./task.js";
+import { setLongTimeout, proactiveRefreshDelay } from "./timers.js";
+import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js";
import type {
AuthClient,
AuthOptions,
AuthState,
+ AuthStatus,
Session,
SignInInput,
User,
UserInput,
} from "./types.js";
-import {
- isRecord,
- receiveTokens,
- runTask,
- validateUser,
- type SessionTokens,
-} from "./validation.js";
+import { sameUser, validateUser } from "./user.js";
type InternalSession = SessionTokens & { id: string; user: U };
-/** Starts work nobody awaits. Failures are already published to `state.error`. */
+/** How a refresh ended, inside the cross-tab lock. */
+type RefreshOutcome =
+ /** Storage no longer holds this tab's session; follow what it holds. */
+ | { kind: "stale" }
+ /** The backend rejected the refresh token, so the session ended. */
+ | { kind: "signedOut" }
+ | { kind: "installed"; session: Session };
+
+/**
+ * Starts work nobody awaits. Its failures are already published to
+ * `state.error`, or are `SESSION_CHANGED` from work a newer session superseded.
+ */
function inBackground(work: Promise) {
work.catch(() => {});
}
-function tokensOf(saved: SessionTokens): SessionTokens {
- return {
- accessToken: saved.accessToken,
- refreshToken: saved.refreshToken,
- expiresAt: saved.expiresAt,
- };
+/** Statuses that expose `user`. Adding a status will not compile until it is listed. */
+const SIGNED_IN = {
+ initializing: false,
+ authenticated: true,
+ refreshing: true,
+ unauthenticated: false,
+ unavailable: false,
+} satisfies Record;
+
+/**
+ * Statuses that guards treat alike, so moving between them leaves `version`
+ * unchanged. A refresh keeps the user signed in for guards.
+ */
+const GUARD_STATUS = {
+ initializing: "initializing",
+ authenticated: "authenticated",
+ refreshing: "authenticated",
+ unauthenticated: "unauthenticated",
+ unavailable: "unavailable",
+} satisfies Record;
+
+/** Whether `error` means the saved session itself is unusable, not storage. */
+function rejectsSaved(error: AuthError) {
+ return (
+ error.code === "INVALID_SESSION" ||
+ error.code === "USER_VALIDATION_FAILED"
+ );
}
/**
@@ -67,40 +98,50 @@ export function createAuth(
type U = User;
type I = UserInput;
const persistence = createPersistence(options.name, options.maxAge);
- const store = new Store>({
+ const store = atom>({
status: "initializing",
user: null,
sessionId: null,
version: 0,
error: null,
});
- let session: InternalSession | null = 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.
let epoch = 0;
- let profileVersion = 0;
- let profileIntent = 0;
let controller = new AbortController();
- let initialization: Promise | undefined;
+ let mounts = 0;
+ let unwatch: (() => void) | undefined;
+ let cancelExpiry: (() => void) | undefined;
+ let cancelProactive: (() => void) | undefined;
+
+ // Restoration. `session` is meaningful only once `initialized` is true;
+ // `initialization` is the restore in progress, if any.
+ let session: InternalSession | null = null;
let initialized = false;
- let rejectedToken: string | undefined;
+ let initialization: Promise | undefined;
+
+ // 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 failure: AuthError | undefined;
- let mounts = 0;
- let proactiveTimer: ReturnType | undefined;
- let expiryTimer: ReturnType | undefined;
let refreshAttempted = false;
- let unwatch: (() => void) | undefined;
+ let rejectedToken: string | undefined;
+
+ // Profile. `profileVersion` changes whenever the user is replaced, so a
+ // refresh that began earlier cannot overwrite it. `profileIntent` orders
+ // overlapping `updateUser` calls; only the latest commits.
+ let profileVersion = 0;
+ let profileIntent = 0;
const valid = () =>
session !== null &&
session.accessToken !== rejectedToken &&
(session.expiresAt === undefined || session.expiresAt > Date.now());
- /** Whether another tab's saved tokens can be used instead of refreshing. */
- const usable = (tokens: SessionTokens) =>
- tokens.accessToken !== rejectedToken &&
- (tokens.expiresAt === undefined ||
- tokens.expiresAt >= Date.now() + 5_000);
-
/**
* 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.
@@ -128,29 +169,23 @@ export function createAuth(
});
}
+ function deriveStatus(): AuthStatus {
+ if (!initialized) return "initializing";
+ if (!session) return "unauthenticated";
+ if (valid()) return "authenticated";
+ return flight ? "refreshing" : "unavailable";
+ }
+
function publish(error: AuthError | null = null) {
- const status = !initialized
- ? "initializing"
- : !session
- ? "unauthenticated"
- : valid()
- ? "authenticated"
- : flight
- ? "refreshing"
- : "unavailable";
- const user =
- status === "authenticated" || status === "refreshing"
- ? session!.user
- : null;
+ const status = deriveStatus();
+ const user = SIGNED_IN[status] ? session!.user : null;
const sessionId = session?.id ?? null;
const previous = store.get();
- const guardStatus = (value: AuthState["status"]) =>
- value === "refreshing" ? "authenticated" : value;
const changed =
- guardStatus(previous.status) !== guardStatus(status) ||
+ GUARD_STATUS[previous.status] !== GUARD_STATUS[status] ||
previous.user !== user ||
previous.sessionId !== sessionId;
- store.setState(() =>
+ store.set(
Object.freeze({
status,
user,
@@ -162,8 +197,9 @@ export function createAuth(
}
function stopTimers() {
- clearTimeout(proactiveTimer);
- clearTimeout(expiryTimer);
+ cancelExpiry?.();
+ cancelProactive?.();
+ cancelExpiry = cancelProactive = undefined;
}
function schedule() {
@@ -171,27 +207,15 @@ export function createAuth(
if (!mounts || !session || session.expiresAt === undefined) return;
const remaining = session.expiresAt - Date.now();
if (remaining <= 0) return;
- expiryTimer = setTimeout(
- () => {
- if (valid()) {
- schedule();
- return;
- }
- inBackground(getSession());
- },
- Math.min(remaining, 2_147_483_647),
- );
+ cancelExpiry = setLongTimeout(() => {
+ // The browser clock may have moved while the timer waited.
+ if (valid()) schedule();
+ else inBackground(getSession());
+ }, remaining);
if (options.refresh && session.refreshToken && !refreshAttempted) {
- const delay = remaining - Math.min(60_000, remaining / 2);
- proactiveTimer = setTimeout(
- () => {
- if (delay > 2_147_483_647) {
- schedule();
- return;
- }
- inBackground(refreshSession(snapshot(), false));
- },
- Math.min(delay, 2_147_483_647),
+ cancelProactive = setLongTimeout(
+ () => inBackground(refreshSession(snapshot(), false)),
+ proactiveRefreshDelay(remaining),
);
}
}
@@ -220,11 +244,27 @@ export function createAuth(
publish(error);
}
+ /** Signs out every tab because the saved session cannot be used, then clears it. */
+ function discardSaved(error: AuthError) {
+ forget(error);
+ try {
+ persistence.clear();
+ } catch (cleanup) {
+ const failure = new AuthError(
+ "PERSISTENCE_FAILED",
+ "Could not clear the invalid saved session",
+ { cause: new AggregateError([error, cleanup]) },
+ );
+ publish(failure);
+ throw failure;
+ }
+ }
+
function commit(next: InternalSession) {
try {
persistence.write(next);
} catch (cause) {
- const error = authError(
+ const error = toAuthError(
"PERSISTENCE_FAILED",
"Could not save the session",
cause,
@@ -284,7 +324,10 @@ export function createAuth(
/**
* Applies what another tab saved: a sign-out, a sign-in or account switch, a
* refresh, or a profile update. Reads storage rather than the event, so a
- * late or repeated event cannot apply stale data.
+ * 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.
*/
async function sync() {
if (!initialized) {
@@ -302,30 +345,49 @@ export function createAuth(
return;
}
if (stored.id !== session?.id) return reload();
- const sameUser =
- JSON.stringify(stored.user) === JSON.stringify(session.user);
- const sameTokens =
+ const userUnchanged = sameUser(stored.user, session.user);
+ const tokensUnchanged =
stored.accessToken === session.accessToken &&
stored.refreshToken === session.refreshToken &&
stored.expiresAt === session.expiresAt;
- if (sameUser && sameTokens) return;
+ if (userUnchanged && tokensUnchanged) return;
const expected = epoch;
- const user = sameUser
- ? session.user
- : await runTask(
- () => validateUser(options.userSchema, stored.user),
- controller.signal,
- );
+ 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);
+ 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;
+ }
const now = latest(stored.id);
if (
epoch !== expected ||
!now ||
now.accessToken !== stored.accessToken ||
- JSON.stringify(now.user) !== JSON.stringify(stored.user)
+ !sameUser(now.user, stored.user)
)
return;
- if (!sameTokens) refreshAttempted = false;
- if (!sameUser) profileVersion++;
+ if (!tokensUnchanged) refreshAttempted = false;
+ if (!userUnchanged) profileVersion++;
install({ ...tokensOf(stored), id: stored.id, user });
}
@@ -360,35 +422,22 @@ export function createAuth(
schedule();
} catch (cause) {
assertEpoch(expected);
- const error = authError(
+ const error = toAuthError(
"PERSISTENCE_FAILED",
"Could not restore auth",
cause,
);
- if (
- error.code === "INVALID_SESSION" ||
- error.code === "USER_VALIDATION_FAILED"
- ) {
- forget(error);
- try {
- persistence.clear();
- } catch (cleanup) {
- const failure = new AuthError(
- "PERSISTENCE_FAILED",
- "Could not clear the invalid saved session",
- { cause: new AggregateError([error, cleanup]) },
- );
- publish(failure);
- throw failure;
- }
+ if (rejectsSaved(error)) {
+ discardSaved(error);
return;
}
- store.setState((previous) => ({
+ const previous = store.get();
+ store.set({
...previous,
status: "unavailable",
error,
version: previous.version + 1,
- }));
+ });
throw error;
}
})();
@@ -428,7 +477,7 @@ export function createAuth(
profileVersion++;
commit({ ...tokens, id: crypto.randomUUID(), user });
} catch (cause) {
- throw authError(
+ throw toAuthError(
"USER_VALIDATION_FAILED",
"Could not accept the user",
cause,
@@ -449,7 +498,7 @@ export function createAuth(
try {
persistence.clear();
} catch (cause) {
- const error = authError(
+ const error = toAuthError(
"PERSISTENCE_FAILED",
"Could not clear the saved session",
cause,
@@ -514,58 +563,22 @@ export function createAuth(
failure = undefined;
const promise = (async () => {
try {
- const outcome = await persistence.exclusive(lifecycle, async () => {
+ const run = async (): Promise> => {
assertEpoch(expected);
const stored = latest(current.id);
- if (stored === null) return "stale" as const;
- const savedUser = (saved: StoredSession) =>
- JSON.stringify(saved.user) === JSON.stringify(current.user)
- ? current.user
- : validateUser(options.userSchema, saved.user);
- const result = await runTask(async (signal) => {
- if (
- stored &&
- stored.accessToken !== current.accessToken &&
- usable(stored)
- ) {
- return {
- tokens: tokensOf(stored),
- user: await savedUser(stored),
- adopted: true,
- };
- }
- const refreshToken =
- stored?.refreshToken ?? current.refreshToken!;
- const value = await refreshHandler({
- refreshToken,
- signal,
- });
- if (value === null) return null;
- const tokens = receiveTokens(value);
- if (
- tokens.expiresAt !== undefined &&
- tokens.expiresAt < Date.now() + 5_000
- ) {
- throw new AuthError(
- "INVALID_SESSION",
- "Refreshed access token must last at least five seconds",
- );
- }
- const user =
- isRecord(value) && value.user !== undefined
- ? await validateUser(options.userSchema, value.user)
- : stored
- ? await savedUser(stored)
- : undefined;
- return {
- tokens: {
- ...tokens,
- refreshToken: tokens.refreshToken ?? refreshToken,
- },
- user,
- adopted: false,
- };
- }, lifecycle);
+ if (stored === null) return { kind: "stale" };
+ const result = await runTask(
+ (signal) =>
+ obtainTokens({
+ current,
+ stored,
+ refresh: refreshHandler,
+ userSchema: options.userSchema,
+ rejectedToken,
+ signal,
+ }),
+ lifecycle,
+ );
assertEpoch(expected);
if (result === null) {
const after = latest(current.id);
@@ -574,9 +587,9 @@ export function createAuth(
(after &&
after.accessToken !== (stored ?? current).accessToken)
)
- return "stale" as const;
+ return { kind: "stale" };
await signOut();
- return null;
+ return { kind: "signedOut" };
}
if (
result.tokens.expiresAt !== undefined &&
@@ -599,13 +612,21 @@ export function createAuth(
: session!.user;
rejectedToken = undefined;
refreshAttempted = false;
- if (latest(current.id) === null) return "stale" as const;
+ 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 snapshot();
- });
- return outcome === "stale" ? followStorage(current.id) : outcome;
+ return { kind: "installed", session: snapshot() };
+ };
+ const outcome = await persistence.exclusive(lifecycle, run);
+ switch (outcome.kind) {
+ case "stale":
+ return followStorage(current.id);
+ case "signedOut":
+ return null;
+ case "installed":
+ return outcome.session;
+ }
} catch (cause) {
if (epoch !== expected) {
if (
@@ -616,7 +637,7 @@ export function createAuth(
throw sessionChanged();
}
flight = undefined;
- const error = authError(
+ const error = toAuthError(
"REFRESH_FAILED",
"Could not refresh the session",
cause,
@@ -670,7 +691,23 @@ export function createAuth(
}
return {
- state: { get: store.get, subscribe: store.subscribe },
+ state: {
+ get: store.get,
+ // `listen`, not `subscribe`: nanostores' `subscribe` also calls the
+ // listener immediately with the current value.
+ subscribe: (listener) => ({
+ unsubscribe: store.listen((value) => {
+ try {
+ listener(value);
+ } catch (error) {
+ // A failing listener must not interrupt the auth operation that notified it.
+ queueMicrotask(() => {
+ throw error;
+ });
+ }
+ }),
+ }),
+ },
signIn,
signOut,
updateUser,
diff --git a/src/core/errors.ts b/src/core/errors.ts
index c5bb66b..caa4af1 100644
--- a/src/core/errors.ts
+++ b/src/core/errors.ts
@@ -8,19 +8,30 @@ export type AuthErrorCode =
| "SESSION_CHANGED"
| "UNAUTHENTICATED";
+export type AuthErrorOptions = ErrorOptions & {
+ /** Schema issues behind a `USER_VALIDATION_FAILED` error. */
+ issues?: readonly StandardSchemaV1.Issue[];
+};
+
export class AuthError extends Error {
+ readonly issues?: readonly StandardSchemaV1.Issue[];
+
constructor(
readonly code: AuthErrorCode,
message: string,
- options?: ErrorOptions,
- readonly issues?: readonly StandardSchemaV1.Issue[],
+ options?: AuthErrorOptions,
) {
super(message, options);
this.name = "AuthError";
+ this.issues = options?.issues;
}
}
-export function authError(
+/**
+ * Returns `cause` unchanged when it is already an `AuthError`; otherwise wraps
+ * it with `code`. The code is a fallback, not a guarantee.
+ */
+export function toAuthError(
code: AuthErrorCode,
message: string,
cause: unknown,
diff --git a/src/core/json.ts b/src/core/json.ts
new file mode 100644
index 0000000..0ac88f3
--- /dev/null
+++ b/src/core/json.ts
@@ -0,0 +1,3 @@
+export function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/src/core/persistence.ts b/src/core/persistence.ts
index 2eee88c..e9a13bd 100644
--- a/src/core/persistence.ts
+++ b/src/core/persistence.ts
@@ -1,9 +1,18 @@
import { AuthError } from "./errors.js";
-import { isRecord, validateTokens, type SessionTokens } from "./validation.js";
+import { isRecord } from "./json.js";
+import { validateTokens, type SessionTokens } from "./tokens.js";
export type StoredSession = SessionTokens & { id: string; user: unknown };
-export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) {
+/** Format of the saved entry; entries in any other format are not restored. */
+const STORAGE_VERSION = 3;
+/** Thirty days. */
+const DEFAULT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
+
+export function createPersistence(
+ name: string,
+ maxAge = DEFAULT_MAX_AGE_SECONDS,
+) {
if (
!/^[a-zA-Z0-9_-]{1,64}$/.test(name) ||
!Number.isSafeInteger(maxAge) ||
@@ -58,7 +67,7 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) {
const saved: unknown = JSON.parse(text);
if (
!isRecord(saved) ||
- saved.v !== 3 ||
+ saved.v !== STORAGE_VERSION ||
typeof saved.id !== "string" ||
!saved.id ||
!("user" in saved) ||
@@ -88,7 +97,7 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) {
localStorage.setItem(
key,
JSON.stringify({
- v: 3,
+ v: STORAGE_VERSION,
...session,
persistUntil: Date.now() + maxAge * 1000,
}),
diff --git a/src/core/refresh.ts b/src/core/refresh.ts
new file mode 100644
index 0000000..f24961d
--- /dev/null
+++ b/src/core/refresh.ts
@@ -0,0 +1,86 @@
+import type { StandardSchemaV1 } from "@standard-schema/spec";
+
+import { AuthError } from "./errors.js";
+import { isRecord } from "./json.js";
+import type { StoredSession } from "./persistence.js";
+import { receiveTokens, tokensOf, type SessionTokens } from "./tokens.js";
+import type { RefreshFn, User, UserInput } from "./types.js";
+import { sameUser, validateUser } from "./user.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. */
+ user: U | undefined;
+ /** Whether the tokens came from another tab's save, not the callback. */
+ adopted: boolean;
+};
+
+/**
+ * Gets tokens to replace `current`'s: another tab's newer saved tokens while
+ * 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 };
+ /** What storage holds for this session; `undefined` when unreadable. */
+ stored: StoredSession | undefined;
+ refresh: RefreshFn>;
+ userSchema: S;
+ /** 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);
+ if (
+ stored &&
+ stored.accessToken !== current.accessToken &&
+ stored.accessToken !== rejectedToken &&
+ (stored.expiresAt === undefined ||
+ stored.expiresAt >= Date.now() + MIN_TOKEN_LIFETIME_MS)
+ ) {
+ return {
+ tokens: tokensOf(stored),
+ user: await savedUser(stored),
+ adopted: true,
+ };
+ }
+ const refreshToken = stored?.refreshToken ?? current.refreshToken!;
+ const value = await refresh({ refreshToken, signal });
+ if (value === null) return null;
+ const tokens = receiveTokens(value);
+ if (
+ tokens.expiresAt !== undefined &&
+ tokens.expiresAt < Date.now() + MIN_TOKEN_LIFETIME_MS
+ ) {
+ throw new AuthError(
+ "INVALID_SESSION",
+ "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;
+ return {
+ tokens: { ...tokens, refreshToken: tokens.refreshToken ?? refreshToken },
+ user,
+ adopted: false,
+ };
+}
diff --git a/src/core/task.ts b/src/core/task.ts
new file mode 100644
index 0000000..2820dc3
--- /dev/null
+++ b/src/core/task.ts
@@ -0,0 +1,36 @@
+/** Deadline for validation and refresh work. */
+const TASK_TIMEOUT_MS = 15_000;
+
+/**
+ * Runs `work` with a signal tied to `parent` and a timeout. Rejects on abort or
+ * timeout even when `work` ignores the signal, so late results never commit.
+ */
+export function runTask(
+ work: (signal: AbortSignal) => Promise,
+ parent: AbortSignal,
+ timeoutMs = TASK_TIMEOUT_MS,
+): Promise {
+ const controller = new AbortController();
+ const abort = () => controller.abort(parent.reason);
+ parent.addEventListener("abort", abort, { once: true });
+ if (parent.aborted) abort();
+ const timer = setTimeout(
+ () => controller.abort(new Error("Auth operation timed out")),
+ timeoutMs,
+ );
+ return new Promise((resolve, reject) => {
+ const fail = () => reject(controller.signal.reason);
+ controller.signal.addEventListener("abort", fail, { once: true });
+ if (controller.signal.aborted) fail();
+ else
+ Promise.resolve()
+ .then(() => {
+ controller.signal.throwIfAborted();
+ return work(controller.signal);
+ })
+ .then(resolve, reject);
+ }).finally(() => {
+ clearTimeout(timer);
+ parent.removeEventListener("abort", abort);
+ });
+}
diff --git a/src/core/timers.ts b/src/core/timers.ts
new file mode 100644
index 0000000..7c28ce7
--- /dev/null
+++ b/src/core/timers.ts
@@ -0,0 +1,32 @@
+/** The longest delay `setTimeout` supports, about 24.8 days. */
+const MAX_TIMER_DELAY_MS = 2_147_483_647;
+/** Proactive refresh starts this long before expiry, or halfway, if sooner. */
+const REFRESH_LEAD_MS = 60_000;
+
+/**
+ * Like `setTimeout`, but a delay beyond the platform limit re-arms instead of
+ * firing early. Each re-arm measures from the clock, not elapsed timer time,
+ * so a wait that outlasts a suspended device stays anchored to its deadline.
+ * Returns a function that cancels the timer.
+ */
+export function setLongTimeout(
+ callback: () => void,
+ delay: number,
+): () => void {
+ const deadline = Date.now() + delay;
+ let timer: ReturnType;
+ const arm = () => {
+ const remaining = deadline - Date.now();
+ timer =
+ remaining > MAX_TIMER_DELAY_MS
+ ? setTimeout(arm, MAX_TIMER_DELAY_MS)
+ : setTimeout(callback, Math.max(remaining, 0));
+ };
+ arm();
+ return () => clearTimeout(timer);
+}
+
+/** How long to wait before refreshing a token that expires in `remaining` ms. */
+export function proactiveRefreshDelay(remaining: number): number {
+ return remaining - Math.min(REFRESH_LEAD_MS, remaining / 2);
+}
diff --git a/src/core/tokens.ts b/src/core/tokens.ts
new file mode 100644
index 0000000..acab375
--- /dev/null
+++ b/src/core/tokens.ts
@@ -0,0 +1,108 @@
+import { AuthError } from "./errors.js";
+import { isRecord } from "./json.js";
+import type { Tokens } from "./types.js";
+
+/** Session tokens with `expiresAt` measured on this browser's clock. */
+export type SessionTokens = Omit;
+
+/** Copies only the token fields, dropping a stored session's other data. */
+export function tokensOf(saved: SessionTokens): SessionTokens {
+ return {
+ accessToken: saved.accessToken,
+ refreshToken: saved.refreshToken,
+ expiresAt: saved.expiresAt,
+ };
+}
+
+function jwtClaims(token: string): { exp?: number; iat?: number } {
+ try {
+ const parts = token.split(".");
+ if (parts.length !== 3) return {};
+ 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) };
+ } catch {
+ return {};
+ }
+}
+
+function positive(value: unknown, message: string): number | undefined {
+ if (
+ value !== undefined &&
+ (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
+ ) {
+ throw new AuthError("INVALID_SESSION", message);
+ }
+ return value;
+}
+
+/** Checks token shape and an explicit `expiresAt`. Restores saved sessions as-is. */
+export function validateTokens(value: unknown): SessionTokens {
+ if (
+ !isRecord(value) ||
+ typeof value.accessToken !== "string" ||
+ !value.accessToken.trim()
+ ) {
+ throw new AuthError(
+ "INVALID_SESSION",
+ "A nonempty access token is required",
+ );
+ }
+ if (
+ value.refreshToken !== undefined &&
+ (typeof value.refreshToken !== "string" || !value.refreshToken.trim())
+ ) {
+ throw new AuthError(
+ "INVALID_SESSION",
+ "Refresh token must be a nonempty string",
+ );
+ }
+ return {
+ accessToken: value.accessToken,
+ refreshToken: value.refreshToken,
+ expiresAt: positive(
+ value.expiresAt,
+ "expiresAt must be positive Unix milliseconds",
+ ),
+ };
+}
+
+/**
+ * Accepts newly issued tokens. Lifetimes are measured from receipt so a skewed
+ * browser clock cannot expire them early: `expiresIn`, then JWT `exp - iat`.
+ * Only explicit `expiresAt` and JWT `exp` without `iat` use absolute time.
+ */
+export function receiveTokens(value: unknown): SessionTokens {
+ const tokens = validateTokens(value);
+ const expiresIn = positive(
+ isRecord(value) ? value.expiresIn : undefined,
+ "expiresIn must be positive seconds",
+ );
+ if (expiresIn !== undefined && tokens.expiresAt !== undefined) {
+ throw new AuthError(
+ "INVALID_SESSION",
+ "Provide expiresAt or expiresIn, not both",
+ );
+ }
+ if (tokens.expiresAt !== undefined) return tokens;
+ if (expiresIn !== undefined)
+ return { ...tokens, expiresAt: Date.now() + expiresIn * 1000 };
+ const { exp, iat } = jwtClaims(tokens.accessToken);
+ if (exp === undefined) return tokens;
+ return {
+ ...tokens,
+ expiresAt:
+ iat !== undefined && exp > iat
+ ? Date.now() + (exp - iat) * 1000
+ : exp * 1000,
+ };
+}
diff --git a/src/core/types.ts b/src/core/types.ts
index 7144578..b53c41a 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,5 +1,4 @@
import type { StandardSchemaV1 } from "@standard-schema/spec";
-import type { Store } from "@tanstack/store";
import type { AuthError } from "./errors.js";
@@ -76,13 +75,6 @@ export type RefreshFn = (context: {
signal: AbortSignal;
}) => Promise>;
-/** Types a refresh callback against a user schema before passing it to `createAuth`. */
-export function createRefreshFn(
- handler: RefreshFn,
-): RefreshFn {
- return handler;
-}
-
/** A frozen snapshot of the current credentials, from `getSession()`. */
export type Session = Readonly<{
/** Stable across refreshes and profile updates; new on every sign-in. */
@@ -97,21 +89,24 @@ export type Session = Readonly<{
user: U;
}>;
+/**
+ * - `initializing`: restoring the saved session has not finished.
+ * - `authenticated`: access is locally usable; `user` is set.
+ * - `refreshing`: a refresh is replacing expired or rejected access; `user` stays visible.
+ * - `unauthenticated`: no session; sign-in is needed.
+ * - `unavailable`: restoration failed, or the credentials cannot supply usable access.
+ */
+export type AuthStatus =
+ | "initializing"
+ | "authenticated"
+ | "refreshing"
+ | "unauthenticated"
+ | "unavailable";
+
/** Reactive auth snapshot. Tokens are intentionally excluded. */
export type AuthState = Readonly<{
- /**
- * - `initializing`: restoring the saved session has not finished.
- * - `authenticated`: access is locally usable; `user` is set.
- * - `refreshing`: a refresh is replacing expired or rejected access; `user` stays visible.
- * - `unauthenticated`: no session; sign-in is needed.
- * - `unavailable`: restoration failed, or the credentials cannot supply usable access.
- */
- status:
- | "initializing"
- | "authenticated"
- | "refreshing"
- | "unauthenticated"
- | "unavailable";
+ /** 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. */
@@ -123,8 +118,17 @@ export type AuthState = Readonly<{
}>;
export type AuthClient = {
- /** Reactive `AuthState`. Read it in React with `useAuth`. */
- state: Pick>, "get" | "subscribe">;
+ /**
+ * Reactive `AuthState`. Read it in React with `useAuth`. `subscribe` reports
+ * later changes, not the current value. A listener that throws does not
+ * interrupt auth; its error is rethrown asynchronously.
+ */
+ state: {
+ get: () => AuthState;
+ subscribe: (listener: (state: AuthState) => void) => {
+ unsubscribe: () => void;
+ };
+ };
/**
* Validates and saves a new session, replacing any current one. The latest
* call wins. Rejects expired tokens; invalid input keeps the previous session.
diff --git a/src/core/user.ts b/src/core/user.ts
new file mode 100644
index 0000000..556d6cd
--- /dev/null
+++ b/src/core/user.ts
@@ -0,0 +1,94 @@
+import type { StandardSchemaV1 } from "@standard-schema/spec";
+
+import { AuthError } from "./errors.js";
+import { isRecord } from "./json.js";
+import type { User } from "./types.js";
+
+/**
+ * Rejects values that JSON would silently drop or change, then deep-freezes the
+ * value so callers cannot mutate auth state without notifying subscribers.
+ */
+function freezeJson(value: unknown, ancestors = new Set