Files
minstrel/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md
T
bvandeusen d53e7e2985 docs(spec): add web UI auth design
Covers the login flow, session store, fetch wrapper, persistent Shell,
and route guarding for the first frontend feature on top of the scaffold.
Commits to TanStack Query as the data layer going forward.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 15:40:07 -04:00

14 KiB

Web UI Auth — Design Spec

Date: 2026-04-22 Status: Design approved Follows: Web UI Scaffold — 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

// 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

// 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.tsfetch 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.tsapi 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.