No description
  • TypeScript 49.3%
  • JavaScript 37.5%
  • HTML 6.5%
  • CSS 6%
  • Dockerfile 0.6%
Find a file
2026-10-31 20:05:33 +01:00
.claude/skills/workout-templates Add play_last_off flag to repeat blocks and drop the last-off workarounds 2026-10-31 20:05:33 +01:00
migrations Default sim gradient to flat and fix the rolling resistance sent to the trainer 2026-10-31 20:05:33 +01:00
scripts Add play_last_off flag to repeat blocks and drop the last-off workarounds 2026-10-31 20:05:33 +01:00
server mark as virtual activity 2026-10-31 20:05:33 +01:00
web Default sim gradient to flat and fix the rolling resistance sent to the trainer 2026-10-31 20:05:33 +01:00
.dockerignore Initial commit: standalone Kickr Player with auth, migrations and Docker 2026-10-09 18:46:08 +02:00
.gitignore Initial commit: standalone Kickr Player with auth, migrations and Docker 2026-10-09 18:46:08 +02:00
bun.lock Add ride summary chart and record the workout's base watts 2026-10-20 22:19:20 +02:00
deploy Add play_last_off flag to repeat blocks and drop the last-off workarounds 2026-10-31 20:05:33 +01:00
docker-compose.yml Add own/global workout templates, soft delete and LLM-assisted workout creation 2026-10-15 08:02:37 +02:00
Dockerfile Serve the ride chart PNG and show it in the summary dialog 2026-10-22 22:34:13 +02:00
package.json Add ride summary chart and record the workout's base watts 2026-10-20 22:19:20 +02:00
README.md Add own/global workout templates, soft delete and LLM-assisted workout creation 2026-10-15 08:02:37 +02:00
tsconfig.json Add workout-templates skill and template-stats script 2026-10-09 19:12:52 +02:00

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 the kickr_session cookie (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 answers 200 (no account enumeration; rate-limited) and, if the address is registered, emails a link APP_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 role user and the inviter is recorded in users.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 (or GET/POST /api/tokens, DELETE /api/tokens/:id with 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 an Authorization header is present it is authoritative (an invalid token is a 401, 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 returns 401 without a valid session. Login attempts are throttled (5 failures / 15 min per client + email, in-memory).
  • State-changing requests with a foreign Origin are rejected; cookies are SameSite=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.