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>
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:
-
Transport (
lib/api/).apiFetch(path, init)hits the relative/api/*origin, setsContent-Type: application/json, parses the JSON response, and throws a typedApiError{code, message, status}on any non-2xx. A thin facadeapi.get<T>,api.post<T>,api.delwraps it. 401 responses triggerauth.logout({silent: true})before the error propagates. -
Auth state (
lib/auth/). A Svelte 5 rune-based store —let _user = $state<User|null>(null)plus helpersbootstrap(),login(u, p),logout(). The store is the single synchronous source of truth for "is the current user authenticated, and if so who." -
Query layer (
lib/query/). TanStack Query's Svelte 5 adapter. AQueryClientis instantiated once in the root layout and installed via<QueryClientProvider>. Components consume data withcreateQuery({queryKey, queryFn: () => api.get<T>(path)}).auth.logout()callsqueryClient.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.tsto 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.errormatches the backend's error envelope:{"error":{"code":"...","message":"..."}}(seeinternal/api/errors.go). Non-JSON error bodies degrade to{code:'unknown', message: statusText}— pragmatic.- Lazy-importing
$lib/auth/store.svelteinside the 401 branch avoids a circular import (auth/storeimportsapi).
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.
- Username and password are bound to local
$statewith$state(''). - On submit (button click or Enter in either field):
- Set
pending = true. Disable the button and setaria-busy="true". await login(username, password).- On success: read
returnTofrom$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}: seterror = '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.
- Set
- Enter in either input triggers form
submit. - 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 callsauth.logout()thengoto('/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—fetchstubbed viavi.stubGlobal.api.getresolves with parsed JSON on 200.api.postserializes body and setsContent-Type.- Non-2xx throws
ApiErrorwithcode,message,status. - 401 calls
auth.logout({silent: true})— spy on the dynamically-importedlogoutvia module mocking. - 204 resolves to
nullwithout calling.json(). - JSON parse failure on an error body degrades to
{code:'unknown', message: statusText}.
-
auth/store.test.ts—apimodule mocked.bootstrap()setsuseron success; leavesnullon rejection.login()stores the returneduser.logout()callsapi.post('/api/auth/logout', {}), clearsuser, callsqueryClient.clear().logout({silent: true})does NOT callapi.post; still clears store and cache.
-
components/Shell.test.ts— render with a non-nulluser.- Header shows
user.username. - Sidebar has exactly three
<a>elements to/,/search,/playlists. - When
$page.url.pathname === '/', the Library link hasaria-current="page". - Clicking the user menu reveals "Log out"; outside-click hides it.
- Header shows
Component test — routes/login/+page.test.ts (using @testing-library/svelte).
- Renders two inputs + submit button.
- Invalid creds: stub
api.postto 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/abcin$page.url; assertgotocalled with/artists/abcand{replaceState: true}. - Invalid
returnTovariants:http://evil.com,//evil.com,/login→ each should fall back togoto('/'), 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 devwith 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_sessioncookie 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 testin theweb/directory). - No backend changes required; the plan is pure frontend.