Files
minstrel/docs/superpowers/plans/2026-04-20-web-ui-server-auth-foundation.md
T
bvandeusen 9b0433ad9b docs: add web UI server auth foundation plan
10-task plan for the first implementation slice of the web UI
scaffold: sessions table, /api/auth/{login,logout}, /api/me, and
the RequireUser middleware (cookie + bearer). Stops short of the
library read endpoints and the SvelteKit project, which become
their own follow-up plans.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 19:30:24 -04:00

44 KiB
Raw Blame History

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.gogenerated 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.gohandleLogin, handleLogout
  • internal/api/auth_test.go — handler tests
  • internal/api/me.gohandleGetMe
  • internal/api/me_test.go — handler test
  • internal/api/errors.gowriteErr(w, status, code, message) helper + tests in auth_test.go

Modified files:

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

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


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

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

Task 1: Migration — create sessions table

Files:

  • Create: internal/db/migrations/0004_sessions.up.sql

  • Create: internal/db/migrations/0004_sessions.down.sql

  • Step 1: Write the up migration

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

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

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:

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
git add internal/db/migrations/0004_sessions.up.sql internal/db/migrations/0004_sessions.down.sql
git commit -m "$(cat <<'EOF'
feat(db): add sessions table for /api/* auth

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

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

Task 2: sqlc queries for sessions

Files:

  • Create: internal/db/queries/sessions.sql

  • Regenerate: internal/db/dbq/sessions.sql.go (via make generate)

  • Step 1: Write the queries

File: internal/db/queries/sessions.sql

-- 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
git add internal/db/queries/sessions.sql internal/db/dbq/sessions.sql.go
git commit -m "$(cat <<'EOF'
feat(dbq): generate session queries

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

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

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

Files:

  • Create: internal/auth/session.go

  • Create: internal/auth/session_test.go

  • Step 1: Write the failing test

File: internal/auth/session_test.go

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

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
git add internal/auth/session.go internal/auth/session_test.go
git commit -m "$(cat <<'EOF'
feat(auth): session token + password verification helpers

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

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

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:

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:

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

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

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

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

Task 5: internal/api package skeleton + error helper

Files:

  • Create: internal/api/api.go

  • Create: internal/api/errors.go

  • Create: internal/api/errors_test.go

  • Step 1: Write the failing test

File: internal/api/errors_test.go

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

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

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
git add internal/api/api.go internal/api/errors.go internal/api/errors_test.go
git commit -m "$(cat <<'EOF'
feat(api): package skeleton + error envelope

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

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

Task 6: POST /api/auth/login

Files:

  • Create: internal/api/types.go

  • Modify: internal/api/api.go — replace login stub with real implementation

  • Create: internal/api/auth_test.go

  • Step 1: Write the failing test

File: internal/api/auth_test.go

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

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

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
git add internal/api/auth.go internal/api/api.go internal/api/types.go internal/api/auth_test.go
git commit -m "$(cat <<'EOF'
feat(api): POST /api/auth/login

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

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

Task 7: POST /api/auth/logout

Files:

  • Modify: internal/api/auth.go — add handleLogout

  • Modify: internal/api/api.go — remove logout stub

  • Modify: internal/api/auth_test.go — add logout tests

  • Step 1: Write the failing tests

Append to internal/api/auth_test.go:

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:

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

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:

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
git add internal/api/auth.go internal/api/api.go internal/api/auth_test.go internal/auth/session.go
git commit -m "$(cat <<'EOF'
feat(api): POST /api/auth/logout

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

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

Task 8: GET /api/me

Files:

  • Create: internal/api/me.go

  • Modify: internal/api/api.go — remove handleGetMe stub

  • Create: internal/api/me_test.go

  • Step 1: Write the failing test

File: internal/api/me_test.go

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

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
git add internal/api/me.go internal/api/me_test.go internal/api/api.go
git commit -m "$(cat <<'EOF'
feat(api): GET /api/me

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

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

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

Files:

  • Modify: internal/server/server.go

  • Step 1: Update the router

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

Concretely — final shape:

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:

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
git add internal/server/server.go
git commit -m "$(cat <<'EOF'
feat(server): mount /api/* (login, logout, me)

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

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

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

Files: none (verification only)

  • Step 1: Bring the full stack up
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
# 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
curl -i http://localhost:4533/api/me \
  -H "Cookie: minstrel_session=<token-from-login>"

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

  • Step 4: Call /api/me with bearer instead
curl -i http://localhost:4533/api/me \
  -H "Authorization: Bearer <token-from-login>"

Expected: HTTP/1.1 200 OK; same body.

  • Step 5: Verify 401 without auth
curl -i http://localhost:4533/api/me

Expected: HTTP/1.1 401 Unauthorized.

  • Step 6: Verify 401 with wrong password on login
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
curl -i -X POST http://localhost:4533/api/auth/logout \
  -H "Cookie: minstrel_session=<token>"

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

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

  • Step 8: Open the PR

After all smoke steps pass:

git push origin dev

Then create a PR from devmain 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.