From d53e7e2985c385a9f4781ec743816a1747718bc6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 15:40:07 -0400 Subject: [PATCH 01/17] 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 --- .../specs/2026-04-22-web-ui-auth-design.md | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-web-ui-auth-design.md diff --git a/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md b/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md new file mode 100644 index 00000000..4b4a54dc --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-web-ui-auth-design.md @@ -0,0 +1,256 @@ +# 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. From 0b5c4a13bf7e7b41b43b6e4d8c75a1565b129359 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 15:47:23 -0400 Subject: [PATCH 02/17] docs(plan): add web UI auth implementation plan 12 TDD tasks: deps, api client, query client, auth store, 401 interceptor, Shell, placeholder pages, login page, root layout wiring, and final verification + branch finish. Each task writes the failing test first, then minimum implementation, then commits. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-04-22-web-ui-auth.md | 1245 +++++++++++++++++ 1 file changed, 1245 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-22-web-ui-auth.md diff --git a/docs/superpowers/plans/2026-04-22-web-ui-auth.md b/docs/superpowers/plans/2026-04-22-web-ui-auth.md new file mode 100644 index 00000000..01d45ba3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-web-ui-auth.md @@ -0,0 +1,1245 @@ +# 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/+page.test.ts` | Login page tests | +| `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, type ApiError } 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)} /> + + +``` + +- [ ] **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/+page.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web/src/routes/login/+page.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/+page.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. From d0015d3638a85a69cec791d7a9e51ce2c9b2ac9b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 15:51:35 -0400 Subject: [PATCH 03/17] 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. --- web/package-lock.json | 330 +++++++++++++++++++++++++++++++++++++++--- web/package.json | 5 + web/vitest.config.ts | 3 +- web/vitest.setup.ts | 1 + 4 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 web/vitest.setup.ts diff --git a/web/package-lock.json b/web/package-lock.json index 8a431ccd..9e5f666c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -7,10 +7,15 @@ "": { "name": "minstrel-web", "version": "0.0.0", + "dependencies": { + "@tanstack/svelte-query": "^5.90.2" + }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.49", @@ -23,6 +28,13 @@ "vitest": "^2.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -50,6 +62,41 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -560,7 +607,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -571,7 +617,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -582,7 +627,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -592,14 +636,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1051,7 +1093,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1149,6 +1190,136 @@ "vite": "^5.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz", + "integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/svelte-query": { + "version": "5.90.2", + "resolved": "https://registry.npmjs.org/@tanstack/svelte-query/-/svelte-query-5.90.2.tgz", + "integrity": "sha512-owjnp0w8sOXlMhLZhucHrsYvCjgjHrVyII/wlqMGefxKFyroZS3xCwTee+IUx7UHbL+QmKr/HQTeTqhgxmxPQw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1160,14 +1331,12 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { @@ -1287,7 +1456,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1306,6 +1474,29 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1351,7 +1542,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1415,7 +1605,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1596,7 +1785,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1635,6 +1823,13 @@ "node": ">= 0.6" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1738,11 +1933,20 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/devalue": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", - "dev": true, "license": "MIT" }, "node_modules/didyoumean": { @@ -1759,6 +1963,13 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1903,14 +2114,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -2232,6 +2441,16 @@ "node": ">=0.10.0" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2305,7 +2524,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -2321,6 +2539,13 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -2396,7 +2621,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/loupe": { @@ -2413,11 +2637,20 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2493,6 +2726,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2835,6 +3078,21 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2866,6 +3124,13 @@ ], "license": "MIT" }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -2890,6 +3155,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3085,6 +3364,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3125,7 +3417,6 @@ "version": "5.55.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -3791,7 +4082,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/web/package.json b/web/package.json index c41da3d9..3c8559f9 100644 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,8 @@ "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.8.0", "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", "autoprefixer": "^10.4.20", "jsdom": "^25.0.1", "postcss": "^8.4.49", @@ -24,5 +26,8 @@ "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" + }, + "dependencies": { + "@tanstack/svelte-query": "^5.90.2" } } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 5b942998..7ec8c2f0 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ plugins: [sveltekit()], test: { environment: 'jsdom', - include: ['src/**/*.test.ts'] + include: ['src/**/*.test.ts'], + setupFiles: ['./vitest.setup.ts'] } }); diff --git a/web/vitest.setup.ts b/web/vitest.setup.ts new file mode 100644 index 00000000..bb02c60c --- /dev/null +++ b/web/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; From e31242b57f9fbb43b26126877cebd26b44b40b79 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 15:55:00 -0400 Subject: [PATCH 04/17] feat(web): add api client types (User, LoginResponse, ApiError) --- web/src/lib/api/client.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 web/src/lib/api/client.ts diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 00000000..5c8c09a8 --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,16 @@ +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; +}; From 4d1a7f8b932888b3dd5fc23943d749789ac00f3b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 15:56:15 -0400 Subject: [PATCH 05/17] 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. --- web/src/lib/api/client.test.ts | 57 ++++++++++++++++++++++++++++++++++ web/src/lib/api/client.ts | 19 ++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 web/src/lib/api/client.test.ts diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts new file mode 100644 index 00000000..891bbc2a --- /dev/null +++ b/web/src/lib/api/client.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { apiFetch, type ApiError } 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 + }); + }); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 5c8c09a8..c7707d60 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -14,3 +14,22 @@ 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) { + 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; +} From 37e5539e5ae46f1b0ddd048d42c402caea83af2d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 16:04:25 -0400 Subject: [PATCH 06/17] feat(web): add api.{get,post,del} typed facade over apiFetch --- web/src/lib/api/client.test.ts | 26 +++++++++++++++++++++++++- web/src/lib/api/client.ts | 8 ++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts index 891bbc2a..002cb595 100644 --- a/web/src/lib/api/client.test.ts +++ b/web/src/lib/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { apiFetch, type ApiError } from './client'; +import { api, apiFetch, type ApiError } from './client'; afterEach(() => { vi.unstubAllGlobals(); @@ -55,3 +55,27 @@ describe('apiFetch', () => { }); }); }); + +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(); + }); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index c7707d60..87c0fbf7 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -33,3 +33,11 @@ export async function apiFetch(path: string, init?: RequestInit): Promise(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 +}; From 4591618e7b090bc68d335afeb8636f9c614255ae Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 16:20:31 -0400 Subject: [PATCH 07/17] 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). --- web/src/lib/query/client.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 web/src/lib/query/client.ts diff --git a/web/src/lib/query/client.ts b/web/src/lib/query/client.ts new file mode 100644 index 00000000..55cb02f1 --- /dev/null +++ b/web/src/lib/query/client.ts @@ -0,0 +1,11 @@ +import { QueryClient } from '@tanstack/svelte-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 30_000, + refetchOnWindowFocus: false + } + } +}); From 8a6a496c67ad62038c92b3b70bb0755f9d9d0e6c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:26:15 -0400 Subject: [PATCH 08/17] fix(web): drop invalid type-arg on toMatchObject in client tests vitest's toMatchObject doesn't accept a type parameter; tests passed under vitest (esbuild transpile) but svelte-check rejected. Plan-spec bug introduced in 4d1a7f8; fixing here so check is clean before Task 6. Co-Authored-By: Claude Opus 4.7 --- web/src/lib/api/client.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts index 002cb595..9491f024 100644 --- a/web/src/lib/api/client.test.ts +++ b/web/src/lib/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; -import { api, apiFetch, type ApiError } from './client'; +import { api, apiFetch } from './client'; afterEach(() => { vi.unstubAllGlobals(); @@ -37,7 +37,7 @@ describe('apiFetch', () => { 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({ + await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({ code: 'not_found', message: 'track not found', status: 404 @@ -48,7 +48,7 @@ describe('apiFetch', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response('500', { status: 500, statusText: 'Internal Server Error' }) )); - await expect(apiFetch('/api/ping')).rejects.toMatchObject({ + await expect(apiFetch('/api/ping')).rejects.toMatchObject({ code: 'unknown', message: 'Internal Server Error', status: 500 From 12bf873f392f4248458758631ef663f6cddd95b9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:26:37 -0400 Subject: [PATCH 09/17] docs(plan): fix invalid toMatchObject type-arg in auth plan vitest's toMatchObject doesn't accept a type parameter. Removed the annotation in Task 3 test code (and the unused ApiError import) so re-runs of the plan don't reintroduce the type error fixed in 8a6a496. Co-Authored-By: Claude Opus 4.7 --- docs/superpowers/plans/2026-04-22-web-ui-auth.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-04-22-web-ui-auth.md b/docs/superpowers/plans/2026-04-22-web-ui-auth.md index 01d45ba3..39facda0 100644 --- a/docs/superpowers/plans/2026-04-22-web-ui-auth.md +++ b/docs/superpowers/plans/2026-04-22-web-ui-auth.md @@ -153,7 +153,7 @@ Create `web/src/lib/api/client.test.ts`: ```ts import { afterEach, describe, expect, test, vi } from 'vitest'; -import { apiFetch, type ApiError } from './client'; +import { apiFetch } from './client'; afterEach(() => { vi.unstubAllGlobals(); @@ -191,7 +191,7 @@ describe('apiFetch', () => { 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({ + await expect(apiFetch('/api/tracks/x')).rejects.toMatchObject({ code: 'not_found', message: 'track not found', status: 404 @@ -202,7 +202,7 @@ describe('apiFetch', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue( new Response('500', { status: 500, statusText: 'Internal Server Error' }) )); - await expect(apiFetch('/api/ping')).rejects.toMatchObject({ + await expect(apiFetch('/api/ping')).rejects.toMatchObject({ code: 'unknown', message: 'Internal Server Error', status: 500 From 07b5912faef4425f33089739547de76b1cc9ed03 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:28:10 -0400 Subject: [PATCH 10/17] 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. --- web/src/lib/auth/store.svelte.ts | 35 ++++++++++++++ web/src/lib/auth/store.test.ts | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 web/src/lib/auth/store.svelte.ts create mode 100644 web/src/lib/auth/store.test.ts diff --git a/web/src/lib/auth/store.svelte.ts b/web/src/lib/auth/store.svelte.ts new file mode 100644 index 00000000..5e4932ea --- /dev/null +++ b/web/src/lib/auth/store.svelte.ts @@ -0,0 +1,35 @@ +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(); +} diff --git a/web/src/lib/auth/store.test.ts b/web/src/lib/auth/store.test.ts new file mode 100644 index 00000000..cc81499c --- /dev/null +++ b/web/src/lib/auth/store.test.ts @@ -0,0 +1,78 @@ +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(); + }); +}); From 7a6aa50693e78df55bb9ab9d7694e9a26007d565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 17:29:36 -0400 Subject: [PATCH 11/17] 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. --- web/src/lib/api/client.test.ts | 21 +++++++++++++++++++++ web/src/lib/api/client.ts | 7 +++++++ 2 files changed, 28 insertions(+) diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts index 9491f024..b15e90e4 100644 --- a/web/src/lib/api/client.test.ts +++ b/web/src/lib/api/client.test.ts @@ -79,3 +79,24 @@ describe('api.get/post/del', () => { expect(result).toBeNull(); }); }); + +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'); + }); +}); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 87c0fbf7..9c4f9244 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -23,6 +23,13 @@ export async function apiFetch(path: string, init?: RequestInit): Promise null); 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', From e1504f8e6c08ae8dec96c7a8ef294b723dcdc6a7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 22:20:18 -0400 Subject: [PATCH 12/17] 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. --- web/src/lib/components/Shell.svelte | 80 ++++++++++++++++++++++++++++ web/src/lib/components/Shell.test.ts | 45 ++++++++++++++++ web/tsconfig.json | 23 +++++++- web/vitest.config.ts | 3 +- 4 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 web/src/lib/components/Shell.svelte create mode 100644 web/src/lib/components/Shell.test.ts diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte new file mode 100644 index 00000000..b37d41fd --- /dev/null +++ b/web/src/lib/components/Shell.svelte @@ -0,0 +1,80 @@ + + + (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + +
+
+
Minstrel
+
+ + {#if menuOpen} +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="menu" + tabindex="-1" + > + +
+ {/if} +
+
+ + + +
+ {@render children?.()} +
+
diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts new file mode 100644 index 00000000..1ad5a48d --- /dev/null +++ b/web/src/lib/components/Shell.test.ts @@ -0,0 +1,45 @@ +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 }); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 43447105..4660e525 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -9,6 +9,25 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" - } + "moduleResolution": "bundler", + "types": ["node", "vitest"] + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.js", + "vite.config.ts", + "vitest.config.ts", + "vitest.setup.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "test/**/*.js", + "test/**/*.ts", + "test/**/*.svelte", + "tests/**/*.js", + "tests/**/*.ts", + "tests/**/*.svelte" + ] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 7ec8c2f0..a6bc5c75 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,8 +1,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteTesting } from '@testing-library/svelte/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [sveltekit(), svelteTesting()], test: { environment: 'jsdom', include: ['src/**/*.test.ts'], From d2d5d18e74a7e6bd0a1d9934075d2d4fa98d4ddc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 22 Apr 2026 22:22:11 -0400 Subject: [PATCH 13/17] 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. --- web/src/routes/+page.svelte | 10 ++-------- web/src/routes/playlists/+page.svelte | 2 ++ web/src/routes/search/+page.svelte | 2 ++ 3 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 web/src/routes/playlists/+page.svelte create mode 100644 web/src/routes/search/+page.svelte diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 2a2f874d..b4d4e63b 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,8 +1,2 @@ -
-
-

Minstrel

-

- Scaffold — UI features land in subsequent plans. -

-
-
+

Library

+

Library views land in a subsequent plan.

diff --git a/web/src/routes/playlists/+page.svelte b/web/src/routes/playlists/+page.svelte new file mode 100644 index 00000000..c31cbf02 --- /dev/null +++ b/web/src/routes/playlists/+page.svelte @@ -0,0 +1,2 @@ +

Playlists

+

Playlists land in a subsequent plan.

diff --git a/web/src/routes/search/+page.svelte b/web/src/routes/search/+page.svelte new file mode 100644 index 00000000..576cfb9d --- /dev/null +++ b/web/src/routes/search/+page.svelte @@ -0,0 +1,2 @@ +

Search

+

Search lands in a subsequent plan.

From 78f69b873652c7c587bdf5b73a0cf33a21e99414 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 02:41:44 -0400 Subject: [PATCH 14/17] 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). --- web/package.json | 5 +- web/src/routes/login/+page.svelte | 81 +++++++++++++++++++ web/src/routes/login/+page.test.ts | 122 +++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/login/+page.svelte create mode 100644 web/src/routes/login/+page.test.ts diff --git a/web/package.json b/web/package.json index 3c8559f9..691d545f 100644 --- a/web/package.json +++ b/web/package.json @@ -7,8 +7,9 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "test": "svelte-kit sync && vitest run" + "sync": "svelte-kit sync", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 00000000..a76d7337 --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,81 @@ + + +
+
+

Minstrel

+
+ + + + {#if error} + + {/if} +
+
+
diff --git a/web/src/routes/login/+page.test.ts b/web/src/routes/login/+page.test.ts new file mode 100644 index 00000000..38ec3987 --- /dev/null +++ b/web/src/routes/login/+page.test.ts @@ -0,0 +1,122 @@ +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()); + }); +}); From 88961596b177c1cf96878d9ae468272a8200a25c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 06:10:11 -0400 Subject: [PATCH 15/17] fix(web): rename route-colocated test to avoid +page.* collision +page.test.ts triggered svelte-kit sync's route walker (which treats any +-prefixed file as a route). Renaming to login.test.ts lets the sync pass without disabling it in scripts, preserving the upstream guarantee that types in .svelte-kit/ are fresh before check/test. Plan bug introduced in Task 10 (filename chosen in the plan); fixing here and updating the plan so re-runs use the correct name. Co-Authored-By: Claude Opus 4.7 --- web/package.json | 5 ++--- web/src/routes/login/{+page.test.ts => login.test.ts} | 0 2 files changed, 2 insertions(+), 3 deletions(-) rename web/src/routes/login/{+page.test.ts => login.test.ts} (100%) diff --git a/web/package.json b/web/package.json index 691d545f..3c8559f9 100644 --- a/web/package.json +++ b/web/package.json @@ -7,9 +7,8 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", - "sync": "svelte-kit sync", - "check": "svelte-check --tsconfig ./tsconfig.json", - "test": "vitest run" + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "svelte-kit sync && vitest run" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", diff --git a/web/src/routes/login/+page.test.ts b/web/src/routes/login/login.test.ts similarity index 100% rename from web/src/routes/login/+page.test.ts rename to web/src/routes/login/login.test.ts From fe410f14ebd9381ab16e105a1206a95be3b3320f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 06:10:29 -0400 Subject: [PATCH 16/17] docs(plan): correct login test filename to avoid route walker collision Renamed references from +page.test.ts to login.test.ts throughout, matching the fix in 8896159. Keeps the plan accurate for re-runs. Co-Authored-By: Claude Opus 4.7 --- docs/superpowers/plans/2026-04-22-web-ui-auth.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-04-22-web-ui-auth.md b/docs/superpowers/plans/2026-04-22-web-ui-auth.md index 39facda0..1375969b 100644 --- a/docs/superpowers/plans/2026-04-22-web-ui-auth.md +++ b/docs/superpowers/plans/2026-04-22-web-ui-auth.md @@ -27,7 +27,7 @@ | `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/+page.test.ts` | Login page tests | +| `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 | @@ -830,11 +830,11 @@ so the Shell's sidebar nav works end-to-end before the real features land." **Files:** - Create: `web/src/routes/login/+page.svelte` -- Create: `web/src/routes/login/+page.test.ts` +- Create: `web/src/routes/login/login.test.ts` - [ ] **Step 1: Write the failing test** -Create `web/src/routes/login/+page.test.ts`: +Create `web/src/routes/login/login.test.ts`: ```ts import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -1060,7 +1060,7 @@ Expected: PASS — 10 tests (6 `test(...)` blocks + 4 from `test.each` with 4 ro - [ ] **Step 5: Commit** ```bash -git add web/src/routes/login/+page.svelte web/src/routes/login/+page.test.ts +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 From 048663a4c8c476f79b8396fe8be5f4b89eee6b85 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 06:11:43 -0400 Subject: [PATCH 17/17] =?UTF-8?q?feat(web):=20wire=20root=20layout=20?= =?UTF-8?q?=E2=80=94=20bootstrap,=20guard,=20Shell,=20QueryProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit +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. --- web/src/routes/+layout.svelte | 28 +++++++++++++++++++++++++++- web/src/routes/+layout.ts | 10 ++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/+layout.ts diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 7a4e65b2..9ea00a2c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,5 +1,31 @@ - + + {#if user.value !== null && page.url.pathname !== '/login'} + {@render children()} + {:else} + {@render children()} + {/if} + diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts new file mode 100644 index 00000000..ea7719a7 --- /dev/null +++ b/web/src/routes/+layout.ts @@ -0,0 +1,10 @@ +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 {}; +};