A lightweight, low-friction place to jot future development ideas before
they're formalized into a spec/plan -- distinct from
docs/superpowers/specs/ and docs/superpowers/plans/, which are for once an
idea is ready to be built.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reflects the just-merged per-user-profile work: every table is now
user_id-scoped, OIDC accounts get their own isolated dataset via
resolveUser/requireProvisionedUser + POST /api/setup, Garmin sessions are
namespaced per user, and the legacy-owner upgrade bootstrap. Supersedes the
old "single shared profile" framing throughout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per-user profile isolation namespaces each user's mcp-garmin session
cache under {GarminTokenStoreRoot}/{userID} (api.Server.garminFor), but
Load() left GarminTokenStoreRoot ("" when GARMIN_TOKENSTORE is unset)
with no required-var check and no safe default. In that state
garminFor's `if cfg.TokenStorePath != ""` guard skips the per-user
join entirely, so every user's subprocess would fall back to the same
default token cache -- a cross-user Garmin-session collision risk in
any deployment that forgets to set GARMIN_TOKENSTORE.
Default it to a "garmin-tokenstores" directory next to DBPath when
unset, so every deployment gets per-user isolation automatically,
while GARMIN_TOKENSTORE can still override it explicitly. Log the
derived default. Update the field's doc comment (no longer "if set")
and add config_test.go cases covering the default derivation and the
explicit-override precedence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
LoginGate now shows CreateProfile (display name only) instead of App when
an authenticated session has no provisioned geniusrun profile yet, backed
by the new POST /api/setup endpoint and session/me's has_profile flag.
Every store call now needs a userID -- seedsample provisions one fixed
"seedsample-user" account up front and threads it through the rest of the
seeding logic, unchanged in what it actually seeds.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HTTP-level counterpart to the store-layer isolation tests: proves the full
middleware+handler chain rejects/hides another user's activities, workout
kinds, and review queue even when given that user's real row ids, and that
an unprovisioned session is blocked from every data route.
Completes the internal/api scoping pass -- the whole package now compiles
against the per-user store/sync/garmin signatures from Tasks 4-13. Also
fixes the test helpers (newTestServer now returns the provisioned userID)
and a latent bug in TestResolveUser_LeavesContextEmptyWhenNotProvisioned,
which relied on doJSON's hardcoded "test-user" session sub being
unprovisioned -- never caught before since internal/api couldn't compile
since Task 12.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Server no longer holds one fixed Garmin/Sync pair -- garminFor/syncFor
build and cache one instance per user, keyed off their own profile's
Garmin credentials and a per-user token-store subdirectory. The background
incremental sync loop now iterates every provisioned user each tick
instead of syncing one global account.
r.Use(s.resolveUser) was registered after /session/me and /session/logout
had already been added to the same chi inline-mux group. Chi requires all
r.Use() calls on a mux to precede any route registration on it, or it
panics with "chi: all middlewares must be defined before routes on a mux"
(reproduced against the real chi v5.3.1 dependency). This meant
server.Router() would crash at startup, taking down every internal/api
test that builds a Router along with it.
Separately, since chi captures each route's middleware chain at
registration time, /session/me and /session/logout would never have run
resolveUser even without the panic -- so handleSessionMe's
has_profile/display_name logic could never see a resolved user on that
route.
Fix: move r.Use(s.resolveUser) immediately after
r.Use(auth.RequireSession(...)), before any route in the group is
registered, so the ordering is legal and resolveUser applies to
/session/me, /session/logout, and /setup alike.
resolveUser attaches the session's provisioned geniusrun user (if any) to
request context without blocking; requireProvisionedUser (wired fully in
Task 13) 403s routes that need one. GET /api/session/me now reports
has_profile/display_name so the frontend can show the setup screen.
GarminTokenStore is renamed GarminTokenStoreRoot to reflect that it now
roots one subdirectory per user rather than a single session cache path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
NewService now takes a userID, baked into the instance rather than passed
per-call -- matches internal/api's one-Service-per-logged-in-user model
(Task 13), so ClassifyActivity/Backfill/etc. keep their existing call
signatures unchanged everywhere they're already used.
Dedicated adversarial coverage for the core security property: no store
method can read or mutate another user's profile, workout kinds/paces, or
sync state/runs, even when handed that other user's real row id.
None of these three tables gained their own user_id column -- they're
always accessed through a specific activity, so ownership is checked via a
join/subquery against activities.user_id instead.
Update the per-user-profile plan document to reflect the corrected Task 3
design: remove the now-unreachable TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall
test from the code example, update the ClaimLegacyOwner implementation example to
remove the dead-code branch and false doc comment about fresh installs, and fix
the commit message to match the corrected design.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 3 correction: migration 0003 unconditionally seeds a profile row on every
fresh install, and Task 1's migration 0023 preserves this seeded row with
user_id = NULL. Therefore, a fresh install always has at least one profile row
with user_id IS NULL when users table is empty -- the "no-op on genuinely fresh
install" scenario was unreachable dead code. Confirmed with the codebase owner
that no scenario requires defending against a missing profile row.
Collapse the two-branch error handling into a single `if err != nil` check
(matching the pattern used elsewhere in the function), remove the now-unused
database/sql import, update the function's doc comment to remove the false claim
about fresh installs, and delete the now-unreachable
TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Migration 0026 (activities table rebuild for the per-user unique
constraint) issued its own PRAGMA foreign_keys = OFF/ON inside the
migration's SQL content, but the whole migration file runs inside one
db.go tx.Begin()/tx.Exec()/tx.Commit() transaction, and SQLite documents
PRAGMA foreign_keys as a no-op once a transaction is open. As a result FK
enforcement never actually got disabled, so DROP TABLE activities
triggered SQLite's implicit DELETE FROM semantics, firing ON DELETE
CASCADE on every row in laps, activity_samples, and kind_assignments for
every activity -- silently, with no error. On a real upgrade with synced
data this would have permanently destroyed all lap/sample/classification
history. It was masked because every existing migration test runs
migrations back-to-back on an empty temp database with no pre-existing
child rows.
Fix: add "0026_activities_unique_constraint.sql" to db.go's
tableRebuildMigrations map so it gets the same autocommit-mode FK
disable/enable toggle (before/after the transaction) already used for
migrations 0023/0024/0025, and remove the now-redundant/misleading
mid-transaction PRAGMA lines from the migration file itself, matching
the established pattern.
Also restore the DEFAULT '' on event_type_key in migration 0026's
rebuilt activities table -- it was dropped from migration 0007's
original column definition during the rebuild, which broke every insert
that omits event_type_key and relies on that default
(TestClaimLegacyOwner_BindsExistingSingletonRowsToOneNewUser and others).
Extend TestRebuildMigrationsPreserveForeignKeyReferences to cover 0026:
seed a laps row and an activity_samples row (in addition to the existing
kind_assignments row) against a pre-existing activities row before the
rebuild migrations run, then verify after 0023-0026 complete that all
three child rows still exist and still reference the same activity, and
that FK enforcement rejects bogus activity_id/workout_kind_id afterward.
This is the regression guard that would have caught the original bug.
Note: TestClaimLegacyOwner_NoOpOnGenuinelyFreshInstall still fails on
this branch; verified it fails identically at the prior commit
(d8b7228), so it's a pre-existing, unrelated bug in ClaimLegacyOwner
(not migration 0026 or this FK-toggle bug) and out of scope for this fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Migration 0026 rebuilds the activities table to enforce UNIQUE(user_id,
garmin_activity_id) as the composite primary constraint, removing the old
global UNIQUE(garmin_activity_id) constraint from migration 0001. This
allows the same Garmin activity ID to appear for different users without
conflicts, which is essential for the per-user profile design and enables
the TestUpsertActivity_SameGarminActivityIDAllowedAcrossDifferentUsers
test to pass.
garmin_activity_id uniqueness becomes per-user (UNIQUE(user_id,
garmin_activity_id), added in Task 1) so two users' Garmin accounts can
never collide even in the unlikely event their activity ids coincide.
workout_kinds gains a real user_id column (Task 1); workout_type_paces has
none of its own and is scoped via a join to workout_kinds instead, since
it's always accessed through a specific kind.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Part of per-user profile isolation: profile rows are no longer a global
singleton, so every read/write requires the caller's userID. This also
requires scoping ListActivities, ListWorkoutKinds, UpsertActivity,
CreateWorkoutKind, GetSyncState, UpdateSyncState, and ResetAllSyncedData
to userID, plus updating all related tests in the store package.
Binds pre-multi-tenancy singleton rows to one named OIDC subject, given at
startup via an env var (wired in Task 11). No-ops once any user exists or
on a genuinely fresh install.
Lays the groundwork for per-user accounts: CreateUser/GetUserBySub/
ListUsers plus ProvisionUser, which seeds a brand-new user's profile,
default workout-kind taxonomy, and sync state in one transaction. Depends
on later tasks scoping GetProfile/ListWorkoutKinds/GetSyncState to compile
and pass -- expected or committing to a shared branch.
Every existing migration test opens a brand-new DB via store.Open, which
runs migrations 0001-0025 in one uninterrupted pass over an empty
database -- so migrations 0023/0024/0025's table-rebuild (create-new/copy/
drop-old/rename-into-place) never had any real pre-existing rows to carry
across, and no test proved that a genuinely in-use single-tenant install
(real workout_kinds, real synced activities, kind_assignments referencing
workout_kinds by FK) upgrades safely.
TestRebuildMigrationsPreserveForeignKeyReferences manually applies
migrations up to (but not including) the three rebuilds, inserts rows
simulating that pre-existing install, applies the rebuilds, then verifies
the kind_assignments row still resolves to the correct workout_kinds row
by name, and that FK enforcement is genuinely back on afterward (a bogus
workout_kind_id is rejected).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous implementation issued PRAGMA foreign_keys toggles inside
transactions (after tx.Begin()), but SQLite ignores pragmas once a
transaction is open with modernc.org/sqlite, making those calls no-ops.
This fix relocates the toggle to autocommit mode (db.Exec, not tx.Exec)
and limits it to only the three migrations that need it (0023/0024/0025),
which rebuild tables with incoming foreign keys (kind_assignments/
workout_type_paces reference workout_kinds; activities/sync_runs
reference sync_state).
The connection string now correctly restores ?_pragma=foreign_keys(1),
ensuring FK enforcement is ON by default for normal runtime operation
and during migrations that don't need the special handling.
Each affected migration now:
1. Disables FK in autocommit mode before starting its transaction
2. Runs the migration's CREATE/INSERT/DROP/RENAME sequence
3. Re-enables FK in autocommit mode after the transaction commits
All other migrations run normally with FK enforcement active throughout,
protecting against cascading deletions silently failing if a future
connection change causes FK enforcement to inadvertently remain off.
Added TestForeignKeyEnforcementPostMigration to verify FK is correctly
enforced after all migrations complete: valid FK references are accepted,
and invalid ones are rejected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Schema-only step toward per-user profile isolation: profile/workout_kinds/
sync_state are rebuilt to drop their singleton constraints, activities/
sync_runs gain a nullable user_id column. Store methods are scoped in
later tasks.
Reverses the "single active profile, OIDC is access-only" decision from
2026-07-24 now that login needs to map each user to their own dataset
instead of a shared singleton.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four bugs slipped through per-task review since each task only saw its
own diff:
- Logout was a plain <a href> GET against a POST-only backend route, so
it 405'd and never cleared the session cookie or hit Keycloak's
end-session redirect. Now a <form method="post"> with a submit button
styled to match the old link (still a real full-page navigation, not
a fetch, so the Keycloak redirect chain still works).
- Login/logout used origin-relative paths, unreachable from the Vite
dev server (:5173) against the backend (:8080) with no proxy
configured. Both now build their URL from client.ts's now-exported
BASE_URL.
- handleSessionCallback's four failure paths redirected to
/?auth_error=failed with no logging, making a real OIDC failure
undiagnosable in production. Added log.Printf on each failure site.
- handleSessionLogout passed a bare "/" to EndSessionURL; Keycloak
requires post_logout_redirect_uri to be an absolute, registered URL.
Added SessionConfig.PublicBaseURL, wired from cfg.PublicBaseURL in
main.go, and used to build an absolute redirect.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Router() now wraps every route except /health, /session/login, and
/session/callback in a chi group requiring a valid session cookie
(auth.RequireSession). Adds internal/api/session.go with the four
session HTTP handlers (login/callback/logout/me) and SessionConfig.
NewServer takes an auth.Verifier and SessionConfig. Test infra
(doJSON, newTestServer) now mints/attaches a signed session cookie
automatically so the 36 pre-existing tests keep exercising the
already-logged-in path unchanged, plus 8 new tests cover the gating
and session endpoints themselves.
Implements HTTP middleware that enforces session authentication by validating
session cookies and making claims available via ClaimsFromContext. Rejects
requests without valid session cookies with 401 Unauthorized.
Tested via four test cases:
- Missing session cookie rejection
- Valid cookie acceptance with claims extraction
- Expired cookie rejection
- Tampered cookie rejection
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Task-by-task TDD plan covering config, the new internal/auth package
(session cookies, Keycloak OIDC verifier, RequireSession middleware),
wiring into internal/api and cmd/geniusrund, and the frontend
LoginGate.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an access-gate authentication design: Keycloak OIDC via a
backend-driven Authorization Code flow, restricted by realm role,
with geniusrun minting its own session cookie. No data model or
multi-profile changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- GarminConnection now flushes Profile's pending debounced autosave before
connecting, avoiding a race where Connect fires with stale credentials.
- client.go reads res.Content before checking IsError so tool error
messages actually include mcp-garmin's response text.
- seedsample: update kind lookups to match current taxonomy names
("Easy Run" -> "Easy", "Interval" -> "Intervals").
- Add backend/start.sh and frontend/start.sh dev launch scripts, and
check in CLAUDE.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Confirmed via Garmin Connect against a real activity ("Auriol - W3-3-Double
Barrel"): the workout's step count didn't match its 18 recorded laps
because the athlete kept running 6:46 past the prescribed 5-minute
cool-down, logged as an 18th lap the workout never defined. The strict
equality check meant this one extra lap discarded every other lap's real
target too, not just its own.
alignWorkoutTargets now tolerates exactly one extra recorded lap beyond the
step count: the steps that do exist still zip to their laps normally, and
only the trailing extra lap is left without a target. Any larger mismatch
still falls back to nil for every lap, since that can't be trusted at all.
Two new Profile settings -- "minimum representative pace" and "minimum
representative time" -- replace the previous hardcoded 20:00/km cutoff. A
stretch of consecutive samples slower than the configured pace is now
dropped from the chart (and its Y-axis scale) only if it lasts no longer
than the configured time; a longer stretch is kept as a real stop or walk
break rather than noise. Defaults to 12:00/km and 3 seconds.
Caught a boundary bug while verifying against real data: a run lasting
exactly the threshold duration survived filtering because the comparison
used strict "<" instead of "<=", contradicting "not lasting more than N
seconds" (which should include exactly N).
Confirmed against Garmin Connect's own workout view: a lap whose
IntensityType merely repeats the immediately preceding lap's (e.g. a second
"cool-down" lap right after the first) means the recording continued past
the end of that step, not that the workout prescribed a second target for
it. Only the first lap of such a run keeps its target/HR fields; the
continuation shows the actual trace with no expectation overlay. Doesn't
affect normal interval structure, since Effort/Recovery always alternate
and never appear back-to-back.
A cool-down (or warm-up) split across two or more laps was drawing each
lap's own average as a separate flat segment, so a workout with a 2-lap
cool-down showed two different "cool-down averages" back to back instead
of one. Consecutive laps sharing an IntensityType are now merged into a
single phase segment with one duration-weighted average across all of them.