# Web UI Scaffold — Server Auth Foundation 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:** Land the `/api/*` authentication foundation that the upcoming SvelteKit SPA and the future Flutter client will share. Adds a sessions table, a `POST /api/auth/login` / `POST /api/auth/logout` / `GET /api/me` surface, and a `RequireUser` middleware that accepts either a session cookie or an `Authorization: Bearer` header.

**Architecture:** New `internal/api` package holds native JSON handlers (no Subsonic envelope). Sessions are opaque 32-byte tokens, stored as sha256 hashes in a new `sessions` table. `POST /api/auth/login` sets an `httpOnly; SameSite=Strict` cookie and returns the same token in the JSON body so Flutter can use `Authorization: Bearer` later. Middleware tries cookie first, bearer second. `/rest/*` is untouched.

**Tech Stack:** Go 1.23, pgx/v5, sqlc, chi router, golang-migrate, bcrypt, stdlib `crypto/sha256` + `crypto/rand`.

**Scope guard:** This plan intentionally stops at auth + `/api/me`. Library browse / search / stream / cover-art endpoints and the SvelteKit project are separate plans that land after this one.

---

## File Structure

**New files:**

- `internal/db/migrations/0004_sessions.up.sql` — create sessions table
- `internal/db/migrations/0004_sessions.down.sql` — drop sessions table
- `internal/db/queries/sessions.sql` — sqlc queries for sessions
- `internal/db/dbq/sessions.sql.go` — **generated** by `make generate`
- `internal/auth/session.go` — session mint / hash / lookup helpers + `VerifyPassword` + `RequireUser`
- `internal/auth/session_test.go` — unit tests for the helpers
- `internal/api/api.go` — package doc, route mounting (`Mount(chi.Router, pool, logger)`)
- `internal/api/types.go` — shared request/response types
- `internal/api/auth.go` — `handleLogin`, `handleLogout`
- `internal/api/auth_test.go` — handler tests
- `internal/api/me.go` — `handleGetMe`
- `internal/api/me_test.go` — handler test
- `internal/api/errors.go` — `writeErr(w, status, code, message)` helper + tests in `auth_test.go`

**Modified files:**

- `internal/server/server.go:35-54` — mount `api.Mount(...)` inside the `/api` route group
- `internal/auth/middleware.go` — no edits; `RequireUser` lives in the new `session.go` alongside cookie helpers

**Guiding principle:** files stay focused. `session.go` owns the session lifecycle; each `handle*` file owns one HTTP surface. Helpers that would be reused from other `/api/*` handlers (error writer, JSON envelope) live in `api/` for future siblings to import.

---

## Conventions (apply to every task unless a task says otherwise)

- **Always run `gofmt -w` on any file you touch before committing.**
- **Test commands** are run from repo root (`/home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel`).
- **`sqlc generate` is run via `make generate`.** This requires Docker; if Docker isn't available, fail the task — don't skip regeneration.
- **Commit messages follow existing repo style:** `type(scope): subject` (`feat(api): ...`, `fix(auth): ...`), one-line subject ≤ 72 chars, body explains *why*, trailer is `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`.
- **Every `git commit` step below uses a HEREDOC** so multi-line messages survive shell quoting.

---

### Task 1: Migration — create `sessions` table

**Files:**
- Create: `internal/db/migrations/0004_sessions.up.sql`
- Create: `internal/db/migrations/0004_sessions.down.sql`

- [ ] **Step 1: Write the up migration**

File: `internal/db/migrations/0004_sessions.up.sql`

```sql
-- Session tokens authenticate /api/* requests. We store sha256(token) so a
-- read-only DB leak doesn't grant active sessions; the raw token lives only
-- in the client's cookie (web) or bearer header (Flutter).
--
-- last_seen_at enables an "active sessions" UI later (not wired in this plan)
-- without schema churn. user_agent is captured at issue time for the same
-- reason — free metadata now, no migration later.

CREATE TABLE sessions (
    id              uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         uuid        NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash      bytea       NOT NULL UNIQUE,
    user_agent      text        NOT NULL DEFAULT '',
    created_at      timestamptz NOT NULL DEFAULT now(),
    last_seen_at    timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX sessions_user_id_idx ON sessions (user_id);
```

- [ ] **Step 2: Write the down migration**

File: `internal/db/migrations/0004_sessions.down.sql`

```sql
DROP INDEX IF EXISTS sessions_user_id_idx;
DROP TABLE IF EXISTS sessions;
```

- [ ] **Step 3: Run the migration against a disposable DB to prove it's well-formed**

Command:
```bash
docker compose exec -T postgres psql -U minstrel -d minstrel -c "SELECT version FROM schema_migrations;"
docker compose restart minstrel
docker compose logs minstrel --since=30s | grep -i migrat
```

Expected: logs show "applied 0004_sessions" (or equivalent); `SELECT * FROM sessions LIMIT 0;` works afterwards.

If the stack isn't running, start it: `docker compose up -d` and repeat.

- [ ] **Step 4: Commit**

```bash
git add internal/db/migrations/0004_sessions.up.sql internal/db/migrations/0004_sessions.down.sql
git commit -m "$(cat <<'EOF'
feat(db): add sessions table for /api/* auth

Stores sha256(token) plus user_agent + last_seen_at so future
active-sessions UI doesn't need another migration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 2: sqlc queries for sessions

**Files:**
- Create: `internal/db/queries/sessions.sql`
- Regenerate: `internal/db/dbq/sessions.sql.go` (via `make generate`)

- [ ] **Step 1: Write the queries**

File: `internal/db/queries/sessions.sql`

```sql
-- name: InsertSession :one
INSERT INTO sessions (user_id, token_hash, user_agent)
VALUES ($1, $2, $3)
RETURNING *;

-- name: GetSessionByTokenHash :one
SELECT * FROM sessions WHERE token_hash = $1;

-- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now() WHERE id = $1;

-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1;

-- name: DeleteSessionByTokenHash :exec
DELETE FROM sessions WHERE token_hash = $1;
```

- [ ] **Step 2: Regenerate sqlc output**

Run: `make generate`

Expected: `internal/db/dbq/sessions.sql.go` is created with `InsertSession`, `GetSessionByTokenHash`, `TouchSessionLastSeen`, `DeleteSession`, `DeleteSessionByTokenHash`.

- [ ] **Step 3: Verify generated code compiles**

Run: `go build ./...`
Expected: exit 0, no output.

- [ ] **Step 4: Commit**

```bash
git add internal/db/queries/sessions.sql internal/db/dbq/sessions.sql.go
git commit -m "$(cat <<'EOF'
feat(dbq): generate session queries

Adds Insert/Get/Touch/Delete helpers over the sessions table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 3: Session helpers — mint / hash / verify password

**Files:**
- Create: `internal/auth/session.go`
- Create: `internal/auth/session_test.go`

- [ ] **Step 1: Write the failing test**

File: `internal/auth/session_test.go`

```go
package auth

import (
	"crypto/sha256"
	"encoding/base64"
	"strings"
	"testing"

	"golang.org/x/crypto/bcrypt"
)

func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) {
	token, err := MintSessionToken()
	if err != nil {
		t.Fatalf("MintSessionToken: %v", err)
	}
	if len(token) < 40 {
		t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token))
	}
	if strings.ContainsAny(token, "+/=") {
		t.Errorf("token %q contains non-url-safe chars", token)
	}
}

func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) {
	token := "test-token-xyz"
	want := sha256.Sum256([]byte(token))

	got := HashSessionToken(token)
	if len(got) != sha256.Size {
		t.Fatalf("hash length = %d, want %d", len(got), sha256.Size)
	}
	if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) {
		t.Errorf("hash = %x, want %x", got, want)
	}
}

func TestVerifyPassword(t *testing.T) {
	hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
	if err != nil {
		t.Fatalf("GenerateFromPassword: %v", err)
	}
	if !VerifyPassword(string(hash), "hunter2") {
		t.Error("correct password rejected")
	}
	if VerifyPassword(string(hash), "wrong") {
		t.Error("wrong password accepted")
	}
	if VerifyPassword("not-a-hash", "hunter2") {
		t.Error("malformed hash accepted")
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./internal/auth/ -run 'TestMintSessionToken|TestHashSessionToken|TestVerifyPassword' -v`
Expected: FAIL with `undefined: MintSessionToken` / `HashSessionToken` / `VerifyPassword`.

- [ ] **Step 3: Implement**

File: `internal/auth/session.go`

```go
package auth

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"

	"golang.org/x/crypto/bcrypt"
)

// sessionTokenBytes is the raw entropy per session token. 32 bytes of
// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie
// value is 43 chars with no padding.
const sessionTokenBytes = 32

// MintSessionToken returns a freshly-generated, url-safe opaque token.
// The token is what the client carries; the DB only ever sees its sha256.
func MintSessionToken() (string, error) {
	b := make([]byte, sessionTokenBytes)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// HashSessionToken is the single source of truth for mapping a raw token to
// the `sessions.token_hash` column. sha256 is fine here — we're not guarding
// against offline brute force (the token has 256 bits of entropy); we only
// want "leaked DB row can't be replayed without also having the raw token."
func HashSessionToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

// VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective.
func VerifyPassword(hash, plaintext string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
}
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `go test ./internal/auth/ -run 'TestMintSessionToken|TestHashSessionToken|TestVerifyPassword' -v`
Expected: PASS (3 tests).

- [ ] **Step 5: Commit**

```bash
git add internal/auth/session.go internal/auth/session_test.go
git commit -m "$(cat <<'EOF'
feat(auth): session token + password verification helpers

Shared primitives for /api/* auth: mint a url-safe opaque token,
hash it for storage, verify a bcrypt password hash.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 4: `RequireUser` middleware (cookie or bearer)

**Files:**
- Modify: `internal/auth/session.go` — add `RequireUser`, `sessionCookieName`, extract helper
- Modify: `internal/auth/session_test.go` — add middleware tests

- [ ] **Step 1: Write the failing tests**

Append to `internal/auth/session_test.go`:

```go
import "net/http"
import "net/http/httptest"
import "context"
// (merge these imports into the existing import block)

func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
	next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		t.Fatal("handler must not be called")
	})
	h := RequireUser(nil)(next)

	req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	w := httptest.NewRecorder()
	h.ServeHTTP(w, req)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", w.Code)
	}
}

func TestExtractBearerToken(t *testing.T) {
	cases := []struct {
		header string
		want   string
	}{
		{"", ""},
		{"Bearer abc", "abc"},
		{"bearer abc", "abc"},
		{"Token abc", ""},
		{"Bearer", ""},
		{"Bearer   whitespace-token  ", "whitespace-token"},
	}
	for _, c := range cases {
		got := extractBearerToken(c.header)
		if got != c.want {
			t.Errorf("extractBearerToken(%q) = %q, want %q", c.header, got, c.want)
		}
	}
}
```

- [ ] **Step 2: Run to verify they fail**

Run: `go test ./internal/auth/ -run 'TestRequireUser|TestExtractBearerToken' -v`
Expected: FAIL (`undefined: RequireUser`, `undefined: extractBearerToken`).

- [ ] **Step 3: Add `GetUserByID` sqlc query (needed by the middleware)**

Append to `internal/db/queries/users.sql`:

```sql
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
```

Run: `make generate`
Expected: `internal/db/dbq/users.sql.go` now contains `GetUserByID(ctx, id pgtype.UUID)`.

- [ ] **Step 4: Implement the middleware**

Append to `internal/auth/session.go`:

```go
import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"strings"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// (merge these imports into the existing import block at the top of session.go)

// SessionCookieName is the cookie the web SPA rides. Exported because handlers
// that issue/clear the cookie (handleLogin / handleLogout) need to match it.
const SessionCookieName = "minstrel_session"

// RequireUser resolves the caller from a session cookie OR Authorization
// bearer header and puts the dbq.User in request context via userCtxKey.
// Requests without a valid session return 401 with no body so callers don't
// leak whether the username existed (matches the /rest/* auth posture).
func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token := sessionTokenFromRequest(r)
			if token == "" {
				http.Error(w, "unauthenticated", http.StatusUnauthorized)
				return
			}
			if pool == nil {
				// Test-only path: the test at the top of this file constructs
				// the middleware with nil pool to prove the no-token case
				// short-circuits without a DB call. Any real token here is
				// programmer error.
				http.Error(w, "unauthenticated", http.StatusUnauthorized)
				return
			}
			q := dbq.New(pool)
			sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
			if err != nil {
				if errors.Is(err, pgx.ErrNoRows) {
					http.Error(w, "unauthenticated", http.StatusUnauthorized)
					return
				}
				slog.Error("api: session lookup failed", "err", err)
				http.Error(w, "auth lookup failed", http.StatusInternalServerError)
				return
			}
			user, err := q.GetUserByID(r.Context(), sess.UserID)
			if err != nil {
				if errors.Is(err, pgx.ErrNoRows) {
					// Session points at a deleted user — treat as unauth,
					// best-effort cleanup.
					_ = q.DeleteSession(r.Context(), sess.ID)
					http.Error(w, "unauthenticated", http.StatusUnauthorized)
					return
				}
				slog.Error("api: user lookup failed", "err", err)
				http.Error(w, "auth lookup failed", http.StatusInternalServerError)
				return
			}
			// Best-effort last-seen update. A failure here shouldn't fail the
			// request; the session is still valid and this is observability.
			if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
				slog.Warn("api: touch session last_seen failed", "err", err)
			}
			ctx := context.WithValue(r.Context(), userCtxKey, user)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func sessionTokenFromRequest(r *http.Request) string {
	if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
		return c.Value
	}
	return extractBearerToken(r.Header.Get("Authorization"))
}

// extractBearerToken pulls the token out of an Authorization header in
// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is
// missing, malformed, or uses a different scheme.
func extractBearerToken(header string) string {
	if header == "" {
		return ""
	}
	const prefix = "bearer "
	lower := strings.ToLower(header)
	if !strings.HasPrefix(lower, prefix) {
		return ""
	}
	return strings.TrimSpace(header[len(prefix):])
}
```

- [ ] **Step 5: Run middleware tests**

Run: `go test ./internal/auth/ -v`
Expected: all prior tests plus `TestRequireUser_RejectsWhenNoCookieOrBearer` and `TestExtractBearerToken` PASS.

- [ ] **Step 6: Commit**

```bash
git add internal/auth/session.go internal/auth/session_test.go internal/db/queries/users.sql internal/db/dbq/users.sql.go
git commit -m "$(cat <<'EOF'
feat(auth): RequireUser middleware (cookie or bearer)

Resolves /api/* callers from session cookie first, Authorization
bearer second. Touches last_seen on success for future active-
sessions UI. Adds GetUserByID query used by the middleware.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 5: `internal/api` package skeleton + error helper

**Files:**
- Create: `internal/api/api.go`
- Create: `internal/api/errors.go`
- Create: `internal/api/errors_test.go`

- [ ] **Step 1: Write the failing test**

File: `internal/api/errors_test.go`

```go
package api

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestWriteErr_EmitsJSONEnvelope(t *testing.T) {
	w := httptest.NewRecorder()
	writeErr(w, http.StatusBadRequest, "bad_request", "field x required")

	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", w.Code)
	}
	if got := w.Header().Get("Content-Type"); got != "application/json" {
		t.Errorf("content-type = %q, want application/json", got)
	}
	var body struct {
		Error struct {
			Code    string `json:"code"`
			Message string `json:"message"`
		} `json:"error"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
	}
	if body.Error.Code != "bad_request" || body.Error.Message != "field x required" {
		t.Errorf("body = %+v", body)
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `go test ./internal/api/ -v`
Expected: FAIL — package doesn't exist yet.

- [ ] **Step 3: Create the package + error helper**

File: `internal/api/api.go`

```go
// Package api implements Minstrel's native JSON surface under /api. It is
// consumed by the built-in web SPA and (eventually) the Flutter client.
// Subsonic-compatible endpoints under /rest are intentionally separate —
// see internal/subsonic — and the two packages must not depend on each other.
package api

import (
	"log/slog"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)

// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
	h := &handlers{pool: pool, logger: logger}

	r.Route("/api", func(api chi.Router) {
		api.Post("/auth/login", h.handleLogin)
		api.Group(func(authed chi.Router) {
			authed.Use(auth.RequireUser(pool))
			authed.Post("/auth/logout", h.handleLogout)
			authed.Get("/me", h.handleGetMe)
		})
	})
}

type handlers struct {
	pool   *pgxpool.Pool
	logger *slog.Logger
}

// Stub handlers so Mount() compiles. Real implementations in later tasks
// replace these in place (Task 6 login, Task 7 logout, Task 8 me).
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
	writeErr(w, http.StatusNotImplemented, "not_implemented", "login not wired")
}

func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
	writeErr(w, http.StatusNotImplemented, "not_implemented", "logout not wired")
}

func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
	writeErr(w, http.StatusNotImplemented, "not_implemented", "me not wired")
}
```

**Reviewer note:** Real `handleLogin` / `handleLogout` / `handleGetMe` implementations in Tasks 6–8 *move* into their own files (`auth.go`, `me.go`) and the stubs here get deleted as part of each task.

File: `internal/api/errors.go`

```go
package api

import (
	"encoding/json"
	"net/http"
)

// errorBody is the JSON envelope for failures on /api/*. Kept separate from
// the Subsonic envelope so native clients don't have to parse two shapes.
type errorBody struct {
	Error errorPayload `json:"error"`
}

type errorPayload struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

// writeErr is the canonical way to emit a failure. Code is a stable
// short-string clients can switch on; message is human-readable.
func writeErr(w http.ResponseWriter, status int, code, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
}
```

- [ ] **Step 4: Run tests and verify build**

Run: `go build ./... && go test ./internal/api/ -v`
Expected: build exits 0; `TestWriteErr_EmitsJSONEnvelope` PASSes.

- [ ] **Step 5: Commit**

```bash
git add internal/api/api.go internal/api/errors.go internal/api/errors_test.go
git commit -m "$(cat <<'EOF'
feat(api): package skeleton + error envelope

Introduces internal/api with Mount(), the {error:{code,message}}
response shape, and stubbed login/logout/me so later tasks can
land one handler at a time without breaking the build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 6: `POST /api/auth/login`

**Files:**
- Create: `internal/api/types.go`
- Modify: `internal/api/api.go` — replace login stub with real implementation
- Create: `internal/api/auth_test.go`

- [ ] **Step 1: Write the failing test**

File: `internal/api/auth_test.go`

```go
package api

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"github.com/jackc/pgx/v5/pgxpool"
	"golang.org/x/crypto/bcrypt"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
// Skips in -short mode or when the env var is missing, matching the pattern
// used elsewhere (scanner_test.go, etc.).
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
	t.Helper()
	if testing.Short() {
		t.Skip("skipping api integration in -short mode")
	}
	dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("MINSTREL_TEST_DATABASE_URL not set")
	}
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	if err := db.Migrate(dsn, logger); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	pool, err := pgxpool.New(context.Background(), dsn)
	if err != nil {
		t.Fatalf("pool: %v", err)
	}
	t.Cleanup(pool.Close)
	if _, err := pool.Exec(context.Background(),
		"TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil {
		t.Fatalf("truncate: %v", err)
	}
	return &handlers{pool: pool, logger: logger}, pool
}

func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
	t.Helper()
	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
	if err != nil {
		t.Fatalf("bcrypt: %v", err)
	}
	u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
		Username:     username,
		PasswordHash: string(hash),
		ApiToken:     "test-api-token-" + username,
		IsAdmin:      isAdmin,
	})
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	return u
}

func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) {
	h, pool := testHandlers(t)
	seedUser(t, pool, "alice", "hunter2", false)

	body := strings.NewReader(`{"username":"alice","password":"hunter2"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}

	var resp struct {
		Token string `json:"token"`
		User  struct {
			Username string `json:"username"`
			IsAdmin  bool   `json:"is_admin"`
		} `json:"user"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
	}
	if resp.Token == "" {
		t.Error("token empty")
	}
	if resp.User.Username != "alice" || resp.User.IsAdmin {
		t.Errorf("user = %+v, want alice/non-admin", resp.User)
	}

	var cookieFound bool
	for _, c := range w.Result().Cookies() {
		if c.Name == auth.SessionCookieName {
			cookieFound = true
			if !c.HttpOnly {
				t.Error("session cookie missing HttpOnly")
			}
			if c.SameSite != http.SameSiteStrictMode {
				t.Errorf("SameSite = %v, want Strict", c.SameSite)
			}
			if c.Value != resp.Token {
				t.Error("cookie value does not match response token")
			}
		}
	}
	if !cookieFound {
		t.Error("session cookie not set")
	}
}

func TestHandleLogin_WrongPasswordReturns401(t *testing.T) {
	h, pool := testHandlers(t)
	seedUser(t, pool, "alice", "hunter2", false)

	body := strings.NewReader(`{"username":"alice","password":"wrong"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", w.Code)
	}
}

func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
	h, _ := testHandlers(t)
	body := strings.NewReader(`{"username":"ghost","password":"whatever"}`)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", w.Code)
	}
}

func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
		bytes.NewReader([]byte("not-json")))
	req.Header.Set("Content-Type", "application/json")
	w := httptest.NewRecorder()
	h.handleLogin(w, req)

	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", w.Code)
	}
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel go test ./internal/api/ -v -run TestHandleLogin`

Expected: tests run (integration DSN reachable) and FAIL with "not_implemented" responses.

If your local stack uses a different DSN, set `MINSTREL_TEST_DATABASE_URL` accordingly. If you're developing without the stack up, skip this step — the CI pipeline runs it.

- [ ] **Step 3: Define shared types**

File: `internal/api/types.go`

```go
package api

import "github.com/jackc/pgx/v5/pgtype"

// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
// web and Flutter both send the same payload.
type LoginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

// LoginResponse carries the opaque session token AND the authenticated user
// so the SPA can hydrate its auth store in one round-trip. The token is also
// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer.
type LoginResponse struct {
	Token string   `json:"token"`
	User  UserView `json:"user"`
}

// UserView is the /api/* view of a user. Narrower than dbq.User — no hash,
// no api_token, no subsonic_password.
type UserView struct {
	ID       pgtype.UUID `json:"id"`
	Username string      `json:"username"`
	IsAdmin  bool        `json:"is_admin"`
}
```

- [ ] **Step 4: Implement `handleLogin`**

File: `internal/api/auth.go`

```go
package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"time"

	"github.com/jackc/pgx/v5"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire
// server-side yet (future work); the cookie still caps browser-side lifetime
// so an abandoned laptop doesn't stay logged in forever.
const sessionCookieMaxAge = 30 * 24 * time.Hour

func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
	var req LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
		return
	}
	if req.Username == "" || req.Password == "" {
		writeErr(w, http.StatusBadRequest, "bad_request", "username and password required")
		return
	}

	q := dbq.New(h.pool)
	user, err := q.GetUserByUsername(r.Context(), req.Username)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
			return
		}
		h.logger.Error("api: user lookup failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
		return
	}
	if !auth.VerifyPassword(user.PasswordHash, req.Password) {
		writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}

	token, err := auth.MintSessionToken()
	if err != nil {
		h.logger.Error("api: mint session token failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "mint failed")
		return
	}
	if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
		UserID:     user.ID,
		TokenHash:  auth.HashSessionToken(token),
		UserAgent:  r.UserAgent(),
	}); err != nil {
		h.logger.Error("api: insert session failed", "err", err)
		writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     auth.SessionCookieName,
		Value:    token,
		Path:     "/",
		HttpOnly: true,
		Secure:   r.TLS != nil, // dev over http stays functional; prod over https gets Secure
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(sessionCookieMaxAge.Seconds()),
	})

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(LoginResponse{
		Token: token,
		User: UserView{
			ID:       user.ID,
			Username: user.Username,
			IsAdmin:  user.IsAdmin,
		},
	})
}
```

Delete the `handleLogin` stub from `internal/api/api.go` — the real one in `auth.go` replaces it.

- [ ] **Step 5: Run tests**

Run: `MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel go test ./internal/api/ -v -run TestHandleLogin`

Expected: all four `TestHandleLogin_*` tests PASS.

- [ ] **Step 6: Commit**

```bash
git add internal/api/auth.go internal/api/api.go internal/api/types.go internal/api/auth_test.go
git commit -m "$(cat <<'EOF'
feat(api): POST /api/auth/login

Verifies bcrypt password hash, mints a session token, stores its
sha256 in the sessions table, and returns the token in both the
response body (for Flutter) and an httpOnly/SameSite=Strict
cookie (for the web SPA).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 7: `POST /api/auth/logout`

**Files:**
- Modify: `internal/api/auth.go` — add `handleLogout`
- Modify: `internal/api/api.go` — remove logout stub
- Modify: `internal/api/auth_test.go` — add logout tests

- [ ] **Step 1: Write the failing tests**

Append to `internal/api/auth_test.go`:

```go
func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "hunter2", false)

	// Manually create a session to log out of.
	token, err := auth.MintSessionToken()
	if err != nil {
		t.Fatalf("mint: %v", err)
	}
	if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
		UserID:    user.ID,
		TokenHash: auth.HashSessionToken(token),
	}); err != nil {
		t.Fatalf("insert: %v", err)
	}

	req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
	req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
	// handleLogout runs behind RequireUser in real routing; simulate that by
	// putting the user into context here.
	req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
	w := httptest.NewRecorder()
	h.handleLogout(w, req)

	if w.Code != http.StatusNoContent {
		t.Errorf("status = %d, want 204", w.Code)
	}

	// Cookie should be cleared.
	var cleared bool
	for _, c := range w.Result().Cookies() {
		if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
			cleared = true
		}
	}
	if !cleared {
		t.Error("session cookie not cleared")
	}

	// Session row should be gone.
	_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
	if err == nil {
		t.Error("session row still present after logout")
	}
}
```

**Reviewer note:** `userCtxKeyForTest()` is a test-only shim that exposes `auth.userCtxKey` to the api package, needed because context keys are unexported. Added in the next step.

- [ ] **Step 2: Expose a test-only context key accessor**

Append to `internal/auth/session.go`:

```go
// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need
// to inject a dbq.User into request context without going through the
// middleware. Do not use this outside _test.go files.
func UserCtxKeyForTest() any { return userCtxKey }
```

Append to `internal/api/auth_test.go` (top-level, after imports):

```go
func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() }
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleLogout`
Expected: FAIL (stub returns 501, not 204).

- [ ] **Step 4: Implement `handleLogout`**

Append to `internal/api/auth.go`:

```go
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
	// The session token can be on the cookie OR bearer header — RequireUser
	// accepted either. Re-resolve it here so we can delete the row.
	token := sessionTokenFromHTTP(r)
	if token != "" {
		if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
			h.logger.Warn("api: delete session failed", "err", err)
			// Continue — logout is best-effort; the client still gets the
			// cookie cleared.
		}
	}
	http.SetCookie(w, &http.Cookie{
		Name:     auth.SessionCookieName,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   -1,
	})
	w.WriteHeader(http.StatusNoContent)
}

// sessionTokenFromHTTP duplicates the internal helper in internal/auth
// because that one is unexported. Cheap to repeat here; keeping the auth
// package's internal helper package-private is worth more than DRY.
func sessionTokenFromHTTP(r *http.Request) string {
	if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
		return c.Value
	}
	h := r.Header.Get("Authorization")
	const prefix = "bearer "
	if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
		return h[len(prefix):]
	}
	return ""
}
```

Delete the logout stub from `internal/api/api.go`.

- [ ] **Step 5: Run tests**

Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleLogout`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add internal/api/auth.go internal/api/api.go internal/api/auth_test.go internal/auth/session.go
git commit -m "$(cat <<'EOF'
feat(api): POST /api/auth/logout

Deletes the session row keyed by the cookie/bearer token and
clears the cookie on the client. Best-effort DB delete — logout
still succeeds for the client if the row's already gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 8: `GET /api/me`

**Files:**
- Create: `internal/api/me.go`
- Modify: `internal/api/api.go` — remove `handleGetMe` stub
- Create: `internal/api/me_test.go`

- [ ] **Step 1: Write the failing test**

File: `internal/api/me_test.go`

```go
package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "hunter2", true)

	req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
	w := httptest.NewRecorder()
	h.handleGetMe(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	var got UserView
	if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if got.Username != "alice" || !got.IsAdmin {
		t.Errorf("user = %+v, want alice/admin", got)
	}
}

func TestHandleGetMe_MissingContextReturns500(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
	w := httptest.NewRecorder()
	h.handleGetMe(w, req)

	if w.Code != http.StatusInternalServerError {
		t.Errorf("status = %d, want 500 (context should be populated by RequireUser)", w.Code)
	}
	_ = dbq.User{} // keep the import used
}
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleGetMe`
Expected: FAIL (stub returns 501).

- [ ] **Step 3: Implement `handleGetMe`**

File: `internal/api/me.go`

```go
package api

import (
	"encoding/json"
	"net/http"

	"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)

func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
	user, ok := auth.UserFromContext(r.Context())
	if !ok {
		// Hitting /me without RequireUser in front of it is a routing bug;
		// it can't happen in real traffic.
		h.logger.Error("api: /me reached without authenticated user")
		writeErr(w, http.StatusInternalServerError, "server_error", "missing auth context")
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(UserView{
		ID:       user.ID,
		Username: user.Username,
		IsAdmin:  user.IsAdmin,
	})
}
```

Delete the `handleGetMe` stub from `internal/api/api.go`.

- [ ] **Step 4: Run tests**

Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleGetMe`
Expected: PASS (both tests).

- [ ] **Step 5: Run the full api package tests to confirm nothing regressed**

Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v`
Expected: every test PASSes.

- [ ] **Step 6: Commit**

```bash
git add internal/api/me.go internal/api/me_test.go internal/api/api.go
git commit -m "$(cat <<'EOF'
feat(api): GET /api/me

Returns the authenticated user (id/username/is_admin). Shape
matches UserView used in the login response so SPA stores stay
typed against one interface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 9: Mount `/api/*` in the root router

**Files:**
- Modify: `internal/server/server.go`

- [ ] **Step 1: Update the router**

Replace the existing `Router()` body at `internal/server/server.go:35-54`. The edit is: after the existing `r.Route("/api", ...)` admin block, call `api.Mount(r, s.Pool, s.Logger)` at the same level. But `api.Mount` *also* opens `/api`; chi allows multiple `Route("/api", ...)` calls — but to avoid confusion, we inline the admin routes into the api package structure in a single `Route("/api", ...)` is cleanest.

Concretely — final shape:

```go
func (s *Server) Router() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.RequestID)
	r.Use(middleware.Recoverer)

	r.Get("/healthz", s.handleHealthz)

	if s.Pool != nil {
		api.Mount(r, s.Pool, s.Logger)
		r.Route("/api/admin", func(admin chi.Router) {
			admin.Use(auth.RequireAdmin(s.Pool))
			if s.Scanner != nil {
				admin.Post("/scan", s.handleAdminScan)
			}
		})
		subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
	}
	return r
}
```

Add the import for `internal/api`:

```go
import (
	// existing imports...
	"git.fabledsword.com/bvandeusen/minstrel/internal/api"
)
```

**Reviewer note:** chi permits both `r.Route("/api", func(api chi.Router) {...})` opened by `api.Mount` and a sibling `r.Route("/api/admin", func...)` because the paths don't overlap at the exact level — chi's internal tree merges prefixes. If you see "handler overlaps" at startup, convert `api.Mount` to accept an optional admin wiring callback and move scan into it; flag this up to the reviewer rather than hacking around it.

- [ ] **Step 2: Build and verify the server still starts**

Run: `go build ./...`
Expected: exit 0.

Run (if stack running): `docker compose restart minstrel && docker compose logs minstrel --since=30s`
Expected: "listening on :4533" and no panics.

- [ ] **Step 3: Commit**

```bash
git add internal/server/server.go
git commit -m "$(cat <<'EOF'
feat(server): mount /api/* (login, logout, me)

Wires the native JSON surface alongside the existing /api/admin
and /rest/* routes. Subsonic compatibility remains untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

### Task 10: End-to-end smoke (manual, recorded in the plan)

**Files:** none (verification only)

- [ ] **Step 1: Bring the full stack up**

```bash
docker compose up --build -d
docker compose logs minstrel --since=60s | grep -iE 'listening|error|panic'
```

Expected: "listening on :4533", no errors.

- [ ] **Step 2: Login with the bootstrap admin**

```bash
# Replace PASSWORD with the bootstrap password from startup logs.
curl -i -X POST http://localhost:4533/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"PASSWORD"}'
```

Expected: HTTP/1.1 200 OK; body contains `{"token":"...","user":{...}}`; `Set-Cookie: minstrel_session=...; HttpOnly; SameSite=Strict`.

- [ ] **Step 3: Call `/api/me` with the cookie**

```bash
curl -i http://localhost:4533/api/me \
  -H "Cookie: minstrel_session=<token-from-login>"
```

Expected: HTTP/1.1 200 OK; body is `{"id":"...","username":"admin","is_admin":true}`.

- [ ] **Step 4: Call `/api/me` with bearer instead**

```bash
curl -i http://localhost:4533/api/me \
  -H "Authorization: Bearer <token-from-login>"
```

Expected: HTTP/1.1 200 OK; same body.

- [ ] **Step 5: Verify 401 without auth**

```bash
curl -i http://localhost:4533/api/me
```

Expected: HTTP/1.1 401 Unauthorized.

- [ ] **Step 6: Verify 401 with wrong password on login**

```bash
curl -i -X POST http://localhost:4533/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"nope"}'
```

Expected: HTTP/1.1 401 Unauthorized; body `{"error":{"code":"invalid_credentials","message":"..."}}`.

- [ ] **Step 7: Logout clears the session**

```bash
curl -i -X POST http://localhost:4533/api/auth/logout \
  -H "Cookie: minstrel_session=<token>"
```

Expected: HTTP/1.1 204 No Content; `Set-Cookie: minstrel_session=; Max-Age=0`.

Then reuse that token against `/api/me` and expect 401.

- [ ] **Step 8: Open the PR**

After all smoke steps pass:

```bash
git push origin dev
```

Then create a PR from `dev` → `main` using the Forgejo MCP (see git workflow memory). Poll CI; merge when green; `git pull --ff-only origin dev` locally.

Done.

---

## Self-Review

**Spec coverage check** (against `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`):

- Sessions table (spec §Authentication → Sessions table) — Task 1.
- sha256(token) at rest (spec §Authentication → Sessions table) — Task 3.
- Login issues both cookie and bearer token (spec §Authentication → Login flow) — Task 6.
- `httpOnly; SameSite=Strict` cookie (spec §Authentication → Login flow) — Task 6.
- Middleware resolves cookie → bearer (spec §Authentication → Middleware) — Task 4.
- `401` on unauthenticated `/api/*` (spec §Authentication → Middleware) — Task 4 + Task 10.
- `/api/me` returns `{id, username, is_admin}` (spec §API surface → first-cut endpoints) — Task 8.
- `/rest/*` untouched (spec §Non-goals and §API surface) — no task modifies `internal/subsonic`, no test imports it.
- Logout deletes session + clears cookie — spec §Authentication → Login flow mentions "expire via Set-Cookie" and sessions table Task 2 includes `DeleteSessionByTokenHash`. — Task 7.
- OIDC headroom (spec §Open questions resolved) — design preserved: login endpoint is the only auth-material touchpoint, so OIDC callback can mint the same cookie+token pair without schema change.

**Not yet landed** (other plans):
- `/api/artists`, `/api/albums/{id}`, `/api/tracks/{id}`, `/api/search`, `/api/stream/{id}`, `/api/cover/{id}` — Plan 2 (server library reads).
- SvelteKit project, Dockerfile multi-stage, `embed.FS` wiring — Plan 3 (web SPA + packaging).

**Placeholder scan:** No TBDs, TODOs, or "implement later" markers. Each step has the exact code or command.

**Type consistency check:**
- `UserView` defined in `types.go` (Task 6), used in `LoginResponse` (Task 6), returned from `handleGetMe` (Task 8) — names match.
- `LoginRequest`/`LoginResponse` — defined once, referenced only by `handleLogin`.
- `SessionCookieName` exported from `internal/auth/session.go` (Task 4), referenced from `internal/api/auth.go` (Task 6), `internal/api/auth_test.go` (Task 6), `internal/api/me.go` (nope — `me.go` uses `UserFromContext`), and the manual curl in Task 10. Consistent.
- `HashSessionToken` / `MintSessionToken` / `VerifyPassword` — defined Task 3, used Tasks 4, 6, 7. Consistent.
- `sessionTokenFromHTTP` (api package, Task 7) vs `sessionTokenFromRequest` (auth package, Task 4) — separate helpers, documented as such.
