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