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