Files
minstrel/docs/superpowers/plans/2026-04-22-web-ui-auth.md
T
bvandeusen fe410f14eb 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 <noreply@anthropic.com>
2026-04-23 06:10:29 -04:00

38 KiB

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

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 <Shell> or bare <slot/>
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:

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
import '@testing-library/jest-dom/vitest';
  • Step 3: Update web/vitest.config.ts

Replace the whole file with:

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

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

import { afterEach, describe, expect, test, vi } from 'vitest';
import { apiFetch } from './client';

afterEach(() => {
  vi.unstubAllGlobals();
});

function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
  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<string, string>)['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('<html>500</html>', { 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:

export async function apiFetch(path: string, init?: RequestInit): Promise<unknown> {
  const res = await fetch(path, {
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) },
    ...init
  });
  const body = res.status === 204 ? null : await res.json().catch(() => null);
  if (!res.ok) {
    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
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:

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<T>('/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:

export const api = {
  get: <T>(path: string): Promise<T> => apiFetch(path) as Promise<T>,
  post: <T>(path: string, body: unknown): Promise<T> =>
    apiFetch(path, { method: 'POST', body: JSON.stringify(body) }) as Promise<T>,
  del: (path: string): Promise<null> =>
    apiFetch(path, { method: 'DELETE' }) as Promise<null>
};
  • 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
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:

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

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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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:

import { api, type User, type LoginResponse } from '$lib/api/client';
import { queryClient } from '$lib/query/client';

let _user = $state<User | null>(null);

export const user = {
  get value(): User | null {
    return _user;
  }
};

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

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

export async function logout(opts: { silent?: boolean } = {}): Promise<void> {
  if (!opts.silent) {
    try {
      await api.post('/api/auth/logout', {});
    } catch {
      // best effort — 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
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:

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:

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

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:

<script lang="ts">
  import { page } from '$app/state';
  import { goto } from '$app/navigation';
  import { user, logout } from '$lib/auth/store.svelte';

  let { children } = $props<{ children: import('svelte').Snippet }>();

  let menuOpen = $state(false);

  async function handleLogout() {
    menuOpen = false;
    await logout();
    goto('/login', { replaceState: true });
  }

  function isActive(href: string): boolean {
    if (href === '/') return page.url.pathname === '/';
    return page.url.pathname.startsWith(href);
  }

  const navItems = [
    { href: '/',          label: 'Library' },
    { href: '/search',    label: 'Search' },
    { href: '/playlists', label: 'Playlists' }
  ];
</script>

<svelte:window onclick={() => (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />

<div class="grid h-screen grid-cols-[auto_1fr] grid-rows-[auto_1fr] bg-background text-text-primary">
  <header class="col-span-2 flex items-center justify-between border-b border-border bg-surface px-4 py-3">
    <div class="font-semibold">Minstrel</div>
    <div class="relative">
      <button
        type="button"
        class="rounded px-3 py-1 hover:bg-surface-hover"
        onclick={(e) => { e.stopPropagation(); menuOpen = !menuOpen; }}
      >
        {user.value?.username ?? ''}
      </button>
      {#if menuOpen}
        <div
          class="absolute right-0 mt-1 w-32 rounded border border-border bg-surface shadow"
          onclick={(e) => e.stopPropagation()}
          role="menu"
        >
          <button
            type="button"
            class="block w-full px-3 py-2 text-left hover:bg-surface-hover"
            onclick={handleLogout}
          >
            Log out
          </button>
        </div>
      {/if}
    </div>
  </header>

  <nav class="hidden w-48 border-r border-border bg-surface md:block">
    <ul class="p-2">
      {#each navItems as item}
        <li>
          <a
            href={item.href}
            class="block rounded px-3 py-2 hover:bg-surface-hover"
            aria-current={isActive(item.href) ? 'page' : undefined}
          >
            {item.label}
          </a>
        </li>
      {/each}
    </ul>
  </nav>

  <main class="overflow-y-auto p-4 md:col-start-2">
    {@render children()}
  </main>
</div>
  • 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
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:

<h1 class="text-2xl font-semibold">Library</h1>
<p class="mt-2 text-text-secondary">Library views land in a subsequent plan.</p>
  • Step 2: Create web/src/routes/search/+page.svelte
<h1 class="text-2xl font-semibold">Search</h1>
<p class="mt-2 text-text-secondary">Search lands in a subsequent plan.</p>
  • Step 3: Create web/src/routes/playlists/+page.svelte
<h1 class="text-2xl font-semibold">Playlists</h1>
<p class="mt-2 text-text-secondary">Playlists land in a subsequent plan.</p>
  • 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
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/login.test.ts

  • Step 1: Write the failing test

Create web/src/routes/login/login.test.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
      new Promise<void>((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
<script lang="ts">
  import { page } from '$app/state';
  import { goto } from '$app/navigation';
  import { login } from '$lib/auth/store.svelte';
  import type { ApiError } from '$lib/api/client';

  let username = $state('');
  let password = $state('');
  let error = $state<string | null>(null);
  let pending = $state(false);

  function safeReturnTo(raw: string | null): string {
    if (!raw) return '/';
    // Starts with exactly one '/' (not '//', which is a protocol-relative URL)
    // AND is not the login page itself.
    if (!/^\/(?!\/)/.test(raw)) return '/';
    if (raw === '/login' || raw.startsWith('/login?') || raw.startsWith('/login/')) return '/';
    return raw;
  }

  async function handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    if (pending) return;
    error = null;
    pending = true;
    try {
      await login(username, password);
      const dest = safeReturnTo(page.url.searchParams.get('returnTo'));
      goto(dest, { replaceState: true });
    } catch (err) {
      const apiErr = err as ApiError;
      if (apiErr?.status === 401 && apiErr?.code === 'invalid_credentials') {
        error = 'Invalid username or password.';
        password = '';
      } else {
        error = 'Sign-in unavailable. Try again in a moment.';
      }
    } finally {
      pending = false;
    }
  }
</script>

<main class="flex min-h-screen items-center justify-center bg-background text-text-primary">
  <div class="w-full max-w-sm rounded-lg border border-border bg-surface p-6 shadow">
    <h1 class="mb-6 text-center text-2xl font-semibold">Minstrel</h1>
    <form class="space-y-4" onsubmit={handleSubmit}>
      <label class="block">
        <span class="mb-1 block text-sm text-text-secondary">Username</span>
        <input
          type="text"
          autocomplete="username"
          class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
          bind:value={username}
          required
        />
      </label>
      <label class="block">
        <span class="mb-1 block text-sm text-text-secondary">Password</span>
        <input
          type="password"
          autocomplete="current-password"
          class="w-full rounded border border-border bg-background px-3 py-2 outline-none focus:border-accent"
          bind:value={password}
          required
        />
      </label>
      <button
        type="submit"
        class="w-full rounded bg-accent px-3 py-2 font-medium text-background disabled:opacity-60"
        aria-busy={pending ? 'true' : undefined}
        disabled={pending}
      >
        Sign in
      </button>
      {#if error}
        <p role="alert" class="text-sm text-danger">{error}</p>
      {/if}
    </form>
  </div>
</main>
  • 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
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
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

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
<script lang="ts">
  import '../app.css';
  import { page } from '$app/state';
  import { goto } from '$app/navigation';
  import { QueryClientProvider } from '@tanstack/svelte-query';
  import { queryClient } from '$lib/query/client';
  import { user } from '$lib/auth/store.svelte';
  import Shell from '$lib/components/Shell.svelte';

  let { children } = $props<{ children: import('svelte').Snippet }>();

  // Reactive guard: runs every time page.url or user.value changes.
  $effect(() => {
    const path = page.url.pathname;
    const isLogin = path === '/login';
    if (user.value === null && !isLogin) {
      const returnTo = path + page.url.search;
      goto('/login?returnTo=' + encodeURIComponent(returnTo), { replaceState: true });
    } else if (user.value !== null && isLogin) {
      goto('/', { replaceState: true });
    }
  });
</script>

<QueryClientProvider client={queryClient}>
  {#if user.value !== null && page.url.pathname !== '/login'}
    <Shell>{@render children()}</Shell>
  {:else}
    {@render children()}
  {/if}
</QueryClientProvider>
  • 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
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:

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:

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.