diff --git a/docs/superpowers/plans/2026-04-22-web-ui-auth.md b/docs/superpowers/plans/2026-04-22-web-ui-auth.md new file mode 100644 index 00000000..1375969b --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-web-ui-auth.md @@ -0,0 +1,1245 @@ +# Web UI Auth Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the browser-side auth flow — login page, session store, persistent Shell, and typed fetch wrapper — on top of the existing SvelteKit scaffold, so every subsequent frontend plan can assume a known-authenticated user and use `api.get(path)` out of the box. + +**Architecture:** Three layers under SvelteKit. `lib/api/client.ts` is a typed wrapper around `fetch` that parses JSON, throws typed `ApiError`, and silently logs the user out on 401. `lib/auth/store.svelte.ts` is a Svelte 5 rune-based store holding the current user, bootstrapped once from `/api/me` in the root `+layout.ts`. `lib/query/client.ts` is a TanStack Query `QueryClient` installed via `QueryClientProvider` at the root layout. Route guarding and shell rendering happen in `+layout.svelte`. + +**Tech Stack:** SvelteKit 2 + Svelte 5 (runes), TypeScript, TanStack Query 5 (`@tanstack/svelte-query`), Vitest + jsdom + Testing Library (`@testing-library/svelte`, `@testing-library/jest-dom`), Tailwind. + +**Reference:** design spec at `docs/superpowers/specs/2026-04-22-web-ui-auth-design.md`. + +--- + +## File Structure + +**New files under `web/`:** + +| File | Responsibility | +|---|---| +| `src/lib/api/client.ts` | `apiFetch`, `api.{get,post,del}`, types `User`, `LoginResponse`, `ApiError` | +| `src/lib/api/client.test.ts` | Unit tests for the transport layer | +| `src/lib/auth/store.svelte.ts` | `$user` rune, `bootstrap`, `login`, `logout` | +| `src/lib/auth/store.test.ts` | Unit tests for auth store | +| `src/lib/query/client.ts` | Singleton `QueryClient` export | +| `src/lib/components/Shell.svelte` | Header + sidebar + main slot | +| `src/lib/components/Shell.test.ts` | Shell component tests | +| `src/routes/+layout.ts` | `load()` → `await bootstrap()` | +| `src/routes/login/+page.svelte` | Login form UI + behavior | +| `src/routes/login/login.test.ts` | Login page tests (note: NOT `+page.test.ts` — the `+` prefix would trigger SvelteKit's route walker) | +| `src/routes/search/+page.svelte` | "Coming soon" placeholder | +| `src/routes/playlists/+page.svelte` | "Coming soon" placeholder | +| `vitest.setup.ts` | jest-dom matcher registration | + +**Modified files:** + +| File | Change | +|---|---| +| `web/package.json` | Add `@tanstack/svelte-query` (runtime) + `@testing-library/svelte`, `@testing-library/jest-dom` (dev) | +| `web/vitest.config.ts` | `setupFiles: ['./vitest.setup.ts']` | +| `web/src/routes/+layout.svelte` | Mount `QueryClientProvider`, run route guard, render `` or bare `` | +| `web/src/routes/+page.svelte` | Replace scaffold text with a minimal "Library" placeholder | + +--- + +## Task 1: Add dependencies + jest-dom setup + +**Files:** +- Modify: `web/package.json` +- Modify: `web/vitest.config.ts` +- Create: `web/vitest.setup.ts` + +- [ ] **Step 1: Install packages** + +Run: +```bash +cd web && npm install --save @tanstack/svelte-query@^5 && npm install --save-dev @testing-library/svelte@^5 @testing-library/jest-dom@^6 +``` + +Expected: three packages added to `package.json`; `package-lock.json` updated. No errors. + +- [ ] **Step 2: Create `web/vitest.setup.ts`** + +```ts +import '@testing-library/jest-dom/vitest'; +``` + +- [ ] **Step 3: Update `web/vitest.config.ts`** + +Replace the whole file with: + +```ts +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + environment: 'jsdom', + include: ['src/**/*.test.ts'], + setupFiles: ['./vitest.setup.ts'] + } +}); +``` + +- [ ] **Step 4: Sanity-check the existing test still passes** + +Run: `cd web && npm test` +Expected: PASS — `src/lib/example.test.ts (1 test)` passes. The new setup file doesn't break anything. + +- [ ] **Step 5: Commit** + +```bash +git add web/package.json web/package-lock.json web/vitest.config.ts web/vitest.setup.ts +git commit -m "build(web): add TanStack Query + Testing Library deps + +Wires @tanstack/svelte-query for the data layer and +@testing-library/{svelte,jest-dom} for component-level tests. Registers +the jest-dom matchers via a new vitest setup file." +``` + +--- + +## Task 2: Types and the `ApiError` shape + +**Files:** +- Create: `web/src/lib/api/client.ts` (types only so far) + +- [ ] **Step 1: Write `web/src/lib/api/client.ts` with types only** + +```ts +export type ApiError = { + code: string; + message: string; + status: number; +}; + +export type User = { + id: string; + username: string; + is_admin: boolean; +}; + +export type LoginResponse = { + token: string; + user: User; +}; +``` + +- [ ] **Step 2: Verify tsc is happy** + +Run: `cd web && npm run check` +Expected: `svelte-check found 0 errors and 0 warnings`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/api/client.ts +git commit -m "feat(web): add api client types (User, LoginResponse, ApiError)" +``` + +--- + +## Task 3: `apiFetch` happy path + error envelope + +**Files:** +- Modify: `web/src/lib/api/client.ts` +- Create: `web/src/lib/api/client.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/lib/api/client.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { apiFetch } from './client'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stubFetch(status: number, body: unknown, init: Partial = {}) { + const res = new Response( + body === null ? null : JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' }, ...init } + ); + const spy = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', spy); + return spy; +} + +describe('apiFetch', () => { + test('resolves with parsed JSON on 200', async () => { + stubFetch(200, { hello: 'world' }); + const result = await apiFetch('/api/ping'); + expect(result).toEqual({ hello: 'world' }); + }); + + test('sends Content-Type application/json by default', async () => { + const spy = stubFetch(200, {}); + await apiFetch('/api/ping'); + const init = spy.mock.calls[0][1] as RequestInit; + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + }); + + test('resolves to null on 204', async () => { + stubFetch(204, null); + const result = await apiFetch('/api/ping', { method: 'DELETE' }); + expect(result).toBeNull(); + }); + + test('throws ApiError from error envelope on 4xx', async () => { + stubFetch(404, { error: { code: 'not_found', message: 'track not found' } }); + await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({ + code: 'not_found', + message: 'track not found', + status: 404 + }); + }); + + test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('500', { status: 500, statusText: 'Internal Server Error' }) + )); + await expect(apiFetch('/api/ping')).rejects.toMatchObject({ + code: 'unknown', + message: 'Internal Server Error', + status: 500 + }); + }); +}); +``` + +- [ ] **Step 2: Run the test, confirm it fails** + +Run: `cd web && npm test -- src/lib/api/client.test.ts` +Expected: FAIL — `apiFetch` not exported from `./client`. + +- [ ] **Step 3: Implement `apiFetch`** + +Append to `web/src/lib/api/client.ts`: + +```ts +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + ...init + }); + const body = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) { + const envelope = body && (body as { error?: { code?: string; message?: string } }).error; + const err: ApiError = { + code: envelope?.code ?? 'unknown', + message: envelope?.message ?? res.statusText, + status: res.status + }; + throw err; + } + return body; +} +``` + +- [ ] **Step 4: Run the test again, confirm it passes** + +Run: `cd web && npm test -- src/lib/api/client.test.ts` +Expected: PASS — 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts +git commit -m "feat(web): add apiFetch with typed error envelope + +Wraps fetch, parses JSON, and throws ApiError{code,message,status} +on non-2xx. Degrades to {code:'unknown'} when the error body is not +the expected envelope shape." +``` + +--- + +## Task 4: `api.get` / `api.post` / `api.del` facade + +**Files:** +- Modify: `web/src/lib/api/client.ts` +- Modify: `web/src/lib/api/client.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append inside `web/src/lib/api/client.test.ts`: + +```ts +import { api } from './client'; + +describe('api.get/post/del', () => { + test('api.get returns typed JSON on 200', async () => { + stubFetch(200, { id: 'abc', name: 'A' }); + type T = { id: string; name: string }; + const result = await api.get('/api/artists/abc'); + expect(result).toEqual({ id: 'abc', name: 'A' }); + }); + + test('api.post serializes body and sets method', async () => { + const spy = stubFetch(200, { ok: true }); + await api.post('/api/auth/login', { username: 'u', password: 'p' }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' })); + }); + + test('api.del sends DELETE and resolves to null on 204', async () => { + const spy = stubFetch(204, null); + const result = await api.del('/api/sessions/xyz'); + expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); + expect(result).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/api/client.test.ts` +Expected: FAIL — `api` not exported. + +- [ ] **Step 3: Implement the facade** + +Append to `web/src/lib/api/client.ts`: + +```ts +export const api = { + get: (path: string): Promise => apiFetch(path) as Promise, + post: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, + del: (path: string): Promise => + apiFetch(path, { method: 'DELETE' }) as Promise +}; +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/api/client.test.ts` +Expected: PASS — 8 tests total (5 from Task 3 + 3 new). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts +git commit -m "feat(web): add api.{get,post,del} typed facade over apiFetch" +``` + +--- + +## Task 5: `QueryClient` singleton + +**Files:** +- Create: `web/src/lib/query/client.ts` + +- [ ] **Step 1: Write the module** + +Create `web/src/lib/query/client.ts`: + +```ts +import { QueryClient } from '@tanstack/svelte-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false + } + } +}); +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `cd web && npm run check` +Expected: `svelte-check found 0 errors and 0 warnings`. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/lib/query/client.ts +git commit -m "feat(web): add TanStack Query client singleton + +Tuned defaults: retry disabled (we throw typed errors), 30s staleTime +to make tab-switch re-navigation feel instant, no refetchOnWindowFocus +(users on a music app don't expect spurious network when they tab back)." +``` + +--- + +## Task 6: `auth` store — `bootstrap`, `login`, `logout` + +**Files:** +- Create: `web/src/lib/auth/store.svelte.ts` +- Create: `web/src/lib/auth/store.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/auth/store.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$lib/api/client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + del: vi.fn() + } +})); + +vi.mock('$lib/query/client', () => ({ + queryClient: { clear: vi.fn() } +})); + +import { api } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; +import { bootstrap, login, logout, user } from './store.svelte'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +afterEach(() => { + // Force-clear the store between tests by stubbing api.get to reject, + // then calling bootstrap. +}); + +describe('auth store', () => { + test('bootstrap populates user on 200', async () => { + (api.get as ReturnType).mockResolvedValue({ + id: '1', username: 'alice', is_admin: false + }); + await bootstrap(); + expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false }); + }); + + test('bootstrap leaves user null on failure', async () => { + (api.get as ReturnType).mockRejectedValue( + { code: 'unauthorized', message: 'no session', status: 401 } + ); + await bootstrap(); + expect(user.value).toBeNull(); + }); + + test('login sets user from LoginResponse.user', async () => { + (api.post as ReturnType).mockResolvedValue({ + token: 't', user: { id: '2', username: 'bob', is_admin: true } + }); + await login('bob', 'pw'); + expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true }); + expect(api.post).toHaveBeenCalledWith('/api/auth/login', { + username: 'bob', password: 'pw' + }); + }); + + test('logout clears user, calls /api/auth/logout, and clears query cache', async () => { + (api.post as ReturnType).mockResolvedValue(null); + await logout(); + expect(user.value).toBeNull(); + expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {}); + expect(queryClient.clear).toHaveBeenCalledTimes(1); + }); + + test('logout with silent:true skips the POST but still clears state', async () => { + await logout({ silent: true }); + expect(user.value).toBeNull(); + expect(api.post).not.toHaveBeenCalled(); + expect(queryClient.clear).toHaveBeenCalledTimes(1); + }); + + test('logout swallows POST errors (best-effort)', async () => { + (api.post as ReturnType).mockRejectedValue( + { code: 'server_error', message: 'boom', status: 500 } + ); + await expect(logout()).resolves.toBeUndefined(); + expect(user.value).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/auth/store.test.ts` +Expected: FAIL — module `./store.svelte` does not exist. + +- [ ] **Step 3: Implement the store** + +Create `web/src/lib/auth/store.svelte.ts`: + +```ts +import { api, type User, type LoginResponse } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; + +let _user = $state(null); + +export const user = { + get value(): User | null { + return _user; + } +}; + +export async function bootstrap(): Promise { + try { + _user = await api.get('/api/me'); + } catch { + _user = null; + } +} + +export async function login(username: string, password: string): Promise { + const res = await api.post('/api/auth/login', { username, password }); + _user = res.user; +} + +export async function logout(opts: { silent?: boolean } = {}): Promise { + if (!opts.silent) { + try { + await api.post('/api/auth/logout', {}); + } catch { + // best effort — server-side session may already be gone + } + } + _user = null; + queryClient.clear(); +} +``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/auth/store.test.ts` +Expected: PASS — 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/auth/store.svelte.ts web/src/lib/auth/store.test.ts +git commit -m "feat(web): add rune-based auth store with bootstrap/login/logout + +Central synchronous source of truth for 'who is signed in'. bootstrap() +runs once in +layout.ts load(); login/logout mutate _user and the +TanStack query cache. The silent option on logout is used by the 401 +interceptor (next commit) to avoid POSTing /logout against an already- +invalid session." +``` + +--- + +## Task 7: Wire 401 → `logout({silent:true})` into `apiFetch` + +**Files:** +- Modify: `web/src/lib/api/client.ts` +- Modify: `web/src/lib/api/client.test.ts` + +**Why a separate task:** the 401 interceptor requires `auth/store` to exist, and `auth/store` imports from `api/client`. To break the cycle, `apiFetch` uses a dynamic `import()` — introducing this logic only after the store is in place. + +- [ ] **Step 1: Add the failing test** + +Append inside `web/src/lib/api/client.test.ts`: + +```ts +describe('apiFetch 401 interceptor', () => { + test('401 response triggers auth.logout({silent:true}) and still throws', async () => { + const logoutSpy = vi.fn(); + vi.doMock('$lib/auth/store.svelte', () => ({ + logout: logoutSpy, + login: vi.fn(), + bootstrap: vi.fn(), + user: { value: null } + })); + // Re-import to pick up the mock. + const { apiFetch: apiFetchFresh } = await import('./client'); + stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } }); + await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({ + code: 'unauthorized', + status: 401 + }); + expect(logoutSpy).toHaveBeenCalledWith({ silent: true }); + vi.doUnmock('$lib/auth/store.svelte'); + }); +}); +``` + +- [ ] **Step 2: Run, confirm the new test fails** + +Run: `cd web && npm test -- src/lib/api/client.test.ts` +Expected: FAIL — `logoutSpy` not called. + +- [ ] **Step 3: Add the interceptor in `apiFetch`** + +Replace the `if (!res.ok)` block in `web/src/lib/api/client.ts` with: + +```ts + if (!res.ok) { + if (res.status === 401) { + // Lazy import: auth/store imports from this file, so a top-level + // import would be circular. By the time any 401 actually happens + // at runtime, both modules have finished loading. + const { logout } = await import('$lib/auth/store.svelte'); + await logout({ silent: true }); + } + const envelope = body && (body as { error?: { code?: string; message?: string } }).error; + const err: ApiError = { + code: envelope?.code ?? 'unknown', + message: envelope?.message ?? res.statusText, + status: res.status + }; + throw err; + } +``` + +- [ ] **Step 4: Run all client tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/api/client.test.ts` +Expected: PASS — 9 tests total. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/api/client.ts web/src/lib/api/client.test.ts +git commit -m "feat(web): auto-logout on 401 inside apiFetch + +Dynamic import breaks the apiFetch <-> auth/store cycle. silent:true +prevents re-POSTing /logout against an already-dead session." +``` + +--- + +## Task 8: Shell component + +**Files:** +- Create: `web/src/lib/components/Shell.svelte` +- Create: `web/src/lib/components/Shell.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `web/src/lib/components/Shell.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +vi.mock('$app/state', () => ({ + page: { url: new URL('http://localhost/') } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: '1', username: 'alice', is_admin: false } }, + logout: vi.fn().mockResolvedValue(undefined) +})); + +import Shell from './Shell.svelte'; +import { logout } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Shell', () => { + test('renders the username in the header', () => { + render(Shell); + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + + test('renders three nav items: Library, Search, Playlists', () => { + render(Shell); + expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); + expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); + }); + + test('user-menu "Log out" calls logout() and navigates to /login', async () => { + render(Shell); + await fireEvent.click(screen.getByRole('button', { name: /alice/i })); + await fireEvent.click(screen.getByRole('button', { name: /log out/i })); + expect(logout).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); + }); +}); +``` + +- [ ] **Step 2: Run the tests, confirm they fail** + +Run: `cd web && npm test -- src/lib/components/Shell.test.ts` +Expected: FAIL — component does not exist. + +- [ ] **Step 3: Implement the component** + +Create `web/src/lib/components/Shell.svelte`: + +```svelte + + + (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + +
+
+
Minstrel
+
+ + {#if menuOpen} +
e.stopPropagation()} + role="menu" + > + +
+ {/if} +
+
+ + + +
+ {@render children()} +
+
+``` + +- [ ] **Step 4: Run the tests, confirm they pass** + +Run: `cd web && npm test -- src/lib/components/Shell.test.ts` +Expected: PASS — 3 tests. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/components/Shell.svelte web/src/lib/components/Shell.test.ts +git commit -m "feat(web): add Shell component with header, sidebar, and user menu + +Persistent chrome used by every authenticated route. Sidebar hides +below md:; mobile nav is a separate concern for a later plan." +``` + +--- + +## Task 9: Placeholder pages for `/`, `/search`, `/playlists` + +**Files:** +- Modify: `web/src/routes/+page.svelte` +- Create: `web/src/routes/search/+page.svelte` +- Create: `web/src/routes/playlists/+page.svelte` + +- [ ] **Step 1: Replace `web/src/routes/+page.svelte`** + +Overwrite the file with: + +```svelte +

Library

+

Library views land in a subsequent plan.

+``` + +- [ ] **Step 2: Create `web/src/routes/search/+page.svelte`** + +```svelte +

Search

+

Search lands in a subsequent plan.

+``` + +- [ ] **Step 3: Create `web/src/routes/playlists/+page.svelte`** + +```svelte +

Playlists

+

Playlists land in a subsequent plan.

+``` + +- [ ] **Step 4: Verify nothing breaks** + +Run: `cd web && npm run check && npm run build` +Expected: check passes with 0 errors; build emits `web/build/` including `index.html`. + +Then restore the committed placeholder (the build regenerates it; we don't want the real build output in git): + +Run: `git checkout -- web/build/index.html` +Expected: `git status` shows no changes under `web/build/`. + +- [ ] **Step 5: Commit** + +```bash +git add web/src/routes/+page.svelte web/src/routes/search/+page.svelte web/src/routes/playlists/+page.svelte +git commit -m "feat(web): add Library/Search/Playlists placeholder pages + +Library replaces the scaffold splash. Search and Playlists are routable +so the Shell's sidebar nav works end-to-end before the real features land." +``` + +--- + +## Task 10: Login page + +**Files:** +- Create: `web/src/routes/login/+page.svelte` +- Create: `web/src/routes/login/login.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/routes/login/login.test.ts`: + +```ts +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy +// and can run before top-level `let` is initialized, since imports are +// hoisted above variable declarations. +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') })); + +vi.mock('$app/state', () => ({ + page: { + get url() { return state.pageUrl; } + } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + login: vi.fn(), + user: { value: null } +})); + +import LoginPage from './+page.svelte'; +import { login } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/login'); +}); + +async function submit(username = 'alice', password = 'pw') { + const u = screen.getByLabelText(/username/i) as HTMLInputElement; + const p = screen.getByLabelText(/password/i) as HTMLInputElement; + await fireEvent.input(u, { target: { value: username } }); + await fireEvent.input(p, { target: { value: password } }); + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })); +} + +describe('login page', () => { + test('renders username, password, and sign-in button', () => { + render(LoginPage); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + test('invalid credentials show inline error and clear password', async () => { + (login as ReturnType).mockRejectedValue({ + code: 'invalid_credentials', message: 'bad creds', status: 401 + }); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i); + }); + expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe(''); + expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); + }); + + test('server error shows generic message and preserves both fields', async () => { + (login as ReturnType).mockRejectedValue({ + code: 'server_error', message: 'boom', status: 500 + }); + render(LoginPage); + await submit('alice', 'pw'); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i); + }); + expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw'); + expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); + }); + + test('success navigates to returnTo when safe', async () => { + state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc'); + (login as ReturnType).mockResolvedValue(undefined); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true }); + }); + }); + + test('success falls back to "/" when returnTo is missing', async () => { + (login as ReturnType).mockResolvedValue(undefined); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); + }); + }); + + test.each([ + ['//evil.com', 'protocol-relative'], + ['http://evil.com', 'absolute URL'], + ['/login', 'self-redirect'], + ['../admin', 'parent traversal'] + ])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => { + state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`); + (login as ReturnType).mockResolvedValue(undefined); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); + }); + }); + + test('submit button disables and marks aria-busy while pending', async () => { + let release!: () => void; + (login as ReturnType).mockReturnValue( + new Promise((resolve) => { release = () => resolve(); }) + ); + render(LoginPage); + await submit(); + const btn = screen.getByRole('button', { name: /sign in/i }); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('aria-busy', 'true'); + release(); + await waitFor(() => expect(btn).not.toBeDisabled()); + }); +}); +``` + +- [ ] **Step 2: Run tests, confirm they fail** + +Run: `cd web && npm test -- src/routes/login/+page.test.ts` +Expected: FAIL — component does not exist. + +- [ ] **Step 3: Implement `web/src/routes/login/+page.svelte`** + +```svelte + + +
+
+

Minstrel

+
+ + + + {#if error} + + {/if} +
+
+
+``` + +- [ ] **Step 4: Run tests, confirm they pass** + +Run: `cd web && npm test -- src/routes/login/+page.test.ts` +Expected: PASS — 10 tests (6 `test(...)` blocks + 4 from `test.each` with 4 rows). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/routes/login/+page.svelte web/src/routes/login/login.test.ts +git commit -m "feat(web): add login page with returnTo support and typed error UX + +Inline errors for invalid creds vs. server-side failures. Validates +returnTo to block open-redirect vectors (//, http://, /login, +parent-traversal)." +``` + +--- + +## Task 11: Root layout wiring — bootstrap, QueryClientProvider, guard, Shell + +**Files:** +- Create: `web/src/routes/+layout.ts` +- Modify: `web/src/routes/+layout.svelte` + +- [ ] **Step 1: Create `web/src/routes/+layout.ts`** + +```ts +import type { LayoutLoad } from './$types'; +import { bootstrap } from '$lib/auth/store.svelte'; + +export const ssr = false; // adapter-static fallback; we're SPA-only +export const prerender = false; + +export const load: LayoutLoad = async () => { + await bootstrap(); + return {}; +}; +``` + +- [ ] **Step 2: Overwrite `web/src/routes/+layout.svelte`** + +```svelte + + + + {#if user.value !== null && page.url.pathname !== '/login'} + {@render children()} + {:else} + {@render children()} + {/if} + +``` + +- [ ] **Step 3: Type-check** + +Run: `cd web && npm run check` +Expected: `svelte-check found 0 errors and 0 warnings`. + +- [ ] **Step 4: Build** + +Run: `cd web && npm run build` +Expected: build succeeds. Then: + +Run: `git checkout -- web/build/index.html` +Expected: committed placeholder restored. + +- [ ] **Step 5: Full test suite** + +Run: `cd web && npm test` +Expected: all test files pass. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/routes/+layout.ts web/src/routes/+layout.svelte +git commit -m "feat(web): wire root layout — bootstrap, guard, Shell, QueryProvider + ++layout.ts runs auth.bootstrap() in load() so the shell sees the user +synchronously. +layout.svelte installs the TanStack QueryClientProvider, +runs the route guard as a \$effect, and swaps Shell vs. bare slot +based on auth state." +``` + +--- + +## Task 12: Final verification + branch finish + +**Files:** none (verification only). + +- [ ] **Step 1: Full Go suite (make sure the frontend work didn't accidentally touch Go files)** + +Run: `go test -short -race ./...` +Expected: PASS. + +- [ ] **Step 2: Go linter** + +Run: `golangci-lint run ./...` +Expected: no issues. + +- [ ] **Step 3: Web check + tests + build** + +Run: `cd web && npm run check && npm test && npm run build` +Expected: check 0 errors; all vitest files pass; build emits to `web/build/`. + +Run: `git checkout -- web/build/index.html` +Expected: committed placeholder restored. + +- [ ] **Step 4: Docker build smoke** + +Run: `docker build -t minstrel:auth-smoke .` +Expected: image builds. + +Run: `docker run --rm --entrypoint /bin/sh minstrel:auth-smoke -c 'test -x /usr/local/bin/minstrel && echo ok'` +Expected: `ok`. + +- [ ] **Step 5: Local end-to-end manual test** + +Bring up the stack: + +```bash +docker compose up --build -d +``` + +Seed a user into Postgres if one does not already exist (reuse whatever bootstrap the auth-foundation plan established; e.g., `psql` insert with a bcrypt hash). Record the username/password used. + +Then in a browser, verify these five flows: + +1. **Deep-link gated:** visit `http://localhost:4533/artists/abc`. Expected: URL changes to `/login?returnTo=%2Fartists%2Fabc`; login form is visible. +2. **Login + deep-link return:** sign in with seeded creds. Expected: URL changes to `/artists/abc`; page is blank (SPA fallback — library plan fills this in) but the Shell header shows your username and sidebar is visible. +3. **Refresh does not flash:** press F5. Expected: Shell renders without the login form appearing first. +4. **Logout:** click username menu → "Log out". Expected: URL changes to `/login`; visiting `/` redirects back to `/login`. +5. **Cross-tab session death:** log in again, open a second tab to `/`. In dev tools, clear the `minstrel_session` cookie. Click "Search" in the nav of the second tab. Expected: after the first API call in the tab (which will 401), the tab redirects to `/login`. + +Tear down: + +```bash +docker compose down +``` + +- [ ] **Step 6: Finish the branch** + +Follow `superpowers:finishing-a-development-branch`: present the four-option menu. The expected path is Option 2 — push `dev`, open a PR to `main`, wait for Forgejo CI, merge once green. + +--- + +## Self-Review Notes + +**Spec coverage check:** +- `apiFetch` contract → Tasks 3, 7 +- `api` facade → Task 4 +- `auth` store → Task 6 +- QueryClient → Task 5 +- Shell component → Task 8 +- Login page → Task 10 +- Placeholder pages → Task 9 +- Root layout bootstrap + guard → Task 11 +- Testing coverage → each feature task has its own test step +- Dependencies → Task 1 +- Final verification & branch finish → Task 12 + +All spec sections map to tasks. No gaps. + +**Type consistency:** +- `User` shape `{id, username, is_admin}` — used identically in Tasks 2, 6, 8. +- `ApiError` shape `{code, message, status}` — used identically in Tasks 2, 3, 7, 10. +- `LoginResponse` shape `{token, user}` — used in Tasks 2, 6. +- `user.value` getter — Tasks 6 (definition), 8, 11 (consumption). +- `logout(opts?: {silent?: boolean})` — Tasks 6 (definition), 7 (silent call), 8 (plain call). +- `page` from `$app/state` (not `$app/stores`) — consistent across Tasks 8, 10, 11. diff --git a/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md b/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md new file mode 100644 index 00000000..4b4a54dc --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md @@ -0,0 +1,256 @@ +# Web UI Auth — Design Spec + +**Date:** 2026-04-22 +**Status:** Design approved +**Follows:** [Web UI Scaffold](2026-04-20-web-ui-scaffold-design.md) — this spec is the first SPA feature on top of the scaffold. + +## Goal + +Let a browser visitor sign in with a username and password, reach a protected app shell, and sign out again. After this lands, every subsequent sub-plan (library views, search, player) builds on top of a known-authenticated `user` store, a persistent Shell component, and a typed HTTP client. + +## Non-goals + +Explicit YAGNI — kept out of this plan so the scope stays tight: + +- No self-service sign-up or password reset. Users are admin-provisioned; a forgotten password is a DBA problem for now. +- No "remember me" / persistent-session checkbox. The server cookie already has a 30-day `MaxAge`; every login is long-lived. +- No OAuth / OIDC / third-party sign-in. Deferred (see memory — OIDC is pushed to post-v1). +- No multi-tab logout propagation via `BroadcastChannel`. If you log out in one tab, the other tab will discover it on the next API call (401 → silent logout). Good enough. +- No rate-limiting UX on the login form. The backend already 401s on bad creds; brute-force mitigation is a server-side concern tracked elsewhere. + +## Architecture + +Three layers stacked under the SvelteKit app: + +1. **Transport (`lib/api/`).** `apiFetch(path, init)` hits the relative `/api/*` origin, sets `Content-Type: application/json`, parses the JSON response, and throws a typed `ApiError{code, message, status}` on any non-2xx. A thin facade `api.get`, `api.post`, `api.del` wraps it. 401 responses trigger `auth.logout({silent: true})` before the error propagates. + +2. **Auth state (`lib/auth/`).** A Svelte 5 rune-based store — `let _user = $state(null)` plus helpers `bootstrap()`, `login(u, p)`, `logout()`. The store is the single synchronous source of truth for "is the current user authenticated, and if so who." + +3. **Query layer (`lib/query/`).** TanStack Query's Svelte 5 adapter. A `QueryClient` is instantiated once in the root layout and installed via ``. Components consume data with `createQuery({queryKey, queryFn: () => api.get(path)})`. `auth.logout()` calls `queryClient.clear()` so stale data doesn't leak to the next user. + +**Bootstrap timing is load-time, not render-time.** The root `+layout.ts` `load()` awaits `auth.bootstrap()` before SvelteKit renders, so `+layout.svelte` sees the store already populated. There is no "flash of login screen" on refresh. + +## File structure + +New files under `web/`: + +``` +src/ +├── lib/ +│ ├── api/ +│ │ ├── client.ts # apiFetch + api.{get,post,del} + ApiError + User/LoginResponse types +│ │ └── client.test.ts +│ ├── auth/ +│ │ ├── store.svelte.ts # $user rune, bootstrap/login/logout +│ │ └── store.test.ts +│ ├── query/ +│ │ └── client.ts # export const queryClient = new QueryClient({...}) +│ └── components/ +│ ├── Shell.svelte # header + sidebar +
slot +│ └── Shell.test.ts +├── routes/ +│ ├── +layout.ts # load() → await auth.bootstrap() +│ ├── +layout.svelte # QueryClientProvider + guard + Shell or bare +│ ├── +page.svelte # placeholder "Library" — replaced by the library plan +│ ├── login/ +│ │ ├── +page.svelte # LoginForm +│ │ └── +page.test.ts +│ ├── search/+page.svelte # "Coming soon" placeholder +│ └── playlists/+page.svelte # "Coming soon" placeholder +└── app.d.ts # (untouched) +``` + +Files that change: +- `web/package.json` — add `@tanstack/svelte-query`, `@testing-library/svelte`, `@testing-library/jest-dom`. +- `web/vitest.config.ts` — include `./vitest.setup.ts` to install jest-dom matchers. +- `web/src/routes/+page.svelte` — replace the scaffold placeholder with a bare "Library" placeholder that survives into the library plan. + +## Routes + +| Path | Guard | Component | +|---|---|---| +| `/login` | public; if already authenticated, navigate to `/` | `routes/login/+page.svelte` | +| `/` | guarded | Shell + `routes/+page.svelte` (Library placeholder) | +| `/search` | guarded | Shell + placeholder | +| `/playlists` | guarded | Shell + placeholder | +| (anything else) | guarded | Shell + whatever the URL resolves to, or 404 | + +The guard lives in `+layout.svelte` (not per-page `+page.ts`). On every render, it checks `user.value`. If `null` and path is not `/login`, it calls `goto('/login?returnTo=' + encodeURIComponent(path), {replaceState: true})`. If non-null and path is `/login`, it calls `goto('/', {replaceState: true})`. Otherwise it renders `` (authed) or `` (bare `/login`). + +## `apiFetch` contract + +```ts +// lib/api/client.ts +export type ApiError = { code: string; message: string; status: number }; +export type User = { id: string; username: string; is_admin: boolean }; +export type LoginResponse = { token: string; user: User }; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + ...init, + }); + const body = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) { + if (res.status === 401) { + // Avoid a circular import: lazy-require the auth store. + const { logout } = await import('$lib/auth/store.svelte'); + logout({ silent: true }); + } + const err = (body && body.error) || { code: 'unknown', message: res.statusText }; + throw { ...err, status: res.status } as ApiError; + } + return body; +} + +export const api = { + get: (p: string) => apiFetch(p) as Promise, + post: (p: string, b: unknown) => + apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise, + del: (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise, +}; +``` + +Notes: +- `credentials: 'same-origin'` is correct for both the Vite proxy (dev) and the embedded production serve — both are same-origin. Cross-origin would need `'include'` plus a CORS policy, which the backend does not currently implement. +- `body.error` matches the backend's error envelope: `{"error":{"code":"...","message":"..."}}` (see `internal/api/errors.go`). Non-JSON error bodies degrade to `{code:'unknown', message: statusText}` — pragmatic. +- Lazy-importing `$lib/auth/store.svelte` inside the 401 branch avoids a circular import (`auth/store` imports `api`). + +## `auth` store contract + +```ts +// lib/auth/store.svelte.ts +import { api, type User, type LoginResponse } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; + +let _user = $state(null); +export const user = { get value() { return _user; } }; + +export async function bootstrap(): Promise { + try { _user = await api.get('/api/me'); } + catch { _user = null; } +} + +export async function login(username: string, password: string): Promise { + const res = await api.post('/api/auth/login', { username, password }); + _user = res.user; +} + +export async function logout(opts: { silent?: boolean } = {}): Promise { + if (!opts.silent) { + try { await api.post('/api/auth/logout', {}); } catch { /* best effort */ } + } + _user = null; + queryClient.clear(); +} +``` + +The `silent: true` path is used only by `apiFetch`'s 401 interceptor. Without it, a 401 response would trigger a logout POST that itself 401s — pointless round-trip. Everywhere else (the Shell's "Log out" button), `logout()` is called without flags and does the full round-trip. + +## Login page + +**Layout.** Centered card on the dark palette. Minstrel wordmark above the card. The card contains the form: label+input for username, label+input for password, a full-width "Sign in" button, and a slot for error text below the button. + +**Behavior.** + +1. Username and password are bound to local `$state` with `$state('')`. +2. On submit (button click or Enter in either field): + - Set `pending = true`. Disable the button and set `aria-busy="true"`. + - `await login(username, password)`. + - On success: read `returnTo` from `$page.url.searchParams`; validate it matches `/^\/(?!\/)/` (starts with exactly one `/`, not `//`, which would be a protocol-relative URL) AND does not start with `/login`; `goto(validatedReturnTo || '/', {replaceState: true})`. + - On `ApiError{code: 'invalid_credentials', status: 401}`: set `error = 'Invalid username or password.'`, clear password. + - On any other error: set `error = 'Sign-in unavailable. Try again in a moment.'`, leave fields intact. + - Finally: `pending = false`. +3. Enter in either input triggers form `submit`. +4. The error region has `role="alert"` so screen readers announce it on change. + +## Shell component + +**Structure.** + +``` +
+
+ Minstrel [username ▾] (dropdown has "Log out") +
+ +
{@render children()}
+
+``` + +- Sidebar hides below `md:`. (Mobile gets a hamburger button in a later plan — not this one.) +- Active nav item uses `aria-current="page"` so the active-styling CSS selector is semantic. +- The user-menu dropdown is a minimal disclosure: click to toggle, outside-click closes, Escape closes. Inside it is a single `` that calls `auth.logout()` then `goto('/login', {replaceState: true})`. + +## Error handling summary + +| Source | Shape | User sees | +|---|---|---| +| Login, bad creds | `ApiError{code:'invalid_credentials', status:401}` | Inline text under form: "Invalid username or password." | +| Login, server 500 / network | `ApiError{status:500}` or fetch rejection | Inline text: "Sign-in unavailable. Try again in a moment." | +| 401 mid-session on any API call | `ApiError{code:'unauthorized', status:401}` | Store clears; guard navigates to `/login?returnTo=`; no visible error | +| Non-2xx elsewhere | `ApiError` thrown into TanStack Query | TanStack Query `error` state; each consuming component renders its own UI (out of scope for this plan — the library plan handles it) | + +## Testing + +**Unit (Vitest, jsdom).** + +- `api/client.test.ts` — `fetch` stubbed via `vi.stubGlobal`. + - `api.get` resolves with parsed JSON on 200. + - `api.post` serializes body and sets `Content-Type`. + - Non-2xx throws `ApiError` with `code`, `message`, `status`. + - 401 calls `auth.logout({silent: true})` — spy on the dynamically-imported `logout` via module mocking. + - 204 resolves to `null` without calling `.json()`. + - JSON parse failure on an error body degrades to `{code:'unknown', message: statusText}`. + +- `auth/store.test.ts` — `api` module mocked. + - `bootstrap()` sets `user` on success; leaves `null` on rejection. + - `login()` stores the returned `user`. + - `logout()` calls `api.post('/api/auth/logout', {})`, clears `user`, calls `queryClient.clear()`. + - `logout({silent: true})` does NOT call `api.post`; still clears store and cache. + +- `components/Shell.test.ts` — render with a non-null `user`. + - Header shows `user.username`. + - Sidebar has exactly three `` elements to `/`, `/search`, `/playlists`. + - When `$page.url.pathname === '/'`, the Library link has `aria-current="page"`. + - Clicking the user menu reveals "Log out"; outside-click hides it. + +**Component test — `routes/login/+page.test.ts`** (using `@testing-library/svelte`). +- Renders two inputs + submit button. +- Invalid creds: stub `api.post` to reject with `{code:'invalid_credentials', status:401}`; submit; assert inline "Invalid username or password." is shown; password input is empty; username input is preserved. +- Server error: stub to reject with `{status:500}`; assert generic error message; both inputs preserved. +- Success: stub to resolve; set `?returnTo=/artists/abc` in `$page.url`; assert `goto` called with `/artists/abc` and `{replaceState: true}`. +- Invalid `returnTo` variants: `http://evil.com`, `//evil.com`, `/login` → each should fall back to `goto('/')`, never the original value. +- Loading: during a pending promise, `aria-busy="true"` on the button and button is disabled. + +**Manual integration (Plan Task 8).** +- `npm run dev` with backend running on `:4533`. Seed a user via the existing auth plan's flow. +- Visit `/artists/abc` → redirected to `/login?returnTo=%2Fartists%2Fabc`. +- Sign in → land on `/artists/abc` (which 404s from SPA fallback — that's expected; library plan fixes it). +- Hard-refresh `/` while authenticated → Shell renders immediately, no login flash. +- Click user menu → "Log out" → redirected to `/login`. Visit `/` → redirected back to `/login`. +- In a second tab, also authenticated: open dev tools, delete the `minstrel_session` cookie manually, navigate in the SPA → auto-redirected to `/login`. + +## Dependencies added + +| Package | Kind | Why | +|---|---|---| +| `@tanstack/svelte-query` | runtime | Data-layer caching/invalidation per design section 1. | +| `@testing-library/svelte` | dev | Component test rendering. | +| `@testing-library/jest-dom` | dev | `toBeInTheDocument`, `toHaveAttribute`, etc. | + +Versions pinned to latest Svelte-5-compatible majors at plan-writing time. + +## Success criteria + +- A user can sign in with valid creds, see the Shell, navigate to `/`, `/search`, `/playlists`, and log out. +- Deep link `/artists/` while signed out lands on `/login?returnTo=`; after login, lands on ``. +- Refreshing any guarded page while authenticated renders immediately with no login flash. +- A 401 on any API call silently clears auth and redirects to `/login`. +- All unit and component tests pass in CI (`npm test` in the `web/` directory). +- No backend changes required; the plan is pure frontend. diff --git a/web/package-lock.json b/web/package-lock.json index 8a431ccd..9e5f666c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,10 +7,15 @@ "": { "name": "minstrel-web", "version": "0.0.0", + "dependencies": { + "@tanstack/svelte-query": "^5.90.2" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.49", @@ -23,6 +28,13 @@ "vitest": "^2.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -50,6 +62,41 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -560,7 +607,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -571,7 +617,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -582,7 +627,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -592,14 +636,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1051,7 +1093,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1149,6 +1190,136 @@ "vite": "^5.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/svelte-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-5.90.2.tgz", + "integrity": "sha512-owjnp0w8sOXlMhLZhucHrsYvCjgjHrVyII/wlqMGefxKFyroZS3xCwTee+IUx7UHbL+QmKr/HQTeTqhgxmxPQw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1160,14 +1331,12 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { @@ -1287,7 +1456,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1306,6 +1474,29 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1351,7 +1542,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1415,7 +1605,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1596,7 +1785,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1635,6 +1823,13 @@ "node": ">= 0.6" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1738,11 +1933,20 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", - "dev": true, "license": "MIT" }, "node_modules/didyoumean": { @@ -1759,6 +1963,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1903,14 +2114,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2232,6 +2441,16 @@ "node": ">=0.10.0" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2305,7 +2524,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -2321,6 +2539,13 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -2396,7 +2621,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/loupe": { @@ -2413,11 +2637,20 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2493,6 +2726,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2835,6 +3078,21 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2866,6 +3124,13 @@ ], "license": "MIT" }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -2890,6 +3155,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3085,6 +3364,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3125,7 +3417,6 @@ "version": "5.55.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -3791,7 +4082,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/web/package.json b/web/package.json index c41da3d9..3c8559f9 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,8 @@ "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.49", @@ -24,5 +26,8 @@ "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" + }, + "dependencies": { + "@tanstack/svelte-query": "^5.90.2" } } diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts new file mode 100644 index 00000000..b15e90e4 --- /dev/null +++ b/web/src/lib/api/client.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { api, apiFetch } from './client'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function stubFetch(status: number, body: unknown, init: Partial = {}) { + const res = new Response( + body === null ? null : JSON.stringify(body), + { status, headers: { 'Content-Type': 'application/json' }, ...init } + ); + const spy = vi.fn().mockResolvedValue(res); + vi.stubGlobal('fetch', spy); + return spy; +} + +describe('apiFetch', () => { + test('resolves with parsed JSON on 200', async () => { + stubFetch(200, { hello: 'world' }); + const result = await apiFetch('/api/ping'); + expect(result).toEqual({ hello: 'world' }); + }); + + test('sends Content-Type application/json by default', async () => { + const spy = stubFetch(200, {}); + await apiFetch('/api/ping'); + const init = spy.mock.calls[0][1] as RequestInit; + expect((init.headers as Record)['Content-Type']).toBe('application/json'); + }); + + test('resolves to null on 204', async () => { + stubFetch(204, null); + const result = await apiFetch('/api/ping', { method: 'DELETE' }); + expect(result).toBeNull(); + }); + + test('throws ApiError from error envelope on 4xx', async () => { + stubFetch(404, { error: { code: 'not_found', message: 'track not found' } }); + await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({ + code: 'not_found', + message: 'track not found', + status: 404 + }); + }); + + test('degrades to {code:unknown, message:statusText} on non-JSON error body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue( + new Response('500', { status: 500, statusText: 'Internal Server Error' }) + )); + await expect(apiFetch('/api/ping')).rejects.toMatchObject({ + code: 'unknown', + message: 'Internal Server Error', + status: 500 + }); + }); +}); + +describe('api.get/post/del', () => { + test('api.get returns typed JSON on 200', async () => { + stubFetch(200, { id: 'abc', name: 'A' }); + type T = { id: string; name: string }; + const result = await api.get('/api/artists/abc'); + expect(result).toEqual({ id: 'abc', name: 'A' }); + }); + + test('api.post serializes body and sets method', async () => { + const spy = stubFetch(200, { ok: true }); + await api.post('/api/auth/login', { username: 'u', password: 'p' }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify({ username: 'u', password: 'p' })); + }); + + test('api.del sends DELETE and resolves to null on 204', async () => { + const spy = stubFetch(204, null); + const result = await api.del('/api/sessions/xyz'); + expect(spy.mock.calls[0][1]).toMatchObject({ method: 'DELETE' }); + expect(result).toBeNull(); + }); +}); + +describe('apiFetch 401 interceptor', () => { + test('401 response triggers auth.logout({silent:true}) and still throws', async () => { + const logoutSpy = vi.fn(); + vi.doMock('$lib/auth/store.svelte', () => ({ + logout: logoutSpy, + login: vi.fn(), + bootstrap: vi.fn(), + user: { value: null } + })); + // Re-import to pick up the mock. + const { apiFetch: apiFetchFresh } = await import('./client'); + stubFetch(401, { error: { code: 'unauthorized', message: 'session expired' } }); + await expect(apiFetchFresh('/api/me')).rejects.toMatchObject({ + code: 'unauthorized', + status: 401 + }); + expect(logoutSpy).toHaveBeenCalledWith({ silent: true }); + vi.doUnmock('$lib/auth/store.svelte'); + }); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 00000000..9c4f9244 --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,50 @@ +export type ApiError = { + code: string; + message: string; + status: number; +}; + +export type User = { + id: string; + username: string; + is_admin: boolean; +}; + +export type LoginResponse = { + token: string; + user: User; +}; + +export async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + ...init + }); + const body = res.status === 204 ? null : await res.json().catch(() => null); + if (!res.ok) { + if (res.status === 401) { + // Lazy import: auth/store imports from this file, so a top-level + // import would be circular. By the time any 401 actually happens + // at runtime, both modules have finished loading. + const { logout } = await import('$lib/auth/store.svelte'); + await logout({ silent: true }); + } + const envelope = body && (body as { error?: { code?: string; message?: string } }).error; + const err: ApiError = { + code: envelope?.code ?? 'unknown', + message: envelope?.message ?? res.statusText, + status: res.status + }; + throw err; + } + return body; +} + +export const api = { + get: (path: string): Promise => apiFetch(path) as Promise, + post: (path: string, body: unknown): Promise => + apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise, + del: (path: string): Promise => + apiFetch(path, { method: 'DELETE' }) as Promise +}; diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts new file mode 100644 index 00000000..5e4932ea --- /dev/null +++ b/web/src/lib/auth/store.svelte.ts @@ -0,0 +1,35 @@ +import { api, type User, type LoginResponse } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; + +let _user = $state(null); + +export const user = { + get value(): User | null { + return _user; + } +}; + +export async function bootstrap(): Promise { + try { + _user = await api.get('/api/me'); + } catch { + _user = null; + } +} + +export async function login(username: string, password: string): Promise { + const res = await api.post('/api/auth/login', { username, password }); + _user = res.user; +} + +export async function logout(opts: { silent?: boolean } = {}): Promise { + if (!opts.silent) { + try { + await api.post('/api/auth/logout', {}); + } catch { + // best effort — server-side session may already be gone + } + } + _user = null; + queryClient.clear(); +} diff --git a/web/src/lib/auth/store.test.ts b/web/src/lib/auth/store.test.ts new file mode 100644 index 00000000..cc81499c --- /dev/null +++ b/web/src/lib/auth/store.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +vi.mock('$lib/api/client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + del: vi.fn() + } +})); + +vi.mock('$lib/query/client', () => ({ + queryClient: { clear: vi.fn() } +})); + +import { api } from '$lib/api/client'; +import { queryClient } from '$lib/query/client'; +import { bootstrap, login, logout, user } from './store.svelte'; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +afterEach(() => { + // Force-clear the store between tests by stubbing api.get to reject, + // then calling bootstrap. +}); + +describe('auth store', () => { + test('bootstrap populates user on 200', async () => { + (api.get as ReturnType).mockResolvedValue({ + id: '1', username: 'alice', is_admin: false + }); + await bootstrap(); + expect(user.value).toEqual({ id: '1', username: 'alice', is_admin: false }); + }); + + test('bootstrap leaves user null on failure', async () => { + (api.get as ReturnType).mockRejectedValue( + { code: 'unauthorized', message: 'no session', status: 401 } + ); + await bootstrap(); + expect(user.value).toBeNull(); + }); + + test('login sets user from LoginResponse.user', async () => { + (api.post as ReturnType).mockResolvedValue({ + token: 't', user: { id: '2', username: 'bob', is_admin: true } + }); + await login('bob', 'pw'); + expect(user.value).toEqual({ id: '2', username: 'bob', is_admin: true }); + expect(api.post).toHaveBeenCalledWith('/api/auth/login', { + username: 'bob', password: 'pw' + }); + }); + + test('logout clears user, calls /api/auth/logout, and clears query cache', async () => { + (api.post as ReturnType).mockResolvedValue(null); + await logout(); + expect(user.value).toBeNull(); + expect(api.post).toHaveBeenCalledWith('/api/auth/logout', {}); + expect(queryClient.clear).toHaveBeenCalledTimes(1); + }); + + test('logout with silent:true skips the POST but still clears state', async () => { + await logout({ silent: true }); + expect(user.value).toBeNull(); + expect(api.post).not.toHaveBeenCalled(); + expect(queryClient.clear).toHaveBeenCalledTimes(1); + }); + + test('logout swallows POST errors (best-effort)', async () => { + (api.post as ReturnType).mockRejectedValue( + { code: 'server_error', message: 'boom', status: 500 } + ); + await expect(logout()).resolves.toBeUndefined(); + expect(user.value).toBeNull(); + }); +}); diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte new file mode 100644 index 00000000..b37d41fd --- /dev/null +++ b/web/src/lib/components/Shell.svelte @@ -0,0 +1,80 @@ + + + (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + + diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts new file mode 100644 index 00000000..1ad5a48d --- /dev/null +++ b/web/src/lib/components/Shell.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +vi.mock('$app/state', () => ({ + page: { url: new URL('http://localhost/') } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: '1', username: 'alice', is_admin: false } }, + logout: vi.fn().mockResolvedValue(undefined) +})); + +import Shell from './Shell.svelte'; +import { logout } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Shell', () => { + test('renders the username in the header', () => { + render(Shell); + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + + test('renders three nav items: Library, Search, Playlists', () => { + render(Shell); + expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); + expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); + }); + + test('user-menu "Log out" calls logout() and navigates to /login', async () => { + render(Shell); + await fireEvent.click(screen.getByRole('button', { name: /alice/i })); + await fireEvent.click(screen.getByRole('button', { name: /log out/i })); + expect(logout).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); + }); +}); diff --git a/web/src/lib/query/client.ts b/web/src/lib/query/client.ts new file mode 100644 index 00000000..55cb02f1 --- /dev/null +++ b/web/src/lib/query/client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/svelte-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false + } + } +}); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 7a4e65b2..9ea00a2c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,5 +1,31 @@ - + + {#if user.value !== null && page.url.pathname !== '/login'} + {@render children()} + {:else} + {@render children()} + {/if} + diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 00000000..ea7719a7 --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1,10 @@ +import type { LayoutLoad } from './$types'; +import { bootstrap } from '$lib/auth/store.svelte'; + +export const ssr = false; // adapter-static fallback; we're SPA-only +export const prerender = false; + +export const load: LayoutLoad = async () => { + await bootstrap(); + return {}; +}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 2a2f874d..b4d4e63b 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,8 +1,2 @@ -
-
-

Minstrel

-

- Scaffold — UI features land in subsequent plans. -

-
-
+

Library

+

Library views land in a subsequent plan.

diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 00000000..a76d7337 --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,81 @@ + + +
+
+

Minstrel

+
+ + + + {#if error} + + {/if} +
+
+
diff --git a/web/src/routes/login/login.test.ts b/web/src/routes/login/login.test.ts new file mode 100644 index 00000000..38ec3987 --- /dev/null +++ b/web/src/routes/login/login.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; + +// vi.hoisted avoids the temporal-dead-zone hazard: mock factories are lazy +// and can run before top-level `let` is initialized, since imports are +// hoisted above variable declarations. +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/login') })); + +vi.mock('$app/state', () => ({ + page: { + get url() { return state.pageUrl; } + } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + login: vi.fn(), + user: { value: null } +})); + +import LoginPage from './+page.svelte'; +import { login } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/login'); +}); + +async function submit(username = 'alice', password = 'pw') { + const u = screen.getByLabelText(/username/i) as HTMLInputElement; + const p = screen.getByLabelText(/password/i) as HTMLInputElement; + await fireEvent.input(u, { target: { value: username } }); + await fireEvent.input(p, { target: { value: password } }); + await fireEvent.click(screen.getByRole('button', { name: /sign in/i })); +} + +describe('login page', () => { + test('renders username, password, and sign-in button', () => { + render(LoginPage); + expect(screen.getByLabelText(/username/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + test('invalid credentials show inline error and clear password', async () => { + (login as ReturnType).mockRejectedValue({ + code: 'invalid_credentials', message: 'bad creds', status: 401 + }); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/invalid username or password/i); + }); + expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe(''); + expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); + }); + + test('server error shows generic message and preserves both fields', async () => { + (login as ReturnType).mockRejectedValue({ + code: 'server_error', message: 'boom', status: 500 + }); + render(LoginPage); + await submit('alice', 'pw'); + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent(/try again in a moment/i); + }); + expect((screen.getByLabelText(/password/i) as HTMLInputElement).value).toBe('pw'); + expect((screen.getByLabelText(/username/i) as HTMLInputElement).value).toBe('alice'); + }); + + test('success navigates to returnTo when safe', async () => { + state.pageUrl = new URL('http://localhost/login?returnTo=/artists/abc'); + (login as ReturnType).mockResolvedValue(undefined); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(goto).toHaveBeenCalledWith('/artists/abc', { replaceState: true }); + }); + }); + + test('success falls back to "/" when returnTo is missing', async () => { + (login as ReturnType).mockResolvedValue(undefined); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); + }); + }); + + test.each([ + ['//evil.com', 'protocol-relative'], + ['http://evil.com', 'absolute URL'], + ['/login', 'self-redirect'], + ['../admin', 'parent traversal'] + ])('rejects unsafe returnTo %s (%s) and falls back to "/"', async (value) => { + state.pageUrl = new URL(`http://localhost/login?returnTo=${encodeURIComponent(value)}`); + (login as ReturnType).mockResolvedValue(undefined); + render(LoginPage); + await submit(); + await waitFor(() => { + expect(goto).toHaveBeenCalledWith('/', { replaceState: true }); + }); + }); + + test('submit button disables and marks aria-busy while pending', async () => { + let release!: () => void; + (login as ReturnType).mockReturnValue( + new Promise((resolve) => { release = () => resolve(); }) + ); + render(LoginPage); + await submit(); + const btn = screen.getByRole('button', { name: /sign in/i }); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('aria-busy', 'true'); + release(); + await waitFor(() => expect(btn).not.toBeDisabled()); + }); +}); diff --git a/web/src/routes/playlists/+page.svelte b/web/src/routes/playlists/+page.svelte new file mode 100644 index 00000000..c31cbf02 --- /dev/null +++ b/web/src/routes/playlists/+page.svelte @@ -0,0 +1,2 @@ +

Playlists

+

Playlists land in a subsequent plan.

diff --git a/web/src/routes/search/+page.svelte b/web/src/routes/search/+page.svelte new file mode 100644 index 00000000..576cfb9d --- /dev/null +++ b/web/src/routes/search/+page.svelte @@ -0,0 +1,2 @@ +

Search

+

Search lands in a subsequent plan.

diff --git a/web/tsconfig.json b/web/tsconfig.json index 43447105..4660e525 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -9,6 +9,25 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" - } + "moduleResolution": "bundler", + "types": ["node", "vitest"] + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.js", + "vite.config.ts", + "vitest.config.ts", + "vitest.setup.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "test/**/*.js", + "test/**/*.ts", + "test/**/*.svelte", + "tests/**/*.js", + "tests/**/*.ts", + "tests/**/*.svelte" + ] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 5b942998..a6bc5c75 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,10 +1,12 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteTesting } from '@testing-library/svelte/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [sveltekit(), svelteTesting()], test: { environment: 'jsdom', - include: ['src/**/*.test.ts'] + include: ['src/**/*.test.ts'], + setupFiles: ['./vitest.setup.ts'] } }); diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts new file mode 100644 index 00000000..bb02c60c --- /dev/null +++ b/web/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest';