9b0433ad9b
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>
1459 lines
44 KiB
Markdown
1459 lines
44 KiB
Markdown
# 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 6–8 *move* into their own files (`auth.go`, `me.go`) and the stubs here get deleted as part of each task.
|
||
|
||
File: `internal/api/errors.go`
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
)
|
||
|
||
// errorBody is the JSON envelope for failures on /api/*. Kept separate from
|
||
// the Subsonic envelope so native clients don't have to parse two shapes.
|
||
type errorBody struct {
|
||
Error errorPayload `json:"error"`
|
||
}
|
||
|
||
type errorPayload struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// writeErr is the canonical way to emit a failure. Code is a stable
|
||
// short-string clients can switch on; message is human-readable.
|
||
func writeErr(w http.ResponseWriter, status int, code, message string) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_ = json.NewEncoder(w).Encode(errorBody{Error: errorPayload{Code: code, Message: message}})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests and verify build**
|
||
|
||
Run: `go build ./... && go test ./internal/api/ -v`
|
||
Expected: build exits 0; `TestWriteErr_EmitsJSONEnvelope` PASSes.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add internal/api/api.go internal/api/errors.go internal/api/errors_test.go
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(api): package skeleton + error envelope
|
||
|
||
Introduces internal/api with Mount(), the {error:{code,message}}
|
||
response shape, and stubbed login/logout/me so later tasks can
|
||
land one handler at a time without breaking the build.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 <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.
|