- TypeScript 49.3%
- JavaScript 37.5%
- HTML 6.5%
- CSS 6%
- Dockerfile 0.6%
| .claude/skills/workout-templates | ||
| migrations | ||
| scripts | ||
| server | ||
| web | ||
| .dockerignore | ||
| .gitignore | ||
| bun.lock | ||
| deploy | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
Kickr Player
Browser-based structured-workout player for smart trainers (ERG / simulation mode via Web Bluetooth, FTMS).
Pick a workout, connect your trainer and heart-rate strap, ride; the backend records the ride, writes a
.fit file and optionally uploads it to Strava and/or intervals.icu.
- Backend: Bun + SQLite (
bun:sqlite), no framework —server/ - Frontend: single-page app (vanilla JS modules) built with Vite —
web/ - Storage: one SQLite file + FIT files in
DATA_DIR - Auth: email/password, server-side sessions in an HttpOnly cookie. No public sign-up — accounts come from the bootstrap admin or from invitations. Password reset and invitations are sent via SMTP.
Web Bluetooth only works in Chromium-based browsers and requires HTTPS (or
localhost). Run it behind a TLS-terminating reverse proxy when hosting publicly.
Run with Docker
docker build -t kickr-player .
docker run -d --name kickr-player -p 3002:3002 -v kickr-data:/data \
-e ADMIN_EMAIL=you@example.com -e ADMIN_PASSWORD='choose-something-long' \
-e APP_URL=https://kickr.example.com \
-e SMTP_HOST=smtp.example.com -e SMTP_USER=… -e SMTP_PASSWORD=… -e MAIL_FROM='Kickr Player <kickr@example.com>' \
-e TRUST_PROXY=true \
kickr-player
Or ADMIN_EMAIL=… ADMIN_PASSWORD=… APP_URL=… docker compose up -d (plus the SMTP variables) (see docker-compose.yml, binds to 127.0.0.1:3002).
State lives in the /data volume — back it up (kickr-player.sqlite, fit-files/).
Configuration
| Variable | Default | Description |
|---|---|---|
ADMIN_EMAIL, ADMIN_PASSWORD |
– | On startup, create this user (role admin) if it does not exist. An existing user is never modified, so changing the env var later does not change the password. |
PORT |
3002 |
Listen port |
DATA_DIR |
./data (/data in Docker) |
Database and FIT files |
DATABASE_PATH |
$DATA_DIR/kickr-player.sqlite |
SQLite file |
FIT_FILES_DIR |
$DATA_DIR/fit-files |
Generated FIT files |
STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET |
– | Your Strava API application; Strava upload is disabled unless both are set |
STRAVA_REDIRECT_URI_OVERRIDE |
$APP_URL/api/strava/callback |
Replaces the redirect_uri sent to Strava's /oauth/authorize. For when several apps share one Strava client (only one callback domain per client); the URL must end up at this app's /api/strava/callback |
OPENROUTER_API_KEY |
– | Enables the Create workout button (LLM-generated workouts); off without it |
OPENROUTER_MODEL |
anthropic/claude-haiku-5.5 |
OpenRouter model ID used for workout creation |
APP_URL |
– | Public base URL, e.g. https://kickr.example.com. Used for links in emails (never taken from request headers). Required for password reset and invitations |
SMTP_HOST |
– | SMTP server. Without it, invitations fail with 503 and reset requests are logged and dropped |
SMTP_PORT |
587 |
465 for implicit TLS (then also set SMTP_SECURE=true); otherwise STARTTLS is used when the server offers it |
SMTP_SECURE |
false |
true = TLS from the first byte (port 465) |
SMTP_USER, SMTP_PASSWORD |
– | SMTP login (omit for an open relay) |
MAIL_FROM |
$SMTP_USER |
Sender address, e.g. Kickr Player <kickr@example.com> |
COOKIE_SECURE |
true |
Set false only for plain-http local development |
TRUST_PROXY |
false |
Use X-Forwarded-For for login throttling. Enable only behind your own reverse proxy |
SESSION_TTL_DAYS |
30 |
Login session lifetime |
intervals.icu upload (optional, per user)
Each user can click intervals.icu in the top bar and enter their athlete ID and personal API key (intervals.icu → Settings → Developer Settings). Finished rides are uploaded as FIT files directly to intervals.icu; the credentials are stored in intervals_credentials. No server configuration needed.
Strava upload (optional, per user)
With STRAVA_CLIENT_ID/STRAVA_CLIENT_SECRET (and APP_URL, or STRAVA_REDIRECT_URI_OVERRIDE) set, each user can click Strava → Connect with Strava in the top bar. Strava's Authorization Callback Domain must match the redirect URI's domain. Finished rides are then uploaded automatically; the server refreshes and stores the tokens in strava_credentials.
Development
bun install
ADMIN_EMAIL=me@example.com ADMIN_PASSWORD=dev-password-1 COOKIE_SECURE=false bun run dev # API + static on :3002 (restarts on change)
bun run dev:web # Vite dev server on :5173, proxies /api → :3002
bun run build # production frontend → web/dist (served by the backend)
bun run typecheck
Open http://localhost:5173 while developing, or build and open http://localhost:3002.
Authentication & authorization
POST /api/auth/login{email, password}→ sets thekickr_sessioncookie (argon2id password hashes; session tokens are random, only their SHA-256 is stored).POST /api/auth/logout,GET /api/auth/me.- Forgot password:
POST /api/auth/forgot {email}always answers200(no account enumeration; rate-limited) and, if the address is registered, emails a linkAPP_URL/#reset=<token>. The token is random, single-use, valid for 1 hour and stored only as a hash (in the URL fragment, so it never reaches server logs).POST /api/auth/reset {token, password}sets the new password and signs out every session of that user. - Change password (logged in):
POST /api/auth/password {current_password, new_password}; other sessions are signed out. Passwords must be at least 10 characters. - Invite a friend (any logged-in user, browser session only, max. 10 per day):
POST /api/invite {email}creates an account with a random password, emails it, and flags the account so the UI demands a password change at first sign-in. The answer is the same if the address is already registered. If the email cannot be sent, the account is rolled back. Invited users have roleuserand the inviter is recorded inusers.invited_by. - Password change, invitations and token management are refused for API-token requests (browser session required).
- API tokens for scripts/other clients: send
Authorization: Bearer kp_…. Create and revoke them via the API tokens button in the UI (orGET/POST /api/tokens,DELETE /api/tokens/:idwith a browser session). A token acts as its user (same data scope), may have an expiry, and is stored only as a SHA-256 hash — the plaintext is shown once. Tokens cannot create or revoke tokens. If anAuthorizationheader is present it is authoritative (an invalid token is a401, no cookie fallback), and the same-origin check is skipped for such requests. Example:curl -H "Authorization: Bearer $TOKEN" https://host/api/sessions. - Every other
/api/*route returns401without a valid session. Login attempts are throttled (5 failures / 15 min per client + email, in-memory). - State-changing requests with a foreign
Originare rejected; cookies areSameSite=Strict. - Ride sessions, records, FTP and FIT downloads are scoped to the owning user (
user_id). Workout templates are shared. users.role(admin|user) exists but nothing is role-gated yet. There is no user-management UI/API; new users arrive only via invitations.
Database & migrations
The app owns its schema (previously it borrowed fitness-data.sqlite from training-planner). Tables:
users, auth_sessions, password_resets, workout_templates, ftp, ride_sessions, ride_records, ride_rr (raw RR intervals per beat), api_tokens, strava_credentials, intervals_credentials (+ schema_migrations).
Migrations are plain SQL files in migrations/, named NNN_description.sql. They run automatically on startup,
in order, each in a transaction, and are recorded in schema_migrations. To change the schema add a new file with the
next number — never edit one that has already been applied. bun run migrate applies them without starting the server.
Migration 002 seeds the 16 built-in workout templates.
Layout
server/ Bun backend (index.ts = routes, auth.ts, migrate.ts, fit-writer.ts, interval-engine.ts, strava.ts, intervals.ts)
migrations/ SQL schema migrations
web/ Vite SPA (index.html, src/main.js = auth gate, src/player.js = player UI, src/ble.js = Web Bluetooth)
Workout templates are stored as JSON (workout_templates.intervals_json); the format is described by WorkoutTemplate
in server/interval-engine.ts.