Adds the /api/* native JSON surface used by the web UI and future Flutter client, separate from the existing /rest/* Subsonic compatibility layer.
Session auth: server-minted opaque tokens (base64 url-safe, 32 random bytes) with a sha256 hash stored server-side. Clients present the token as a minstrel_session cookie (HttpOnly, SameSite=Strict, Max-Age 30d) or Authorization: Bearer … header — native clients can use either.
Endpoints: POST /api/auth/login, POST /api/auth/logout, GET /api/me. RequireUser middleware protects logout + me; login stays public.
All errors use a consistent {"error":{"code","message"}} envelope.
Scope stops at auth + /api/me — library read endpoints and the SvelteKit frontend ship in follow-up PRs.
What's in the branch
12 commits, 21 files touched (see git log origin/main..HEAD for the full list):
Secure cookie flag derives from r.TLS != nil, which is wrong behind a TLS-terminating reverse proxy — honor X-Forwarded-Proto or add a MINSTREL_COOKIE_SECURE config
Login has a user-enumeration timing oracle (bcrypt only runs when the user exists) — run a dummy bcrypt on the not-found path
No rate limiting on /api/auth/login
me_test.go has a _ = dbq.User{} preserve-line that can be removed by dropping the unused dbq import
Full-suite parallel test DB isolation: internal/api tests TRUNCATE users, which can collide with internal/auth bootstrap tests under -p >1. Run per-package or with -p 1 until we assign each package its own schema/DB.
Out of scope (per plan)
OIDC / OAuth — still deferred to late-v1 per project scope
Library read endpoints (/api/albums, /api/artists, etc.) — next plan
Web frontend (SvelteKit) — next plan after library endpoints
## Summary
- Adds the `/api/*` native JSON surface used by the web UI and future Flutter client, separate from the existing `/rest/*` Subsonic compatibility layer.
- Session auth: server-minted opaque tokens (base64 url-safe, 32 random bytes) with a sha256 hash stored server-side. Clients present the token as a `minstrel_session` cookie (HttpOnly, SameSite=Strict, Max-Age 30d) or `Authorization: Bearer …` header — native clients can use either.
- Endpoints: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/me`. `RequireUser` middleware protects logout + me; login stays public.
- All errors use a consistent `{"error":{"code","message"}}` envelope.
Scope stops at auth + `/api/me` — library read endpoints and the SvelteKit frontend ship in follow-up PRs.
## What's in the branch
12 commits, 21 files touched (see `git log origin/main..HEAD` for the full list):
- Migration `0004_sessions.up.sql` + sqlc queries (`InsertSession`, `GetSessionByTokenHash`, `TouchSessionLastSeen`, `DeleteSession`, `DeleteSessionByTokenHash`)
- `internal/auth/session.go` — `MintSessionToken`, `HashSessionToken`, `VerifyPassword`, `RequireUser` middleware
- `internal/api/` package — `api.Mount`, handlers for login/logout/me, error envelope, types
- `internal/server/server.go` — root router now calls `api.Mount` alongside existing `/api/admin` and `/rest/*` routes
- Last commit fixes a whitespace-bearer divergence caught in final review (logout now matches middleware trimming)
## Test plan
- [x] Unit / integration tests (`go test ./internal/api/... ./internal/auth/...`)
- [x] End-to-end smoke on live docker compose stack:
- [x] `POST /api/auth/login` → 200 + Set-Cookie (HttpOnly, SameSite=Strict, Max-Age=2592000) + bearer token in body
- [x] `GET /api/me` via cookie → 200
- [x] `GET /api/me` via bearer → 200
- [x] `GET /api/me` unauthenticated → 401
- [x] Login with wrong password → 401 `{"error":{"code":"invalid_credentials"…}}`
- [x] `POST /api/auth/logout` → 204, Set-Cookie Max-Age=0
- [x] Reusing token after logout (cookie or bearer) → 401 (server-side row deleted)
- [x] Bearer with trailing whitespace on logout → 204 AND session server-side row deleted (regression guard)
- [ ] Watch CI pipeline on the PR before merge
- [ ] After merge: `git pull --ff-only origin main` locally
## Known non-blocking follow-ups (tracked for later PRs)
- `RequireUser` issues 3 sequential DB calls (session lookup + user lookup + touch) — consolidate via JOIN once traffic matters
- `RequireUser` silently swallows orphan-session cleanup errors — add `slog.Warn`
- `Secure` cookie flag derives from `r.TLS != nil`, which is wrong behind a TLS-terminating reverse proxy — honor `X-Forwarded-Proto` or add a `MINSTREL_COOKIE_SECURE` config
- Login has a user-enumeration timing oracle (bcrypt only runs when the user exists) — run a dummy bcrypt on the not-found path
- No rate limiting on `/api/auth/login`
- `me_test.go` has a `_ = dbq.User{}` preserve-line that can be removed by dropping the unused `dbq` import
- Full-suite parallel test DB isolation: `internal/api` tests TRUNCATE `users`, which can collide with `internal/auth` bootstrap tests under `-p >1`. Run per-package or with `-p 1` until we assign each package its own schema/DB.
## Out of scope (per plan)
- OIDC / OAuth — still deferred to late-v1 per project scope
- Library read endpoints (`/api/albums`, `/api/artists`, etc.) — next plan
- Web frontend (SvelteKit) — next plan after library endpoints
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
/api/*native JSON surface used by the web UI and future Flutter client, separate from the existing/rest/*Subsonic compatibility layer.minstrel_sessioncookie (HttpOnly, SameSite=Strict, Max-Age 30d) orAuthorization: Bearer …header — native clients can use either.POST /api/auth/login,POST /api/auth/logout,GET /api/me.RequireUsermiddleware protects logout + me; login stays public.{"error":{"code","message"}}envelope.Scope stops at auth +
/api/me— library read endpoints and the SvelteKit frontend ship in follow-up PRs.What's in the branch
12 commits, 21 files touched (see
git log origin/main..HEADfor the full list):0004_sessions.up.sql+ sqlc queries (InsertSession,GetSessionByTokenHash,TouchSessionLastSeen,DeleteSession,DeleteSessionByTokenHash)internal/auth/session.go—MintSessionToken,HashSessionToken,VerifyPassword,RequireUsermiddlewareinternal/api/package —api.Mount, handlers for login/logout/me, error envelope, typesinternal/server/server.go— root router now callsapi.Mountalongside existing/api/adminand/rest/*routesTest plan
go test ./internal/api/... ./internal/auth/...)POST /api/auth/login→ 200 + Set-Cookie (HttpOnly, SameSite=Strict, Max-Age=2592000) + bearer token in bodyGET /api/mevia cookie → 200GET /api/mevia bearer → 200GET /api/meunauthenticated → 401{"error":{"code":"invalid_credentials"…}}POST /api/auth/logout→ 204, Set-Cookie Max-Age=0git pull --ff-only origin mainlocallyKnown non-blocking follow-ups (tracked for later PRs)
RequireUserissues 3 sequential DB calls (session lookup + user lookup + touch) — consolidate via JOIN once traffic mattersRequireUsersilently swallows orphan-session cleanup errors — addslog.WarnSecurecookie flag derives fromr.TLS != nil, which is wrong behind a TLS-terminating reverse proxy — honorX-Forwarded-Protoor add aMINSTREL_COOKIE_SECUREconfig/api/auth/loginme_test.gohas a_ = dbq.User{}preserve-line that can be removed by dropping the unuseddbqimportinternal/apitests TRUNCATEusers, which can collide withinternal/authbootstrap tests under-p >1. Run per-package or with-p 1until we assign each package its own schema/DB.Out of scope (per plan)
/api/albums,/api/artists, etc.) — next plan🤖 Generated with Claude Code