# 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 `. - **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 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 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 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 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 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 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 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 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 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=" ``` 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 " ``` 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=" ``` 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.