# 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<T>`, `api.post<T>`, `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<User|null>(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 `<QueryClientProvider>`. Components consume data with `createQuery({queryKey, queryFn: () => api.get<T>(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 + <main> slot
│       └── Shell.test.ts
├── routes/
│   ├── +layout.ts             # load() → await auth.bootstrap()
│   ├── +layout.svelte         # QueryClientProvider + guard + Shell or bare <slot/>
│   ├── +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 `<Shell><slot/></Shell>` (authed) or `<slot/>` (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<unknown> {
  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:  <T>(p: string) => apiFetch(p) as Promise<T>,
  post: <T>(p: string, b: unknown) =>
          apiFetch(p, { method: 'POST', body: JSON.stringify(b) }) as Promise<T>,
  del:  (p: string) => apiFetch(p, { method: 'DELETE' }) as Promise<void>,
};
```

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<User | null>(null);
export const user = { get value() { return _user; } };

export async function bootstrap(): Promise<void> {
  try { _user = await api.get<User>('/api/me'); }
  catch { _user = null; }
}

export async function login(username: string, password: string): Promise<void> {
  const res = await api.post<LoginResponse>('/api/auth/login', { username, password });
  _user = res.user;
}

export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
  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.**

```
<div class="h-screen grid grid-cols-[auto_1fr] grid-rows-[auto_1fr]">
  <header class="col-span-2 ...">
    Minstrel    [username ▾]  (dropdown has "Log out")
  </header>
  <nav class="hidden md:block ...">
    <a href="/">Library</a>
    <a href="/search">Search</a>
    <a href="/playlists">Playlists</a>
  </nav>
  <main class="overflow-y-auto">{@render children()}</main>
</div>
```

- 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 `<button>Log out</button>` 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=<current>`; 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 `<a>` 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/<any>` while signed out lands on `/login?returnTo=<path>`; after login, lands on `<path>`.
- 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.
