From d17fb10b93562a2459e560aadce8f2b5116f4d67 Mon Sep 17 00:00:00 2001 From: monarcode Date: Thu, 1 Oct 2026 03:27:53 +0100 Subject: [PATCH 1/5] chore: Add manual alpha release workflow Implement release validation and npm trusted publishing with GitHub prereleases, tags, and verified build artifacts. Document the release process and add tests for version, package, changelog, and registry validation. --- .github/workflows/release.yml | 99 +++++++++++++++++++++++++++++++++++ README.md | 9 ++-- RELEASING.md | 67 ++++++++++++++++++++++++ scripts/prepare-release.mjs | 47 +++++++++++++++++ tests/release.test.mjs | 43 +++++++++++++++ 5 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 scripts/prepare-release.mjs create mode 100644 tests/release.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..338ed0d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +name: Release alpha + +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/README.md b/README.md index 0d607bf..8552846 100644 --- a/README.md +++ b/README.md @@ -484,8 +484,9 @@ 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 +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 @@ -498,7 +499,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..113c8d4 --- /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/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/tests/release.test.mjs b/tests/release.test.mjs new file mode 100644 index 0000000..b1ce5c2 --- /dev/null +++ b/tests/release.test.mjs @@ -0,0 +1,43 @@ +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/); +}); From b9ab69f331e73212c5c5e132ba44681d8c394b65 Mon Sep 17 00:00:00 2001 From: monarcode Date: Thu, 1 Oct 2026 16:18:41 +0100 Subject: [PATCH 2/5] refactor(auth): simplify session persistence and background handling --- CHANGELOG.md | 10 ++++++ README.md | 19 ++++++----- scripts/check-readme.mjs | 5 --- src/core/client.ts | 31 ++++++----------- src/core/persistence.ts | 60 ++++++++++++-------------------- src/core/types.ts | 6 ++-- src/core/validation.ts | 16 +++++---- src/react/connect.ts | 2 -- src/react/hooks.ts | 1 - tests/auth.test.mjs | 74 ++++++++++++++++++---------------------- tests/consumer.ts | 1 - tests/react.test.mjs | 2 -- 12 files changed, 97 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dff0e34..01aa3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### 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 8552846..89a6874 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 @@ -443,13 +443,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. 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/src/core/client.ts b/src/core/client.ts index 045836f..347a9e5 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -15,12 +15,17 @@ 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 +94,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 +106,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 +118,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 +219,6 @@ export function createAuth( } return; } - // No authenticated snapshot is exposed when storage cannot be read. store.setState((previous) => ({ ...previous, status: "unavailable", @@ -238,7 +237,6 @@ 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 { @@ -280,14 +278,10 @@ 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. */ - }); + inBackground(getSession()); } } } @@ -399,7 +393,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) @@ -444,7 +437,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 +446,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 +454,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/persistence.ts b/src/core/persistence.ts index 2ca7e5f..7307841 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -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 { @@ -39,19 +32,13 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { 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( @@ -63,20 +50,20 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { 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" || @@ -90,8 +77,7 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { "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 +98,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..4ea867e 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -39,7 +39,7 @@ export type Session = Readonly<{ user: U; }>; -// Tokens are intentionally absent from the reactive snapshot. +/** Reactive auth snapshot. Tokens are intentionally excluded. */ export type AuthState = Readonly<{ status: | 'initializing' @@ -72,6 +72,6 @@ export type AuthOptions = { name: string; userSchema: S; refresh?: RefreshFn>>; - /** Cookie lifetime in seconds, renewed on successful writes. Default: 30 days. */ - cookieMaxAge?: number; + /** 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..3149007 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -6,8 +6,10 @@ 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,7 +65,6 @@ 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), ); @@ -110,10 +111,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 +155,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/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/tests/auth.test.mjs b/tests/auth.test.mjs index 4badd1d..27f2e48 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -5,14 +5,14 @@ import { connectAuth, safeReturnTo } from "@monarcode/session-kit/react"; import { createAuthFetch } from "@monarcode/session-kit/http"; 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", @@ -46,9 +46,9 @@ 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), @@ -59,7 +59,6 @@ beforeEach(() => { 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 +73,17 @@ 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 +93,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); @@ -168,11 +168,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"); @@ -232,7 +230,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 +260,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); }); @@ -291,37 +289,35 @@ 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")); + const saved = JSON.parse(values.get(PROFILE)); values.set( - "test:auth:user", + 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) => { @@ -339,16 +335,13 @@ for (const invalidUser of [false, true]) { 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 +357,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); @@ -400,7 +393,7 @@ test("concurrent refresh requests share one backend operation", async () => { assert.equal((await a).accessToken, "access-2"); assert.equal((await b).accessToken, "access-2"); assert.equal( - JSON.parse(decodeURIComponent(cookies.get("test_auth"))).refreshToken, + JSON.parse(values.get(TOKENS)).refreshToken, "refresh-1", ); }); @@ -417,7 +410,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 () => { @@ -490,7 +483,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 +711,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 +783,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..c69bd13 100644 --- a/tests/consumer.ts +++ b/tests/consumer.ts @@ -20,7 +20,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/react.test.mjs b/tests/react.test.mjs index ad34eaa..32fa665 100644 --- a/tests/react.test.mjs +++ b/tests/react.test.mjs @@ -30,7 +30,6 @@ beforeEach(async () => { 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")); @@ -170,7 +169,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); } From 691517ae0450728a2f1f03dbd52eaa34c9139c9d Mon Sep 17 00:00:00 2001 From: monarcode Date: Thu, 1 Oct 2026 16:41:09 +0100 Subject: [PATCH 3/5] chore: set up Husky, Oxfmt, and Oxlint - Enforce lint and format checks before commits and in CI. - Add EditorConfig and format the repo with a tab width of 3. --- .editorconfig | 14 ++ .github/workflows/ci.yml | 67 +++--- .github/workflows/release.yml | 181 +++++++------- .husky/pre-commit | 1 + .oxfmtrc.json | 45 ++++ .oxlintrc.json | 45 ++++ README.md | 415 +++++++++++++++++++------------- RELEASING.md | 14 +- package.json | 78 +++--- pnpm-lock.yaml | 436 ++++++++++++++++++++++++++++++++++ src/core/client.ts | 24 +- src/core/errors.ts | 52 ++-- src/core/persistence.ts | 19 +- src/core/types.ts | 90 ++++--- src/core/validation.ts | 19 +- src/http/index.ts | 22 +- src/index.ts | 30 +-- src/react/index.ts | 9 +- tests/auth.test.mjs | 105 +++++--- tests/consumer.ts | 5 +- tests/package.test.mjs | 5 +- tests/react.test.mjs | 68 ++++-- tests/release.test.mjs | 129 +++++++--- tests/tsconfig.bundler.json | 10 +- tests/tsconfig.json | 20 +- 25 files changed, 1333 insertions(+), 570 deletions(-) create mode 100644 .editorconfig create mode 100644 .husky/pre-commit create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json 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 index 338ed0d..7e494b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,99 +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 + workflow_dispatch: + inputs: + version: + description: Alpha version already committed in package.json (without v) + required: true + type: string permissions: - contents: read + contents: read concurrency: - group: session-kit-release - cancel-in-progress: false + 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 + 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" + 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/README.md b/README.md index 89a6874..4b24364 100644 --- a/README.md +++ b/README.md @@ -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`, @@ -465,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` @@ -484,7 +558,7 @@ pnpm install pnpm run check ``` -`check` builds the package, runs 45 auth behavior tests, five React hook tests, +`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 @@ -493,6 +567,15 @@ 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. diff --git a/RELEASING.md b/RELEASING.md index 113c8d4..ad74c6c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -11,13 +11,13 @@ 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** | +| 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. diff --git a/package.json b/package.json index 7921d1b..8321e5f 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", "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/src/core/client.ts b/src/core/client.ts index 347a9e5..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,7 +12,12 @@ 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 }; @@ -241,10 +247,7 @@ export function createAuth( 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", @@ -255,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,8 +280,7 @@ export function createAuth( schedule(); if (session && !valid()) { publish(store.get().error); - if (mounts) - inBackground(getSession()); + if (mounts) inBackground(getSession()); } } } @@ -403,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) { 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 7307841..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 }; @@ -25,7 +25,9 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { throw new AuthError( "PERSISTENCE_FAILED", "Auth storage is unavailable", - { cause }, + { + cause, + }, ); } } @@ -41,10 +43,7 @@ export function createPersistence(name: string, maxAge = 30 * 24 * 60 * 60) { } } if (failures.length) - throw new AggregateError( - failures, - "Could not clear auth storage", - ); + throw new AggregateError(failures, "Could not clear auth storage"); }); } @@ -73,9 +72,7 @@ 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"); } if (localStorage.getItem(tokensKey) !== tokensText) throw new Error("Auth storage changed while reading"); diff --git a/src/core/types.ts b/src/core/types.ts index 4ea867e..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; }>; /** 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>>; - /** Saved-session lifetime in seconds, renewed on successful writes. Default: 30 days. */ - maxAge?: 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 3149007..b21f8ab 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -1,6 +1,7 @@ -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); @@ -65,9 +66,7 @@ export async function validateUser( input: unknown, ): Promise> { try { - 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", @@ -86,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, + }); } } 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/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 27f2e48..149cdb5 100644 --- a/tests/auth.test.mjs +++ b/tests/auth.test.mjs @@ -1,8 +1,9 @@ -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"; const userSchema = z.object({ @@ -12,16 +13,24 @@ const userSchema = z.object({ }); const user = { id: "alice", email: "alice@example.com", role: "member" }; let cookies, values, blockedStorage, blockedWrites, blockedDeletes, cleanups; -const TOKENS = "test:auth:tokens", PROFILE = "test:auth:user"; +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) => { @@ -50,9 +59,12 @@ beforeEach(() => { 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() { @@ -77,7 +89,8 @@ beforeEach(() => { values.set(key, value); }, removeItem(key) { - if (blockedStorage || blockedDeletes.has(key)) throw new Error("Blocked"); + if (blockedStorage || blockedDeletes.has(key)) + throw new Error("Blocked"); values.delete(key); }, }); @@ -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); @@ -191,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(); @@ -279,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; @@ -292,10 +324,7 @@ test("a superseded sign-in cannot recover or clear the newer account", async (t) test("token/profile mismatch fails closed and removes saved data", async () => { await client().signIn(input()); const saved = JSON.parse(values.get(PROFILE)); - values.set( - PROFILE, - JSON.stringify({ ...saved, writeId: "wrong" }), - ); + values.set(PROFILE, JSON.stringify({ ...saved, writeId: "wrong" })); assert.equal(await client().getSession(), null); assert.equal(values.size, 0); }); @@ -330,7 +359,10 @@ 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"); @@ -392,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(values.get(TOKENS)).refreshToken, - "refresh-1", - ); + assert.equal(JSON.parse(values.get(TOKENS)).refreshToken, "refresh-1"); }); test("late refresh success cannot restore a signed-out session", async () => { @@ -430,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")); diff --git a/tests/consumer.ts b/tests/consumer.ts index c69bd13..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", 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 32fa665..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,17 +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, }); } ({ 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")); }); @@ -73,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() { @@ -88,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"); @@ -111,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()); @@ -150,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(); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index b1ce5c2..ea067d5 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -1,43 +1,112 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { checkRegistry, prepareRelease } from '../scripts/prepare-release.mjs'; +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/' }, + 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'; +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 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 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 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("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/); +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"] } From 8e99d477b5fea94c1397cfef01f608af4747964f Mon Sep 17 00:00:00 2001 From: monarcode Date: Thu, 1 Oct 2026 16:42:25 +0100 Subject: [PATCH 4/5] Bump package version to alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8321e5f..5ff05b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@monarcode/session-kit", - "version": "0.1.0-alpha.0", + "version": "0.1.0-alpha.1", "description": "Schema-driven browser sessions for React and TanStack Router", "keywords": [ "auth", From 7f57fddff8edad0104b85427ca425684c5d61c68 Mon Sep 17 00:00:00 2001 From: monarcode Date: Thu, 1 Oct 2026 16:57:33 +0100 Subject: [PATCH 5/5] Release version 0.1.0-alpha.1 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01aa3ea..3f439dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.1.0-alpha.1 ### Changed