Commit Graph

373 Commits

Author SHA1 Message Date
bvandeusen 154f415f92 fix(server,web): auth-gate client APK + version endpoints + per-user rate limit (#397)
Closes the bandwidth-abuse vector on the in-app update flow. Both
endpoints now sit inside the authed.Group; APK additionally gets a
60s/user rate limit to suppress accidental hammering or scripted
abuse.

### Server

- internal/api/client_assets.go:
  - clientAPKAllowDownload(): in-memory map[userID]time.Time under
    a mutex. Returns 0 (allow) or wait duration (block).
  - handleClientAPK reads user from context, checks the limit,
    returns 429 + Retry-After header if blocked.
  - testResetClientAPKRateLimit() lets tests start clean.
- internal/api/api.go: routes moved from the root /api group into
  the authed.Group block (alongside /quarantine, /requests, etc.).
- Tests: added TestClientAPK_401WhenUnauthenticated and
  TestClientAPK_RateLimit_429OnRapidSecondCall (also verifies
  different user gets a fresh slot). Existing tests updated to use
  authedRequest() helper.

### Web

- MobileAppDownload.svelte: switched from bare fetch (no credentials)
  to api.get<>() which carries the session cookie. 404 / 401 /
  network errors all silently hide the download row.
- Removed the login-page mount entirely — pre-auth surfaces should
  never show this. Settings → Mobile app section keeps it for
  logged-in users.

Flutter unaffected: dio's Bearer interceptor already attaches the
token, and the polling only fires once the post-login shell mounts
the banner widget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:28:27 -04:00
bvandeusen 14d7678624 fix: CI lint errors after #397 commits
- flutter analyze: Riverpod 3 dropped StateProvider; replaced
  _dismissedVersionsProvider with a small Notifier<Set<String>>
  + NotifierProvider, mutating via an `add(version)` method.
  Public API (shouldShowUpdateBannerProvider, dismissUpdateProvider)
  unchanged.
- golangci errcheck: defer f.Close() now wrapped in func() { _ = f.Close() }();
  os.Setenv calls in test helper switched to t.Setenv (cleaner — auto-restores
  on test cleanup, no manual Unsetenv needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:01:43 -04:00
bvandeusen a2bea8601a feat(server): /api/client/version + /api/client/apk endpoints (#397 phase 1)
Phase 1 of the in-app update flow — server side. Endpoints serve the
bundled Android APK + sidecar version file from /app/client/.
Returns 404 gracefully when files aren't present, so dev environments
and pre-CI-wiring images degrade cleanly to "no update available."

- internal/api/client_assets.go: handleClientVersion + handleClientAPK.
  Both unauthenticated (matches /healthz) so install flow doesn't
  depend on a live session. APK served with proper
  application/vnd.android.package-archive Content-Type +
  http.ServeContent so Range requests work for resumable downloads
  on flaky networks.
- Path resolves to /app/client/ by default; MINSTREL_CLIENT_APK_DIR
  env var overrides for dev.
- Dockerfile creates /app/client/ + commented COPY hooks for the CI
  sequencing phase.
- Tests cover all four states: missing apk, apk-but-no-version,
  both present (200 with correct shape), apk stream (200 with
  correct Content-Type + body bytes).

Phases 2 (Flutter client provider + banner + install intent + Android
manifest changes) and 3 (CI sequencing to bake the APK into the image)
land in follow-up commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:35:24 -04:00
bvandeusen 27f123f7d9 fix(server,flutter): sync wire format + systemVariant column (#357)
Two fixes in one commit because they're entangled — the systemVariant
work would have been theater otherwise.

## The wire-format bug

/api/library/sync was emitting PascalCase JSON for artist / album /
track / playlist upserts (raw json.Marshal of sqlc-generated structs
with no JSON tags — sqlc.yaml: emit_json_tags=false). Flutter's
sync_controller _*FromJson reads snake_case keys, so all metadata
sync rows landed in drift with empty strings / zero ints.

The like_track / like_album / like_artist / playlist_track entities
work because they're hand-built `map[string]string` payloads with
snake_case keys — they sidestepped the bug. The 4 raw-marshal
entities did not.

Existing sync test caught zero of this — it asserts on len(upserts)
not field shape.

Fix: server-side view structs in library_sync_views.go with proper
JSON tags + pgtype-flattening (UUID → 8-4-4-4-12 hex string,
Date → "2006-01-02"). Mirrors the playlistRowView pattern from
/api/playlists. New library_sync_views_test.go pins the wire keys
so future field-name drift breaks loud.

## systemVariant column (closes #357 plan C v1 limitation)

playlistSyncView now carries `system_variant` server → wire.

Flutter drift schema bumped from 1 → 2 with onUpgrade adding the
`systemVariant TEXT NULL` column to cached_playlists. Cursor reset
to 0 in the migration so existing rows refresh with the new field
on the next sync.

playlistsListProvider now filters locally by systemVariant:
- kind='user'   → systemVariant IS NULL  (the add-to-playlist sheet's intent)
- kind='system' → systemVariant IS NOT NULL
- kind='all'    → no filter

Closes the documented v1 limitation where the add-to-playlist sheet
showed system playlists alongside user-created ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 18:47:30 -04:00
bvandeusen 8d5c90e0ed fix(server): wrap defer tx.Rollback for errcheck + gofmt -s alignment
golangci-lint surfaced both on 9c7dec6:
- errcheck on bare 'defer tx.Rollback(ctx)' in 2 test files
- gofmt -s wanted tighter map-key alignment in library_sync.go +
  library_sync_test.go (auto-fixed by 'gofmt -s -w')

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:47:25 -04:00
bvandeusen 6fb6729beb feat(server): playlists service writes library_changes
Five sites wired:

- Create / Update / Delete playlist: pool-bound LogChange after success
- AppendTracks: tx-bound LogChange per inserted playlist_track row
- RemoveTrack: tx-bound LogChange after the delete + lookup the
  track_id pre-delete so the composite key is still resolvable

The two tx-bound paths (AppendTracks, RemoveTrack) treat LogChange as
required — failure rolls back the playlist mutation too. The pool-bound
paths Warn on failure to match the scanner / likes pattern (mutation
already committed; missed log row recovers on next mutation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:41:59 -04:00
bvandeusen e0c5789cee feat(server): like/unlike handlers write library_changes
All 6 like/unlike sites (track/album/artist × like/unlike) now emit a
library_changes row via the shared logLikeChange helper. Best-effort:
LogChange failures Warn but don't fail the HTTP response — a missed
log row is recovered at the next mutation on the same entity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:40:56 -04:00
bvandeusen 0fa7dc7982 feat(server): scanner + DeleteTrackFile write library_changes
Wires sync.LogChange into the library mutation sites so /api/library/sync
reflects upserts and deletes.

Architectural pivot: LogChange's signature is now (ctx, dbq.DBTX, ...) so
it works with both *pgxpool.Pool and pgx.Tx. The scanner doesn't run
mutations in explicit transactions, so it pool-binds; delete.go matches.
Tx-bound callers (likes/playlists in subsequent commits) keep atomicity.

Also: sync.FormatUUID centralizes the pgtype.UUID → canonical string
conversion that both the scanner and the sync handler need; library_sync.go
now uses it instead of a local copy.

Best-effort logging on scanner failures (Warn, don't fail the scan): a
LogChange error after a successful upsert is rare and self-healing — the
next scan that touches the entity re-emits the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:39:58 -04:00
bvandeusen 6bd8a15c7a feat(server): mount /api/library/sync route
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:46 -04:00
bvandeusen 9bf4b504b3 feat(server): GET /api/library/sync endpoint
Returns batched upserts + deletes since the supplied cursor. Empty cursor
returns full snapshot; subsequent calls pull deltas. Per-user entities
(likes, playlists) are scoped to the authed user. Composite-key entities
(likes, playlist_tracks) use stable string ids encoded by sync.EncodeLikeID
/ sync.EncodePlaylistTrackID.

Behavior:
  204 No Content - no changes since cursor
  200 OK         - JSON syncResponse {cursor, upserts, deletes}
  410 Gone       - cursor older than oldest log row; client must reset
  401 / 500      - standard envelope errors

Adds sqlc queries GetAlbumsByIDs, GetTracksByIDs, GetPlaylistsByIDs to
mirror the existing GetArtistsByIDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:26 -04:00
bvandeusen 3959c85111 feat(server): sync.LogChange helper + EntityType/Op constants
New package internal/sync. LogChange writes a library_changes row inside
the supplied tx. Encode helpers produce stable composite ids for like_*
and playlist_track entries. Subsequent commits wire LogChange into the
scanner / likes / playlists services.

Also: dbtest.dataTables now includes library_changes so test isolation
holds across runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:29:51 -04:00
bvandeusen cb35133843 feat(server): library_changes log table + sqlc queries
Append-only change log for library entities. Every mutation on
artists/albums/tracks/likes/playlists/playlist_tracks will write a row
in the same transaction as the mutation itself (wired in subsequent
commits). Powers the Flutter delta-sync endpoint (#357).

- 0025_library_changes migration (up + down)
- internal/db/queries/library_changes.sql (Insert, GetSince, MaxCursor, MinCursor)
- regenerated dbq from sqlc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:28:26 -04:00
bvandeusen 22f03a5fe8 test(server/admin): cover new test endpoint response shape
- Updates the happy-path test stub to handle the three list endpoints
  Lidarr exposes (qualityprofile / metadataprofile / rootfolder) and
  asserts profiles + folders make it through to the response.
- Adds a partial-failure case where one list endpoint 5xxs; verifies
  ok=true, the failing list is omitted, and list_errors carries its
  bucket code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:47 -04:00
bvandeusen 0bc17a85fb feat(server/admin): test endpoint returns profiles + folders on success
Extends POST /api/admin/lidarr/test response to include quality_profiles,
metadata_profiles, root_folders, and an optional list_errors map when the
Lidarr connection succeeds. The three list fetches run in parallel after
the ping; any per-list failure goes into list_errors so the response can
still report ok=true with whatever data did come back. Failed-ping
response shape is unchanged.

This lets /admin/integrations populate its dropdowns on a single
round-trip during first-time setup, fixing the chicken-and-egg where
the dropdowns previously gated on cfg.Enabled (which can't be true
until the first save, which itself needs non-zero defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:07:15 -04:00
bvandeusen c4cccea775 refactor(server): remove bootstrap admin path
The bootstrap-admin-from-env-vars flow was a holdover from before
self-registration could create the first admin. Now that handleRegister
+ CreateUserFirstAdminRace promotes the first user to register on an
empty users table (verified shipped in v2026.05.08.2 / #376), the
bootstrap path is just a second source of truth that confuses operators
(and leaves an "admin" account in the DB that nobody asked for).

Removes:
- internal/auth/bootstrap.go + its test
- The auth.Bootstrap call from cmd/minstrel/main.go
- AuthConfig + AdminBootstrapConfig structs from config
- MINSTREL_AUTH_ADMIN_USERNAME / _PASSWORD env reads + their test
- The auth: block from config.example.yaml
- Bootstrap-related comments in docker-compose.yml + README.md

The README quickstart now points operators at /register on first start
instead of "watch the logs for a one-time password."

Existing instances keep their bootstrap-admin row in the DB; operators
who want it gone can register a new admin via /register, promote them
in /admin/users, then delete the old bootstrap user (last-admin guard
will require the new admin to be promoted first). No migration needed.

Recovery story for forgotten admin passwords now hinges on Fable #321
(admin password reset CLI) — currently the only path back in if no
other admin exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:14:33 -04:00
bvandeusen 4f669c26e9 fix(coverart/test): set MinInterval in rate-limit-serialization tests 2026-05-08 13:21:45 -04:00
bvandeusen b5a138bb27 fix(server): repair recursive withUser, gofmt 2 files (golangci-lint) 2026-05-08 12:05:35 -04:00
bvandeusen 6a444334ea fix(server/api/test): drop unused 'context' imports A3 sed left behind 2026-05-08 11:29:36 -04:00
bvandeusen 923b8286ee fix(server/api/test): retype callRadio user param to dbq.User for withUser helper 2026-05-08 11:05:25 -04:00
bvandeusen 4ce7be4296 fix(server): repair go vet failures from A1+A2 cleanup (orphan imports + needle deref) 2026-05-08 10:46:43 -04:00
bvandeusen c81491164a refactor(server/api/test): withUser helper; migrate 51 context-value sites (A3) 2026-05-08 10:40:03 -04:00
bvandeusen bf90a3a868 refactor(server/api): migrate admin_smtp/admin_tracks + playlist refresh preludes (A2 6/N) 2026-05-08 10:18:35 -04:00
bvandeusen 9dd5da514c refactor(server/api): migrate me/* + events preludes (A2 5/N) 2026-05-08 08:44:50 -04:00
bvandeusen 79c3ec3659 refactor(server/api): migrate library/suggestions/radio/home preludes (A2 4/N) 2026-05-08 08:43:36 -04:00
bvandeusen dde3dd2804 refactor(server/api): migrate requests/quarantine/me_token/listenbrainz/admin_invites preludes (A2 3/N) 2026-05-08 08:26:43 -04:00
bvandeusen 8cd8825efc refactor(server/api): migrate likes.go + playlists.go preludes (A2 2/N) 2026-05-08 08:20:33 -04:00
bvandeusen c6f5706535 refactor(server/api): prelude helpers (requireUser/requireURLUUID/decodeBody); migrate admin_users.go (A2 1/N) 2026-05-08 08:17:35 -04:00
bvandeusen bffa397250 refactor(server/sql): unify *ForUser track queries via nullable user_id (A1) 2026-05-08 08:15:19 -04:00
bvandeusen 1ad966b7f3 fix(coverart/test): use newHTTPClient in provider test fixtures 2026-05-08 07:58:00 -04:00
bvandeusen 038d737ae7 refactor(coverart/theaudiodb): delegate HTTP plumbing to httpClient (C1 3/3) 2026-05-08 07:54:45 -04:00
bvandeusen 3d5e816c5f refactor(coverart/deezer): delegate HTTP plumbing to httpClient (C1 2/3) 2026-05-08 07:53:09 -04:00
bvandeusen 13680221de refactor(coverart/lastfm): delegate HTTP plumbing to httpClient (C1 1/3) 2026-05-08 07:52:28 -04:00
bvandeusen b4edda5518 feat(coverart): shared httpClient for provider HTTP plumbing (C1 prep) 2026-05-08 07:51:33 -04:00
bvandeusen 92813ba1bb fix(server/api): silence unused-parameter lint in resolve_test fakes 2026-05-07 22:18:40 -04:00
bvandeusen 965df28127 refactor(server): audit.WriteOrLog wrapper; migrate 13 sites (T5)
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.
2026-05-07 21:33:14 -04:00
bvandeusen 49218e5231 refactor(server/api): resolveByID helper for parseUUID->fetch->404 (T4) 2026-05-07 21:28:20 -04:00
bvandeusen 754a7955cc refactor(server): final writeErr migration + ErrNotFound aliases (T3c) 2026-05-07 21:19:35 -04:00
bvandeusen 91f4f5c18a refactor(server/api): migrate library/media handlers to writeErr(err) (T3b) 2026-05-07 20:48:20 -04:00
bvandeusen e382b0d718 refactor(server/api): migrate auth/me handlers to writeErr(err) (T3a) 2026-05-07 20:25:26 -04:00
bvandeusen 58766dbebe fix(server/api): preserve 500-class messages via apierror.InternalMsg
The PR1-T2 admin handler migration replaced bespoke 500-class messages
("lookup failed", "refetch failed", "trigger failed", "bump failed",
"remove failed", "update failed", "schedule row missing", "re-read
failed", "read failed") with apierror.Internal(err), which forces the
wire Message to "internal server error". That violates the PR1
contract that errEnvelope{Code, Message} must remain byte-identical
for existing clients.

Add apierror.InternalMsg(message, cause) — a 500-class constructor
that preserves the call site's user-facing message while keeping the
cause attached for logging and errors.Is. Re-migrate the 12 admin
sites whose pre-1cc7eb6 source had a non-empty bespoke Message so
they restore the original wire string. Sites whose original Message
was empty stay on Internal(err) (humanization to "internal server
error" is a tolerable improvement, not a regression).

admin_smtp.go's send_failed site (line 129) was already preserved
via a raw &apierror.Error literal in 1cc7eb6 and needs no fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:10:18 -04:00
bvandeusen 1cc7eb6cad refactor(server/api): writeErr accepts error; migrate admin handlers (1/3)
Rewrite writeErr(w, err) to wrap *apierror.Error via apierror.From,
preserving the existing {"error": {"code", "message"}} wire envelope.
Add writeErrWithLog helper for 500-class errors that need an operator
log line. Migrate all 13 admin_*.go handler files (~76 call sites) to
the new signature; T3 will sweep the remaining api package.

The old 4-arg writeErr is removed, so non-admin call sites in
internal/api will not compile until T3 lands. This is by design — T2
and T3 are paired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:48:25 -04:00
bvandeusen c919650606 feat(server/apierror): typed Error + sentinels for /api/* envelope 2026-05-07 19:31:02 -04:00
bvandeusen 045ba6975f feat(server/m7-user-mgmt): U2.5 auto-approve handler wiring
Wires the auto_approve_requests user flag into the request creation
flow. When the flag is set (admin enabled it via /admin/users from
U2-T3), POST /api/requests transitions the just-created pending row
through Service.Approve inline, which dispatches to Lidarr the same
way a manual admin approve does.

Failures inside Approve (ErrLidarrDisabled, ErrDefaultsIncomplete,
network/Lidarr errors) leave the row pending — same fallback as
manual approve hitting the same path. The user gets a 201 either
way; admin can still resolve the row in /admin/requests if it
stayed pending. The handler logs the auto-approve failure with
user_id + request_id for observability.

The "actor" id passed to Approve is the user's own ID — they're
acting under the privilege the admin granted them via the toggle.
The trail of "admin set the flag" already lives in audit_log
(ActionAutoApproveToggle from U2-T2). Adding a per-auto-approval
audit entry is a future enhancement; for v1 the toggle audit plus
the request row's status transition is enough.

Test verifies the Lidarr-disabled fallback contract: a user with
auto_approve=true and Lidarr unavailable still gets 201 Created
with status=pending (no crash, no 500). The "auto-approve actually
succeeds" test path requires a Lidarr stub; deferred until a
broader Lidarr test fixture lands.
2026-05-07 18:46:01 -04:00
bvandeusen 042f9919fe fix(server/playlists): redistributeSlots double-counting + tieBreakHash avalanche
Two real algorithm bugs in F-T1's For-You composition + Discover
allocator. Both surfaced as failing unit tests under go test -race.

1. redistributeSlots was re-redistributing a bucket's full deficit
   on every pass instead of just the residual. The loop computed
   `deficit = b.want - final[i]` each iteration, but final[i] for
   a deficit bucket never increases (its supply is exhausted), so
   pass N saw the same deficit as pass N-1 and kept shoveling it
   to peers. For [want:40 avail:100, want:30 avail:0, want:30 avail:100],
   four passes pushed cross-user's deficit into dormant+random four
   times each, hitting the 100-slot clamp at the end and producing
   [50, 0, 50] instead of the spec'd [55, 0, 45].

   Fix: track per-source `redistributed[i]` and subtract it from the
   deficit each pass. Multi-pass behavior still works for the case
   where a peer's supply runs out mid-distribution.

2. tieBreakHash used FNV-1a 64-bit with trackID + dateStr appended.
   For dateStrs differing only in the last character ("2026-05-07"
   vs "2026-05-08"), the FNV state diverged only in low bits at the
   final byte; multiplication by FNV_prime propagates upward but the
   relative ordering of 60 small candidate UUIDs (which differ only
   in their last byte) ended up identical across the two dates. The
   For-You head/tail test asserted that the tail's first 5 should
   change across days; it didn't.

   Fix: switch to SHA-256 truncated to 8 bytes. SHA-256 has full
   avalanche, so any single-bit input change roughly half-flips the
   output bits and meaningfully reorders.

The hash isn't security-load-bearing; we just need strong avalanche
for tiny dateStr deltas. Determinism (same inputs → same output) is
preserved.
2026-05-07 18:33:06 -04:00
bvandeusen e4ebc71162 fix(ci): mailer + audit lint + settings Save button collision
Three Go lint hits + two web test failures, all from the U3 push.

- internal/mailer/mailer.go: SentEmail.HtmlBody → HTMLBody (Go's
  initialism convention; revive flagged); FakeSender.Send unused
  ctx parameter renamed to _.
- internal/audit/audit_test.go: removed dead validUUID helper. It
  was added speculatively in U1-T1 and never called by any test.
  pgtype import stays — other tests use it.
- web/src/routes/settings/settings.test.ts: existing ListenBrainz
  tests queried `getByRole('button', { name: /save/i })` which
  worked when the page had only one Save button. The new Profile
  card adds a "Save profile" button that also matches the regex,
  triggering "found multiple elements". Anchored to /^save$/i for
  exact-match. The newer Save profile tests still use
  /save profile/i which is unique.

This is the same shape of test-fixture-lag I owe an answer for: I
landed new content that broke an existing test, and the existing
test had a too-loose selector. The right fix is to tighten the old
selector now (this commit) and to flag this pattern — selectors
that regex-match by partial words — as a candidate for the DRY
pass / test-utils consolidation.
2026-05-07 18:15:39 -04:00
bvandeusen 444144038a fix(ci): library_test Mount arg + 4 web test selectors + forgot-pw catch
Five fixes in one commit, all from the U1+U2+U3 push:

1. internal/api/library_test.go's Mount call wasn't updated when
   U3-T4 added the mailer.Sender param. 17 args, sig wants 18.
   Adds a trailing nil for the mailer. Same shape of test-fixture
   lag the project has hit before — flagged as a recurring pattern
   for the DRY-pass slice.

2. web/src/routes/settings/settings.test.ts mocked $lib/api/me with
   `getAPIToken: vi.fn()` (no return value). The new /settings API
   Token card's $effect calls `getAPIToken().then(...)` which threw
   "Cannot read properties of undefined (reading 'then')" on every
   ListenBrainz test. Default the mock to mockResolvedValue so any
   test that doesn't override gets a valid resolution.

3. web/src/routes/admin/users/users.test.ts queried row buttons by
   /^Delete$/ but T3 added per-user aria-labels ("Delete alice"),
   so the row buttons' accessible name is no longer "Delete". The
   modal's confirm button has no aria-label so its name IS "Delete"
   exactly. Tests now click by per-user aria-label first, then by
   /^Delete$/ for the modal confirm.

4. Same file: /Set password/i regex matched both the dialog's
   submit button AND the row's "Reset password for ..." buttons
   (because regex `Set` matches "ReSet" case-insensitively). Switched
   to /^Set password$/ exact-match.

5. web/src/routes/reset-password/[token]/reset-password.test.ts had
   /New password/i which matched both the "New password" and
   "Confirm new password" labels. Switched to exact-match string
   selectors.

6. web/src/routes/forgot-password/+page.svelte's onSubmit handler
   had no catch — a rejected forgotPassword() bubbled as an
   unhandled rejection in vitest. Added a silent catch since the
   page intentionally shows the same success message regardless of
   outcome (no-enumeration posture).
2026-05-07 18:05:30 -04:00
bvandeusen 9ed576a029 fix(ci): pgconn import path + reset-password token type + SMTP label
Three CI fixes from the U1+U2+U3 push:

- internal/api/admin_users.go imported the standalone
  github.com/jackc/pgconn — that module isn't in go.sum and
  go vet caught it. Switch to github.com/jackc/pgx/v5/pgconn
  (the path used elsewhere in the package, e.g. auth_register.go
  and me_profile.go).

- /reset-password/[token] dereferenced page.params.token without
  the undefined narrowing svelte-kit's typegen requires. Coalesce
  to '' on read and reject empty token at submit time with a
  clear error.

- /admin/integrations SMTP card had a label without an associated
  control as the TLS row's leading column header. Switched to a
  span — the actual checkbox lives in the wrapping label below it.
2026-05-07 17:48:44 -04:00
bvandeusen cbe838cbe3 feat(server/m7-user-mgmt): forgot + reset password endpoints (U3)
POST /api/auth/forgot-password and POST /api/auth/reset-password.

Forgot-password ALWAYS returns 200 with empty JSON to prevent
enumeration of registered emails. Side effect: when email matches
a user with email-on-file, generates a 32-byte hex token (24h
TTL), inserts into password_resets, and sends the reset email via
the mailer. Mailer failures are logged (not surfaced) and the
audit log carries metadata.email_match for operator visibility.

Reset-password atomically claims the token via UsePasswordReset
(:execrows; concurrent calls can't both succeed). On rows=1,
hashes the new password and writes via ChangeUserPassword.
Returns 204 on success, 400 invalid_token on stale/used/missing
tokens, 400 password_too_short for short passwords. Audits
ActionPasswordResetByEmail.

Wires the mailer.Sender into the handlers struct via Mount;
production sender (NewSMTPSender) constructed in server.Router();
tests inject FakeSender via testHandlers default. The reset
URL embedded in the email is derived from r.Host (no PublicURL
config setting in v1; self-hosted operators see their own
hostname).

Tests cover happy-path send + token-row insertion, unknown email
returns 200 with no send, mailer failure still returns 200, reset
happy path verifies bcrypt match + used_at set, already-used
token 400, expired token 400, short password 400, bogus token 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:49:30 -04:00
bvandeusen aa67a09b80 feat(server/m7-user-mgmt): mailer package + SMTP config endpoints (U3)
New internal/mailer/ package:

- Sender interface with two impls: SMTPSender (production, reads
  smtp_config at send time so admin edits apply without restart;
  uses stdlib net/smtp + STARTTLS) and FakeSender (test/concurrent-
  safe call recorder).
- Embedded text + HTML templates for the password reset email,
  rendered via stdlib text/template + html/template. The HTML
  uses the FabledSword forest-teal accent color.
- ErrNotConfigured surfaces when smtp_config.enabled is false or
  required fields are empty; callers like the future forgot-password
  handler will treat this as "log and pretend success" to avoid
  user-enumeration leaks.

Three admin endpoints under RequireAdmin:

- GET /api/admin/smtp-config — returns the singleton; password
  field is masked ("***" or "").
- PUT /api/admin/smtp-config — updates settings. Validates
  host + from_address are non-empty when enabled=true. Empty
  password in the request preserves the stored value (so the
  operator doesn't have to re-enter it on every save).
- POST /api/admin/smtp-config/test — sends a real test email to
  the calling admin's email. 400 if admin has no email; 500 with
  the error message on send failure (so the operator can debug
  config without grep-then-trace through logs).

Tests cover the password-mask, password-preservation-on-empty,
enabled-requires-host-and-from validation, and the no-email-on-file
rejection. Mailer unit tests cover the fake recorder and template
rendering. The "real SMTP send" path needs a live server and isn't
covered in CI; the FakeSender covers that role for downstream tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:43:44 -04:00
bvandeusen aa73c93f85 feat(server/m7-user-mgmt): self-service /me endpoints (U3)
Four authenticated endpoints for the user's own account:

- PUT /api/me/password — change own password. Caller must
  supply current_password (verified via bcrypt). Distinct from
  admin-driven reset (which doesn't require knowing the old).
  Audits ActionPasswordChangeSelf.

- PUT /api/me/profile — set display_name + email. Both fields
  are nullable; empty string clears, omitted leaves unchanged.
  Email is lowercased before store + format-validated. Unique
  violation → 409 email_taken.

- GET  /api/me/api-token — returns current API token (for
  copy-paste into Subsonic clients).
- POST /api/me/api-token — regenerates token. Old one stops
  working immediately. Audits ActionTokenRegenerate.

All four use the existing RequireUser middleware on the authed
sub-router; audit writes are best-effort (logged on failure).

Tests cover happy paths, wrong-password 401, password-too-short
400, email-invalid 400, email-taken 409, clear-by-empty-string,
omit-leaves-unchanged, token GET + regen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 12:38:48 -04:00