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>
44 KiB
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 tableinternal/db/migrations/0004_sessions.down.sql— drop sessions tableinternal/db/queries/sessions.sql— sqlc queries for sessionsinternal/db/dbq/sessions.sql.go— generated bymake generateinternal/auth/session.go— session mint / hash / lookup helpers +VerifyPassword+RequireUserinternal/auth/session_test.go— unit tests for the helpersinternal/api/api.go— package doc, route mounting (Mount(chi.Router, pool, logger))internal/api/types.go— shared request/response typesinternal/api/auth.go—handleLogin,handleLogoutinternal/api/auth_test.go— handler testsinternal/api/me.go—handleGetMeinternal/api/me_test.go— handler testinternal/api/errors.go—writeErr(w, status, code, message)helper + tests inauth_test.go
Modified files:
internal/server/server.go:35-54— mountapi.Mount(...)inside the/apiroute groupinternal/auth/middleware.go— no edits;RequireUserlives in the newsession.goalongside 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 -won any file you touch before committing. - Test commands are run from repo root (
/home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel). sqlc generateis run viamake 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 isCo-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>. - Every
git commitstep 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(viamake 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
)"
Task 4: RequireUser middleware (cookie or bearer)
Files:
-
Modify:
internal/auth/session.go— addRequireUser,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
GetUserByIDsqlc 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 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
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— addhandleLogout -
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— removehandleGetMestub -
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/mewith 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/mewith 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 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=Strictcookie (spec §Authentication → Login flow) — Task 6.- Middleware resolves cookie → bearer (spec §Authentication → Middleware) — Task 4.
401on unauthenticated/api/*(spec §Authentication → Middleware) — Task 4 + Task 10./api/mereturns{id, username, is_admin}(spec §API surface → first-cut endpoints) — Task 8./rest/*untouched (spec §Non-goals and §API surface) — no task modifiesinternal/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.FSwiring — 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:
UserViewdefined intypes.go(Task 6), used inLoginResponse(Task 6), returned fromhandleGetMe(Task 8) — names match.LoginRequest/LoginResponse— defined once, referenced only byhandleLogin.SessionCookieNameexported frominternal/auth/session.go(Task 4), referenced frominternal/api/auth.go(Task 6),internal/api/auth_test.go(Task 6),internal/api/me.go(nope —me.gousesUserFromContext), 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) vssessionTokenFromRequest(auth package, Task 4) — separate helpers, documented as such.