diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..63254bc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = tab +indent_size = tab +tab_width = 3 +trim_trailing_whitespace = true +max_line_length = 80 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 104bc01..45ff6d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,41 +1,44 @@ name: CI +env: + HUSKY: 0 + on: - push: - branches: [main] - pull_request: + push: + branches: [main] + pull_request: permissions: - contents: read + contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - check: - 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 - - - name: Build and test - run: pnpm run check - - - name: Verify package tarball - run: pnpm --config.ignore-scripts=true pack --pack-destination "$RUNNER_TEMP/session-kit-pack" + check: + 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 + + - name: Build and test + run: pnpm run check + + - name: Verify package tarball + run: pnpm --config.ignore-scripts=true pack --pack-destination "$RUNNER_TEMP/session-kit-pack" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7e494b0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Release alpha + +env: + HUSKY: 0 + +on: + workflow_dispatch: + inputs: + version: + description: Alpha version already committed in package.json (without v) + required: true + type: string + +permissions: + contents: read + +concurrency: + group: session-kit-release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Require main + env: + RELEASE_REF: ${{ github.ref }} + run: test "$RELEASE_REF" = refs/heads/main + - name: Check out release commit + uses: actions/checkout@v7 + with: + fetch-depth: 0 + - 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 + package-manager-cache: false + - name: Validate version and release notes + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + node scripts/prepare-release.mjs "$RELEASE_VERSION" "$RUNNER_TEMP/release" --check-registry + if git show-ref --verify --quiet "refs/tags/v$RELEASE_VERSION"; then + echo "The release tag already exists." + exit 1 + fi + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Build and test + run: pnpm run check + - name: Pack verified build + run: pnpm --config.ignore-scripts=true pack --pack-destination "$RUNNER_TEMP/release" + - name: Save release artifact + uses: actions/upload-artifact@v7 + with: + name: session-kit-release + path: ${{ runner.temp }}/release/ + if-no-files-found: error + retention-days: 14 + + publish: + needs: prepare + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + id-token: write + steps: + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + package-manager-cache: false + - name: Download verified release artifact + uses: actions/download-artifact@v8 + with: + name: session-kit-release + path: ${{ runner.temp }}/release + - name: Check npm supports trusted publishing + run: | + node --input-type=module -e 'import { execFileSync } from "node:child_process"; const [major, minor, patch] = execFileSync("npm", ["--version"], { encoding: "utf8" }).trim().split(".").map(Number); if (!(major > 11 || (major === 11 && (minor > 5 || (minor === 5 && patch >= 1))))) throw new Error("npm >=11.5.1 is required");' + - name: Publish alpha to npm + env: + RELEASE_VERSION: ${{ inputs.version }} + run: npm publish "$RUNNER_TEMP/release/monarcode-session-kit-$RELEASE_VERSION.tgz" --tag next --access public --ignore-scripts + - name: Create GitHub prerelease and tag + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_COMMIT: ${{ github.sha }} + run: | + gh release create "v$RELEASE_VERSION" \ + --target "$RELEASE_COMMIT" \ + --title "v$RELEASE_VERSION" \ + --prerelease --latest=false \ + --notes-file "$RUNNER_TEMP/release/release-notes.md" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..eca68eb --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm run lint && pnpm run format:check diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..f9355b8 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,45 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "jsxSingleQuote": false, + "printWidth": 80, + "tabWidth": 3, + "useTabs": true, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSpacing": true, + "insertFinalNewline": true, + "ignorePatterns": [ + "node_modules", + "dist", + "build", + ".netlify", + "routeTree.gen.ts", + "*.gen.ts", + "coverage", + "templates/", + "scripts/" + ], + "sortImports": { + "customGroups": [ + { + "groupName": "react", + "selector": "external", + "elementNamePattern": ["react", "react-*"] + } + ], + "groups": [ + "builtin", + "react", + "external", + ["internal", "subpath"], + ["parent", "sibling", "index"], + "style", + "unknown" + ], + "internalPattern": ["@/"], + "newlinesBetween": true + } +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..a49ab01 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,45 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "react", + "import", + "jsx-a11y", + "promise", + "typescript", + "unicorn" + ], + "env": { + "browser": true, + "node": true, + "es2025": true + }, + "ignorePatterns": [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/.netlify/**", + "**/routeTree.gen.ts", + "**/*.gen.ts", + "**/coverage/**", + "**/templates/**", + "**/scripts/**" + ], + "rules": { + "no-unused-vars": "off", + "typescript/no-unused-vars": [ + "warn", + { + "fix": { + "imports": "safe-fix" + } + } + ], + "import/no-cycle": "error", + "react/exhaustive-deps": "warn", + "jsx_a11y/anchor-is-valid": "off", + "jsx_a11y/click-events-have-key-events": "warn", + "jsx_a11y/label-has-associated-control": "warn", + "jsx_a11y/no-static-element-interactions": "warn", + "jsx_a11y/prefer-tag-over-role": "off" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index dff0e34..3f439dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.1.0-alpha.1 + +### Changed + +- Tokens now persist in a `:auth:tokens` localStorage entry instead of a + cookie, so browsers no longer attach access and refresh tokens to every + same-origin request. The profile remains in `:auth:user`. +- **Breaking:** the `cookieMaxAge` option is renamed `maxAge`. +- The 3,800-character token budget is removed. + ## 0.1.0-alpha.0 This continues `@monarcode/tanstack-auth@0.1.0-alpha.0` with the same APIs and diff --git a/README.md b/README.md index 0d607bf..4b24364 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Schema-driven browser authentication for React and TanStack Router. **Version: 0.1.0-alpha.0. Early development; not ready for production.** One auth client lives in Router context. TanStack Store makes its state reactive, -with no separate AuthProvider. Tokens are stored in JavaScript-readable cookies; -the validated user profile is stored in localStorage. +with no separate AuthProvider. Tokens and the validated user profile are stored +in localStorage as separate entries. ## Installation @@ -54,7 +54,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(), + ], }); ``` @@ -64,10 +67,10 @@ 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?, expiresAt?, user }` | -| `POST /api/auth/refresh` | JSON `{ refreshToken }` | `{ accessToken, refreshToken?, expiresAt?, user? }` | +| Endpoint | Request | Successful response | +| ------------------------ | -------------------------- | --------------------------------------------------- | +| `POST /api/auth/login` | JSON `{ email, password }` | `{ accessToken, refreshToken?, expiresAt?, user }` | +| `POST /api/auth/refresh` | JSON `{ refreshToken }` | `{ accessToken, refreshToken?, expiresAt?, user? }` | `expiresAt` is positive Unix time in **milliseconds**. In this example, a refresh 401 means terminal rejection; other failures are operational. Adapt that mapping @@ -79,39 +82,42 @@ to your backend's contract. If it returns `expiresIn` in seconds, convert it to `src/auth.ts` + ```ts 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(), - expiresAt: z.number().positive().optional(), + accessToken: z.string().min(1), + refreshToken: z.string().min(1).optional(), + expiresAt: z.number().positive().optional(), }); export const loginResponseSchema = tokensSchema.extend({ user: userSchema }); -const refreshResponseSchema = tokensSchema.extend({ user: userSchema.optional() }); +const refreshResponseSchema = tokensSchema.extend({ + 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; @@ -128,31 +134,49 @@ frozen; use `updateUser` to replace it. `src/routes/__root.tsx` + ```tsx -import { createRootRouteWithContext, Outlet, useRouter } from "@tanstack/react-router"; +import { + 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" && ( + + )} +
+ )} + + + ); } ``` @@ -165,6 +189,7 @@ protected route unmounts. `src/routes/login.tsx` + ```tsx import { useState, type FormEvent } from "react"; import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"; @@ -172,56 +197,75 @@ 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
-

Sign in

- - - {error &&

{error}

} - -
; + 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 ( +
+

Sign in

+ + + {error &&

{error}

} + +
+ ); } ``` @@ -233,38 +277,43 @@ routes inherit the guard without adding `authenticated` to the URL. `src/routes/_authenticated.tsx` + ```tsx import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; import { AuthError } from "@monarcode/session-kit"; import { useAuth } 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, - }; - }, - 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, + }; + }, + component: ProtectedLayout, }); function ProtectedLayout() { - const { authVersion } = Route.useRouteContext(); - const status = useAuth((state) => state.status); - const version = useAuth((state) => state.version); - if (status !== "authenticated" || version !== authVersion) { - return

Checking session…

; - } - return ; + const { authVersion } = Route.useRouteContext(); + const status = useAuth((state) => state.status); + const version = useAuth((state) => state.version); + if (status !== "authenticated" || version !== authVersion) { + return

Checking session…

; + } + return ; } ``` @@ -277,24 +326,33 @@ the protected layout. Add other private routes beside it, such as `src/routes/_authenticated/index.tsx` + ```tsx 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}

+ +
+ ); } ``` @@ -303,6 +361,7 @@ function Home() { `src/router.tsx` + ```tsx import { createRouter, useRouter } from "@tanstack/react-router"; import { connectAuth } from "@monarcode/session-kit/react"; @@ -310,29 +369,37 @@ 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; + } } ``` @@ -351,6 +418,7 @@ Disconnecting stops background work without signing out. `src/main.tsx` + ```tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; @@ -358,11 +426,15 @@ import { RouterProvider } from "@tanstack/react-router"; import { router, disconnectAuth } from "./router.js"; const root = createRoot(document.getElementById("root")!); -root.render(); +root.render( + + + , +); export function dispose() { - root.unmount(); - disconnectAuth(); + root.unmount(); + disconnectAuth(); } ``` @@ -373,21 +445,23 @@ 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 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()); + }); } ``` @@ -401,11 +475,11 @@ ends only the matching session. Network or refresh errors reject the request. ## Refresh and errors -| 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. | +| 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. | A failed proactive refresh leaves access usable until its expiry. Expired or explicitly rejected access is unavailable; a later `getSession()` can retry with @@ -419,12 +493,12 @@ 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. 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. | -| `unauthenticated` | No session; sign-in is needed. | -| `unavailable` | Restoration failed or current credentials cannot supply usable access. | +| State status | Meaning | +| ----------------- | ---------------------------------------------------------------------- | +| `initializing` | Restoration has not completed. | +| `authenticated` | Access is locally usable; `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 state. Error codes are `USER_VALIDATION_FAILED`, `INVALID_SESSION`, @@ -443,13 +517,14 @@ the remaining data after storage becomes available. ## Storage and limits -- Tokens use the `_auth` cookie; the profile uses `:auth:user` in - localStorage. Matching session/write IDs detect incomplete writes on restoration. -- Cookies are host-only, with `Path=/`, `SameSite=Lax`, and `Secure` on HTTPS. - `cookieMaxAge` is in seconds, defaults to 30 days, and renews on successful writes. - It does not extend backend token validity. Encoded token cookies have a 3,800 - character budget; oversized values fail visibly. -- Cookies and profiles are readable by same-origin JavaScript. The backend must +- Tokens use the `:auth:tokens` localStorage entry; the profile uses + `:auth:user`. Matching session/write IDs detect incomplete writes on + restoration. 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. @@ -464,11 +539,11 @@ the remaining data after storage becomes available. ## Entry points -| Import | Exports | -| --- | --- | -| `@monarcode/session-kit` | `createAuth`, `createRefreshFn`, `AuthError`, public auth types | +| 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` | +| `@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` @@ -483,14 +558,24 @@ pnpm install pnpm run check ``` -`check` builds the package, runs 45 auth behavior tests, five React hook tests, -and four package smoke 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 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 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, build output, and `scripts/` 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. Hook tests mount React DOM in jsdom, including StrictMode subscription cleanup. @@ -498,7 +583,9 @@ 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 workflow does not publish or create a release. +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). ### Try a local build diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..ad74c6c --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,67 @@ +# Releasing session-kit + +The `Release alpha` workflow is manually triggered. It releases an alpha version +already committed to `main`; it does not change versions or commit files. +It checks and packs with pnpm, publishes the verified tarball to npm using `next`, +then creates a Git tag and GitHub prerelease at the workflow's commit. + +## One-time npm setup + +Push `.github/workflows/release.yml` to `main` before configuring npm. +In the npm settings for `@monarcode/session-kit`, add a GitHub Actions trusted +publisher with these values: + +| Field | Value | +| -------------------- | ---------------------------- | +| Organization or user | `monarcode` | +| Repository | `session-kit` | +| Workflow filename | `release.yml` | +| Environment | Leave blank | +| Allowed action | Enable **Allow npm publish** | + +The workflow uses direct publishing. npm's newer default allowing staged +publishing alone is insufficient. No npm token secret is required. +See [npm's trusted publishing documentation](https://docs.npmjs.com/trusted-publishers/). +The workflow runs on GitHub-hosted runners with Node.js 24 and npm >=11.5.1. +For this public repository/package, trusted publishing also generates provenance. + +## Release an alpha + +1. Make the intended package changes. Set `package.json` to a new alpha version, + such as `0.1.0-alpha.1`, and add its `## 0.1.0-alpha.1` section to `CHANGELOG.md`. +2. Run `pnpm run check`, then commit and push those changes to `main`. +3. Open GitHub Actions → **Release alpha** → **Run workflow**. Choose `main` + and enter the exact package version, without a `v` prefix. +4. Verify the run succeeds, npm exposes the version through `next`, and the + matching GitHub prerelease exists. + +The workflow rejects a different branch, invalid or mismatched versions, missing +release notes, an existing npm version, and an existing Git tag. It does not +support stable releases yet. The published `0.1.0-alpha.0` cannot be republished. + +The build job has read-only repository permissions. Only the publishing job +receives OIDC and release-write permissions. Its tarball and release notes come +from the same run's artifact, retained for 14 days. Publishing uses npm only for +the OIDC-authenticated upload; installation, tests, and packing use pnpm. + +## Recover a partial release + +If npm publishing fails, resolve the reported problem before rerunning. Check +whether npm already contains the version before attempting another publication. + +If npm succeeds but GitHub release creation fails, do not bump the version or +republish. Download the `session-kit-release` artifact from that run and create +the missing GitHub prerelease with its `release-notes.md`, using the original +run's full commit SHA: + +```sh +gh release create v0.1.0-alpha.1 \ + --repo monarcode/session-kit \ + --target FULL_COMMIT_SHA_FROM_THE_RUN \ + --title v0.1.0-alpha.1 \ + --prerelease --latest=false \ + --notes-file /absolute/path/to/release-notes.md +``` + +Use the affected version in that command. If the tag or release already exists, +inspect it and complete the missing step rather than creating it again. diff --git a/package.json b/package.json index 7921d1b..5ff05b3 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,38 @@ { "name": "@monarcode/session-kit", - "version": "0.1.0-alpha.0", - "packageManager": "pnpm@10.12.1", + "version": "0.1.0-alpha.1", "description": "Schema-driven browser sessions for React and TanStack Router", - "type": "module", + "keywords": [ + "auth", + "react", + "router", + "standard-schema", + "tanstack" + ], + "homepage": "https://github.com/monarcode/session-kit#readme", + "bugs": { + "url": "https://github.com/monarcode/session-kit/issues" + }, "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/monarcode/session-kit.git" + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "type": "module", + "sideEffects": false, "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "./react": { "types": "./dist/react/index.d.ts", "import": "./dist/react/index.js" @@ -19,23 +43,16 @@ }, "./package.json": "./package.json" }, - "files": ["dist", "README.md", "LICENSE", "CHANGELOG.md"], - "sideEffects": false, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, - "repository": { - "type": "git", - "url": "git+https://github.com/monarcode/session-kit.git" - }, - "homepage": "https://github.com/monarcode/session-kit#readme", - "bugs": { - "url": "https://github.com/monarcode/session-kit/issues" - }, - "keywords": ["tanstack", "router", "auth", "react", "standard-schema"], - "engines": { "node": ">=24" }, "scripts": { + "prepare": "husky", + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", "clean": "node scripts/clean.mjs", "build": "pnpm run clean && tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", @@ -43,28 +60,35 @@ "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:docs": "node scripts/check-readme.mjs", - "check": "pnpm run build && pnpm test && pnpm run test:types", + "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/store": "^0.11.1", - "@tanstack/react-store": "^0.11.1" - }, - "peerDependencies": { - "react": "^19.3.0", - "react-dom": "^19.3.0", - "@tanstack/react-router": "^1.170.38" + "@tanstack/react-store": "^0.11.1", + "@tanstack/store": "^0.11.1" }, "devDependencies": { - "jsdom": "26.1.0", - "typescript": "6.0.3", + "@tanstack/react-router": "1.170.38", "@types/node": "22.20.4", "@types/react": "19.3.0", "@types/react-dom": "19.3.0", + "husky": "9.1.7", + "jsdom": "26.1.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", "react": "19.3.0", "react-dom": "19.3.0", - "@tanstack/react-router": "1.170.38", + "typescript": "6.0.3", "zod": "4.4.3" - } + }, + "peerDependencies": { + "@tanstack/react-router": "^1.170.38", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "engines": { + "node": ">=24" + }, + "packageManager": "pnpm@10.12.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b761a02..fdf10cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,9 +30,18 @@ importers: '@types/react-dom': specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) + husky: + specifier: 9.1.7 + version: 9.1.7 jsdom: specifier: 26.1.0 version: 26.1.0 + oxfmt: + specifier: 0.71.0 + version: 0.71.0 + oxlint: + specifier: 1.86.0 + version: 1.86.0 react: specifier: 19.3.0 version: 19.3.0 @@ -79,6 +88,234 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@oxfmt/binding-android-arm-eabi@0.71.0': + resolution: {integrity: sha512-l31EyBfB4egJeFua11aQFEUE9nnDtfpWCadqQovm0WL30t/yUHtdFl/5HyFXvd5xT8Ewh3U4TI9f30GowrQ1fw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.71.0': + resolution: {integrity: sha512-E8q39SUZzSXQ08oZoFoTkxRADkdBNgL6De4qgZYr2O4JCunwqrZwAyCebxIF3yVHBafHM30WUkhTkDi+jNYZOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.71.0': + resolution: {integrity: sha512-pTteTrN88DicrmJ3DocBmpNDa6Umfh0iveEG8rnAlDFQpclmtDS7hnIvZbIEV9mDO0Ot1U0sjtoJQ1itd/ZMhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.71.0': + resolution: {integrity: sha512-G5melRC3IiNEgXlNKJhSExJYQGAQIppHFp2cl/wFrqQcEwmc4k4TkrHaFTR29SDudjoW5AZq5cXPGJDqtlH/Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.71.0': + resolution: {integrity: sha512-Hb1P5yKb9aiH2VSbm3Ml37mKjzqpunYet9krw/YK7V5JsAUJDQlju3Yk/dEqQvJD/usBvpi01Vbk9ZDNo0a8Cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': + resolution: {integrity: sha512-r7n4JEO+rkp0kaP3ia+0hv6FzK3PQIHqaF0jxGuhjJ4JHVX9ZVRbgpDVDW+OloyuJsRWpWFDKIHe8gfl1p/4mQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': + resolution: {integrity: sha512-EEynTF4nakgqDhVCmd3cHQCmTcw0BM8ea6eHigTdtZ3yxdsBNv+dYkt+OpDDZx1n/jzjI7J+Ht7IgkqFWuJUxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.71.0': + resolution: {integrity: sha512-7VgJIrywCwR/G6YMv+HqsccUt8Z4q4mbM2xR+Ry/6AeMk4pCmZ9aO4Srd3mmrvVu8PsCZGP7oHEHSXWTmyloaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-arm64-musl@0.71.0': + resolution: {integrity: sha512-AOCaminv/+fhinUXKvtPZT4POhNYmN9GfvaJdgJa1AbvuzClWGwDIXfJg8eu3sYaVL2B68jNvmS7LZGAYa0z3A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': + resolution: {integrity: sha512-0SHxuaa4QRLM7jfsd8wc6L7KdLg+FR6/ah9k1eAvcrK/Sni7oxchMvsjwUsyakVAta+wBVDkjJTr7/RFU0uUGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': + resolution: {integrity: sha512-UNKj1+rPorTK6uSlQzpN96vDNbHbL/63zcpznweo0ImQdkDdtQCS7G3puxX++2cESPKT2B56Y6DICUmFTJde5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-riscv64-musl@0.71.0': + resolution: {integrity: sha512-5cVXKj7a0mWu7GU6/bZgNyv+KtLgM5S9cnT26Os2/abj8pK/bklHsbdYEHGLLniGjNYkyzwem8mZciPCaDtilw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxfmt/binding-linux-s390x-gnu@0.71.0': + resolution: {integrity: sha512-lNpcMuZvyU1V/CY5h19hac03m4E4PLSYRckcxZOynSPojI4Mwt6nEu9wT/gVf0bclH3xGEWjwqApnSTgjkd2oA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxfmt/binding-linux-x64-gnu@0.71.0': + resolution: {integrity: sha512-5/Z6pUewQpknXqC4/ykK6Zc6RiteAnPem1Ci7K1RZLVF6w6MMjwHjR4vsjijW4Czidgv7HKeVglGjElADliT9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-linux-x64-musl@0.71.0': + resolution: {integrity: sha512-uVdG2N/4GEbOeljpQ+xv+NeEwJWJGj0WaxSiSYnoiqIYy3RWrWd3rGUmxWXP1A8+ferNvvwFoDAtvgsDUvBuSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxfmt/binding-openharmony-arm64@0.71.0': + resolution: {integrity: sha512-1LwEZmSDRFKeBaGd/Hgj2xI3SmgGFpF4YtgkSx0sTYdTiZfHWbcFvRIBjL1YiDNdQd9Khda3Ij8CoqBULjskDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.71.0': + resolution: {integrity: sha512-5tplmkeMXmza3PUptJQy25wCPJbIAg5g5r7zs+DI+7eEYOqbY+7roMdSH2zbDvv1Pb4oTDJ6NWYx9IRxBxXwhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.71.0': + resolution: {integrity: sha512-T2LWgh3Vx4PYDjtcqRRGDyCsVxeF/VE5xTpUQiCm65bNLwMpRnJYj6Hlkf6qRM3+jxCIOqHD1kVHNQCCiAVMNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.71.0': + resolution: {integrity: sha512-L06SVp7Hu1EQTkh3+PHnKK26dxVbEpOG2InMwCooTE4MA437b5FgfTleCCiGn4ykISHnHU6UHaIg7SiMO7rBHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.86.0': + resolution: {integrity: sha512-63Ozq6yQn79B2UxjSZ9huJmI+3/ZPI7hxAe6TyFh9oQL447KnIMoffOdtog+h4KILbQr61ZRvFPW5gYbAUgpgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.86.0': + resolution: {integrity: sha512-JdLGp44phbf/0Du236Dt06yfK6Qw8xhTFeDvHJdjk167r59OfP5+BL31mgMYVnswS8e+j6tFuccSY0QDC/lgCw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.86.0': + resolution: {integrity: sha512-h+vkOr4ik6KLFCXdtNVEj9xfnXfNUpPoF95QjORdhiSpLqQePzcN81kGHjAgsw1/G7WBWK5KsFKbI2UK8EyzMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.86.0': + resolution: {integrity: sha512-wbglRuH5eKsp68keTOOeKDHU1astWSRYFsh2bwKQTAPb44rD6kVgzYI1ZrAJRTc6/7PH55ZXFCQx0rQXrj0UdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.86.0': + resolution: {integrity: sha512-yTQ7QSOlNfQsrfbeVweTiegQJLKPjUKgXp/JpGiFzza6Hp29S9LAoH77TM3L0RjK39iQRZB5lk5iq2t241Mfag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + resolution: {integrity: sha512-QYJGy0E2Tv8GAayyBIeHFTwQb8u+zrvrOYrifuDGZDr9ad5XsDhiMScpZymhS9fwWjqrgG2ZUwT4vpY0ISqp4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + resolution: {integrity: sha512-oQo83CafLT8p3qh0AGkN0tSUIh5Hm63mfDsoqUmBQUBnSZLNBAbyRaAftfGY5a0cKZ34dG6jAtMYgcY5kZqt5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.86.0': + resolution: {integrity: sha512-EM6wy5c2UM12qPb7iDJDBAZvHyfEJGH6iosFgaFiaRZpirz99//EHcUyD2KAskz07x1TULuiAtsojf4nvWWksw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-arm64-musl@1.86.0': + resolution: {integrity: sha512-vCiUQb9ZNzZalxYRU4mlZwUqT5f+c7Sk9DwUOplWHtik3Dl4r3DVnUB5tBeRuTAUe6MUVCTylNW7TkkN8J1oTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + resolution: {integrity: sha512-T0AzE89yjTYmOhB/hb6i+rWTpe2mYt+xiguBhs9DEPIJTZ1H8OYqsE4VxJHHw2Wlq8Mztwvp2P8NV87ZOuhPRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + resolution: {integrity: sha512-s49r0K2f5Oc7fJoMUO1WkdhYK5Ob79uYRwWQ/XNoGysaOq3RUGmGUbHSuXiyydTTA+P9xEdDEnnlZVXiaSJhlw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-riscv64-musl@1.86.0': + resolution: {integrity: sha512-JIa2tYR8ayb3nvcMSZzn4O/xTy13S7MHllu7cBD+B5qDQXXSRy/Vwk1aNpiTjZmBJGwKCHOy/NvnY5Xcwg27uQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + + '@oxlint/binding-linux-s390x-gnu@1.86.0': + resolution: {integrity: sha512-XwXMyBsAuWZkhZU74UNcfQKr1CJJAKnS80dZzOnjl6A4fKcf91IjFgidegQugMHA5SN8ulwDXEDV1yPntjXgQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@oxlint/binding-linux-x64-gnu@1.86.0': + resolution: {integrity: sha512-C1WjukSyMnr66b+w1/tV8RFVv6d9v0MzDf4p9IxVXknqgmTHBgZh1pccN1eHzFr0b9Tbb3OXoPsAAdAuHAQfeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-linux-x64-musl@1.86.0': + resolution: {integrity: sha512-ap6KLmvC38c6MdYzsIh25cXQupqYvjd37tMNftzrX1DCtkX1Gcf2B+S2B17dD4lKa5c5gJog7DQJyDo82PBKyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@oxlint/binding-openharmony-arm64@1.86.0': + resolution: {integrity: sha512-996Q9w/2GLwNyy5Djs39Jt09aFlr4b7TPhoj7dvo5FHmcyiF68nPz73HNc5UAYKIYeK+M9JzPQ4Wg1r1dXvP6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.86.0': + resolution: {integrity: sha512-9uTCHYTknNkug5+46ViSoUMuOuGWK61D87QnblU6CXaWKJC630Z7dbls61vdHxya/cR2ycQZ6Mm6ckgF6Cag5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.86.0': + resolution: {integrity: sha512-a47ga7EKVfMU8rO0i1m3mGwfNRj3FhQyt1d/ukO/E9u1HHF3KpR3tyb3VhuJBGEVPF8uR4exSyHUyx7xbE+uxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.86.0': + resolution: {integrity: sha512-az4dzifCRzES1crwu6pDFXzJTIanrdxXFL9TbiJWrx9LKcuacbR83aPvxDA1LKLNswi/RoqitC7Afcflhu3zBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -163,6 +400,11 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -192,6 +434,32 @@ packages: nwsapi@2.2.28: resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + oxfmt@0.71.0: + resolution: {integrity: sha512-lUPUl0d/+Io5pDrsPXWs6rB4N/bpB78oj9CTDpnbulfDz+0r3XXcHPlQ7kRPJ2GjIT4nX+/mcqunOeP9BvsEtg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint@1.86.0: + resolution: {integrity: sha512-og0lhgvZfgGF//gOOmZXvtr+GmBbAGEnbEhv/QUg7UW2Wi4wHMnJbnMD+zuHgPdJxdklfgpPupdlAaAySyxrZg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2003' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -234,6 +502,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tinypool@2.2.0: + resolution: {integrity: sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==} + engines: {node: ^20.0.0 || >=22.0.0} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -335,6 +607,120 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@oxfmt/binding-android-arm-eabi@0.71.0': + optional: true + + '@oxfmt/binding-android-arm64@0.71.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.71.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.71.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.71.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.71.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.71.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.71.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.71.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.71.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.71.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.71.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.86.0': + optional: true + + '@oxlint/binding-android-arm64@1.86.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.86.0': + optional: true + + '@oxlint/binding-darwin-x64@1.86.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.86.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.86.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.86.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.86.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.86.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.86.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.86.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.86.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.86.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.86.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.86.0': + optional: true + '@standard-schema/spec@1.1.0': {} '@tanstack/history@1.162.4': {} @@ -418,6 +804,8 @@ snapshots: transitivePeerDependencies: - supports-color + husky@9.1.7: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -459,6 +847,52 @@ snapshots: nwsapi@2.2.28: {} + oxfmt@0.71.0: + dependencies: + tinypool: 2.2.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.71.0 + '@oxfmt/binding-android-arm64': 0.71.0 + '@oxfmt/binding-darwin-arm64': 0.71.0 + '@oxfmt/binding-darwin-x64': 0.71.0 + '@oxfmt/binding-freebsd-x64': 0.71.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.71.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.71.0 + '@oxfmt/binding-linux-arm64-gnu': 0.71.0 + '@oxfmt/binding-linux-arm64-musl': 0.71.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.71.0 + '@oxfmt/binding-linux-riscv64-musl': 0.71.0 + '@oxfmt/binding-linux-s390x-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-gnu': 0.71.0 + '@oxfmt/binding-linux-x64-musl': 0.71.0 + '@oxfmt/binding-openharmony-arm64': 0.71.0 + '@oxfmt/binding-win32-arm64-msvc': 0.71.0 + '@oxfmt/binding-win32-ia32-msvc': 0.71.0 + '@oxfmt/binding-win32-x64-msvc': 0.71.0 + + oxlint@1.86.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.86.0 + '@oxlint/binding-android-arm64': 1.86.0 + '@oxlint/binding-darwin-arm64': 1.86.0 + '@oxlint/binding-darwin-x64': 1.86.0 + '@oxlint/binding-freebsd-x64': 1.86.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.86.0 + '@oxlint/binding-linux-arm-musleabihf': 1.86.0 + '@oxlint/binding-linux-arm64-gnu': 1.86.0 + '@oxlint/binding-linux-arm64-musl': 1.86.0 + '@oxlint/binding-linux-ppc64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-gnu': 1.86.0 + '@oxlint/binding-linux-riscv64-musl': 1.86.0 + '@oxlint/binding-linux-s390x-gnu': 1.86.0 + '@oxlint/binding-linux-x64-gnu': 1.86.0 + '@oxlint/binding-linux-x64-musl': 1.86.0 + '@oxlint/binding-openharmony-arm64': 1.86.0 + '@oxlint/binding-win32-arm64-msvc': 1.86.0 + '@oxlint/binding-win32-ia32-msvc': 1.86.0 + '@oxlint/binding-win32-x64-msvc': 1.86.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -490,6 +924,8 @@ snapshots: symbol-tree@3.2.4: {} + tinypool@2.2.0: {} + tldts-core@6.1.86: {} tldts@6.1.86: diff --git a/scripts/check-readme.mjs b/scripts/check-readme.mjs index 572d09b..22c8380 100644 --- a/scripts/check-readme.mjs +++ b/scripts/check-readme.mjs @@ -10,8 +10,6 @@ const examples = [...readme.matchAll( /\s*```tsx?\n([\s\S]*?)\n```/g, )]; if (!examples.length) throw new Error("No README TypeScript examples found"); -// A separate program prevents the consumer fixture's Register from merging -// with the README's Router. Staying inside the repo resolves package self-imports. const directory = await mkdtemp(join(root, "tests", ".readme-")); const require = createRequire(import.meta.url); const compiler = require.resolve("typescript/bin/tsc"); @@ -31,9 +29,6 @@ try { "src/routes/_authenticated/index.tsx", ]; if (fileRoutePaths.every((path) => paths.has(path))) { - // In an application, the Router plugin generates this file. The docs check - // creates the smallest equivalent tree so route modules retain real context - // and Register inference without asking contributors to edit generated code. await writeFile(join(directory, "src/routeTree.gen.ts"), ` import { Route as rootRoute } from "./routes/__root.js"; import { Route as loginRouteImport } from "./routes/login.js"; diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..f412be6 --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,47 @@ +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 }; +} + +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}.`); +} + +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 045836f..bc35b93 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1,5 +1,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { Store } from "@tanstack/store"; + import { AuthError, authError, sessionChanged } from "./errors.js"; import { createPersistence } from "./persistence.js"; import type { @@ -11,16 +12,26 @@ import type { User, UserInput, } from "./types.js"; -import { isRecord, runTask, validateTokens, validateUser } from "./validation.js"; +import { + isRecord, + runTask, + validateTokens, + validateUser, +} from "./validation.js"; type InternalSession = Tokens & { id: string; user: U }; +/** Starts work nobody awaits. Failures are already published to `state.error`. */ +function inBackground(work: Promise) { + work.catch(() => {}); +} + export function createAuth( options: AuthOptions, ): AuthClient, User> { type U = User; type I = UserInput; - const persistence = createPersistence(options.name, options.cookieMaxAge); + const persistence = createPersistence(options.name, options.maxAge); const store = new Store>({ status: "initializing", user: null, @@ -89,7 +100,6 @@ export function createAuth( clearTimeout(expiryTimer); } - // Long delays are rechecked instead of overflowing the browser's timer limit. function schedule() { stopTimers(); if (!mounts || !session || session.expiresAt === undefined) return; @@ -102,9 +112,7 @@ export function createAuth( return; } publish(store.get().error); - void getSession().catch(() => { - /* Error is available in state. */ - }); + inBackground(getSession()); }, Math.min(remaining, 2_147_483_647), ); @@ -116,9 +124,7 @@ export function createAuth( schedule(); return; } - void refreshSession(snapshot(), false).catch(() => { - /* Keep valid access until expiry. */ - }); + inBackground(refreshSession(snapshot(), false)); }, Math.min(delay, 2_147_483_647), ); @@ -219,7 +225,6 @@ export function createAuth( } return; } - // No authenticated snapshot is exposed when storage cannot be read. store.setState((previous) => ({ ...previous, status: "unavailable", @@ -238,15 +243,11 @@ export function createAuth( } async function signIn(input: Tokens & { user: I }) { - // Last sign-in attempt wins. Invalid input leaves the previous session intact. advanceEpoch(); const expected = epoch; try { const tokens = validateTokens(input); - if ( - tokens.expiresAt !== undefined && - tokens.expiresAt <= Date.now() - ) { + if (tokens.expiresAt !== undefined && tokens.expiresAt <= Date.now()) { throw new AuthError( "INVALID_SESSION", "Cannot sign in with an expired access token", @@ -257,10 +258,7 @@ export function createAuth( controller.signal, ); assertEpoch(expected); - if ( - tokens.expiresAt !== undefined && - tokens.expiresAt <= Date.now() - ) { + if (tokens.expiresAt !== undefined && tokens.expiresAt <= Date.now()) { throw new AuthError( "INVALID_SESSION", "Access token expired during validation", @@ -280,14 +278,9 @@ export function createAuth( } finally { if (epoch === expected) { schedule(); - // Validation paused the previous session's timers. If it expired - // meanwhile, hide its profile and recover once after sign-in fails. if (session && !valid()) { publish(store.get().error); - if (mounts) - void getSession().catch(() => { - /* Recovery errors are exposed through state. */ - }); + if (mounts) inBackground(getSession()); } } } @@ -399,7 +392,6 @@ export function createAuth( "Refresh returned the rejected access token", ); } - // A newer explicit profile update takes precedence over refresh's profile. const user = profile === profileVersion ? (result.user ?? session!.user) @@ -410,8 +402,7 @@ export function createAuth( ...result.tokens, id: current.id, user, - refreshToken: - result.tokens.refreshToken ?? current.refreshToken, + refreshToken: result.tokens.refreshToken ?? current.refreshToken, }); return snapshot(); } catch (cause) { @@ -444,7 +435,6 @@ export function createAuth( async function getSession(): Promise | null> { const expected = epoch; await initialize(); - // Invalid saved data can deliberately advance the epoch and clear the session. if (epoch !== expected && session) throw sessionChanged(); if (!session) return null; if (valid()) return snapshot(); @@ -454,9 +444,7 @@ export function createAuth( function mount() { mounts++; - void getSession().catch(() => { - /* Errors are exposed through state and guards. */ - }); + inBackground(getSession()); schedule(); let active = true; return () => { @@ -464,7 +452,6 @@ export function createAuth( active = false; if (--mounts === 0) { stopTimers(); - // Lifecycle cleanup cancels validation/network work without deleting storage. advanceEpoch(); } }; diff --git a/src/core/errors.ts b/src/core/errors.ts index 3c98319..c5bb66b 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -1,38 +1,38 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; +import type { StandardSchemaV1 } from "@standard-schema/spec"; export type AuthErrorCode = - | 'USER_VALIDATION_FAILED' - | 'INVALID_SESSION' - | 'PERSISTENCE_FAILED' - | 'REFRESH_FAILED' - | 'SESSION_CHANGED' - | 'UNAUTHENTICATED'; + | "USER_VALIDATION_FAILED" + | "INVALID_SESSION" + | "PERSISTENCE_FAILED" + | "REFRESH_FAILED" + | "SESSION_CHANGED" + | "UNAUTHENTICATED"; export class AuthError extends Error { - constructor( - readonly code: AuthErrorCode, - message: string, - options?: ErrorOptions, - readonly issues?: readonly StandardSchemaV1.Issue[], - ) { - super(message, options); - this.name = 'AuthError'; - } + constructor( + readonly code: AuthErrorCode, + message: string, + options?: ErrorOptions, + readonly issues?: readonly StandardSchemaV1.Issue[], + ) { + super(message, options); + this.name = "AuthError"; + } } export function authError( - code: AuthErrorCode, - message: string, - cause: unknown, + code: AuthErrorCode, + message: string, + cause: unknown, ): AuthError { - return cause instanceof AuthError - ? cause - : new AuthError(code, message, { cause }); + return cause instanceof AuthError + ? cause + : new AuthError(code, message, { cause }); } export function sessionChanged(): AuthError { - return new AuthError( - 'SESSION_CHANGED', - 'The session changed during this operation', - ); + return new AuthError( + "SESSION_CHANGED", + "The session changed during this operation", + ); } diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 2ca7e5f..590142e 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -1,6 +1,6 @@ -import { AuthError } from './errors.js'; -import type { Tokens } from './types.js'; -import { isRecord, validateTokens } from './validation.js'; +import { AuthError } from "./errors.js"; +import type { Tokens } from "./types.js"; +import { isRecord, validateTokens } from "./validation.js"; export type StoredSession = Tokens & { id: string; user: unknown }; @@ -11,18 +11,11 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { maxAge <= 0 ) { throw new Error( - "Use a simple auth name and a positive cookieMaxAge in seconds", + "Use a simple auth name and a positive maxAge in seconds", ); } - const cookieName = `${name}_auth`; + const tokensKey = `${name}:auth:tokens`; const userKey = `${name}:auth:user`; - const attributes = () => - `; Path=/; SameSite=Lax${location.protocol === "https:" ? "; Secure" : ""}`; - const readCookie = () => - document.cookie - .split("; ") - .find((part) => part.startsWith(`${cookieName}=`)) - ?.slice(cookieName.length + 1); function storageOperation(work: () => T): T { try { @@ -32,51 +25,44 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { throw new AuthError( "PERSISTENCE_FAILED", "Auth storage is unavailable", - { cause }, + { + cause, + }, ); } } function clear() { storageOperation(() => { - // Attempt both, even if one fails. Surface failure to the caller. const failures: unknown[] = []; - try { - document.cookie = `${cookieName}=; Max-Age=0${attributes()}`; - if (readCookie() !== undefined) - throw new Error("Cookie deletion was blocked"); - } catch (error) { - failures.push(error); - } - try { - localStorage.removeItem(userKey); - } catch (error) { - failures.push(error); + for (const key of [tokensKey, userKey]) { + try { + localStorage.removeItem(key); + } catch (error) { + failures.push(error); + } } if (failures.length) - throw new AggregateError( - failures, - "Could not clear auth storage", - ); + throw new AggregateError(failures, "Could not clear auth storage"); }); } function read(): StoredSession | null { return storageOperation(() => { - const cookie = readCookie(); - if (!cookie) { + const tokensText = localStorage.getItem(tokensKey); + if (!tokensText) { localStorage.removeItem(userKey); return null; } const userText = localStorage.getItem(userKey); try { - const tokens: unknown = JSON.parse(decodeURIComponent(cookie)); + const tokens: unknown = JSON.parse(tokensText); const profile: unknown = userText ? JSON.parse(userText) : null; if ( !isRecord(tokens) || !isRecord(profile) || - tokens.v !== 1 || - profile.v !== 1 || + tokens.v !== 2 || + profile.v !== 2 || typeof tokens.id !== "string" || !tokens.id || typeof tokens.writeId !== "string" || @@ -86,12 +72,9 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { !Number.isFinite(tokens.persistUntil) || tokens.persistUntil <= Date.now() ) { - throw new Error( - "Missing, expired, or mismatched auth data", - ); + throw new Error("Missing, expired, or mismatched auth data"); } - // A second read detects a cookie change during the profile read. - if (readCookie() !== cookie) + if (localStorage.getItem(tokensKey) !== tokensText) throw new Error("Auth storage changed while reading"); return { ...validateTokens(tokens), @@ -112,23 +95,19 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { storageOperation(() => { const { user, ...tokens } = session; const writeId = crypto.randomUUID(); - const value = encodeURIComponent( + localStorage.setItem( + userKey, + JSON.stringify({ v: 2, id: session.id, writeId, user }), + ); + localStorage.setItem( + tokensKey, JSON.stringify({ - v: 1, + v: 2, ...tokens, writeId, persistUntil: Date.now() + maxAge * 1000, }), ); - if (cookieName.length + value.length > 3800) - throw new Error("Auth tokens exceed the cookie size budget"); - localStorage.setItem( - userKey, - JSON.stringify({ v: 1, id: session.id, writeId, user }), - ); - document.cookie = `${cookieName}=${value}; Max-Age=${maxAge}${attributes()}`; - if (readCookie() !== value) - throw new Error("Cookie write was blocked"); }); } return { read, write, clear }; diff --git a/src/core/types.ts b/src/core/types.ts index 4b1d47c..bb14f73 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,77 +1,71 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; -import type { Store } from '@tanstack/store'; -import type { AuthError } from './errors.js'; +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import type { Store } from "@tanstack/store"; + +import type { AuthError } from "./errors.js"; export type UserInput = - StandardSchemaV1.InferInput; + StandardSchemaV1.InferInput; -export type User = - StandardSchemaV1.InferOutput; +export type User = StandardSchemaV1.InferOutput; export type Tokens = { - accessToken: string; - refreshToken?: string; - /** Unix time in milliseconds. Falls back to JWT exp when omitted. */ - expiresAt?: number; + accessToken: string; + refreshToken?: string; + /** Unix time in milliseconds. Falls back to JWT exp when omitted. */ + expiresAt?: number; }; export type SignInInput = Tokens & { user: I }; -export type RefreshResult = - | (Tokens & { user?: I }) - | null; +export type RefreshResult = (Tokens & { user?: I }) | null; export type RefreshFn = (context: { - refreshToken: string; - signal: AbortSignal; + refreshToken: string; + signal: AbortSignal; }) => Promise>; export function createRefreshFn( - handler: RefreshFn, + handler: RefreshFn, ): RefreshFn { - return handler; + return handler; } export type Session = Readonly<{ - id: string; - accessToken: string; - expiresAt?: number; - user: U; + id: string; + accessToken: string; + expiresAt?: number; + user: U; }>; -// Tokens are intentionally absent from the reactive snapshot. +/** Reactive auth snapshot. Tokens are intentionally excluded. */ export type AuthState = Readonly<{ - status: - | 'initializing' - | 'authenticated' - | 'unauthenticated' - | 'unavailable'; - user: U | null; - sessionId: string | null; - /** Changes when guards must run again. */ - version: number; - error: AuthError | null; + status: "initializing" | "authenticated" | "unauthenticated" | "unavailable"; + user: U | null; + sessionId: string | null; + /** Changes when guards must run again. */ + version: number; + error: AuthError | null; }>; export type AuthClient = { - state: Pick>, 'get' | 'subscribe'>; - signIn: (input: SignInInput) => Promise; - signOut: () => Promise; - updateUser: (input: I | (() => Promise)) => Promise; - getSession: () => Promise | null>; - /** Recover from rejection of this particular access token. */ - refresh: (session: Session) => Promise | null>; - /** End only the session that produced this rejected token. */ - rejectSession: (session: Session) => Promise; - isCurrent: (session: Session) => boolean; - /** Called by the Router connector. Cleanup stops background timers. */ - mount: () => () => void; + state: Pick>, "get" | "subscribe">; + signIn: (input: SignInInput) => Promise; + signOut: () => Promise; + updateUser: (input: I | (() => Promise)) => Promise; + getSession: () => Promise | null>; + /** Recover from rejection of this particular access token. */ + refresh: (session: Session) => Promise | null>; + /** End only the session that produced this rejected token. */ + rejectSession: (session: Session) => Promise; + isCurrent: (session: Session) => boolean; + /** Called by the Router connector. Cleanup stops background timers. */ + mount: () => () => void; }; export type AuthOptions = { - name: string; - userSchema: S; - refresh?: RefreshFn>>; - /** Cookie lifetime in seconds, renewed on successful writes. Default: 30 days. */ - cookieMaxAge?: number; + name: string; + userSchema: S; + refresh?: RefreshFn>>; + /** Saved-session lifetime in seconds, renewed on successful writes. Default: 30 days. */ + maxAge?: number; }; diff --git a/src/core/validation.ts b/src/core/validation.ts index db23e1d..b21f8ab 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -1,13 +1,16 @@ -import type { StandardSchemaV1 } from '@standard-schema/spec'; -import { AuthError } from './errors.js'; -import type { Tokens, User } from './types.js'; +import type { StandardSchemaV1 } from "@standard-schema/spec"; + +import { AuthError } from "./errors.js"; +import type { Tokens, User } from "./types.js"; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -// Reject values that JSON would silently drop or change. Freeze the validated -// output so callers cannot mutate auth state without notifying subscribers. +/** + * 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()): void { if ( value === null || @@ -63,10 +66,7 @@ export async function validateUser( input: unknown, ): Promise> { try { - // Clone first: a schema that returns its input must not freeze caller-owned data. - const result = await schema["~standard"].validate( - structuredClone(input), - ); + const result = await schema["~standard"].validate(structuredClone(input)); if (result.issues) { throw new AuthError( "USER_VALIDATION_FAILED", @@ -85,11 +85,9 @@ export async function validateUser( return result.value; } catch (cause) { if (cause instanceof AuthError) throw cause; - throw new AuthError( - "USER_VALIDATION_FAILED", - "User validation failed", - { cause }, - ); + throw new AuthError("USER_VALIDATION_FAILED", "User validation failed", { + cause, + }); } } @@ -110,10 +108,10 @@ function jwtExpiry(token: string): number | undefined { ) { return payload.exp * 1000; } + return undefined; } catch { - /* Opaque tokens have no locally readable expiry. */ + return undefined; } - return undefined; } export function validateTokens(value: unknown): Tokens { @@ -154,7 +152,10 @@ export function validateTokens(value: unknown): Tokens { }; } -// Also bounds handlers that ignore AbortSignal. Work after a timeout must never commit. +/** + * 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, diff --git a/src/http/index.ts b/src/http/index.ts index 49813be..732c7f1 100644 --- a/src/http/index.ts +++ b/src/http/index.ts @@ -1,5 +1,5 @@ -import { AuthError, sessionChanged } from '../core/errors.js'; -import type { AuthClient, Session } from '../core/types.js'; +import { AuthError, sessionChanged } from "../core/errors.js"; +import type { AuthClient, Session } from "../core/types.js"; export function createAuthFetch(auth: AuthClient, baseUrl: string) { const base = new URL(baseUrl); @@ -13,12 +13,12 @@ export function createAuthFetch(auth: AuthClient, baseUrl: string) { "Authenticated requests must stay on the configured API origin", ); } - const captured = await auth.getSession(); + const captured = await auth.getSession(); 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 canReplay = request.method === "GET" || request.method === "HEAD"; const send = async (session: Session) => { if (!auth.isCurrent(session)) throw sessionChanged(); @@ -29,21 +29,21 @@ export function createAuthFetch(auth: AuthClient, baseUrl: string) { ); if (!auth.isCurrent(session)) throw sessionChanged(); return response; - }; + }; - const response = await send(captured); + const response = await send(captured); - if (response.status !== 401) return response; + if (response.status !== 401) return response; - const fresh = await auth.refresh(captured); + const fresh = await auth.refresh(captured); if (!fresh) return response; - if (!canReplay) return response; + if (!canReplay) return response; await response.body?.cancel(); - const retried = await send(fresh); + const retried = await send(fresh); - if (retried.status === 401) await auth.rejectSession(fresh); + if (retried.status === 401) await auth.rejectSession(fresh); return retried; }; diff --git a/src/index.ts b/src/index.ts index c6e7dd1..2df8bf3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,18 @@ -export { createAuth } from './core/client.js'; -export { createRefreshFn } from './core/types.js'; -export { AuthError } from './core/errors.js'; +export { createAuth } from "./core/client.js"; +export { createRefreshFn } from "./core/types.js"; +export { AuthError } from "./core/errors.js"; -export type { AuthErrorCode } from './core/errors.js'; +export type { AuthErrorCode } from "./core/errors.js"; export type { - AuthClient, - AuthOptions, - AuthState, - Tokens, - SignInInput, - Session, - RefreshFn, - RefreshResult, - User, - UserInput, -} from './core/types.js'; + AuthClient, + AuthOptions, + AuthState, + Tokens, + SignInInput, + Session, + RefreshFn, + RefreshResult, + User, + UserInput, +} from "./core/types.js"; diff --git a/src/react/connect.ts b/src/react/connect.ts index d291d19..9fd675f 100644 --- a/src/react/connect.ts +++ b/src/react/connect.ts @@ -1,6 +1,5 @@ import type { AuthClient } from "../core/types.js"; -// Structural typing preserves the application's actual Router/context types. type AuthRouter = { options: { context: { auth: AuthClient } }; invalidate: () => Promise; @@ -18,7 +17,6 @@ export function connectAuth(router: AuthRouter): () => void { const subscription = auth.state.subscribe((next) => { if (next.version === previous.version) return; previous = next; - // Evict inactive/preloaded Router data whenever guard-relevant state changes. router.clearCache(); if (queued) return; queued = true; diff --git a/src/react/hooks.ts b/src/react/hooks.ts index ee45b84..177c4ce 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -48,7 +48,6 @@ function useContextAuth(): AuthSource { } export function useAuthClient(): RegisteredAuth { - // The runtime shape is checked above; the app's Register supplies its full type. return useContextAuth() as RegisteredAuth; } diff --git a/src/react/index.ts b/src/react/index.ts index 904ec9f..a884286 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,7 +1,4 @@ -export { connectAuth, safeReturnTo } from './connect.js'; -export { useAuth, useAuthClient } from './hooks.js'; +export { connectAuth, safeReturnTo } from "./connect.js"; +export { useAuth, useAuthClient } from "./hooks.js"; -export type { - RegisteredAuth, - RegisteredAuthState, -} from './hooks.js'; +export type { RegisteredAuth, RegisteredAuthState } from "./hooks.js"; diff --git a/tests/auth.test.mjs b/tests/auth.test.mjs index 4badd1d..149cdb5 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -1,27 +1,36 @@ -import { test, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; +import { test, beforeEach, afterEach } from "node:test"; + import { createAuth } from "@monarcode/session-kit"; -import { connectAuth, safeReturnTo } from "@monarcode/session-kit/react"; import { createAuthFetch } from "@monarcode/session-kit/http"; +import { connectAuth, safeReturnTo } from "@monarcode/session-kit/react"; import { z } from "zod"; -// Ported from the v3 reference suite. Exercise the package's emitted public API. const userSchema = z.object({ id: z.string(), email: z.string(), role: z.enum(["admin", "member"]).default("member"), }); const user = { id: "alice", email: "alice@example.com", role: "member" }; -let cookies, values, blockedCookies, blockedStorage, blockedStorageDeletion, cleanups; +let cookies, values, blockedStorage, blockedWrites, blockedDeletes, cleanups; +const TOKENS = "test:auth:tokens", + PROFILE = "test:auth:user"; let originalGlobals; const browserGlobals = [ - "location", "document", "localStorage", "fetch", "self", "scrollTo", "window", + "location", + "document", + "localStorage", + "fetch", + "self", + "scrollTo", + "window", ]; -const setGlobal = (name, value) => Object.defineProperty(globalThis, name, { - configurable: true, - writable: true, - value, -}); +const setGlobal = (name, value) => + Object.defineProperty(globalThis, name, { + configurable: true, + writable: true, + value, + }); const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { @@ -46,20 +55,22 @@ const code = (expected) => (error) => error.code === expected; beforeEach(() => { cookies = new Map(); values = new Map(); - blockedCookies = false; blockedStorage = false; - blockedStorageDeletion = false; + blockedWrites = new Set(); + blockedDeletes = new Set(); cleanups = []; - originalGlobals = new Map(browserGlobals.map((name) => [ - name, Object.getOwnPropertyDescriptor(globalThis, name), - ])); + originalGlobals = new Map( + browserGlobals.map((name) => [ + name, + Object.getOwnPropertyDescriptor(globalThis, name), + ]), + ); setGlobal("location", { protocol: "https:" }); setGlobal("document", { get cookie() { return [...cookies].map(([k, v]) => `${k}=${v}`).join("; "); }, set cookie(text) { - if (blockedCookies) return; const pair = text.split(";")[0]; const index = pair.indexOf("="); const key = pair.slice(0, index), @@ -74,18 +85,18 @@ beforeEach(() => { return values.get(key) ?? null; }, setItem(key, value) { - if (blockedStorage) throw new Error("Quota"); + if (blockedStorage || blockedWrites.has(key)) throw new Error("Quota"); values.set(key, value); }, removeItem(key) { - if (blockedStorage || blockedStorageDeletion) throw new Error("Blocked"); + if (blockedStorage || blockedDeletes.has(key)) + throw new Error("Blocked"); values.delete(key); }, }); }); afterEach(() => { try { - // Disconnect clients before restoring the browser environment. for (const cleanup of cleanups.toReversed()) cleanup(); } finally { for (const [name, descriptor] of originalGlobals) { @@ -95,11 +106,13 @@ afterEach(() => { } }); -test("schema defaults and custom fields survive restoration; tokens stay out of localStorage/state", async () => { +test("schema defaults and custom fields survive restoration; tokens stay out of the profile/state", async () => { const auth = client(); await auth.signIn(input({ user: { id: "alice", email: user.email } })); assert.equal(auth.state.get().user.role, "member"); - assert.equal(JSON.stringify([...values]).includes("access-1"), false); + assert.equal(values.get(PROFILE).includes("access-1"), false); + assert.equal(JSON.parse(values.get(TOKENS)).refreshToken, "refresh-1"); + assert.equal(cookies.size, 0); assert.equal(JSON.stringify(auth.state.get()).includes("refresh-1"), false); const restored = await client().getSession(); assert.equal(restored.user.email, user.email); @@ -128,7 +141,10 @@ test("invalid sign-in leaves the current session usable", async () => { for (const timeout of [false, true]) { for (const canRefresh of [false, true]) { test(`expiry during ${timeout ? "timed-out" : "failed"} sign-in ${canRefresh ? "refreshes" : "clears"} the previous session`, async (t) => { - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 1_000_000 }); + t.mock.timers.enable({ + apis: ["Date", "setTimeout"], + now: 1_000_000, + }); const validation = deferred(); const refresh = deferred(); let refreshCalls = 0; @@ -136,19 +152,27 @@ for (const timeout of [false, true]) { userSchema: userSchema.refine(async (value) => value.id === "bob" ? validation.promise : true, ), - refresh: canRefresh ? async () => { - refreshCalls++; - return refresh.promise; - } : undefined, + refresh: canRefresh + ? async () => { + refreshCalls++; + return refresh.promise; + } + : undefined, }); await auth.signIn(input({ expiresAt: 1_001_000 })); const previousId = auth.state.get().sessionId; cleanups.push(auth.mount()); await flush(); - const pending = auth.signIn(input({ - accessToken: "bob-token", user: { ...user, id: "bob" }, - })); - const checked = assert.rejects(pending, code("USER_VALIDATION_FAILED")); + const pending = auth.signIn( + input({ + accessToken: "bob-token", + user: { ...user, id: "bob" }, + }), + ); + const checked = assert.rejects( + pending, + code("USER_VALIDATION_FAILED"), + ); await flush(); t.mock.timers.tick(1_000); if (timeout) t.mock.timers.tick(14_000); @@ -159,7 +183,10 @@ for (const timeout of [false, true]) { assert.equal(refreshCalls, 1); assert.equal(auth.state.get().status, "unavailable"); assert.equal(auth.state.get().user, null); - refresh.resolve({ accessToken: "access-2", expiresAt: Date.now() + 60_000 }); + refresh.resolve({ + accessToken: "access-2", + expiresAt: Date.now() + 60_000, + }); await flush(); assert.equal(auth.state.get().status, "authenticated"); assert.equal(auth.state.get().sessionId, previousId); @@ -168,11 +195,9 @@ for (const timeout of [false, true]) { assert.equal(auth.state.get().status, "unauthenticated"); assert.equal(auth.state.get().user, null); assert.equal(auth.state.get().sessionId, null); - assert.equal(cookies.size, 0); assert.equal(values.size, 0); } if (timeout) { - // A validator that ignores cancellation must not commit its late result. validation.resolve(true); await flush(); assert.notEqual(auth.state.get().user?.id, "bob"); @@ -193,9 +218,12 @@ test("expiry during validation does not cancel a successful new sign-in", async const previousId = auth.state.get().sessionId; cleanups.push(auth.mount()); await flush(); - const pending = auth.signIn(input({ - accessToken: "bob-token", user: { ...user, id: "bob" }, - })); + const pending = auth.signIn( + input({ + accessToken: "bob-token", + user: { ...user, id: "bob" }, + }), + ); await flush(); t.mock.timers.tick(1_000); await flush(); @@ -232,7 +260,7 @@ test("failed sign-in recovery retains expired credentials without a refresh retr assert.equal(refreshCalls, 1); assert.equal(auth.state.get().status, "unavailable"); assert.equal(auth.state.get().error.code, "REFRESH_FAILED"); - assert.equal(cookies.size, 1); + assert.ok(values.has(TOKENS)); t.mock.timers.tick(60_000); await flush(); assert.equal(refreshCalls, 1); @@ -262,7 +290,7 @@ test("failed sign-in hides an expired unmounted session until demand retries ref assert.equal(auth.state.get().status, "unavailable"); assert.equal(auth.state.get().user, null); assert.equal(refreshCalls, 0); - assert.equal(cookies.size, 1); + assert.ok(values.has(TOKENS)); assert.equal((await auth.getSession()).accessToken, "access-2"); assert.equal(refreshCalls, 1); }); @@ -281,7 +309,9 @@ test("a superseded sign-in cannot recover or clear the newer account", async (t) const pending = auth.signIn(input({ user: { ...user, id: "bob" } })); const checked = assert.rejects(pending, code("SESSION_CHANGED")); await flush(); - await auth.signIn(input({ accessToken: "carol-token", user: { ...user, id: "carol" } })); + await auth.signIn( + input({ accessToken: "carol-token", user: { ...user, id: "carol" } }), + ); t.mock.timers.tick(2_000); validation.resolve(false); await checked; @@ -291,37 +321,32 @@ test("a superseded sign-in cannot recover or clear the newer account", async (t) assert.equal((await auth.getSession()).accessToken, "carol-token"); }); -test("cookie/profile mismatch fails closed and removes saved data", async () => { +test("token/profile mismatch fails closed and removes saved data", async () => { await client().signIn(input()); - const saved = JSON.parse(values.get("test:auth:user")); - values.set( - "test:auth:user", - JSON.stringify({ ...saved, writeId: "wrong" }), - ); + const saved = JSON.parse(values.get(PROFILE)); + values.set(PROFILE, JSON.stringify({ ...saved, writeId: "wrong" })); assert.equal(await client().getSession(), null); - assert.equal(cookies.size, 0); assert.equal(values.size, 0); }); test("malformed stored user fails schema validation", async () => { await client().signIn(input()); - const saved = JSON.parse(values.get("test:auth:user")); - values.set("test:auth:user", JSON.stringify({ ...saved, user: { id: 5 } })); + const saved = JSON.parse(values.get(PROFILE)); + values.set(PROFILE, JSON.stringify({ ...saved, user: { id: 5 } })); assert.equal(await client().getSession(), null); }); for (const invalidUser of [false, true]) { - for (const blockedTarget of ["cookie", "profile"]) { + for (const blockedTarget of ["tokens", "profile"]) { test(`restoring ${invalidUser ? "an invalid user" : "a malformed session"} exposes blocked ${blockedTarget} cleanup and permits retry`, async () => { await client().signIn(input()); if (invalidUser) { - const saved = JSON.parse(values.get("test:auth:user")); - values.set("test:auth:user", JSON.stringify({ ...saved, user: { id: 5 } })); + const saved = JSON.parse(values.get(PROFILE)); + values.set(PROFILE, JSON.stringify({ ...saved, user: { id: 5 } })); } else { - cookies.set("test_auth", "invalid-json"); + values.set(TOKENS, "invalid-json"); } - blockedCookies = blockedTarget === "cookie"; - blockedStorageDeletion = blockedTarget === "profile"; + blockedDeletes.add(blockedTarget === "tokens" ? TOKENS : PROFILE); const auth = client(); let failure; await assert.rejects(auth.getSession(), (error) => { @@ -334,21 +359,21 @@ for (const invalidUser of [false, true]) { assert.equal(auth.state.get().sessionId, null); assert.ok(failure.cause instanceof AggregateError); const [restoration, cleanup] = failure.cause.errors; - assert.equal(restoration.code, invalidUser ? "USER_VALIDATION_FAILED" : "INVALID_SESSION"); + assert.equal( + restoration.code, + invalidUser ? "USER_VALIDATION_FAILED" : "INVALID_SESSION", + ); 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); - // Clearing must attempt both stores even when one deletion fails. - assert.equal(cookies.size, blockedTarget === "cookie" ? 1 : 0); - assert.equal(values.size, blockedTarget === "profile" ? 1 : 0); + assert.equal(values.has(TOKENS), blockedTarget === "tokens"); + assert.equal(values.has(PROFILE), blockedTarget === "profile"); assert.equal(await auth.getSession(), null); assert.equal(auth.state.get().error, failure); - blockedCookies = false; - blockedStorageDeletion = false; + blockedDeletes.clear(); await auth.signOut(); assert.equal(auth.state.get().error, null); - assert.equal(cookies.size, 0); assert.equal(values.size, 0); }); } @@ -364,18 +389,18 @@ test("storage read failure is operational and can be retried", async () => { assert.equal((await auth.getSession()).user.id, "alice"); }); -test("cookie write failure clears runtime state and fails visibly", async () => { +test("token write failure clears runtime state and fails visibly", async () => { const auth = client(); - blockedCookies = true; + blockedWrites.add(TOKENS); await assert.rejects(auth.signIn(input()), code("PERSISTENCE_FAILED")); assert.equal(auth.state.get().status, "unauthenticated"); assert.equal(values.size, 0); }); -test("signout stays signed out when cookie deletion fails", async () => { +test("signout stays signed out when token deletion fails", async () => { const auth = client(); await auth.signIn(input()); - blockedCookies = true; + blockedDeletes.add(TOKENS); await assert.rejects(auth.signOut(), code("PERSISTENCE_FAILED")); assert.equal(await auth.getSession(), null); assert.equal(auth.state.get().user, null); @@ -399,10 +424,7 @@ test("concurrent refresh requests share one backend operation", async () => { gate.resolve({ accessToken: "access-2" }); assert.equal((await a).accessToken, "access-2"); assert.equal((await b).accessToken, "access-2"); - assert.equal( - JSON.parse(decodeURIComponent(cookies.get("test_auth"))).refreshToken, - "refresh-1", - ); + assert.equal(JSON.parse(values.get(TOKENS)).refreshToken, "refresh-1"); }); test("late refresh success cannot restore a signed-out session", async () => { @@ -417,7 +439,7 @@ test("late refresh success cannot restore a signed-out session", async () => { await checked; await flush(); assert.equal(await auth.getSession(), null); - assert.equal(cookies.size, 0); + assert.equal(values.size, 0); }); test("late refresh failure cannot clear a new account", async () => { @@ -437,9 +459,7 @@ test("late refresh failure cannot clear a new account", async () => { test("async validation cannot commit after logout", async () => { const gate = deferred(); - const schema = z - .object({ id: z.string() }) - .refine(async () => gate.promise); + const schema = z.object({ id: z.string() }).refine(async () => gate.promise); const auth = client({ userSchema: schema }); const pending = auth.signIn(input({ user: { id: "a" } })); const checked = assert.rejects(pending, code("SESSION_CHANGED")); @@ -490,7 +510,7 @@ test("refresh null ends the session; thrown network errors retain credentials", code("REFRESH_FAILED"), ); assert.equal(retry.state.get().status, "unavailable"); - assert.equal(cookies.size, 1); + assert.ok(values.has(TOKENS)); }); test("a rejected access token cannot be returned as fresh", async () => { @@ -718,7 +738,7 @@ test("invalid optional refresh user cannot partially replace tokens", async () = auth.refresh(await auth.getSession()), code("USER_VALIDATION_FAILED"), ); - const saved = JSON.parse(decodeURIComponent(cookies.get("test_auth"))); + const saved = JSON.parse(values.get(TOKENS)); assert.equal(saved.accessToken, "access-1"); }); @@ -790,7 +810,6 @@ test("real Router guards rerun after logout and produce a login redirect", async history: createMemoryHistory({ initialEntries: ["/private"] }), isServer: false, }); - // Simulate React acknowledging the committed transition. router.startTransition = async (fn) => { fn(); return true; diff --git a/tests/consumer.ts b/tests/consumer.ts index 8876402..c17049f 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -1,11 +1,12 @@ -import { z } from "zod"; import { createRootRouteWithContext, createRouter, } from "@tanstack/react-router"; +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 { createAuthFetch } from "../dist/http/index.js"; const auth = createAuth({ name: "type-test", @@ -20,7 +21,6 @@ declare module "@tanstack/react-router" { } } -// Compile-only: this file must not run as an application. connectAuth(router); createAuthFetch(auth, "https://api.example.com"); diff --git a/tests/package.test.mjs b/tests/package.test.mjs index ce72978..03f2fde 100644 --- a/tests/package.test.mjs +++ b/tests/package.test.mjs @@ -1,9 +1,10 @@ -import test from "node:test"; 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 { connectAuth, useAuth } from "@monarcode/session-kit/react"; import { createAuthFetch } from "@monarcode/session-kit/http"; +import { connectAuth, useAuth } from "@monarcode/session-kit/react"; const packageRoot = new URL("../", import.meta.url); diff --git a/tests/react.test.mjs b/tests/react.test.mjs index ad34eaa..3009327 100644 --- a/tests/react.test.mjs +++ b/tests/react.test.mjs @@ -1,9 +1,11 @@ -import { test, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; +import { test, beforeEach, afterEach } from "node:test"; + import { act, createElement as h, StrictMode } from "react"; + +import { createAuth } from "@monarcode/session-kit"; import { JSDOM } from "jsdom"; import { z } from "zod"; -import { createAuth } from "@monarcode/session-kit"; let dom, roots, originalGlobals; let createRoot, RouterContextProvider, createRouter, createRootRoute; @@ -22,18 +24,26 @@ beforeEach(async () => { localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true, }; - originalGlobals = new Map(Object.keys(globals).map((name) => [ - name, Object.getOwnPropertyDescriptor(globalThis, name), - ])); + 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, + configurable: true, + writable: true, + value, }); } - // Load DOM-aware modules after installing the browser environment. ({ createRoot } = await import("react-dom/client")); - ({ RouterContextProvider, createRouter, createRootRoute, createMemoryHistory } = - await import("@tanstack/react-router")); + ({ + RouterContextProvider, + createRouter, + createRootRoute, + createMemoryHistory, + } = await import("@tanstack/react-router")); ({ useAuth, useAuthClient } = await import("@monarcode/session-kit/react")); }); @@ -74,7 +84,9 @@ async function mount(router, Component, { strict = false } = {}) { const root = createRoot(container); roots.add(root); const tree = h(RouterContextProvider, { router }, h(Component)); - await act(async () => root.render(strict ? h(StrictMode, null, tree) : tree)); + await act(async () => + root.render(strict ? h(StrictMode, null, tree) : tree), + ); return { container, async unmount() { @@ -89,13 +101,19 @@ test("useAuth renders sign-in, profile updates, and sign-out", async () => { const { auth, router } = await fixture(); function Profile() { const state = useAuth(); - return h("output", null, `${state.status}:${state.user?.email ?? "none"}`); + return h( + "output", + null, + `${state.status}:${state.user?.email ?? "none"}`, + ); } 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" })); + 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"); @@ -112,13 +130,18 @@ test("useAuth selector skips unrelated updates and renders changed selections", } const { container } = await mount(router, Identity); const before = renders; - await act(async () => auth.updateUser({ ...user, email: "updated@example.com" })); + 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" }, - })); + 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()); @@ -151,17 +174,21 @@ 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; + let subscriptions = 0, + unsubscriptions = 0, + notifications = 0; auth.state.subscribe = (listener) => { subscriptions++; const subscription = subscribe((value) => { notifications++; listener(value); }); - return { unsubscribe() { - unsubscriptions++; - subscription.unsubscribe(); - } }; + return { + unsubscribe() { + unsubscriptions++; + subscription.unsubscribe(); + }, + }; }; function Profile() { const state = useAuth(); @@ -170,7 +197,6 @@ for (const strict of [false, true]) { const mounted = await mount(router, Profile, { strict }); assert.equal(subscriptions - unsubscriptions, 1); if (strict) { - // Verify StrictMode actually exercised effect cleanup and remount. assert.ok(subscriptions >= 2); assert.ok(unsubscriptions >= 1); } diff --git a/tests/release.test.mjs b/tests/release.test.mjs new file mode 100644 index 0000000..ea067d5 --- /dev/null +++ b/tests/release.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { checkRegistry, prepareRelease } from "../scripts/prepare-release.mjs"; + +const manifest = { + name: "@monarcode/session-kit", + version: "0.1.0-alpha.1", + repository: { url: "git+https://github.com/monarcode/session-kit.git" }, + publishConfig: { + access: "public", + registry: "https://registry.npmjs.org/", + }, +}; +const changelog = + "# Changelog\n\n## 0.1.0-alpha.1\n\nNew fix.\n\n### Tests\n\nVerified.\n\n## 0.1.0-alpha.0\n\nOld notes.\n"; + +test("release notes contain only the selected version, including subsections", () => { + assert.deepEqual(prepareRelease(manifest, changelog, manifest.version), { + version: manifest.version, + tag: "v0.1.0-alpha.1", + notes: "New fix.\n\n### Tests\n\nVerified.", + }); + assert.equal( + prepareRelease( + manifest, + changelog.replaceAll("\n", "\r\n"), + manifest.version, + ).tag, + "v0.1.0-alpha.1", + ); +}); + +test("release rejects invalid alpha versions and a package version mismatch", () => { + for (const version of [ + "0.1.0", + "0.1.0-beta.1", + "v0.1.0-alpha.1", + "0.1.0-alpha.01", + "01.1.0-alpha.1", + "0.1.0-alpha.1\n", + "$(echo bad)", + undefined, + ]) { + assert.throws( + () => prepareRelease(manifest, changelog, version), + /alpha version/, + ); + } + assert.throws( + () => prepareRelease(manifest, changelog, "0.1.0-alpha.2"), + /match package/, + ); +}); + +test("release requires the expected package identity and publishing settings", () => { + for (const change of [ + { name: "other" }, + { repository: {} }, + { publishConfig: { access: "restricted" } }, + ]) { + assert.throws( + () => + prepareRelease( + { ...manifest, ...change }, + changelog, + manifest.version, + ), + /configuration/, + ); + } +}); + +test("release requires unique, nonempty notes for the version", () => { + for (const notes of [ + "# Changelog", + `${changelog}\n## 0.1.0-alpha.1\nDuplicate`, + "## 0.1.0-alpha.1\n\n## 0.1.0-alpha.0\nOld", + ]) { + assert.throws( + () => prepareRelease(manifest, notes, manifest.version), + /heading|empty/, + ); + } +}); + +test("registry preflight allows only an unpublished version and propagates failures", async () => { + await checkRegistry(manifest.version, async () => ({ + status: 404, + ok: false, + })); + await assert.rejects( + checkRegistry(manifest.version, async () => ({ + status: 200, + ok: true, + })), + /already published/, + ); + await assert.rejects( + checkRegistry(manifest.version, async () => ({ + status: 503, + ok: false, + })), + /HTTP 503/, + ); + await assert.rejects( + checkRegistry(manifest.version, async () => { + throw new Error("Network unavailable"); + }), + /Network unavailable/, + ); +}); diff --git a/tests/tsconfig.bundler.json b/tests/tsconfig.bundler.json index 206ebec..f4ec556 100644 --- a/tests/tsconfig.bundler.json +++ b/tests/tsconfig.bundler.json @@ -1,7 +1,7 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler" - } + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + } } diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 4efbe17..f116136 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -1,12 +1,12 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "noEmit": true, - "skipLibCheck": true, - "types": [] - }, - "include": ["consumer.ts"] + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["consumer.ts"] }