Compare commits

..

119 Commits

Author SHA1 Message Date
5b238b3f54 docs: align historical plans/specs with renamed identifiers (roundTrip -> execute, log schema)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:24:05 +02:00
5ebb0f756d feat(frontend): pathname-addressable views, deep links survive login, config page layout
Every view (/activities, /analysis, /plan, /profile, /config) is now
driven by the URL -- one pushState/popstate navigate() replaces the
tab/profile/config state flags, so all views deep-link, survive reload,
and honor Back/Forward. A logged-out visit to any path is stashed by
LoginGate and restored (replaceState) on the first render after the
OIDC round-trip, which otherwise always lands on '/'. The config page
gets Profile-style card spacing and a wider card so environment values
fit on one line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:23:05 +02:00
49ebd289a4 chore: untrack Garmin token store, ignore token/db-sidecar/IDE files
backend/.garmin (live Garmin OAuth tokens) was accidentally swept into
1ba7724 by a broad git add; stop tracking it and ignore it, along with
SQLite -shm/-wal sidecars and the rest of .idea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:21:03 +02:00
10cdc4176a refactor(log): split location into package/file/class/method, auto-derived
Per the log schema: 'package' is the Go package (absent on
Python-emitted lines), 'file' the Go/Python source basename, 'class' the
Go receiver or Python class (omitted when there is none -- free
functions no longer masquerade under a package-as-class), 'method' the
emitting function. applog.App() now takes no arguments and derives all
of it from runtime.Caller, so labels can never drift from the code; the
manual http/wrapper emitters and forwarded wrapper.py lines (file=
wrapper.py, no package) carry the same fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:59:48 +02:00
6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
78c494affb fix(garmin): startup tokenstore resume recovers after its 10s timeout
The background login thread now flips _auth_state to authenticated
itself when a slow tokenstore resume succeeds after the 10s wait --
previously its result landed in an abandoned private queue and the
docstring's promised late recovery never happened, leaving every
subsequent call failing until an explicit authenticate. Guarded to only
ever transition from unauthenticated, so an explicit authenticate/MFA
flow that took over meanwhile is never clobbered; the timeout branch no
longer re-writes the state (a redundant write that could race a success
at the boundary). Structured-log messages reworked alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:17:24 +02:00
0e6cf00dba refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout
All registry keys must exist as rows in the config table: main seeds
missing keys with their defaults at startup, LoadApp fails fast on a
missing key, and the code-side fallback const/helper for the onboarding
setup timeout is gone -- the value rides in SessionConfig.SetupTimeout.
The key is renamed session.idle_timeout -> session.setup_timeout, and
the /config page's 'overridden' now means 'differs from the default'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:17:24 +02:00
8c9285f33c fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import
session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:44:37 +02:00
dcbb1d8bb0 feat(garmin): all-JSON wrapper protocol and log stream
Error responses from wrapper.py now carry error_type and traceback in
the protocol itself, logged as attrs on the Go side's per-call record.
wrapper.py's free-text stderr debug prints become JSON log lines
({level,msg,...attrs}); client.go parses that stream and re-emits it
through the application logger (level-mapped, source=garmin-wrapper),
wrapping any non-JSON line instead of passing it through raw. The
wrapper also survives malformed request lines and unserializable
results with JSON responses instead of crashing with a raw traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:42:25 +02:00
431315bac3 refactor(log): replace stdlib log with the application JSON logger
Every log.Printf/Fatalf becomes a structured slog call through
internal/log: request handlers use the request-scoped logger
(applog.FromContext, carrying request_id), startup failures exit via a
fatal() helper that still emits JSON, and the seedsample/dumpschema CLIs
bootstrap the same JSON logger. Stale client_test expectations aligned
with the refactored wrapper-call logging (message casing, result attr,
ERROR level).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:33:26 +02:00
042579a396 refactor(store): single account name on users; rename profile/laps tables
users.display_name becomes users.name and is now the only human-facing
name -- profiles.name is dropped (it duplicated the account name; the
Profile page's Name field now edits users.name through PUT /api/profile,
which carries Name alongside the profiles columns). The profile table
becomes profiles and laps becomes activity_laps, homogeneous with
activity_samples. Onboarding pre-fills the display name from the OIDC
claim's name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:11:23 +02:00
e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00
19c9aecdeb docs(skill): update renamed env vars in geniusrun-dev skill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:46:10 +02:00
bee3d7e493 feat(frontend): /config page + icon-only header (profile/settings/logout)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:20:02 +02:00
d45489714d feat(api): GET/PUT /api/config serving app + env configuration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:16:26 +02:00
e6cb21c65a feat(config): app-config registry, env-var renames, wire session.duration from DB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:11:56 +02:00
f6712497ba feat(store): config table for instance-global app-config overrides
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:06:22 +02:00
8508e89ad7 chore: onboarding/MFA-modal WIP, dev-workflow guidance, ideas updates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
27af77418e feat(store): default backfill horizon to 90 days
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
a2f24273ce feat(frontend): error banners persist until manually dismissed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
a394bbb770 refactor(api): merge review-queue into /api/activities, rename resolve to assign
Removes the unused GET /api/activities list/detail endpoints, moves the
review-queue list and resolve/unlock/unassign actions under
/api/activities, renames resolve to assign end-to-end, and drops the
now-unused store.ReviewQueue helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
eb24dcd89d docs: spec for application vs environment configuration
Design for the 'configuration' backlog idea: instance-global key/value
config table (overrides only, cold reload), env-var renames, /api/config
GET/PUT, and the /config page with header icon buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:47:57 +02:00
c40f6634f2 test(store): cover SetActivityWorkoutNotFound adversarially for user isolation
SetActivityWorkoutNotFound is a per-user-scoped store method but never got
the adversarial cross-user coverage CLAUDE.md's testing conventions require
for such features. Extend the existing isolation test with a mismatched-user
call (must no-op) and a correct-user call (must exclude the activity from
that user's own ActivitiesMissingWorkout/count without touching the other
user's pending activity).
2026-07-27 19:16:43 +02:00
6f6d1ea5e0 fix(sync): stop retrying a workout Garmin confirms is gone
fillPendingWorkouts previously treated every get_workout_by_id
failure identically -- log it, leave workout_raw_json null, retry
next sync -- which is correct for a transient failure but means a
definitive 404 (the workout deleted on Garmin's side after being
linked to an activity) got silently retried forever, with the only
symptom being an unexplained, permanent "1 more workout pending"
nudge. Now checks errors.Is(err, garmin.ErrNotFound) and, only for
that case, calls SetActivityWorkoutNotFound instead of retrying.
2026-07-27 19:03:09 +02:00
7d371219b7 feat(store): add workout_not_found_at, exclude it from missing-workout queries
A confirmed-404 workout must stop being retried forever, but
workout_raw_json should never be fabricated -- it stays null exactly
as it does for "not yet fetched." workout_not_found_at is the
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
now check for, mirroring the existing pattern of the other _fetched_at
columns. UpsertActivity's ON CONFLICT clause already never touches
these columns, so the marker persists across every later re-sync.
2026-07-27 18:58:21 +02:00
c6e2c5c00e feat(garmin): expose ErrNotFound for a wrapper-reported 404
roundTrip now wraps the returned error with the new ErrNotFound
sentinel whenever the wrapper's response set not_found (Task 1),
letting any Client method's caller distinguish a definitive 404 from
a transient failure via errors.Is, regardless of which garminconnect
method was called.
2026-07-27 18:53:59 +02:00
1d878e0f46 feat(garmin): mark a wrapper 404 with not_found in the error response
garminconnect's own GarminConnectNotFoundError already exists
specifically for this (its docstring: "so callers can now catch a
missing resource specifically, e.g. deleting an already-deleted
workout"), raised by connectapi() for any real HTTP 404. dispatch()
now surfaces that distinction as an extra not_found: true field
alongside the existing error string, so internal/garmin/client.go
(next commit) can tell a definitive 404 apart from a transient
failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:50:43 +02:00
b5cd47c219 docs: add implementation plan for permanently-missing-workout handling
4 tasks: mark a wrapper-level 404 as not_found, expose it as
garmin.ErrNotFound in the Go client, add a workout_not_found_at
column + store query updates, then have fillPendingWorkouts stop
retrying a confirmed-404 workout instead of retrying forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:45:11 +02:00
67f4ec27ab docs: add design spec for permanently-missing-workout handling
A workout deleted on Garmin's side after being linked to an activity
404s forever on get_workout_by_id, and today's fillPendingWorkouts
retries it every sync indefinitely with no user-visible feedback --
just a perpetual "1 more workout pending" nudge. Distinguishes a
definitive 404 (via garminconnect's own GarminConnectNotFoundError)
from a transient failure, and stops retrying the former.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:00:38 +02:00
fe344867c0 fix(frontend): show a banner when LoginGate can't reach the backend
LoginGate's initial session check swallowed every failure into the
same "unauthenticated" branch, so a genuinely unreachable backend
looked identical to a normal logged-out state -- the one screen in
the app where "backend not here" showed no feedback at all, since
every banner-migrated component only renders after authentication.

api/client.ts's request() now throws a dedicated NetworkError (a
distinct type, not just a distinguishable message) for a fetch()
failure specifically, so LoginGate.tsx's catch can tell that apart
from a real 401 via instanceof and show a banner before falling
through to "unauthenticated" either way.

Verified with a real headless-browser run against the dev server
with no backend: the banner renders correctly and coexists with the
existing ?auth_error= banner without conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 11:00:00 +02:00
0e3295b746 fix(auth): stop flipLastChar's tamper tests from flaking
Corrupting the true last character of a JWT's base64url-encoded
HMAC-SHA256 signature is unreliable: that character encodes only 4
real bits plus 2 unused padding bits, and Go's encoding/base64
ignores those padding bits by default -- about 1 in 4 replacement
characters decode to byte-identical signature bytes, so the
"tampered" cookie still verifies and the test spuriously passes.
Corrupting the second-to-last character instead is deterministic,
since HMAC-SHA256's fixed 32-byte digest length means that position
is always fully significant.

Confirmed via 20 repeated runs (previously ~1/3 failure rate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:59:49 +02:00
f9b1d454bc fix(frontend): dedupe identical banners in banner.ts
Multiple independent requests that fail with the same message (e.g.
Activities.tsx's mount effect firing loadFirstPage/listWorkoutKinds/getProfile
separately) each call showError() independently, previously stacking
2-3 textually-identical banners for a single root cause (backend down,
brief network blip). show() now skips adding a banner if one with the
same severity+message is already visible, and skips scheduling a
redundant auto-dismiss timer for it -- the original banner's timer
keeps running untouched.
2026-07-27 10:34:57 +02:00
83a59d0fdd chore(frontend): remove the now-dead .error CSS class
Every inline error paragraph that used it has been migrated to the
banner system across the preceding tasks.
2026-07-27 10:20:14 +02:00
3cf5d4b747 refactor(frontend): split OnboardingWizard's validation vs real failures
Field-validation messages ("please fill this in") stay local and
inline. Real failures (Garmin login rejected, MFA rejected, complete()
failing) now call showError. The complete()-failure Retry button's
visibility switches from the error text's presence to a new
completeFailed boolean, since the error text itself now lives only in
the (transient) banner.
2026-07-27 10:11:18 +02:00
273731442f refactor(frontend): migrate LoginGate.tsx's auth_error to the banner system 2026-07-27 10:07:00 +02:00
7e960e3ce1 refactor(frontend): migrate TrainingTypesCard.tsx to the banner system 2026-07-27 10:01:56 +02:00
687dc2bca2 refactor(frontend): migrate Analysis.tsx to the banner system 2026-07-27 09:59:32 +02:00
668fb9efb8 refactor(frontend): migrate Activities.tsx to the banner system
Every load/classify/unlock failure now calls showError instead of
setting local error state; the inline error paragraph is removed.
2026-07-27 09:56:33 +02:00
940295510e refactor(frontend): migrate Profile.tsx to the banner system
Load/save/delete failures now call showError instead of setting local
error state. The profile-not-loaded-yet fallback gains a
profileLoadFailed boolean so it can still distinguish "loading" from
"failed to load" without the error text itself, which now lives only
in the (transient) banner.
2026-07-27 09:53:28 +02:00
a5fffff737 refactor(frontend): SyncModal reports its result via a banner, auto-closes
The modal is now purely the progress bar/spinner. The instant a poll
reports the sync finished, it builds one banner message (the
success/error line plus any pending-activities/pending-workouts
nudge as extra lines) and closes itself -- no more manual Close click
to dismiss a result the user already saw. The Close button stays as a
manual escape hatch for a still-running sync.
2026-07-27 09:47:53 +02:00
d48bbfd43e refactor(frontend): migrate GarminConnection.tsx to the banner system
Removes the local error state and its inline paragraph; every failure
(connect, MFA, sync trigger, reset-all) now calls showError directly.
2026-07-27 09:44:53 +02:00
a147f5cffa fix(frontend): show a friendly message when the backend is unreachable
request()'s fetch() call throwing (network down, connection refused,
offline) previously propagated the raw browser error text (e.g.
"TypeError: Failed to fetch"). Centralizing the friendly message here,
rather than in each call site, means every existing
catch((e) => showError(String(e))) pattern gets it for free.
2026-07-27 09:41:34 +02:00
aef58fd529 feat(frontend): add shared banner system (error/warning/notice/success)
A plain module-level singleton store (banner.ts) plus a single renderer
(BannerStack, mounted once above LoginGate in main.tsx) -- no React
Context, since this codebase has none today and a plain exported
function is callable from anywhere, including api/client.ts (a plain
module, not a component) in a later task. Nothing calls it yet; that's
each of this plan's remaining tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:37:24 +02:00
3bf11ae6bf docs: add implementation plan for modern error displays
11 tasks: the shared banner store + renderer, a friendlier
network-unreachable message in client.ts, then migrating every
existing inline error site (GarminConnection, SyncModal, Profile,
Activities, Analysis, TrainingTypesCard, LoginGate, OnboardingWizard)
to it, finishing with dead-CSS cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:31:57 +02:00
b2c705ce29 docs: add design spec for modern error displays
Replaces every page/component's ad-hoc inline error paragraph with a
shared, module-level banner store (red/orange/blue/green, stacking,
auto-dismiss + manual close) -- from docs/IDEAS.md's backlog item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:22:47 +02:00
6e01037832 fix(frontend): stop SyncModal polling once sync has finished
It polled /api/sync/status every 1.5s indefinitely even after
in_progress became false and the final result was already shown,
only stopping on unmount (clicking Close). Skip scheduling the next
poll once a fetched status reports the sync is no longer running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:38 +02:00
f7e7b68078 test(sync): cover FullSync's phase transitions end-to-end
TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases
only ever called FillPendingDetails directly, never exercising
FullSync's own setProgress(PhaseDiscovering, ...) / deferred
setProgress(PhaseIdle, ...) wiring around backfillCore/
incrementalSyncCore -- what handleSyncRun actually runs in production
and what SyncModal's "Discovering activities..." spinner depends on.

Add a mock.Client.Delay field (slept, ctx-cancellable, at the start of
GetActivities) so a test can give the discovering phase real
wall-clock duration, then add
TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle, which
runs FullSync in a goroutine and polls Progress() to confirm it
observes PhaseDiscovering mid-flight and PhaseIdle after completion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:30 +02:00
3a7f305221 docs(store): update stale SyncRun doc comment
Every run recorded today is SyncKindFull (the single-pass "Sync now"
action) -- "backfill"/"incremental" only ever appear in historical
rows predating that consolidation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:17 +02:00
0a7aa3a5e0 test(store): adversarially cover ActivitiesMissingWorkout isolation
Follow this file's existing pattern (two real provisioned users,
identical-shaped rows, assert on empty-result/unaffected-by rather
than just two independently-created rows not colliding) for the two
methods touched by the details_fetched_at gating fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:10 +02:00
1046e9f7a0 fix(store): gate ActivitiesMissingWorkout on details already fetched
fillPendingDetails and fillPendingWorkouts are each independently
LIMIT-bounded over different candidate sets, so on a large first
backfill a structured-workout activity could fall inside the workouts
pass's window while still outside the details pass's window.
fillActivityWorkout would then align workout targets against zero laps
(details/laps never written yet) and unconditionally mark
workout_raw_json non-NULL, permanently losing that activity's target
pace/HR bands once its real laps arrived later -- silently, with no
error.

Add details_fetched_at IS NOT NULL to ActivitiesMissingWorkout's and
CountActivitiesMissingWorkout's WHERE clause: an activity is only
eligible for a workout fetch once its laps actually exist to align
against. This still covers the intended retry case (an activity whose
workout fetch previously failed always has details_fetched_at already
set) while excluding never-yet-processed activities.

Rename/rewrite the store test to assert the corrected exclusion
(count=1, not 2) as an explicit regression test, and fix the
TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending API fixture,
which exercised the same buggy shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:02 +02:00
101ef639ab feat(frontend): replace inline sync progress with SyncModal
GarminConnection.tsx no longer polls sync status itself or renders any
inline "syncing.../last sync..." text -- SyncModal (previous commit) is
now the only place sync progress and results are shown. Auth-status
polling is simplified to a fixed interval now that it no longer needs
to speed up while a sync is running.
2026-07-27 08:36:31 +02:00
1fb9271aaa feat(frontend): add SyncModal component
Blocking overlay polling /api/sync/status, rendering by progress.Phase
(indeterminate spinner while discovering, a real progress bar for the
activities/workouts phases), then the final sync result (success count,
or the error message that was already fetched but never rendered
before) plus the existing pending-details/pending-workouts nudge. Never
auto-closes -- not yet wired into GarminConnection.tsx (next commit).
2026-07-27 08:31:01 +02:00
39aa121420 feat(api): reshape /api/sync/status for phase-aware progress
Replaces detail_fill_progress ({Done, Total}) with progress ({Phase,
Done, Total}), and adds workouts_pending (mirroring
activities_pending_details) from the new CountActivitiesMissingWorkout.
Frontend types updated to match -- GarminConnection.tsx is intentionally
left broken by this commit alone; it's fixed in the next commit that
adds SyncModal.
2026-07-27 08:27:34 +02:00
35d9933d1b feat(sync): split FillPendingDetails into activities/workouts phases
Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.

alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.

Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
2026-07-27 06:55:23 +02:00
32fdfc1c72 feat(store): add ActivitiesMissingWorkout/CountActivitiesMissingWorkout
Mirrors ActivitiesMissingDetails/CountActivitiesMissingDetails, but
queries workout_id IS NOT NULL AND workout_raw_json IS NULL --
independent of whether details/splits were ever fetched, since
workout_id is known at initial upsert time. This also fixes a latent
gap: an activity whose details were fetched successfully but whose
workout fetch failed in the same run previously had no way to ever be
retried, since ActivitiesMissingDetails stops returning it the moment
details_fetched_at/splits_fetched_at are set.
2026-07-27 06:49:44 +02:00
7204a48c1c refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
Both were only reachable via the periodic background sync loop removed
in 4d2cbe4 -- nothing in production calls them anymore, only tests did.
FullSync already calls backfillCore/incrementalSyncCore directly.
Rewrite the affected tests to call the *Core functions (still exported
within the package) instead, dropping the now-redundant standalone
SyncRun-recording assertion covered by TestFullSync_RecordsOneCombinedSyncRun.
2026-07-27 06:44:39 +02:00
9d65c50068 chore: ignore .worktrees/ directory
Local worktree scratch space for isolated feature branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 06:37:25 +02:00
aac9359b02 docs: add implementation plan for improved synchronization UX
Six tasks: remove dead Backfill/IncrementalSync wrappers, add
ActivitiesMissingWorkout/CountActivitiesMissingWorkout, split
FillPendingDetails into phase-aware activities/workouts passes,
reshape GET /api/sync/status, add SyncModal, wire it into
GarminConnection.tsx.
2026-07-26 23:05:43 +02:00
a828055c30 docs: fold dead Backfill/IncrementalSync wrapper cleanup into sync spec
Discovered while verifying the sync design with the user: these two
exported methods are dead in production (only FullSync's *Core calls
are actually used), kept alive only by tests, and their doc comments
still reference the background sync loop removed in 4d2cbe4.
2026-07-26 22:45:59 +02:00
7f5618ce49 docs: add design spec for improved synchronization UX
Phase-aware sync progress (discovering/activities/workouts), a new
/api/sync/status shape, and a blocking SyncModal replacing the Profile
page's inline sync text -- implementing the "improve synchronization"
idea from docs/IDEAS.md.
2026-07-26 22:09:49 +02:00
d3b26d7d41 fix(frontend): fix off-by-one date shown in the backfill horizon picker
daysAgoToISODate built the date at local midnight but formatted it with
toISOString(), which converts to UTC first -- in any timezone ahead of
UTC (e.g. Europe/Paris), local midnight is still the previous day in
UTC, so the picker displayed a date one day earlier than the one that
was actually selected/stored. Format from the Date's local
year/month/day components instead of going through UTC.
2026-07-26 21:52:38 +02:00
0211dffa1e fix(config): stop deriving GarminTokenStoreRoot's default from DBPath
There's no reason the Garmin token-store root and the SQLite database
file need to live near each other -- they're independent env vars
(GARMIN_TOKENSTORE, GENIUSRUN_DB_PATH) and should stay independently
configurable, including in their defaults. Revert the "next to DBPath"
default introduced in 77f9588 back to a plain ".garmin" relative to
the working directory, so pointing GENIUSRUN_DB_PATH elsewhere never
silently drags the token-store default along with it.
2026-07-26 21:45:39 +02:00
12cbfdbbee docs(ideas): refine backlog notes on sync UX and error banners
Merge the old "improve activities/workouts download" and "better UX
when backend is not available" bullets into one clearer "modern error
displays" idea (ephemeral, severity-colored banners reused across
login/logout/sync/backend-down states), and flesh out the sync-modal
idea with the activities/workouts split and progress-bar prerequisite.
2026-07-26 21:26:24 +02:00
92284d3b96 polish(frontend): tighten the onboarding wizard's UX
- Drop "Logout" everywhere in the flow: nothing is persisted to the
  database until Garmin actually connects, so closing the tab is
  already a clean escape hatch -- no separate control needed.
- Display-name step: replace the "Next" button with a small checkmark
  icon button next to the input (Enter still submits).
- Garmin credentials step: drop "Previous" (changing the display name
  just means quitting and relaunching, since nothing is persisted yet)
  and rename "Next" to "Login".
- MFA step: drop "Previous" for the same reason.
- Once Garmin reports success (MFA or not), finish setup immediately
  instead of waiting on a "Continue to geniusrun" click.
2026-07-26 21:26:14 +02:00
45f2921971 fix(garmin): make wrapper.py's startup tokenstore login lazy
_startup_login() used to run unconditionally at process boot, before
main()'s stdin dispatch loop started. Whenever the very first real
command turned out to be an explicit authenticate, this was actively
counterproductive: on success it duplicated a login authenticate was
about to redo anyway, and on failure it was a wasted, unauthenticated
hit against Garmin's servers moments before the real attempt --
exactly the kind of extra load that worsens rate-limiting risk. It
also never handled MFA, so it couldn't stand in for authenticate
regardless.

Make it lazy instead: only _handle_call falls back to it, at most once
per subprocess lifetime, and only if authenticate was never explicitly
attempted first. This is what it was actually for -- silently resuming
a cached tokenstore session for a data call that never goes through
the explicit authenticate command (e.g. "Sync now" reaching an
already-connected user's client right after a backend restart cleared
the in-memory client cache).
2026-07-26 21:25:53 +02:00
77f9588c2d refactor(config): rename default Garmin token-store dir to .garmin
Shorter, more conventional dotdir name than garmin-tokenstores. Restore
the "next to DBPath" join that got dropped when the default was
inlined via getEnvDefault -- an explicit GARMIN_TOKENSTORE still wins
verbatim, but the implicit default must still resolve relative to the
DB file's directory, not the process's CWD.

internal/garmin/client.go's GARMIN_TOKENSTORE env var is now always
set (TokenStorePath can no longer be empty), so the conditional that
only appended it when non-empty is dead code.
2026-07-26 21:25:39 +02:00
a12fe3e810 fix(api): stop promoting the ephemeral Garmin client after setup completes
handleSetupComplete reused the onboarding client object as-is in the
permanent per-user cache, but its garmin.Config.TokenStorePath was fixed
at construction to the ephemeral setup/{hash} directory and never
corrected after that directory was renamed to the permanent {userID}
path. The next respawn of that same client (e.g. any Profile-page save,
which unconditionally calls UpdateCredentials) wrote a fresh token file
back under setup/{hash}, forcing a real re-login/MFA on the next Garmin
connect even though a valid session already existed under {userID}.

Close the ephemeral client instead and let the next garminFor call build
a fresh one against the correct, already-renamed directory.
2026-07-26 21:23:24 +02:00
4d2cbe4883 refactor: remove automatic background incremental sync
Garmin's rate-limiting means every unattended sync attempt risks a
ban; syncing should only ever happen when explicitly triggered via
"Sync now" (POST /api/sync/run), never on an unattended timer.

Removes the periodic background loop (main.go's runIncrementalSyncLoop,
api.Server.RunIncrementalSyncForAllUsers), its GENIUSRUN_INCREMENTAL_SYNC_EVERY
config, and store.DB.ListUsers (which existed solely to feed it). The
manual "Sync now" flow (Backfill/IncrementalSync/FillPendingDetails via
FullSync) is untouched.
2026-07-26 18:01:41 +02:00
7346e2eadd docs: add implementation plan for structured JSON logging 2026-07-26 14:36:04 +02:00
c9e089a2b2 feat(garmin): log every wrapper subprocess call as structured JSON 2026-07-26 14:33:20 +02:00
1d4b1cccfd feat(api): add structured JSON access log with request-id correlation 2026-07-26 14:30:39 +02:00
379e7cc990 feat(applog): add JSON logger and context helpers 2026-07-26 14:28:22 +02:00
501d951e5b docs: add design spec for structured JSON logging 2026-07-26 14:22:28 +02:00
2fbe762290 docs: add implementation plan for deferred-commit onboarding wizard 2026-07-26 14:12:44 +02:00
84a7417781 feat(onboarding): replace CreateProfile/ConnectGarmin with a single deferred-commit wizard 2026-07-26 13:32:32 +02:00
7d68af5b3c feat(api): defer account creation until Garmin actually connects 2026-07-26 13:30:51 +02:00
a1eaa42d42 docs: add design spec for deferred-commit onboarding wizard 2026-07-26 13:17:59 +02:00
35eff03509 docs: add implementation plan for improved first-connection flow 2026-07-26 12:29:26 +02:00
a622af1bfa Merge branch 'worktree-improve-first-connection' 2026-07-26 12:28:45 +02:00
7f4f6d3493 docs: remove improve-first-connection-page from IDEAS backlog (implemented) 2026-07-26 12:26:33 +02:00
049ed16b69 feat(onboarding): add mandatory ConnectGarmin gate to first login 2026-07-26 12:25:09 +02:00
002858c1cb feat(api): persist and expose garmin_connected on successful auth 2026-07-26 12:23:39 +02:00
6a3f0201af feat(store): persist garmin_connected_at, set once on first successful auth 2026-07-26 12:21:42 +02:00
50d65b40c2 docs: add design spec for improved first-connection flow 2026-07-26 12:09:35 +02:00
d1d2d612a5 added ideas 2026-07-26 12:00:10 +02:00
4c939be6d6 added go work files 2026-07-26 12:00:00 +02:00
f7bf7e9c79 removed unwanted ide files 2026-07-26 11:57:33 +02:00
8353cd148b feat(auth): pass id_token_hint on Keycloak logout
Carries the raw ID token in the session cookie so logout can hand it back
to Keycloak as id_token_hint, letting it skip its own logout-confirmation
prompt -- otherwise a user could cancel out of it and land back in the app
with a Keycloak SSO session but no geniusrun profile (e.g. right after
deleting their account).
2026-07-26 11:55:13 +02:00
d7202eb9bb docs: add implementation plan for profile deletion 2026-07-26 10:23:32 +02:00
ce5057a309 Merge branch 'worktree-profile-deletion' 2026-07-26 10:22:52 +02:00
efbe6a6760 docs: remove profile deletion from IDEAS backlog (implemented) 2026-07-26 10:21:31 +02:00
e2533bd1a8 feat(profile): add Danger zone account deletion UI 2026-07-26 10:20:56 +02:00
ab8cdea214 feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown 2026-07-26 10:19:00 +02:00
e8c340a00e feat(store): add DeleteUser with cascading account deletion 2026-07-26 10:17:21 +02:00
c5faaf5a17 docs: add design spec for profile deletion 2026-07-26 10:06:04 +02:00
65a2dc90d0 refactored component names 2026-07-26 10:00:24 +02:00
3b2b5d0735 refined ideas + start.sh rely on .env 2026-07-26 09:59:56 +02:00
d308201803 fix(api): logout's post_logout_redirect_uri uses FrontendURL, not BackendURL
Same class of bug as the OIDC callback fix: handleSessionLogout redirected
Keycloak's end-session flow back to BackendURL+"/", which 404s in a
split-origin deployment (the backend serves no "/" route). Flagged as a
known-deferred question in the callback-redirect design spec; fixing it
now that it's been hit in practice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:37:22 +02:00
099e746119 refactor(config): rename GENIUSRUN_PUBLIC_BASE_URL to GENIUSRUN_BACKEND_URL
Now that GENIUSRUN_FRONTEND_URL exists as a separate config value, keeping
the backend's own origin named "PublicBaseURL" invited exactly the kind of
mixup that caused the OIDC callback 404 in the first place. Renamed
consistently: env var, Config.BackendURL, api.SessionConfig.BackendURL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:00:35 +02:00
db2e8e487d fix(api): redirect the OIDC callback to FrontendURL, not a relative path
handleSessionCallback's redirects (success and all 4 failure branches)
were relative paths, which resolve against the backend's own origin --
broken in this project's own supported split-origin local dev setup,
since the Go backend serves no "/" route at all. Now uses the new
config.Config.FrontendURL (defaults to PublicBaseURL, so no change for
single-origin production deployments).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:52:15 +02:00
f93aa331d9 feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL
Lets the OIDC callback redirect to the frontend's real origin instead of
a relative path resolved against the backend's own origin -- needed for
this project's own supported split-origin local dev setup (frontend on
Vite, backend on geniusrund, bridged by CORS).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:50:46 +02:00
d73c841ab7 docs: add implementation plan for the OIDC callback redirect fix
Two small tasks: add config.Config.FrontendURL (Task 1), then thread it
through api.SessionConfig and handleSessionCallback's five redirects
(Task 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:49:02 +02:00
fe4978dbea docs: add design spec for fixing OIDC callback redirect origin
handleSessionCallback's relative redirects resolve against the backend's
own origin, which 404s in this project's own supported split-origin local
dev setup (frontend on Vite, backend on geniusrund, bridged by CORS). Adds
GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL for the common
single-origin production case, as the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:42:50 +02:00
85577c9d26 added ideas 2026-07-25 21:59:56 +02:00
487df1f9b9 fix(garmin): give startup login its own result queue, not the shared one
_startup_login's background thread and _handle_authenticate's shared the
module-level _login_result_queue with no correlation. Since _startup_login
can now time out at 10s while its thread keeps running (from the previous
fix in this wave), a slow-cold-starting subprocess's first explicit
"Connect to Garmin" call could accidentally dequeue the startup thread's
stale result instead of its own fresh one, orphaning the loser's result to
corrupt a later authenticate/complete_mfa call.

_handle_authenticate and _handle_complete_mfa still correctly share
_login_result_queue -- they're two halves of one explicit, MFA-capable
login flow. _startup_login is a background tokenstore resume with no MFA
involved, so it now uses its own private, function-local queue.Queue()
instead, making cross-contamination structurally impossible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:52:48 +02:00
e422f86925 fix: bound startup-login timeout, refresh stale SKILL.md and doc comments
_startup_login() ran garminconnect's login synchronously with no timeout
before main() ever started reading stdin, so a slow/rate-limited Garmin
login could wedge a user's whole subprocess before it became responsive.
Give it the same background-thread + bounded-10s-timeout shape
_handle_authenticate already uses, with tests for both the fast-success
and timeout paths.

Also refreshes .claude/skills/geniusrun-dev/SKILL.md (still describing
the retired mcp-garmin MCP architecture) and three stale doc comments
(garmin.AuthStatus, config.GarminTokenStoreRoot, mock package doc) left
over from the direct-wrapper migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:43:52 +02:00
136567d964 docs: remove stale cmd/mcpspike references 2026-07-25 21:31:24 +02:00
2fad83fe9a chore: drop the mcp-go dependency and remove mcpspike throwaway spike
Nothing in the backend speaks MCP anymore -- internal/garmin talks to its
embedded Python wrapper over plain JSON-lines instead. The cmd/mcpspike
directory was a temporary spike for validating the mcp-go client, which is
no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:27:19 +02:00
f122f28206 docs: fix stale mcp-garmin references in CLAUDE.md's overview
Updates the Project overview section to correctly describe the direct
Python wrapper instead of the retired MCP-based approach, eliminating
the contradiction with the Garmin integration section below.
2026-07-25 21:23:24 +02:00
1b9088bd03 chore: wire up the new garmin.Config shape, refresh start.sh and CLAUDE.md
Removes the last references to garmin.Config.ServerPath and the
MCP_GARMIN_* env vars now that the wrapper script is embedded in the
binary; updates CLAUDE.md's mcp-garmin section to describe the direct
wrapper instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:18:50 +02:00
284ec3142d feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
The wrapper script is embedded in the binary now (internal/garmin), so
there's no script path left to configure. The interpreter path becomes
optional, defaulting to python3 on PATH, matching how other optional
plumbing (e.g. GENIUSRUN_OIDC_REQUIRED_ROLE) is already handled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:13:26 +02:00
8c94c94c3f feat(garmin): implement data-fetch methods via generic call dispatch
GetActivities/GetActivitySplits/GetActivityDetails/GetWorkoutByID now send
{"cmd":"call","params":{"method":...,"args":...}} instead of named MCP
tools. subprocessClient fully implements Client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:02:49 +02:00
9cbcf62aed feat(garmin): implement Authenticate/CompleteMFA on subprocessClient
Structured {status, message} responses replace the old string
pattern-matching (parseAuthResult) -- both sides of the protocol are now
owned by this repo, so there's no need to guess at phrasing anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:54:54 +02:00
df197f45fd fix(garmin): wait for stderr-copy goroutine before cmd.Wait()
ensureStarted's stderr-copy goroutine could still be mid-Read when close()
called cmd.Wait(), which os/exec's StderrPipe docs call out as incorrect
and can truncate/garble trailing stderr diagnostics or surface a spurious
"file already closed" error. close() now waits on a stderrDone channel,
closed by the copy goroutine once it hits EOF (unblocked by killing the
process), before calling Wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:50:39 +02:00
6c161f2206 feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:41:44 +02:00
7985000440 feat(garmin): add direct garminconnect wrapper script
Replaces mcp-garmin's server.py: a JSON-lines subprocess protocol
(authenticate/complete_mfa/call) around garminconnect directly, no MCP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:35:13 +02:00
de48c44858 docs: add implementation plan for the direct Garmin wrapper
Task-by-task TDD plan for docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md:
Python wrapper + tests, Go transport scaffolding, auth methods, data-fetch
methods, config changes, wiring/docs, and final dependency cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:30:05 +02:00
0936d98161 docs: embed the garmin wrapper script instead of configuring its path
Since wrapper.py now lives inside this repo, its location is no longer a
deploy-time concern -- go:embed it into the binary and drop the
GARMIN_WRAPPER_SCRIPT env var entirely. Only the Python interpreter choice
remains configurable, and now optionally so (defaults to "python3").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:14:36 +02:00
961a7d8aca docs: add design spec for dropping MCP in favor of a direct Garmin wrapper
Replaces internal/garmin's MCP-based mcp-garmin integration with a custom
newline-delimited-JSON subprocess protocol around python-garminconnect
directly, folded into this repo. MCP's dynamic tool-discovery value is
unused here (fixed call sites, no LLM choosing tools), and both projects
are owned by the same person, so the extra protocol layer and two SDK
dependencies (mcp-go, mcp[cli]) were pure overhead -- especially given
plans to containerize the backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:04:18 +02:00
124 changed files with 20909 additions and 3242 deletions

View File

@@ -1,13 +1,13 @@
---
name: geniusrun-dev
description: Use when working on the geniusrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions.
description: Use when working on the geniusrun repo (backend Go services, classification rule engine, Garmin integration, or frontend) to stay consistent with established conventions.
---
# geniusrun-dev
## Project overview
geniusrun is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
geniusrun is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around `garminconnect`, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
**MVP scope boundary**: sync + classification + review queue + progression charts. A future training-recommendation engine (analyzing aerobic/anaerobic training-effect balance across kinds to suggest what to train next) is explicitly deferred — the schema stores the metrics it would need, but nothing consumes them yet.
@@ -17,8 +17,7 @@ geniusrun is a personal web app that pulls running activities from Garmin Connec
backend/
cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/mcpspike/ throwaway MCP-client spike, safe to delete
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details)
internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer + embedded migrations
@@ -54,16 +53,16 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance — the device already knows which laps were work vs rest when the activity was recorded as a structured workout.
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap (cardiac drift). Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
## mcp-garmin integration
## Garmin integration (direct wrapper, no MCP)
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (the Go→Python subprocess protocol variable — the process-level config var is `GENIUSRUN_TOKENSTORE_PATH`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits()`** (added to mcp-garmin, wraps `garminconnect`'s existing `get_activity_splits`) is the one that returns actual lap/split summaries (`lapDTOs`).
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
## Data model conventions
@@ -75,7 +74,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
## Dev workflow
- Backend: `cd backend && go run ./cmd/geniusrund` (needs `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` env vars for the mcp-garmin subprocess paths; see `internal/config/config.go` for all knobs). Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
- Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB.
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
@@ -87,4 +86,4 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly.
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching.
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`.
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints.
- The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real `geniusrund` auth endpoints.

9
.gitignore vendored
View File

@@ -20,6 +20,15 @@ frontend/dist/
backend/mcpspike
backend/cmd/mcpspike/mcpspike
.superpowers/
.worktrees/
# Claude Code session-local runtime state
.claude/*.lock
# Garmin token stores hold live OAuth tokens -- never track them.
backend/.garmin/
# SQLite sidecar files
*.db-shm
*.db-wal
# IDE state beyond the shared bits already ignored above
.idea/

View File

@@ -4,16 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project overview
geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around `garminconnect`, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a `profile` row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (`profile`, `workout_kinds`/`workout_type_paces`, `activities` and everything under it, `sync_state`/`sync_runs`) is scoped to a `user_id`, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, mcp-garmin subprocess paths).
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a `profile` row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (`profile`, `workout_kinds`/`workout_type_paces`, `activities` and everything under it, `sync_state`/`sync_runs`) is scoped to a `user_id`, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, the Garmin wrapper's Python interpreter path).
The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open, and `docs/IDEAS.md` for informal, not-yet-formalized ideas for what's next — check it at the start of a session for context on where to pick up.
## Commands
Backend (from `backend/`):
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the local mcp-garmin subprocess paths and DB path already set) or `go run ./cmd/geniusrund` directly if you export `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` yourself.
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed).
- Build/vet: `go build ./...` && `go vet ./...`
- Format: `gofmt -l .` must report nothing before committing.
- All tests: `go test ./...`
@@ -21,6 +21,7 @@ Backend (from `backend/`):
- Single test: `go test ./internal/store/... -run TestProfile -v`
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
- **This app is still under active development, not yet in production.** A breaking schema change (new non-nullable column, renamed/removed column, changed constraint, etc.) is expected to require deleting the existing local DB file and letting `Open()` recreate it fresh from the current `schema.sql` -- this is the normal, accepted remedy during this phase, not a workaround to avoid. Do not add `ALTER TABLE` migration logic, backfill scripts, or any other backward-compatibility shim for an existing DB file to accommodate a schema change. Real migration tooling (Flyway) is planned once the first version ships to production; until then, every schema change is free to be breaking.
Frontend (from `frontend/`):
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
@@ -36,7 +37,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`. Session duration is application configuration, not an env var: `session.duration` (hours, default `720`), editable on the `/config` page and applied on the next backend restart. See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
## Repo layout
@@ -45,8 +46,7 @@ backend/
cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql
cmd/mcpspike/ throwaway MCP-client spike, safe to delete
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning
@@ -80,23 +80,23 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default from `GENIUSRUN_MIN_CONFIDENCE`, 0.6). All three populate `Candidates` for the review UI.
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (hard-coded `classify.DefaultMinConfidence`, 0.6). All three populate `Candidates` for the review UI.
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
- **Race** is special-cased: `is_race` is derived at sync time from Garmin's own `eventType.typeKey == "race"` (see `internal/sync/mapping.go`), not a rule the user tunes. Once an activity is assigned Race (or manually overridden to any kind), `POST /api/reclassify` (`handleReclassifyAll`) skips it — Race is a hard Garmin fact and manual assignments are the user's definitive word, neither is ever silently overwritten by a global reclassify.
## mcp-garmin integration
## Garmin integration (direct wrapper, no MCP)
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout (stderr carries JSON log lines, re-emitted through the Go application logger): `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, this token-store root (`config.GarminTokenStoreRoot`, read from `GENIUSRUN_TOKENSTORE_PATH`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`).
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
- **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
## Data model conventions
@@ -117,6 +117,7 @@ See `docs/DATABASE.md` for the full, always-current schema (every table/column/i
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
- **Verify UI/frontend changes with a live click-through against the actual running dev servers (real backend + `npm run dev`), never by mocking network requests (e.g. Playwright route interception) to fake a logged-in session.** This repo's OIDC login gate makes a fully-mocked session tempting, but it's fragile in exactly the way that matters: a real backend is often already running (e.g. started from an IDE) on the same port a mocked test assumes is free, so any request the mock doesn't cover falls through to that real, live backend instead of erroring cleanly -- discovered when an incomplete route-mock's unmocked calls 401'd against a real GoLand-launched `geniusrund` and triggered `client.ts`'s 401-redirect-reload loop. If a live click-through isn't possible in the current environment (no real credentials, no browser tool available), say so explicitly rather than substituting a mocked simulation.
## Testing conventions
@@ -125,4 +126,4 @@ See `docs/DATABASE.md` for the full, always-current schema (every table/column/i
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call.
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`; `newTestServer` auto-provisions a `"test-user"` account matching the session cookie `doJSON` mints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an *unprovisioned* session (e.g. the setup flow, or `resolveUser`'s not-found path) build a bare `NewServer` directly instead of using `newTestServer`.
- **Cross-user isolation is tested adversarially, not just in parallel** — `internal/store/isolation_test.go` and `internal/api/isolation_test.go` provision two real users and attempt real reads/writes against the *other* user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test.
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints.
- The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real `geniusrund` auth endpoints.

10
backend/.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>

6
backend/.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -10,11 +10,12 @@ package main
import (
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
)
@@ -26,6 +27,7 @@ type schemaEntry struct {
}
func main() {
slog.SetDefault(applog.NewLogger("info", os.Stdout))
tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema")
must(err)
defer os.RemoveAll(tmpDir)
@@ -91,11 +93,12 @@ func main() {
outPath := "../docs/DATABASE.md"
must(os.WriteFile(outPath, []byte(b.String()), 0644))
log.Printf("wrote %s", outPath)
applog.App().Info("wrote schema doc", "path", outPath)
}
func must(err error) {
if err != nil {
log.Fatal(err)
applog.App().Error("dumpschema failed", "error", err)
os.Exit(1)
}
}

View File

@@ -5,8 +5,9 @@ package main
import (
"context"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
@@ -15,80 +16,104 @@ import (
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
// fatal logs through the application JSON logger and exits -- the
// structured replacement for log.Fatalf, so even startup failures come
// out as JSON lines.
func fatal(msg string, err error) {
applog.App().Error(msg, "error", err)
os.Exit(1)
}
db, err := store.Open(cfg.DBPath)
func main() {
// Bootstrap logger at info so failures loading the env config itself
// (which carries the real log level) are still emitted as JSON.
slog.SetDefault(applog.NewLogger("warn", os.Stdout))
envCfg, err := config.LoadEnv()
if err != nil {
log.Fatalf("open database: %v", err)
fatal("load env config", err)
}
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
db, err := store.Open(envCfg.DBPath)
if err != nil {
fatal("open database", err)
}
defer db.Close()
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
RequiredRole: cfg.OIDCRequiredRole,
})
values, err := db.ConfigValues(context.Background())
if err != nil {
log.Fatalf("oidc: %v", err)
fatal("read app config", err)
}
// Every registry key is mandatory in the DB: seed missing ones with
// their defaults so the config table is always fully populated (no
// code-side fallbacks anywhere downstream).
for _, k := range config.AppRegistry() {
if _, ok := values[k.Key]; !ok {
if err := db.SetConfigValue(context.Background(), k.Key, k.Default); err != nil {
fatal("seed app config default", err)
}
values[k.Key] = k.Default
}
}
appCfg, err := config.LoadApp(values)
if err != nil {
fatal("load app config", err)
}
server := api.NewServer(db, garmin.NewClient, garmin.Config{
PythonPath: cfg.GarminPythonPath,
ServerPath: cfg.GarminServerPath,
TokenStorePath: cfg.GarminTokenStoreRoot,
}, appsync.Config{
MinConfidence: cfg.MinConfidence,
}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure,
PublicBaseURL: cfg.PublicBaseURL,
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
IssuerURL: envCfg.OIDCIssuerURL,
ClientID: envCfg.OIDCClientID,
ClientSecret: envCfg.OIDCClientSecret,
RedirectURL: envCfg.OIDCRedirectURL,
RequiredRole: envCfg.OIDCRequiredRole,
})
if err != nil {
fatal("oidc verifier setup", err)
}
server := api.NewServer(
db,
garmin.NewClient,
garmin.ClientConfig{
PythonPath: envCfg.PythonPath,
TokenStorePath: envCfg.TokenStoreRoot,
},
garmin.SyncConfig{},
authVerifier,
api.SessionConfig{
Secret: envCfg.SessionSecret,
Duration: appCfg.SessionDuration,
SetupTimeout: appCfg.SetupTimeout,
Secure: envCfg.SessionSecure,
BackendURL: envCfg.BackendURL,
FrontendURL: envCfg.FrontendURL,
})
for _, e := range envCfg.DisplayEnv() {
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery)
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
go func() {
log.Printf("geniusrund listening on %s", cfg.Addr)
applog.App().Info("geniusrund listening", "addr", envCfg.BackendAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
fatal("http server", err)
}
}()
<-ctx.Done()
log.Println("shutting down...")
applog.App().Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("http server shutdown: %v", err)
}
}
// runIncrementalSyncLoop periodically syncs new activities for every
// provisioned user in the background so the frontend doesn't need to
// trigger every sync manually.
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
server.RunIncrementalSyncForAllUsers(ctx)
}
applog.App().Error("http server shutdown", "error", err)
}
}

View File

@@ -1,243 +0,0 @@
// Throwaway spike to validate that mark3labs/mcp-go's stdio client can drive
// mcp-garmin (spawn, initialize handshake, call tools, parse results).
// Not part of the production build — delete once internal/garmin is built.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
pythonPath := flag.String("python", "", "path to mcp-garmin .venv python executable")
serverPath := flag.String("server", "", "path to mcp-garmin server.py")
limit := flag.Int("limit", 5, "activity limit for get_activities")
startDate := flag.String("start", "", "start date YYYY-MM-DD for get_activities (default: 365 days ago)")
endDate := flag.String("end", "", "end date YYYY-MM-DD for get_activities (default: today)")
flag.Parse()
if *pythonPath == "" || *serverPath == "" {
log.Fatal("usage: mcpspike -python <path to .venv/bin/python> -server <path to server.py>")
}
email := os.Getenv("GARMIN_EMAIL")
password := os.Getenv("GARMIN_PASSWORD")
if email == "" || password == "" {
log.Fatal("GARMIN_EMAIL and GARMIN_PASSWORD must be set in the environment")
}
env := []string{
"GARMIN_EMAIL=" + email,
"GARMIN_PASSWORD=" + password,
"PYTHONUNBUFFERED=1",
}
c, err := client.NewStdioMCPClient(*pythonPath, env, *serverPath)
if err != nil {
log.Fatalf("spawn subprocess: %v", err)
}
defer c.Close()
if stdio, ok := c.GetTransport().(*transport.Stdio); ok {
go func() {
buf := make([]byte, 4096)
for {
n, err := stdio.Stderr().Read(buf)
if n > 0 {
fmt.Fprint(os.Stderr, string(buf[:n]))
}
if err != nil {
return
}
}
}()
} else {
log.Println("warning: could not get stdio transport to forward subprocess stderr")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{Name: "mcpspike", Version: "0.0.1"}
initRes, err := c.Initialize(ctx, initReq)
if err != nil {
log.Fatalf("initialize handshake failed: %v", err)
}
fmt.Printf("initialized OK: server=%s version=%s protocol=%s\n",
initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
if err != nil {
log.Fatalf("list tools failed: %v", err)
}
fmt.Printf("server exposes %d tools:\n", len(tools.Tools))
for _, t := range tools.Tools {
fmt.Printf(" - %s: %s\n", t.Name, t.Description)
}
fmt.Println("\ncalling authenticate()...")
authRes, err := callTool(ctx, c, "authenticate", nil)
if err != nil {
log.Fatalf("authenticate call failed: %v", err)
}
fmt.Printf("authenticate() -> %s\n", authRes)
if containsMFAPrompt(authRes) {
fmt.Print("MFA required. Enter code: ")
var code string
fmt.Scanln(&code)
mfaRes, err := callTool(ctx, c, "complete_mfa", map[string]any{"code": code})
if err != nil {
log.Fatalf("complete_mfa call failed: %v", err)
}
fmt.Printf("complete_mfa() -> %s\n", mfaRes)
}
start := *startDate
if start == "" {
start = time.Now().AddDate(0, 0, -365).Format("2006-01-02")
}
end := *endDate
if end == "" {
end = time.Now().Format("2006-01-02")
}
fmt.Printf("\ncalling get_activities(start_date=%s, end_date=%s, limit=%d)...\n", start, end, *limit)
actRes, err := callTool(ctx, c, "get_activities", map[string]any{
"start_date": start,
"end_date": end,
"limit": *limit,
})
if err != nil {
log.Fatalf("get_activities call failed: %v", err)
}
var activities []map[string]any
if err := json.Unmarshal([]byte(actRes), &activities); err != nil {
fmt.Printf("get_activities() returned non-JSON (likely an error string): %s\n", actRes)
return
}
pretty, _ := json.MarshalIndent(activities, "", " ")
fmt.Printf("get_activities() parsed OK, %d activities, %d bytes:\n%s\n", len(activities), len(actRes), truncate(string(pretty), 4000))
if len(activities) == 0 {
fmt.Println("\nno activities in range, skipping get_activity_details")
return
}
// Prefer a running activity if one is present, for the most relevant lap/split shape.
chosen := activities[0]
for _, a := range activities {
if at, ok := a["activityType"].(map[string]any); ok {
if tk, _ := at["typeKey"].(string); tk == "running" || tk == "trail_running" || tk == "treadmill_running" {
chosen = a
break
}
}
}
var activityID string
switch v := chosen["activityId"].(type) {
case float64:
activityID = strconv.FormatFloat(v, 'f', -1, 64)
default:
activityID = fmt.Sprint(v)
}
fmt.Printf("\ncalling get_activity_details(activity_id=%s) [activityName=%v]...\n", activityID, chosen["activityName"])
splitsRes, err := callTool(ctx, c, "get_activity_splits", map[string]any{"activity_id": activityID})
if err != nil {
log.Fatalf("get_activity_splits call failed: %v", err)
}
var splits map[string]any
if err := json.Unmarshal([]byte(splitsRes), &splits); err != nil {
fmt.Printf("get_activity_splits() returned non-JSON: %s\n", splitsRes)
return
}
keys := make([]string, 0, len(splits))
for k := range splits {
keys = append(keys, k)
}
fmt.Printf("get_activity_splits() top-level keys: %v\n", keys)
prettySplits, _ := json.MarshalIndent(splits, "", " ")
fmt.Printf("get_activity_splits() parsed OK, %d bytes:\n%s\n", len(splitsRes), truncate(string(prettySplits), 8000))
fmt.Println("\ncalling get_activity_details() again to inspect metricDescriptors + a small metrics sample...")
detailsRes, err := callTool(ctx, c, "get_activity_details", map[string]any{"activity_id": activityID})
if err != nil {
log.Fatalf("get_activity_details call failed: %v", err)
}
var details map[string]any
if err := json.Unmarshal([]byte(detailsRes), &details); err != nil {
fmt.Printf("get_activity_details() returned non-JSON: %s\n", detailsRes)
return
}
if descriptors, ok := details["metricDescriptors"]; ok {
pretty, _ := json.MarshalIndent(descriptors, "", " ")
fmt.Printf("metricDescriptors:\n%s\n", pretty)
} else {
fmt.Println("no metricDescriptors key found")
}
if rows, ok := details["activityDetailMetrics"].([]any); ok && len(rows) > 0 {
sampleN := 5
if len(rows) < sampleN {
sampleN = len(rows)
}
pretty, _ := json.MarshalIndent(rows[:sampleN], "", " ")
fmt.Printf("activityDetailMetrics sample (first %d of %d rows):\n%s\n", sampleN, len(rows), pretty)
}
}
func callTool(ctx context.Context, c *client.Client, name string, args map[string]any) (string, error) {
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
res, err := c.CallTool(ctx, req)
if err != nil {
return "", err
}
if res.IsError {
return "", fmt.Errorf("tool %s returned an error result", name)
}
var out string
for _, content := range res.Content {
if tc, ok := content.(mcp.TextContent); ok {
out += tc.Text
}
}
return out, nil
}
func containsMFAPrompt(s string) bool {
for _, needle := range []string{"MFA required", "MFA code", "complete_mfa"} {
if len(s) >= len(needle) {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return true
}
}
}
}
return false
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}

View File

@@ -9,23 +9,25 @@ import (
"context"
"flag"
"fmt"
"log"
"log/slog"
"os"
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func main() {
slog.SetDefault(applog.NewLogger("info", os.Stdout))
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
flag.Parse()
ctx := context.Background()
db, err := store.Open(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
fatal("open db", err)
}
defer db.Close()
@@ -73,8 +75,8 @@ func main() {
IsActive: true,
}))
m := &mock.Client{}
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil)
m := &garmin.MockClient{}
svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
today := time.Now()
activityIDs := []int64{}
@@ -234,10 +236,17 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activ
func must(err error) {
if err != nil {
log.Fatal(err)
fatal("seedsample", err)
}
}
// fatal logs through the application JSON logger and exits -- the
// structured replacement for log.Fatal.
func fatal(msg string, err error) {
applog.App().Error(msg, "error", err)
os.Exit(1)
}
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
kinds, err := db.ListWorkoutKinds(ctx, userID, false)
must(err)
@@ -246,6 +255,7 @@ func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string
return k.ID
}
}
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name)
applog.App().Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name)
os.Exit(1)
return 0
}

View File

@@ -6,7 +6,6 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-chi/chi/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/mark3labs/mcp-go v0.56.0
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.53.0
)
@@ -14,16 +13,11 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.14.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

View File

@@ -1,53 +1,25 @@
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -57,12 +29,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=

View File

@@ -1,7 +1,9 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"github.com/go-chi/chi/v5"
@@ -9,109 +11,248 @@ import (
"geniusrun/backend/internal/store"
)
// activityListItem is one row of the activities list, enriched with its
// current classification so the frontend can show what kind it's assigned
// to and whether that assignment is locked (see Locked's doc comment).
type activityListItem struct {
activityResponse
WorkoutKindID *int64 `json:"workout_kind_id"`
WorkoutKindName *string `json:"workout_kind_name"`
AssignmentSource *string `json:"assignment_source"`
AssignmentStatus *string `json:"assignment_status"`
// Locked is true when a global reclassify pass will never touch this
// activity again: a manual assignment is the user's definitive word, and
// a Race assignment comes from a hard Garmin fact, not a retunable rule.
Locked bool `json:"locked"`
// defaultActivitiesPageSize matches the frontend's initial/incremental
// page size for its infinite-scroll list.
const defaultActivitiesPageSize = 10
type activityItem struct {
store.KindAssignment
Activity activityResponse `json:"activity"`
Laps []lapResponse `json:"laps"`
Samples []store.Sample `json:"samples"`
}
// handleListActivities backs the Activities page: every activity that has been
// classified at least once, not just ones still needing review, so the page
// can show each activity's current kind (or "Unclassified") and let the user
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
// (especially) per-second samples is expensive once there are many of them,
// so that work only happens for the requested page, not the full backlog.
// Sorting/cursor filtering still needs each activity's summary row (a cheap
// indexed lookup, no laps/samples), which happens for the whole backlog --
// only the heavy per-item fetches are deferred to the page actually being
// returned. The optional kind_id/unclassified filters are applied before
// that cursor slicing too, so a filtered view still only loads (and
// chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
q := r.URL.Query()
filter := store.ActivityFilter{
FromDate: q.Get("from"),
ToDate: q.Get("to"),
limit := defaultActivitiesPageSize
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
filter.Limit = limit
}
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
filter.Offset = offset
}
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
var kindIDFilter *int64
if v := r.URL.Query().Get("kind_id"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
kindIDFilter = &n
}
}
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
type withActivity struct {
assignment store.KindAssignment
activity store.Activity
}
kindNames := make(map[int64]string, len(kinds))
for _, k := range kinds {
kindNames[k.ID] = k.Name
all := make([]withActivity, 0, len(queue))
for _, a := range queue {
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
continue
}
resp := make([]activityListItem, 0, len(activities))
for _, a := range activities {
item := activityListItem{activityResponse: toActivityResponse(a)}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
if unclassifiedOnly && a.WorkoutKindID != nil {
continue
}
if ok {
source, status := assignment.AssignmentSource, assignment.Status
item.AssignmentSource, item.AssignmentStatus = &source, &status
if assignment.WorkoutKindID != nil {
item.WorkoutKindID = assignment.WorkoutKindID
if name, found := kindNames[*assignment.WorkoutKindID]; found {
item.WorkoutKindName = &name
}
}
item.Locked = source == store.AssignmentSourceManual ||
(item.WorkoutKindName != nil && *item.WorkoutKindName == "Race")
}
resp = append(resp, item)
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity not found")
return
continue
}
all = append(all, withActivity{assignment: a, activity: activity})
}
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
// Most recent run first, by when the activity actually happened (not
// when the rule engine flagged it), so the queue reads like a log.
sort.Slice(all, func(i, j int) bool {
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
})
total := len(all)
if cursor != "" {
idx := 0
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
idx++
}
all = all[idx:]
}
hasMore := len(all) > limit
if len(all) > limit {
all = all[:limit]
}
items := make([]activityItem, 0, len(all))
for _, wa := range all {
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
// Per-second telemetry, not just per-lap averages, so the chart can
// show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps).
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
items = append(items, activityItem{
KindAssignment: wa.assignment,
Activity: toActivityResponse(wa.activity),
Laps: toLapResponses(laps),
Samples: samples,
})
}
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
if hasAssignment {
resp["assignment"] = assignment
var nextCursor *string
if hasMore && len(items) > 0 {
c := items[len(items)-1].Activity.StartTimeUTC
nextCursor = &c
}
writeJSON(w, http.StatusOK, resp)
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"next_cursor": nextCursor,
"total": total,
})
}
func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
var body struct {
WorkoutKindID int64 `json:"workout_kind_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.WorkoutKindID == 0 {
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
return
}
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else if !ok {
writeError(w, http.StatusBadRequest, "workout kind not found")
return
}
if kind.Name == "Race" {
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
return
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "assigned"})
}
// handleUnassignActivity manually clears an activity's kind back to
// Unclassified. Like handleAssignActivity, it's a deliberate manual choice --
// recorded as source=manual (with no kind) so the rule engine leaves it alone
// on a later reclassify pass, exactly as it would for a manual kind
// assignment. The frontend only offers this while the activity is unlocked
// (locked activities must be unlocked first, same precondition as picking a
// different kind), but the backend doesn't re-enforce that here, matching
// handleAssignActivity's own lack of a lock precondition check.
func (s *Server) handleUnassignActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
}
// handleUnlockActivity reverts a manual assignment back to rule_engine
// sourcing, keeping the same kind, so a later reclassify pass (which always
// skips manual assignments) is free to change it again. It's the inverse of
// handleAssignActivity, not a delete: the kind stays visible as-is until
// something actually reclassifies it.
func (s *Server) handleUnlockActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity has no assignment yet")
return
}
if current.AssignmentSource != store.AssignmentSourceManual {
writeError(w, http.StatusBadRequest, "activity is not manually locked")
return
}
status := store.AssignmentStatusAssigned
if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
}

View File

@@ -5,9 +5,11 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
@@ -16,9 +18,8 @@ import (
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func newCtx() context.Context { return context.Background() }
@@ -26,8 +27,10 @@ func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
SetupTimeout: 15 * time.Minute,
Secure: false,
PublicBaseURL: "https://geniusrun.example.com",
BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com",
}
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
@@ -43,9 +46,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
t.Fatalf("ProvisionUser: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID
}
@@ -63,7 +66,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint test session cookie: %v", err)
}
@@ -193,7 +196,7 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
}
}
func TestReviewQueueResolve(t *testing.T) {
func TestActivitiesAssign(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
@@ -225,9 +228,9 @@ func TestReviewQueueResolve(t *testing.T) {
}
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("review queue status = %d", rec.Code)
t.Fatalf("activities list status = %d", rec.Code)
}
var page struct {
Items []map[string]any `json:"items"`
@@ -236,43 +239,43 @@ func TestReviewQueueResolve(t *testing.T) {
}
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
}
laps, _ := page.Items[0]["laps"].([]any)
if len(laps) != 1 {
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
}
samples, _ := page.Items[0]["samples"].([]any)
if len(samples) != 1 {
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
if rec.Code != http.StatusOK {
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
}
// The activity stays listed after resolving -- the Activities page shows
// every activity, locked or not, not just ones still needing review.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total)
}
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"])
}
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got)
t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got)
}
// Unlocking reverts the source to rule_engine but keeps the same kind,
// so a later reclassify pass is free to change it again.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
@@ -282,18 +285,18 @@ func TestReviewQueueResolve(t *testing.T) {
}
// Unlocking an already-unlocked activity is rejected.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
}
// Manually unassigning clears the kind back to Unclassified and locks
// that decision (source=manual), same as resolving to a specific kind.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil)
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
@@ -303,7 +306,7 @@ func TestReviewQueueResolve(t *testing.T) {
}
// It also shows up under the Unclassified filter now.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
@@ -311,18 +314,18 @@ func TestReviewQueueResolve(t *testing.T) {
// Unassigning locks it, so unlocking works again (reverting to
// rule_engine sourcing with no kind, i.e. needs_review).
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
}
}
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
func TestActivities_PaginatesByCursor(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
@@ -352,7 +355,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
}
getPage := func(query string) page {
t.Helper()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -400,7 +403,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
}
}
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
@@ -444,7 +447,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
}
getPage := func(query string) page {
t.Helper()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -487,7 +490,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
}
}
func TestResolveReview_RejectsRaceKind(t *testing.T) {
func TestAssignActivity_RejectsRaceKind(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
@@ -500,9 +503,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID})
rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID})
if rec.Code != http.StatusBadRequest {
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
@@ -573,57 +576,6 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
}
}
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var items []activityListItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("unmarshal: %v", err)
}
locked := map[int64]bool{}
kindName := map[int64]string{}
for _, it := range items {
locked[it.ID] = it.Locked
if it.WorkoutKindName != nil {
kindName[it.ID] = *it.WorkoutKindName
}
}
if locked[ruleEngineActivity] {
t.Errorf("rule-engine activity should not be locked")
}
if !locked[manualActivity] {
t.Errorf("manual activity should be locked")
}
if !locked[raceActivity] {
t.Errorf("race activity should be locked")
}
if kindName[raceActivity] != "Race" {
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
}
}
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
@@ -633,7 +585,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil)
rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
if rec.Code != http.StatusAccepted {
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -654,9 +606,59 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
}
// "Full backfill" no longer exists as an endpoint -- superseded by reset.
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil)
rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
if rec.Code != http.StatusNotFound {
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code)
t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
}
}
func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
workoutID := int64(555)
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
// ActivitiesMissingWorkout only surfaces activities whose details have
// already been fetched (see store.ActivitiesMissingWorkout) -- an
// activity is only eligible for a workout fetch once fillActivityDetails
// has given it laps to align target pace/HR bands against.
if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil {
t.Fatalf("SetActivityDetails: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp struct {
InProgress bool `json:"in_progress"`
Progress struct {
Phase string `json:"Phase"`
Done int `json:"Done"`
Total int `json:"Total"`
} `json:"progress"`
ActivitiesPendingDetails int `json:"activities_pending_details"`
WorkoutsPending int `json:"workouts_pending"`
}
unmarshalBody(t, rec, &resp)
if resp.InProgress {
t.Error("in_progress = true, want false (nothing running)")
}
if resp.Progress.Phase != "idle" {
t.Errorf("progress.Phase = %q, want %q", resp.Progress.Phase, "idle")
}
if resp.ActivitiesPendingDetails != 1 {
t.Errorf("activities_pending_details = %d, want 1", resp.ActivitiesPendingDetails)
}
if resp.WorkoutsPending != 1 {
t.Errorf("workouts_pending = %d, want 1", resp.WorkoutsPending)
}
}
@@ -714,7 +716,7 @@ func itoa(v int64) string {
}
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _, _ := newTestServer(t)
s, db, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
@@ -732,10 +734,18 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
got.GarminEmail = "runner@example.com"
got.GarminPassword = "hunter2"
got.RollingWindowDays = 120
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
// Name rides along in the profile payload but lives on the users row.
rec = doJSON(t, router, http.MethodPut, "/api/profile", struct {
store.Profile
Name string
}{got, "Renamed Runner"})
if rec.Code != http.StatusOK {
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.Name != "Renamed Runner" {
t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
var updated store.Profile
@@ -763,6 +773,135 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
}
}
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var before sessionMeResponse
unmarshalBody(t, rec, &before)
if before.GarminConnected {
t.Fatal("expected a fresh account to report garmin_connected=false")
}
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var after sessionMeResponse
unmarshalBody(t, rec, &after)
if !after.GarminConnected {
t.Fatal("expected garmin_connected=true after a successful auth")
}
}
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t)
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.GarminConnected {
t.Fatal("expected garmin_connected=false after mfa_required")
}
}
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper()
s, db, _ := newTestServer(t)
@@ -828,7 +967,7 @@ func TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome(t *testing.
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/" {
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
}
var sessionCookie *http.Cookie
@@ -864,7 +1003,7 @@ func TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie(t *testing.T)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=forbidden" {
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=forbidden" {
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
}
for _, c := range rec.Result().Cookies() {
@@ -879,7 +1018,7 @@ func TestSessionCallback_MissingTxnCookieRedirectsFailed(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=failed" {
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=failed" {
t.Fatalf("status = %d, Location = %q", rec.Code, rec.Header().Get("Location"))
}
}
@@ -922,3 +1061,146 @@ func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T)
t.Fatalf("expected session cookie to be cleared, got %+v", cleared)
}
}
func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
verifier := &authmock.Verifier{} // EndSessionResult unset: echoes back postLogoutRedirectURL unchanged
s, _ := newTestServerWithAuth(t, verifier)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != testSessionConfig.FrontendURL+"/" {
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
}
}
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
// token carried in the session cookie (minted at callback time) is handed
// back to EndSessionURL on logout, so Keycloak can skip its own
// logout-confirmation prompt instead of leaving the user a chance to cancel
// out of it after their geniusrun account is already deleted.
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
s, _ := newTestServerWithAuth(t, verifier)
cookie, err := auth.MintSessionCookie(
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
"raw-id-token-jwt", // travels in the cookie apart from Claims
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
}
}
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
// token from a completed OIDC callback ends up in the session cookie (not
// just Sub/Name/Email), since that's the only place logout can later read
// it back from to build id_token_hint.
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
verifier := &authmock.Verifier{
CallbackResult: auth.LoginResult{
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
Authorized: true,
IDToken: "raw-id-token-jwt",
},
}
s, _ := newTestServerWithAuth(t, verifier)
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
if err != nil {
t.Fatalf("mint txn cookie: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
req.AddCookie(txnCookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("expected a session cookie to be set")
}
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
if err != nil {
t.Fatalf("read id token from session cookie: %v", err)
}
if idToken != "raw-id-token-jwt" {
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
}
}
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
s, _, _ := newTestServer(t)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["type"] != "http" || entry["package"] != "api" || entry["file"] != "server.go" || entry["method"] != "loggingMiddleware" {
t.Errorf("schema fields = %v, want type=http package=api file=server.go method=loggingMiddleware", entry)
}
if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("expected no msg on the http record, got %v", entry["msg"])
}
if entry["http_method"] != "GET" || entry["path"] != "/api/health" {
t.Errorf("http_method/path = %v/%v, want GET//api/health", entry["http_method"], entry["path"])
}
if entry["status"] != float64(http.StatusOK) {
t.Errorf("status = %v, want 200", entry["status"])
}
if _, ok := entry["duration_ms"]; !ok {
t.Error("expected a duration_ms field")
}
}
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
s, db, _ := newTestServer(t)
db.Close() // force a downstream DB call to fail with a 500
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
}
}

View File

@@ -1,85 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"geniusrun/backend/internal/garmin"
)
type authResponse struct {
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
Message string `json:"message"`
}
func authStatusString(s garmin.AuthStatus) string {
switch s {
case garmin.AuthSuccess:
return "authenticated"
case garmin.AuthMFARequired:
return "mfa_required"
case garmin.AuthFailed:
return "failed"
default:
return "unknown"
}
}
func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
client, err := s.garminFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
client, err := s.garminFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}

View File

@@ -0,0 +1,92 @@
package api
import (
"encoding/json"
"net/http"
"geniusrun/backend/internal/config"
)
// EnvVar is one read-only environment-configuration entry, already
// display-safe (masking happens in config.Config.DisplayEnv).
type EnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
type configEntry struct {
Key string `json:"key"`
Value string `json:"value"`
Default string `json:"default"`
Overridden bool `json:"overridden"`
Description string `json:"description"`
}
// configView assembles the GET/PUT response: every registry key with its
// stored value (the DB is fully seeded with defaults at startup; the
// registry default only fills in here for a key added since the last
// boot, e.g. under httptest where main's seeding never ran), plus the env
// snapshot. Overridden means "differs from the default", since every key
// always has a row.
func (s *Server) configView(r *http.Request) (map[string]any, error) {
values, err := s.DB.ConfigValues(r.Context())
if err != nil {
return nil, err
}
registry := config.AppRegistry()
app := make([]configEntry, 0, len(registry))
for _, k := range registry {
value, ok := values[k.Key]
if !ok {
value = k.Default
}
app = append(app, configEntry{
Key: k.Key, Value: value, Default: k.Default,
Overridden: value != k.Default, Description: k.Description,
})
}
envVars := s.EnvVars
if envVars == nil {
envVars = []EnvVar{}
}
return map[string]any{"application": app, "environment": envVars}, nil
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
resp, err := s.configView(r)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
// handlePutConfig updates application configuration. Cold: saved values
// take effect on the next backend restart; the response is just the
// refreshed view, same shape as GET.
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate every pair before writing anything -- all-or-nothing.
for key, value := range body {
if err := config.ValidateAppValue(key, value); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
for key, value := range body {
if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
resp, err := s.configView(r)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,111 @@
package api
import (
"encoding/json"
"net/http"
"testing"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
)
type configTestResponse struct {
Application []struct {
Key string `json:"key"`
Value string `json:"value"`
Default string `json:"default"`
Overridden bool `json:"overridden"`
Description string `json:"description"`
} `json:"application"`
Environment []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"environment"`
}
func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
s, _, _ := newTestServer(t)
s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp configTestResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(resp.Application) != 2 {
t.Fatalf("expected 2 app-config entries, got %d: %+v", len(resp.Application), resp.Application)
}
byKey := map[string]struct {
value, def string
overridden bool
}{}
for _, e := range resp.Application {
if e.Description == "" {
t.Errorf("entry %q has no description", e.Key)
}
byKey[e.Key] = struct {
value, def string
overridden bool
}{e.Value, e.Default, e.Overridden}
}
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
t.Fatalf("session.duration default entry = %+v", e)
}
if e := byKey["session.setup_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
t.Fatalf("session.setup_timeout default entry = %+v", e)
}
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
}
}
func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"})
if rec.Code != http.StatusOK {
t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp configTestResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Application[0].Value != "168" || !resp.Application[0].Overridden {
t.Fatalf("override not reflected: %+v", resp.Application[0])
}
if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest {
t.Fatalf("unknown key status = %d, want 400", rec.Code)
}
rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid value status = %d, want 400", rec.Code)
}
rec = doJSON(t, router, http.MethodGet, "/api/config", nil)
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Application[0].Value != "168" {
t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0])
}
}
// Same gate as every data route: an unprovisioned session gets 403, per
// the repo's adversarial-isolation testing convention.
func TestConfig_RequiresProvisionedUser(t *testing.T) {
db, err := store.Open(t.TempDir() + "/config_gate_test.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
t.Fatalf("GET status = %d, want 403", rec.Code)
}
if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden {
t.Fatalf("PUT status = %d, want 403", rec.Code)
}
}

View File

@@ -0,0 +1,200 @@
package api
import (
"context"
"encoding/json"
"net/http"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
)
// detailFillBatchSize bounds how many activities' details/splits are fetched
// per sync trigger, matching the sequential rate-limited fetch in
// internal/sync.Service.FillPendingDetails.
const detailFillBatchSize = 50
type authResponse struct {
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
Message string `json:"message"`
}
func authStatusString(s garmin.AuthStatus) string {
switch s {
case garmin.AuthSuccess:
return "authenticated"
case garmin.AuthMFARequired:
return "mfa_required"
case garmin.AuthFailed:
return "failed"
default:
return "unknown"
}
}
// recordAuthResult updates the in-memory auth status/message for userID,
// and -- on a successful authentication -- persists that this account has
// connected to Garmin at least once (store.MarkGarminConnected), which is
// what the login gate actually checks (the in-memory auth status resets on
// every backend restart; this doesn't).
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
applog.App().Error("mark garmin connected", "user_id", userID, "error", err)
}
}
}
func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
// watermark, so widening the configured history horizon between clicks is
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Sync Now
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.ResetAll(ctx)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
progress := svc.Progress()
resp := map[string]any{
"in_progress": inProgress,
"progress": progress,
"activities_pending_details": pendingDetails,
"workouts_pending": pendingWorkouts,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -10,9 +10,7 @@ import (
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
// doJSONAs is doJSON but for an explicit session Sub, for tests that need
@@ -33,7 +31,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint test session cookie: %v", err)
}
@@ -43,7 +41,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
return rec
}
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil {
@@ -58,21 +56,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
_ = userB
router := s.Router()
// userA (the default doJSON identity) can see it.
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
if rec.Code != http.StatusOK {
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
if err != nil || len(kindsB) == 0 {
t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
}
kindB := kindsB[0]
if kindB.Name == "Race" { // Race is rejected before the ownership check
kindB = kindsB[1]
}
// userB, given the exact same activity id, gets 404 -- not another
// user's data, and not a 500 that would leak existence either way.
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
if rec.Code != http.StatusNotFound {
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
// userB, given userA's real activity id (and a kind userB legitimately
// owns), must not be able to write an assignment onto it.
rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID})
if rec.Code == http.StatusOK {
t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String())
}
if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil {
t.Fatalf("CurrentAssignment: %v", err)
} else if ok {
t.Fatalf("userB's rejected assign still created an assignment on userA's activity")
}
}
@@ -106,7 +109,7 @@ func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
}
}
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
func TestIsolation_ActivitiesListOnlyShowsOwnActivities(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil {
@@ -125,7 +128,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
t.Fatalf("InsertKindAssignment(b): %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
@@ -135,7 +138,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
}
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
t.Fatalf("expected userA's activities list to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
}
}
@@ -145,11 +148,82 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
}
}
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
// successful Garmin auth never flips garmin_connected for another user.
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
var meB sessionMeResponse
unmarshalBody(t, rec, &meB)
if meB.GarminConnected {
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
}
}
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
// subject's pending ephemeral Garmin session is invisible to another
// subject -- e.g. subject B completing MFA must not accidentally continue
// subject A's in-progress attempt.
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "a@example.com", "garmin_password": "pw-a",
})
if rec.Code != http.StatusOK {
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
if rec.Code != http.StatusConflict {
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"geniusrun/backend/internal/store"
)
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
return nil
}
// profilePayload is the profile API's request/response shape: the profiles
// row plus Name, which lives on the users row (the account's single
// human-facing name) but is edited from the same Profile screen.
type profilePayload struct {
store.Profile
Name string
}
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
p, err := s.DB.GetProfile(r.Context(), userID)
@@ -42,26 +51,35 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, p)
u, _ := userFromContext(r.Context())
writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name})
}
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var p store.Profile
var p profilePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := validateProfile(p); err != nil {
if strings.TrimSpace(p.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := validateProfile(p.Profile); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil {
if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
client, err := s.garminFor(r.Context(), userID)
if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -73,5 +91,31 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
writeJSON(w, http.StatusOK, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)})
}
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeUserClient(userID)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -1,258 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/store"
)
// defaultReviewQueuePageSize matches the frontend's initial/incremental
// page size for its infinite-scroll list.
const defaultReviewQueuePageSize = 10
type reviewQueueItem struct {
store.KindAssignment
Activity activityResponse `json:"activity"`
Laps []lapResponse `json:"laps"`
Samples []store.Sample `json:"samples"`
}
// handleReviewQueue backs the Activities page: every activity that has been
// classified at least once, not just ones still needing review, so the page
// can show each activity's current kind (or "Unclassified") and let the user
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
// (especially) per-second samples is expensive once there are many of them,
// so that work only happens for the requested page, not the full backlog.
// Sorting/cursor filtering still needs each activity's summary row (a cheap
// indexed lookup, no laps/samples), which happens for the whole backlog --
// only the heavy per-item fetches are deferred to the page actually being
// returned. The optional kind_id/unclassified filters are applied before
// that cursor slicing too, so a filtered view still only loads (and
// chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
limit := defaultReviewQueuePageSize
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
var kindIDFilter *int64
if v := r.URL.Query().Get("kind_id"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
kindIDFilter = &n
}
}
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type withActivity struct {
assignment store.KindAssignment
activity store.Activity
}
all := make([]withActivity, 0, len(queue))
for _, a := range queue {
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
continue
}
if unclassifiedOnly && a.WorkoutKindID != nil {
continue
}
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
continue
}
all = append(all, withActivity{assignment: a, activity: activity})
}
// Most recent run first, by when the activity actually happened (not
// when the rule engine flagged it), so the queue reads like a log.
sort.Slice(all, func(i, j int) bool {
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
})
total := len(all)
if cursor != "" {
idx := 0
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
idx++
}
all = all[idx:]
}
hasMore := len(all) > limit
if len(all) > limit {
all = all[:limit]
}
items := make([]reviewQueueItem, 0, len(all))
for _, wa := range all {
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Per-second telemetry, not just per-lap averages, so the chart can
// show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps).
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
items = append(items, reviewQueueItem{
KindAssignment: wa.assignment,
Activity: toActivityResponse(wa.activity),
Laps: toLapResponses(laps),
Samples: samples,
})
}
var nextCursor *string
if hasMore && len(items) > 0 {
c := items[len(items)-1].Activity.StartTimeUTC
nextCursor = &c
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"next_cursor": nextCursor,
"total": total,
})
}
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
var body struct {
WorkoutKindID int64 `json:"workout_kind_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.WorkoutKindID == 0 {
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
return
}
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else if !ok {
writeError(w, http.StatusBadRequest, "workout kind not found")
return
}
if kind.Name == "Race" {
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
return
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// handleUnassignReview manually clears an activity's kind back to
// Unclassified. Like handleResolveReview, it's a deliberate manual choice --
// recorded as source=manual (with no kind) so the rule engine leaves it alone
// on a later reclassify pass, exactly as it would for a manual kind
// assignment. The frontend only offers this while the activity is unlocked
// (locked activities must be unlocked first, same precondition as picking a
// different kind), but the backend doesn't re-enforce that here, matching
// handleResolveReview's own lack of a lock precondition check.
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
}
// handleUnlockReview reverts a manual assignment back to rule_engine
// sourcing, keeping the same kind, so a later reclassify pass (which always
// skips manual assignments) is free to change it again. It's the inverse of
// handleResolveReview, not a delete: the kind stays visible as-is until
// something actually reclassifies it.
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity has no assignment yet")
return
}
if current.AssignmentSource != store.AssignmentSourceManual {
writeError(w, http.StatusBadRequest, "activity is not manually locked")
return
}
status := store.AssignmentStatusAssigned
if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
}

View File

@@ -4,20 +4,25 @@ package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
applog "geniusrun/backend/internal/log"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"time"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Server wires the HTTP handlers to the app's dependencies. garmin.Client
@@ -26,124 +31,58 @@ import (
type Server struct {
DB *store.DB
Auth auth.Verifier
Session SessionConfig
SessionConfig SessionConfig
// GarminFactory builds a real (or fake, in tests) garmin.Client from a
// fully-resolved per-user Config. Production wiring passes
// garmin.NewClient; tests inject a factory returning a shared
// *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config
// (subprocess paths + the token-store root directory); only
GarminFactory func(garmin.ClientConfig) garmin.Client
// ClientConfig holds the plumbing shared by every user's garmin.Config
// (the python interpreter path + the token-store root directory); only
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor.
GarminBase garmin.Config
SyncConfig appsync.Config
ClientConfig garmin.ClientConfig
SyncConfig garmin.SyncConfig
// EnvVars is the read-only, display-safe environment-configuration
// snapshot served by GET /api/config -- built once in main.go from
// config.Config.DisplayEnv() (secrets already masked there); handlers
// never call os.Getenv.
EnvVars []EnvVar
mu sync.Mutex
userGarmin map[int64]garmin.Client
userSync map[int64]*appsync.Service
userClient map[int64]garmin.Client
userSync map[int64]*garmin.Sync
userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string
userSyncRunning map[int64]bool
setupSession map[string]*setupSession
}
// NewServer builds a Server.
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server {
func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
return &Server{
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
Auth: authVerifier, Session: session,
userGarmin: map[int64]garmin.Client{},
userSync: map[int64]*appsync.Service{},
DB: db,
GarminFactory: garminFactory,
ClientConfig: garminBase,
SyncConfig: syncConfig,
Auth: authVerifier,
SessionConfig: sessionConfig,
userClient: map[int64]garmin.Client{},
userSync: map[int64]*garmin.Sync{},
userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{},
}
}
// garminFor returns userID's garmin.Client, building and caching it (from
// userID's own profile row) on first use.
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userGarmin[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.GarminBase
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
}
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.userGarmin[userID]; ok {
return c, nil // built concurrently by another request between our unlock and re-lock
}
client := s.GarminFactory(cfg)
s.userGarmin[userID] = client
return client, nil
}
// syncFor returns userID's sync.Service, building and caching it on first use.
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.garminFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
}
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
// every provisioned user in turn, replacing the old single-global-Service
// background loop.
func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) {
users, err := s.DB.ListUsers(ctx)
if err != nil {
log.Printf("api: list users for incremental sync: %v", err)
return
}
for _, u := range users {
svc, err := s.syncFor(ctx, u.ID)
if err != nil {
log.Printf("api: sync service for user %d: %v", u.ID, err)
continue
}
if err := svc.IncrementalSync(ctx); err != nil {
log.Printf("api: incremental sync for user %d: %v", u.ID, err)
continue
}
if err := svc.FillPendingDetails(ctx, 50); err != nil {
log.Printf("api: fill pending details for user %d: %v", u.ID, err)
}
setupSession: map[string]*setupSession{},
}
}
// Router builds the HTTP routes.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(loggingMiddleware)
r.Use(corsMiddleware)
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth)
@@ -154,12 +93,19 @@ func (s *Server) Router() http.Handler {
r.Get("/session/callback", s.handleSessionCallback)
r.Group(func(r chi.Router) {
r.Use(auth.RequireSession(s.Session.Secret))
r.Use(auth.RequireSession(s.SessionConfig.Secret))
r.Use(s.resolveUser)
r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout)
r.Post("/setup", s.handleSetup)
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleSetupGarminLogin)
r.Post("/mfa", s.handleSetupGarminMFA)
})
})
r.Group(func(r chi.Router) {
r.Use(requireProvisionedUser)
@@ -167,24 +113,30 @@ func (s *Server) Router() http.Handler {
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
})
r.Route("/garmin", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin)
r.Post("/mfa", s.handleAuthMFA)
r.Get("/status", s.handleAuthStatus)
r.Post("/login", s.handleGarminAuthLogin)
r.Post("/mfa", s.handleGarminAuthMFA)
r.Get("/status", s.handleGarminAuthStatus)
})
r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun)
r.Post("/reset", s.handleSyncReset)
r.Get("/runs", s.handleSyncRuns)
r.Get("/status", s.handleSyncStatus)
r.Post("/run", s.handleGarminSyncRun)
r.Post("/reset", s.handleGarminSyncReset)
r.Get("/runs", s.handleGarminSyncRuns)
r.Get("/status", s.handleGarminSyncStatus)
})
})
r.Route("/activities", func(r chi.Router) {
r.Get("/", s.handleListActivities)
r.Get("/{id}", s.handleGetActivity)
r.Post("/{activityID}/assign", s.handleAssignActivity)
r.Post("/{activityID}/unlock", s.handleUnlockActivity)
r.Post("/{activityID}/unassign", s.handleUnassignActivity)
})
r.Route("/workout-kinds", func(r chi.Router) {
@@ -195,12 +147,8 @@ func (s *Server) Router() http.Handler {
r.Post("/reclassify", s.handleReclassifyAll)
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)
r.Post("/{activityID}/unlock", s.handleUnlockReview)
r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/config", s.handleGetConfig)
r.Put("/config", s.handlePutConfig)
r.Get("/progression/{kindID}", s.handleProgression)
})
@@ -209,6 +157,37 @@ func (s *Server) Router() http.Handler {
return r
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// loggingMiddleware logs one type=http JSON line per HTTP request. The
// record's meaning is fully carried by its fields (http_method, path,
// status, duration_ms), so it has no msg; "method" stays reserved for the
// emitting function per the log schema, hence http_method for the verb.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
slog.Default().LogAttrs(r.Context(), level, "",
slog.String("type", "http"),
slog.String("package", "api"),
slog.String("file", "server.go"),
slog.String("method", "loggingMiddleware"),
slog.String("http_method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
)
})
}
// corsMiddleware allows the frontend dev server (a different port) to call
// this API. Reflecting any origin back is safe even with credentials
// enabled: this remains a single-operator app whose real access control is
@@ -229,20 +208,89 @@ func corsMiddleware(next http.Handler) http.Handler {
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
// clientFor returns userID's garmin.Client, building and caching it (from
// userID's own profile row) on first use.
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userClient[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.ClientConfig
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: encode response: %v", err)
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.userClient[userID]; ok {
return c, nil // built concurrently by another request between our unlock and re-lock
}
client := s.GarminFactory(cfg)
s.userClient[userID] = client
return client, nil
}
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeUserClient(userID int64) {
s.mu.Lock()
client, ok := s.userClient[userID]
delete(s.userClient, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
applog.App().Error("close garmin client for deleted user", "user_id", userID, "error", err)
}
}
if s.ClientConfig.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
applog.App().Error("remove token store dir for deleted user", "user_id", userID, "error", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
// syncFor returns userID's sync.Service, building and caching it on first use.
func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.clientFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
}
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
@@ -264,8 +312,30 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
s.mu.Unlock()
}()
if err := fn(context.Background()); err != nil {
log.Printf("api: background sync error (user %d): %v", userID, err)
applog.App().Error("background sync failed", "user_id", userID, "error", err)
}
}()
return true
}
// setupTokenStoreDir returns the token-store directory an ephemeral setup
// session for sub should use -- hashed rather than sub itself, since sub is
// an opaque string from the identity provider and using it verbatim in a
// filesystem path would be a directory-traversal risk if it ever contained
// path separators.
func setupTokenStoreDir(root, sub string) string {
h := sha256.Sum256([]byte(sub))
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
applog.App().Error("encode response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

View File

@@ -1,10 +1,11 @@
package api
import (
"log"
"net/http"
"time"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/auth"
)
@@ -14,13 +15,27 @@ import (
type SessionConfig struct {
Secret []byte
Duration time.Duration
// SetupTimeout evicts an unfinished onboarding Garmin setup session
// once idle this long (app-config key session.setup_timeout, minutes;
// distinct from Duration, the login cookie lifetime). Mandatory --
// there is no code fallback; the default lives in the DB, seeded at
// startup.
SetupTimeout time.Duration
Secure bool
// PublicBaseURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash), used to build an
// absolute post_logout_redirect_uri for the identity provider -- some
// providers, including Keycloak, require this to be an absolute URL
// matching one registered on the client, not a bare relative path.
PublicBaseURL string
// BackendURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash) -- derives
// OIDCRedirectURL (config.Config), the only thing that must stay pointed
// at the backend itself, since that's where /api/session/callback is
// actually served.
BackendURL string
// FrontendURL is the origin the browser should land on after any
// user-facing redirect: the OIDC callback (success or failure) and the
// post_logout_redirect_uri sent to the identity provider on logout. Some
// providers, including Keycloak, require an absolute URL matching one
// registered on the client, not a bare relative path -- see
// config.Config.FrontendURL for why this can differ from BackendURL in a
// split-origin deployment.
FrontendURL string
}
type sessionMeResponse struct {
@@ -28,6 +43,7 @@ type sessionMeResponse struct {
Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
GarminConnected bool `json:"garmin_connected"`
}
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
@@ -36,7 +52,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadGateway, err.Error())
return
}
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure)
cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -48,42 +64,58 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil {
log.Printf("session callback: missing txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
applog.App().Warn("missing txn cookie", "error", err)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
applog.App().Warn("failed to parse txn cookie", "error", err)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
applog.App().Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
if !result.Authorized {
http.Redirect(w, r, "/?auth_error=forbidden", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
return
}
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, sessionCookie)
http.Redirect(w, r, "/", http.StatusFound)
http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
}
// handleSessionLogout clears geniusrun's own session cookie and redirects
// through Keycloak's end-session endpoint, passing the session's ID token
// as id_token_hint (read back from the cookie via
// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims)
// so Keycloak can skip its own logout-confirmation prompt -- otherwise a
// user could cancel out of it and land back on the app with a Keycloak SSO
// session but no geniusrun profile (already deleted, in the
// profile-deletion case this exists for).
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound)
claims, _ := auth.ClaimsFromContext(r.Context())
s.removeSetupSession(claims.Sub)
// Best-effort: an unreadable cookie just means logging out without the
// hint, at worst showing Keycloak's own confirmation screen.
var idToken string
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret)
}
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
}
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
@@ -97,7 +129,13 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true
resp.DisplayName = u.DisplayName
resp.DisplayName = u.Name
profile, err := s.DB.GetProfile(r.Context(), u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp.GarminConnected = profile.GarminConnectedAt != nil
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -3,11 +3,120 @@ package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
)
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Closed (never promoted into Server.userClient) once
// /api/setup/complete actually creates the account -- its Client's
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
// setup/{hash} directory, so reusing the object after that directory is
// renamed to the permanent {userID} one would respawn against a stale path
// the next time anything closes and restarts its subprocess; a later
// garminFor(ctx, userID) call builds a fresh client with the correct path
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
// subject checks staleness first) once idle past SessionConfig.SetupTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
// row exists yet at this point (see setupSession in server.go).
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if _, found := userFromContext(r.Context()); found {
writeError(w, http.StatusConflict, "profile already exists for this account")
return
}
var body struct {
GarminEmail string `json:"garmin_email"`
GarminPassword string `json:"garmin_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.GarminEmail == "" || body.GarminPassword == "" {
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
return
}
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
res, err := sess.Client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordSetupAuthResult(claims.Sub, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
// (started by handleSetupGarminLogin) with an MFA code, on the same
// session/subprocess -- never replaces it.
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
sess, ok := s.setupSessionFor(claims.Sub)
if !ok {
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
return
}
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordSetupAuthResult(claims.Sub, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
// handleSetupComplete is the single atomic commit point: only reachable
// once the ephemeral session for this subject last reported
// garmin.AuthSuccess. Provisions the account, persists the Garmin
// credentials, marks it connected, closes the ephemeral client, and
// renames its token-store directory into the permanent per-user path --
// the next real clientFor(ctx, userID) builds a fresh client from scratch
// against that now-permanent directory, whose subprocess's lazy
// startup-login resumes the just-renamed, still-valid session without
// needing to re-authenticate (a cheap local token-store resume, not a
// fresh Garmin login).
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
@@ -30,10 +139,145 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
return
}
sess, ok := s.setupSessionFor(claims.Sub)
if !ok || sess.Status != garmin.AuthSuccess {
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
return
}
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
profile, err := s.DB.GetProfile(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
profile.GarminEmail = sess.Email
profile.GarminPassword = sess.Password
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// The ephemeral client's own garmin.Config.TokenStorePath was set once,
// at construction time in replaceSetupSession, to the ephemeral
// setup/{hash} directory being renamed below -- there's no setter to
// correct it in place, so promoting this object into s.userGarmin would
// leave a client whose subprocess respawns (e.g. on the very next
// UpdateCredentials call from a Profile save) using that now-stale
// path, recreating a setup/{hash} directory next to the real one.
// Closing it here and leaving s.userGarmin empty for this user makes
// the next garminFor(ctx, userID) call build a fresh client against the
// correct, just-renamed {userID} directory instead -- its subprocess's
// lazy startup-login resumes that session without a real Garmin
// re-authentication.
sess.Client.Close()
s.mu.Lock()
delete(s.setupSession, claims.Sub)
s.userAuthStatus[userID] = sess.Status
s.userAuthMessage[userID] = sess.Message
s.mu.Unlock()
if s.ClientConfig.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
// A stale {userID} directory can survive from a previous account
// with the same id -- pre-production the DB file is freely deleted
// and recreated (ids restart at 1) while the token-store root lives
// on, and os.Rename refuses to replace a non-empty directory. The
// just-validated setup session must win, so clear the target first.
if err := os.RemoveAll(newDir); err != nil {
applog.App().Error("remove stale token store dir", "user_id", userID, "error", err)
}
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
applog.App().Error("rename setup token store dir", "user_id", userID, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
}
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
// any and not stale. A stale session is closed and evicted first, so the
// caller always either gets a fresh, live session or none.
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.setupSession[sub]
if !ok {
return nil, false
}
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
sess.Client.Close()
delete(s.setupSession, sub)
if s.ClientConfig.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
}
return nil, false
}
return sess, true
}
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
// (if any) with a freshly built one for the given credentials -- same
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
s.mu.Lock()
old, ok := s.setupSession[sub]
s.mu.Unlock()
if ok {
old.Client.Close()
}
cfg := s.ClientConfig
cfg.GarminEmail = email
cfg.GarminPassword = password
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
}
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
s.mu.Lock()
s.setupSession[sub] = sess
s.mu.Unlock()
return sess
}
// recordSetupAuthResult updates sub's ephemeral session after a login or
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.setupSession[sub]; ok {
sess.Status = res.Status
sess.Message = res.Message
sess.LastUsed = time.Now()
}
}
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
// any, and best-effort removes its token-store directory. Used when
// abandoning onboarding (logout). handleSetupComplete closes the client the
// same way but keeps (renames) the directory instead of removing it, since
// that's the real, now-permanent session.
func (s *Server) removeSetupSession(sub string) {
s.mu.Lock()
sess, ok := s.setupSession[sub]
delete(s.setupSession, sub)
s.mu.Unlock()
if !ok {
return
}
sess.Client.Close()
if s.ClientConfig.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
}
}

View File

@@ -4,82 +4,314 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) {
// This test specifically needs an *unprovisioned* session, unlike every
// other test in this package -- build the server without the
// newTestServer helper's automatic ProvisionUser call.
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
// (the identity doJSON's cookie always mints) has no users/profile row yet
// -- every test in this file needs that starting state, unlike
// newTestServer's auto-provisioned default.
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, m
}
func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) {
s, db, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.HasProfile {
t.Fatal("expected a brand-new session to have no profile yet")
}
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String())
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp authResponse
unmarshalBody(t, rec, &resp)
if resp.Status != "authenticated" {
t.Fatalf("status = %q, want authenticated", resp.Status)
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
unmarshalBody(t, rec, &me)
if !me.HasProfile || me.DisplayName != "Lucie" {
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
}
}
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub
func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
s, db, m := newUnprovisionedServer(t)
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp authResponse
unmarshalBody(t, rec, &resp)
if resp.Status != "mfa_required" {
t.Fatalf("status = %q, want mfa_required", resp.Status)
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
if rec.Code != http.StatusOK {
t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String())
}
unmarshalBody(t, rec, &resp)
if resp.Status != "authenticated" {
t.Fatalf("status after mfa = %q, want authenticated", resp.Status)
}
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err)
}
}
func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClient(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
var factoryConfigs []garmin.ClientConfig
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
factoryConfigs = append(factoryConfigs, cfg)
return m
}
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if u.DisplayName != "Lucie" {
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
}
}
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
profile, err := db.GetProfile(newCtx(), u.ID)
if err != nil {
t.Fatalf("store.Open: %v", err)
t.Fatalf("GetProfile: %v", err)
}
if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" {
t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile)
}
if profile.GarminConnectedAt == nil {
t.Error("expected GarminConnectedAt to be set")
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""})
if m.AuthenticateCalls != 1 {
t.Errorf("AuthenticateCalls = %d, want 1 (only the original login, no redundant re-authentication)", m.AuthenticateCalls)
}
if !m.ClosedCalled {
t.Error("expected the ephemeral client to be Close()d at setup completion, not promoted as-is -- its cfg.TokenStorePath still points at the ephemeral setup/{hash} dir, which would go stale the moment anything (e.g. a Profile save) later respawns it")
}
// A later real use must build a genuinely fresh client, configured
// against the permanent {userID} token store directory -- never the
// stale ephemeral setup/{hash} one the closed client was carrying.
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
t.Fatalf("garminFor: %v", err)
}
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
var gotTokenStorePath string
for _, cfg := range factoryConfigs {
if cfg.GarminEmail == "runner@example.com" {
gotTokenStorePath = cfg.TokenStorePath
}
}
if gotTokenStorePath != wantTokenStorePath {
t.Errorf("garminFor built client with TokenStorePath = %q, want %q", gotTokenStorePath, wantTokenStorePath)
}
}
func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) {
s, db, _ := newUnprovisionedServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created, found=%v err=%v", found, err)
}
}
func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"})
func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
// Simulate what a real subprocess would have written under the
// ephemeral (subject-keyed) directory during that login call.
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
if err := os.MkdirAll(oldDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
}
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
}
}
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
}
}
// Regression: os.Rename refuses to replace an existing directory, so a
// stale {userID} token-store dir (left behind when the DB file was
// recreated -- ids restart at 1 -- while the token-store root survived)
// used to make setup completion silently keep the OLD tokens in place.
// The fresh, just-validated session's directory must win.
func TestSetupComplete_ReplacesStaleTokenStoreDir(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "setup_stale_dir_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
// The ephemeral setup dir the wrapper would have written tokens into.
setupDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
if err := os.MkdirAll(setupDir, 0o755); err != nil {
t.Fatalf("mkdir setup dir: %v", err)
}
if err := os.WriteFile(filepath.Join(setupDir, "oauth_token"), []byte("fresh"), 0o600); err != nil {
t.Fatalf("write fresh token: %v", err)
}
// A stale dir already occupying the permanent {userID} path (fresh DB
// starts ids at 1).
staleDir := filepath.Join(tokenStoreRoot, "1")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
t.Fatalf("mkdir stale dir: %v", err)
}
if err := os.WriteFile(filepath.Join(staleDir, "oauth_token"), []byte("stale"), 0o600); err != nil {
t.Fatalf("write stale token: %v", err)
}
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
token, err := os.ReadFile(filepath.Join(tokenStoreRoot, itoa(u.ID), "oauth_token"))
if err != nil {
t.Fatalf("read token after complete: %v", err)
}
if string(token) != "fresh" {
t.Fatalf("token content = %q, want the fresh setup session to replace the stale dir", token)
}
if _, err := os.Stat(setupDir); !os.IsNotExist(err) {
t.Errorf("ephemeral setup dir still present after rename: %v", err)
}
}

View File

@@ -1,102 +0,0 @@
package api
import (
"context"
"net/http"
)
// detailFillBatchSize bounds how many activities' details/splits are fetched
// per sync trigger, matching the sequential rate-limited fetch in
// internal/sync.Service.FillPendingDetails.
const detailFillBatchSize = 50
// handleSyncRun does a full sync pass: Backfill first (resumes from the
// watermark, so widening the configured history horizon between clicks is
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
// handleSyncReset wipes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Sync Now
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.ResetAll(ctx)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
progress := svc.Progress()
resp := map[string]any{
"in_progress": inProgress,
"detail_fill_progress": progress,
"activities_pending_details": remaining,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -15,7 +15,7 @@ const resolvedUserContextKey userContextKey = iota
// session's OIDC subject.
type resolvedUser struct {
ID int64
DisplayName string
Name string
}
// resolveUser runs after auth.RequireSession on every request and looks up
@@ -40,7 +40,7 @@ func (s *Server) resolveUser(next http.Handler) http.Handler {
}
ctx := r.Context()
if found {
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, Name: u.Name})
}
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@@ -9,9 +9,7 @@ import (
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) {
}
func TestRequireSession_ValidCookie(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false)
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, "", []byte(testSecret), time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
@@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) {
}
func TestRequireSession_ExpiredCookie(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
@@ -59,11 +59,11 @@ func TestRequireSession_ExpiredCookie(t *testing.T) {
}
func TestRequireSession_TamperedCookie(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
cookie.Value = flipLastChar(cookie.Value)
cookie.Value = flipSignatureChar(cookie.Value)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()

View File

@@ -18,6 +18,7 @@ type Verifier struct {
CallbackResult auth.LoginResult
CallbackErr error
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
}
var _ auth.Verifier = (*Verifier)(nil)
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
return v.CallbackResult, nil
}
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string {
func (v *Verifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
v.LastIDTokenHint = idTokenHint
if v.EndSessionResult != "" {
return v.EndSessionResult
}

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64"
"fmt"
"net/url"
"slices"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -24,13 +25,21 @@ type Verifier interface {
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
// EndSessionURL builds the identity provider's logout URL, redirecting
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
EndSessionURL(postLogoutRedirectURL string) string
// idTokenHint, if non-empty, is passed as id_token_hint so Keycloak can
// positively identify the session being ended and skip its own
// logout-confirmation prompt (which would otherwise let the user cancel
// out of logout after their geniusrun account is already deleted).
EndSessionURL(postLogoutRedirectURL, idTokenHint string) string
}
// LoginResult is what a completed callback exchange resolves to.
type LoginResult struct {
Claims Claims
Authorized bool
// IDToken is the raw Keycloak ID token JWT, kept apart from Claims:
// it's only ever needed once more, at logout (id_token_hint), so it
// rides in the session cookie but never in the per-request Claims.
IDToken string
}
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
@@ -55,12 +64,7 @@ type idTokenClaims struct {
}
func (c idTokenClaims) hasRole(required string) bool {
for _, r := range c.RealmAccess.Roles {
if r == required {
return true
}
}
return false
return slices.Contains(c.RealmAccess.Roles, required)
}
type oidcVerifier struct {
@@ -137,10 +141,11 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
return LoginResult{
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
Authorized: claims.hasRole(v.requiredRole),
IDToken: rawIDToken,
}, nil
}
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
@@ -154,6 +159,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
q := u.Query()
q.Set("client_id", v.oauth2Config.ClientID)
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
u.RawQuery = q.Encode()
return u.String()
}

View File

@@ -1,10 +1,83 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// newTestOIDCVerifier spins up a fake OIDC discovery endpoint (just enough
// for oidc.NewProvider's discovery GET to succeed) and returns a real
// oidcVerifier backed by it, for tests that exercise EndSessionURL without
// a live Keycloak.
func newTestOIDCVerifier(t *testing.T) Verifier {
t.Helper()
var issuerURL string
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{
"issuer": %[1]q,
"authorization_endpoint": "%[1]s/auth",
"token_endpoint": "%[1]s/token",
"end_session_endpoint": "%[1]s/logout",
"jwks_uri": "%[1]s/certs"
}`, issuerURL)
})
mux.HandleFunc("/certs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"keys":[]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
issuerURL = srv.URL
verifier, err := NewOIDCVerifier(context.Background(), OIDCConfig{
IssuerURL: issuerURL, ClientID: "geniusrun", ClientSecret: "secret", RedirectURL: issuerURL + "/callback",
})
if err != nil {
t.Fatalf("NewOIDCVerifier: %v", err)
}
return verifier
}
func TestEndSessionURL_IncludesIDTokenHintWhenProvided(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "raw-id-token-jwt")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
q := u.Query()
if q.Get("id_token_hint") != "raw-id-token-jwt" {
t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), "raw-id-token-jwt")
}
if q.Get("client_id") != "geniusrun" {
t.Errorf("client_id = %q, want geniusrun", q.Get("client_id"))
}
if q.Get("post_logout_redirect_uri") != "https://app.example.com/" {
t.Errorf("post_logout_redirect_uri = %q, want https://app.example.com/", q.Get("post_logout_redirect_uri"))
}
}
func TestEndSessionURL_OmitsIDTokenHintWhenEmpty(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
if u.Query().Has("id_token_hint") {
t.Errorf("expected no id_token_hint param when idTokenHint is empty, got %q", got)
}
}
func TestIDTokenClaims_HasRole(t *testing.T) {
cases := []struct {
name string

View File

@@ -1,9 +1,17 @@
// Package auth implements geniusrun's own login gate: an OIDC Authorization
// Code flow against an existing Keycloak realm (see oidc.go), backed by a
// signed session cookie geniusrun mints itself (this file) and a chi
// middleware that checks it (middleware.go). Keycloak's own tokens are never
// stored or refreshed -- once HandleCallback verifies the ID token and role,
// only this package's own cookie matters for subsequent requests.
// middleware that checks it (middleware.go). Keycloak's own access/refresh
// tokens are never stored or refreshed -- once HandleCallback verifies the
// ID token and role, only this package's own cookie matters for subsequent
// requests. The one exception is the raw ID token itself, carried opaquely
// inside the signed session cookie (apart from Claims -- see
// MintSessionCookie's idToken parameter and IDTokenFromSessionCookie)
// solely so a later logout can pass it back to Keycloak as
// id_token_hint -- letting Keycloak
// skip its own logout-confirmation prompt for a session it can positively
// identify, rather than leaving the user a chance to cancel out of it after
// their geniusrun account (and profile) is already gone.
package auth
import (
@@ -26,7 +34,11 @@ const (
)
// Claims identifies the authenticated user, carried in the signed session
// cookie.
// cookie and stashed in every request's context. Deliberately does NOT
// carry the raw Keycloak ID token: that JWT lives in the cookie payload
// separately (see MintSessionCookie/IDTokenFromSessionCookie) because it's
// only ever needed once more, at logout, and has no business riding
// through every handler's context.
type Claims struct {
Sub string
Name string
@@ -37,6 +49,7 @@ type sessionClaims struct {
Sub string `json:"sub"`
Name string `json:"name"`
Email string `json:"email"`
IDToken string `json:"id_token,omitempty"`
jwt.RegisteredClaims
}
@@ -55,12 +68,15 @@ type txnClaims struct {
// MintSessionCookie signs claims into a JWT valid for duration and wraps it
// in a cookie. secure should be true whenever the app is served over HTTPS.
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
// idToken is the raw Keycloak ID token to carry for the eventual logout's
// id_token_hint (empty is fine, e.g. in tests -- the hint is optional).
func MintSessionCookie(claims Claims, idToken string, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
Sub: claims.Sub,
Name: claims.Name,
Email: claims.Email,
IDToken: idToken,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
@@ -94,6 +110,20 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
}
// IDTokenFromSessionCookie verifies the cookie and returns the raw Keycloak
// ID token it carries, for logout's id_token_hint. Only the logout handler
// needs this -- everything else uses ParseSessionCookie's Claims.
func IDTokenFromSessionCookie(cookie *http.Cookie, secret []byte) (string, error) {
if cookie == nil {
return "", fmt.Errorf("no session cookie")
}
var sc sessionClaims
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
return "", fmt.Errorf("parse session token: %w", err)
}
return sc.IDToken, nil
}
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) {
now := time.Now()

View File

@@ -9,7 +9,7 @@ const testSecret = "test-secret-at-least-32-bytes-long!"
func TestMintAndParseSessionCookie(t *testing.T) {
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true)
cookie, err := MintSessionCookie(claims, "raw-id-token-jwt", []byte(testSecret), time.Hour, true)
if err != nil {
t.Fatalf("mint: %v", err)
}
@@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) {
if got != claims {
t.Fatalf("got %+v, want %+v", got, claims)
}
// The ID token rides in the cookie apart from Claims, retrievable only
// through the dedicated logout-path helper.
idToken, err := IDTokenFromSessionCookie(cookie, []byte(testSecret))
if err != nil {
t.Fatalf("IDTokenFromSessionCookie: %v", err)
}
if idToken != "raw-id-token-jwt" {
t.Fatalf("idToken = %q, want raw-id-token-jwt", idToken)
}
}
func TestParseSessionCookie_Expired(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false)
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
@@ -37,33 +47,46 @@ func TestParseSessionCookie_Expired(t *testing.T) {
}
func TestParseSessionCookie_Tampered(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}
cookie.Value = flipLastChar(cookie.Value)
cookie.Value = flipSignatureChar(cookie.Value)
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
t.Fatal("expected error for tampered cookie")
}
}
// flipLastChar corrupts a signed token for tamper tests, guaranteeing the
// last character actually changes -- blindly overwriting it with a fixed
// character (e.g. "x") would occasionally be a no-op if that character
// already happened to be there (it's derived from the token's embedded
// timestamp, so this isn't as rare as it sounds), silently passing the
// test without having tampered with anything.
func flipLastChar(s string) string {
last := s[len(s)-1]
// flipSignatureChar corrupts a signed JWT for tamper tests by changing the
// second-to-last character of its base64url signature, guaranteeing the
// decoded signature bytes actually change. Two pitfalls to avoid here:
// 1. Blindly overwriting a character with a fixed replacement (e.g. "x")
// would occasionally be a no-op if that character was already there --
// it's derived from the token's embedded timestamp, so this isn't as
// rare as it sounds.
// 2. Flipping the *last* character of the signature specifically (as this
// helper used to) is flaky in a subtler way: HMAC-SHA256 produces a
// 32-byte digest, which base64url-encodes to 43 characters with a
// final 3-character group covering only a 2-byte remainder -- the
// true last character encodes 4 real bits plus 2 unused padding bits.
// Go's encoding/base64 ignores those padding bits when decoding
// (non-strict by default), so about 1 in 4 replacement characters for
// that position decode to byte-identical signature bytes, silently
// passing the test without having tampered with anything. The
// second-to-last character of that final group has no such unused
// bits, so corrupting it is deterministic.
func flipSignatureChar(s string) string {
pos := len(s) - 2
orig := s[pos]
replacement := byte('x')
if last == replacement {
if orig == replacement {
replacement = 'y'
}
return s[:len(s)-1] + string(replacement)
return s[:pos] + string(replacement) + s[pos+1:]
}
func TestParseSessionCookie_WrongSecret(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false)
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil {
t.Fatalf("mint: %v", err)
}

View File

@@ -0,0 +1,103 @@
package config
import (
"fmt"
"strconv"
"time"
)
// AppKey describes one application-configuration key: instance-global,
// stored (as an override) in the DB `config` table, read once at startup.
// Cold -- a changed value applies on the next backend restart.
type AppKey struct {
Key string
Default string
Description string
Validate func(value string) error
}
// KeySessionDuration is the session cookie lifetime, in integer hours.
const (
KeySessionDuration = "session.duration"
// KeySessionSetupTimeout bounds an *onboarding Garmin setup session*
// (the ephemeral, pre-account login attempt), not the login cookie --
// session.duration is how long a signed-in user stays signed in
// (hours); session.setup_timeout is how long an unfinished setup
// attempt survives untouched before its subprocess is torn down
// (minutes).
KeySessionSetupTimeout = "session.setup_timeout"
)
var appRegistry = []AppKey{
{
Key: KeySessionDuration,
Default: "720",
Description: "Session cookie lifetime in hours",
Validate: validatePositiveInt,
},
{
Key: KeySessionSetupTimeout,
Default: "15",
Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session",
Validate: validatePositiveInt,
},
}
// AppRegistry returns every known application-configuration key, in
// display order.
func AppRegistry() []AppKey { return appRegistry }
func validatePositiveInt(v string) error {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fmt.Errorf("must be a positive integer, got %q", v)
}
return nil
}
// ValidateAppValue rejects unknown keys and invalid values -- every write
// path's guard, so the config table can never accumulate junk.
func ValidateAppValue(key, value string) error {
for _, k := range appRegistry {
if k.Key == key {
if err := k.Validate(value); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
return nil
}
}
return fmt.Errorf("unknown configuration key %q", key)
}
// AppConfig is the typed application configuration, built from the DB's
// config rows.
type AppConfig struct {
SessionDuration time.Duration
SetupTimeout time.Duration
}
// LoadApp builds the typed AppConfig from the config table's rows. Every
// registry key is mandatory: main seeds missing keys with their defaults
// at startup (see cmd/geniusrund), so a missing key here means that
// seeding didn't run -- fail fast, same posture as LoadEnv() for env
// vars, and likewise for an unknown or invalid stored value. Pure -- it
// takes the raw map instead of a *store.DB so this package needs no store
// dependency and the logic tests without a database.
func LoadApp(values map[string]string) (AppConfig, error) {
for key, value := range values {
if err := ValidateAppValue(key, value); err != nil {
return AppConfig{}, fmt.Errorf("app config: %w", err)
}
}
for _, k := range appRegistry {
if _, ok := values[k.Key]; !ok {
return AppConfig{}, fmt.Errorf("app config: missing key %q (defaults are seeded into the DB at startup)", k.Key)
}
}
hours, _ := strconv.Atoi(values[KeySessionDuration])
setupMinutes, _ := strconv.Atoi(values[KeySessionSetupTimeout])
return AppConfig{
SessionDuration: time.Duration(hours) * time.Hour,
SetupTimeout: time.Duration(setupMinutes) * time.Minute,
}, nil
}

View File

@@ -0,0 +1,88 @@
package config
import (
"strings"
"testing"
"time"
)
func TestLoadApp(t *testing.T) {
tests := []struct {
name string
values map[string]string
want time.Duration
wantIdle time.Duration
wantErr string
}{
// Every registry key is mandatory: main seeds the DB with defaults
// at startup, so LoadApp always receives a complete map.
{name: "seeded defaults", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15"}, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
{name: "custom values", values: map[string]string{"session.duration": "168", "session.setup_timeout": "30"}, want: 168 * time.Hour, wantIdle: 30 * time.Minute},
{name: "missing key fails fast", values: map[string]string{"session.duration": "720"}, wantErr: "missing key"},
{name: "nil map fails fast", values: nil, wantErr: "missing key"},
{name: "invalid stored value", values: map[string]string{"session.duration": "zero", "session.setup_timeout": "15"}, wantErr: "session.duration"},
{name: "invalid setup timeout", values: map[string]string{"session.duration": "720", "session.setup_timeout": "0"}, wantErr: "session.setup_timeout"},
{name: "unknown stored key", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15", "bogus.key": "1"}, wantErr: "unknown configuration key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LoadApp(tt.values)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("LoadApp: %v", err)
}
if got.SessionDuration != tt.want {
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
}
if got.SetupTimeout != tt.wantIdle {
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle)
}
})
}
}
func TestValidateAppValue(t *testing.T) {
if err := ValidateAppValue("session.duration", "24"); err != nil {
t.Fatalf("valid value rejected: %v", err)
}
if err := ValidateAppValue("session.duration", "-1"); err == nil {
t.Fatal("negative hours accepted")
}
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
t.Fatal("non-integer accepted")
}
if err := ValidateAppValue("nope", "1"); err == nil {
t.Fatal("unknown key accepted")
}
}
func TestDisplayEnv_MasksSecrets(t *testing.T) {
cfg := EnvConfig{
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
}
entries := map[string]string{}
for _, e := range cfg.DisplayEnv() {
entries[e.Name] = e.Value
}
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
}
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
}
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
}
empty := EnvConfig{}
for _, e := range empty.DisplayEnv() {
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
t.Errorf("unset secret = %q, want (unset)", e.Value)
}
}
}

View File

@@ -1,133 +0,0 @@
// Package config loads geniusrund's runtime infrastructure configuration
// from environment variables (paths and process settings that don't belong
// in the user-editable profile). Garmin credentials and every tunable
// analysis-engine parameter live in the profile (internal/store.Profile)
// instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md.
package config
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// Config holds geniusrund's process-level configuration.
type Config struct {
// Addr is the HTTP listen address, e.g. ":8080".
Addr string
// DBPath is the SQLite database file path.
DBPath string
// GarminPythonPath is mcp-garmin's venv python executable.
GarminPythonPath string
// GarminServerPath is mcp-garmin's server.py.
GarminServerPath string
// GarminTokenStoreRoot is the root directory under which each user's
// mcp-garmin session cache lives (one subdirectory per user id, e.g.
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it
// defaults to a "garmin-tokenstores" directory next to DBPath so every
// deployment gets per-user isolation automatically -- multi-tenant
// operation always relies on this being a real, distinct-per-user path
// (see api.Server.garminFor), so it can never be silently left empty.
GarminTokenStoreRoot string
MinConfidence float64
IncrementalSyncEvery time.Duration
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
// OIDCRedirectURL and whether session cookies can be marked Secure,
// instead of requiring both to be configured separately and risking them
// drifting out of sync.
PublicBaseURL string
OIDCIssuerURL string
OIDCClientID string
OIDCClientSecret string
OIDCRedirectURL string
OIDCRequiredRole string
SessionSecret []byte
SessionDuration time.Duration
SessionSecure bool
}
// Load reads configuration from environment variables, applying defaults
// for anything optional. Returns an error if a required variable is unset.
func Load() (Config, error) {
cfg := Config{
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
}
if cfg.GarminTokenStoreRoot == "" {
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
}
if cfg.GarminPythonPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)")
}
if cfg.GarminServerPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)")
}
if cfg.PublicBaseURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)")
}
if cfg.OIDCIssuerURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
}
if cfg.OIDCClientID == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required")
}
if cfg.OIDCClientSecret == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
}
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
if len(sessionSecret) < 32 {
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
}
cfg.SessionSecret = []byte(sessionSecret)
cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback"
cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://")
return cfg, nil
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getEnvFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func getEnvDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}

View File

@@ -1,128 +0,0 @@
package config
import (
"path/filepath"
"testing"
"time"
)
func setRequiredEnv(t *testing.T) {
t.Helper()
t.Setenv("MCP_GARMIN_PYTHON", "/usr/bin/python3")
t.Setenv("MCP_GARMIN_SERVER", "/opt/mcp-garmin/server.py")
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com")
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
}
func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) {
setRequiredEnv(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OIDCRedirectURL != "https://geniusrun.example.com/api/session/callback" {
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
}
if !cfg.SessionSecure {
t.Error("SessionSecure = false, want true for an https base URL")
}
if cfg.OIDCRequiredRole != "geniusrun-user" {
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
}
if cfg.SessionDuration != 720*time.Hour {
t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration)
}
}
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.SessionSecure {
t.Error("SessionSecure = true, want false for an http base URL")
}
if cfg.OIDCRedirectURL != "http://localhost:8080/api/session/callback" {
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
}
}
func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
cases := []string{
"GENIUSRUN_PUBLIC_BASE_URL",
"GENIUSRUN_OIDC_ISSUER_URL",
"GENIUSRUN_OIDC_CLIENT_ID",
"GENIUSRUN_OIDC_CLIENT_SECRET",
}
for _, missing := range cases {
t.Run(missing, func(t *testing.T) {
setRequiredEnv(t)
t.Setenv(missing, "")
if _, err := Load(); err == nil {
t.Fatalf("expected error when %s is unset", missing)
}
})
}
}
func TestLoad_SessionSecretTooShort(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
if _, err := Load(); err == nil {
t.Fatal("expected error for a session secret under 32 characters")
}
}
func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GARMIN_TOKENSTORE", "")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores")
if cfg.GarminTokenStoreRoot != want {
t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want)
}
}
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" {
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot)
}
}
func TestLoad_CustomRoleAndDuration(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OIDCRequiredRole != "admin" {
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
}
if cfg.SessionDuration != 24*time.Hour {
t.Errorf("SessionDuration = %v", cfg.SessionDuration)
}
}

View File

@@ -0,0 +1,145 @@
// Package config loads geniusrund's runtime infrastructure configuration
// from environment variables (paths and process settings that don't belong
// in the user-editable profile). Garmin credentials and every tunable
// analysis-engine parameter live in the profile (internal/store.Profile)
// instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md.
package config
import (
"fmt"
"os"
"strings"
)
// EnvConfig holds geniusrund's process-level configuration.
type EnvConfig struct {
// BackendAddr is the HTTP listen address, e.g. ":8080".
BackendAddr string
// DBPath is the SQLite database file path.
DBPath string
// PythonPath is the python3 interpreter used to run the embedded
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
// Defaults to "python3" resolved via PATH if unset.
PythonPath string
// TokenStoreRoot is the root directory under which each user's
// Garmin session cache lives (one subdirectory per user id, e.g.
// "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
// independently of DBPath -- if unset, it defaults to a ".garmin"
// directory relative to the working directory the process is started
// from, not derived from DBPath in any way, so every deployment gets
// per-user isolation automatically -- multi-tenant operation always
// relies on this being a real, distinct-per-user path (see
// api.Server.garminFor), so it can never be silently left empty.
TokenStoreRoot string
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
LogLevel string
// OIDC login gate (Keycloak). BackendURL is this app's own externally
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
// OIDCRedirectURL and whether session cookies can be marked Secure,
// instead of requiring both to be configured separately and risking them
// drifting out of sync.
BackendURL string
// FrontendURL is the origin the browser should land on after the OIDC
// callback (both success and failure) -- e.g. "http://localhost:5173" in
// local dev, where the frontend and backend are different origins
// bridged by CORS (see internal/api's corsMiddleware and
// frontend/src/api/client.ts's BASE_URL). Defaults to BackendURL when
// unset, which is correct for the common production topology where a
// reverse proxy unifies frontend and backend under one origin.
// BackendURL itself must stay pointed at the backend's own origin
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
// which must match wherever those routes are actually served.
FrontendURL string
OIDCIssuerURL string
OIDCClientID string
OIDCClientSecret string
OIDCRedirectURL string
OIDCRequiredRole string
SessionSecret []byte
SessionSecure bool
}
// LoadEnv reads configuration from environment variables, applying defaults
// for anything optional. Returns an error if a required variable is unset.
func LoadEnv() (EnvConfig, error) {
cfg := EnvConfig{
BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
}
if cfg.BackendURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
}
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
if cfg.OIDCIssuerURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
}
if cfg.OIDCClientID == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_ID is required")
}
if cfg.OIDCClientSecret == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
}
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
if len(sessionSecret) < 32 {
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
}
cfg.SessionSecret = []byte(sessionSecret)
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
return cfg, nil
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// EnvEntry is one environment-configuration variable as displayed on the
// config page. Display-only: secrets are masked here, so raw values never
// leave the process.
type EnvEntry struct {
Name string
Value string
}
// DisplayEnv returns the environment configuration as a display-safe
// list, in stable order, secrets masked.
func (c EnvConfig) DisplayEnv() []EnvEntry {
mask := func(set bool) string {
if set {
return "•••• (set)"
}
return "(unset)"
}
return []EnvEntry{
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
{Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL},
{Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID},
{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")},
{Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole},
{Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)},
}
}

View File

@@ -0,0 +1,194 @@
package config
import (
"testing"
)
func setRequiredEnv(t *testing.T) {
t.Helper()
t.Setenv("GENIUSRUN_BACKEND_URL", "https://geniusrun.example.com")
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
}
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
setRequiredEnv(t)
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OIDCRedirectURL != "https://geniusrun.example.com/api/session/callback" {
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
}
if !cfg.SessionSecure {
t.Error("SessionSecure = false, want true for an https base URL")
}
if cfg.OIDCRequiredRole != "geniusrun-user" {
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
}
}
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.SessionSecure {
t.Error("SessionSecure = true, want false for an http base URL")
}
if cfg.OIDCRedirectURL != "http://localhost:8080/api/session/callback" {
t.Errorf("OIDCRedirectURL = %q", cfg.OIDCRedirectURL)
}
}
func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
cases := []string{
"GENIUSRUN_BACKEND_URL",
"GENIUSRUN_OIDC_ISSUER_URL",
"GENIUSRUN_OIDC_CLIENT_ID",
"GENIUSRUN_OIDC_CLIENT_SECRET",
}
for _, missing := range cases {
t.Run(missing, func(t *testing.T) {
setRequiredEnv(t)
t.Setenv(missing, "")
if _, err := LoadEnv(); err == nil {
t.Fatalf("expected error when %s is unset", missing)
}
})
}
}
func TestLoad_SessionSecretTooShort(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
if _, err := LoadEnv(); err == nil {
t.Fatal("expected error for a session secret under 32 characters")
}
}
func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.TokenStoreRoot != ".garmin" {
t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
}
}
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.TokenStoreRoot != "/custom/tokenstores" {
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
}
}
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
}
}
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
}
}
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_PYTHON_PATH", "")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.PythonPath != "python3" {
t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
}
}
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.PythonPath != "/opt/venv/bin/python3" {
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
}
}
func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.FrontendURL != cfg.BackendURL {
t.Errorf("FrontendURL = %q, want it to default to BackendURL %q", cfg.FrontendURL, cfg.BackendURL)
}
}
func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.FrontendURL != "http://localhost:5173" {
t.Errorf("FrontendURL = %q, want http://localhost:5173 (trailing slash trimmed)", cfg.FrontendURL)
}
}
func TestLoad_CustomRole(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OIDCRequiredRole != "admin" {
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
}
}

View File

@@ -1,23 +1,36 @@
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client
// interface, so the rest of geniusrun never deals with MCP/JSON-RPC directly.
// Package garmin wraps a direct garminconnect subprocess (see
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strconv"
"strings"
"sync"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"time"
)
//go:embed wrapper/wrapper.py
var wrapperScript string
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
// subprocess -- well above the default 64KB bufio.Scanner limit, since
// get_activity_details responses (per-second telemetry) can be several MB.
const maxWrapperLineBytes = 16 * 1024 * 1024
// Client is the interface the rest of geniusrun depends on. The real
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides
// a fake for tests and frontend-only development.
// implementation drives an embedded Python wrapper subprocess over stdio;
// internal/garmin/mock provides a fake for tests and frontend-only
// development.
type Client interface {
// Authenticate triggers Garmin login using credentials the subprocess
// was started with. Spawns the subprocess on first call.
@@ -42,180 +55,407 @@ type Client interface {
Close() error
}
// Config configures how the mcp-garmin subprocess is spawned.
type Config struct {
PythonPath string // path to mcp-garmin's venv python executable
ServerPath string // path to mcp-garmin's server.py
// ClientConfig configures how the Garmin wrapper subprocess is spawned.
type ClientConfig struct {
// PythonPath is the python3 interpreter to run the embedded wrapper
// script with. Empty defaults to "python3" resolved via PATH.
PythonPath string
GarminEmail string
GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin
// persists/resumes a Garmin session there instead of the default ~/.garth.
// TokenStorePath is passed as GARMIN_TOKENSTORE so the wrapper
// persists/resumes a Garmin session there.
TokenStorePath string
}
// mcpClient is the real Client implementation, backed by an mcp-garmin
// subprocess spoken to over stdio MCP.
type mcpClient struct {
cfg Config
// wireRequest is one line sent to the wrapper subprocess's stdin.
type wireRequest struct {
ID int `json:"id"`
Cmd string `json:"cmd"`
Params any `json:"params,omitempty"`
}
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client
inner *mcpclient.Client
// wireResponse is one line read from the wrapper subprocess's stdout.
// ErrorType/Traceback carry the Python-side failure detail in the protocol
// itself (see wrapper.py's dispatch), so the Go side logs one complete
// structured record per failed call instead of correlating stderr noise.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Traceback string `json:"traceback,omitempty"`
NotFound bool `json:"not_found,omitempty"`
}
// ErrNotFound wraps any error a Client method returns when the wrapper
// reported a definitive HTTP 404 (garminconnect's own
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
// on Garmin's side after being linked to an activity. Callers use
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
// worth retrying.
var ErrNotFound = errors.New("garmin: resource not found")
// callParams is the Params payload for a generic "call" request: dispatches
// to any garminconnect.Garmin method by name.
type callParams struct {
Method string `json:"method"`
Args map[string]any `json:"args,omitempty"`
}
// authResultWire is the Result payload for authenticate/complete_mfa.
type authResultWire struct {
Status string `json:"status"`
Message string `json:"message"`
}
func mapAuthStatus(s string) AuthStatus {
switch s {
case "success":
return AuthSuccess
case "mfa_required":
return AuthMFARequired
case "failed":
return AuthFailed
default:
return AuthUnknown
}
}
// subprocessClient is the real Client implementation, backed by a wrapper
// subprocess spoken to over newline-delimited JSON on stdio.
type subprocessClient struct {
cfg ClientConfig
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
cmd *exec.Cmd
stdin io.WriteCloser
enc *json.Encoder
scanner *bufio.Scanner
scriptPath string // temp file holding the embedded wrapper.py, written once
started bool
nextID int
stderrDone chan struct{} // closed once the stderr-copy goroutine has finished reading
}
// NewClient builds a Client. The subprocess is not spawned until the first
// call that needs it (Authenticate, or any data call once authenticated).
func NewClient(cfg Config) Client {
return &mcpClient{cfg: cfg}
}
func (c *mcpClient) ensureStarted(ctx context.Context) error {
// ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu.
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
if c.started {
return nil
}
env := []string{
if c.scriptPath == "" {
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
if err != nil {
return fmt.Errorf("write wrapper script: %w", err)
}
if _, err := f.WriteString(wrapperScript); err != nil {
f.Close()
return fmt.Errorf("write wrapper script: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("write wrapper script: %w", err)
}
c.scriptPath = f.Name()
}
pythonPath := c.cfg.PythonPath
if pythonPath == "" {
pythonPath = "python3"
}
cmd := exec.Command(pythonPath, c.scriptPath)
extraEnv := []string{
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"GARMIN_TOKENSTORE=" + c.cfg.TokenStorePath,
"PYTHONUNBUFFERED=1",
}
if c.cfg.TokenStorePath != "" {
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
}
cmd.Env = append(os.Environ(), extraEnv...)
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("spawn mcp-garmin subprocess: %w", err)
return fmt.Errorf("open wrapper stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open wrapper stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("open wrapper stderr: %w", err)
}
if stdio, ok := inner.GetTransport().(*transport.Stdio); ok {
go drainStderr(stdio)
if err := cmd.Start(); err != nil {
return fmt.Errorf("spawn wrapper subprocess: %w", err)
}
// wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
// (see its _log helper); forwardWrapperStderr re-emits them through the
// application logger so the backend's output stays one JSON stream. Per
// os/exec's StderrPipe docs, it's incorrect to call Wait before all
// reads from the pipe have completed, so close() waits on stderrDone
// before Wait-ing.
c.stderrDone = make(chan struct{})
stderrDone := c.stderrDone
go func() {
forwardWrapperStderr(stderr)
close(stderrDone)
}()
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{Name: "geniusrund", Version: "0.0.1"}
if _, err := inner.Initialize(ctx, initReq); err != nil {
inner.Close()
return fmt.Errorf("mcp initialize handshake: %w", err)
}
c.inner = inner
c.cmd = cmd
c.stdin = stdin
c.enc = json.NewEncoder(stdin)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c.scanner = scanner
c.started = true
c.nextID = 0
return nil
}
// execute sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted. Logs exactly one type=wrapper line regardless of
// outcome (the record's meaning is carried by its fields, no msg) --
// cmd/params are always safe to log in full here: Garmin credentials only
// ever reach the subprocess via env vars at spawn time (see
// ensureStarted), never through these wire params.
func (c *subprocessClient) execute(ctx context.Context, cmd string, params any) (result json.RawMessage, err error) {
start := time.Now()
var errorType, traceback string // Python-side failure detail, set from the error response
defer func() {
attrs := []slog.Attr{
slog.String("type", "wrapper"),
slog.String("package", "garmin"),
slog.String("file", "client.go"),
slog.String("class", "subprocessClient"),
slog.String("method", "execute"),
slog.String("cmd", cmd),
slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
level := slog.LevelInfo
if result != nil {
attrs = append(attrs, slog.String("result", truncate(string(result), 500)))
}
if err != nil {
level = slog.LevelError
attrs = append(attrs, slog.String("error", err.Error()))
if errorType != "" {
attrs = append(attrs, slog.String("error_type", errorType))
}
if traceback != "" {
attrs = append(attrs, slog.String("traceback", traceback))
}
}
slog.Default().LogAttrs(ctx, level, "", attrs...)
}()
c.nextID++
id := c.nextID
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmd, Params: params}); err != nil {
err = fmt.Errorf("write %s request: %w", cmd, err)
return nil, err
}
if !c.scanner.Scan() {
if serr := c.scanner.Err(); serr != nil {
err = fmt.Errorf("read %s response: %w", cmd, serr)
} else {
err = fmt.Errorf("read %s response: subprocess closed its output", cmd)
}
return nil, err
}
var resp wireResponse
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
err = fmt.Errorf("parse %s response: %w", cmd, uerr)
return nil, err
}
if resp.ID != id {
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmd, resp.ID, id)
return nil, err
}
if resp.Error != "" {
errorType, traceback = resp.ErrorType, resp.Traceback
if resp.NotFound {
err = fmt.Errorf("%s: %s: %w", cmd, resp.Error, ErrNotFound)
} else {
err = fmt.Errorf("%s: %s", cmd, resp.Error)
}
return nil, err
}
result = resp.Result
return result, nil
}
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
raw, err := c.execute(ctx, "authenticate", nil)
if err != nil {
return AuthResult{}, err
}
var wire authResultWire
if err := json.Unmarshal(raw, &wire); err != nil {
return AuthResult{}, fmt.Errorf("parse authenticate result: %w", err)
}
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
}
func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
raw, err := c.execute(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
var wire authResultWire
if err := json.Unmarshal(raw, &wire); err != nil {
return AuthResult{}, fmt.Errorf("parse complete_mfa result: %w", err)
}
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
}
// UpdateCredentials implements Client.
func (c *mcpClient) UpdateCredentials(email, password string) {
func (c *subprocessClient) UpdateCredentials(email, password string) {
c.mu.Lock()
defer c.mu.Unlock()
c.cfg.GarminEmail = email
c.cfg.GarminPassword = password
if c.started {
if c.inner != nil {
c.inner.Close()
c.close()
}
// Close implements Client.
func (c *subprocessClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.close()
}
// close terminates the subprocess, if running. Callers must hold c.mu.
func (c *subprocessClient) close() error {
if !c.started {
return nil
}
c.inner = nil
c.started = false
if c.stdin != nil {
c.stdin.Close()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
if c.stderrDone != nil {
// Killing the process closes its end of the stderr pipe, which
// unblocks the copy goroutine's Read with EOF; wait for it to finish
// before Wait, per os/exec's StderrPipe doc.
<-c.stderrDone
}
var err error
if c.cmd != nil {
err = c.cmd.Wait()
}
c.cmd, c.stdin, c.enc, c.scanner, c.stderrDone = nil, nil, nil, nil, nil
return err
}
// forwardWrapperStderr re-emits the wrapper subprocess's stderr through
// the application logger. wrapper.py writes JSON lines ({"level","msg",
// ...attrs} -- see its _log helper), which are decoded and re-logged at
// the corresponding level with their attrs preserved. Anything that isn't
// such a line (a Python startup crash before _log exists, a chatty
// third-party library printing directly) is wrapped as a warn-level
// type=wrapper record carrying the raw "line" rather than passed through, so the
// backend's combined output is JSON no matter what the subprocess does.
func forwardWrapperStderr(r io.Reader) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tracebacks can exceed the default token size
for scanner.Scan() {
line := scanner.Text()
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
slog.Warn("", "type", "wrapper", "package", "garmin", "file", "client.go", "method", "forwardWrapperStderr", "line", line)
continue
}
msg, _ := entry["msg"].(string)
levelName, _ := entry["level"].(string)
delete(entry, "msg")
delete(entry, "level")
// Python-emitted lines carry no "package" (a Go-only field) and no
// "class" (wrapper.py has none); method arrives in the entry itself
// (wrapper.py's _log stamps the emitting function).
attrs := make([]slog.Attr, 0, len(entry)+2)
attrs = append(attrs, slog.String("type", "wrapper"), slog.String("file", "wrapper.py"))
for k, v := range entry {
attrs = append(attrs, slog.Any(k, v))
}
slog.Default().LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
}
}
// drainStderr forwards the subprocess's debug/log output so it isn't
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there).
func drainStderr(stdio *transport.Stdio) {
buf := make([]byte, 4096)
for {
n, err := stdio.Stderr().Read(buf)
if n > 0 {
fmt.Print(string(buf[:n]))
}
if err != nil {
return
}
}
}
func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (string, error) {
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
res, err := c.inner.CallTool(ctx, req)
if err != nil {
return "", fmt.Errorf("call tool %s: %w", name, err)
}
var out strings.Builder
for _, content := range res.Content {
if tc, ok := content.(mcp.TextContent); ok {
out.WriteString(tc.Text)
}
}
if res.IsError {
return "", fmt.Errorf("tool %s returned an error result: %s", name, out.String())
}
return out.String(), nil
}
func (c *mcpClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "authenticate", nil)
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func (c *mcpClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func parseAuthResult(msg string) AuthResult {
switch {
case strings.Contains(msg, "Authenticated successfully") || strings.Contains(msg, "MFA accepted"):
return AuthResult{Status: AuthSuccess, Message: msg}
case strings.Contains(msg, "MFA required"):
return AuthResult{Status: AuthMFARequired, Message: msg}
// wrapperLogLevel maps wrapper.py's level strings onto slog levels,
// defaulting to info for anything unrecognized.
func wrapperLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return AuthResult{Status: AuthFailed, Message: msg}
return slog.LevelInfo
}
}
func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}
var _ Client = (*subprocessClient)(nil)
// NewClient builds a Client. The subprocess is not spawned until the first
// call that needs it (Authenticate, or any data call once authenticated).
func NewClient(cfg ClientConfig) Client {
return &subprocessClient{cfg: cfg}
}
func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return nil, err
}
msg, err := c.callTool(ctx, "get_activities", map[string]any{
"start_date": startDate,
"end_date": endDate,
"limit": limit,
raw, err := c.execute(ctx, "call", callParams{
Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate},
})
if err != nil {
return nil, err
}
var rawActivities []json.RawMessage
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil {
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err)
if err := json.Unmarshal(raw, &rawActivities); err != nil {
return nil, fmt.Errorf("get_activities_by_date did not return a JSON array (%q): %w", truncate(string(raw), 200), err)
}
if limit > 0 && limit < len(rawActivities) {
rawActivities = rawActivities[:limit]
}
activities := make([]Activity, 0, len(rawActivities))
@@ -230,15 +470,16 @@ func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string
return activities, nil
}
func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err
}
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{
"activity_id": strconv.FormatInt(activityID, 10),
raw, err := c.execute(ctx, "call", callParams{
Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
if err != nil {
return ActivitySplits{}, err
@@ -248,8 +489,8 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
ActivityID int64 `json:"activityId"`
Laps []json.RawMessage `json:"lapDTOs"`
}
if err := json.Unmarshal([]byte(msg), &envelope); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
if err := json.Unmarshal(raw, &envelope); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
}
splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
@@ -264,63 +505,48 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
return splits, nil
}
func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err
}
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{
"activity_id": strconv.FormatInt(activityID, 10),
raw, err := c.execute(ctx, "call", callParams{
Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
if err != nil {
return ActivityDetails{}, err
}
var details ActivityDetails
if err := json.Unmarshal([]byte(msg), &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
if err := json.Unmarshal(raw, &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
}
details.Raw = json.RawMessage(msg)
details.Raw = json.RawMessage(raw)
return details, nil
}
func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return Workout{}, err
}
msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{
"workout_id": strconv.FormatInt(workoutID, 10),
raw, err := c.execute(ctx, "call", callParams{
Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
})
if err != nil {
return Workout{}, err
}
var workout Workout
if err := json.Unmarshal([]byte(msg), &workout); err != nil {
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err)
if err := json.Unmarshal(raw, &workout); err != nil {
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
}
workout.Raw = json.RawMessage(msg)
workout.Raw = json.RawMessage(raw)
return workout, nil
}
func (c *mcpClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.started {
return nil
}
return c.inner.Close()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}

View File

@@ -1,29 +1,173 @@
package garmin
import "testing"
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os/exec"
"strings"
"testing"
"time"
func TestParseAuthResult(t *testing.T) {
cases := []struct {
msg string
want AuthStatus
}{
{"Authenticated successfully.", AuthSuccess},
{"MFA accepted. Authenticated successfully.", AuthSuccess},
{"MFA required. Garmin has sent a verification code...", AuthMFARequired},
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
{"Authentication failed after MFA: bad code", AuthFailed},
"geniusrun/backend/internal/log"
)
// wireResponsePayload is what a fake wrapper handler returns for one
// request; the harness fills in the response ID.
type wireResponsePayload struct {
result json.RawMessage
err string
notFound bool
}
for _, c := range cases {
got := parseAuthResult(c.msg)
if got.Status != c.want {
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want)
func fakeResult(v any) wireResponsePayload {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return wireResponsePayload{result: b}
}
func fakeError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg}
}
func fakeNotFoundError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg, notFound: true}
}
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine
// that plays the Python wrapper's role, so protocol-level Go logic can be
// tested without python3/garminconnect installed.
func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawMessage) wireResponsePayload) *subprocessClient {
t.Helper()
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqW.Close()
respW.Close()
})
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
enc := json.NewEncoder(respW)
for scanner.Scan() {
var req struct {
ID int `json:"id"`
Cmd string `json:"cmd"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
continue
}
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
if err := enc.Encode(resp); err != nil {
return
}
}
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
return &subprocessClient{
started: true,
enc: json.NewEncoder(reqW),
scanner: scanner,
}
}
func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess
func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
defer reqW.Close()
defer respW.Close()
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
scanner.Scan() // read and discard the one request
json.NewEncoder(respW).Encode(wireResponse{ID: 999, Result: json.RawMessage(`{}`)})
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
}
}
func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
defer reqW.Close()
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
scanner.Scan()
respW.Close() // subprocess "exited": stdout closes with no response
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
}
}
func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
}
}
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeNotFoundError("API Error 404")
})
_, err := c.execute(context.Background(), "call", nil)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
}
if !strings.Contains(err.Error(), "API Error 404") {
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
}
}
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.execute(context.Background(), "call", nil)
if errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
}
}
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
c.UpdateCredentials("new@example.com", "new")
@@ -33,7 +177,360 @@ func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
if c.started {
t.Error("started should be reset to false so the next call respawns the subprocess")
}
if c.inner != nil {
t.Error("inner should be cleared so ensureStarted spawns a fresh client")
}
func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) {
c := &subprocessClient{}
if err := c.Close(); err != nil {
t.Errorf("Close on an unstarted client = %v, want nil", err)
}
}
// TestSubprocessClient_Close_WaitsForStderrCopyGoroutine exercises close()
// against a real subprocess that writes to stderr, mirroring how
// ensureStarted wires up the stderr-copy goroutine. Per os/exec's
// StderrPipe docs it is incorrect to call Wait before all reads from the
// pipe have completed; this guards against close() calling cmd.Wait()
// before the copy goroutine has drained the pipe (which previously risked a
// race / truncated stderr / spurious "file already closed" errors).
func TestSubprocessClient_Close_WaitsForStderrCopyGoroutine(t *testing.T) {
cmd := exec.Command("sh", "-c", "for i in 1 2 3 4 5; do echo line$i 1>&2; done; sleep 5")
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("StderrPipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
stderrDone := make(chan struct{})
go func() {
io.Copy(io.Discard, stderr)
close(stderrDone)
}()
c := &subprocessClient{started: true, cmd: cmd, stderrDone: stderrDone}
done := make(chan error, 1)
go func() { done <- c.close() }()
select {
case <-done:
// close() returned -- since it killed the process and waited on
// stderrDone before Wait-ing, this proves the ordering held without
// deadlocking.
case <-time.After(5 * time.Second):
t.Fatal("close() did not return in time -- likely blocked waiting on stderrDone")
}
if c.stderrDone != nil {
t.Error("stderrDone should be reset to nil after close()")
}
}
func TestSubprocessClient_Authenticate_Success(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
if cmd != "authenticate" {
t.Fatalf("unexpected cmd %q", cmd)
}
return fakeResult(authResultWire{Status: "success", Message: "Authenticated successfully."})
})
res, err := c.Authenticate(context.Background())
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if res.Status != AuthSuccess || res.Message != "Authenticated successfully." {
t.Errorf("Authenticate result = %+v", res)
}
}
func TestSubprocessClient_Authenticate_MFARequired(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required. ..."})
})
res, err := c.Authenticate(context.Background())
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if res.Status != AuthMFARequired {
t.Errorf("Authenticate status = %v, want AuthMFARequired", res.Status)
}
}
func TestSubprocessClient_Authenticate_WrapperErrorPropagates(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("subprocess exploded")
})
_, err := c.Authenticate(context.Background())
if err == nil || !strings.Contains(err.Error(), "subprocess exploded") {
t.Fatalf("Authenticate error = %v, want it to mention the wrapper error", err)
}
}
func TestSubprocessClient_CompleteMFA_SendsCodeAndReturnsStatus(t *testing.T) {
var gotCode string
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
if cmd != "complete_mfa" {
t.Fatalf("unexpected cmd %q", cmd)
}
var p struct {
Code string `json:"code"`
}
if err := json.Unmarshal(params, &p); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
gotCode = p.Code
return fakeResult(authResultWire{Status: "success", Message: "MFA accepted. Authenticated successfully."})
})
res, err := c.CompleteMFA(context.Background(), "123456")
if err != nil {
t.Fatalf("CompleteMFA: %v", err)
}
if gotCode != "123456" {
t.Errorf("code sent to wrapper = %q, want 123456", gotCode)
}
if res.Status != AuthSuccess {
t.Errorf("CompleteMFA status = %v, want AuthSuccess", res.Status)
}
}
func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "failed", Message: "Authentication failed after MFA: bad code"})
})
res, err := c.CompleteMFA(context.Background(), "000000")
if err != nil {
t.Fatalf("CompleteMFA: %v", err)
}
if res.Status != AuthFailed {
t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status)
}
}
func TestSubprocessClient_GetActivities_UsesGarminconnectKwargNames(t *testing.T) {
var gotMethod string
var gotArgs map[string]any
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
if err := json.Unmarshal(params, &p); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
gotMethod, gotArgs = p.Method, p.Args
return fakeResult([]map[string]any{
{"activityId": float64(123), "activityType": map[string]any{"typeKey": "running"}},
})
})
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 0)
if err != nil {
t.Fatalf("GetActivities: %v", err)
}
if gotMethod != "get_activities_by_date" {
t.Errorf("method = %q, want get_activities_by_date", gotMethod)
}
if gotArgs["startdate"] != "2026-07-01" || gotArgs["enddate"] != "2026-07-25" {
t.Errorf("args = %+v, want startdate/enddate matching garminconnect's real kwargs", gotArgs)
}
if len(activities) != 1 || activities[0].ActivityID != 123 {
t.Fatalf("activities = %+v", activities)
}
}
func TestSubprocessClient_GetActivities_AppliesLimitClientSide(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
raw := make([]map[string]any, 5)
for i := range raw {
raw[i] = map[string]any{"activityId": float64(i)}
}
return fakeResult(raw)
})
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 2)
if err != nil {
t.Fatalf("GetActivities: %v", err)
}
if len(activities) != 2 {
t.Fatalf("len(activities) = %d, want 2", len(activities))
}
}
func TestSubprocessClient_GetActivitySplits_ParsesLapDTOsEnvelope(t *testing.T) {
var gotMethod string
var gotArgs map[string]any
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
json.Unmarshal(params, &p)
gotMethod, gotArgs = p.Method, p.Args
return fakeResult(map[string]any{
"activityId": float64(111),
"lapDTOs": []map[string]any{{"lapIndex": float64(1), "distance": float64(1000)}},
})
})
splits, err := c.GetActivitySplits(context.Background(), 111)
if err != nil {
t.Fatalf("GetActivitySplits: %v", err)
}
if gotMethod != "get_activity_splits" || gotArgs["activity_id"] != "111" {
t.Errorf("method/args = %q/%+v, want get_activity_splits with activity_id=\"111\"", gotMethod, gotArgs)
}
if splits.ActivityID != 111 || len(splits.Laps) != 1 || splits.Laps[0].LapIndex != 1 {
t.Fatalf("splits = %+v", splits)
}
}
func TestSubprocessClient_GetActivityDetails_ParsesRawTelemetry(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
json.Unmarshal(params, &p)
if p.Method != "get_activity_details" || p.Args["activity_id"] != "222" {
t.Fatalf("unexpected call: %+v", p)
}
return fakeResult(map[string]any{
"activityId": float64(222),
"metricDescriptors": []map[string]any{{"key": "directHeartRate", "metricsIndex": float64(0)}},
"activityDetailMetrics": []map[string]any{{"metrics": []any{float64(101)}}},
})
})
details, err := c.GetActivityDetails(context.Background(), 222)
if err != nil {
t.Fatalf("GetActivityDetails: %v", err)
}
if details.ActivityID != 222 || len(details.MetricDescriptors) != 1 {
t.Fatalf("details = %+v", details)
}
}
func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
json.Unmarshal(params, &p)
if p.Method != "get_workout_by_id" || p.Args["workout_id"] != "333" {
t.Fatalf("unexpected call: %+v", p)
}
return fakeResult(map[string]any{
"workoutId": float64(333),
"workoutName": "Tempo",
"workoutSegments": []map[string]any{{"segmentOrder": float64(1), "workoutSteps": []any{}}},
})
})
workout, err := c.GetWorkoutByID(context.Background(), 333)
if err != nil {
t.Fatalf("GetWorkoutByID: %v", err)
}
if workout.WorkoutID != 333 || workout.WorkoutName != "Tempo" || len(workout.Segments) != 1 {
t.Fatalf("workout = %+v", workout)
}
}
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
})
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
if _, err := c.Authenticate(context.Background()); err != nil {
t.Fatalf("Authenticate: %v", err)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["type"] != "wrapper" || entry["package"] != "garmin" || entry["file"] != "client.go" || entry["class"] != "subprocessClient" || entry["method"] != "execute" {
t.Errorf("schema fields = %v, want type=wrapper package=garmin file=client.go class=subprocessClient method=execute", entry)
}
if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("expected no msg on the wrapper-call record, got %v", entry["msg"])
}
if entry["cmd"] != "authenticate" {
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
}
preview, _ := entry["result"].(string)
if !strings.Contains(preview, "mfa_required") {
t.Errorf("result = %q, want it to contain mfa_required", preview)
}
if _, hasError := entry["error"]; hasError {
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
}
}
func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
if _, err := c.Authenticate(context.Background()); err == nil {
t.Fatal("expected Authenticate to return an error")
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "ERROR" {
t.Errorf("level = %v, want ERROR for a failed call", entry["level"])
}
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
}
}
func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("debug", &buf))
defer slog.SetDefault(prev)
stderr := strings.NewReader(
`{"level":"warn","msg":"startup tokenstore login failed","error":"boom"}` + "\n" +
"Traceback (most recent call last): free text\n",
)
forwardWrapperStderr(stderr)
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 log lines, got %d: %q", len(lines), buf.String())
}
var first map[string]any
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("first line not JSON: %v", err)
}
if first["level"] != "WARN" || first["msg"] != "startup tokenstore login failed" {
t.Errorf("first = %v, want the wrapper's level and msg preserved", first)
}
if first["error"] != "boom" || first["type"] != "wrapper" || first["file"] != "wrapper.py" {
t.Errorf("first = %v, want error attr preserved and type=wrapper file=wrapper.py", first)
}
var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("second line not JSON: %v", err)
}
if _, hasMsg := second["msg"]; hasMsg {
t.Errorf("second = %v, want no msg on a wrapped free-text record", second)
}
if second["type"] != "wrapper" || second["level"] != "WARN" {
t.Errorf("second = %v, want free text wrapped as a warn type=wrapper record", second)
}
if line, _ := second["line"].(string); !strings.Contains(line, "Traceback") {
t.Errorf("second line attr = %q, want the raw text preserved", line)
}
}

View File

@@ -1,4 +1,4 @@
package sync
package garmin
import (
"encoding/json"
@@ -6,7 +6,6 @@ import (
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
)
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
return strings.Contains(strings.ToLower(typeKey), "run")
}
func toActivityRow(a garmin.Activity) store.Activity {
func toActivityRow(a Activity) store.Activity {
return store.Activity{
GarminActivityID: a.ActivityID,
EventTypeKey: a.EventType.TypeKey,
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
// fall within that lap's time window. Lap boundaries are derived from
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
// are contiguous and this sidesteps timezone parsing entirely.
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
rows := make([]store.Lap, 0, len(laps))
var elapsedStart float64
for i, l := range laps {
@@ -94,9 +93,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
return rows
}
// alignWorkoutTargets zips an activity's recorded laps against its
// structured workout's flattened steps, returning one *garmin.WorkoutStep
// per lap (nil where unavailable).
// alignWorkoutTargets zips an activity's lap count against its structured
// workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
// where unavailable).
//
// Confirmed against a real activity (via Garmin Connect's own workout view)
// that recording sometimes continues one lap past the end of the workout's
@@ -110,11 +109,11 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
// etc.) can't be trusted at all, so every entry comes back nil rather than
// risk showing a target against the wrong lap.
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
steps := workout.FlattenSteps()
out := make([]*garmin.WorkoutStep, len(laps))
out := make([]*WorkoutStep, lapCount)
switch len(laps) - len(steps) {
switch lapCount - len(steps) {
case 0, 1:
for i := range steps {
s := steps[i]
@@ -126,7 +125,7 @@ func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.Wo
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
// pace-zone target, or (nil, nil) if it doesn't target pace.
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, nil
}
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
// range are resolved via the user's Karvonen profile.
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil
}
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
}
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
var out []classify.SampleInfo
for _, s := range samples {
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
return out
}
func toSampleRows(samples []garmin.Sample) []store.Sample {
func toSampleRows(samples []Sample) []store.Sample {
rows := make([]store.Sample, len(samples))
for i, s := range samples {
rows[i] = store.Sample{

View File

@@ -0,0 +1,114 @@
// MockClient support: a fake Client for tests and frontend/dev
// work without a live Garmin account or the wrapper subprocess.
package garmin
import (
"context"
"time"
)
// MockClient is a fake Client returning data supplied by the test/caller.
type MockClient struct {
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []Activity
Splits map[int64]ActivitySplits
Details map[int64]ActivityDetails
Workouts map[int64]Workout
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
// return that error for that ID specifically -- independent of the
// all-calls-fail Err field below -- so a test can simulate one
// activity's workout fetch failing while others in the same batch
// succeed.
WorkoutErrByID map[int64]error
// Delay, if set, is slept (ctx-cancellable) at the start of every
// GetActivities call -- lets a test give sync.Service's discovering
// phase (backfillCore/incrementalSyncCore) real wall-clock duration, so
// a concurrent goroutine can observe Progress() mid-flight instead of
// the call returning instantly.
Delay time.Duration
Err error // if set, every call returns this error
authResultCursor int
ClosedCalled bool
GetActivitiesCalls int
AuthenticateCalls int
LastEmail string
LastPassword string
}
var _ Client = (*MockClient)(nil)
func (c *MockClient) nextAuthResult() AuthResult {
if c.authResultCursor >= len(c.AuthResults) {
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
}
r := c.AuthResults[c.authResultCursor]
c.authResultCursor++
return r
}
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.AuthenticateCalls++
if c.Err != nil {
return AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
if c.Err != nil {
return AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.GetActivitiesCalls++
if c.Delay > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.Delay):
}
}
if c.Err != nil {
return nil, c.Err
}
if limit > 0 && limit < len(c.Activities) {
return c.Activities[:limit], nil
}
return c.Activities, nil
}
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
if c.Err != nil {
return ActivitySplits{}, c.Err
}
return c.Splits[activityID], nil
}
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
if c.Err != nil {
return ActivityDetails{}, c.Err
}
return c.Details[activityID], nil
}
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
if c.Err != nil {
return Workout{}, c.Err
}
if err, ok := c.WorkoutErrByID[workoutID]; ok {
return Workout{}, err
}
return c.Workouts[workoutID], nil
}
func (c *MockClient) UpdateCredentials(email, password string) {
c.LastEmail = email
c.LastPassword = password
}
func (c *MockClient) Close() error {
c.ClosedCalled = true
return nil
}

View File

@@ -1,91 +0,0 @@
// Package mock provides a fake garmin.Client for tests and frontend/dev
// work without a live Garmin account or the mcp-garmin subprocess.
package mock
import (
"context"
"geniusrun/backend/internal/garmin"
)
// Client is a fake garmin.Client returning data supplied by the test/caller.
type Client struct {
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []garmin.Activity
Splits map[int64]garmin.ActivitySplits
Details map[int64]garmin.ActivityDetails
Workouts map[int64]garmin.Workout
Err error // if set, every call returns this error
authResultCursor int
ClosedCalled bool
GetActivitiesCalls int
LastEmail string
LastPassword string
}
var _ garmin.Client = (*Client)(nil)
func (c *Client) nextAuthResult() garmin.AuthResult {
if c.authResultCursor >= len(c.AuthResults) {
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
}
r := c.AuthResults[c.authResultCursor]
c.authResultCursor++
return r
}
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
c.GetActivitiesCalls++
if c.Err != nil {
return nil, c.Err
}
if limit > 0 && limit < len(c.Activities) {
return c.Activities[:limit], nil
}
return c.Activities, nil
}
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
if c.Err != nil {
return garmin.ActivitySplits{}, c.Err
}
return c.Splits[activityID], nil
}
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
if c.Err != nil {
return garmin.ActivityDetails{}, c.Err
}
return c.Details[activityID], nil
}
func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) {
if c.Err != nil {
return garmin.Workout{}, c.Err
}
return c.Workouts[workoutID], nil
}
func (c *Client) UpdateCredentials(email, password string) {
c.LastEmail = email
c.LastPassword = password
}
func (c *Client) Close() error {
c.ClosedCalled = true
return nil
}

View File

@@ -1,27 +1,27 @@
// Package sync orchestrates fetching activities from Garmin (via
// Package garmin orchestrates fetching activities from Garmin (via
// internal/garmin), persisting them (via internal/store), and classifying
// them (via internal/classify). It's the only package that depends on all
// three, keeping garmin/store/classify decoupled from each other.
package sync
package garmin
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store"
)
// Config tunes sync behavior. Zero values fall back to sensible defaults in
// NewService. How far back Backfill reaches is not here -- it's
// NewSync. How far back Backfill reaches is not here -- it's
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart.
type Config struct {
type SyncConfig struct {
// BackfillWindowDays is the page size for each get_activities call
// during backfill.
BackfillWindowDays int
@@ -38,7 +38,7 @@ type Config struct {
MinConfidence float64
}
func (c Config) withDefaults() Config {
func (c SyncConfig) withDefaults() SyncConfig {
if c.BackfillWindowDays == 0 {
c.BackfillWindowDays = 90
}
@@ -54,49 +54,64 @@ func (c Config) withDefaults() Config {
return c
}
// Progress reports how far a currently-running (or just-finished)
// FillPendingDetails pass has gotten, for a status banner to poll.
// Phase values reported by Progress.Phase.
const (
PhaseIdle = "idle"
PhaseDiscovering = "discovering"
PhaseActivities = "activities"
PhaseWorkouts = "workouts"
)
// Progress reports how far a currently-running (or just-finished) FullSync
// has gotten, for a status modal to poll. PhaseDiscovering (backfillCore/
// incrementalSyncCore) has no meaningful Total -- discovering how many
// activities exist IS the act of fetching them, so it's reported as an
// indeterminate step (Done/Total both 0) rather than a fake percentage.
// PhaseActivities/PhaseWorkouts do have a real Total, taken once from a
// local DB count at the start of each pass (see FillPendingDetails).
type Progress struct {
Phase string
Done int
Total int
}
// Service is the sync orchestrator, scoped to one user -- every store call
// Sync is the sync orchestrator, scoped to one user -- every store call
// it makes is for userID's data only.
type Service struct {
garmin garmin.Client
type Sync struct {
garmin Client
db *store.DB
userID int64
cfg Config
cfg SyncConfig
now func() time.Time
progressMu sync.Mutex
progress Progress
}
// NewService builds a Service scoped to userID. now defaults to time.Now if
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
// nil (tests can override it for deterministic date windows).
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service {
func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
if now == nil {
now = time.Now
}
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now}
return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
}
// Progress returns the current detail-fill progress (0/0 when idle).
func (s *Service) Progress() Progress {
// Progress returns the current sync progress (phase idle, 0/0 when nothing
// is running).
func (s *Sync) Progress() Progress {
s.progressMu.Lock()
defer s.progressMu.Unlock()
return s.progress
}
func (s *Service) setProgress(done, total int) {
func (s *Sync) setProgress(phase string, done, total int) {
s.progressMu.Lock()
s.progress = Progress{Done: done, Total: total}
s.progress = Progress{Phase: phase, Done: done, Total: total}
s.progressMu.Unlock()
}
// Backfill pages backward in Config.BackfillWindowDays windows until
// backfillCore pages backward in Config.BackfillWindowDays windows until
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
// The horizon is read fresh from the profile on every call (not fixed at
// server startup), so a user-edited value takes effect on the very next
@@ -106,28 +121,11 @@ func (s *Service) setProgress(done, total int) {
// completed backfill, or is a fast no-op if the configured horizon is
// already fully covered -- it does not re-walk years of already-known
// history. Widening the horizon between calls resumes further back instead
// of re-fetching everything.
func (s *Service) Backfill(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill)
if err != nil {
return err
}
total, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
}
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
// bookkeeping, so FullSync can run it as one step of a single combined run
// instead of its own separately-recorded one. The returned count reflects
// whatever was fetched even when an error is also returned, matching
// Backfill's own partial-progress-on-error behavior.
func (s *Service) backfillCore(ctx context.Context) (int, error) {
// of re-fetching everything. Used by FullSync as one step of its single
// combined SyncRun; there is no standalone entrypoint for this anymore
// (the periodic background sync loop that used to call one is gone -- see
// 4d2cbe4 refactor: remove automatic background incremental sync).
func (s *Sync) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return 0, fmt.Errorf("load profile: %w", err)
@@ -191,26 +189,11 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
return total, nil
}
// IncrementalSync fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today.
func (s *Service) IncrementalSync(ctx context.Context) error {
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental)
if err != nil {
return err
}
n, err := s.incrementalSyncCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
return err
}
return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
}
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
// the SyncRun bookkeeping -- see backfillCore.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
// incrementalSyncCore fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today. Used
// by FullSync as one step of its single combined SyncRun -- see
// backfillCore's comment for why there's no standalone entrypoint.
func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
@@ -221,20 +204,21 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
return newCount, err
}
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes
// from the watermark), then IncrementalSync (catches anything new since the
// latest known activity), then FillPendingDetails -- recorded as a single
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when
// called on their own (used by the periodic background loop), but a manual
// sync runs both back to back, and FillPendingDetails records no run at all;
// showing the user only the most recently *recorded* run (IncrementalSync's)
// would silently hide however many activities Backfill fetched. Recording
// one combined run makes the reported count match the whole action.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
// FullSync performs a complete manual "Sync now" pass -- backfillCore
// (resumes from the watermark), then incrementalSyncCore (catches anything
// new since the latest known activity), then FillPendingDetails --
// recorded as a single SyncRun so the reported activity count covers the
// whole action instead of only whichever stage happened to finish last.
func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil {
return err
}
// Reset to idle on every return path, including an early error return
// from backfillCore/incrementalSyncCore before FillPendingDetails (which
// otherwise owns its own idle-reset) ever runs.
s.setProgress(PhaseDiscovering, 0, 0)
defer s.setProgress(PhaseIdle, 0, 0)
backfillCount, err := s.backfillCore(ctx)
if err != nil {
@@ -264,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
// assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched.
func (s *Service) ResetAll(ctx context.Context) error {
func (s *Sync) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx, s.userID)
}
@@ -280,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window).
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil {
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
@@ -306,11 +290,22 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
return len(activities), newCount, nil
}
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
// to limit activities that don't have them yet, then (re)classifies each one.
// Calls are made sequentially with Config.InterCallDelay between them to
// FillPendingDetails fetches activity details/splits for up to limit
// activities missing them, then fetches workouts for up to limit activities
// missing those (independently -- see ActivitiesMissingWorkout), then
// (re)classifies every activity touched by the first pass. Each pass makes
// its Garmin calls sequentially with Config.InterCallDelay between them to
// avoid Garmin/Cloudflare rate limiting.
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
defer s.setProgress(PhaseIdle, 0, 0)
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
return err
}
return s.fillPendingWorkouts(ctx, limit)
}
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil {
return err
@@ -320,8 +315,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
return fmt.Errorf("load profile: %w", err)
}
s.setProgress(0, len(pending))
defer s.setProgress(0, 0)
s.setProgress(PhaseActivities, 0, len(pending))
for i, a := range pending {
if i > 0 {
@@ -337,12 +331,57 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err)
}
s.setProgress(i+1, len(pending))
s.setProgress(PhaseActivities, i+1, len(pending))
}
return nil
}
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
// fillPendingWorkouts fetches get_workout_by_id for up to limit activities
// missing it. Unlike fillPendingActivityDetails, one activity's workout
// fetch failing is non-fatal (logged, loop continues) -- workout target
// bands are enrichment, not core activity data, and workout_raw_json
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
// the next sync.
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
if err != nil {
return err
}
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return fmt.Errorf("load profile: %w", err)
}
s.setProgress(PhaseWorkouts, 0, len(pending))
for i, a := range pending {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
if errors.Is(err, ErrNotFound) {
// A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops
// surfacing it, instead of retrying forever.
applog.App().Warn("workout not found on Garmin, marking as such (will not retry)", "garmin_activity_id", a.GarminActivityID, "error", err)
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
}
} else {
applog.App().Warn("fill workout failed, will retry next sync", "garmin_activity_id", a.GarminActivityID, "error", err)
}
}
s.setProgress(PhaseWorkouts, i+1, len(pending))
}
return nil
}
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
if err != nil {
return fmt.Errorf("get_activity_splits: %w", err)
@@ -352,23 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
return fmt.Errorf("get_activity_details: %w", err)
}
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
if a.WorkoutID != nil {
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil {
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
return err
}
}
}
samples := garmin.ExtractSamples(details)
samples := ExtractSamples(details)
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err
}
// No workout-target alignment here -- that's fillActivityWorkout's job,
// run as its own later pass (see fillPendingWorkouts). targets is an
// all-nil placeholder the same length as splits.Laps.
targets := make([]*WorkoutStep, len(splits.Laps))
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
@@ -378,10 +408,39 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
}
// fillActivityWorkout fetches a's structured workout and re-derives its
// laps' target pace/HR bands from it. Requires a.WorkoutID to be set --
// only ever called for activities ActivitiesMissingWorkout returned, which
// already filters on that. Reads laps back from the DB (already written by
// fillActivityDetails, in some earlier pass or run) rather than needing the
// original garmin.Lap data again, since alignWorkoutTargets only needs a
// count.
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil {
return fmt.Errorf("get_workout_by_id: %w", err)
}
laps, err := s.db.LapsForActivity(ctx, s.userID, a.ID)
if err != nil {
return err
}
targets := alignWorkoutTargets(len(laps), workout)
for i := range laps {
if i < len(targets) && targets[i] != nil {
laps[i].TargetPaceLowMps, laps[i].TargetPaceHighMps = targetPaceRange(*targets[i])
laps[i].TargetHRLowBpm, laps[i].TargetHRHighBpm = targetHRRange(*targets[i], profile)
}
}
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, laps); err != nil {
return err
}
return s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw))
}
// ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil {
return err

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ package garmin
import "encoding/json"
// AuthStatus is the outcome of an authenticate()/complete_mfa() call.
// mcp-garmin's tools return a plain human-readable string rather than a
// structured status, so the client pattern-matches known phrases into this.
// wrapper.py returns a structured {"status", "message"} JSON object, which
// the client maps directly into this rather than pattern-matching strings.
type AuthStatus int
const (

View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
.pytest_cache/
*.pyc

View File

@@ -0,0 +1,18 @@
[project]
name = "geniusrun-garmin-wrapper"
version = "0.1.0"
description = "Subprocess wrapper around garminconnect for geniusrund's internal/garmin package"
requires-python = ">=3.11"
dependencies = [
"garminconnect",
]
[project.optional-dependencies]
dev = ["pytest"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]

View File

@@ -0,0 +1,366 @@
import json
import os
import time
import queue
import threading
from unittest.mock import MagicMock, patch
import pytest
import wrapper
@pytest.fixture(autouse=True)
def reset_state():
original_state = wrapper._auth_state
original_client = wrapper._client
original_startup_attempted = wrapper._startup_login_attempted
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
while not q.empty():
try:
q.get_nowait()
except queue.Empty:
break
wrapper._startup_login_attempted = False
yield
wrapper._auth_state = original_state
wrapper._client = original_client
wrapper._startup_login_attempted = original_startup_attempted
def test_authenticate_success():
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
resp = wrapper.dispatch({"id": 1, "cmd": "authenticate"})
assert resp == {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}}
assert wrapper._auth_state == "authenticated"
def test_authenticate_mfa_required():
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
with patch.object(wrapper._login_result_queue, "get", side_effect=queue.Empty):
resp = wrapper.dispatch({"id": 2, "cmd": "authenticate"})
assert resp["result"]["status"] == "mfa_required"
assert wrapper._auth_state == "mfa_pending"
def test_authenticate_missing_credentials():
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("GARMIN_EMAIL", None)
os.environ.pop("GARMIN_PASSWORD", None)
resp = wrapper.dispatch({"id": 3, "cmd": "authenticate"})
assert resp["result"]["status"] == "failed"
assert "GARMIN_EMAIL" in resp["result"]["message"]
def test_complete_mfa_success():
wrapper._auth_state = "mfa_pending"
wrapper._login_result_queue.put(("success", None))
resp = wrapper.dispatch({"id": 4, "cmd": "complete_mfa", "params": {"code": "123456"}})
assert resp == {
"id": 4,
"result": {"status": "success", "message": "MFA accepted. Authenticated successfully."},
}
assert wrapper._auth_state == "authenticated"
assert wrapper._mfa_input_queue.get_nowait() == "123456"
def test_complete_mfa_not_in_progress():
wrapper._auth_state = "unauthenticated"
resp = wrapper.dispatch({"id": 5, "cmd": "complete_mfa", "params": {"code": "123456"}})
assert resp["result"]["status"] == "failed"
assert "No MFA in progress" in resp["result"]["message"]
def test_call_dispatches_to_named_garminconnect_method():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_activities_by_date.return_value = [{"activityId": "111"}]
resp = wrapper.dispatch({
"id": 6,
"cmd": "call",
"params": {
"method": "get_activities_by_date",
"args": {"startdate": "2026-07-01", "enddate": "2026-07-25"},
},
})
assert resp == {"id": 6, "result": [{"activityId": "111"}]}
wrapper._client.get_activities_by_date.assert_called_once_with(
startdate="2026-07-01", enddate="2026-07-25"
)
def test_call_unauthenticated_is_error():
# Explicitly absent GARMIN_EMAIL/PASSWORD: this now indirectly triggers
# the lazy _startup_login fallback (see _handle_call), which must
# return immediately with nothing to resume, leaving this the same
# "Not authenticated" error as before -- not dependent on whatever
# happens to be in the ambient shell environment.
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("GARMIN_EMAIL", None)
os.environ.pop("GARMIN_PASSWORD", None)
resp = wrapper.dispatch({
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
})
assert "error" in resp
assert "Not authenticated" in resp["error"]
def test_call_unknown_method_is_error():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock(spec=["get_activities_by_date"])
resp = wrapper.dispatch({
"id": 8, "cmd": "call", "params": {"method": "delete_everything", "args": {}},
})
assert resp["id"] == 8
assert "error" in resp
def test_call_propagates_garminconnect_exception_as_error():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_activity_splits.side_effect = Exception("not found")
resp = wrapper.dispatch({
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
})
assert resp["id"] == 9
assert resp["error"] == "not found"
# Failure detail travels in the protocol so the Go side can log one
# complete structured record (no stderr correlation needed).
assert resp["error_type"] == "Exception"
assert "Exception: not found" in resp["traceback"]
assert "not_found" not in resp
def test_dispatch_unknown_cmd_is_error():
resp = wrapper.dispatch({"id": 10, "cmd": "not_a_real_cmd"})
assert resp["id"] == 10
assert "unknown cmd" in resp["error"]
def test_startup_login_success():
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
wrapper._startup_login()
assert wrapper._auth_state == "authenticated"
def test_startup_login_missing_credentials_returns_immediately():
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("GARMIN_EMAIL", None)
os.environ.pop("GARMIN_PASSWORD", None)
wrapper._auth_state = "unauthenticated"
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
assert wrapper._client is None
def test_startup_login_times_out_without_blocking():
# _startup_login now waits on its own private, function-local queue
# (never the shared wrapper._login_result_queue -- see the
# no-shared-queue-leakage test below), so forcing its timeout path
# means intercepting the queue.Queue() constructor it calls internally
# rather than patching a module-level queue object.
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
# Should return promptly (bounded by the 10s timeout passed to
# queue.get, which is mocked here to raise immediately) rather
# than blocking main()'s stdin dispatch loop from starting.
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
def test_startup_login_does_not_leak_into_shared_authenticate_queue():
"""Regression test: _startup_login used to push its background thread's
result onto the same module-level _login_result_queue that
_handle_authenticate/_handle_complete_mfa share for the MFA handoff. A
startup login that was still in flight when a user's first explicit
'authenticate' call came in could have that call accidentally dequeue
the startup thread's stale result instead of its own fresh one.
_startup_login now uses its own private queue, so a still-in-flight
startup attempt can never interfere with a later authenticate() call."""
release = threading.Event()
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
# A startup login whose underlying garminconnect call is still
# blocked (simulating "slow to resolve") when _startup_login's
# own bounded wait (mocked to time out immediately, so this test
# doesn't need to sleep the real 10s) returns.
slow_client = MagicMock()
slow_client.login.side_effect = lambda **kwargs: release.wait(5)
mock_garmin_cls.return_value = slow_client
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
# Nothing from the still-in-flight startup attempt ever touches the
# shared queue that authenticate()/complete_mfa() rely on.
assert wrapper._login_result_queue.empty()
release.set() # let the stale startup thread finish harmlessly in the background
# A fresh, unrelated authenticate() call must get its own result, not
# anything left over from the startup attempt above.
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls2:
mock_garmin_cls2.return_value = MagicMock()
resp = wrapper.dispatch({"id": 20, "cmd": "authenticate"})
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
assert wrapper._auth_state == "authenticated"
def test_call_triggers_startup_login_lazily_on_first_call():
"""A data 'call' with no prior explicit authenticate on this subprocess
-- e.g. "Sync now" reaching an already-connected user's client right
after a backend restart -- gets exactly one lazy attempt to silently
resume a cached tokenstore session before giving up."""
wrapper._auth_state = "unauthenticated"
wrapper._client = MagicMock()
wrapper._client.get_activities_by_date.return_value = []
def fake_startup_login():
wrapper._auth_state = "authenticated"
with patch("wrapper._startup_login", side_effect=fake_startup_login) as mock_startup:
resp = wrapper.dispatch({
"id": 30, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
})
mock_startup.assert_called_once()
assert resp == {"id": 30, "result": []}
def test_call_does_not_retry_startup_login_after_first_attempt():
"""If the lazy startup-login attempt doesn't actually authenticate, a
second 'call' must not try it again -- one attempt per subprocess
lifetime, not once per call."""
wrapper._auth_state = "unauthenticated"
with patch("wrapper._startup_login") as mock_startup: # no-op: leaves state unauthenticated
wrapper.dispatch({"id": 31, "cmd": "call", "params": {"method": "x", "args": {}}})
wrapper.dispatch({"id": 32, "cmd": "call", "params": {"method": "x", "args": {}}})
mock_startup.assert_called_once()
def test_authenticate_prevents_later_lazy_startup_login():
"""Once authenticate has been explicitly called (regardless of
outcome), a later 'call' must never fall back to _startup_login -- that
would either duplicate a login authenticate already did, or waste an
extra hit against Garmin after a failure."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
wrapper.dispatch({"id": 33, "cmd": "authenticate"})
wrapper._auth_state = "unauthenticated" # simulate a later, separate call finding no session
with patch("wrapper._startup_login") as mock_startup:
wrapper.dispatch({"id": 34, "cmd": "call", "params": {"method": "x", "args": {}}})
mock_startup.assert_not_called()
def test_call_marks_not_found_error_specifically():
from garminconnect import GarminConnectNotFoundError
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
resp = wrapper.dispatch({
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp["id"] == 11
assert resp["error"] == "API Error 404"
assert resp["not_found"] is True
assert resp["error_type"] == "GarminConnectNotFoundError"
def test_call_does_not_mark_other_errors_as_not_found():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
resp = wrapper.dispatch({
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp["id"] == 12
assert resp["error"] == "rate limited"
assert "not_found" not in resp
def test_startup_login_late_success_flips_auth_state_for_later_calls():
"""A tokenstore resume that outlives the 10s wait must still recover
the subprocess: the background thread itself flips _auth_state on
success, so the next `call` command passes the auth check without an
explicit authenticate."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
# Simulate the 10s timeout: the reader gives up immediately,
# while the (still-blocked) login thread keeps running.
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
release.set() # the slow login now completes, after the timeout
for _ in range(100):
if wrapper._auth_state == "authenticated":
break
time.sleep(0.01)
assert wrapper._auth_state == "authenticated"
def test_startup_login_late_success_never_overrides_explicit_auth_flow():
"""If an explicit authenticate/MFA flow moved the state while the
startup resume was still in flight, the late success must not clobber
it -- the explicit flow owns the state once it has moved it."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
wrapper._auth_state = "mfa_pending" # an explicit flow took over meanwhile
release.set()
time.sleep(0.2) # give the thread ample time to (wrongly) flip it
assert wrapper._auth_state == "mfa_pending"
def test_log_stamps_emitting_method(capsys):
"""Every wrapper log line carries the emitting function as "method" --
the Go side forwards it into the unified log schema."""
def some_emitter():
wrapper._log("info", "hello", extra=1)
some_emitter()
entry = json.loads(capsys.readouterr().err.strip())
assert entry == {"level": "info", "msg": "hello", "method": "some_emitter", "extra": 1}

View File

@@ -0,0 +1,312 @@
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
JSON on stdin/stdout by geniusrund's internal/garmin package. See
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
import json
import os
import queue
import sys
import threading
import traceback
from garminconnect import Garmin, GarminConnectNotFoundError
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin"))
_client = None
_auth_state = "unauthenticated"
_mfa_input_queue = queue.Queue()
_login_result_queue = queue.Queue()
# Set the first time this subprocess attempts any login, whichever path
# gets there first (see _handle_call's lazy call and _handle_authenticate) --
# guarantees _startup_login runs at most once per subprocess lifetime, and
# never at all once an explicit authenticate has been attempted.
_startup_login_attempted = False
def _log(level, msg, **attrs):
"""Emit one JSON log line on stderr. internal/garmin/client.go reads
stderr line by line and re-emits these through the Go application
logger, so the backend's combined output stays a single JSON stream --
never print free text to stderr or stdout from this process (stdout is
reserved for the request/response protocol). The emitting function is
stamped as "method" automatically; the Go side adds the rest of the
log schema (type=wrapper, file=wrapper.py)."""
entry = {"level": level, "msg": msg, "method": sys._getframe(1).f_code.co_name}
entry.update(attrs)
print(json.dumps(entry), file=sys.stderr, flush=True)
def _prompt_mfa():
_log("debug", "invoked")
code = _mfa_input_queue.get(timeout=300)
_log("debug", "returning MFA code", code_length=len(code))
return code
def _startup_login():
"""Silently resume a cached tokenstore session, so a freshly
(re)spawned subprocess can already be authenticated for a data 'call'
that never goes through the explicit authenticate command -- e.g.
"Sync now" reaching an already-connected user's client right after a
backend restart cleared the in-memory cache.
Called lazily (see _handle_call), at most once per subprocess lifetime,
and only if nothing has explicitly called authenticate first. Running
it unconditionally at process start used to be actively counterproductive
whenever the very first command actually was authenticate: on success it
just duplicated a login _handle_authenticate was about to redo anyway
(it always rebuilds _client from scratch), and on failure it was a
wasted, unauthenticated hit against Garmin's servers moments before the
real attempt -- extra load that only makes rate-limiting worse.
Bounded to the same 10s timeout as _handle_authenticate: the actual
login runs on a background daemon thread, and this function waits up
to 10s for it before returning either way, so a slow/rate-limited
Garmin login can never block the caller indefinitely. On timeout the
triggering call fails ("Not authenticated"), but the thread keeps
running and, if the login eventually succeeds, flips _auth_state to
"authenticated" itself -- only ever from "unauthenticated", never
overriding an explicit authenticate/MFA flow that ran in the
meantime -- so subsequent calls recover without user action. A late
failure changes nothing (the state is already "unauthenticated").
Deliberately uses its own private, function-local result queue rather
than the module-level _login_result_queue that _handle_authenticate and
_handle_complete_mfa share -- those two are legitimately two halves of
one explicit, MFA-capable login flow and must share a queue for the MFA
handoff to work, but this is a plain background tokenstore resume with
no MFA involved. Sharing the queue here would let this thread's stale
result (arriving after the 10s timeout below) be dequeued by an
unrelated, later authenticate/complete_mfa call instead of that call's
own fresh result."""
global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL")
password = os.environ.get("GARMIN_PASSWORD")
if not email or not password:
return
_client = Garmin(email, password)
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
def _do_startup_login():
global _auth_state
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
try:
_client.login(tokenstore=TOKENSTORE)
_log("debug", "_client.login() returned successfully")
result_queue.put(("success", None))
# A success arriving after the 10s timeout below would land in
# an abandoned queue -- flip the state here too, so later calls
# benefit from the resume. Only from "unauthenticated": a
# concurrent explicit authenticate/MFA flow owns the state once
# it has moved it anywhere else.
if _auth_state == "unauthenticated":
_auth_state = "authenticated"
except Exception as exc:
_log(
"error",
"_client.login() failed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
result_queue.put(("error", str(exc)))
threading.Thread(target=_do_startup_login, daemon=True).start()
try:
status, err = result_queue.get(timeout=10)
_log("debug", "got result within 10s timeout", status=status)
if status == "success":
_auth_state = "authenticated"
else:
_log("error", "login failed", error=err)
_auth_state = "unauthenticated"
except queue.Empty:
# No assignment here: the state is already "unauthenticated", and
# writing it again could clobber a success the background thread
# records right around the timeout boundary (see _do_startup_login).
_log(
"warn",
"hit the 10s timeout but still running in the "
"background and will update auth state if it eventually succeeds",
)
def _handle_authenticate(_params):
global _client, _auth_state, _startup_login_attempted
# An explicit authenticate is happening (successful or not) -- the lazy
# startup-login fallback in _handle_call must never fire after this, it
# would be redundant at best and a wasted extra hit against Garmin at
# worst.
_startup_login_attempted = True
email = os.environ.get("GARMIN_EMAIL", "")
password = os.environ.get("GARMIN_PASSWORD", "")
if not email or not password:
return {
"status": "failed",
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
}
def _do_authenticate_login():
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
try:
_client.login(tokenstore=TOKENSTORE)
_log("debug", "_client.login() returned successfully")
_login_result_queue.put(("success", None))
except Exception as exc:
_log(
"error",
"_client.login() failed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
_login_result_queue.put(("error", str(exc)))
_client = Garmin(email, password)
_client.prompt_mfa = _prompt_mfa
threading.Thread(target=_do_authenticate_login, daemon=True).start()
try:
status, err = _login_result_queue.get(timeout=10)
_log("debug", "got result within 10s timeout", status=status)
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "Authenticated successfully."}
else:
_log("error", "login failed", error=err)
return {"status": "failed", "message": f"Authentication failed: {err}"}
except queue.Empty:
_log(
"warn",
"hit the 10s timeout with no result yet, reporting mfa_required",
)
_auth_state = "mfa_pending"
return {
"status": "mfa_required",
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
}
def _handle_complete_mfa(params):
global _auth_state
if _auth_state != "mfa_pending":
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
code = params["code"]
_log("debug", "received a code, pushing to mfa queue", code_length=len(code))
_mfa_input_queue.put(code)
try:
status, err = _login_result_queue.get(timeout=30)
_log("debug", "got result", status=status, error=err)
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
except queue.Empty:
_log(
"error",
"hit the 30s timeout with no result yet, reporting unauthenticated",
)
_auth_state = "unauthenticated"
return {
"status": "failed",
"message": "Timed out waiting for authentication to complete.",
}
def _handle_call(params):
global _startup_login_attempted
if _auth_state != "authenticated" and not _startup_login_attempted:
_startup_login_attempted = True
_startup_login()
if _auth_state != "authenticated":
raise RuntimeError("Not authenticated. Call authenticate first.")
method = params["method"]
args = params.get("args") or {}
fn = getattr(_client, method)
return fn(**args)
_HANDLERS = {
"authenticate": _handle_authenticate,
"complete_mfa": _handle_complete_mfa,
"call": _handle_call,
}
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
# The full failure detail travels IN the response -- error text,
# exception class, and traceback -- so the Go side can log one
# complete structured record instead of correlating stderr noise.
resp = {
"id": req.get("id"),
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(),
}
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
# side after being linked to an activity) is definitive, not a
# transient failure worth retrying forever -- marked specifically so
# internal/garmin/client.go can tell the two apart (see
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
if isinstance(exc, GarminConnectNotFoundError):
resp["not_found"] = True
return resp
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError as exc:
# A malformed request has no id to answer to -- log and keep
# serving rather than crashing the subprocess.
_log("error", "malformed request line", error=str(exc))
continue
resp = dispatch(req)
try:
print(json.dumps(resp), flush=True)
except (TypeError, ValueError) as exc:
# A non-JSON-serializable handler result must still produce a
# protocol response, or the Go side would block on a reply.
print(
json.dumps(
{
"id": req.get("id"),
"error": f"unserializable result: {exc}",
"error_type": type(exc).__name__,
}
),
flush=True,
)
if __name__ == "__main__":
try:
main()
except Exception as exc: # last resort: die loudly, but still in JSON
_log(
"error",
"wrapper crashed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
raise SystemExit(1)

View File

@@ -0,0 +1,87 @@
// Package applog provides geniusrun's structured JSON logging: a
// log/slog-based logger writing to stdout, plus the App helper that tags
// records with the schema fields every geniusrun log line carries --
// "type" ("app" | "http" | "wrapper"), "package" (Go package; absent on
// Python-emitted lines), "file" (Go/Python source file basename), "class"
// (Go receiver type or Python class, omitted when there is none), and
// "method" (the emitting Go/Python function). "msg" is optional:
// NewLogger's handler drops it when empty.
package applog
import (
"io"
"log/slog"
"path/filepath"
"runtime"
"strings"
)
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
// defaults to info). Empty msg values are omitted from the output --
// records whose meaning is fully carried by type/class/method and attrs
// (e.g. the HTTP request line) don't need one.
func NewLogger(level string, w io.Writer) *slog.Logger {
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: parseLevel(level),
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.MessageKey && a.Value.String() == "" {
return slog.Attr{}
}
return a
},
}))
}
// App returns the default logger tagged with the schema fields for an
// ordinary application record (type=app), deriving the emitting location
// from the caller via the runtime: package, file, class (the receiver
// type, omitted for free functions), method. Derivation instead of
// hand-typed strings means the labels can never drift from the code. The
// http and wrapper emitters (internal/api's logging middleware,
// internal/garmin's execute/forwardWrapperStderr) tag their own type and
// location instead.
func App() *slog.Logger {
pkg, file, class, method := location(1)
logger := slog.Default().With("type", "app", "package", pkg, "file", file)
if class != "" {
logger = logger.With("class", class)
}
return logger.With("method", method)
}
// location resolves the caller (skip frames above this function's caller)
// into the log schema's package/file/class/method fields. A method's
// runtime name looks like "geniusrun/backend/internal/api.(*Server).X";
// a free function's like "geniusrun/backend/internal/api.X"; a closure
// gets its parent's name plus ".funcN", kept verbatim in method.
func location(skip int) (pkg, file, class, method string) {
pc, path, _, ok := runtime.Caller(skip + 1)
if !ok {
return "unknown", "unknown", "", "unknown"
}
file = filepath.Base(path)
full := runtime.FuncForPC(pc).Name()
base := full[strings.LastIndex(full, "/")+1:]
pkg, rest, _ := strings.Cut(base, ".")
if strings.HasPrefix(rest, "(*") {
if end := strings.Index(rest, ")."); end != -1 {
class = rest[2:end]
rest = rest[end+2:]
}
}
return pkg, file, class, rest
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,78 @@
package applog
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("warn", &buf)
logger.Info("should be dropped")
if buf.Len() != 0 {
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
}
logger.Warn("should appear")
if !strings.Contains(buf.String(), "should appear") {
t.Fatalf("expected the Warn message in output, got %q", buf.String())
}
}
func TestNewLogger_WritesValidJSON(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("info", &buf)
logger.Info("hello", "key", "value")
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
}
if decoded["msg"] != "hello" || decoded["key"] != "value" {
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
}
}
func TestNewLogger_OmitsEmptyMsg(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("info", &buf)
logger.Info("", "type", "http", "status", 200)
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output not JSON: %v (%q)", err, buf.String())
}
if _, present := decoded["msg"]; present {
t.Errorf("empty msg should be omitted, got %+v", decoded)
}
if decoded["type"] != "http" {
t.Errorf("attrs lost alongside dropped msg: %+v", decoded)
}
}
func TestApp_TagsMandatoryFields(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(NewLogger("info", &buf))
defer slog.SetDefault(prev)
App().Info("did it", "extra", 1)
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output not JSON: %v", err)
}
if decoded["type"] != "app" || decoded["package"] != "log" || decoded["file"] != "log_test.go" {
t.Errorf("fields = %+v, want type=app package=log (import-path element) file=log_test.go", decoded)
}
if decoded["method"] != "TestApp_TagsMandatoryFields" {
t.Errorf("method = %v, want the emitting function name", decoded["method"])
}
if _, hasClass := decoded["class"]; hasClass {
t.Errorf("class should be omitted for a free function, got %v", decoded["class"])
}
}

View File

@@ -45,6 +45,11 @@ type Activity struct {
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
// HTTP 404 for this activity's WorkoutID -- distinct from
// WorkoutRawJSON staying nil for "not yet fetched" (see
// SetActivityWorkoutNotFound).
WorkoutNotFoundAt *string
CreatedAt string
UpdatedAt string
}
@@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt,
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
@@ -113,7 +118,7 @@ const activityColumns = `
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at
workout_not_found_at, created_at, updated_at
`
// GetActivity fetches one activity by its internal id, scoped to userID.
@@ -225,6 +230,21 @@ func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64,
return nil
}
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
// deleted on Garmin's side after being linked to this activity.
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND user_id = ?`, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
}
return nil
}
// SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
@@ -272,3 +292,58 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (
}
return n, nil
}
// ActivitiesMissingWorkout returns userID's activities that have a
// structured workout (WorkoutID set at initial upsert time, straight from
// Garmin's activity summary) but haven't had get_workout_by_id fetched yet.
// Gated on details_fetched_at IS NOT NULL: fillActivityWorkout resolves each
// lap's target pace/HR band via alignWorkoutTargets, which needs the
// activity's laps already written by fillActivityDetails -- without this
// gate, a structured-workout activity whose details/laps haven't been
// fetched yet would fall into a separate, independently LIMIT-bounded batch
// than fillPendingDetails's, get its workout_raw_json set against zero laps
// (a silent no-op alignment), and then never be retried once its laps
// finally arrive, since workout_raw_json IS NULL is the only signal this
// query has left. This is still independent of splits_fetched_at (unlike
// ActivitiesMissingDetails, which requires both): splits/laps aren't needed
// to resolve workout targets, only details_fetched_at is. It also still
// covers the intended retry case -- an activity whose workout fetch
// previously failed after details succeeded always has details_fetched_at
// already set, so it's still surfaced here -- while excluding activities
// that simply haven't been processed by fillActivityDetails at all yet.
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
}
defer rows.Close()
activities := []Activity{}
for rows.Next() {
a, err := scanActivity(rows)
if err != nil {
return nil, fmt.Errorf("scan activity row: %w", err)
}
activities = append(activities, a)
}
return activities, rows.Err()
}
// CountActivitiesMissingWorkout returns how many of userID's activities
// still need get_workout_by_id fetched, regardless of any per-call batch
// limit -- used to report overall remaining work, mirroring
// CountActivitiesMissingDetails. See ActivitiesMissingWorkout for why this
// is additionally gated on details_fetched_at IS NOT NULL.
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
}
return n, nil
}

View File

@@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (
return a, true, nil
}
// ReviewQueue returns userID's activities whose current assignment status
// is needs_review, newest first.
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
rows, err := db.QueryContext(ctx, `
SELECT `+kindAssignmentColumns+`
FROM current_kind_assignment a
JOIN activities ON activities.id = a.activity_id
WHERE activities.user_id = ? AND a.status = ?
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
if err != nil {
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
}
defer rows.Close()
assignments := []KindAssignment{}
for rows.Next() {
a, err := scanKindAssignment(rows)
if err != nil {
return nil, fmt.Errorf("scan kind assignment row: %w", err)
}
assignments = append(assignments, a)
}
return assignments, rows.Err()
}
// AllCurrentAssignments returns the latest assignment for every one of
// userID's activities that has one, regardless of status or source -- the
// basis for deciding which activities a global reclassify pass may touch.

View File

@@ -0,0 +1,40 @@
package store
import (
"context"
"fmt"
)
// ConfigValues returns every application-configuration row as key ->
// value. The table holds a row for every registry key: main seeds missing
// keys with their defaults at startup, so downstream code never needs a
// code-side fallback.
func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) {
rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`)
if err != nil {
return nil, fmt.Errorf("config values: %w", err)
}
defer rows.Close()
values := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("scan config row: %w", err)
}
values[k] = v
}
return values, rows.Err()
}
// SetConfigValue upserts one override row. Key validation is the caller's
// job (config.ValidateAppValue) -- the store stays a dumb K/V layer.
func (db *DB) SetConfigValue(ctx context.Context, key, value string) error {
if _, err := db.ExecContext(ctx, `
INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, value); err != nil {
return fmt.Errorf("set config %q: %w", key, err)
}
return nil
}

View File

@@ -0,0 +1,35 @@
package store
import (
"context"
"testing"
)
func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
values, err := db.ConfigValues(ctx)
if err != nil {
t.Fatalf("ConfigValues (empty): %v", err)
}
if len(values) != 0 {
t.Fatalf("expected no overrides in a fresh DB, got %v", values)
}
if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil {
t.Fatalf("SetConfigValue: %v", err)
}
// Same key again: upsert must overwrite, not error or duplicate.
if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil {
t.Fatalf("SetConfigValue (overwrite): %v", err)
}
values, err = db.ConfigValues(ctx)
if err != nil {
t.Fatalf("ConfigValues: %v", err)
}
if len(values) != 1 || values["session.duration"] != "24" {
t.Fatalf("expected {session.duration: 24}, got %v", values)
}
}

View File

@@ -137,7 +137,7 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
}
runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill)
runID, err := db.StartSyncRun(ctx, userA, SyncKindFull)
if err != nil {
t.Fatalf("StartSyncRun(a): %v", err)
}
@@ -161,3 +161,213 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
}
}
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
if _, err := db.GetProfile(ctx, userB); err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
userBRow, found, err := db.GetUserBySub(ctx, "sub-b")
if err != nil || !found || userBRow.Name != "B" {
t.Errorf("userB's account changed after deleting userA: %+v (found=%v err=%v)", userBRow, found, err)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}
// TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers confirms
// ActivitiesMissingWorkout/CountActivitiesMissingWorkout only ever surface
// userID's own pending-workout activities, never another user's, even
// though both users have activities in the exact shape (workout_id set,
// details fetched, workout_raw_json still NULL) the query selects for.
func TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
workoutID := int64(999)
idA, err := db.UpsertActivity(ctx, userA, Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity(a): %v", err)
}
if err := db.SetActivityDetails(ctx, userA, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails(a): %v", err)
}
idB, err := db.UpsertActivity(ctx, userB, Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.SetActivityDetails(ctx, userB, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails(b): %v", err)
}
pendingA, err := db.ActivitiesMissingWorkout(ctx, userA, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(a): %v", err)
}
if len(pendingA) != 1 || pendingA[0].ID != idA {
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want only userA's own activity (id %d)", pendingA, idA)
}
countA, err := db.CountActivitiesMissingWorkout(ctx, userA)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(a): %v", err)
}
if countA != 1 {
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (userB's identical-looking activity must not be counted)", countA)
}
// Calling SetActivityWorkoutNotFound with the WRONG user's ID against
// userA's activity (idA) must be a no-op: the WHERE id = ? AND user_id = ?
// won't match any row, so it should neither error nor mark idA not-found.
// (This runs before the SetActivityWorkout resolution below, while idA
// is still pending -- once workout_raw_json is set, idA drops out of
// ActivitiesMissingWorkout regardless of workout_not_found_at, which
// would make the "still appears" assertion vacuous.)
if err := db.SetActivityWorkoutNotFound(ctx, userB, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound(b, idA): %v", err)
}
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
}
if len(pendingA) != 1 || pendingA[0].ID != idA {
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want idA still pending -- userB's mismatched call must not mark it not-found", pendingA)
}
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
}
if countA != 1 {
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (unaffected by userB's mismatched-user SetActivityWorkoutNotFound call)", countA)
}
// Now the CORRECT user marks their own activity not-found: idA must
// disappear from userA's own results, and userB's own idB (still
// pending) must remain wholly unaffected.
if err := db.SetActivityWorkoutNotFound(ctx, userA, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound(a, idA): %v", err)
}
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
}
if len(pendingA) != 0 {
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want empty after userA marked their own idA not-found", pendingA)
}
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
}
if countA != 0 {
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 0 after userA marked their own idA not-found", countA)
}
pendingB, err := db.ActivitiesMissingWorkout(ctx, userB, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
}
if len(pendingB) != 1 || pendingB[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's idB still pending, unaffected by userA's SetActivityWorkoutNotFound call", pendingB)
}
countB, err := db.CountActivitiesMissingWorkout(ctx, userB)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
}
if countB != 1 {
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkoutNotFound call)", countB)
}
// Resolving userA's workout (now moot for idA specifically, since it was
// already marked not-found above, but still exercises SetActivityWorkout
// itself) must not affect userB's pending activity.
if err := db.SetActivityWorkout(ctx, userA, idA, `{"segments":[]}`); err != nil {
t.Fatalf("SetActivityWorkout(a): %v", err)
}
pendingB, err = db.ActivitiesMissingWorkout(ctx, userB, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(b): %v", err)
}
if len(pendingB) != 1 || pendingB[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's activity unaffected by userA's SetActivityWorkout", pendingB)
}
countB, err = db.CountActivitiesMissingWorkout(ctx, userB)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(b): %v", err)
}
if countB != 1 {
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkout call)", countB)
}
}
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
// one user's Garmin connection never sets another user's flag.
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if err := db.MarkGarminConnected(ctx, userA); err != nil {
t.Fatalf("MarkGarminConnected(a): %v", err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b): %v", err)
}
if profileB.GarminConnectedAt != nil {
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
}
}

View File

@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_laps WHERE activity_id = ?`, activityID); err != nil {
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
}
for _, l := range laps {
_, err := tx.ExecContext(ctx, `
INSERT INTO laps (
INSERT INTO activity_laps (
activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
@@ -84,7 +84,7 @@ func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]
SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps,
laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min,
laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
FROM laps
FROM activity_laps AS laps
JOIN activities ON activities.id = laps.activity_id
WHERE laps.activity_id = ? AND activities.user_id = ?
ORDER BY laps.lap_index`, activityID, userID)

View File

@@ -8,11 +8,14 @@ import (
// Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct {
// Name labels this profile so a future multi-profile setup can show
// which one is active. Only one profile row exists today (id=1).
Name string
GarminEmail string
GarminPassword string
// GarminConnectedAt is nil until this user's first successful Garmin
// authentication (see store.MarkGarminConnected) -- the login gate uses
// it, not UpdateProfile, so it's deliberately excluded from
// UpdateProfile's SET clause below and can only ever move from nil to
// set, never reset by a normal profile save.
GarminConnectedAt *string
RollingWindowDays int
// BackfillHorizonDays bounds how far back "Sync now" reaches when
// walking backward from today; it's read fresh on every sync (not fixed
@@ -70,7 +73,7 @@ type Profile struct {
}
const profileColumns = `
name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
@@ -84,8 +87,8 @@ const profileColumns = `
// GetProfile returns the profile row for userID.
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
&p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct,
@@ -106,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
// replaces every column.
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
_, err := db.ExecContext(ctx, `
UPDATE profile SET
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
UPDATE profiles SET
garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?,
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
@@ -117,7 +120,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now')
WHERE user_id = ?`,
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
p.HRZone5MinPct, p.HRZone5MaxPct,
@@ -132,3 +135,14 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
}
return nil
}
// MarkGarminConnected records the first time userID successfully
// authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `UPDATE profiles SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
if err != nil {
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
}
return nil
}

View File

@@ -26,15 +26,12 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 {
t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p)
}
if p.BackfillHorizonDays != 1095 {
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
if p.BackfillHorizonDays != 90 {
t.Errorf("BackfillHorizonDays = %d, want 90 (schema default)", p.BackfillHorizonDays)
}
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
}
if p.Name != "Default" {
t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default")
}
if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
}
@@ -52,7 +49,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
}
maxHR, restingHR := 190.0, 50.0
p.Name = "Kriss"
p.PaceColor = "#111111"
p.EffortColor = "#222222"
p.MainLineTintPct = 45
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got)
}
if got.Name != "Kriss" {
t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
}
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
}
@@ -107,3 +100,44 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
}
}
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
before, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if before.GarminConnectedAt != nil {
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
}
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected: %v", err)
}
afterFirst, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after first mark: %v", err)
}
if afterFirst.GarminConnectedAt == nil {
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
}
firstValue := *afterFirst.GarminConnectedAt
// A second call must not change the recorded first-connection time.
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected (second call): %v", err)
}
afterSecond, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after second mark: %v", err)
}
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
}
}

View File

@@ -8,15 +8,16 @@
-- here rather than appending an ALTER TABLE migration.
-- One geniusrun account per OIDC subject. Every other table below is scoped
-- to a user_id, directly (profile/workout_kinds/activities/sync_state/
-- sync_runs) or transitively through a JOIN to the owning row (laps/
-- to a user_id, directly (profiles/workout_kinds/activities/sync_state/
-- sync_runs) or transitively through a JOIN to the owning row (activity_laps/
-- activity_samples/kind_assignments/workout_type_paces, which have no
-- user_id column of their own since they're never queried except through a
-- specific activity or workout kind).
-- specific activity or workout kind). name is the account's single
-- human-facing name: set at onboarding, editable from the Profile page.
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -24,17 +25,22 @@ CREATE TABLE users (
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
-- chart colors). store.ProvisionUser creates this (and everything else
-- below) in one transaction when a new account signs up.
CREATE TABLE profile (
CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL DEFAULT 'Default',
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
rolling_window_days INTEGER NOT NULL DEFAULT 90,
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
-- walking backward from today; read fresh on every sync (not cached at
-- server startup), so changing it here takes effect on the next click.
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
max_heart_rate REAL,
resting_heart_rate REAL,
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
@@ -82,7 +88,7 @@ CREATE TABLE profile (
-- recursive AND/OR condition tree evaluated by internal/classify.
CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
@@ -100,7 +106,7 @@ CREATE TABLE workout_kinds (
-- synced activity log is the history. No user_id column of its own --
-- ownership is checked via a JOIN to workout_kinds.
CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL,
@@ -118,7 +124,7 @@ CREATE TABLE workout_type_paces (
-- instead of storing a redundant copy.
CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's
@@ -145,6 +151,14 @@ CREATE TABLE activities (
-- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT,
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
-- workout was deleted on Garmin's side after being linked to this
-- activity) -- distinct from workout_raw_json staying null for "not yet
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
-- it stops being retried forever (see
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
-- workout_raw_json itself is never fabricated; it just stays null.
workout_not_found_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, garmin_activity_id)
@@ -154,7 +168,7 @@ CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
-- One row per lap/split (from get_activity_splits), plus HR drift/recovery
-- derived from activity_samples. No user_id column -- always accessed
-- through a specific owning activity.
CREATE TABLE laps (
CREATE TABLE activity_laps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL,
@@ -219,7 +233,7 @@ JOIN (
-- re-walking years of already-known history on every call.
CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id)
@@ -229,7 +243,7 @@ CREATE TABLE sync_state (
-- status the frontend polls.
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL,
finished_at TEXT,
@@ -237,3 +251,16 @@ CREATE TABLE sync_runs (
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
error_message TEXT
);
-- Application configuration: instance-global key/value settings, shared
-- by every user -- deliberately the one table with no user_id, because
-- application configuration is common to all users by definition. Every
-- key in internal/config's app-key registry is mandatory here: missing
-- rows are seeded with their defaults at startup (cmd/geniusrund), so
-- there are no code-side fallbacks. Cold: read once at startup, a change
-- applies on the next backend restart.
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -149,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
}
queue, err := db.ReviewQueue(ctx, userID)
if err != nil {
t.Fatalf("ReviewQueue: %v", err)
pending, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err)
}
if len(queue) != 1 {
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
if pending.Status != AssignmentStatusNeedsReview {
t.Fatalf("current status = %q, want needs_review", pending.Status)
}
// Then: user manually resolves it.
@@ -167,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (manual): %v", err)
}
queue, err = db.ReviewQueue(ctx, userID)
if err != nil {
t.Fatalf("ReviewQueue after resolve: %v", err)
}
if len(queue) != 0 {
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
}
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
@@ -244,7 +236,7 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
ctx := context.Background()
// UNIQUE(user_id, name) allows the same name across two different users.
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
t.Fatalf("insert users: %v", err)
}
if _, err := db.ExecContext(ctx, `
@@ -255,10 +247,10 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
}
// profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate.
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil {
if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil {
t.Fatalf("insert profile for sub-a: %v", err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil {
if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil {
t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
}
}
@@ -356,6 +348,183 @@ func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
}
}
// TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched
// is a regression test for a bug where ActivitiesMissingWorkout selected
// activities purely on workout_id IS NOT NULL AND workout_raw_json IS NULL,
// with no regard for whether get_activity_details had ever run for that
// activity. Since fillPendingDetails and fillPendingWorkouts are each
// independently LIMIT-bounded over different candidate sets, a
// structured-workout activity could fall inside the workouts pass's window
// while still outside the details pass's window on a large first backfill.
// fillActivityWorkout would then call LapsForActivity on an activity with no
// laps yet written, compute alignWorkoutTargets against zero laps (a
// no-op), and unconditionally call SetActivityWorkout anyway -- permanently
// marking workout_raw_json non-NULL before the activity ever had a chance
// to get real target pace/HR bands once its laps finally arrived. The fix
// gates the query on details_fetched_at IS NOT NULL, so an activity is only
// eligible for a workout fetch once fillActivityDetails has actually given
// it laps to align against.
func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
// Activity a: has a workout_id, but details/splits have never been
// fetched (the exact shape of the bug above). Must now be EXCLUDED --
// this is the regression assertion for the bug.
withWorkoutNoDetails := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, withWorkoutNoDetails)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
// Activity b: has a workout_id, and its details/splits have already
// been fetched (e.g. its workout fetch failed in some prior run after
// details succeeded). This is the one case ActivitiesMissingWorkout must
// still surface, since ActivitiesMissingDetails would never pick this
// activity up again once details_fetched_at/splits_fetched_at are set.
withWorkoutAndDetails := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, withWorkoutAndDetails)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
// Activity c: no workout_id at all. Must be excluded.
noWorkout := Activity{
GarminActivityID: 3, StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}",
}
if _, err := db.UpsertActivity(ctx, userID, noWorkout); err != nil {
t.Fatalf("UpsertActivity (c): %v", err)
}
// Activity d: already has its workout_raw_json set. Must be excluded.
alreadyHasWorkout := Activity{
GarminActivityID: 4, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-04 06:00:00", RawJSON: "{}",
}
idD, err := db.UpsertActivity(ctx, userID, alreadyHasWorkout)
if err != nil {
t.Fatalf("UpsertActivity (d): %v", err)
}
if err := db.SetActivityWorkout(ctx, userID, idD, `{"segments":[]}`); err != nil {
t.Fatalf("SetActivityWorkout (d): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (activity b only -- activity a must be excluded since its details were never fetched)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 {
t.Fatalf("ActivitiesMissingWorkout returned %d activities, want 1", len(pending))
}
if pending[0].ID != idB {
t.Errorf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
for _, a := range pending {
if a.ID == idA {
t.Errorf("ActivitiesMissingWorkout incorrectly included activity a (workout_id set but details never fetched) -- regression for the silent-loss bug")
}
}
}
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
// Details already fetched, workout confirmed 404 on Garmin -- must not
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
notFound := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, notFound)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails (a): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
}
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
}
// A genuinely still-pending activity (details fetched, workout not yet
// attempted) must still be included, for contrast.
stillPending := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, stillPending)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 || pending[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
confirmed, _, err := db.GetActivity(ctx, userID, idA)
if err != nil {
t.Fatalf("GetActivity (a): %v", err)
}
if confirmed.WorkoutNotFoundAt == nil {
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
}
if confirmed.WorkoutRawJSON != nil {
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
}
func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s)
for _, substr := range substrs {

View File

@@ -7,12 +7,10 @@ import (
)
const (
SyncKindBackfill = "backfill"
SyncKindIncremental = "incremental"
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed
// by IncrementalSync followed by FillPendingDetails, recorded as one run
// so the reported activity count covers the whole action instead of only
// whichever stage happened to finish last.
// SyncKindFull is a manually-triggered "Sync now" pass: backfillCore
// followed by incrementalSyncCore followed by FillPendingDetails,
// recorded as one run so the reported activity count covers the whole
// action instead of only whichever stage happened to finish last.
SyncKindFull = "full"
SyncStatusRunning = "running"
@@ -20,7 +18,10 @@ const (
SyncStatusError = "error"
)
// SyncRun records one backfill or incremental sync attempt.
// SyncRun records one sync attempt. Every run recorded today is
// SyncKindFull (the "Sync now" action, covering backfill + incremental sync
// + detail/workout fill in a single pass) -- "backfill"/"incremental" only
// ever appear in historical rows predating that consolidation.
type SyncRun struct {
ID int64
Kind string

View File

@@ -13,7 +13,7 @@ import (
type User struct {
ID int64
OIDCSub string
DisplayName string
Name string
CreatedAt string
}
@@ -21,8 +21,8 @@ type User struct {
// the session-resolution middleware ever uses.
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
var u User
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt)
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
if err == sql.ErrNoRows {
return User{}, false, nil
}
@@ -32,26 +32,6 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err
return u, true, nil
}
// ListUsers returns every provisioned user, for the background incremental
// sync loop to iterate.
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
users := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("scan user row: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
// neverMatchRule is the placeholder every fresh install's rule-engine kinds
// start with -- every activity lands in needs_review until the user tunes
// real rules.
@@ -76,14 +56,14 @@ var defaultWorkoutKindSeeds = []WorkoutKind{
// default workout kinds (with their paired workout_type_paces rows), and an
// initial sync_state row -- all in one transaction, so a partially
// provisioned user is never observable.
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) {
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, name string) (int64, error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return 0, fmt.Errorf("begin provision user tx: %w", err)
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName)
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES (?, ?)`, oidcSub, name)
if err != nil {
return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
}
@@ -92,7 +72,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
return 0, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil {
if _, err := tx.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES (?)`, userID); err != nil {
return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
}
@@ -119,3 +99,26 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
return userID, tx.Commit()
}
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}
// UpdateUserName renames userID's account -- the single human-facing name
// (shown in the header and session info), editable from the Profile page.
func (db *DB) UpdateUserName(ctx context.Context, userID int64, name string) error {
if _, err := db.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, name, userID); err != nil {
return fmt.Errorf("update name for user %d: %w", userID, err)
}
return nil
}

View File

@@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if u.ID != userID || u.DisplayName != "Lucie" {
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID)
if u.ID != userID || u.Name != "Lucie" {
t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID)
}
profile, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if profile.Name != "Lucie" {
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
}
if profile.RollingWindowDays != 90 {
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
}
@@ -89,3 +86,76 @@ func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
t.Fatal("expected each user's seeded kinds to be distinct rows")
}
}
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindFull); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profiles WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM activity_laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}

View File

@@ -1,728 +0,0 @@
package sync
import (
"context"
"path/filepath"
"testing"
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
)
func f(v float64) *float64 { return &v }
func openTestDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func fixedNow(t time.Time) func() time.Time {
return func() time.Time { return t }
}
func provisionTestUser(t *testing.T, db *store.DB) int64 {
t.Helper()
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
return userID
}
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config).
func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
t.Helper()
ctx := context.Background()
profile, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
profile.BackfillHorizonDays = days
if err := db.UpdateProfile(ctx, userID, profile); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
}
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
}}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 1 {
t.Fatalf("expected 1 stored activity, got %d", len(activities))
}
if activities[0].GarminActivityID != 1 {
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
}
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess {
t.Fatalf("expected 1 successful sync run, got %+v", runs)
}
}
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
}},
}}
targets := alignWorkoutTargets(laps, workout)
if len(targets) != 2 {
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
}
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
t.Errorf("targets[0] = %+v, want pace.zone step", targets[0])
}
if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" {
t.Errorf("targets[1] = %+v, want no.target step", targets[1])
}
}
func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) {
// Confirmed via Garmin Connect against a real activity: recording
// sometimes continues one lap past the workout's last step (e.g. a
// 5-minute prescribed cool-down followed by another 6:46 the athlete
// just kept running). The extra lap should have no target, but every
// other lap's real target must still come through.
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
}},
}}
targets := alignWorkoutTargets(laps, workout)
if len(targets) != 3 {
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
}
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0])
}
if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" {
t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1])
}
if targets[2] != nil {
t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2])
}
}
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO"},
}},
}}
targets := alignWorkoutTargets(laps, workout)
if len(targets) != 3 {
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
}
for i, target := range targets {
if target != nil {
t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target)
}
}
}
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
lo, hi := targetPaceRange(garmin.WorkoutStep{
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
})
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
}
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
if lo != nil || hi != nil {
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
}
}
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
lo, hi := targetHRRange(garmin.WorkoutStep{
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
}, store.Profile{})
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
}
zone := 3
profile := store.Profile{
MaxHeartRate: f(190), RestingHeartRate: f(50),
HRZone3MinPct: 70, HRZone3MaxPct: 80,
}
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
}
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
if lo != nil || hi != nil {
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
}
}
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
}}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 2 {
t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities)
}
for _, a := range activities {
if a.GarminActivityID != 1 && a.GarminActivityID != 2 {
t.Errorf("unexpected non-running activity stored: %+v", a)
}
}
}
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 42
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
}},
},
Details: map[int64]garmin.ActivityDetails{
garminActivityID: {
ActivityID: garminActivityID,
MetricDescriptors: []garmin.MetricDescriptor{
{Key: "directHeartRate", MetricsIndex: 0},
{Key: "sumElapsedDuration", MetricsIndex: 1},
},
},
},
}
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
// A workout kind that should cleanly match the seeded activity's pace.
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
activityID := activities[0].ID
if activities[0].DetailsFetchedAt == nil {
t.Error("expected DetailsFetchedAt to be set after FillPendingDetails")
}
if activities[0].SplitsFetchedAt == nil {
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
}
laps, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
}
if assignment.Status != classify.StatusAssigned {
t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON)
}
}
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 55
const workoutID = 999
workoutIDPtr := int64(workoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
}},
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]garmin.Workout{
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps)
}
if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 {
t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps)
}
}
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 56
const workoutID = 1000
workoutIDPtr := int64(workoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
}},
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]garmin.Workout{
// One step for two recorded laps -- the second lap is an
// unplanned continuation past the workout's end (confirmed via
// Garmin Connect against a real activity), not a genuine
// mismatch, so the first lap should still get a real target.
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 2 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps)
}
if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil {
t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps)
}
}
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
activity := store.Activity{
DurationSeconds: 1800,
DistanceMeters: 6000,
AvgSpeedMps: f(3.33),
AvgHR: f(152),
AerobicTrainingEffect: f(3.2),
}
laps := []store.Lap{
{IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)},
{IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)},
}
ctx := buildMetricContext(activity, laps, 190)
if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 {
t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got)
}
if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want {
t.Errorf("avg_hr_pct_max = %v, want %v", got, want)
}
if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 {
t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got)
}
if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 {
t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got)
}
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
// structured interval workout.
if got := ctx["lap_interval_pattern"]; got != 0 {
t.Errorf("lap_interval_pattern = %v, want 0", got)
}
if got := ctx["is_race"]; got != 0 {
t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got)
}
}
func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0)
if got := ctx["is_race"]; got != 1 {
t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got)
}
ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0)
if got := ctx["is_race"]; got != 0 {
t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got)
}
}
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
firstCallCount := m.GetActivitiesCalls
if firstCallCount == 0 {
t.Fatal("expected first backfill to call GetActivities at least once")
}
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
if !state.BackfillComplete {
t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state)
}
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}
if m.GetActivitiesCalls != firstCallCount {
t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)",
m.GetActivitiesCalls-firstCallCount)
}
}
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
firstCallCount := m.GetActivitiesCalls
if err := svc.ResetAll(ctx); err != nil {
t.Fatalf("ResetAll: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 0 {
t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities))
}
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill after reset: %v", err)
}
if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
}
activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities after re-backfill: %v", err)
}
if len(activities) != 1 {
t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities))
}
}
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
firstCallCount := m.GetActivitiesCalls
// Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
setBackfillHorizon(t, db, userID, 30)
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
if err := svc2.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}
if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
}
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
if !state.BackfillComplete {
t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state)
}
}
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
Details: map[int64]garmin.ActivityDetails{
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
if err := svc.FullSync(ctx, 10); err != nil {
t.Fatalf("FullSync: %v", err)
}
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
if len(runs) != 1 {
t.Fatalf("expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v", len(runs), runs)
}
run := runs[0]
if run.Kind != store.SyncKindFull {
t.Errorf("Kind = %q, want %q", run.Kind, store.SyncKindFull)
}
if run.Status != store.SyncStatusSuccess {
t.Errorf("Status = %q, want success", run.Status)
}
// Backfill (one window covering the whole horizon) stores both of the
// mock's activities as genuinely new (2). IncrementalSync then runs its
// own separate fetch and -- since the fake client ignores the date range
// it's called with -- sees the exact same 2 activities again, but they're
// already stored by then, so it contributes 0 new ones. The combined
// run's count (2) must reflect that dedup, not naively sum each stage's
// raw fetch count (which would double-count to 4) or report only
// whichever stage happened to run last (which would silently drop
// Backfill's count) -- both are bugs this test guards against.
if run.ActivitiesFetched != 2 {
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 2 {
t.Fatalf("expected 2 stored activities (upserted, not duplicated), got %d", len(activities))
}
}
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{},
Splits: map[int64]garmin.ActivitySplits{},
Details: map[int64]garmin.ActivityDetails{},
}
const n = 3
for i := int64(1); i <= n; i++ {
m.Activities = append(m.Activities, garmin.Activity{
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
})
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
}
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
if p := svc.Progress(); p.Total != 0 {
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
}
done := make(chan error, 1)
go func() { done <- svc.FillPendingDetails(ctx, n) }()
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
mid := svc.Progress()
if mid.Total != n {
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
}
if mid.Done <= 0 || mid.Done >= n {
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
}
if err := <-done; err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
}
}
func TestService_TwoUsersSyncIndependently(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
mA := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
}}
mB := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
}}
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svcA.Backfill(ctx); err != nil {
t.Fatalf("Backfill(a): %v", err)
}
if err := svcB.Backfill(ctx); err != nil {
t.Fatalf("Backfill(b): %v", err)
}
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(a): %v", err)
}
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(b): %v", err)
}
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
}
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
}
}

View File

@@ -2,16 +2,8 @@
set -euo pipefail
cd "$(dirname "$0")"
export MCP_GARMIN_PYTHON="${MCP_GARMIN_PYTHON:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/.venv/bin/python3}"
export MCP_GARMIN_SERVER="${MCP_GARMIN_SERVER:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/server.py}"
export GENIUSRUN_DB_PATH="${GENIUSRUN_DB_PATH:-geniusrun.db}"
# OIDC login gate -- no safe default exists for a client secret or a
# session-signing key, so these must come from your own shell/CI secrets.
export GENIUSRUN_PUBLIC_BASE_URL="${GENIUSRUN_PUBLIC_BASE_URL:?set GENIUSRUN_PUBLIC_BASE_URL, e.g. https://geniusrun.example.com}"
export GENIUSRUN_OIDC_ISSUER_URL="${GENIUSRUN_OIDC_ISSUER_URL:?set GENIUSRUN_OIDC_ISSUER_URL, e.g. https://keycloak.example.com/realms/myrealm}"
export GENIUSRUN_OIDC_CLIENT_ID="${GENIUSRUN_OIDC_CLIENT_ID:?set GENIUSRUN_OIDC_CLIENT_ID}"
export GENIUSRUN_OIDC_CLIENT_SECRET="${GENIUSRUN_OIDC_CLIENT_SECRET:?set GENIUSRUN_OIDC_CLIENT_SECRET}"
export GENIUSRUN_SESSION_SECRET="${GENIUSRUN_SESSION_SECRET:?set GENIUSRUN_SESSION_SECRET to a random string >=32 characters}"
set -a
source .env
set +a
exec go run ./cmd/geniusrund

View File

@@ -5,15 +5,16 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
## Tables
- [`users`](#users)
- [`profile`](#profile)
- [`profiles`](#profiles)
- [`workout_kinds`](#workout_kinds)
- [`workout_type_paces`](#workout_type_paces)
- [`activities`](#activities)
- [`laps`](#laps)
- [`activity_laps`](#activity_laps)
- [`activity_samples`](#activity_samples)
- [`kind_assignments`](#kind_assignments)
- [`sync_state`](#sync_state)
- [`sync_runs`](#sync_runs)
- [`config`](#config)
## `users`
@@ -21,25 +22,30 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
## `profile`
## `profiles`
```sql
CREATE TABLE profile (
CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL DEFAULT 'Default',
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
rolling_window_days INTEGER NOT NULL DEFAULT 90,
-- BackfillHorizonDays bounds how far back "Sync now" reaches when
-- walking backward from today; read fresh on every sync (not cached at
-- server startup), so changing it here takes effect on the next click.
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095,
backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
max_heart_rate REAL,
resting_heart_rate REAL,
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
@@ -87,7 +93,7 @@ CREATE TABLE profile (
```sql
CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
@@ -104,7 +110,7 @@ CREATE TABLE workout_kinds (
```sql
CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL,
@@ -117,7 +123,7 @@ CREATE TABLE workout_type_paces (
```sql
CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's
@@ -144,6 +150,14 @@ CREATE TABLE activities (
-- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT,
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
-- workout was deleted on Garmin's side after being linked to this
-- activity) -- distinct from workout_raw_json staying null for "not yet
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
-- it stops being retried forever (see
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
-- workout_raw_json itself is never fabricated; it just stays null.
workout_not_found_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, garmin_activity_id)
@@ -156,10 +170,10 @@ Indexes:
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
```
## `laps`
## `activity_laps`
```sql
CREATE TABLE laps (
CREATE TABLE activity_laps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL,
@@ -227,7 +241,7 @@ CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
```sql
CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id)
@@ -239,7 +253,7 @@ CREATE TABLE sync_state (
```sql
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL,
finished_at TEXT,
@@ -249,6 +263,16 @@ CREATE TABLE sync_runs (
);
```
## `config`
```sql
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
## Views
### `current_kind_assignment`

View File

@@ -7,9 +7,68 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
for that history) and remove it from here once a spec exists.
## Backlog
- (add ideas here)
- new workout kinds
- add "Recovery", "Quick", and "Sprint" workout kinds
- order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
- adapt the color palette from blue to purple according to this order (starting with blue for recovery -> green -> yellow -> orange -> red until purple for race), put the color code in DB (UI shall not resolve it with kindColor based on workout kind name)
- workout templates
- provide workout templates in YAML (see docs/workouts.yaml for the structure)
- first time setup page
- once connection is validated, add a step to ask user its best race results on 5k, 10k, half-marathon and marathon (at least one shall be provided) and store it in the profile. these values will be updated later during synchronization (as we can detect race kinds with their distance)
- add another step to enter latest known MAS (in mm:ss/km) and store it in the profile. this value will be updated later during synchronization (as we can assign an activity with "MAS test" kind)
- profile page
- move "Heart rate" card before "Activity analysis" card and rename it to "Characteristics"
- add genre (male/female) and age in "Characteristics"
- rename "Activity analysis" card to "Activities"
- in activity analysis, remove "rolling window" and consider the future AI-assisted activity kind analysis will be done on all the synced activities
- in activity analysis, remove the "warm-up" and "cool-down" as we'll detect them during activities synchronization and block identification
- Move all "Chart colors" items inside "Activities" card (and remove "Chart colors" card)
- add a personal records cards for 1k, 5k, 10k, half-marathon, marathon (we'll have at least one value coming from "first time setup page", or more after synchronization). show the date if it comes from an activity
- add a performance card showing latest know MAS (we'll have at least one value coming from "first time setup page", or a true one after synchronization). show the date if it comes from an activity
- rename "Training types" card to "Workouts"
- for each workout kind in "Workout kinds" card
- add a radio button to declare a warm-up or not (when checked, add a field to enter duration in minutes)
- add a radio button to declare a cool-down or not (when checked, add a field to enter duration in minutes)
- add a way to choose its primary objective (either "heart rate", either "% MAS")
- if "heart rate" is selected, make appear the "min heart rate", "max heart rate", "min pace", "max pace" fields like before (heart rate fields are mandatory, but not the pace ones)
- if "% MAS" is selected, make appear a "% MAS" field requiring a value between 0 and 150, and a "pace amplitude (s/km)" field requiring a number between 0 and 60
- add Claude API key (keep it optional, this will be for AI-assisted services)
- analysis page
- refactor /api/progress in /api/analysis
- there should now be history graphs per workout kind, like today (remove VO2max metric as we'll move it elsewhere) and general history graphs to monitor new metrics not linked to a workout kind
- general metrics are :
- VO2max
- (as soon as if we have a 5k race in the profile) "Endurance Index (5k)" which is (100-%MAS)/(ln(6/t)) where %MAS is 5k pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of 5k race in minutes
- (as soon as if we have a 10k race in the profile) "Endurance Index (10k)" which is (100-%MAS)/(ln(6/t)) where %MAS is 10k pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of 10k race in minutes
- (as soon as if we have a half-marathon race in the profile) "Endurance Index (HM)" which is (100-%MAS)/(ln(6/t)) where %MAS is half-marathon pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of half-marathon race in minutes
- (as soon as if we have a marathon race in the profile) "Endurance Index (M)" which is (100-%MAS)/(ln(6/t)) where %MAS is marathon pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of marathon race in minutes
- general metrics history policy is :
- VO2max, one history point for all activities (whatever their kind)
- Endurance Index metrics, one history point as soon as one formula value is changing (aka race time or MAS)
- create a "Status" card with following elements before the history graphs
- a line scale indicating current VO2max (declare and use application configuration vo2max.gxxyyy where g could be "m" for male or "f" for female, xx could be 20,30,40,50,60,70 and yyy could be "fair", "good", "excellent", "superior", get default values from https://www8.garmin.com/manuals/webhelp/venu/EN-US/GUID-1FBCCD9E-19E1-4E4C-BD60-1793B5B97EB3.html, for example vo2max.m40good=42.4)
- (as soon as if we have a 5k race in the profile) a line scale indicating current "Endurance Index (5k)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- (as soon as if we have a 10k race in the profile) a line scale indicating current "Endurance Index (10k)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- (as soon as if we have a half-marathon race in the profile) a line scale indicating current "Endurance Index (HM)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- (as soon as if we have a marathon race in the profile) a line scale indicating current "Endurance Index (M)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- training plan page
- we need to store our workouts and dedicated /api/workouts (these are not the garmin workouts)
- this page will list all the planned workouts of the plan, no consideration of schedule for now, just a list of workouts
- for each workout, we have to see
- kind (with same color policy as for the activities)
- objective (as given in workout type profile)
- min pace / max pace ()
- activities page
- add an indicator if a personal record has been beaten (with a cup icon)
- add an indicator for the maximum heart rate (just after the average heart rate)
- AI-assisted ranking button that will set workout kind on unlocked activities (based on workout kind description in profile page)
- discovery of warm-up, cool-down phases, so we can read activity per blocks (with detection of extension of a block compared to linked workout)
- discovery of additional blocks done but not requested by linked workout (for example, additional small sprints at the end of an easy run)
- add icon to indicate if an activity has an associated workout
- manage 2 layouts
- list layout with smaller graphs (no scale, no targets)
- detailed layout (once clicked on details...) showing graphs for each block incl. delta with workout)
## Someday / maybe
- (lower-priority or vaguer ideas here)
- training programs with phases (tapering, easier weeks, post-race recovery)

View File

@@ -22,8 +22,8 @@
**Files:**
- Create: `backend/internal/store/migrations/0003_profile.sql`
- Create: `backend/internal/store/profile.go`
- Test: `backend/internal/store/profile_test.go`
- Create: `../../../backend/internal/store/profiles.go`
- Test: `../../../backend/internal/store/profiles_test.go`
**Interfaces:**
- Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`.
@@ -72,7 +72,7 @@ INSERT INTO profile (id) VALUES (1);
- [ ] **Step 2: Write the failing test**
Create `backend/internal/store/profile_test.go`:
Create `../../../backend/internal/store/profiles_test.go`:
```go
package store
@@ -130,9 +130,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
Run: `cd backend && go test ./internal/store/... -run TestProfile -v`
Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet).
- [ ] **Step 4: Write `profile.go`**
- [ ] **Step 4: Write `profiles.go`**
Create `backend/internal/store/profile.go`:
Create `../../../backend/internal/store/profiles.go`:
```go
package store
@@ -253,7 +253,7 @@ Expected: PASS
- [ ] **Step 6: Commit**
```bash
cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profile.go internal/store/profile_test.go
cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profiles.go internal/store/profiles_test.go
git commit -m "feat: add single-profile settings table and store layer"
```
(If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.)
@@ -876,7 +876,7 @@ Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
cd backend && git add internal/api/profile.go internal/api/server.go internal/api/api_test.go
cd backend && git add internal/api/profiles.go internal/api/server.go internal/api/api_test.go
git commit -m "feat: add profile REST endpoints with HR zone validation"
```
@@ -1256,8 +1256,8 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
### Task 7: Classification reads max HR from the profile, not static config
**Files:**
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
- Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
**Interfaces:**
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1).
@@ -1265,7 +1265,7 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
- [ ] **Step 1: Update the test first**
In `backend/internal/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
In `../../../backend/internal/garmin/sync_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
```go
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
@@ -1284,7 +1284,7 @@ Expected: passes as-is right now (we haven't removed the field yet) — this ste
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39):
In `../../../backend/internal/garmin/sync.go`, the `Config` struct currently ends with (lines 35-39):
```go
// MinConfidence is the classify.Classify threshold below which even a
@@ -1381,7 +1381,7 @@ Expected: all PASS. If `cmd/geniusrund/main.go` fails to build because it still
- [ ] **Step 6: Commit**
```bash
cd backend && git add internal/sync/service.go internal/sync/service_test.go
cd backend && git add internal/sync/sync.go internal/sync/sync_test.go
git commit -m "feat: classification reads max HR from the profile instead of static config"
```
@@ -1390,7 +1390,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
**Files:**
- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below)
- Modify: `../../../backend/internal/config/envconfig.go` (full rewrite — small file, shown complete below)
- Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
**Interfaces:**
@@ -1399,7 +1399,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
- [ ] **Step 1: Rewrite `internal/config/config.go`**
Replace the full contents of `backend/internal/config/config.go` with:
Replace the full contents of `../../../backend/internal/config/envconfig.go` with:
```go
// Package config loads geniusrund's runtime infrastructure configuration
@@ -1590,7 +1590,7 @@ Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` r
- [ ] **Step 5: Commit**
```bash
cd backend && git add internal/config/config.go cmd/geniusrund/main.go
cd backend && git add internal/config/envconfig.go cmd/geniusrund/main.go
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
```

View File

@@ -61,15 +61,15 @@ CLAUDE.md [MODIFY] document the new env vars / auth architectur
### Task 1: Config — OIDC/session settings
**Files:**
- Modify: `backend/internal/config/config.go`
- Create: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Create: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`.
- [ ] **Step 1: Write the failing tests**
Create `backend/internal/config/config_test.go`:
Create `../../../backend/internal/config/envconfig_test.go`:
```go
package config
@@ -179,7 +179,7 @@ Expected: compile errors (`cfg.OIDCRedirectURL` etc. undefined) — that's the e
- [ ] **Step 3: Implement the config fields and validation**
In `backend/internal/config/config.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
In `../../../backend/internal/config/envconfig.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "config: add OIDC/session env vars for the login gate"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
# Fix OIDC callback redirect origin — implementation 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:** Fix `handleSessionCallback`'s redirects so they resolve to the frontend's actual origin instead of a relative path resolved against the backend's own origin, which 404s in this project's own supported split-origin local dev setup.
**Architecture:** Add one new optional config field, `FrontendURL` (env `GENIUSRUN_FRONTEND_URL`), defaulting to `PublicBaseURL`. Thread it through `config.Config``cmd/geniusrund/main.go``api.SessionConfig` → the five `http.Redirect` calls in `handleSessionCallback`.
**Tech Stack:** Go, `net/http`, existing `internal/config`/`internal/api` test conventions.
## Global Constraints
- No behavior change when `GENIUSRUN_FRONTEND_URL` is unset — `FrontendURL` must default to exactly `PublicBaseURL` (already trimmed of trailing slash).
- `handleSessionLogin`'s `writeError` responses are explicitly out of scope — do not touch them (confirmed with the user).
- `handleSessionLogout`'s redirect (`s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/")`) is explicitly out of scope — do not touch it.
- All 5 redirect targets in `handleSessionCallback` must use `s.Session.FrontendURL` as the origin: the 3 `"/?auth_error=failed"` branches, the 1 `"/?auth_error=forbidden"` branch, and the 1 success (`"/"`) branch.
---
### Task 1: Add `FrontendURL` to `internal/config`
**Files:**
- Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Produces: `Config.FrontendURL string` — set by `Load()`, defaults to `Config.PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, trimmed of any trailing slash otherwise. Consumed by Task 2's `main.go` wiring.
- [ ] **Step 1: Write the failing tests**
Add to `../../../backend/internal/config/envconfig_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
```go
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.FrontendURL != cfg.PublicBaseURL {
t.Errorf("FrontendURL = %q, want it to default to PublicBaseURL %q", cfg.FrontendURL, cfg.PublicBaseURL)
}
}
func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.FrontendURL != "http://localhost:5173" {
t.Errorf("FrontendURL = %q, want http://localhost:5173 (trailing slash trimmed)", cfg.FrontendURL)
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/config/... -run TestLoad_FrontendURL -v`
Expected: compile failure — `Config.FrontendURL` doesn't exist yet.
- [ ] **Step 3: Add the field and default logic**
In `../../../backend/internal/config/envconfig.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
// OIDCRedirectURL and whether session cookies can be marked Secure,
// instead of requiring both to be configured separately and risking them
// drifting out of sync.
PublicBaseURL string
// FrontendURL is the origin the browser should land on after the OIDC
// callback (both success and failure) -- e.g. "http://localhost:5173" in
// local dev, where the frontend and backend are different origins
// bridged by CORS (see internal/api's corsMiddleware and
// frontend/src/api/client.ts's BASE_URL). Defaults to PublicBaseURL when
// unset, which is correct for the common production topology where a
// reverse proxy unifies frontend and backend under one origin.
// PublicBaseURL itself must stay pointed at the backend's own origin
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
// which must match wherever those routes are actually served.
FrontendURL string
OIDCIssuerURL string
```
(i.e. insert the `FrontendURL` field and its comment between `PublicBaseURL` and `OIDCIssuerURL` — the struct's other fields are unchanged.)
In `Load()`, immediately after the existing `if cfg.PublicBaseURL == "" { return cfg, fmt.Errorf(...) }` check (so the default is only computed once `PublicBaseURL` is confirmed non-empty), add:
```go
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.PublicBaseURL), "/")
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cd backend && go test ./internal/config/... -v`
Expected: all tests PASS, including the two new ones.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "$(cat <<'EOF'
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL
Lets the OIDC callback redirect to the frontend's real origin instead of
a relative path resolved against the backend's own origin -- needed for
this project's own supported split-origin local dev setup (frontend on
Vite, backend on geniusrund, bridged by CORS).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: Use `FrontendURL` in `handleSessionCallback`'s redirects
**Files:**
- Modify: `backend/internal/api/session.go`
- Modify: `backend/cmd/geniusrund/main.go`
- Modify: `backend/internal/api/api_test.go`
**Interfaces:**
- Consumes: `config.Config.FrontendURL` (Task 1).
- Produces: `api.SessionConfig.FrontendURL string` — new field, read by `handleSessionCallback`.
- [ ] **Step 1: Update the shared test fixture and write the failing tests**
In `backend/internal/api/api_test.go`, update `testSessionConfig` (currently):
```go
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
Secure: false,
PublicBaseURL: "https://geniusrun.example.com",
}
```
to:
```go
var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour,
Secure: false,
PublicBaseURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com",
}
```
(Deliberately a *different* value from `PublicBaseURL`, so the three tests below actually exercise the new field instead of accidentally passing against an empty-string prefix.)
Update the three existing `Location`-asserting tests' expectations:
`TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome` — change:
```go
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
```
to:
```go
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/" {
```
`TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie` — change:
```go
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=forbidden" {
```
to:
```go
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=forbidden" {
```
`TestSessionCallback_MissingTxnCookieRedirectsFailed` — change:
```go
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/?auth_error=failed" {
```
to:
```go
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "https://app.geniusrun.example.com/?auth_error=failed" {
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/api/... -run TestSessionCallback -v`
Expected: all three tests FAIL (current code still redirects to the bare relative path; `SessionConfig.FrontendURL` doesn't exist yet, so this is actually a compile failure first — that's expected too).
- [ ] **Step 3: Add `FrontendURL` to `SessionConfig` and use it in the five redirects**
In `backend/internal/api/session.go`, add a field to `SessionConfig` (right after `PublicBaseURL`):
```go
type SessionConfig struct {
Secret []byte
Duration time.Duration
Secure bool
// PublicBaseURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash), used to build an
// absolute post_logout_redirect_uri for the identity provider -- some
// providers, including Keycloak, require this to be an absolute URL
// matching one registered on the client, not a bare relative path.
PublicBaseURL string
// FrontendURL is the origin the browser should land on after the OIDC
// callback (success or failure) -- see config.Config.FrontendURL for why
// this can differ from PublicBaseURL in a split-origin deployment.
FrontendURL string
}
```
Then replace `handleSessionCallback`'s body (all five `http.Redirect` targets) — the function currently is:
```go
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil {
log.Printf("session callback: missing txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
http.Redirect(w, r, "/?auth_error=failed", http.StatusFound)
return
}
if !result.Authorized {
http.Redirect(w, r, "/?auth_error=forbidden", http.StatusFound)
return
}
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, sessionCookie)
http.Redirect(w, r, "/", http.StatusFound)
}
```
Replace it with (only the five redirect-target string literals change, nothing else):
```go
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil {
log.Printf("session callback: missing txn cookie: %v", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure))
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret)
if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound)
return
}
if !result.Authorized {
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
return
}
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
http.SetCookie(w, sessionCookie)
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound)
}
```
In `backend/cmd/geniusrund/main.go`, change:
```go
}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure,
PublicBaseURL: cfg.PublicBaseURL,
})
```
to:
```go
}, authVerifier, api.SessionConfig{
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure,
PublicBaseURL: cfg.PublicBaseURL,
FrontendURL: cfg.FrontendURL,
})
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cd backend && go test ./internal/api/... -v`
Expected: all tests in the package PASS, including the three updated ones.
- [ ] **Step 5: Full-repo verification**
Run:
```bash
cd backend
gofmt -l .
go build ./...
go vet ./...
go test ./...
```
Expected: `gofmt -l .` prints nothing; `go build`/`go vet` produce no errors; `go test ./...` passes for every package.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/api/session.go backend/internal/api/api_test.go backend/cmd/geniusrund/main.go
git commit -m "$(cat <<'EOF'
fix(api): redirect the OIDC callback to FrontendURL, not a relative path
handleSessionCallback's redirects (success and all 4 failure branches)
were relative paths, which resolve against the backend's own origin --
broken in this project's own supported split-origin local dev setup,
since the Go backend serves no "/" route at all. Now uses the new
config.Config.FrontendURL (defaults to PublicBaseURL, so no change for
single-origin production deployments).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EOF
)"
```

View File

@@ -765,16 +765,16 @@ EOF
---
## Task 4: Scope `profile.go` to `user_id`
## Task 4: Scope `profiles.go` to `user_id`
**Files:**
- Modify: `backend/internal/store/profile.go`
- Modify: `backend/internal/store/profile_test.go`
- Modify: `../../../backend/internal/store/profiles.go`
- Modify: `../../../backend/internal/store/profiles_test.go`
**Interfaces:**
- Produces: `func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error)`; `func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error`. Every other task/caller of these two methods must pass `userID` as the second positional argument from here on.
- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `backend/internal/store/profile.go`**
- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `../../../backend/internal/store/profiles.go`**
Replace the two function bodies (the `Profile` struct and `profileColumns` constant are unchanged):
@@ -832,7 +832,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
}
```
- [ ] **Step 2: Fix `backend/internal/store/profile_test.go`'s call sites**
- [ ] **Step 2: Fix `../../../backend/internal/store/profiles_test.go`'s call sites**
The existing `TestProfile_DefaultsThenUpdate` calls `db.GetProfile(ctx)` and `db.UpdateProfile(ctx, p)` directly against a fresh DB with no provisioned user — since Task 1 made `profile` rows always belong to a user, this test must provision one first. Replace the test's opening two lines:
@@ -866,7 +866,7 @@ Run: `cd backend && gofmt -l internal/store/`
Expected: no output.
```bash
git add backend/internal/store/profile.go backend/internal/store/profile_test.go
git add backend/internal/store/profiles.go backend/internal/store/profiles_test.go
git commit -m "$(cat <<'EOF'
store: scope GetProfile/UpdateProfile to a user_id
@@ -2223,14 +2223,14 @@ EOF
## Task 10: `internal/sync.Service` becomes per-user
**Files:**
- Modify: `backend/internal/sync/service.go`
- Modify: `backend/internal/sync/service_test.go`
- Modify: `../../../backend/internal/garmin/sync.go`
- Modify: `../../../backend/internal/garmin/sync_test.go`
**Interfaces:**
- Consumes: every scoped store method from Tasks 4-8.
- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself.
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`**
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `../../../backend/internal/garmin/sync.go`**
Change the `Service` struct and `NewService`:
@@ -2269,7 +2269,7 @@ Then, in every remaining method, prefix `s.userID` as the new argument to every
- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)``s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)``s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)``s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)``s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)``s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`.
- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)``s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)``s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)``s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)``s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})``s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`.
- [ ] **Step 2: Fix `backend/internal/sync/service_test.go`'s `NewService` calls**
- [ ] **Step 2: Fix `../../../backend/internal/garmin/sync_test.go`'s `NewService` calls**
Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`):
@@ -2355,7 +2355,7 @@ Run: `cd backend && gofmt -l internal/sync/`
Expected: no output.
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
git commit -m "$(cat <<'EOF'
sync: scope Service to one user per instance
@@ -2372,12 +2372,12 @@ EOF
## Task 11: `internal/config` additions
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `../../../backend/internal/config/envconfig.go`
**Interfaces:**
- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both.
- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`**
- [ ] **Step 1: Rename `GarminTokenStore` → `TokenStoreRoot` and add `LegacyOwnerOIDCSub`**
In the `Config` struct, change:
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
Expected: no output.
```bash
git add backend/internal/config/config.go
git add backend/internal/config/envconfig.go
git commit -m "$(cat <<'EOF'
config: add per-user Garmin token store root and legacy-owner bootstrap var
@@ -2442,17 +2442,17 @@ EOF
## Task 12: User-resolution middleware + `POST /api/setup`
**Files:**
- Create: `backend/internal/api/usercontext.go`
- Create: `../../../backend/internal/api/user.go`
- Create: `backend/internal/api/setup.go`
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
- Create: `backend/internal/api/usercontext_test.go`
- Create: `../../../backend/internal/api/user_test.go`
- Create: `backend/internal/api/setup_test.go`
**Interfaces:**
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing).
- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`.
- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_test.go`**
- [ ] **Step 1: Write the failing tests in `../../../backend/internal/api/user_test.go`**
Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly.
@@ -2521,7 +2521,7 @@ func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
Run: `cd backend && go vet ./internal/api/...`
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`**
- [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
```go
package api
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output.
```bash
git add backend/internal/api/usercontext.go backend/internal/api/usercontext_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
git add backend/internal/api/user.go backend/internal/api/user_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
git commit -m "$(cat <<'EOF'
api: add user-resolution middleware and POST /api/setup
@@ -3302,7 +3302,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
- [ ] **Step 4: Build everything and fix remaining call sites**
Run: `cd backend && go build ./... 2>&1 | head -50`
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `auth.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profiles.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `garmin.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
- [ ] **Step 5: `gofmt` and commit**
@@ -3325,12 +3325,12 @@ EOF
---
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.go`, `progression.go`
## Task 14: Thread `userID` into `profiles.go`, `kinds.go`, `garmin.go`, `progression.go`
**Files:**
- Modify: `backend/internal/api/profile.go`
- Modify: `backend/internal/api/kinds.go`
- Modify: `backend/internal/api/auth.go`
- Modify: `../../../backend/internal/api/garmin.go`
- Modify: `backend/internal/api/progression.go`
- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged)
@@ -3508,7 +3508,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
}
```
- [ ] **Step 3: Update `backend/internal/api/auth.go`**
- [ ] **Step 3: Update `../../../backend/internal/api/garmin.go`**
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output.
```bash
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/auth.go backend/internal/api/progression.go
git add backend/internal/api/profiles.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go
git commit -m "$(cat <<'EOF'
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID

View File

@@ -0,0 +1,822 @@
# Improve First Connection Page Implementation 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:** Make connecting a Garmin account a mandatory, persisted gate during onboarding (and on every subsequent login until it succeeds), instead of an optional step left to the Profile page.
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleGarminAuthLogin`/`handleGarminAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile``CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
**Tech Stack:** Go (`database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
## Global Constraints
- `internal/store/schema.sql` is edited directly, never migrated (pre-production app, see `CLAUDE.md`).
- Every store/API method touching per-user data takes an explicit `userID` and filters by it.
- Cross-user isolation is tested adversarially (two real users, real IDs), not just checked for non-collision.
- After the `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (from `backend/`).
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
- This local dev DB predates schema changes made this session (`ON DELETE CASCADE`) but was already fixed up in place. A brand-new nullable column (`garmin_connected_at TEXT`) needs no such fix-up: SQLite's idempotent-skip `applySchema` never re-applies `schema.sql` to an existing DB file, but adding a nullable column to `profile`'s Go-side handling doesn't require the on-disk table to already have it selected correctly... **actually it does**`GetProfile`'s `SELECT` will fail against the live dev DB once it lists `garmin_connected_at` in `profileColumns`, since that column doesn't exist there yet. Task 1 includes a real `ALTER TABLE` against the live dev DB (safe, additive, no rebuild needed this time) to keep manual testing working.
---
### Task 1: Persist `garmin_connected_at` in the store layer
**Files:**
- Modify: `backend/internal/store/schema.sql` (add column to the `profile` table)
- Modify: `../../../backend/internal/store/profiles.go` (`Profile` struct, `profileColumns`, `GetProfile`, new `MarkGarminConnected`)
- Modify: `../../../backend/internal/store/profiles_test.go` (new tests)
- Modify: `backend/internal/store/isolation_test.go` (new adversarial test)
- Modify: `docs/DATABASE.md` (regenerated)
- Modify (real DB, not version-controlled): `backend/geniusrun.db` — additive `ALTER TABLE`
**Interfaces:**
- Produces: `Profile.GarminConnectedAt *string` (nil until first successful Garmin auth); `func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error`.
- [ ] **Step 1: Write the failing tests**
Add to `../../../backend/internal/store/profiles_test.go`:
```go
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
before, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if before.GarminConnectedAt != nil {
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
}
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected: %v", err)
}
afterFirst, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after first mark: %v", err)
}
if afterFirst.GarminConnectedAt == nil {
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
}
firstValue := *afterFirst.GarminConnectedAt
// A second call must not change the recorded first-connection time.
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected (second call): %v", err)
}
afterSecond, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after second mark: %v", err)
}
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
}
}
```
Add to `backend/internal/store/isolation_test.go`:
```go
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
// one user's Garmin connection never sets another user's flag.
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if err := db.MarkGarminConnected(ctx, userA); err != nil {
t.Fatalf("MarkGarminConnected(a): %v", err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b): %v", err)
}
if profileB.GarminConnectedAt != nil {
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/store/... -run 'TestMarkGarminConnected|TestIsolation_MarkGarminConnected' -v`
Expected: FAIL — `db.MarkGarminConnected undefined` / `Profile has no field GarminConnectedAt`.
- [ ] **Step 3: Add the column to `schema.sql`**
In `backend/internal/store/schema.sql`, in the `profile` table (right after `garmin_password`), change:
```sql
garmin_password TEXT NOT NULL DEFAULT '',
rolling_window_days INTEGER NOT NULL DEFAULT 90,
```
to:
```sql
garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
rolling_window_days INTEGER NOT NULL DEFAULT 90,
```
- [ ] **Step 4: Update `Profile`, `profileColumns`, and `GetProfile` in `profiles.go`**
Change the struct (add the field right after `GarminPassword`):
```go
Name string
GarminEmail string
GarminPassword string
// GarminConnectedAt is nil until this user's first successful Garmin
// authentication (see store.MarkGarminConnected) -- the login gate uses
// it, not UpdateProfile, so it's deliberately excluded from
// UpdateProfile's SET clause below and can only ever move from nil to
// set, never reset by a normal profile save.
GarminConnectedAt *string
RollingWindowDays int
```
Change `profileColumns`:
```go
const profileColumns = `
name, garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
`
```
Change `GetProfile`'s `Scan` call (add `&p.GarminConnectedAt` right after `&p.GarminPassword`):
```go
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct,
&p.WarmupMinutes, &p.CooldownMinutes,
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
&p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor,
&p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct,
&p.CreatedAt, &p.UpdatedAt,
)
```
Do **not** touch `UpdateProfile``garmin_connected_at` must stay out of its `SET`/args list so a normal Profile-page save can never reset it.
- [ ] **Step 5: Add `MarkGarminConnected`**
Append to `../../../backend/internal/store/profiles.go`:
```go
// MarkGarminConnected records the first time userID successfully
// authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `UPDATE profile SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
if err != nil {
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
}
return nil
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd backend && go test ./internal/store/... -run 'TestMarkGarminConnected|TestIsolation_MarkGarminConnected' -v`
Expected: PASS.
- [ ] **Step 7: Run the full store test suite**
Run: `cd backend && go test ./internal/store/...`
Expected: PASS.
- [ ] **Step 8: Regenerate `docs/DATABASE.md`**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` shows the new `garmin_connected_at` column in `profile`.
- [ ] **Step 9: Additively update the live local dev DB**
The real `backend/geniusrun.db` predates this column (schema.sql changes never retrofit an existing DB file — see `applySchema`'s idempotent skip in `db.go`). Unlike the earlier `ON DELETE CASCADE` fix, this is purely additive (a new nullable column), so a plain `ALTER TABLE` is sufficient — no rebuild-and-copy dance needed:
```bash
sqlite3 backend/geniusrun.db "ALTER TABLE profile ADD COLUMN garmin_connected_at TEXT;"
sqlite3 backend/geniusrun.db "SELECT sql FROM sqlite_master WHERE type='table' AND name='profile';" | grep garmin_connected_at
```
Confirm the backend isn't running before touching the file (`ps aux | grep geniusrund`), same care as before.
- [ ] **Step 10: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/profiles.go backend/internal/store/profiles_test.go backend/internal/store/isolation_test.go docs/DATABASE.md
git commit -m "feat(store): persist garmin_connected_at, set once on first successful auth"
```
---
### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
**Files:**
- Modify: `../../../backend/internal/api/garmin.go` (`recordAuthResult`, both call sites)
- Modify: `backend/internal/api/session.go` (`sessionMeResponse`, `handleSessionMe`)
- Modify: `backend/internal/api/api_test.go` (new tests)
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test)
**Interfaces:**
- Consumes: `store.DB.MarkGarminConnected(ctx, userID) error` (Task 1); `store.DB.GetProfile(ctx, userID) (store.Profile, error)` (existing).
- Produces: `sessionMeResponse.GarminConnected bool` (JSON `garmin_connected`).
- [ ] **Step 1: Write the failing tests**
Append to `backend/internal/api/api_test.go`:
```go
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var before sessionMeResponse
unmarshalBody(t, rec, &before)
if before.GarminConnected {
t.Fatal("expected a fresh account to report garmin_connected=false")
}
rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var after sessionMeResponse
unmarshalBody(t, rec, &after)
if !after.GarminConnected {
t.Fatal("expected garmin_connected=true after a successful auth")
}
}
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t)
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.GarminConnected {
t.Fatal("expected garmin_connected=false after mfa_required")
}
}
```
Append to `backend/internal/api/isolation_test.go`:
```go
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
// successful Garmin auth never flips garmin_connected for another user.
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
var meB sessionMeResponse
unmarshalBody(t, rec, &meB)
if meB.GarminConnected {
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`.
- [ ] **Step 3: Update `recordAuthResult` in `garmin.go`**
Change the imports:
```go
import (
"context"
"encoding/json"
"log"
"net/http"
"geniusrun/backend/internal/garmin"
)
```
Change `recordAuthResult` and both call sites:
```go
// recordAuthResult updates the in-memory auth status/message for userID,
// and -- on a successful authentication -- persists that this account has
// connected to Garmin at least once (store.MarkGarminConnected), which is
// what the login gate actually checks (session cookie's in-memory auth
// status resets on every backend restart; this doesn't).
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
}
}
}
```
In `handleGarminAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleGarminAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
- [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`**
Change:
```go
type sessionMeResponse struct {
Name string `json:"name"`
Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
}
```
to:
```go
type sessionMeResponse struct {
Name string `json:"name"`
Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
GarminConnected bool `json:"garmin_connected"`
}
```
Change `handleSessionMe`:
```go
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
// Unreachable in practice -- RequireSession already 401s before this
// handler runs -- but fail closed rather than panic if that ever changes.
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true
resp.DisplayName = u.DisplayName
profile, err := s.DB.GetProfile(r.Context(), u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp.GarminConnected = profile.GarminConnectedAt != nil
}
writeJSON(w, http.StatusOK, resp)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
Expected: PASS.
- [ ] **Step 6: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/api/garmin.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git commit -m "feat(api): persist and expose garmin_connected on successful auth"
```
---
### Task 3: Frontend — three-way login gate and the new ConnectGarmin screen
**Files:**
- Modify: `frontend/src/types/api.ts` (`SessionInfo`)
- Create: `frontend/src/ConnectGarmin.tsx`
- Modify: `frontend/src/CreateProfile.tsx` (add Log out link)
- Modify: `frontend/src/CreateProfile.css` (one new class, shared with `ConnectGarmin.tsx`)
- Modify: `frontend/src/LoginGate.tsx` (three-way fork)
**Interfaces:**
- Consumes: `api.getProfile()`, `api.updateProfile(profile)`, `api.login()`, `api.submitMFA(code)` (all existing, unchanged signatures); `SessionInfo.garmin_connected: boolean` (new).
- Produces: `ConnectGarmin({ onConnected: () => void })`, a React component.
- [ ] **Step 1: Add `garmin_connected` to `SessionInfo`**
In `frontend/src/types/api.ts`, change:
```ts
export interface SessionInfo {
name: string;
email: string;
has_profile: boolean;
display_name?: string;
}
```
to:
```ts
export interface SessionInfo {
name: string;
email: string;
has_profile: boolean;
display_name?: string;
garmin_connected: boolean;
}
```
- [ ] **Step 2: Add a shared status-message class to `CreateProfile.css`**
Append to `frontend/src/CreateProfile.css`:
```css
.create-profile-message {
margin: 0;
color: #9ca3af;
}
.create-profile-mfa {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 320px;
}
```
- [ ] **Step 3: Create `frontend/src/ConnectGarmin.tsx`**
```tsx
import { useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./CreateProfile.css";
import type { AuthResponse } from "./types/api";
// Shown after CreateProfile (or on any later login, until it succeeds --
// see LoginGate) since connecting Garmin is mandatory: the account exists
// but this is the last gate before the main app. Deliberately not a reuse
// of GarminConnection.tsx (the Profile page's version), which also renders
// Sync now/Disconnect/Reset all -- none of which make sense here.
export function ConnectGarmin({ onConnected }: { onConnected: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [auth, setAuth] = useState<AuthResponse | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function connect(e: React.FormEvent) {
e.preventDefault();
if (!email.trim() || !password) {
setError("Please enter your Garmin email and password.");
return;
}
setBusy(true);
setError(null);
try {
const profile = await api.getProfile();
await api.updateProfile({ ...profile, GarminEmail: email.trim(), GarminPassword: password });
setAuth(await api.login());
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
} finally {
setBusy(false);
}
}
async function submitMFA() {
if (!code.trim()) return;
setBusy(true);
setError(null);
try {
setAuth(await api.submitMFA(code.trim()));
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
} finally {
setBusy(false);
}
}
const status = auth?.status;
return (
<div className="create-profile">
<h1>🧞 Connect your Garmin account</h1>
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
{status !== "authenticated" && status !== "mfa_required" && (
<form onSubmit={connect}>
<input
type="text"
placeholder="Garmin email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy}
autoFocus
/>
<input
type="password"
placeholder="Garmin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
/>
<button type="submit" disabled={busy}>
{busy ? "Connecting…" : "Connect"}
</button>
</form>
)}
{status === "mfa_required" && (
<div className="create-profile-mfa">
<input
placeholder="MFA code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
disabled={busy}
autoFocus
/>
<button type="button" disabled={busy} onClick={submitMFA}>
Submit code
</button>
</div>
)}
{status === "authenticated" && (
<>
<p className="create-profile-message">Connected to Garmin.</p>
<button type="button" onClick={onConnected}>
Continue to geniusrun
</button>
</>
)}
{auth?.message && status !== "authenticated" && <p className="create-profile-message">{auth.message}</p>}
{error && <p className="create-profile-error">{error}</p>}
<form method="post" action={`${BASE_URL}/api/session/logout`}>
<button type="submit" className="logout-link">
Log out
</button>
</form>
</div>
);
}
```
- [ ] **Step 4: Add a Log out link to `CreateProfile.tsx`**
Change:
```tsx
{error && <p className="create-profile-error">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Continue"}
</button>
</form>
</div>
);
}
```
to:
```tsx
{error && <p className="create-profile-error">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Continue"}
</button>
</form>
<form method="post" action={`${BASE_URL}/api/session/logout`}>
<button type="submit" className="logout-link">
Log out
</button>
</form>
</div>
);
}
```
And change the import line at the top of `CreateProfile.tsx`:
```tsx
import { api } from "./api/client";
```
to:
```tsx
import { api, BASE_URL } from "./api/client";
```
- [ ] **Step 5: Wire the three-way fork into `LoginGate.tsx`**
Change the imports:
```tsx
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";
import { CreateProfile } from "./CreateProfile";
import type { SessionInfo } from "./types/api";
```
to:
```tsx
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";
import { ConnectGarmin } from "./ConnectGarmin";
import { CreateProfile } from "./CreateProfile";
import type { SessionInfo } from "./types/api";
```
Change the final part of the component body:
```tsx
if (!session!.has_profile) {
return (
<CreateProfile
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/>
);
}
return <App session={session!} />;
}
```
to:
```tsx
if (!session!.has_profile) {
return (
<CreateProfile
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/>
);
}
// Connecting Garmin is mandatory, re-checked on every login (not just
// right after signup) -- see docs/superpowers/specs/2026-07-26-improve-first-connection-design.md
// for why this can't be a purely in-session check.
if (!session!.garmin_connected) {
return <ConnectGarmin onConnected={() => setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />;
}
return <App session={session!} />;
}
```
Also update the doc comment above the `LoginGate` function (currently describes only a two-way fork) -- change:
```tsx
// Wraps App: on mount, asks the backend whether this browser already has a
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
// this is the first fork -- "show the login screen" vs "show the app." A
// second fork, once authenticated, is whether the session's account has a
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
// sees CreateProfile instead of App until it submits one.
```
to:
```tsx
// Wraps App: on mount, asks the backend whether this browser already has a
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
// this is the first fork -- "show the login screen" vs "show the app." A
// second fork, once authenticated, is whether the session's account has a
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
// sees CreateProfile instead of App until it submits one. A third fork,
// once provisioned, is whether Garmin has ever been successfully connected
// (session.garmin_connected) -- mandatory, and re-checked on every login,
// not just immediately after signup.
```
- [ ] **Step 6: Build and lint**
Run: `cd frontend && npm run build && npm run lint`
Expected: build succeeds; lint reports no new warnings beyond the 3 pre-existing `PaceField.tsx` ones.
- [ ] **Step 7: Manual browser verification**
With the backend (`cd backend && ./start.sh`) and frontend (`cd frontend && ./start.sh`) running:
1. Delete your profile (or use a fresh OIDC account) so you land on `CreateProfile`.
2. Submit a display name — confirm you're immediately routed to `ConnectGarmin`, not the main app.
3. Try an obviously wrong Garmin password — confirm the error shows and the form stays editable.
4. Enter correct credentials (resolve MFA if prompted) — confirm "Connected to Garmin." appears with a "Continue to geniusrun" button, and clicking it lands in the main app.
5. Log out, log back in with the same account — confirm you land straight in the app (not back through `ConnectGarmin`), proving `garmin_connected_at` persisted.
6. On the `ConnectGarmin` screen specifically, confirm the "Log out" link works.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/types/api.ts frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.tsx frontend/src/CreateProfile.css frontend/src/LoginGate.tsx
git commit -m "feat(onboarding): add mandatory ConnectGarmin gate to first login"
```
---
### Task 4: Final verification
**Files:** none (verification + cleanup only)
- [ ] **Step 1: Full backend check**
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 2: Full frontend check**
Run: `cd frontend && npm run build && npm run lint`
Expected: both succeed, no new lint warnings.
- [ ] **Step 3: Confirm `docs/DATABASE.md` is current**
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
Expected: no output from `git status` (already committed in Task 1, unchanged since).
- [ ] **Step 4: Update `docs/IDEAS.md`**
Remove the now-implemented line from the Backlog section:
```
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
```
- [ ] **Step 5: Commit**
```bash
git add docs/IDEAS.md
git commit -m "docs: remove improve-first-connection-page from IDEAS backlog (implemented)"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,766 @@
# Profile Deletion Implementation 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:** Let a signed-in geniusrun user permanently delete their entire account (Garmin credentials, custom rules, every synced activity) from a "Danger zone" button on the Profile page, and log them out afterward.
**Architecture:** Add `ON DELETE CASCADE` to every FK pointing at `users(id)` (and `workout_kinds(id)`) in `schema.sql`, so a single `DELETE FROM users` removes everything. Wrap that in a new `store.DeleteUser`, expose it via `DELETE /api/profile`, and have that handler also tear down the deleted user's cached Garmin subprocess/client and on-disk token-store directory. The frontend gates the call behind a type-`DELETE`-to-confirm UI, then performs a real POST navigation to the existing `/api/session/logout` route (the same one the header's Log out button already uses) so the OIDC session ends too.
**Tech Stack:** Go (`net/http`, `database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
## Global Constraints
- `internal/store/schema.sql` is edited directly, never migrated — this is a pre-production app (see `CLAUDE.md`).
- Every store/API method that touches per-user data takes an explicit `userID` and must filter/scope by it — the entire cross-user isolation boundary depends on this (see `CLAUDE.md`'s Authentication section).
- Cross-user isolation must be tested adversarially (two real users, real IDs), not just checked for non-collision — matches `internal/store/isolation_test.go` / `internal/api/isolation_test.go` convention.
- After any `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (run from `backend/`).
- `gofmt -l .` must report nothing before committing; `go vet ./...` and `go build ./...` must pass.
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
---
### Task 1: Cascading deletes in the store layer
**Files:**
- Modify: `backend/internal/store/schema.sql:29,85,103,121,222,232`
- Modify: `backend/internal/store/users.go` (add `DeleteUser`)
- Modify: `backend/internal/store/users_test.go` (add `TestDeleteUser_RemovesUserAndCascadesEverything`)
- Modify: `backend/internal/store/isolation_test.go` (add `TestIsolation_DeleteUserLeavesOtherUsersDataIntact`)
- Modify: `docs/DATABASE.md` (regenerated, not hand-edited)
**Interfaces:**
- Produces: `func (db *DB) DeleteUser(ctx context.Context, userID int64) error` — deletes the `users` row for `userID`; every owned row (profile, workout_kinds, workout_type_paces, activities, laps, activity_samples, kind_assignments, sync_state, sync_runs) cascades away via the schema FKs added in this task.
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/store/users_test.go`:
```go
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}
```
Add to `backend/internal/store/isolation_test.go`:
```go
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
if profileB.Name != "B" {
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
Expected: FAIL — `db.DeleteUser undefined (type *DB has no field or method DeleteUser)`.
- [ ] **Step 3: Add `ON DELETE CASCADE` to schema.sql**
In `backend/internal/store/schema.sql`, change these six lines (each currently ends `REFERENCES users(id),` or `REFERENCES workout_kinds(id),`):
Line 29 (`profile.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 85 (`workout_kinds.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 103 (`workout_type_paces.workout_kind_id`), from:
```sql
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
```
to:
```sql
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
```
Line 121 (`activities.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 222 (`sync_state.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 232 (`sync_runs.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities(id)` — no change needed there. SQLite's `foreign_keys` pragma is already on for every connection (`db.go`), so these cascades (including the two-level `users → workout_kinds → workout_type_paces` chain) take effect immediately, no code change required for the pragma itself.
- [ ] **Step 4: Add `DeleteUser` to `backend/internal/store/users.go`**
Append after `ProvisionUser` (end of file):
```go
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
Expected: PASS.
- [ ] **Step 6: Run the full store test suite**
Run: `cd backend && go test ./internal/store/...`
Expected: PASS (confirms the new `ON DELETE CASCADE` clauses didn't break `ResetAllSyncedData` or any other existing behavior).
- [ ] **Step 7: Regenerate `docs/DATABASE.md`**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` updates to show `ON DELETE CASCADE` on the six changed columns.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/users.go backend/internal/store/users_test.go backend/internal/store/isolation_test.go docs/DATABASE.md
git commit -m "feat(store): add DeleteUser with cascading account deletion"
```
---
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
**Files:**
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeUserClient` after `syncFor`), `:167-170` (route)
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
**Interfaces:**
- Consumes: `store.DB.DeleteUser(ctx, userID) error` (Task 1); `garmin.Client.Close() error` and `.UpdateCredentials` (existing); `Server.userGarmin/userSync/userAuthStatus/userAuthMessage/userSyncRunning map[int64]...` and `Server.mu sync.Mutex` (existing fields); `Server.GarminBase garmin.Config` (existing, has `.TokenStorePath string`).
- Produces: `func (s *Server) removeGarminClient(userID int64)` — used only within this task's handler, not exported further. `DELETE /api/profile` route → `handleDeleteProfile`, responding `204` on success, `409` if a sync is running for that user.
- [ ] **Step 1: Write the failing tests**
Add `"os"` to `backend/internal/api/api_test.go`'s import block (needed for Step 1's token-store test):
```go
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Append to `backend/internal/api/api_test.go`:
```go
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
```
Append to `backend/internal/api/isolation_test.go`:
```go
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
- [ ] **Step 3: Add the `os` import and `removeUserClient` to `server.go`**
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
```go
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Insert this new method right after `syncFor` (i.e. between the existing `syncFor` closing brace and the `RunIncrementalSyncForAllUsers` doc comment):
```go
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeGarminClient(userID int64) {
s.mu.Lock()
client, ok := s.userGarmin[userID]
delete(s.userGarmin, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.GarminBase.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
```
- [ ] **Step 4: Register the route in `Router()`**
In `server.go`, change:
```go
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
})
```
to:
```go
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
})
```
- [ ] **Step 5: Add `handleDeleteProfile` to `profiles.go`**
Append to `backend/internal/api/profile.go`:
```go
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeGarminClient(userID)
w.WriteHeader(http.StatusNoContent)
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: PASS.
- [ ] **Step 7: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` prints nothing; `go build`/`go vet`/`go test` all succeed.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/api/server.go backend/internal/api/profiles.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git commit -m "feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown"
```
---
### Task 3: Frontend Danger zone UI
**Files:**
- Modify: `frontend/src/api/client.ts` (add `deleteProfile`)
- Modify: `frontend/src/pages/Profile.tsx` (add Danger zone fieldset + confirm flow)
- Modify: `frontend/src/App.css` (small `.danger-zone-confirm` rule)
**Interfaces:**
- Consumes: `DELETE /api/profile` (Task 2, returns `204` or throws via the existing `request()` helper's non-2xx handling); `BASE_URL` (already exported from `client.ts`).
- Produces: `api.deleteProfile(): Promise<void>`, used only from `Profile.tsx`.
- [ ] **Step 1: Add `deleteProfile` to the API client**
In `frontend/src/api/client.ts`, in the `// Profile` section, change:
```ts
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
```
to:
```ts
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Permanently deletes the signed-in user's entire account. The caller is
// responsible for following a successful call with a real logout
// navigation -- this does not touch the session cookie (see Profile.tsx).
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
```
- [ ] **Step 2: Add the Danger zone section to `Profile.tsx`**
Change the import line:
```tsx
import { api } from "../api/client";
```
to:
```tsx
import { api, BASE_URL } from "../api/client";
```
Add these three state variables inside `Profile`, right after the existing `saveTimeoutRef` declaration:
```tsx
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
```
Add this function right after `flushPendingSave`:
```tsx
async function deleteAccount() {
setDeleting(true);
setError(null);
try {
await api.deleteProfile();
// Deletion doesn't clear the session cookie -- follow with a real
// logout navigation (must be a POST, same as the header's Log out
// button in App.tsx) so Keycloak's own SSO session ends too, not just
// geniusrun's local one.
const form = document.createElement("form");
form.method = "post";
form.action = `${BASE_URL}/api/session/logout`;
document.body.appendChild(form);
form.submit();
} catch (e) {
setError(String(e));
setDeleting(false);
}
}
```
Add this fieldset right after `<TrainingTypesCard />` (still inside the closing `</div>` of `.profile-page`):
```tsx
<fieldset className="kind-editor danger-zone">
<legend>Danger zone</legend>
{!deleteConfirmOpen ? (
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
Delete profile
</button>
) : (
<div className="danger-zone-confirm">
<p>
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
activity -- and logs you out. This cannot be undone.
</p>
<label>
Type DELETE to confirm
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
disabled={deleting}
/>
</label>
<div className="controls">
<button
type="button"
disabled={deleting}
onClick={() => {
setDeleteConfirmOpen(false);
setDeleteConfirmText("");
}}
>
Cancel
</button>
<button
type="button"
className="button-danger"
disabled={deleting || deleteConfirmText !== "DELETE"}
onClick={deleteAccount}
>
Permanently delete
</button>
</div>
</div>
)}
</fieldset>
```
- [ ] **Step 3: Add the confirmation block's CSS**
In `frontend/src/App.css`, add right after the `.button-danger:hover:not(:disabled)` rule:
```css
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
```
- [ ] **Step 4: Build and lint**
Run: `cd frontend && npm run build && npm run lint`
Expected: build succeeds; lint reports no new warnings (the 3 pre-existing `PaceField.tsx` `only-export-components` warnings are unrelated and expected to remain).
- [ ] **Step 5: Manual browser verification**
With the backend running (`cd backend && ./start.sh`) and frontend dev server running (`cd frontend && ./start.sh`), in a browser:
1. Log in, go to the Profile page, scroll to "Danger zone".
2. Click "Delete profile" — the confirm block appears; "Permanently delete" is disabled.
3. Type `delete` (lowercase) — button stays disabled. Type `DELETE` — button enables.
4. Click "Cancel" — block collapses, typed text is cleared if reopened.
5. Reopen, type `DELETE`, click "Permanently delete" — expect a full-page navigation ending on the login screen (Keycloak or geniusrun's own login gate, matching what the existing "Log out" button already does).
6. Log back in with the same account — expect the "Create your profile" screen (`CreateProfile.tsx`), confirming the account was actually deleted, not just logged out.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/api/client.ts frontend/src/pages/Profile.tsx frontend/src/App.css
git commit -m "feat(profile): add Danger zone account deletion UI"
```
---
### Task 4: Final verification
**Files:** none (verification only)
- [ ] **Step 1: Full backend check**
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 2: Full frontend check**
Run: `cd frontend && npm run build && npm run lint`
Expected: both succeed, no new lint warnings.
- [ ] **Step 3: Confirm `docs/DATABASE.md` is current**
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
Expected: no output from `git status` (already committed in Task 1, and hasn't drifted since).
- [ ] **Step 4: Update `docs/IDEAS.md`**
Remove the now-implemented line from `docs/IDEAS.md`'s Backlog section:
```
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
```
- [ ] **Step 5: Commit**
```bash
git add docs/IDEAS.md
git commit -m "docs: remove profile deletion from IDEAS backlog (implemented)"
```

View File

@@ -0,0 +1,961 @@
# Structured JSON Logging Implementation 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:** Emit structured JSON logs on stdout for (1) every HTTP API request and (2) every Garmin wrapper subprocess call, correlated by a per-request id — without touching any existing `log.Printf` call site.
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `loggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `execute`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
**Tech Stack:** Go stdlib `log/slog` (no new dependency), `github.com/go-chi/chi/v5/middleware` (already-available subpackage of the existing chi dependency).
## Global Constraints
- Existing `log.Printf`/`log.Fatalf` call sites are untouched — they keep going to stderr, unstructured, exactly as today.
- Garmin credentials must never be logged. Confirmed safe: `authenticate`/`complete_mfa`/`call` wire params never carry email/password (only env vars at subprocess spawn do) — logging `params` in full is safe everywhere in `execute`.
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
---
### Task 1: `internal/applog` — JSON logger + context helpers
**Files:**
- Create: `backend/internal/applog/applog.go`
- Create: `../../../backend/internal/log/log_test.go`
**Interfaces:**
- Produces: `func NewLogger(level string, w io.Writer) *slog.Logger`, `func WithLogger(ctx context.Context, logger *slog.Logger) context.Context`, `func FromContext(ctx context.Context) *slog.Logger` (never nil — falls back to `slog.Default()`).
- [ ] **Step 1: Write the failing tests**
Create `backend/internal/applog/applog_test.go`:
```go
package applog
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("warn", &buf)
logger.Info("should be dropped")
if buf.Len() != 0 {
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
}
logger.Warn("should appear")
if !strings.Contains(buf.String(), "should appear") {
t.Fatalf("expected the Warn message in output, got %q", buf.String())
}
}
func TestNewLogger_WritesValidJSON(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("info", &buf)
logger.Info("hello", "key", "value")
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
}
if decoded["msg"] != "hello" || decoded["key"] != "value" {
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
}
}
func TestWithLogger_FromContext_RoundTrip(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
ctx := WithLogger(context.Background(), logger)
if got := FromContext(ctx); got != logger {
t.Errorf("FromContext returned a different logger than what was stashed")
}
}
func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
if got := FromContext(context.Background()); got == nil {
t.Fatal("FromContext on a bare context returned nil, want slog.Default()")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/applog/... -v`
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure).
- [ ] **Step 3: Create `../../../backend/internal/log/log.go`**
```go
// Package applog provides geniusrun's structured JSON logging: a
// log/slog-based logger writing to stdout, plus context helpers so a
// logger enriched in one layer (e.g. internal/api's HTTP middleware,
// attaching a request_id) is picked up by another (e.g. internal/garmin's
// wrapper-call logging) without either package depending on the other.
package applog
import (
"context"
"io"
"log/slog"
"strings"
)
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
// defaults to info).
func NewLogger(level string, w io.Writer) *slog.Logger {
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)}))
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
type ctxKey struct{}
// WithLogger returns a context carrying logger, retrievable via FromContext.
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, logger)
}
// FromContext returns the logger stashed by WithLogger, or slog.Default()
// if none was -- callers (e.g. the background incremental-sync loop, or
// tests that don't bother injecting one) always get a working logger,
// never nil.
func FromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && go test ./internal/applog/... -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/applog
git commit -m "feat(applog): add JSON logger and context helpers"
```
---
### Task 2: HTTP access log middleware
**Files:**
- Modify: `../../../backend/internal/config/envconfig.go` (new `LogLevel` field)
- Modify: `../../../backend/internal/config/envconfig_test.go` (new tests)
- Modify: `backend/cmd/geniusrund/main.go` (wire up `slog.SetDefault`)
- Modify: `backend/internal/api/server.go` (`loggingMiddleware`, registered in `Router()`)
- Modify: `backend/internal/api/api_test.go` (new tests)
**Interfaces:**
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1).
- Produces: `loggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
- [ ] **Step 1: Write the failing config tests**
Append to `../../../backend/internal/config/envconfig_test.go`:
```go
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
}
}
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
}
}
```
- [ ] **Step 2: Write the failing API tests**
Add `"log/slog"` to `backend/internal/api/api_test.go`'s stdlib import group, and `"geniusrun/backend/internal/applog"` to its internal import group.
Append to `backend/internal/api/api_test.go`:
```go
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
s, _, _ := newTestServer(t)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req = req.WithContext(applog.WithLogger(req.Context(), logger))
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["msg"] != "http request" {
t.Errorf("msg = %v, want \"http request\"", entry["msg"])
}
if entry["method"] != "GET" || entry["path"] != "/api/health" {
t.Errorf("method/path = %v/%v, want GET//api/health", entry["method"], entry["path"])
}
if entry["status"] != float64(http.StatusOK) {
t.Errorf("status = %v, want 200", entry["status"])
}
if _, ok := entry["duration_ms"]; !ok {
t.Error("expected a duration_ms field")
}
}
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
s, db, _ := newTestServer(t)
db.Close() // force a downstream DB call to fail with a 500
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
req = req.WithContext(applog.WithLogger(req.Context(), logger))
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
}
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
Expected: FAIL — `LogLevel` field doesn't exist on `Config`; `applog` import unresolved; no log output produced (middleware doesn't exist).
- [ ] **Step 4: Add `LogLevel` to `envconfig.go`**
Change:
```go
MinConfidence float64
IncrementalSyncEvery time.Duration
```
to:
```go
MinConfidence float64
IncrementalSyncEvery time.Duration
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
LogLevel string
```
Change:
```go
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
```
to:
```go
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
```
- [ ] **Step 5: Add the middleware to `server.go`**
Change the import block:
```go
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Insert this above `// Router builds the HTTP routes.`:
```go
var requestIDCounter atomic.Int64
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
// path, status, duration) and attaches a per-request logger (tagged with a
// request_id) to the request context, so any downstream call this request
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
// correlating id (see internal/applog, internal/garmin's roundTrip).
func requestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
logger := applog.FromContext(r.Context()).With("request_id", id)
r = r.WithContext(applog.WithLogger(r.Context(), logger))
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
logger.LogAttrs(r.Context(), level, "http request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
)
})
}
```
Change `Router()`'s opening lines:
```go
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(corsMiddleware)
```
to:
```go
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(requestLoggingMiddleware)
r.Use(corsMiddleware)
```
- [ ] **Step 6: Wire `slog.SetDefault` into `main.go`**
Change the import block:
```go
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
to:
```go
import (
"context"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Change:
```go
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
db, err := store.Open(cfg.DBPath)
```
to:
```go
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
db, err := store.Open(cfg.DBPath)
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
Expected: PASS.
- [ ] **Step 8: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 9: Commit**
```bash
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
git commit -m "feat(api): add structured JSON access log with request-id correlation"
```
---
### Task 3: Garmin wrapper call log
**Files:**
- Modify: `backend/internal/garmin/client.go` (`execute`, `ensureStarted`, all six call sites)
- Modify: `backend/internal/garmin/client_test.go` (update 3 direct-`execute` calls, add 2 new tests)
**Interfaces:**
- Consumes: `applog.FromContext` (Task 1).
- Produces: `func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error)` (signature change — `ctx` added as first param), `func (c *subprocessClient) ensureStarted(ctx context.Context) error` (signature change — `ctx` added).
- [ ] **Step 1: Update the 3 existing direct-`execute` tests and write the 2 new failing tests**
In `backend/internal/garmin/client_test.go`, add `"bytes"` and `"log/slog"` to the stdlib import block, and add `"geniusrun/backend/internal/applog"` as a new import group.
Change (in `TestSubprocessClient_RoundTrip_DetectsIDMismatch`):
```go
_, err := c.roundTrip("authenticate", nil)
```
to:
```go
_, err := c.roundTrip(context.Background(), "authenticate", nil)
```
Change (in `TestSubprocessClient_RoundTrip_SubprocessClosedIsError`):
```go
_, err := c.roundTrip("authenticate", nil)
```
to:
```go
_, err := c.roundTrip(context.Background(), "authenticate", nil)
```
Change (in `TestSubprocessClient_RoundTrip_WrapperErrorPropagates`):
```go
_, err := c.roundTrip("authenticate", nil)
```
to:
```go
_, err := c.roundTrip(context.Background(), "authenticate", nil)
```
Append two new tests:
```go
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
})
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
ctx := applog.WithLogger(context.Background(), logger)
if _, err := c.Authenticate(ctx); err != nil {
t.Fatalf("Authenticate: %v", err)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["msg"] != "garmin wrapper call" {
t.Errorf("msg = %v, want \"garmin wrapper call\"", entry["msg"])
}
if entry["cmd"] != "authenticate" {
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
}
preview, _ := entry["result_preview"].(string)
if !strings.Contains(preview, "mfa_required") {
t.Errorf("result_preview = %q, want it to contain mfa_required", preview)
}
if _, hasError := entry["error"]; hasError {
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
}
}
func TestSubprocessClient_RoundTrip_LogsErrorAtWarnLevel(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
ctx := applog.WithLogger(context.Background(), logger)
if _, err := c.Authenticate(ctx); err == nil {
t.Fatal("expected Authenticate to return an error")
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a failed call", entry["level"])
}
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/garmin/... -run 'TestSubprocessClient_RoundTrip' -v`
Expected: FAIL — `execute` still takes 2 args, not 3; `applog` import unresolved.
- [ ] **Step 3: Update `client.go`**
Change the import block:
```go
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"sync"
)
```
to:
```go
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strconv"
"sync"
"time"
"geniusrun/backend/internal/applog"
)
```
Change `ensureStarted`'s signature and add the spawn log. From:
```go
// ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu.
func (c *subprocessClient) ensureStarted() error {
if c.started {
return nil
}
```
to:
```go
// ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu.
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
if c.started {
return nil
}
```
And change its ending, from:
```go
c.cmd = cmd
c.stdin = stdin
c.enc = json.NewEncoder(stdin)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c.scanner = scanner
c.started = true
c.nextID = 0
return nil
}
```
to:
```go
c.cmd = cmd
c.stdin = stdin
c.enc = json.NewEncoder(stdin)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c.scanner = scanner
c.started = true
c.nextID = 0
applog.FromContext(ctx).Info("garmin wrapper spawning",
"python_path", pythonPath,
"token_store_configured", c.cfg.TokenStorePath != "",
)
return nil
}
```
Replace `execute` entirely. From:
```go
// roundTrip sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted.
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) {
c.nextID++
id := c.nextID
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
return nil, fmt.Errorf("write %s request: %w", cmdName, err)
}
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s response: %w", cmdName, err)
}
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName)
}
var resp wireResponse
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("parse %s response: %w", cmdName, err)
}
if resp.ID != id {
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
}
if resp.Error != "" {
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return resp.Result, nil
}
```
to:
```go
// roundTrip sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted. Logs exactly one "garmin wrapper call" line
// regardless of outcome (see internal/applog) -- cmd/params are always
// safe to log in full here: Garmin credentials only ever reach the
// subprocess via env vars at spawn time (see ensureStarted), never through
// these wire params.
func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error) {
start := time.Now()
defer func() {
attrs := []slog.Attr{
slog.String("cmd", cmdName),
slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
level := slog.LevelInfo
if result != nil {
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
}
if err != nil {
level = slog.LevelWarn
attrs = append(attrs, slog.String("error", err.Error()))
}
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
}()
c.nextID++
id := c.nextID
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
err = fmt.Errorf("write %s request: %w", cmdName, err)
return nil, err
}
if !c.scanner.Scan() {
if serr := c.scanner.Err(); serr != nil {
err = fmt.Errorf("read %s response: %w", cmdName, serr)
} else {
err = fmt.Errorf("read %s response: subprocess closed its output", cmdName)
}
return nil, err
}
var resp wireResponse
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
err = fmt.Errorf("parse %s response: %w", cmdName, uerr)
return nil, err
}
if resp.ID != id {
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
return nil, err
}
if resp.Error != "" {
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
return nil, err
}
result = resp.Result
return result, nil
}
```
Update the six call sites. In `Authenticate`:
```go
if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip("authenticate", nil)
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip(ctx, "authenticate", nil)
```
In `CompleteMFA`:
```go
if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip(ctx, "complete_mfa", map[string]any{"code": code})
```
In `GetActivities`:
```go
if err := c.ensureStarted(); err != nil {
return nil, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return nil, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate},
})
```
In `GetActivitySplits`:
```go
if err := c.ensureStarted(); err != nil {
return ActivitySplits{}, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
In `GetActivityDetails`:
```go
if err := c.ensureStarted(); err != nil {
return ActivityDetails{}, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
In `GetWorkoutByID`:
```go
if err := c.ensureStarted(); err != nil {
return Workout{}, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return Workout{}, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
})
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && go test ./internal/garmin/... -v`
Expected: PASS (all existing tests still pass with the new `ctx` param threaded through; the 2 new logging tests pass).
- [ ] **Step 5: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "feat(garmin): log every wrapper subprocess call as structured JSON"
```
---
### Task 4: Final verification
**Files:** none (verification only)
- [ ] **Step 1: Full backend check**
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 2: Manual smoke check**
Run the real server briefly (`cd backend && ./start.sh`, or `go run ./cmd/geniusrund` with the required env vars set) and confirm stdout shows JSON lines for at least a health-check request (`curl localhost:8080/api/health`) — one `"msg":"http request"` line with a `request_id`. This doesn't require a real Garmin account; it only confirms the JSON handler is actually wired to stdout in the real binary (as opposed to only passing in tests).
- [ ] **Step 3: Commit (if anything drifted)**
```bash
git add -A
git commit -m "fix: address final verification findings"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,844 @@
# Stop Retrying a Permanently-Missing Garmin Workout Implementation 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:** Stop `fillPendingWorkouts` from retrying a `get_workout_by_id` call forever when Garmin
returns a definitive 404 (the workout was deleted after being linked to an activity), per
`docs/superpowers/specs/2026-07-27-workout-not-found-design.md`.
**Architecture:** `garminconnect`'s own `GarminConnectNotFoundError` (raised for any HTTP 404) is
caught specifically in `wrapper.py`'s dispatch loop and marked with `"not_found": true` in the
JSON error response. `internal/garmin/client.go` turns that into a Go sentinel error
(`ErrNotFound`) any caller can `errors.Is` against. `internal/sync/service.go`'s
`fillPendingWorkouts` checks for it and, only for that specific case, calls a new store method
(`SetActivityWorkoutNotFound`) that permanently excludes the activity from
`ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` -- `workout_raw_json` stays `NULL`
forever (no fabricated data), but a new `workout_not_found_at` column distinguishes "confirmed
missing" from "not yet fetched."
**Tech Stack:** Go (backend, `internal/garmin`/`internal/store`/`internal/sync`), Python
(`internal/garmin/pyscript/wrapper.py`). No frontend changes.
## Global Constraints
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, `go test ./...` must all
pass before any commit.
- Every store method takes an explicit `userID` and uses it in a real `WHERE`/`JOIN` clause (per
this repo's per-user isolation convention) -- `SetActivityWorkoutNotFound` follows the exact
pattern of the existing `SetActivitySplitsFetched`.
- No migration history: `internal/store/schema.sql` is edited directly, then
`go run ./cmd/dumpschema` regenerates `docs/DATABASE.md`.
- `fillActivityDetails`/`fillPendingActivityDetails`'s error handling (abort-the-batch-and-report)
is explicitly out of scope -- this fix only changes `fillPendingWorkouts`'s behavior.
- No frontend changes -- `workouts_pending` simply stops counting a confirmed-missing workout.
---
### Task 1: `wrapper.py` marks a 404 with `not_found: true`
**Files:**
- Modify: `../../../backend/internal/garmin/wrapper/wrapper.py` (imports, `dispatch`)
- Modify: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py` (new test)
**Interfaces:**
- Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency).
- Produces: `dispatch()`'s returned error dict gains an additional `"not_found": true` key
whenever the caught exception is (or is a subclass of) `GarminConnectNotFoundError` -- Task 2
reads this field on the Go side.
- [ ] **Step 1: Write the failing test**
Add to `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
```python
def test_call_marks_not_found_error_specifically():
from garminconnect import GarminConnectNotFoundError
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
resp = wrapper.dispatch({
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp == {"id": 11, "error": "API Error 404", "not_found": True}
def test_call_does_not_mark_other_errors_as_not_found():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
resp = wrapper.dispatch({
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp == {"id": 12, "error": "rate limited"}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -k not_found -v`
(use whatever Python interpreter this repo's wrapper tests normally run under -- see
`../../../backend/internal/garmin/wrapper/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
Expected: `test_call_marks_not_found_error_specifically` FAILS (`resp` has no `"not_found"` key
yet); `test_call_does_not_mark_other_errors_as_not_found` already passes (nothing to change for
that case).
- [ ] **Step 3: Update `dispatch()`**
In `../../../backend/internal/garmin/wrapper/wrapper.py`, replace:
```python
from garminconnect import Garmin
```
with:
```python
from garminconnect import Garmin, GarminConnectNotFoundError
```
Replace:
```python
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
return {"id": req.get("id"), "error": str(exc)}
```
with:
```python
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
resp = {"id": req.get("id"), "error": str(exc)}
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
# side after being linked to an activity) is definitive, not a
# transient failure worth retrying forever -- marked specifically so
# internal/garmin/client.go can tell the two apart (see
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
if isinstance(exc, GarminConnectNotFoundError):
resp["not_found"] = True
return resp
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -v`
Expected: all tests PASS, including both new ones and every existing test unchanged.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/garmin/wrapper/wrapper.py backend/internal/garmin/wrapper/tests/test_wrapper.py
git commit -m "$(cat <<'EOF'
feat(garmin): mark a wrapper 404 with not_found in the error response
garminconnect's own GarminConnectNotFoundError already exists
specifically for this (its docstring: "so callers can now catch a
missing resource specifically, e.g. deleting an already-deleted
workout"), raised by connectapi() for any real HTTP 404. dispatch()
now surfaces that distinction as an extra not_found: true field
alongside the existing error string, so internal/garmin/client.go
(next commit) can tell a definitive 404 apart from a transient
failure.
EOF
)"
```
---
### Task 2: `internal/garmin/client.go` exposes `ErrNotFound`
**Files:**
- Modify: `backend/internal/garmin/client.go` (`wireResponse`, new `ErrNotFound`, `execute`)
- Modify: `backend/internal/garmin/client_test.go` (`wireResponsePayload`/harness, new test)
**Interfaces:**
- Consumes: `wireResponse.NotFound` (wire field written by Task 1's `wrapper.py` change).
- Produces: `var ErrNotFound error` -- Task 4 (`fillPendingWorkouts`) checks
`errors.Is(err, garmin.ErrNotFound)` against errors returned by `GetWorkoutByID` (and, since
this is wired at the generic `execute` level, any other `Client` method too).
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/garmin/client_test.go`, right after `fakeError`:
```go
func fakeNotFoundError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg, notFound: true}
}
```
Update the `wireResponsePayload` struct (near the top of the file) from:
```go
type wireResponsePayload struct {
result json.RawMessage
err string
}
```
to:
```go
type wireResponsePayload struct {
result json.RawMessage
err string
notFound bool
}
```
Update `newFakeWrapperClient`'s harness -- replace:
```go
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err}
```
with:
```go
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
```
Add a new test, right after `TestSubprocessClient_RoundTrip_WrapperErrorPropagates`:
```go
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeNotFoundError("API Error 404")
})
_, err := c.roundTrip(context.Background(), "call", nil)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
}
if !strings.Contains(err.Error(), "API Error 404") {
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
}
}
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.roundTrip(context.Background(), "call", nil)
if errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
}
}
```
Add `"errors"` to this file's import block (alongside the existing `"strings"` etc.).
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient_RoundTrip_NotFound -v`
Expected: FAIL to compile (`wireResponsePayload` has no field `notFound` yet -- wait, Step 1 above
already added it to the test file; the actual compile failure is `wireResponse` has no field
`NotFound` yet, and `ErrNotFound` is undefined) -- confirms the test exercises code that doesn't
exist yet.
- [ ] **Step 3: Add `ErrNotFound` and wire it through `execute`**
In `backend/internal/garmin/client.go`, replace:
```go
// wireResponse is one line read from the wrapper subprocess's stdout.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
```
with:
```go
// wireResponse is one line read from the wrapper subprocess's stdout.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
NotFound bool `json:"not_found,omitempty"`
}
// ErrNotFound wraps any error a Client method returns when the wrapper
// reported a definitive HTTP 404 (garminconnect's own
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
// on Garmin's side after being linked to an activity. Callers use
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
// worth retrying.
var ErrNotFound = errors.New("garmin: resource not found")
```
Replace, in `execute`:
```go
if resp.Error != "" {
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
return nil, err
}
```
with:
```go
if resp.Error != "" {
if resp.NotFound {
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)
} else {
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return nil, err
}
```
Add `"errors"` to `client.go`'s import block.
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd backend && go test ./internal/garmin/... -v`
Expected: all tests PASS, including the two new ones and every existing test unchanged.
- [ ] **Step 5: Run the full backend suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "$(cat <<'EOF'
feat(garmin): expose ErrNotFound for a wrapper-reported 404
roundTrip now wraps the returned error with the new ErrNotFound
sentinel whenever the wrapper's response set not_found (Task 1),
letting any Client method's caller distinguish a definitive 404 from
a transient failure via errors.Is, regardless of which garminconnect
method was called.
EOF
)"
```
---
### Task 3: Store gains `workout_not_found_at`
**Files:**
- Modify: `backend/internal/store/schema.sql` (`activities` table)
- Modify: `backend/internal/store/activities.go` (`Activity` struct, `activityColumns`,
`scanActivity`, new `SetActivityWorkoutNotFound`, `ActivitiesMissingWorkout`/
`CountActivitiesMissingWorkout`)
- Modify: `backend/internal/store/store_test.go` (extend the existing test, add a new one)
- Regenerate: `docs/DATABASE.md` (via `go run ./cmd/dumpschema`)
**Interfaces:**
- Consumes: nothing new.
- Produces: `db.SetActivityWorkoutNotFound(ctx, userID, activityID int64) error` -- Task 4
(`fillPendingWorkouts`) calls this. `Activity.WorkoutNotFoundAt *string` -- available to any
caller of `GetActivity`/`ListActivities`/etc. that wants to check it (none currently do, besides
this task's own test).
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/store/store_test.go`, a new test (don't modify the existing
`TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched` -- this is
additive):
```go
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
// Details already fetched, workout confirmed 404 on Garmin -- must not
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
notFound := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, notFound)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails (a): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
}
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
}
// A genuinely still-pending activity (details fetched, workout not yet
// attempted) must still be included, for contrast.
stillPending := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, stillPending)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 || pending[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
confirmed, _, err := db.GetActivity(ctx, userID, idA)
if err != nil {
t.Fatalf("GetActivity (a): %v", err)
}
if confirmed.WorkoutNotFoundAt == nil {
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
}
if confirmed.WorkoutRawJSON != nil {
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend && go test ./internal/store/... -run TestActivitiesMissingWorkout_ExcludesConfirmedNotFound -v`
Expected: FAIL to compile (`db.SetActivityWorkoutNotFound` and `Activity.WorkoutNotFoundAt`
undefined).
- [ ] **Step 3: Add the schema column**
In `backend/internal/store/schema.sql`, replace:
```sql
-- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
```
with:
```sql
-- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT,
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
-- workout was deleted on Garmin's side after being linked to this
-- activity) -- distinct from workout_raw_json staying null for "not yet
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
-- it stops being retried forever (see
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
-- workout_raw_json itself is never fabricated; it just stays null.
workout_not_found_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
```
- [ ] **Step 4: Update the Go struct, column list, and scan**
In `backend/internal/store/activities.go`, replace:
```go
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
// activity's structured workout -- the source used to compute each lap's
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
CreatedAt string
UpdatedAt string
}
```
with:
```go
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
// activity's structured workout -- the source used to compute each lap's
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
// HTTP 404 for this activity's WorkoutID -- distinct from
// WorkoutRawJSON staying nil for "not yet fetched" (see
// SetActivityWorkoutNotFound).
WorkoutNotFoundAt *string
CreatedAt string
UpdatedAt string
}
```
Replace:
```go
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at
`
```
with:
```go
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
workout_not_found_at, created_at, updated_at
`
```
- [ ] **Step 5: Add `SetActivityWorkoutNotFound` and update the two query methods**
In `backend/internal/store/activities.go`, right after `SetActivityWorkout`, add:
```go
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
// deleted on Garmin's side after being linked to this activity.
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND user_id = ?`, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
}
return nil
}
```
Replace:
```go
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
```
with:
```go
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
```
Replace:
```go
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL`, userID).Scan(&n)
```
with:
```go
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `cd backend && go test ./internal/store/... -run TestActivitiesMissingWorkout -v`
Expected: PASS, including both the existing test and the new one.
- [ ] **Step 7: Regenerate the schema doc**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` updates to include `workout_not_found_at` in the `activities` table.
- [ ] **Step 8: Run the full backend suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 9: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/activities.go backend/internal/store/store_test.go docs/DATABASE.md
git commit -m "$(cat <<'EOF'
feat(store): add workout_not_found_at, exclude it from missing-workout queries
A confirmed-404 workout must stop being retried forever, but
workout_raw_json should never be fabricated -- it stays null exactly
as it does for "not yet fetched." workout_not_found_at is the
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
now check for, mirroring the existing pattern of the other _fetched_at
columns. UpsertActivity's ON CONFLICT clause already never touches
these columns, so the marker persists across every later re-sync.
EOF
)"
```
---
### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
**Files:**
- Modify: `../../../backend/internal/garmin/sync.go` (`fillPendingWorkouts`)
- Modify: `../../../backend/internal/garmin/sync_test.go` (new test)
**Interfaces:**
- Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3).
- Produces: nothing new -- this is the final integration point for this fix.
- [ ] **Step 1: Write the failing test**
Add to `../../../backend/internal/garmin/sync_test.go`, right after
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
```go
func TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const missingWorkoutID = 300
missingPtr := int64(missingWorkoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]garmin.Workout{},
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", garmin.ErrNotFound)},
}
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails should not return an error for a confirmed-404 workout: %v", err)
}
remaining, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if remaining != 0 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 0 (confirmed-404 activity must not be retried)", remaining)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 1 {
t.Fatalf("ListActivities returned %d activities, want 1", len(activities))
}
got, _, err := db.GetActivity(ctx, userID, activities[0].ID)
if err != nil {
t.Fatalf("GetActivity: %v", err)
}
if got.WorkoutNotFoundAt == nil {
t.Error("WorkoutNotFoundAt is nil, want it set")
}
if got.WorkoutRawJSON != nil {
t.Error("WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
// A second FillPendingDetails call must not attempt this workout again
// (it's no longer in ActivitiesMissingWorkout's result set at all).
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("second FillPendingDetails: %v", err)
}
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend && go test ./internal/sync/... -run TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried -v`
Expected: FAIL -- `CountActivitiesMissingWorkout` still returns 1 (today's code retries forever
regardless of the error's nature).
- [ ] **Step 3: Update `fillPendingWorkouts`**
In `../../../backend/internal/garmin/sync.go`, replace:
```go
for i, a := range pending {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
log.Printf("sync: fill workout for activity %d failed, will retry next sync: %v", a.GarminActivityID, err)
}
s.setProgress(PhaseWorkouts, i+1, len(pending))
}
return nil
}
```
with:
```go
for i, a := range pending {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
if errors.Is(err, garmin.ErrNotFound) {
// A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops
// surfacing it, instead of retrying forever.
log.Printf("sync: workout for activity %d not found on Garmin, marking as such (will not retry): %v", a.GarminActivityID, err)
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
}
} else {
log.Printf("sync: fill workout for activity %d failed, will retry next sync: %v", a.GarminActivityID, err)
}
}
s.setProgress(PhaseWorkouts, i+1, len(pending))
}
return nil
}
```
Add `"errors"` to `sync.go`'s import block.
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd backend && go test ./internal/sync/... -run TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried -v`
Expected: PASS.
- [ ] **Step 5: Run the full `internal/sync` test suite**
Run: `cd backend && go test ./internal/sync/... -v 2>&1 | tail -60`
Expected: every test passes, including
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass` unchanged (its failure
is a plain `fmt.Errorf("garmin says no")`, not `ErrNotFound`-wrapped, so it still hits the
retry-next-sync branch exactly as before).
- [ ] **Step 6: Run the full backend suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
git commit -m "$(cat <<'EOF'
fix(sync): stop retrying a workout Garmin confirms is gone
fillPendingWorkouts previously treated every get_workout_by_id
failure identically -- log it, leave workout_raw_json null, retry
next sync -- which is correct for a transient failure but means a
definitive 404 (the workout deleted on Garmin's side after being
linked to an activity) got silently retried forever, with the only
symptom being an unexplained, permanent "1 more workout pending"
nudge. Now checks errors.Is(err, garmin.ErrNotFound) and, only for
that case, calls SetActivityWorkoutNotFound instead of retrying.
EOF
)"
```
---
## Final verification
- [ ] Run the full backend suite one more time: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
- [ ] Run the Python wrapper tests one more time: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/ -v`
- [ ] Confirm `docs/DATABASE.md` reflects the new `workout_not_found_at` column.
- [ ] Use superpowers:finishing-a-development-branch to wrap up (tests green -> present the
merge/PR/keep-as-is menu).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
# Drop MCP for Garmin integration — direct Python wrapper design
Date: 2026-07-25
## Overview
`internal/garmin` currently talks to `mcp-garmin` (a separate repo) as an MCP client over stdio, spawning `server.py` as a subprocess and driving it through JSON-RPC tool calls (`authenticate`, `complete_mfa`, `get_activities`, `get_activity_splits`, `get_activity_details`, `get_workout_by_id`). MCP's actual value — letting an LLM dynamically discover and choose among tools — is unused here: geniusrun's Go backend calls a fixed, hardcoded set of operations in fixed code paths. The MCP protocol layer (session init, capability negotiation, JSON-RPC envelope, the `mcp-go` client dependency in Go, the `mcp` SDK dependency in Python) buys nothing for this use case.
Both `mcp-garmin` and geniusrun are projects the same person owns, so there's no third-party maintenance benefit to keeping `mcp-garmin` as a separate abstraction either. Given a plan to package the backend in a container, fewer runtime dependencies is a concrete win: dropping the MCP layer means one fewer Python package (`mcp[cli]`) and one fewer Go dependency (`github.com/mark3labs/mcp-go`), and folding the wrapper script into the geniusrun repo itself means the container image doesn't need to vendor/clone a second repo.
This replaces the MCP transport with a small custom Python subprocess wrapper around `garminconnect` directly, speaking a minimal newline-delimited JSON protocol over stdin/stdout. `mcp-garmin` is retired once this ships.
## Goals
- Remove the MCP protocol layer and its two SDK dependencies (`mcp-go` in Go, `mcp[cli]` in Python) entirely.
- Fold the Python wrapper into the geniusrun repo (`backend/garmin-wrapper/wrapper.py`) so the container image only needs a Python runtime + `pip install garminconnect` + this one file — no separate repo to fetch at build time.
- Preserve `internal/garmin.Client`'s existing interface and behavior exactly (lazy subprocess spawn, per-user token store, `UpdateCredentials` tear-down/respawn, stderr draining) so `internal/sync` and `internal/api` require zero changes.
- Replace today's fragile string pattern-matching of auth replies (`parseAuthResult`, documented in CLAUDE.md as a "learned the hard way" wart) with structured JSON status, now that both sides of the protocol are under our control.
- Make every method `python-garminconnect`'s `Garmin` class exposes reachable from Go without per-method Python boilerplate, anticipating future AI-driven features (recovery/HRV context for training-plan recommendations) that may need data beyond what's synced today.
## Non-goals
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `clientFor` caching in `api.Server`.
## Wire protocol
Newline-delimited JSON, one object per line each direction, over the subprocess's stdin/stdout:
```
Go -> Python: {"id": 1, "cmd": "authenticate"}
Python -> Go: {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}}
Go -> Python: {"id": 2, "cmd": "complete_mfa", "params": {"code": "123456"}}
Python -> Go: {"id": 2, "result": {"status": "mfa_required", "message": "..."}}
Go -> Python: {"id": 3, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {"start_date": "2026-07-01", "end_date": "2026-07-25"}}}
Python -> Go: {"id": 3, "result": [...]}
Go -> Python: {"id": 4, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "123"}}}
Python -> Go: {"id": 4, "error": "Error fetching activity splits: <exception message>"}
```
Three command kinds:
- **`authenticate`** / **`complete_mfa`** — special-cased in Python: same background-thread login + MFA-prompt race + 10-second timeout detection logic `mcp-garmin`'s `server.py` already has (this is genuine custom control flow, not a passthrough to a single `garminconnect` method), but now return a **structured** `{"status": "success" | "mfa_required" | "failed", "message": "..."}` object instead of a string Go has to pattern-match.
- **`call`** — a fully generic dispatcher: `getattr(garmin_client, params["method"])(**params["args"])`, JSON-serializing whatever it returns. This is what makes every `garminconnect` method reachable without new Python code per method — including future needs (sleep, HRV, stress, body battery) and, by the same generic mechanism, mutating/destructive methods (`upload_activity`, `delete_activity`, etc.). No allowlist is enforced in Python; correctness of what Go chooses to call is Go's responsibility, same as calling any other library method directly would be.
`id` lets Go assert a response matches its in-flight request — calls are already serialized by the existing `sync.Mutex` in `mcpClient`/its replacement, so this is a desync-detection assertion, not a concurrency mechanism.
Go's line reader uses a raised buffer size (well above the default 64KB `bufio.Scanner` limit) since `get_activity_details` responses (per-second telemetry for a full run) can be several MB.
## Components
### Python wrapper — `backend/garmin-wrapper/wrapper.py`
- Single file. Same shape as today's `server.py` minus all MCP scaffolding (`FastMCP`, `@mcp.tool()` decorators, `mcp.run()`).
- Embedded into the Go binary at compile time via `//go:embed wrapper.py` (a `[]byte`/`string` constant in `internal/garmin`), rather than referenced by a configurable filesystem path. At process start, `ensureStarted` writes the embedded source to a temp file (`os.CreateTemp`) once per process lifetime and execs the configured Python interpreter against that path. This removes any notion of a "server script location" from configuration entirely — whatever `wrapper.py` shipped inside the running binary is exactly what gets executed, so there's no drift between binary version and script version, and the container image needs no separate `COPY` step for it.
- Reads `GARMIN_EMAIL` / `GARMIN_PASSWORD` / `GARMIN_TOKENSTORE` from the environment at process start — unchanged from today.
- Holds one module-level `Garmin` client instance and an auth-state variable (`unauthenticated` / `mfa_pending` / `authenticated`).
- Main loop: `for line in sys.stdin`, parse `{"id", "cmd", "params"}`, dispatch to the `authenticate` / `complete_mfa` / `call` handler, write exactly one JSON line to stdout per response, flush immediately. Debug/diagnostic output continues to go to stderr only (stdout is reserved for the response protocol) — Go already drains stderr today and keeps doing so unchanged.
- Any exception raised by a `call` dispatch (bad method name via `AttributeError`, `GarminConnectAuthenticationError`, network errors, anything else) is caught at the top-level dispatch loop and turned into `{"id": N, "error": "<message>"}` — it must never let a raw Python traceback hit stdout, since that would corrupt the line-based framing.
- Dependency: `garminconnect` only. Drop `mcp[cli]`.
### Go client — `internal/garmin/client.go`
- Same `mu sync.Mutex` + lazy `ensureStarted`/`started` pattern as today. The mcp-go stdio transport is replaced with `os/exec.Cmd` plus a `bufio.Scanner` (raised buffer) over `Stdout` and a `json.Encoder` over `Stdin`.
- `UpdateCredentials` still tears down (kill subprocess) and lets the next call respawn fresh, unchanged.
- `Close` still terminates the subprocess.
- Each `Client` interface method builds the appropriate request (`authenticate`/`complete_mfa` directly, or a `call` envelope naming the right `garminconnect` method for `GetActivities``get_activities_by_date`, `GetActivitySplits``get_activity_splits`, `GetActivityDetails``get_activity_details`, `GetWorkoutByID``get_workout_by_id`), writes+flushes it, reads one response line, and unmarshals `result` into the existing typed structs (`Activity`, `ActivitySplits`, `ActivityDetails`, `Workout` — unchanged; the wrapper still forwards `garminconnect`'s own JSON-compatible dicts/lists as-is).
- `AuthResult`/`AuthStatus` types are unchanged, but `parseAuthResult`'s string-matching (`client.go:189-198`) is deleted entirely — the wrapper's `status` field maps directly to `AuthSuccess` / `AuthMFARequired` / `AuthFailed`.
- The interface itself gains no new methods yet (YAGNI) — the generic `call` command exists at the wire-protocol level so a future typed method (e.g. for sleep/HRV data, if the AI training-plan features end up needing it) is a small, self-contained addition when actually needed, not a protocol change.
## Error handling
- Python: every `call` dispatch exception is caught and returned as `{"error": "<message>"}`. `authenticate`/`complete_mfa` never raise past their own handlers (matches today).
- Go: a non-nil `error` field in a response becomes `fmt.Errorf("call %s: %s", method, resp.Error)` (or equivalent for `authenticate`/`complete_mfa`), the same shape `internal/sync` already receives and wraps further today (`fmt.Errorf("get_activities(...): %w", err)`, etc.) — confirmed no code downstream branches on specific error text (e.g. no "Authentication error" string-matching in `internal/sync`/`internal/api`), so no structured error-type field is needed beyond the plain message.
- Process-level failures (subprocess exited, pipe closed, malformed JSON line) surface as a Go error from the read/write call. `started` is not auto-reset on such failures — matching today's behavior where a crashed subprocess just errors on next use until `UpdateCredentials` forces a respawn.
## Process lifecycle & testing
- Lifecycle unchanged: lazy-start on first call, one persistent subprocess per user (kept warm across calls to preserve the Garmin session/tokenstore), torn down and respawned only by `UpdateCredentials`.
- `internal/garmin/mock.Client` is untouched — it fakes the `Client` interface, not the transport, so `internal/sync`/`internal/api` tests need no changes.
- New/updated tests in `internal/garmin`:
- Framing round-trip: a request encodes correctly, a response line decodes correctly, and a large (multi-MB) `get_activity_details`-shaped payload doesn't overflow the scanner buffer.
- Structured auth status mapping (replacing today's `parseAuthResult` string-matching tests).
- Error propagation: a `{"error": ...}` response becomes a Go error with the expected message.
- These can run against a stub script (not the real `wrapper.py`/`garminconnect`) that speaks the protocol, mirroring how `client_test.go` already avoids hitting real Garmin.
## Migration & container
- Full replacement, no compatibility shim, consistent with this project's "no migration history" stance elsewhere: `mcp-go` import and all MCP-specific code in `client.go` are deleted in the same change, not kept behind a flag.
- `backend/start.sh`'s `MCP_GARMIN_PYTHON` / `MCP_GARMIN_SERVER` env vars are retired, not just renamed. Only one config knob remains: `GARMIN_WRAPPER_PYTHON`, and it becomes **optional** (default `"python3"`, resolved via `PATH`) rather than required — matching how `config.Load()` already treats genuinely-optional plumbing (e.g. `GENIUSRUN_OIDC_REQUIRED_ROLE`). There is no longer a script-location env var at all, since `wrapper.py` is embedded in the binary (see Components above). Local dev can still point `GARMIN_WRAPPER_PYTHON` at a venv interpreter if desired; it's just no longer required to.
- Container image only needs: a Python runtime with `garminconnect` installed (`pip install garminconnect` in the image, no venv necessary since the container itself is the isolation boundary) — no second repo to clone/vendor, no `mcp` SDK on either side, and no separate wrapper script file to `COPY` in.
- The standalone `mcp-garmin` repo is retired (archived) once this ships; its test suite (`tests/test_server.py`) either gets ported to test `wrapper.py` directly inside geniusrun, or is dropped in favor of the new `internal/garmin` tests above, whichever ends up covering the same ground with less duplication — a call to make during implementation, not part of this spec.
## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
- Existing per-user token stores under `TokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).

View File

@@ -0,0 +1,56 @@
# Fix cross-origin redirect targets in the OIDC callback — design
Date: 2026-07-25
## Overview
`internal/api/session.go`'s `handleSessionCallback` redirects the browser back into the app using bare relative paths (`"/"` on success, `"/?auth_error=failed"`/`"/?auth_error=forbidden"` on failure). A relative redirect resolves against the origin of the request that received it — and since Keycloak redirects the browser straight to `OIDCRedirectURL` (the backend's own `/api/session/callback`), that origin is always the **backend's**, never the frontend's.
This is silently correct in a deployment where a reverse proxy unifies the frontend and backend under one origin (the assumed production topology, per `GENIUSRUN_PUBLIC_BASE_URL`'s doc comment: "this app's own externally reachable origin"). It is broken in any split-origin deployment — including this project's own supported local dev setup, where the frontend (Vite, port 5173) and backend (`geniusrund`, port 8080) are deliberately different origins, bridged by `corsMiddleware` and `VITE_API_BASE_URL` (see `internal/api/server.go`'s `corsMiddleware` comment and `frontend/src/api/client.ts`'s `BASE_URL`). In that setup, the Go backend has no `/` route at all — only `/api/*` — so every one of these five redirects 404s.
## Goals
- All 5 redirects in `handleSessionCallback` (4 error cases + 1 success case) resolve to the frontend's actual origin, in both the single-origin (production, reverse-proxy) and split-origin (local dev) topologies, with no manual per-environment code branching.
- No behavior change for an existing single-origin deployment: if the new config value is left unset, behavior is identical to today.
## Non-goals
- `handleSessionLogin`'s `writeError` responses (on `BeginLogin`/txn-cookie-mint failure) are explicitly out of scope — confirmed with the user. These render a raw JSON error directly on the backend's own origin today; they are not redirects, so they don't 404, just look untailored. Left as-is.
- No change to `handleSessionLogout`'s redirect target, which already uses `s.Session.PublicBaseURL+"/"` (an absolute URL) rather than a relative path — it doesn't have this bug. (Whether `PublicBaseURL` is even the *correct* absolute origin for that redirect in a split-origin setup is a separate, pre-existing question not raised by this task; not touched here.)
- No change to the OIDC redirect_uri / post_logout_redirect_uri themselves (`PublicBaseURL`-derived) — those correctly must stay pointed at the backend's own origin, since that's where those routes are actually served.
## Design
Add one new optional config field, `FrontendURL` (env var `GENIUSRUN_FRONTEND_URL`), read in `config.Load()` and defaulted to `PublicBaseURL` when unset. Thread it into `api.SessionConfig` alongside the existing `PublicBaseURL` field. In `handleSessionCallback`, replace every relative redirect target with `s.Session.FrontendURL + "/"` (success and the two path-only cases) or `s.Session.FrontendURL + "/?auth_error=..."` (the error cases) instead of the bare `"/"` / `"/?auth_error=..."` strings.
### Config
`internal/config/config.go`:
- New `Config.FrontendURL string` field, doc comment explaining the split-origin-dev rationale above.
- In `Load()`: `FrontendURL: getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.PublicBaseURL)` — note this must be set *after* `PublicBaseURL` is computed (trimmed of trailing slash) in the same struct literal, or as a follow-up assignment; since Go struct literals can't reference sibling fields being built in the same literal, this needs to be assigned as a statement after the literal, mirroring how `OIDCRedirectURL`/`SessionSecure` are already derived post-literal today.
- Trim any trailing slash from the explicit env var value the same way `PublicBaseURL` already is, so `FrontendURL + "/"` never produces a double slash.
### Wiring
`cmd/geniusrund/main.go`: pass `cfg.FrontendURL` into the new `api.SessionConfig.FrontendURL` field alongside the existing `PublicBaseURL` field.
`internal/api/session.go`'s `SessionConfig` struct: add `FrontendURL string` field with a doc comment cross-referencing `PublicBaseURL`'s and explaining why they can differ.
### Redirect targets
In `handleSessionCallback`, each of the five `http.Redirect` calls' second-to-last argument changes from a relative path to `s.Session.FrontendURL + "<same relative path as today>"`:
- `"/?auth_error=failed"` (×3, one per failure branch) → `s.Session.FrontendURL+"/?auth_error=failed"`
- `"/?auth_error=forbidden"``s.Session.FrontendURL+"/?auth_error=forbidden"`
- `"/"` (success) → `s.Session.FrontendURL+"/"`
No change to any other logic in the handler (the log lines, the role check, cookie minting — all untouched).
## Testing
- `internal/config`: new test(s) asserting `FrontendURL` defaults to `PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, and that an explicit value overrides the default (mirroring the existing `TestLoad_GarminTokenStoreRoot*`-style pair).
- `internal/api`: `testSessionConfig` (`api_test.go:26-31`) currently has no `FrontendURL` field set; since it's distinct from `PublicBaseURL` ("https://geniusrun.example.com"), it must be given its own distinct value (e.g. "https://app.geniusrun.example.com") so the three existing `Location`-asserting tests actually exercise the new field instead of accidentally passing against a zero-value empty-string prefix:
- `TestSessionCallback_AuthorizedSetsSessionCookieAndRedirectsHome` (`api_test.go:813-841`) — currently asserts `Location != "/"`; update to assert the `FrontendURL`-prefixed value.
- `TestSessionCallback_UnauthorizedRedirectsWithoutSessionCookie` (`api_test.go:852-873`) — currently asserts `Location != "/?auth_error=forbidden"`; update similarly.
- `TestSessionCallback_MissingTxnCookieRedirectsFailed` (`api_test.go:875-882`) — currently asserts `Location != "/?auth_error=failed"`; update similarly.
- No existing test appears to cover the other two `"/?auth_error=failed"` branches (txn-cookie-parse failure, `HandleCallback` error) — not introduced by this fix, so not required here, but worth noting as pre-existing gaps.
- No change needed to `internal/auth` (untouched by this fix).

View File

@@ -85,7 +85,7 @@ A new middleware step runs immediately after the existing `RequireSession` (role
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `TokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
@@ -125,5 +125,5 @@ Every existing handler in `internal/api` that reads/writes `profile`, `activitie
## Rollout notes
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`TokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.

View File

@@ -0,0 +1,163 @@
# Improve the first connection page
Status: approved, not yet implemented.
## Problem
`docs/IDEAS.md` backlog item: "improve first connection page: in
CreateProfile.tsx, in addition to the display name, we should directly ask
garmin login/password and manage MFA from there, so the synchronization
experience in profile page will be easier later on".
Today, `CreateProfile.tsx` only asks for a display name (`POST /api/setup`)
and drops the user straight into the main app -- Garmin credentials are
filled in later, whenever the user happens to visit the Profile page. This
means a brand-new account can sit around fully "set up" but never actually
synced, with no prompt nudging the user to connect Garmin.
## Design
### Scope decision: mandatory, persisted gate
Connecting Garmin becomes a **hard requirement** to enter the app, enforced
on every login, not just the first one -- confirmed explicitly, including
the corollary that this can't be a purely in-session check: account
provisioning (`POST /api/setup`) must happen before Garmin login can even be
attempted (the per-user Garmin client is keyed by an already-existing
account), so a user who abandons the flow mid-connect already has a
`users`/`profile` row. Enforcing the gate on *every subsequent login* (not
just immediately after signup) requires a persisted "has this account ever
successfully connected" flag -- the in-memory `Server.userAuthStatus` map
resets on every backend restart and can't be trusted for this.
Also confirmed: both `CreateProfile` and the new gate screen get a "Log out"
link (neither has one today), since a mandatory gate a user can get
genuinely stuck on (no Garmin credentials handy right now) needs an escape
hatch back to the login screen.
### Data model
`schema.sql`, `profile` table -- one new nullable column:
```sql
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
```
`store.Profile` gets a matching `GarminConnectedAt *string` field (same
nullable-TEXT-pointer pattern as `sync_state.EarliestSyncedDate`).
New store method (`internal/store/users.go` or a new small file, e.g.
`profiles.go` wherever `UpdateProfile`/`GetProfile` already live):
```go
// MarkGarminConnected records the first time userID successfully
// authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error
```
Implemented as `UPDATE profile SET garmin_connected_at = datetime('now')
WHERE user_id = ? AND garmin_connected_at IS NULL`.
After the schema edit, regenerate `docs/DATABASE.md` via `go run
./cmd/dumpschema` (per project convention).
### Backend API
- **`handleGarminAuthLogin`/`handleGarminAuthMFA`** (`internal/api/auth.go`): right after
the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
A failure here is logged and does *not* fail the HTTP response -- the
Garmin authentication itself already succeeded; failing to persist the
flag is a non-fatal side effect (the next successful login attempt would
just set it then).
- **`sessionMeResponse`** (`internal/api/session.go`): add `GarminConnected
bool \`json:"garmin_connected"\``.
- **`handleSessionMe`**: when the session already has a profile, fetch it
(`s.DB.GetProfile`) and set `resp.GarminConnected = profile.GarminConnectedAt
!= nil`.
- No other endpoint changes. The new frontend screen calls the exact same
`PUT /api/profile`, `POST /api/auth/login`, `POST /api/auth/mfa` the
existing Profile page already uses.
### Frontend flow
- **`types/api.ts`**: `SessionInfo` gains `garmin_connected: boolean`.
- **`LoginGate.tsx`**: becomes a three-way fork instead of two:
```
!authenticated -> login screen
authenticated && !has_profile -> CreateProfile
authenticated && has_profile && !garmin_connected -> ConnectGarmin
authenticated && has_profile && garmin_connected -> App
```
`CreateProfile.onCreated` and the new `ConnectGarmin.onConnected` both just
flip the relevant boolean in the local `session` state object (same
pattern `has_profile` already uses).
- **`CreateProfile.tsx`**: behavior unchanged (display name ->
`POST /api/setup`); it's simply no longer the last screen. Gains a
"Log out" link (`<form method="post" action="{BASE_URL}/api/session/logout">`,
same pattern as `App.tsx`'s header).
- **New `frontend/src/ConnectGarmin.tsx`**: a dedicated, self-contained
onboarding screen (not a reuse of `GarminConnection.tsx`, to avoid
coupling that shared Profile-page component to an onboarding-only special
case):
- Email + password fields.
- "Connect" button: `PUT /api/profile` (persist credentials into the
profile row created by setup) then `POST /api/auth/login`.
- If the result is `mfa_required`, show an MFA code field -> `POST
/api/auth/mfa`.
- On `authenticated`, show a brief confirmation and a "Continue to
geniusrun" button that calls `onConnected()` -- no sync is
auto-triggered here; the existing background incremental-sync loop and
the Profile page's "Sync now" handle actual data fetching, keeping this
screen scoped to connecting only.
- On `failed` (wrong password, Garmin unreachable, rate-limited), show the
returned message and leave the fields editable for a retry -- never a
dead end.
- Also gains a "Log out" link.
### Error handling
- Wrong credentials / Garmin down / rate-limited: surfaced via the same
`authResponse.message` field `GarminConnection.tsx` already displays;
fields stay editable, retry is always available.
- `MarkGarminConnected` DB failure: logged server-side, never surfaced to
the user and never fails the auth response (see Backend API above).
- Abandoning the flow (closing the tab) leaves the account provisioned but
not connected -- the *next* login re-enters at `ConnectGarmin` (that's the
whole point of persisting the flag), not at `CreateProfile` again.
### Testing
- `internal/store`: `MarkGarminConnected` sets the timestamp exactly once
(a second call is a no-op, preserving the first-connection time);
adversarial isolation test that marking user A connected never affects
user B's `GarminConnectedAt`.
- `internal/api`: `POST /api/auth/login` (via `mock.Client` returning
`AuthSuccess`) followed by `GET /api/session/me` reports `garmin_connected:
true`; an `mfa_required` or `failed` result does *not* set it; a freshly
provisioned user (via `newTestServer`, no Garmin attempt yet) reports
`garmin_connected: false`.
- No frontend test suite exists yet (per `CLAUDE.md`) -- manual smoke test:
fresh account -> display name -> forced into `ConnectGarmin` -> wrong
password shows an error and allows retry -> correct password (or MFA)
resolved -> lands in `<App>` -> log out -> log back in with the same
account -> confirm it goes straight to `<App>` (not back through
`ConnectGarmin`), proving `garmin_connected_at` actually persisted.
## Out of scope
- Auto-triggering a first sync from `ConnectGarmin` -- deferred to the
existing "Sync now" / background incremental-sync mechanisms.
- Any "reconnect required" re-gating if Garmin credentials later go stale
(e.g. password changed on Garmin's side) -- this feature only covers the
*first-ever* successful connection; subsequent reconnects still go through
the normal Profile page flow.
- Combining the display-name and Garmin-credential steps into a single
form/request -- kept as two sequential screens since Garmin login
requires an already-provisioned account (see Scope decision above).

View File

@@ -0,0 +1,149 @@
# Improve Synchronization UX — Design
**Source idea:** `docs/IDEAS.md` — "improve synchronization: make it modal, split downloads of
activities and workouts (as workouts are currently downloaded from their ID in related
activities), add progress bar (which requires to know in advance how many activities or
workouts will have to be downloaded)."
## Goal
Replace the Profile page's single inline "syncing: N/M activities" text line with a blocking
modal that shows real progress through sync, broken into named phases the user can actually
follow: discovering new activities, fetching activity details, then fetching workouts.
## Cleanup folded into this work: dead `Backfill`/`IncrementalSync` wrappers
`Service.Backfill(ctx)` and `Service.IncrementalSync(ctx)` are exported methods that each record
their own `SyncRun`, wrapping `backfillCore`/`incrementalSyncCore`. Their doc comments say
they're used "by the periodic background loop" -- but that loop was removed in an earlier
session (`4d2cbe4 refactor: remove automatic background incremental sync`), and grepping
`internal/api/` and `cmd/` confirms nothing in production calls them anymore. `FullSync` (the
only production caller of the core logic) calls `backfillCore`/`incrementalSyncCore` directly.
Only `internal/sync/service_test.go` still calls `Backfill`/`IncrementalSync`, which is the only
reason they're not already flagged as unused by the compiler.
Since this plan already restructures `sync.go`'s progress model, remove these two dead
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
that called them to exercise the same behavior through `FullSync` or the `*Core` functions
directly (same package, so unexported functions are still directly testable) -- before any of
the progress-model changes below, so later tasks aren't touching code that's about to be
deleted.
## Current state (for reference)
- `sync.Service.Progress()` returns a flat `{Done, Total}`, written only by
`FillPendingDetails``Backfill`/`IncrementalSync` (the "discover which activities exist"
phase) report no progress at all today.
- `FillPendingDetails` fetches, per activity, in one pass: `GetActivitySplits`,
`GetActivityDetails`, and (if the activity has a `workout_id`) `GetWorkoutByID` — then writes
samples, laps (with workout-derived target pace/HR bands baked in), activity details, and
marks splits fetched.
- `GET /api/sync/status` returns `{in_progress, detail_fill_progress: {Done, Total},
activities_pending_details, last_run?}`. The frontend (`GarminConnection.tsx`) polls this and
renders an inline `<p>` line, both while syncing and once idle (a static "last sync: ..."
summary).
- `sync_runs` stores one row per `Backfill`/`IncrementalSync`/`FullSync` call
(`kind`/`started_at`/`finished_at`/`activities_fetched`/`status`/`error_message`).
`GET /api/sync/runs` exists but nothing in the frontend calls it.
## Scope
**In scope:** "Sync now" (`FullSync`) gets the new modal and phase-aware progress. The static
idle "last sync" summary on the Profile page is removed — the modal becomes the only place
sync status/results are shown; closing it means nothing is shown again until the next sync.
**Out of scope:** "Reset all" keeps its current behavior unchanged (confirm dialog, fire, poll
via a blocking `while` loop, reload the page) — no modal, no progress UI. The "modern error
displays" idea (ephemeral banners, backend-down handling) from the same backlog section is a
separate, later feature — this one only makes sync's own errors visible via the modal's final
state, reusing the `sync_runs.error_message` field that's already fetched but never rendered
today.
## Backend design
### Phase-aware progress
```go
type Progress struct {
Phase string // "idle" | "discovering" | "activities" | "workouts"
Done int
Total int
}
```
`FullSync` drives the phase transitions:
1. **`discovering`** — set (Done=0, Total=0, indeterminate) while `Backfill`/`IncrementalSync`
run. These still report no count; the modal shows a spinner, not a bar, during this phase.
2. **`activities`** — `FillPendingDetails` is split into two sequential passes. The first pass
fetches `GetActivitySplits`+`GetActivityDetails`+samples for every activity missing details
(`ActivitiesMissingDetails`/`CountActivitiesMissingDetails`, unchanged queries), writing laps
*without* workout-target alignment yet. `Total` is the pending-details count taken once at
the start of this pass; `Done` advances per activity.
3. **`workouts`** — a new second pass. `workout_id` is decoded from Garmin's activity summary
and stored on the `activities` row at upsert time, well before any detail fetch — so
"activities needing a workout fetched" is an independently queryable condition
(`workout_id IS NOT NULL AND workout_raw_json IS NULL`), needing no live Garmin call to
count. Two new store methods, `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout`
(mirroring the naming of the existing `ActivitiesMissingDetails`/
`CountActivitiesMissingDetails`), list/count these. For each: fetch `GetWorkoutByID`,
re-derive lap target bands from the just-stored lap data (`alignWorkoutTargets`), update
the laps and set `workout_raw_json`. This condition also picks up any older activity that
has a `workout_id` but never got its workout aligned in some prior run (e.g. a past
transient failure) — not just ones touched in this run's `activities` pass — a small
latent-bug fix as a side effect.
4. **`idle`** — reset to `{Phase: "idle", Done: 0, Total: 0}` once `FullSync` returns, same
`defer`-based reset as today.
Restructuring `fillActivityDetails` to decouple lap-writing from workout-target alignment
means an activity with a workout gets its laps written twice (once plain in the `activities`
pass, once updated with target bands in the `workouts` pass) — this is a second local SQL
delete+insert, not a second Garmin API call, so it costs nothing against rate limits.
### API
`GET /api/sync/status` changes shape:
```json
{
"in_progress": true,
"progress": { "phase": "workouts", "done": 3, "total": 8 },
"activities_pending_details": 0,
"last_run": { "...": "SyncRun, unchanged fields" }
}
```
This replaces `detail_fill_progress`/`DetailFillProgress` outright (no other consumer exists).
`SyncRun.Kind`'s frontend type also gains its missing `"full"` value (backend has always been
able to report it; the frontend type was just never updated to match).
## Frontend design
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
its `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
JSX.
A new `SyncModal.tsx`:
- Renders as a blocking overlay (page behind it non-interactive) the moment `api.syncRun()`
resolves successfully.
- Polls `api.syncStatus()` every 1500ms while open (same cadence as today's polling).
- Renders by `progress.phase`:
- `discovering` → spinner + "Discovering activities…"
- `activities` → progress bar + "Activities: {done}/{total}"
- `workouts` → progress bar + "Workouts: {done}/{total}"
- Once `in_progress` becomes `false`: shows `last_run.Status`, `ActivitiesFetched`, and
`ErrorMessage` (if the run errored) plus the existing "N more pending — sync again" nudge if
`activities_pending_details > 0`, and a **Close** button. No auto-close, no timeout — the
user decides when to dismiss it (this is the one place sync errors are visible, so nothing
should hide it automatically).
## Testing
- `internal/sync`: new/updated tests for the two-phase `FillPendingDetails` split (an activity
with a workout gets its laps written in both passes, target bands only present after the
`workouts` pass; an activity without a workout is untouched by the `workouts` pass), and for
phase-aware `Progress()` transitions.
- `internal/api`: `sync_test.go` updated for the new `/api/sync/status` JSON shape.
- Frontend: no test suite exists (per CLAUDE.md) — manual smoke test against `seedsample` data,
watching the modal move through all four phases.

View File

@@ -0,0 +1,232 @@
# Onboarding wizard with deferred DB commit
Status: approved, not yet implemented.
## Problem
Feedback on the just-shipped mandatory Garmin-connect onboarding
(`docs/superpowers/specs/2026-07-26-improve-first-connection-design.md`):
1. UX: a wizard-style Previous/Next pair reads better than the current
Continue/Log out pairing across `CreateProfile``ConnectGarmin`.
2. If the user quits mid-flow without logging out, the account is left in a
provisioned-but-not-Garmin-connected state -- confirmed this should
instead mean **nothing is written to the database at all** until Garmin
authentication actually succeeds, at which point display name + Garmin
credentials + the connected flag are all committed together, atomically.
(A third piece of feedback, structured JSON logging, is unrelated and gets
its own design afterward.)
## Design
### Consequence: the three-way LoginGate fork collapses
Since account provisioning and Garmin-connection now happen together in one
atomic step, `has_profile=true && garmin_connected=false` becomes
structurally unreachable -- confirmed to collapse `LoginGate` back to a
two-way fork (`has_profile ? App : OnboardingWizard`). `profile.garmin_connected_at`
stays as an informational timestamp (still written by the new atomic
commit); it just no longer gates anything on its own.
### The core problem: Garmin auth needs an account today
`clientFor` builds a per-user `garmin.Client` keyed by a DB user id and
reads Garmin credentials from that user's `profile` row. To authenticate
with Garmin *before* an account exists, onboarding needs a **temporary,
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
known from the session cookie, before any `users` row exists) instead of a
user id. A long-held-open SQL transaction across the wizard's multiple
HTTP round trips is not an option: SQLite's single connection
(`SetMaxOpenConns(1)`, see `db.go`) means one held-open transaction would
block every other request in the app for as long as the user takes to
finish onboarding.
### Backend: ephemeral pre-account Garmin sessions
New type and `Server` field (`server.go`, alongside the existing
`userClient`/`userSync`/etc. per-user maps):
```go
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Promoted into Server.userGarmin once
// /api/setup/complete actually creates the account; evicted lazily (the
// next setup-endpoint touch for that subject checks staleness first) once
// idle past setupSessionIdleTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
const setupSessionIdleTimeout = 15 * time.Minute
```
`Server` gains `setupGarmin map[string]*setupSession` (keyed by OIDC
subject string), initialized alongside the other maps in `NewServer`.
Helper methods on `Server` (`server.go`):
- `setupSessionFor(sub string) (*setupSession, bool)` -- returns the
session if present and not stale; a stale one is closed and evicted
first (lazy cleanup, no background sweep goroutine -- an abandoned
session otherwise leaks one idle subprocess until the backend restarts,
an accepted trade-off for a single-operator app).
- `replaceSetupSession(sub, email, password string) *setupSession` --
closes and replaces any existing session for `sub` (same "close old,
spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
fresh ephemeral client whose `TokenStorePath` is namespaced under
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}`
namespacing `clientFor` uses, so two people onboarding concurrently never
collide, and so the ephemeral session's persisted Garmin tokens have
*some* home even though no user id exists yet).
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a
session's `Status`/`Message`/`LastUsed` after a login or MFA attempt.
- `removeSetupSession(sub string)` -- closes and drops `sub`'s session
(best-effort removes its `{root}/setup/{sub}` token-store directory too).
Called both when `/api/setup/complete` promotes a session into the
permanent cache, and from `handleSessionLogout` so abandoning mid-wizard
via logout doesn't linger until the idle timeout.
### Backend: three new endpoints replace `/api/setup`
Registered where `r.Post("/setup", s.handleSetup)` used to be (same
middleware group: `RequireSession` + `resolveUser`, but *not*
`requireProvisionedUser` -- these must work before a profile exists):
```go
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleSetupGarminLogin)
r.Post("/mfa", s.handleSetupGarminMFA)
})
})
```
- **`POST /api/setup/garmin/login`** `{garmin_email, garmin_password}`:
401 if unauthenticated; 409 if a profile already exists for this subject
(mirrors old `handleSetup`'s existing check); 400 on missing fields.
Otherwise `replaceSetupSession` + `Authenticate()` + `recordSetupAuthResult`,
responding with the same `{status, message}` shape `/api/auth/login`
already uses.
- **`POST /api/setup/garmin/mfa`** `{code}`: 409 if no session exists for
this subject (never started, or expired) -- frontend resets to the
Garmin step and asks the user to reconnect. Otherwise
`CompleteMFA()` + `recordSetupAuthResult` on the *same* session (never
replaces it -- MFA continues an in-progress attempt on the same
subprocess).
- **`POST /api/setup/complete`** `{display_name}`: 409 if already
provisioned; 409 if no session exists for this subject or its `Status !=
garmin.AuthSuccess` ("Garmin isn't connected yet"). Otherwise, in order:
1. `ProvisionUser(sub, displayName)` (unchanged).
2. `GetProfile` + set `GarminEmail`/`GarminPassword` from the session +
`UpdateProfile` (unchanged method, just called here instead of from
the Profile page).
3. `MarkGarminConnected(userID)`.
4. Promote: move the session's already-authenticated `garmin.Client`
into `s.userGarmin[userID]` and its recorded status into
`s.userAuthStatus[userID]`/`userAuthMessage[userID]` (avoids a
redundant re-authentication -- and possibly a repeat MFA prompt --
immediately after signup), then `delete(s.setupGarmin, sub)` without
closing the client (ownership transferred, not discarded).
5. Best-effort `os.Rename` the ephemeral token-store directory
(`{root}/setup/{sub}`) to the permanent one (`{root}/{userID}`), so a
later respawn of this user's subprocess (e.g. after a server restart)
resumes the already-established Garmin session instead of requiring a
fresh login. Logged, not fatal, on error.
`internal/api/auth.go`'s existing `handleGarminAuthLogin`/`handleGarminAuthMFA`/`handleGarminAuthStatus`
(used by the Profile page's `GarminConnection.tsx` for reconnecting an
already-provisioned user) are untouched.
### Frontend: one wizard component instead of two screens
`CreateProfile.tsx` and `ConnectGarmin.tsx` are deleted, replaced by
`frontend/src/OnboardingWizard.tsx` (and `OnboardingWizard.css`, replacing
`CreateProfile.css`). Two top-level steps (`"name" | "garmin"`); the
in-progress-MFA and already-authenticated states are just conditions on
`auth?.status` *within* the `"garmin"` step, not a third step value --
simpler than threading a separate `"mfa"` step through, and matches how
`GarminConnection.tsx` already branches on `status` today:
- **`name` step**: display name field, held in local state only -- no API
call. "Next" just validates non-empty and advances `step` to `"garmin"`.
- **`garmin` step**, branching on `auth?.status`:
- `undefined` / `"failed"`: email/password fields. "Previous" returns to
the `name` step (pure local state, nothing to undo server-side).
"Next" calls `api.setupGarminLogin(email, password)`.
- `"mfa_required"`: MFA code field. "Previous" clears `auth` back to
`null`, which re-shows the credentials form (submitting it again just
replaces the ephemeral session, same as the backend's "close old,
spawn new" semantics). "Submit code" calls `api.setupGarminMFA(code)`.
- `"authenticated"`: a confirmation message and "Continue to geniusrun",
which calls `api.setupComplete(displayName)` and then `onCreated(...)`.
- "Log out" (`<form method="post" action=".../session/logout">`) stays
visible throughout, the same escape hatch as before.
`frontend/src/api/client.ts`: remove `setup`; add `setupGarminLogin`,
`setupGarminMFA`, `setupComplete` (bodies/shapes as described above).
`frontend/src/LoginGate.tsx`: collapse back to
`!session.has_profile ? <OnboardingWizard onCreated={...}> : <App>` --
`garmin_connected` is no longer read here at all (still present on
`SessionInfo`/`session/me`, just unused by the gate).
### Error handling
- Garmin login/MFA failure: same as today -- message shown, fields stay
editable, retry always available, nothing persisted regardless of
outcome.
- `/api/setup/complete` with no session, or session not yet `authenticated`:
`409`, frontend falls back to the `garmin` step (credentials form) so the
user can (re)connect.
- `/api/setup/complete`/`/api/setup/garmin/login` when already provisioned
(double-submit, duplicate tab): `409` "profile already exists"; frontend
treats this as success and refreshes session info rather than showing an
error.
- Idle sessions (15 minutes unused) are evicted lazily on next touch --
`/api/setup/garmin/mfa` or `/api/setup/complete` against an expired
session behaves identically to "never connected" (409), prompting a
fresh `/api/setup/garmin/login`.
- Logging out mid-wizard closes and drops that subject's ephemeral session
and its token-store directory.
### Testing
- `internal/api`: `POST /api/setup/garmin/login` (mock `AuthSuccess`)
creates no DB user yet (`GetUserBySub` still not found); the
`mfa_required` path via `/api/setup/garmin/mfa` on the same session;
`/api/setup/complete` after a successful session creates the user with
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted
via `clientFor` returning that exact pointer, and `ClosedCalled` still
false -- proving no redundant re-authentication happened);
`/api/setup/complete` with no prior login attempt → 409;
already-provisioned subject hitting either endpoint → 409; two different
OIDC subjects' ephemeral sessions never interfere (isolation test,
matching this repo's adversarial-isolation convention). A token-store
rename test mirroring the existing `TestDeleteProfile_RemovesTokenStoreDirectory`
pattern (set `GarminBase.TokenStorePath`, confirm the directory ends up
under the new user id's path after `/api/setup/complete`, not under
`setup/{sub}`).
- No frontend test suite -- manual smoke test: fresh account → name step →
Previous/Next between name and Garmin steps preserves typed input →
wrong password shows an error and stays editable → MFA prompt → Previous
from MFA returns to credentials → correct credentials (or MFA resolved)
→ "Continue to geniusrun" → lands in the app → confirm `GetUserBySub`
now exists with everything set. Separately: start the wizard, enter
Garmin credentials, then just close the tab without finishing --
confirm no `users`/`profile` row was created at all.
## Out of scope
- A background sweep goroutine for abandoned ephemeral sessions -- accepted
as a known, low-stakes trade-off (see Error handling).
- Structured JSON logging (the third piece of feedback) -- separate design.
- Any change to the Profile page's own reconnect flow
(`GarminConnection.tsx`, `/api/auth/*`) -- untouched.

View File

@@ -0,0 +1,142 @@
# Profile deletion
Status: approved, not yet implemented.
## Problem
`docs/IDEAS.md` backlog item: "profile deletion: add a way to delete our
profile with a dedicated button on the profile page (which by extension will
also logout the user, to be consistent with the logic that login will trigger
the profile creation)".
Today there is no way for a user to remove their geniusrun account. The
closest existing feature, "Reset all" (`GarminConnection.tsx` /
`POST /api/sync/reset`), only wipes synced activity data and rewinds the
backfill watermark -- it leaves the `users` row, `profile`, Garmin
credentials, and custom workout-kind rules untouched. Profile deletion is a
strictly bigger, irreversible operation: the entire account for the signed-in
OIDC subject, gone, followed by a real logout.
## Design
### Data model: cascading deletes
The schema has FK columns pointing at `users(id)` (`profile.user_id`,
`workout_kinds.user_id`, `activities.user_id`, `sync_state.user_id`,
`sync_runs.user_id`) and at `workout_kinds(id)`
(`workout_type_paces.workout_kind_id`), none of which currently cascade.
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities`
(`ON DELETE CASCADE`), which is what lets `ResetAllSyncedData` get away with
a single `DELETE FROM activities`. SQLite's `foreign_keys` pragma is already
enabled on every connection (`db.go`'s `_pragma=foreign_keys(1)`), so the same
mechanism works for the rest of the schema.
`schema.sql` is edited directly (no migration history, per project
convention) to add `ON DELETE CASCADE` to:
- `profile.user_id REFERENCES users(id)`
- `workout_kinds.user_id REFERENCES users(id)`
- `workout_type_paces.workout_kind_id REFERENCES workout_kinds(id)`
- `activities.user_id REFERENCES users(id)`
- `sync_state.user_id REFERENCES users(id)`
- `sync_runs.user_id REFERENCES users(id)`
With that in place, deleting a user becomes one statement:
```go
// store/users.go
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
return err
}
```
After this change, regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`).
### API: `DELETE /api/profile`
New handler alongside the existing `GET`/`PUT /api/profile` routes, inside
the `requireProvisionedUser` group (`server.go`). `userID` comes from
`userIDFromContext`, same as every other handler -- no client-supplied id.
1. If a sync is currently running for this user (`s.userSyncRunning[userID]`,
the same flag `backgroundSync` sets), respond `409 Conflict` ("a sync is
in progress for this account; wait for it to finish before deleting your
profile") rather than deleting rows out from under an in-flight write.
2. Call `s.DB.DeleteUser(ctx, userID)`.
3. Tear down this user's cached Garmin state (new `Server` method, e.g.
`removeGarminClient(userID)`):
- Under `s.mu`, pop `s.userGarmin[userID]` and delete the userID entry
from `userSync`, `userAuthStatus`, `userAuthMessage`, and
`userSyncRunning`.
- If a client existed, call `client.Close()` outside the lock to
terminate its subprocess (mirrors how `handleUpdateProfile` calls
`client.UpdateCredentials` outside `s.mu`).
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
the same path `clientFor` computes when building a client. Log on
error; this is cleanup of an already-orphaned directory, not something
that should fail the request that already deleted the DB row.
4. Respond `204 No Content`.
### Frontend
- `api/client.ts`: `deleteProfile: () => request<void>("/api/profile", {
method: "DELETE" })`.
- `Profile.tsx`: a final `<fieldset className="kind-editor danger-zone">`
("Danger zone") containing a `button-danger` "Delete profile" button
(same class `GarminConnection`'s "Reset all" uses). Clicking it reveals an
inline confirmation block (local component state, no separate modal
component -- same "reveal inline" shape `ClassifyControl` already uses for
its dropdown):
- Explanatory copy: this permanently deletes the account -- Garmin
credentials, every custom rule, every synced activity -- and logs the
user out. Cannot be undone.
- A text input; a "Permanently delete" button stays disabled until the
input's value is exactly `DELETE`.
- A "Cancel" button collapses the block back.
- On confirm: call `api.deleteProfile()`. On success, build a real
`<form method="post" action="{BASE_URL}/api/session/logout">` via the DOM
and submit it immediately -- the same POST-navigation the header's Log out
button already performs (`App.tsx`), required because logout must be a
real browser navigation through Keycloak's end-session redirect, not a
`fetch`. On failure, surface the error via the same `error` state /
`<p className="error">` pattern the rest of `Profile.tsx` already uses,
and leave the confirmation block open so the user can retry.
### Error handling
- Sync-in-progress: `409`, clear message, surfaced like every other API
error on this page.
- DB/teardown errors: `500` with `err.Error()`, same as the rest of this
codebase's handlers.
- The type-`DELETE`-to-confirm gate is UI-only -- same trust boundary as
every other destructive action in this app (e.g. Reset all's
`window.confirm`). The backend does not require a second confirmation
token.
### Testing
- `backend/internal/store`: `TestDeleteUser` -- provision a user, give them
activities (with laps/samples/kind_assignments), workout kinds (with
paces), sync_state, and sync_runs; delete; assert every row is gone.
Adversarial isolation check per this repo's convention
(`isolation_test.go`): provision two users, delete one, assert the other's
rows (profile, workout kinds, activities, etc.) are untouched.
- `backend/internal/api`: handler test for `DELETE /api/profile` against
`newTestServer` + `mock.Client` -- success path (204, user actually gone
from `GetUserBySub`), and the sync-in-progress 409 (set
`userSyncRunning[userID] = true` first).
- No frontend test suite exists yet (per `CLAUDE.md`) -- manually
smoke-tested in the browser: delete flow ends up back at the login/create
profile screen, a fresh login for that OIDC subject lands on "Create your
profile" again (proving the account is truly gone, not just logged out).
## Out of scope
- Any "export my data before deleting" flow -- not requested.
- Re-authentication (password/MFA re-entry) before deletion -- this app has
no such step anywhere else (e.g. Reset all), so it isn't introduced here
either.
- Admin-initiated deletion of another user's account -- there is no admin UI
in this app.

View File

@@ -0,0 +1,173 @@
# Structured JSON logging (API calls + Garmin wrapper calls)
Status: approved, not yet implemented.
## Problem
Motivated by a real debugging need: after implementing the deferred-commit
onboarding wizard, a login attempt reported `mfa_required` but no MFA code
ever arrived by email. There is currently no visibility into what actually
happened between geniusrun and the Garmin wrapper subprocess beyond raw,
unstructured stderr output.
**Likely root cause of that specific incident** (found while surveying the
code for this design, worth recording even though this task doesn't fix
it): `internal/garmin/pyscript/wrapper.py`'s `authenticate` handler blocks
on a 10-second queue read and, on timeout, unconditionally returns
`{"status": "mfa_required"}` -- regardless of whether `garminconnect` ever
actually called `prompt_mfa()`. A login that's merely slow (Garmin/Cloudflare
rate-limiting, already a documented trap in this codebase) looks identical
to a real MFA challenge. The only current way to tell them apart is a raw
stderr line (`"garminconnect invoked prompt_mfa()..."`, only emitted when
MFA is real) that's easy to miss. This design doesn't change that timeout
logic -- it makes the distinction visible in structured logs instead of
buried in stderr, which is the requested first step.
The user asked, specifically: JSON logs on stdout (for an existing
observability stack), for now covering only two things -- calls made to
geniusrun's own APIs, and calls made to external components (the Python
wrapper subprocess).
## Design
### Scope
- Two new log categories: an HTTP access log (one line per API request) and
a Garmin-wrapper call log (one line per subprocess round-trip).
- Existing ad-hoc `log.Printf` calls throughout the codebase are **not**
touched or migrated -- they keep going to stderr, unstructured, exactly as
today. This is deliberate, matching "for now" -- a wholesale migration is
a separate, later concern.
- `log/slog` (stdlib, available with no new dependency given this repo's Go
version) with a JSON handler writing to stdout -- a separate stream from
the existing stderr output, matching "JSON logs on stdout" literally.
### New package: `internal/applog`
```go
package applog
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive, defaulting to
// info for anything unrecognized).
func NewLogger(level string, w io.Writer) *slog.Logger
// WithLogger/FromContext thread a *slog.Logger through request-scoped
// context.Context, so a logger enriched with (e.g.) a request_id in one
// layer is picked up by another (e.g. internal/garmin, downstream of
// internal/api) without either package depending on the other.
// FromContext never returns nil -- it falls back to slog.Default() so
// callers with no request context (the background incremental-sync loop,
// tests that don't bother injecting one) still get a working logger.
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context
func FromContext(ctx context.Context) *slog.Logger
```
`internal/config`: new `LogLevel string` field, `getEnvDefault("GENIUSRUN_LOG_LEVEL", "info")`,
same pattern as every other optional config value.
`cmd/geniusrund/main.go`: once at startup,
```go
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
```
No other wiring needed -- every consumer reads via `applog.FromContext`,
which falls back to this default.
### HTTP access log (`internal/api`)
A new `loggingMiddleware`, registered as the **first** `r.Use(...)`
in `Router()` (ahead of `corsMiddleware`), so it wraps every request
including unauthenticated ones (login redirect, health check) and OPTIONS
preflights:
- Generates a per-request id from an in-process monotonic counter (e.g.
`req-42`) -- simple, no new randomness dependency, resets on restart
(acceptable; log aggregation timestamps disambiguate across restarts).
- Builds `logger := applog.FromContext(r.Context()).With("request_id", id)`
and re-stashes it via `r = r.WithContext(applog.WithLogger(...))` *before*
calling `next.ServeHTTP` -- this is what lets every downstream layer
(including a Garmin wrapper call the handler triggers) log with the same
`request_id`.
- Wraps the `http.ResponseWriter` with chi's own
`middleware.NewWrapResponseWriter` (already available -- `go-chi/chi/v5`
is already a dependency, this is just a different subpackage of it, no
new module) to capture the status code.
- After `next.ServeHTTP` returns, logs one line:
`msg: "http request"`, fields `request_id`, `method`, `path` (`r.URL.Path`
only, deliberately excluding the query string, to stay conservative about
anything unexpected ending up in a log line), `status`, `duration_ms`.
Level `Info`, or `Warn` if `status >= 500`.
### Garmin wrapper call log (`internal/garmin`)
`client.go`'s `execute` is the single funnel point every one of the six
`Client` methods already goes through (confirmed while surveying: none of
them ever put Garmin credentials in the wire `params` -- email/password
only ever reach the subprocess via env vars at spawn time, so logging
`params` in full, for every command, is safe). Change its signature to
`roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error)`
(named returns), and wrap the whole body in a single `defer` that logs
exactly once regardless of which branch returned:
```go
defer func() {
attrs := []slog.Attr{
slog.String("cmd", cmdName),
slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
level := slog.LevelInfo
if result != nil {
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
}
if err != nil {
level = slog.LevelWarn
attrs = append(attrs, slog.String("error", err.Error()))
}
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
}()
```
Reusing the existing `truncate()` helper for `result_preview` (500 chars) is
what keeps this safe for the huge per-second-telemetry responses
(`get_activity_details` can run to several MB, per `maxWrapperLineBytes`)
while still showing tiny auth results (`{"status":"mfa_required",...}`) in
full -- exactly the detail needed for the motivating incident.
All six `Client` methods pass their own `ctx` through to `execute`
(currently they call it without one). `ensureStarted` also gains a `ctx
context.Context` param and logs one `Info` line on actual spawn
(`python_path`, whether a token store is configured) -- spawning the
subprocess is itself a call to the external component.
### Out of scope
- Migrating existing `log.Printf` call sites to `slog`/JSON.
- Fixing `wrapper.py`'s 10-second MFA-timeout ambiguity itself (see
Problem section) -- this design only makes the distinction visible.
- Logging request/response bodies for HTTP calls, or full (non-preview)
Garmin call results.
- `user_id` on the HTTP access log line (would need threading it out of
`resolveUser`'s context back to the outermost middleware, a bigger change
than "for now" calls for) -- `request_id` correlation covers the
motivating need (one login attempt <-> its Garmin calls) without it.
## Testing
- `internal/applog`: level filtering (a below-threshold message doesn't
appear in the output writer); `WithLogger`/`FromContext` round-trip
(same logger comes back out); `FromContext` on a bare `context.Background()`
returns a non-nil logger.
- `internal/garmin`: reuse the existing in-process fake-wrapper harness
(`newFakeWrapperClient`) -- inject a captor logger via
`applog.WithLogger`, call `Authenticate`/`CompleteMFA`, assert the emitted
JSON line's `cmd`/`duration_ms`/`result_preview` fields, and that a
wrapper-reported error surfaces at `Warn` with an `error` field. Update
the three existing tests that call `execute` directly
(`TestSubprocessClient_RoundTrip_DetectsIDMismatch`,
`_SubprocessClosedIsError`, `_WrapperErrorPropagates`) to pass
`context.Background()` as the new first argument.
- `internal/api`: one focused test building a request with a pre-seeded
context logger (writing to a `bytes.Buffer`), confirming the access-log
line's fields, and that a handler returning a 5xx bumps the log level to
`Warn`.

View File

@@ -0,0 +1,167 @@
# Modern Error Displays — Design
**Status:** Approved, ready for implementation planning.
**Origin:** `docs/IDEAS.md`'s "modern error displays" backlog item.
## Goal
Replace every page/component's ad-hoc inline `error`-state paragraph
(`<p className="error">{error}</p>` and its several near-duplicates) with a
single, consistent banner system: ephemeral, color-coded, full-width bars
(red for error, orange for warning, blue for notice, green for success)
that stack, auto-dismiss, and can be closed manually.
This is a full replacement, not an addition alongside the old pattern —
every existing inline error display in the frontend is migrated to the new
system, with one deliberate exception (OnboardingWizard's field-validation
messages — see below).
## Non-goals
- No backend changes. This is purely a frontend presentation change; no API
shape changes anything about how errors are already communicated to the
frontend.
- No new "logout failed" error path. `handleSessionLogout` on the backend
has no failure mode today (unconditional cookie-clear + redirect) — the
banner system will be *capable* of showing a logout error if one is ever
added, but nothing is invented here just to exercise it.
## Architecture
### `frontend/src/banner.ts` — the store
A plain module, not a React Context/Provider. The codebase has no existing
`createContext`/`useContext` usage anywhere, and — critically —
`frontend/src/api/client.ts` is a plain module (not a component), so a
Context wouldn't be reachable from it without extra plumbing. A
module-level singleton store is simpler and callable from anywhere:
```ts
type Severity = "error" | "warning" | "notice" | "success";
type Banner = { id: number; severity: Severity; message: string };
// newest-on-top; auto-dismisses after 8000ms; manually dismissible early.
export function showError(message: string): void;
export function showWarning(message: string): void;
export function showNotice(message: string): void;
export function showSuccess(message: string): void;
export function dismiss(id: number): void;
// useSyncExternalStore plumbing for the render side.
export function subscribe(listener: () => void): () => void;
export function getSnapshot(): Banner[];
```
Multiple banners stack (newest on top); each dismisses independently on its
own timer. A banner's `message` may contain embedded newlines (`\n`) for
the sync-result-plus-nudges case (see below) — the renderer treats them as
line breaks within one banner, not separate banners.
### `frontend/src/components/BannerStack.tsx` + `BannerStack.css` — the renderer
- `useSyncExternalStore(subscribe, getSnapshot)` to read the current list.
- Renders one `<div className="banner banner-{severity}">` per active
banner: the message text (respecting embedded newlines) plus a `×` close
button that calls `dismiss(id)`.
- Four CSS variants — `banner-error` (red bg), `banner-warning` (orange
bg), `banner-notice` (blue bg), `banner-success` (green bg) — each with
readable foreground text/icon color against its background.
- **Mounted exactly once**, in `frontend/src/main.tsx`, as a sibling above
`<LoginGate />`:
```tsx
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BannerStack />
<LoginGate />
</StrictMode>,
);
```
Rendered in normal document flow (not `position: fixed`), so it
naturally pushes down whatever's currently showing below it — the login
screen, the onboarding wizard, or the full app (header + tabs + content)
— without needing a separate mount point wired into each of those three
top-level layouts individually. When no banners are active, it renders
nothing (no reserved empty space).
### `frontend/src/api/client.ts` — friendlier network-failure messages
`request()`'s `fetch()` call is wrapped so a network-level failure (the
`fetch()` promise itself rejecting — offline, connection refused, CORS,
etc.) throws a friendly, fixed message instead of the raw browser error
text:
```ts
let res: Response;
try {
res = await fetch(`${BASE_URL}${path}`, { ... });
} catch {
throw new Error("Can't reach the server — check your connection.");
}
```
A real HTTP response that just happens to be non-2xx (4xx/5xx) is
unaffected — that branch keeps its existing `${status} ${body}` message,
since that's a legitimate API-level error, not a "the backend isn't there"
condition.
This means every call site's existing pattern —
`.catch((e) => setError(String(e)))` — becomes
`.catch((e) => showError(String(e)))`, and the friendly message for a truly
unreachable backend falls out for free, with no per-call-site
network-error special-casing needed. This is what satisfies IDEAS.md's
"used on all pages/tabs in case of backend is not here": every page's
existing catch-and-display path already covers this, once it displays via
`showError` instead of local state.
## Migration inventory
Every current inline-error site, and what changes:
| File | Before | After |
|---|---|---|
| `components/GarminConnection.tsx` | Local `error` state, all catch blocks `setError(String(e))`, inline `{error && <p className="error">{error}</p>}` | Remove the state and the paragraph; every catch block calls `showError(String(e))` directly. |
| `components/SyncModal.tsx` | Never auto-closes; on completion renders a "final result" block inline (success/error line + optional pending-activities/pending-workouts nudges) with a manual Close button; a status-poll fetch failure renders inline too. | The "final result" JSX block is deleted entirely — **the modal now purely shows the progress bar/spinner and nothing else**. On the poll tick where `status.in_progress` is first observed to be `false`, build one message: the success/error line, followed by a newline-separated line for each applicable pending nudge (`"N more activities pending — click Sync now again"` / `"N more workouts pending — click Sync now again"`), call `showSuccess(...)` or `showError(...)` with that combined message, then call `onClose()` immediately — the modal disappears the instant sync finishes, and the banner carries the result. A transient status-poll fetch failure (modal still open, sync still presumably running) calls `showError(String(e))` but does **not** close the modal — polling continues as it does today. |
| `pages/Profile.tsx` | Local `error` state (profile load/save failures), inline paragraph. | Same pattern as GarminConnection — state and paragraph removed, catch blocks call `showError`. |
| `pages/Activities.tsx` | Local `error` state (multiple load/action failures), inline paragraph. | Same. |
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. |
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
**Dead CSS removal:** once no `.tsx` file references them, delete the
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from
their respective CSS files.
## Data flow example (sync completion)
1. User clicks "Sync now" → `GarminConnection.sync()` calls
`api.syncRun()`, sets `showSyncModal(true)`.
2. `SyncModal` polls `/api/sync/status` every 1.5s, rendering the
spinner/progress bar per `progress.Phase` (unchanged from the existing
implementation).
3. A poll response shows `in_progress: false` for the first time.
`SyncModal` builds a message from `last_run.Status`/`ErrorMessage`/
`ActivitiesFetched` plus `activities_pending_details`/
`workouts_pending`, calls `showSuccess(message)` or `showError(message)`
accordingly, then calls `onClose()`.
4. `GarminConnection` unmounts `SyncModal` (its normal `showSyncModal`
toggle, unchanged). The banner, now living in the shared store
independent of any unmounted component, continues to display and
auto-dismiss on its own schedule.
## Testing
No frontend test suite exists in this repo (established convention —
verification is `tsc -b`/`vite build`, `oxlint`, and manual browser
checks). Verification for this feature is:
- `npm run build` / `npm run lint` clean.
- Manual smoke test covering each severity: trigger a real API error (e.g.
an invalid save), a network failure (stop the backend, attempt any
action), a successful sync, a sync ending in error, a sync with pending
activities/workouts remaining, and the login screen's `auth_error`
redirect — confirming banners stack, auto-dismiss, and can be closed
manually, and that `SyncModal` now closes itself the instant sync
finishes rather than waiting for a manual Close click.

View File

@@ -0,0 +1,108 @@
# Stop retrying a permanently-missing Garmin workout forever — Design
**Status:** Approved, ready for implementation planning.
**Origin:** user report -- `/api/sync/status`'s `workouts_pending` count stuck at 1 forever, no
matter how many times "Sync now" is clicked.
## Root cause
`fillPendingWorkouts`/`fillActivityWorkout` (`backend/internal/sync/service.go:339-371`) treats
every `get_workout_by_id` failure identically: log it, leave `workout_raw_json` `NULL`, and let
`ActivitiesMissingWorkout` naturally retry it on the next sync. This is the right behavior for a
*transient* failure (network blip, rate limiting), but wrong for a genuine HTTP 404 -- the
activity's `workout_id` points at a workout that was deleted on Garmin's side after the activity
was recorded (confirmed via the user's own backend log: a 404 for a specific `workout_id`). A 404
is definitive and will never succeed on retry, yet the current code retries it every single sync,
forever, with the failure only ever logged server-side (`log.Printf`) -- never surfaced to the
user beyond a perpetual, unexplained "1 more workout pending" nudge.
## Fix
An activity can legitimately end up with `workout_id` set but `workout_raw_json` permanently
`NULL` when the workout can't be found on Garmin -- `workout_raw_json` stays `NULL` forever (no
fabricated data), but a new, separate marker records "confirmed missing" distinctly from "not yet
fetched," so `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` stop counting it.
### `internal/garmin/pyscript/wrapper.py`
`garminconnect` already defines `GarminConnectNotFoundError` (a subclass of
`GarminConnectConnectionError`) specifically for this case -- its own docstring says "so callers
can now catch a missing resource specifically (e.g. deleting an already-deleted workout)." It's
raised by `connectapi()` (used by `get_workout_by_id` and other lookups) whenever the underlying
HTTP response is a 404.
`dispatch()`'s generic exception handler (used by every `cmd`, including the generic `call`
dispatcher that `get_workout_by_id` goes through) gains a check: if the caught exception is a
`GarminConnectNotFoundError`, the returned JSON error response gets an additional
`"not_found": true` field alongside the existing `"error"` string. This is deliberately generic
(any Garmin API call that 404s gets this marker, not just workouts) -- the decision about what to
*do* with a not-found error stays method-specific in the Go/sync layer.
### `internal/garmin/client.go`
- `wireResponse` gains `NotFound bool `json:"not_found,omitempty"``.
- A new sentinel: `var ErrNotFound = errors.New("garmin: resource not found")`.
- `execute`: when `resp.Error != "" && resp.NotFound`, the returned error wraps `ErrNotFound`
(`fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)`), so callers can
`errors.Is(err, garmin.ErrNotFound)` regardless of which method was called.
### `internal/store` (schema + queries)
- `schema.sql`: `activities` gains `workout_not_found_at TEXT` (nullable), placed next to
`workout_raw_json`, following the exact same style as the existing `details_fetched_at`/
`splits_fetched_at` timestamp markers.
- New method `SetActivityWorkoutNotFound(ctx, userID, activityID) error`, mirroring
`SetActivitySplitsFetched`'s exact shape (`UPDATE activities SET workout_not_found_at =
datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ?`).
- `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` both gain
`AND workout_not_found_at IS NULL` in their `WHERE` clause.
- `UpsertActivity`'s `ON CONFLICT DO UPDATE` already never touches `details_fetched_at`/
`details_raw_json`/`splits_fetched_at`/`workout_raw_json` -- `workout_not_found_at` follows the
same pattern, so it's untouched by a later re-sync and the "confirmed missing" marker persists
indefinitely once set.
- `docs/DATABASE.md` regenerated via `go run ./cmd/dumpschema` after the schema edit.
### `internal/sync/service.go`
In `fillPendingWorkouts`'s loop, when `fillActivityWorkout` returns an error:
- If `errors.Is(err, garmin.ErrNotFound)`: call `db.SetActivityWorkoutNotFound(ctx, s.userID,
a.ID)` and log at Info level ("workout not found on Garmin, likely deleted -- marking as such,
will not retry"). No further retry.
- Otherwise: keep the existing behavior exactly as it is today (log a warning, leave
`workout_raw_json` `NULL`, naturally retried on the next sync).
### `internal/garmin/mock`
`mock.Client`'s existing `WorkoutErrByID map[int64]error` field (added in a previous plan) needs
no shape change -- a test simulating a 404 just sets the mapped error to
`fmt.Errorf("...: %w", garmin.ErrNotFound)` (or `garmin.ErrNotFound` directly), which
`errors.Is` picks up the same way a real wrapped error would.
## Non-goals
- No change to `fillActivityDetails`/`fillPendingActivityDetails`'s error handling (a
details/splits fetch failure still aborts the batch and surfaces as a sync error, as it does
today) -- this fix is scoped to workouts specifically, matching the confirmed root cause. If a
details/splits 404 ever turns out to need the same treatment, that's a separate, unconfirmed
problem to investigate on its own evidence.
- No frontend change -- `workouts_pending` simply stops counting a confirmed-missing workout, so
the existing SyncModal/banner UI needs nothing new.
- No handling for the (very unlikely) edge case of Garmin re-linking a *different* `workout_id`
to the same activity after `workout_not_found_at` was set for an earlier one -- accepted as an
edge case not worth the added complexity.
## Testing
- `internal/garmin/pyscript/tests/test_wrapper.py`: a new test mirroring the existing
`test_call_propagates_garminconnect_exception_as_error`, but raising
`GarminConnectNotFoundError` and asserting the response includes `"not_found": true`.
- `internal/garmin` (Go): a test asserting `execute` wraps the error with `ErrNotFound` when
the wire response sets `not_found: true`.
- `internal/store`: extend the existing `ActivitiesMissingWorkout` test (or add a new one) with a
fixture that has `workout_not_found_at` set, asserting it's excluded from both
`ActivitiesMissingWorkout` and `CountActivitiesMissingWorkout`.
- `internal/sync`: a test using `mock.Client.WorkoutErrByID` set to a `garmin.ErrNotFound`-wrapped
error, asserting `fillPendingWorkouts`/`FillPendingDetails` completes without error, the
activity's `workout_not_found_at` gets set, and it's excluded from a subsequent
`CountActivitiesMissingWorkout` call (i.e., confirming it does NOT get retried on a second call,
unlike today's forever-retry behavior for this exact scenario).

View File

@@ -0,0 +1,132 @@
# Configuration: application vs environment — design
Date: 2026-08-03
Status: approved
## Problem
All runtime tuning today is either an env var (`internal/config`, process-level)
or a per-user `profile` column. There is no home for instance-global,
behavior-affecting settings shared by all users, and some env vars are
misnamed or shouldn't be env vars at all. From `docs/IDEAS.md` ("configuration").
## Concepts
- **Application configuration**: key/value pairs, stored in the database,
common to all users, cold (read once at startup; a change takes effect on
the next backend restart — no hot reload). Unrelated to the execution
environment.
- **Environment configuration**: env vars, tied to the execution environment
(paths, URLs, credentials for infrastructure). Unchanged mechanism.
## Data model
New table in `schema.sql`:
```sql
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
- Deliberately **not** user-scoped — the single exception to the per-user
convention, because application configuration is instance-global by
definition. A schema comment records this.
- The table stores **overrides only**; defaults live in code. Absent key =
default in effect.
- Regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`) after the schema
change. Breaking schema change: delete the local DB and let `Open()`
recreate it (normal pre-production remedy).
## Application-config registry
`internal/config` gains a registry of known keys — name, default, validator,
description. Launch set (one key):
| key | type | default | validation |
|--------------------|---------------|---------|----------------------|
| `session.duration` | integer hours | `720` | integer, > 0 |
- `config.Load()` (env) stays as-is: fail-fast, runs before the DB opens.
- New `config.LoadApp(getter)` reads overrides through a small store-backed
getter, merges defaults, validates, and returns a typed `AppConfig` struct
(`SessionDuration time.Duration`). `main.go` calls it after `store.Open`
and wires the result into `api.SessionConfig.Duration`.
- Writes validate against the registry: unknown keys and invalid values are
rejected, so the `config` table cannot accumulate junk.
## Environment-variable changes (all breaking; pre-production, no shims)
| before | after |
|----------------------------|----------------------------|
| `GENIUSRUN_ADDR` | `GENIUSRUN_BACKEND_ADDR` |
| `GARMIN_WRAPPER_PYTHON` | `GENIUSRUN_PYTHON_PATH` |
| `GARMIN_TOKENSTORE` | `GENIUSRUN_TOKENSTORE_PATH`|
| `GENIUSRUN_MIN_CONFIDENCE` | removed — hard-coded to `classify.DefaultMinConfidence` (0.6) |
| `GENIUSRUN_SESSION_DURATION` | removed — becomes app-config key `session.duration` |
| `GENIUSRUN_SESSION_SECRET` | unchanged (stays env; still required, >= 32 chars) |
Also update: `backend/.env` (local), `CLAUDE.md`, config tests, and any doc
references.
## API
Both routes sit behind the usual session + `requireProvisionedUser` gates.
Any provisioned user may read and write (single-operator app in practice).
- `GET /api/config`
```json
{
"application": [
{"key": "session.duration", "value": "720", "default": "720",
"overridden": false, "description": "Session cookie lifetime in hours"}
],
"environment": [
{"name": "GENIUSRUN_BACKEND_ADDR", "value": ":8080"},
{"name": "GENIUSRUN_OIDC_CLIENT_SECRET", "value": "•••• (set)"}
]
}
```
- Secrets (`GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET`) are
masked as `•••• (set)` / `(unset)`; values never leave the process.
- `main.go` builds the display-safe environment snapshot once (from
`config.Config`, masking applied) and passes it to `api.NewServer` —
handlers never call `os.Getenv`.
- `PUT /api/config` with `{"session.duration": "168"}`:
validates every pair against the registry (reject unknown key / invalid
value with 400 naming the offender; all-or-nothing), upserts, returns the
same shape as GET. Cold semantics: response carries no magic — the UI
states that changes apply after a backend restart.
## Frontend
- **Config page** at browser path `/config` — `App.tsx` derives the view from
`window.location.pathname` (no router library; `history.pushState` on
navigation so the URL is shareable/deep-linkable, and Vite's SPA fallback
serves it in dev; a production reverse proxy needs SPA fallback for the
path).
- Page content: an "Application configuration" card (editable fields per
registry entry, Save button, a visible note "changes take effect after the
backend restarts"), and a read-only "Environment configuration" card
listing name/value pairs, secrets masked by the backend.
- **Header changes** (`App.tsx`), left to right after the tabs:
1. Profile button: icon-only, user-profile glyph (👤). The profile name
moves into `title`/`aria-label` — no visible text.
2. New settings button: icon-only gear (⚙), navigates to `/config`.
3. Log out: icon-only cross (✕), still the POST `<form>` (OIDC logout
needs a real navigation). `title`/`aria-label` "Log out".
- No visible text labels on any of the three; same emoji/glyph style as
the existing tabs, no icon library added.
## Testing
- `internal/store`: config upsert/read round-trip; overrides-only semantics.
- `internal/config`: table-driven registry tests — default when absent,
override applied, invalid value rejected, unknown key rejected.
- `internal/api`: httptest — GET shape + secret masking; PUT happy path,
unknown key 400, invalid value 400; 403 until provisioned (inherited gate,
asserted once).
- Frontend: live click-through — header icons, navigate to `/config`, edit
`session.duration`, save, verify persisted via GET and after restart.

12
docs/workouts.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: v1
name: geniusrun
templates:
- name: 'Recovery 30m'
difficulty: 1
kind: recovery
blocks:
- index: 1
reps: 1
block:
- kind: run
duration: 30m

Some files were not shown because too many files have changed in this diff Show More