diff --git a/.gitignore b/.gitignore index 5b90e79f..8c2acc30 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ go.work.sum # env file .env + +# Superpowers brainstorming companion sessions +.superpowers/ diff --git a/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md b/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md new file mode 100644 index 00000000..e43e3351 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md @@ -0,0 +1,1458 @@ +# 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. diff --git a/docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md b/docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md new file mode 100644 index 00000000..568f4493 --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md @@ -0,0 +1,268 @@ +# Web UI Scaffold — Design + +**Status:** Approved (brainstorm), ready for implementation plan +**Date:** 2026-04-20 +**Milestone:** M1.5 (new) — scaffold for the web UI that M2–M5 layer features onto + +## Purpose + +Minstrel needs a built-in web UI with feature parity to the planned Flutter client. The M1 Subsonic baseline is verified end-to-end via third-party clients (Feishin), but Minstrel's own UI surface is currently empty. This spec describes the scaffold: the stack, the packaging, the auth model, the API surface the SPA consumes, the shell layout, and the first-cut feature set. + +Features that depend on backend work not yet built (events, recommendations, radio, Lidarr) are explicitly deferred and will land alongside their respective backend milestones. The web UI is not a late-stage M6 milestone — it advances through M2–M5 in step with the server. + +## Non-goals + +- OIDC / SSO login (deferred until after a solid UI exists; possibly post-v1). +- Light-theme support (dark theme only for first cut; light mode follows). +- Mobile-first responsive layouts (desktop-first; the web UI is expected to be *more dense* than the Flutter client, which will own the mobile experience). +- SSR / server-rendered initial HTML (pure SPA — SvelteKit static adapter). +- Reusing the `/rest/*` Subsonic surface from the SPA. + +## Stack + +- **Framework:** SvelteKit + TypeScript + Vite. +- **Adapter:** `@sveltejs/adapter-static`, building to `web/build/` as fully-static HTML/CSS/JS. +- **SPA fallback:** `adapter-static` configured with `fallback: 'index.html'` so deep links (`/artists/abc`) load the SPA shell and client-side routing resolves the path. +- **Styling:** Tailwind CSS + component-scoped Svelte styles. No UI kit — hand-rolled components kept minimal. +- **State:** Svelte stores. No external state library. +- **HTTP:** Native `fetch`; a thin wrapper (`web/src/lib/api.ts`) adds credentials, parses errors, returns typed responses. + +### Why Svelte over React/Solid + +- Compiled output is small and fast. Matters when rendering large library views (paginated at first; virtualization if we hit perf walls later). +- Built-in routing / stores / forms mean fewer library decisions for a solo project. +- The trade-off (smaller ecosystem) is acceptable because the components we need — list views, player UI, form controls — are trivial to build or exist as libraries. + +## Packaging + +- **Single binary, single image.** SvelteKit builds to static assets; Go embeds `web/build/` via `//go:embed` and serves from root (`/`) with SPA fallback (unmatched paths return `index.html`). +- **Dockerfile:** multi-stage. + 1. `FROM node: AS web` — `npm ci`, `npm run build`, produces `web/build/`. + 2. `FROM golang: AS go` — `go build`, with `web/build/` copied in before build so `//go:embed` picks it up. + 3. `FROM debian:stable-slim` (or existing base) — copy binary, ffmpeg, run. +- Release artifact remains a single container image. `docker compose up` stays one service for the app. + +## Dev workflow + +- Two processes during development: + 1. `docker compose up` — Go server on `:4533`, Postgres, scanner. + 2. `cd web && npm run dev` — Vite dev server on `:5173` with HMR. +- Vite dev server proxies `/api/*` and `/rest/*` to `http://localhost:4533`. The SPA is loaded from `:5173` in dev; cookies on `:5173` work because the proxy rewrites the origin. +- Production build: `npm run build` runs in CI / Docker build; static assets get embedded. + +## API surface + +### Principle + +- `/rest/*` is the Subsonic-compatibility surface for third-party clients. It keeps salted-md5 + apiKey auth, Subsonic envelope, XML/JSON shape. **The SPA does not call `/rest/*`.** +- `/api/*` is the native surface for Minstrel's own clients (web SPA now, Flutter later). Clean JSON, cookie-or-bearer auth, designed for modern consumers. + +### First-cut endpoints + +| Method | Path | Purpose | +| --- | --- | --- | +| POST | `/api/auth/login` | Verify credentials. Returns `{token, user}`, sets `httpOnly` session cookie. | +| POST | `/api/auth/logout` | Invalidate session cookie and the bearer token associated with the caller's session. | +| GET | `/api/me` | Return the authenticated user (username, is_admin, etc.). | +| GET | `/api/artists` | Paginated artist list with sort keys. | +| GET | `/api/artists/{id}` | Artist with albums array. | +| GET | `/api/albums/{id}` | Album with tracks array. | +| GET | `/api/tracks/{id}` | Single track (used for now-playing detail / direct-link). | +| GET | `/api/search?q=` | Unified search returning `{artists, albums, tracks}` arrays. | +| GET | `/api/stream/{trackId}` | Audio stream with `Accept-Ranges: bytes` for scrubbing. Cookie-auth works because `