Server half of the active-sessions surface. Web UI follows.
The operator wants this specifically to notice a compromised account, which
sets the bar: the addresses have to be trustworthy, or the feature is worse
than absent because it looks like evidence.
Migration 0052 adds created_ip + last_ip. Two columns, not one, and the pair
is the signal: a session issued at home and now being used from elsewhere is
the shape of a stolen token, and neither column alone can show that. Typed
text, matching the user_agent column beside it — these are displayed, never
queried by subnet, and inet round-trips through pgx as a netip.Prefix that
renders "1.2.3.4/32".
The rest of the schema was already waiting. Migration 0004 anticipated this
exactly: "last_seen_at enables an 'active sessions' UI later (not wired in
this plan) without schema churn." last_seen_at is live data — the auth
middleware already touches it per request — so last_ip rides that same
UPDATE for free.
Getting the address right is the substance here. Nothing extracted a client
IP anywhere before, and both obvious approaches are wrong:
- RemoteAddr alone shows the reverse proxy on every session, which is the
normal self-hosted deployment. Noise shaped like data.
- Trusting X-Forwarded-For lets any client choose what its victim sees. A
security surface an attacker can write to is worse than none.
So auth.ClientIP trusts the header only when the request actually arrived
from a proxy range. Public RemoteAddr means a direct connection, so XFF is
attacker-controlled and ignored outright. Private RemoteAddr means we walk
XFF right-to-left — proxies append, so the right end is what our own
infrastructure wrote — and take the first non-proxy address. A forged XFF
only prepends to the left end, which that walk never reaches. Unit-tested,
including both spoofing shapes.
Fails closed on a public-addressed proxy (separate host, CDN): we report the
proxy rather than trusting a forgeable header. Documented at the function.
Endpoints, all scoped by user_id per rule #47:
GET /api/me/sessions → list, flagging the current row
DELETE /api/me/sessions/{id} → 204, or 404 if not yours
POST /api/me/sessions/logout-others → {"revoked": n}
Keyed on session id alone, any household member could revoke another's
session by guessing a uuid, so the delete carries user_id in its WHERE and
:execrows distinguishes "not yours" (404) from a false 204. There's a test
that asserts the row actually survives, not merely that we returned 404.
The middleware now also puts the session id in context. logout-others is
defined by exclusion, and without knowing which session is ours the
safe-looking action deletes everything including the caller's — so it
refuses rather than guesses when the id is absent, and that refusal is
tested for non-deletion too.
audit_log.action is plain text with no CHECK, so the two new actions need no
migration (rule #36 checked, not assumed).
Codegen is real sqlc 1.31.1 via the container in `make generate` — docker is
present on this workstation even though Go and sqlc aren't — rather than the
hand-written .sql.go shortcut used in milestone #268.
Completes the server side of #392 Half B.
PUT /api/me/timezone now calls scheduler.Refresh(ctx, userID) after
the DB write so the rescheduled daily-at-03:00-local job takes
effect synchronously. Failure to refresh is logged but doesn't
undo the DB write — the hourly reconciliation pass would pick it
up within an hour regardless.
POST /api/auth/register calls Refresh after successful user
insert so brand-new users get scheduled immediately rather than
waiting for the hourly pass to discover them.
system_cron.go deleted: the new scheduler subsumes its
responsibilities. The StartSystemPlaylistCron call in main.go is
also removed. Server restart now runs the new scheduler's startup
recovery + catch-up instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add audit.WriteOrLog: a one-line wrapper around Write that logs at
Warn and swallows the error, matching the package contract that
audit failures must not break user-facing operations.
Migrate the 13 call sites across 7 files in internal/api/ from the
3-line "if err != nil { logger.Warn(...) }" shape to a single call.
audit.Write stays exported for tests + any future caller that
needs strict semantics.
Adds three tests: success (no log), failure-via-closed-pool (Warn
record with action+err keys), and nil-logger (no panic). Tests
skip when MINSTREL_TEST_DATABASE_URL is unset, matching the
existing harness convention.
POST /api/auth/register accepts {username, password, invite_token?,
display_name?} and creates a user. Race-safe first-admin path: when
the users table is empty, the SQL query (CreateUserFirstAdminRace)
inserts is_admin computed from a SELECT NOT EXISTS subquery — no
serializable isolation needed; concurrent empty-state registrations
both end up admin (benign).
Past first-admin, registration_settings.mode dictates: 'invite_only'
(default) requires a valid unredeemed unexpired invite token, which
is atomically claimed via RedeemInvite (rows-affected returns from
sqlc's :execrows directive). 'open' mode skips the invite check.
On success: hashes password (bcrypt), mints session token + cookie
matching handleLogin's shape, mints a separate api_token for
Subsonic clients, audits ActionRegister + ActionInviteRedeem
(best-effort — a failed audit write does NOT fail the user-facing
operation).
Validation: 3-32 char usernames (alphanumeric + underscore +
hyphen), 8-char minimum password. Duplicate username surfaces as
409.
Tests cover: first-user-becomes-admin, invite-only requires token,
valid token redeems + non-admin role, invalid token 400, open mode
skips check, duplicate username 409, password too short 400,
username format 400, race scenario asserts at-least-one-admin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>