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

1459 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Web UI Scaffold — Server Auth Foundation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land the `/api/*` authentication foundation that the upcoming SvelteKit SPA and the future Flutter client will share. Adds a sessions table, a `POST /api/auth/login` / `POST /api/auth/logout` / `GET /api/me` surface, and a `RequireUser` middleware that accepts either a session cookie or an `Authorization: Bearer` header.
**Architecture:** New `internal/api` package holds native JSON handlers (no Subsonic envelope). Sessions are opaque 32-byte tokens, stored as sha256 hashes in a new `sessions` table. `POST /api/auth/login` sets an `httpOnly; SameSite=Strict` cookie and returns the same token in the JSON body so Flutter can use `Authorization: Bearer` later. Middleware tries cookie first, bearer second. `/rest/*` is untouched.
**Tech Stack:** Go 1.23, pgx/v5, sqlc, chi router, golang-migrate, bcrypt, stdlib `crypto/sha256` + `crypto/rand`.
**Scope guard:** This plan intentionally stops at auth + `/api/me`. Library browse / search / stream / cover-art endpoints and the SvelteKit project are separate plans that land after this one.
---
## File Structure
**New files:**
- `internal/db/migrations/0004_sessions.up.sql` — create sessions table
- `internal/db/migrations/0004_sessions.down.sql` — drop sessions table
- `internal/db/queries/sessions.sql` — sqlc queries for sessions
- `internal/db/dbq/sessions.sql.go`**generated** by `make generate`
- `internal/auth/session.go` — session mint / hash / lookup helpers + `VerifyPassword` + `RequireUser`
- `internal/auth/session_test.go` — unit tests for the helpers
- `internal/api/api.go` — package doc, route mounting (`Mount(chi.Router, pool, logger)`)
- `internal/api/types.go` — shared request/response types
- `internal/api/auth.go``handleLogin`, `handleLogout`
- `internal/api/auth_test.go` — handler tests
- `internal/api/me.go``handleGetMe`
- `internal/api/me_test.go` — handler test
- `internal/api/errors.go``writeErr(w, status, code, message)` helper + tests in `auth_test.go`
**Modified files:**
- `internal/server/server.go:35-54` — mount `api.Mount(...)` inside the `/api` route group
- `internal/auth/middleware.go` — no edits; `RequireUser` lives in the new `session.go` alongside cookie helpers
**Guiding principle:** files stay focused. `session.go` owns the session lifecycle; each `handle*` file owns one HTTP surface. Helpers that would be reused from other `/api/*` handlers (error writer, JSON envelope) live in `api/` for future siblings to import.
---
## Conventions (apply to every task unless a task says otherwise)
- **Always run `gofmt -w` on any file you touch before committing.**
- **Test commands** are run from repo root (`/home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel`).
- **`sqlc generate` is run via `make generate`.** This requires Docker; if Docker isn't available, fail the task — don't skip regeneration.
- **Commit messages follow existing repo style:** `type(scope): subject` (`feat(api): ...`, `fix(auth): ...`), one-line subject ≤ 72 chars, body explains *why*, trailer is `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`.
- **Every `git commit` step below uses a HEREDOC** so multi-line messages survive shell quoting.
---
### Task 1: Migration — create `sessions` table
**Files:**
- Create: `internal/db/migrations/0004_sessions.up.sql`
- Create: `internal/db/migrations/0004_sessions.down.sql`
- [ ] **Step 1: Write the up migration**
File: `internal/db/migrations/0004_sessions.up.sql`
```sql
-- Session tokens authenticate /api/* requests. We store sha256(token) so a
-- read-only DB leak doesn't grant active sessions; the raw token lives only
-- in the client's cookie (web) or bearer header (Flutter).
--
-- last_seen_at enables an "active sessions" UI later (not wired in this plan)
-- without schema churn. user_agent is captured at issue time for the same
-- reason — free metadata now, no migration later.
CREATE TABLE sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash bytea NOT NULL UNIQUE,
user_agent text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX sessions_user_id_idx ON sessions (user_id);
```
- [ ] **Step 2: Write the down migration**
File: `internal/db/migrations/0004_sessions.down.sql`
```sql
DROP INDEX IF EXISTS sessions_user_id_idx;
DROP TABLE IF EXISTS sessions;
```
- [ ] **Step 3: Run the migration against a disposable DB to prove it's well-formed**
Command:
```bash
docker compose exec -T postgres psql -U minstrel -d minstrel -c "SELECT version FROM schema_migrations;"
docker compose restart minstrel
docker compose logs minstrel --since=30s | grep -i migrat
```
Expected: logs show "applied 0004_sessions" (or equivalent); `SELECT * FROM sessions LIMIT 0;` works afterwards.
If the stack isn't running, start it: `docker compose up -d` and repeat.
- [ ] **Step 4: Commit**
```bash
git add internal/db/migrations/0004_sessions.up.sql internal/db/migrations/0004_sessions.down.sql
git commit -m "$(cat <<'EOF'
feat(db): add sessions table for /api/* auth
Stores sha256(token) plus user_agent + last_seen_at so future
active-sessions UI doesn't need another migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: sqlc queries for sessions
**Files:**
- Create: `internal/db/queries/sessions.sql`
- Regenerate: `internal/db/dbq/sessions.sql.go` (via `make generate`)
- [ ] **Step 1: Write the queries**
File: `internal/db/queries/sessions.sql`
```sql
-- name: InsertSession :one
INSERT INTO sessions (user_id, token_hash, user_agent)
VALUES ($1, $2, $3)
RETURNING *;
-- name: GetSessionByTokenHash :one
SELECT * FROM sessions WHERE token_hash = $1;
-- name: TouchSessionLastSeen :exec
UPDATE sessions SET last_seen_at = now() WHERE id = $1;
-- name: DeleteSession :exec
DELETE FROM sessions WHERE id = $1;
-- name: DeleteSessionByTokenHash :exec
DELETE FROM sessions WHERE token_hash = $1;
```
- [ ] **Step 2: Regenerate sqlc output**
Run: `make generate`
Expected: `internal/db/dbq/sessions.sql.go` is created with `InsertSession`, `GetSessionByTokenHash`, `TouchSessionLastSeen`, `DeleteSession`, `DeleteSessionByTokenHash`.
- [ ] **Step 3: Verify generated code compiles**
Run: `go build ./...`
Expected: exit 0, no output.
- [ ] **Step 4: Commit**
```bash
git add internal/db/queries/sessions.sql internal/db/dbq/sessions.sql.go
git commit -m "$(cat <<'EOF'
feat(dbq): generate session queries
Adds Insert/Get/Touch/Delete helpers over the sessions table.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: Session helpers — mint / hash / verify password
**Files:**
- Create: `internal/auth/session.go`
- Create: `internal/auth/session_test.go`
- [ ] **Step 1: Write the failing test**
File: `internal/auth/session_test.go`
```go
package auth
import (
"crypto/sha256"
"encoding/base64"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestMintSessionToken_ReturnsUrlSafeBase64(t *testing.T) {
token, err := MintSessionToken()
if err != nil {
t.Fatalf("MintSessionToken: %v", err)
}
if len(token) < 40 {
t.Errorf("token length = %d, want >= 40 (32B base64 url-safe)", len(token))
}
if strings.ContainsAny(token, "+/=") {
t.Errorf("token %q contains non-url-safe chars", token)
}
}
func TestHashSessionToken_IsDeterministicSHA256(t *testing.T) {
token := "test-token-xyz"
want := sha256.Sum256([]byte(token))
got := HashSessionToken(token)
if len(got) != sha256.Size {
t.Fatalf("hash length = %d, want %d", len(got), sha256.Size)
}
if base64.StdEncoding.EncodeToString(got) != base64.StdEncoding.EncodeToString(want[:]) {
t.Errorf("hash = %x, want %x", got, want)
}
}
func TestVerifyPassword(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.MinCost)
if err != nil {
t.Fatalf("GenerateFromPassword: %v", err)
}
if !VerifyPassword(string(hash), "hunter2") {
t.Error("correct password rejected")
}
if VerifyPassword(string(hash), "wrong") {
t.Error("wrong password accepted")
}
if VerifyPassword("not-a-hash", "hunter2") {
t.Error("malformed hash accepted")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/auth/ -run 'TestMintSessionToken|TestHashSessionToken|TestVerifyPassword' -v`
Expected: FAIL with `undefined: MintSessionToken` / `HashSessionToken` / `VerifyPassword`.
- [ ] **Step 3: Implement**
File: `internal/auth/session.go`
```go
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"golang.org/x/crypto/bcrypt"
)
// sessionTokenBytes is the raw entropy per session token. 32 bytes of
// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie
// value is 43 chars with no padding.
const sessionTokenBytes = 32
// MintSessionToken returns a freshly-generated, url-safe opaque token.
// The token is what the client carries; the DB only ever sees its sha256.
func MintSessionToken() (string, error) {
b := make([]byte, sessionTokenBytes)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// HashSessionToken is the single source of truth for mapping a raw token to
// the `sessions.token_hash` column. sha256 is fine here — we're not guarding
// against offline brute force (the token has 256 bits of entropy); we only
// want "leaked DB row can't be replayed without also having the raw token."
func HashSessionToken(token string) []byte {
sum := sha256.Sum256([]byte(token))
return sum[:]
}
// VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective.
func VerifyPassword(hash, plaintext string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `go test ./internal/auth/ -run 'TestMintSessionToken|TestHashSessionToken|TestVerifyPassword' -v`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add internal/auth/session.go internal/auth/session_test.go
git commit -m "$(cat <<'EOF'
feat(auth): session token + password verification helpers
Shared primitives for /api/* auth: mint a url-safe opaque token,
hash it for storage, verify a bcrypt password hash.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: `RequireUser` middleware (cookie or bearer)
**Files:**
- Modify: `internal/auth/session.go` — add `RequireUser`, `sessionCookieName`, extract helper
- Modify: `internal/auth/session_test.go` — add middleware tests
- [ ] **Step 1: Write the failing tests**
Append to `internal/auth/session_test.go`:
```go
import "net/http"
import "net/http/httptest"
import "context"
// (merge these imports into the existing import block)
func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Fatal("handler must not be called")
})
h := RequireUser(nil)(next)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestExtractBearerToken(t *testing.T) {
cases := []struct {
header string
want string
}{
{"", ""},
{"Bearer abc", "abc"},
{"bearer abc", "abc"},
{"Token abc", ""},
{"Bearer", ""},
{"Bearer whitespace-token ", "whitespace-token"},
}
for _, c := range cases {
got := extractBearerToken(c.header)
if got != c.want {
t.Errorf("extractBearerToken(%q) = %q, want %q", c.header, got, c.want)
}
}
}
```
- [ ] **Step 2: Run to verify they fail**
Run: `go test ./internal/auth/ -run 'TestRequireUser|TestExtractBearerToken' -v`
Expected: FAIL (`undefined: RequireUser`, `undefined: extractBearerToken`).
- [ ] **Step 3: Add `GetUserByID` sqlc query (needed by the middleware)**
Append to `internal/db/queries/users.sql`:
```sql
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
```
Run: `make generate`
Expected: `internal/db/dbq/users.sql.go` now contains `GetUserByID(ctx, id pgtype.UUID)`.
- [ ] **Step 4: Implement the middleware**
Append to `internal/auth/session.go`:
```go
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// (merge these imports into the existing import block at the top of session.go)
// SessionCookieName is the cookie the web SPA rides. Exported because handlers
// that issue/clear the cookie (handleLogin / handleLogout) need to match it.
const SessionCookieName = "minstrel_session"
// RequireUser resolves the caller from a session cookie OR Authorization
// bearer header and puts the dbq.User in request context via userCtxKey.
// Requests without a valid session return 401 with no body so callers don't
// leak whether the username existed (matches the /rest/* auth posture).
func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := sessionTokenFromRequest(r)
if token == "" {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if pool == nil {
// Test-only path: the test at the top of this file constructs
// the middleware with nil pool to prove the no-token case
// short-circuits without a DB call. Any real token here is
// programmer error.
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
q := dbq.New(pool)
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
slog.Error("api: session lookup failed", "err", err)
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
return
}
user, err := q.GetUserByID(r.Context(), sess.UserID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Session points at a deleted user — treat as unauth,
// best-effort cleanup.
_ = q.DeleteSession(r.Context(), sess.ID)
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
slog.Error("api: user lookup failed", "err", err)
http.Error(w, "auth lookup failed", http.StatusInternalServerError)
return
}
// Best-effort last-seen update. A failure here shouldn't fail the
// request; the session is still valid and this is observability.
if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
slog.Warn("api: touch session last_seen failed", "err", err)
}
ctx := context.WithValue(r.Context(), userCtxKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func sessionTokenFromRequest(r *http.Request) string {
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
return c.Value
}
return extractBearerToken(r.Header.Get("Authorization"))
}
// extractBearerToken pulls the token out of an Authorization header in
// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is
// missing, malformed, or uses a different scheme.
func extractBearerToken(header string) string {
if header == "" {
return ""
}
const prefix = "bearer "
lower := strings.ToLower(header)
if !strings.HasPrefix(lower, prefix) {
return ""
}
return strings.TrimSpace(header[len(prefix):])
}
```
- [ ] **Step 5: Run middleware tests**
Run: `go test ./internal/auth/ -v`
Expected: all prior tests plus `TestRequireUser_RejectsWhenNoCookieOrBearer` and `TestExtractBearerToken` PASS.
- [ ] **Step 6: Commit**
```bash
git add internal/auth/session.go internal/auth/session_test.go internal/db/queries/users.sql internal/db/dbq/users.sql.go
git commit -m "$(cat <<'EOF'
feat(auth): RequireUser middleware (cookie or bearer)
Resolves /api/* callers from session cookie first, Authorization
bearer second. Touches last_seen on success for future active-
sessions UI. Adds GetUserByID query used by the middleware.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 5: `internal/api` package skeleton + error helper
**Files:**
- Create: `internal/api/api.go`
- Create: `internal/api/errors.go`
- Create: `internal/api/errors_test.go`
- [ ] **Step 1: Write the failing test**
File: `internal/api/errors_test.go`
```go
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestWriteErr_EmitsJSONEnvelope(t *testing.T) {
w := httptest.NewRecorder()
writeErr(w, http.StatusBadRequest, "bad_request", "field x required")
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
if got := w.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("content-type = %q, want application/json", got)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
}
if body.Error.Code != "bad_request" || body.Error.Message != "field x required" {
t.Errorf("body = %+v", body)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/api/ -v`
Expected: FAIL — package doesn't exist yet.
- [ ] **Step 3: Create the package + error helper**
File: `internal/api/api.go`
```go
// Package api implements Minstrel's native JSON surface under /api. It is
// consumed by the built-in web SPA and (eventually) the Flutter client.
// Subsonic-compatible endpoints under /rest are intentionally separate —
// see internal/subsonic — and the two packages must not depend on each other.
package api
import (
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger) {
h := &handlers{pool: pool, logger: logger}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
api.Group(func(authed chi.Router) {
authed.Use(auth.RequireUser(pool))
authed.Post("/auth/logout", h.handleLogout)
authed.Get("/me", h.handleGetMe)
})
})
}
type handlers struct {
pool *pgxpool.Pool
logger *slog.Logger
}
// Stub handlers so Mount() compiles. Real implementations in later tasks
// replace these in place (Task 6 login, Task 7 logout, Task 8 me).
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotImplemented, "not_implemented", "login not wired")
}
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotImplemented, "not_implemented", "logout not wired")
}
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusNotImplemented, "not_implemented", "me not wired")
}
```
**Reviewer note:** Real `handleLogin` / `handleLogout` / `handleGetMe` implementations in Tasks 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`
```go
package api
import (
"encoding/json"
"net/http"
)
// errorBody is the JSON envelope for failures on /api/*. Kept separate from
// the Subsonic envelope so native clients don't have to parse two shapes.
type errorBody struct {
Error errorPayload `json:"error"`
}
type errorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
}
// writeErr is the canonical way to emit a failure. Code is a stable
// short-string clients can switch on; message is human-readable.
func writeErr(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
}
```
- [ ] **Step 4: Run tests and verify build**
Run: `go build ./... && go test ./internal/api/ -v`
Expected: build exits 0; `TestWriteErr_EmitsJSONEnvelope` PASSes.
- [ ] **Step 5: Commit**
```bash
git add internal/api/api.go internal/api/errors.go internal/api/errors_test.go
git commit -m "$(cat <<'EOF'
feat(api): package skeleton + error envelope
Introduces internal/api with Mount(), the {error:{code,message}}
response shape, and stubbed login/logout/me so later tasks can
land one handler at a time without breaking the build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 6: `POST /api/auth/login`
**Files:**
- Create: `internal/api/types.go`
- Modify: `internal/api/api.go` — replace login stub with real implementation
- Create: `internal/api/auth_test.go`
- [ ] **Step 1: Write the failing test**
File: `internal/api/auth_test.go`
```go
package api
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
// Skips in -short mode or when the env var is missing, matching the pattern
// used elsewhere (scanner_test.go, etc.).
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Helper()
if testing.Short() {
t.Skip("skipping api integration in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE sessions, users RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
return &handlers{pool: pool, logger: logger}, pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: username,
PasswordHash: string(hash),
ApiToken: "test-api-token-" + username,
IsAdmin: isAdmin,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u
}
func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "alice", "hunter2", false)
body := strings.NewReader(`{"username":"alice","password":"hunter2"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
User struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
}
if resp.Token == "" {
t.Error("token empty")
}
if resp.User.Username != "alice" || resp.User.IsAdmin {
t.Errorf("user = %+v, want alice/non-admin", resp.User)
}
var cookieFound bool
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
cookieFound = true
if !c.HttpOnly {
t.Error("session cookie missing HttpOnly")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("SameSite = %v, want Strict", c.SameSite)
}
if c.Value != resp.Token {
t.Error("cookie value does not match response token")
}
}
}
if !cookieFound {
t.Error("session cookie not set")
}
}
func TestHandleLogin_WrongPasswordReturns401(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "alice", "hunter2", false)
body := strings.NewReader(`{"username":"alice","password":"wrong"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
h, _ := testHandlers(t)
body := strings.NewReader(`{"username":"ghost","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
bytes.NewReader([]byte("not-json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel go test ./internal/api/ -v -run TestHandleLogin`
Expected: tests run (integration DSN reachable) and FAIL with "not_implemented" responses.
If your local stack uses a different DSN, set `MINSTREL_TEST_DATABASE_URL` accordingly. If you're developing without the stack up, skip this step — the CI pipeline runs it.
- [ ] **Step 3: Define shared types**
File: `internal/api/types.go`
```go
package api
import "github.com/jackc/pgx/v5/pgtype"
// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
// web and Flutter both send the same payload.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse carries the opaque session token AND the authenticated user
// so the SPA can hydrate its auth store in one round-trip. The token is also
// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer.
type LoginResponse struct {
Token string `json:"token"`
User UserView `json:"user"`
}
// UserView is the /api/* view of a user. Narrower than dbq.User — no hash,
// no api_token, no subsonic_password.
type UserView struct {
ID pgtype.UUID `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}
```
- [ ] **Step 4: Implement `handleLogin`**
File: `internal/api/auth.go`
```go
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// sessionCookieMaxAge is the cookie lifetime. Sessions don't auto-expire
// server-side yet (future work); the cookie still caps browser-side lifetime
// so an abandoned laptop doesn't stay logged in forever.
const sessionCookieMaxAge = 30 * 24 * time.Hour
func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
if req.Username == "" || req.Password == "" {
writeErr(w, http.StatusBadRequest, "bad_request", "username and password required")
return
}
q := dbq.New(h.pool)
user, err := q.GetUserByUsername(r.Context(), req.Username)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
h.logger.Error("api: user lookup failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
writeErr(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
token, err := auth.MintSessionToken()
if err != nil {
h.logger.Error("api: mint session token failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "mint failed")
return
}
if _, err := q.InsertSession(r.Context(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
UserAgent: r.UserAgent(),
}); err != nil {
h.logger.Error("api: insert session failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "insert failed")
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: r.TLS != nil, // dev over http stays functional; prod over https gets Secure
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionCookieMaxAge.Seconds()),
})
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(LoginResponse{
Token: token,
User: UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
},
})
}
```
Delete the `handleLogin` stub from `internal/api/api.go` — the real one in `auth.go` replaces it.
- [ ] **Step 5: Run tests**
Run: `MINSTREL_TEST_DATABASE_URL=postgres://minstrel:minstrel@localhost:5432/minstrel go test ./internal/api/ -v -run TestHandleLogin`
Expected: all four `TestHandleLogin_*` tests PASS.
- [ ] **Step 6: Commit**
```bash
git add internal/api/auth.go internal/api/api.go internal/api/types.go internal/api/auth_test.go
git commit -m "$(cat <<'EOF'
feat(api): POST /api/auth/login
Verifies bcrypt password hash, mints a session token, stores its
sha256 in the sessions table, and returns the token in both the
response body (for Flutter) and an httpOnly/SameSite=Strict
cookie (for the web SPA).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 7: `POST /api/auth/logout`
**Files:**
- Modify: `internal/api/auth.go` — add `handleLogout`
- Modify: `internal/api/api.go` — remove logout stub
- Modify: `internal/api/auth_test.go` — add logout tests
- [ ] **Step 1: Write the failing tests**
Append to `internal/api/auth_test.go`:
```go
func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
// Manually create a session to log out of.
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
}); err != nil {
t.Fatalf("insert: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
// handleLogout runs behind RequireUser in real routing; simulate that by
// putting the user into context here.
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
// Cookie should be cleared.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("session cookie not cleared")
}
// Session row should be gone.
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
if err == nil {
t.Error("session row still present after logout")
}
}
```
**Reviewer note:** `userCtxKeyForTest()` is a test-only shim that exposes `auth.userCtxKey` to the api package, needed because context keys are unexported. Added in the next step.
- [ ] **Step 2: Expose a test-only context key accessor**
Append to `internal/auth/session.go`:
```go
// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need
// to inject a dbq.User into request context without going through the
// middleware. Do not use this outside _test.go files.
func UserCtxKeyForTest() any { return userCtxKey }
```
Append to `internal/api/auth_test.go` (top-level, after imports):
```go
func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() }
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleLogout`
Expected: FAIL (stub returns 501, not 204).
- [ ] **Step 4: Implement `handleLogout`**
Append to `internal/api/auth.go`:
```go
func (h *handlers) handleLogout(w http.ResponseWriter, r *http.Request) {
// The session token can be on the cookie OR bearer header — RequireUser
// accepted either. Re-resolve it here so we can delete the row.
token := sessionTokenFromHTTP(r)
if token != "" {
if err := dbq.New(h.pool).DeleteSessionByTokenHash(r.Context(), auth.HashSessionToken(token)); err != nil {
h.logger.Warn("api: delete session failed", "err", err)
// Continue — logout is best-effort; the client still gets the
// cookie cleared.
}
}
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
w.WriteHeader(http.StatusNoContent)
}
// sessionTokenFromHTTP duplicates the internal helper in internal/auth
// because that one is unexported. Cheap to repeat here; keeping the auth
// package's internal helper package-private is worth more than DRY.
func sessionTokenFromHTTP(r *http.Request) string {
if c, err := r.Cookie(auth.SessionCookieName); err == nil && c.Value != "" {
return c.Value
}
h := r.Header.Get("Authorization")
const prefix = "bearer "
if len(h) > len(prefix) && (h[:7] == "Bearer " || h[:7] == "bearer ") {
return h[len(prefix):]
}
return ""
}
```
Delete the logout stub from `internal/api/api.go`.
- [ ] **Step 5: Run tests**
Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleLogout`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add internal/api/auth.go internal/api/api.go internal/api/auth_test.go internal/auth/session.go
git commit -m "$(cat <<'EOF'
feat(api): POST /api/auth/logout
Deletes the session row keyed by the cookie/bearer token and
clears the cookie on the client. Best-effort DB delete — logout
still succeeds for the client if the row's already gone.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 8: `GET /api/me`
**Files:**
- Create: `internal/api/me.go`
- Modify: `internal/api/api.go` — remove `handleGetMe` stub
- Create: `internal/api/me_test.go`
- [ ] **Step 1: Write the failing test**
File: `internal/api/me_test.go`
```go
package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestHandleGetMe_ReturnsAuthenticatedUser(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", true)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
h.handleGetMe(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var got UserView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Username != "alice" || !got.IsAdmin {
t.Errorf("user = %+v, want alice/admin", got)
}
}
func TestHandleGetMe_MissingContextReturns500(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
w := httptest.NewRecorder()
h.handleGetMe(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500 (context should be populated by RequireUser)", w.Code)
}
_ = dbq.User{} // keep the import used
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleGetMe`
Expected: FAIL (stub returns 501).
- [ ] **Step 3: Implement `handleGetMe`**
File: `internal/api/me.go`
```go
package api
import (
"encoding/json"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
)
func (h *handlers) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
// Hitting /me without RequireUser in front of it is a routing bug;
// it can't happen in real traffic.
h.logger.Error("api: /me reached without authenticated user")
writeErr(w, http.StatusInternalServerError, "server_error", "missing auth context")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(UserView{
ID: user.ID,
Username: user.Username,
IsAdmin: user.IsAdmin,
})
}
```
Delete the `handleGetMe` stub from `internal/api/api.go`.
- [ ] **Step 4: Run tests**
Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v -run TestHandleGetMe`
Expected: PASS (both tests).
- [ ] **Step 5: Run the full api package tests to confirm nothing regressed**
Run: `MINSTREL_TEST_DATABASE_URL=... go test ./internal/api/ -v`
Expected: every test PASSes.
- [ ] **Step 6: Commit**
```bash
git add internal/api/me.go internal/api/me_test.go internal/api/api.go
git commit -m "$(cat <<'EOF'
feat(api): GET /api/me
Returns the authenticated user (id/username/is_admin). Shape
matches UserView used in the login response so SPA stores stay
typed against one interface.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 9: Mount `/api/*` in the root router
**Files:**
- Modify: `internal/server/server.go`
- [ ] **Step 1: Update the router**
Replace the existing `Router()` body at `internal/server/server.go:35-54`. The edit is: after the existing `r.Route("/api", ...)` admin block, call `api.Mount(r, s.Pool, s.Logger)` at the same level. But `api.Mount` *also* opens `/api`; chi allows multiple `Route("/api", ...)` calls — but to avoid confusion, we inline the admin routes into the api package structure in a single `Route("/api", ...)` is cleanest.
Concretely — final shape:
```go
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Get("/healthz", s.handleHealthz)
if s.Pool != nil {
api.Mount(r, s.Pool, s.Logger)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin(s.Pool))
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
})
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg)
}
return r
}
```
Add the import for `internal/api`:
```go
import (
// existing imports...
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
)
```
**Reviewer note:** chi permits both `r.Route("/api", func(api chi.Router) {...})` opened by `api.Mount` and a sibling `r.Route("/api/admin", func...)` because the paths don't overlap at the exact level — chi's internal tree merges prefixes. If you see "handler overlaps" at startup, convert `api.Mount` to accept an optional admin wiring callback and move scan into it; flag this up to the reviewer rather than hacking around it.
- [ ] **Step 2: Build and verify the server still starts**
Run: `go build ./...`
Expected: exit 0.
Run (if stack running): `docker compose restart minstrel && docker compose logs minstrel --since=30s`
Expected: "listening on :4533" and no panics.
- [ ] **Step 3: Commit**
```bash
git add internal/server/server.go
git commit -m "$(cat <<'EOF'
feat(server): mount /api/* (login, logout, me)
Wires the native JSON surface alongside the existing /api/admin
and /rest/* routes. Subsonic compatibility remains untouched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```
---
### Task 10: End-to-end smoke (manual, recorded in the plan)
**Files:** none (verification only)
- [ ] **Step 1: Bring the full stack up**
```bash
docker compose up --build -d
docker compose logs minstrel --since=60s | grep -iE 'listening|error|panic'
```
Expected: "listening on :4533", no errors.
- [ ] **Step 2: Login with the bootstrap admin**
```bash
# Replace PASSWORD with the bootstrap password from startup logs.
curl -i -X POST http://localhost:4533/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"PASSWORD"}'
```
Expected: HTTP/1.1 200 OK; body contains `{"token":"...","user":{...}}`; `Set-Cookie: minstrel_session=...; HttpOnly; SameSite=Strict`.
- [ ] **Step 3: Call `/api/me` with the cookie**
```bash
curl -i http://localhost:4533/api/me \
-H "Cookie: minstrel_session=<token-from-login>"
```
Expected: HTTP/1.1 200 OK; body is `{"id":"...","username":"admin","is_admin":true}`.
- [ ] **Step 4: Call `/api/me` with bearer instead**
```bash
curl -i http://localhost:4533/api/me \
-H "Authorization: Bearer <token-from-login>"
```
Expected: HTTP/1.1 200 OK; same body.
- [ ] **Step 5: Verify 401 without auth**
```bash
curl -i http://localhost:4533/api/me
```
Expected: HTTP/1.1 401 Unauthorized.
- [ ] **Step 6: Verify 401 with wrong password on login**
```bash
curl -i -X POST http://localhost:4533/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"nope"}'
```
Expected: HTTP/1.1 401 Unauthorized; body `{"error":{"code":"invalid_credentials","message":"..."}}`.
- [ ] **Step 7: Logout clears the session**
```bash
curl -i -X POST http://localhost:4533/api/auth/logout \
-H "Cookie: minstrel_session=<token>"
```
Expected: HTTP/1.1 204 No Content; `Set-Cookie: minstrel_session=; Max-Age=0`.
Then reuse that token against `/api/me` and expect 401.
- [ ] **Step 8: Open the PR**
After all smoke steps pass:
```bash
git push origin dev
```
Then create a PR from `dev``main` using the Forgejo MCP (see git workflow memory). Poll CI; merge when green; `git pull --ff-only origin dev` locally.
Done.
---
## Self-Review
**Spec coverage check** (against `docs/superpowers/specs/2026-04-20-web-ui-scaffold-design.md`):
- Sessions table (spec §Authentication → Sessions table) — Task 1.
- sha256(token) at rest (spec §Authentication → Sessions table) — Task 3.
- Login issues both cookie and bearer token (spec §Authentication → Login flow) — Task 6.
- `httpOnly; SameSite=Strict` cookie (spec §Authentication → Login flow) — Task 6.
- Middleware resolves cookie → bearer (spec §Authentication → Middleware) — Task 4.
- `401` on unauthenticated `/api/*` (spec §Authentication → Middleware) — Task 4 + Task 10.
- `/api/me` returns `{id, username, is_admin}` (spec §API surface → first-cut endpoints) — Task 8.
- `/rest/*` untouched (spec §Non-goals and §API surface) — no task modifies `internal/subsonic`, no test imports it.
- Logout deletes session + clears cookie — spec §Authentication → Login flow mentions "expire via Set-Cookie" and sessions table Task 2 includes `DeleteSessionByTokenHash`. — Task 7.
- OIDC headroom (spec §Open questions resolved) — design preserved: login endpoint is the only auth-material touchpoint, so OIDC callback can mint the same cookie+token pair without schema change.
**Not yet landed** (other plans):
- `/api/artists`, `/api/albums/{id}`, `/api/tracks/{id}`, `/api/search`, `/api/stream/{id}`, `/api/cover/{id}` — Plan 2 (server library reads).
- SvelteKit project, Dockerfile multi-stage, `embed.FS` wiring — Plan 3 (web SPA + packaging).
**Placeholder scan:** No TBDs, TODOs, or "implement later" markers. Each step has the exact code or command.
**Type consistency check:**
- `UserView` defined in `types.go` (Task 6), used in `LoginResponse` (Task 6), returned from `handleGetMe` (Task 8) — names match.
- `LoginRequest`/`LoginResponse` — defined once, referenced only by `handleLogin`.
- `SessionCookieName` exported from `internal/auth/session.go` (Task 4), referenced from `internal/api/auth.go` (Task 6), `internal/api/auth_test.go` (Task 6), `internal/api/me.go` (nope — `me.go` uses `UserFromContext`), and the manual curl in Task 10. Consistent.
- `HashSessionToken` / `MintSessionToken` / `VerifyPassword` — defined Task 3, used Tasks 4, 6, 7. Consistent.
- `sessionTokenFromHTTP` (api package, Task 7) vs `sessionTokenFromRequest` (auth package, Task 4) — separate helpers, documented as such.