The Hyrox Companion exposes a RESTful API under the /api/v1/ prefix. All endpoints (except the health check and cron trigger) require Clerk JWT authentication. Request bodies are validated with Zod schemas, and rate limiting is applied per-user per-category.
Base URL: /api/v1
Content-Type: application/json (requests and responses)
Authentication: Clerk JWT via credentials: "include" (cookie-based)
Machine-readable spec: a committed OpenAPI 3.0 snapshot lives at docs/openapi.json. It is regenerated from the same Zod registry that powers Swagger UI (shared/openapi.ts) via pnpm docs:openapi and is CI-gated — the Build workflow fails if the committed file drifts from the schemas. Use it for client-code generation, contract testing, or importing into tools like Postman / Insomnia. Endpoints marked below that are not yet registered with the registry are not in the snapshot; coverage is being broadened as routes are migrated.
- Error Responses
- Rate Limiting
- CSRF Protection
- Idempotency
- Request Validation
- Auth Routes
- Account Routes
- Workout Routes
- Custom Exercise Routes
- Training Plan Routes
- Timeline Annotation Routes
- Analytics Routes
- AI and Chat Routes
- Coaching Material Routes
- Preferences Routes
- Email Routes
- Push Notification Routes
- Strava Routes
- Garmin Routes
- Timeline and Export Routes
All errors follow a standard format:
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": { "issues": [{ "path": "field", "message": "..." }] }
}detailsis only included for validation errors and non-500 responses.- 500 errors always return
"Internal Server Error"to prevent leaking internals.
Validation error example (400):
{
"error": "Invalid workout data",
"code": "VALIDATION_ERROR",
"details": {
"issues": [
{ "path": "date", "message": "Must be a valid date in YYYY-MM-DD format" },
{ "path": "rpe", "message": "Number must be less than or equal to 10" },
{ "path": "exercises[0].exerciseName", "message": "String must contain at least 1 character(s)" }
]
}
}Rate limit error example (429):
HTTP/1.1 429 Too Many Requests
Retry-After: 45
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 1710500045
{
"error": "Rate limit exceeded",
"code": "RATE_LIMITED"
}Not found error example (404):
{
"error": "Workout not found",
"code": "NOT_FOUND"
}Common HTTP status codes:
| Status | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR, INVALID_CSV |
Invalid input |
| 401 | UNAUTHORIZED |
Missing or invalid auth |
| 404 | NOT_FOUND |
Resource not found |
| 429 | RATE_LIMITED |
Rate limit exceeded (includes Retry-After header) |
| 500 | INTERNAL_SERVER_ERROR |
Server error |
Rate limits are applied per-user (keyed by Clerk userId) and namespaced by category so limits are independent across route groups.
- Default window: 60 seconds
- Strava routes: 15-minute window
- Response on limit:
429withRetry-Afterheader andRATE_LIMITEDcode - Headers: Standard
RateLimit-*headers (RFC 6585)
Implementation: server/routeUtils.ts — rateLimiter(category, maxRequests, windowMs)
All mutating endpoints (POST/PUT/PATCH/DELETE) require a valid CSRF token. The token is obtained via:
Retrieve a CSRF token for use in subsequent mutating requests.
- Auth: Not strictly required (works pre-login, bound to IP; after login, bound to userId)
- Response:
{ token: string } - Side effects: Sets a signed
__Host-hyrox.x-csrfcookie (production) orhyrox.x-csrf(development)
The returned token must be sent as the x-csrf-token header on all mutating requests. Missing or invalid tokens result in a 403 Forbidden response.
Mutating endpoints support the X-Idempotency-Key header for safe request replay.
- Header:
X-Idempotency-Key(optional, max 255 characters) - Behavior: When present on a mutating request (POST/PUT/PATCH/DELETE), the server caches the response for 24 hours keyed by
(userId, key). Repeat requests with the same key return the cached response without re-executing the handler. - Use case: The client's offline queue sends this header when replaying mutations that were queued while offline, preventing duplicate state changes.
Most protected mutation routes now use a shared builder (protectedPost, protectedPatch, protectedDelete) that codifies middleware order and keeps error responses consistent.
For builder-backed routes, middleware runs in this order:
protectedMutationGuards(auth + idempotency guard chain)- route-local limiter (
rateLimiter(...)) - route-specific middleware list (typically
validateBody(...),validateParams(...), and optional feature guards) - handler (wrapped with
asyncHandlerfor async routes)
This ordering guarantees auth/idempotency and cost guards execute before validation or business logic, while still letting validation produce the canonical VALIDATION_ERROR shape. CSRF is enforced separately at router registration (app.use("/api/v1", csrfProtection)).
Endpoints use Zod schemas for request body validation via two patterns:
validateBody(schema)middleware — Parsesreq.bodywith the schema, returns 400 on failure, replacesreq.bodywith parsed data on success.- Inline
safeParse()— Used in routes that need custom error messages or partial validation.
Validation errors return:
{
"error": "First validation error message",
"code": "VALIDATION_ERROR",
"details": {
"issues": [
{ "path": "field.nested", "message": "Must be at least 1" }
]
}
}File: server/routes/auth.ts
Returns the current authenticated user's profile. Creates the user in the database if they don't exist yet (first-call sync from Clerk).
- Auth: Required
- Rate limit:
authcategory, 20/min - Response:
Userobject (id, email, firstName, lastName, profileImageUrl, preferences)
File: server/routes/account.ts
Permanently delete the authenticated user's account and all associated data (GDPR "right to erasure").
- Auth: Required
- Rate limit:
accountDeletecategory, 3/min - Body: none
- Response:
{ "success": true }(or404 { "error": "User not found", "code": "NOT_FOUND" }) - Side effects, in order:
- Clerk identity is deleted first. If Clerk returns HTTP 404, the identity is treated as already-deleted (idempotent retry); any other error aborts the request so the DB row is not orphaned. Without this ordering,
ensureUserExistson the next authenticated request would silently re-provision the account. - Best-effort Strava deauthorization —
POST https://www.strava.com/oauth/deauthorizeis called with the stored access token. Failures are logged and ignored (non-fatal). - DB user row is deleted. FK
ON DELETE CASCADEcleans up:workout_logs,exercise_sets,training_plans,plan_days,chat_messages,coaching_materials,document_chunks,strava_connections,garmin_connections,custom_exercises,push_subscriptions,ai_usage_logs,idempotency_keys, andtimeline_annotations. - Auth seen-cache eviction —
evictUserFromSeenCache(userId)prevents any in-flight session from triggeringensureUserExistswithin the 5-minute cache TTL.
- Clerk identity is deleted first. If Clerk returns HTTP 404, the identity is treated as already-deleted (idempotent retry); any other error aborts the request so the DB row is not orphaned. Without this ordering,
File: server/routes/workouts.ts
List workout logs for the current user with pagination.
- Auth: Required
- Query params:
limit(default 50, max capped),offset(default 0) - Response:
WorkoutLog[]
Get a single workout log by ID.
- Auth: Required
- Response:
WorkoutLogwithexerciseSets - 404: Workout not found
Create a new workout log, optionally with parsed exercises.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Body:
InsertWorkoutLogfields + optionalexercises: ParsedExercise[] - Validation:
insertWorkoutLogSchema+exercisesPayloadSchema - Side effects: If user has AI coach enabled, sets
isAutoCoachingflag and queues anauto-coachjob. - Response: Created
WorkoutLogwith expandedexerciseSets
Request example:
{
"date": "2025-03-15",
"focus": "Strength and Running",
"mainWorkout": "4x8 back squat at 80kg, then 5km easy run",
"accessory": "3x12 lunges, 3x15 wall balls",
"duration": 75,
"rpe": 7,
"exercises": [
{
"exerciseName": "back_squat",
"category": "strength",
"confidence": 95,
"sets": [
{ "setNumber": 1, "reps": 8, "weight": 80 },
{ "setNumber": 2, "reps": 8, "weight": 80 },
{ "setNumber": 3, "reps": 8, "weight": 80 },
{ "setNumber": 4, "reps": 8, "weight": 80 }
]
},
{
"exerciseName": "easy_run",
"category": "running",
"confidence": 90,
"sets": [
{ "setNumber": 1, "distance": 5000, "time": 28 }
]
}
]
}Response example:
{
"id": "wl_abc123",
"userId": "user_456",
"date": "2025-03-15",
"focus": "Strength and Running",
"mainWorkout": "4x8 back squat at 80kg, then 5km easy run",
"accessory": "3x12 lunges, 3x15 wall balls",
"duration": 75,
"rpe": 7,
"source": "manual",
"createdAt": "2025-03-15T10:30:00.000Z",
"exerciseSets": [
{
"id": "es_001",
"workoutLogId": "wl_abc123",
"exerciseName": "back_squat",
"category": "strength",
"setNumber": 1,
"reps": 8,
"weight": 80,
"distance": null,
"time": null
},
{
"id": "es_002",
"workoutLogId": "wl_abc123",
"exerciseName": "back_squat",
"category": "strength",
"setNumber": 2,
"reps": 8,
"weight": 80,
"distance": null,
"time": null
}
]
}Update an existing workout log.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Body: Partial
UpdateWorkoutLogfields + optionalexercises: ParsedExercise[] - Response: Updated
WorkoutLog
Delete a workout log and its exercise sets.
- Auth: Required
- Rate limit:
workoutcategory, 40/min - Response:
{ success: true }
List workouts that have no parsed exercise sets (candidates for reparsing).
- Auth: Required
- Response:
WorkoutLog[]
Re-parse a single workout's text into structured exercise sets using the configured text AI provider.
- Auth: Required
- Rate limit:
reparsecategory, 5/min - Response:
{ exercises: ParsedExercise[], saved: boolean, setCount?: number }
Re-parse all unstructured workouts for the current user.
- Auth: Required
- Rate limit:
batchReparsecategory, 2/min - Response:
{ total: number, parsed: number, failed: number }
File: server/routes/workouts.ts
List all custom exercises defined by the current user.
- Auth: Required
- Response:
CustomExercise[]
Create or upsert a custom exercise.
- Auth: Required
- Rate limit:
customExercisecategory, 20/min - Body:
{ name: string, category?: string } - Validation:
insertCustomExerciseSchema - Response:
CustomExercise
File: server/routes/plans.ts
List all training plans for the current user.
- Auth: Required
- Response:
TrainingPlan[]
Get a training plan with all its days.
- Auth: Required
- Response:
TrainingPlanWithDays
Import a training plan from CSV content.
- Auth: Required
- Rate limit:
planImportcategory, 5/min - Body:
{ csvContent: string, fileName?: string, planName?: string } - Validation:
importPlanRequestSchema(csvContent max 100,000 chars) - Response:
TrainingPlanWithDays
Create the built-in sample Hyrox training plan.
- Auth: Required
- Rate limit:
planSamplecategory, 5/min - Response:
TrainingPlanWithDays
Generate a custom training plan using the configured text AI provider.
- Auth: Required
- Rate limit:
planGeneratecategory, 3/min - Body:
GeneratePlanInput—{ goal, totalWeeks (1-24), daysPerWeek (2-7), experienceLevel, raceDate?, startDate?, restDays?, focusAreas?, injuries? } - Validation:
generatePlanInputSchema - Response:
TrainingPlanWithDays
Rename a training plan.
- Auth: Required
- Rate limit:
planUpdatecategory, 20/min - Body:
{ name: string }(1-255 chars) - Response: Updated
TrainingPlan
Update a training plan's goal.
- Auth: Required
- Rate limit:
planUpdatecategory, 20/min - Body:
{ goal: string | null }(max 500 chars) - Response: Updated
TrainingPlan
Update a plan day (simple update, no cleanup).
- Auth: Required
- Rate limit:
planDayUpdatecategory, 20/min - Body: Partial
UpdatePlanDay(focus, mainWorkout, accessory, notes, status, scheduledDate) - Response: Updated
PlanDay
Update a plan day with cleanup (unlinks workout logs when changing status away from completed).
- Auth: Required
- Rate limit:
planDayUpdatecategory, 20/min - Body: Partial
UpdatePlanDay - Response: Updated
PlanDay
Update only the status and/or scheduled date of a plan day.
- Auth: Required
- Rate limit:
planDayStatuscategory, 20/min - Body:
{ status?: "planned" | "completed" | "skipped", scheduledDate?: string | null } - Response: Updated
PlanDay
Delete a training plan and all its days (cascade).
- Auth: Required
- Rate limit:
planDeletecategory, 10/min - Response:
{ success: true }
Delete a single plan day.
- Auth: Required
- Rate limit:
planDayDeletecategory, 10/min - Response:
{ success: true }
Schedule a plan by assigning dates to all days starting from a given date.
- Auth: Required
- Rate limit:
planSchedulecategory, 10/min - Body:
{ startDate: "YYYY-MM-DD" } - Response:
{ success: true }
File: server/routes/timelineAnnotations.ts
User-authored bands spanning [startDate, endDate] that annotate injury, illness, travel, or rest periods on the Timeline and as shaded bands on Analytics charts. The DB layer (timeline_annotations table) enforces type IN ('injury','illness','travel','rest') and end_date >= start_date. Per-user ownership is enforced at the storage layer — mismatched IDs silently return 404 to avoid leaking existence.
List all annotations for the authenticated user, ordered by startDate ASC.
- Auth: Required
- Rate limit:
annotationscategory, 60/min - Response:
TimelineAnnotation[]
Create a new annotation.
- Auth: Required
- Rate limit:
annotationscategory, 20/min - Body:
{ startDate: "YYYY-MM-DD", endDate: "YYYY-MM-DD", type: "injury" | "illness" | "travel" | "rest", note?: string }(notemax 500 chars) - Validation:
insertTimelineAnnotationSchema(Zod), with a.refinethatendDate >= startDatewhen both dates are present - Response:
201 TimelineAnnotation
Partially update an annotation. The handler fetches the existing row, merges the partial over it, and re-checks the date bounds before writing so a single-field PATCH cannot slip an invalid range past Zod.
- Auth: Required
- Rate limit:
annotationscategory, 20/min - Body: Partial
{ startDate?, endDate?, type?, note? } - Validation:
updateTimelineAnnotationSchema - Response:
TimelineAnnotation(or 404 when the id doesn't belong to the user)
Delete an annotation.
- Auth: Required
- Rate limit:
annotationscategory, 20/min - Response:
{ success: true }(or 404 when the id doesn't belong to the user)
File: server/routes/analytics.ts
All analytics endpoints support optional date filtering via query parameters: ?from=YYYY-MM-DD&to=YYYY-MM-DD.
Coalesced request cache. Exercise sets and workout logs used by these routes pass through two in-memory promise caches (getExerciseSetsCoalesced and getWorkoutLogsCoalesced) keyed by userId + from + to. The cache holds the pending promise, so three concurrent requests for the same user/window trigger a single database query. Parameters:
| Knob | Value | Source |
|---|---|---|
| TTL | 5 minutes (ANALYTICS_CACHE_TTL_MS) |
server/constants.ts |
| Max entries per cache | 500 (MAX_CACHE_SIZE) |
server/routes/analytics.ts |
| Eviction | Expired entries first, then oldest-by-timestamp once over the size cap | evictStale() |
| Failure behavior | The rejected promise is evicted so the next caller retries immediately | .catch in getExerciseSetsCoalesced / getWorkoutLogsCoalesced |
Calculate personal records across all exercises.
- Auth: Required
- Rate limit:
analyticscategory, 20/min - Query:
from?,to? - Response:
PersonalRecord[]— max weight, max distance, best time per exercise category
Calculate per-exercise analytics (volume, intensity trends).
- Auth: Required
- Rate limit:
analyticscategory, 20/min - Query:
from?,to? - Response: Exercise analytics breakdown
Calculate weekly training summaries, category totals, station coverage, and week-over-week deltas.
-
Auth: Required
-
Rate limit:
analyticscategory, 20/min -
Query:
from?,to? -
Response shape:
{ weeklySummaries: WeeklySummary[], workoutDates: string[], categoryTotals: { /* per-category totals */ }, stationCoverage: { /* Hyrox station coverage */ }, currentStats: { totalWorkouts: number, avgPerWeek: number, totalDuration: number, avgDuration: number, avgRpe: number | null, }, // Omitted when no meaningful previous window exists — e.g. the user // picked "all time" so `from` is absent. previousStats?: { totalWorkouts: number, avgPerWeek: number, totalDuration: number, avgDuration: number, avgRpe: number | null, }, }
-
Previous-window derivation (
computePreviousWindow): The previous period is the equal-length, non-overlapping range ending the day beforefrom. Iftois omitted, the current window's upper bound is pinned to midnight UTC of today (not wall-clocknow) so the previous window doesn't drift across the day. Returnsnullwhenfromis absent, and the route responds withoutpreviousStats. -
The client's
DeltaIndicatorcomponent renders the percentage change betweencurrentStatsandpreviousStatsfor each of the four stat cards.
File: server/routes/ai.ts
Parse free-text or voice input into structured exercise data using the configured text AI provider.
- Auth: Required
- Rate limit:
parsecategory, 5/min - Body:
{ text: string }(1-2000 chars) - Validation:
parseExercisesRequestSchema - Response:
ParsedExercise[]with confidence scores and category classification
Request example:
{
"text": "3 sets bench 225lbs x 8, then 3 miles in 24 min"
}Response example:
[
{
"exerciseName": "bench_press",
"category": "strength",
"confidence": 95,
"missingFields": [],
"sets": [
{ "setNumber": 1, "reps": 8, "weight": 225 },
{ "setNumber": 2, "reps": 8, "weight": 225 },
{ "setNumber": 3, "reps": 8, "weight": 225 }
]
},
{
"exerciseName": "easy_run",
"category": "running",
"confidence": 80,
"missingFields": [],
"sets": [
{ "setNumber": 1, "distance": 4828, "time": 24 }
]
}
]Parse a photo of a workout plan (whiteboard, printout, screenshot) into structured exercise data using Gemini's multi-modal vision model. Images are expected to be compressed client-side via client/src/lib/image.ts (compressImage) before upload.
- Auth: Required
- Rate limit:
parsecategory, shared budget with text parsing - Body:
{ imageBase64: string, mimeType: "image/jpeg" | "image/png" | "image/webp" } - Response: Same
ParsedExercise[]shape as/api/v1/parse-exercises.
Re-parse an existing workout's exercises against a newly-uploaded photo. Used from the workout detail dialog when the coach updates the prescribed block or when the athlete captures the post-session whiteboard.
- Auth: Required (user must own the workout)
- Rate limit:
parsecategory - Body:
{ imageBase64, mimeType }(same as above) - Response: The updated workout payload including re-parsed exercises.
Send a message to the AI coach and receive a complete response.
- Auth: Required
- Rate limit:
chatcategory, 10/min - Body:
{ message: string (1-1000 chars), history?: ChatMessage[] (max 20) } - Validation:
chatRequestSchema - Response:
{ response: string, ragInfo: RagInfo }
Send a message to the AI coach and receive a streaming response via Server-Sent Events.
- Auth: Required
- Rate limit:
chatcategory, 10/min - Body: Same as
/api/v1/chat - Response headers:
Content-Type: text/event-stream,Cache-Control: no-cache - SSE events:
{ ragInfo: RagInfo }— First event with RAG metadata{ text: string }— Streaming text chunks{ done: true }— Stream complete{ error: string }— Stream error
Request example:
{
"message": "How should I pace my sled push at competition?",
"history": [
{ "role": "user", "content": "I have a Hyrox race in 6 weeks" },
{ "role": "assistant", "content": "Great! Let me help you prepare..." }
]
}SSE response sequence:
data: {"ragInfo":{"source":"rag","chunkCount":3,"materialCount":2}}
data: {"text":"For the sled push, "}
data: {"text":"I recommend breaking it into "}
data: {"text":"three phases: an aggressive start, steady middle, and controlled finish."}
data: {"done":true}
If an error occurs mid-stream:
data: {"error":"Stream error"}
Retrieve all saved chat messages for the current user.
- Auth: Required
- Response:
ChatMessage[]
Save a chat message to history.
- Auth: Required
- Rate limit:
chatMessagecategory, 20/min - Body:
{ userId: string, role: "user" | "assistant", content: string (1-50000 chars) } - Validation:
insertChatMessageSchema - Response: Saved
ChatMessage
Clear all chat messages for the current user.
- Auth: Required
- Rate limit:
chatHistoryDeletecategory, 5/min - Response:
{ success: true }
Generate AI coaching suggestions for upcoming planned workouts.
- Auth: Required
- Rate limit:
suggestionscategory, 3/min - Response:
{ suggestions: WorkoutSuggestion[], ragInfo: RagInfo } - Note: Returns empty suggestions if no upcoming planned workouts exist.
File: server/routes/coaching.ts
List all coaching materials for the current user.
- Auth: Required
- Response:
CoachingMaterial[]
Create a new coaching material. Triggers background embedding via pg-boss queue.
- Auth: Required
- Rate limit:
coachingcategory, 10/min - Body limit: 2MB (elevated from default 100kb)
- Body:
{ title: string (1-255 chars), content: string (1-1,500,000 chars), type: "principles" | "document" } - Validation:
insertCoachingMaterialSchema - Side effects: Queues
embed-coaching-materialjob for RAG chunking/embedding. - Response:
201CreatedCoachingMaterial
Update a coaching material. Re-embeds if content or title changed.
- Auth: Required
- Rate limit:
coachingcategory, 10/min - Body: Partial
{ title?, content?, type? } - Side effects: Queues re-embedding if content or title changed.
- Response: Updated
CoachingMaterial
Delete a coaching material. Document chunks are cascade-deleted via FK.
- Auth: Required
- Rate limit:
coachingcategory, 10/min - Response:
{ success: true }
Check the RAG pipeline status (embedding counts, dimension info).
- Auth: Required
- Response: RAG status object
Re-embed all coaching materials for the current user.
- Auth: Required
- Rate limit:
coachingcategory, 5/min - Response: Re-embed result summary
File: server/routes/preferences.ts
Get the current user's preferences.
- Auth: Required
- Response:
{ weightUnit, distanceUnit, weeklyGoal, emailNotifications, emailWeeklySummary, emailMissedReminder, aiCoachEnabled }
Update user preferences.
- Auth: Required
- Rate limit:
preferencescategory, 20/min - Body: Partial
{ weightUnit?: "kg" | "lbs", distanceUnit?: "km" | "miles", weeklyGoal?: 1-14, emailNotifications?: boolean, emailWeeklySummary?: boolean, emailMissedReminder?: boolean, aiCoachEnabled?: boolean } - Validation:
updateUserPreferencesSchema - Response: Updated preferences object
- Email toggle semantics:
emailNotificationsis the master switch — whenfalse, no email is sent regardless of the per-type flags.emailWeeklySummaryandemailMissedRemindergate the individual categories and take effect only when the master is on. All three default tofalseat the database level for new users (GDPR-compliant opt-in). - AI consent semantics:
aiCoachEnabledgates every outbound AI provider call (workout parsing, chat, auto-coach, embeddings, and image parsing). It defaults tofalsefor new users; the AI features are hidden or disabled in the UI until the user explicitly opts in. Flipping it tofalseimmediately stops new AI requests; already-persisted chat history and plan AI artifacts remain until the user deletes them.
File: server/routes/email.ts
Manually trigger email checks for the current user (weekly summary, missed reminders).
- Auth: Required (Clerk JWT)
- Rate limit:
emailCheckcategory, 5/min - Response:
{ sent: string[] }— list of email types sent
External cron trigger endpoint for batch email processing across all users.
- Auth:
x-cron-secretheader (timing-safe comparison withCRON_SECRETenv var) - No Clerk auth required
- Response: Cron job result summary
File: server/routes/push.ts
Web Push (VAPID) endpoints used by the PWA to deliver missed-workout nudges and weekly summary notifications. All endpoints return 404 PUSH_NOT_CONFIGURED when VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY are not set.
Return the server's VAPID public key so the client can call PushManager.subscribe.
- Auth: Required
- Response:
{ publicKey: string }
Persist a PushSubscription for the authenticated user. Multiple endpoints per user are allowed (one per device).
- Auth: Required
- Rate limit:
pushcategory, 10/min - Body:
{ endpoint: string, keys: { p256dh: string, auth: string } } - Response:
{ success: true }
Remove a specific subscription endpoint for the authenticated user.
- Auth: Required
- Rate limit:
pushcategory, 10/min - Body:
{ endpoint: string } - Response:
{ success: true }
Dispatch a test notification to every registered subscription for the authenticated user. Used by the Settings UI to verify the PWA install is wired up correctly.
- Auth: Required
- Rate limit:
pushcategory, 5/min - Response:
{ success: true, sent: number }
File: server/strava.ts
Check if the current user has a Strava connection.
- Auth: Required
- Response:
{ connected: boolean, lastSyncedAt?: string }
Generate a Strava OAuth authorization URL with CSRF-protected signed state.
- Auth: Required
- Rate limit: IP-based, 20 per 15 minutes
- Response:
{ url: string }— Redirect URL for Strava OAuth - State parameter: HMAC-SHA256 signed with
userId:timestamp:nonce:signature, max age enforced
OAuth callback handler. Exchanges authorization code for tokens, encrypts and stores them.
- Auth: Not required (redirect from Strava)
- Rate limit: IP-based, 20 per 15 minutes
- Query:
code,state(CSRF-verified),scope - Side effects: Creates
stravaConnectionsrecord with AES-256-GCM encrypted tokens - Response: Redirect to
/settings
Sync recent Strava activities into workout logs.
- Auth: Required
- Rate limit: IP-based, 5 per 15 minutes
- Side effects: Fetches activities from Strava API, maps to WorkoutLog format, deduplicates by
stravaActivityId, auto-refreshes expired tokens. - Response:
{ imported: number }or{ message: string }
Disconnect the Strava integration.
- Auth: Required
- Response:
{ success: true }
File: server/garmin.ts
Garmin Connect sync uses a reverse-engineered SSO flow (email + password), not a public OAuth application. See Integrations → Garmin Connect for the rationale, safety stack, and storage model.
All mutating routes apply protectedMutationGuards (auth + CSRF + idempotency). Every route short-circuits with HTTP 503 GARMIN_CIRCUIT_OPEN when the global 429 circuit breaker is tripped.
Returns the Garmin connection state for the authenticated user.
- Auth: Required
- Response:
{ connected: false }or{ connected: true, garminDisplayName: string | null, lastSyncedAt: string | null, lastError: string | null }
Authenticate with Garmin using email + password and persist the encrypted credentials / OAuth tokens.
- Auth: Required
- Rate limit:
garmin-connectcategory, 5 per 15-minute window per user - Body:
{ email: string (valid email, max 254), password: string (1-256) } - Behavior: Logs into Garmin before writing any DB row — nothing is stored on failure. Fetches
getUserProfile()to capture the display name (optional; non-fatal if it fails). - Responses:
200 { success: true, garminDisplayName: string | null }400 { code: "BAD_REQUEST" }— invalid email / empty password401 { code: "GARMIN_AUTH_FAILED" }— invalid credentials or 2SV enabled (see error translation inserver/garmin.ts)409 { code: "GARMIN_BUSY" }— another Garmin op for the same user is in progress (per-user mutex)503 { code: "GARMIN_CIRCUIT_OPEN" }— global 429 breaker is tripped
Removes the garmin_connections row for the user (credentials, tokens, display name).
- Auth: Required
- Response:
{ success: true }
Imports the most recent activities from Garmin into workout_logs.
- Auth: Required
- Rate limit:
garmin-synccategory, 5 per 15-minute window per user - Preflight rejections (checked before login):
404 { code: "GARMIN_NOT_CONNECTED" }429 { code: "GARMIN_SYNC_TOO_SOON" }— less than 5 minutes sincelastSyncedAt401 { code: "GARMIN_RECONNECT_REQUIRED" }— priorlastErroris set; user must disconnect + reconnect503 { code: "GARMIN_CIRCUIT_OPEN" }— global 429 breaker tripped
- Behavior: Calls
client.getActivities(0, 20), dedupes against the partial unique index(user_id, garmin_activity_id) WHERE garmin_activity_id IS NOT NULL, and inserts the new rows viaonConflictDoNothing. - Success response:
{ success: true, imported: number, skipped: number, total: number }—importedis the true insert count; anything caught by the partial index is rolled intoskipped. - Error responses:
401 GARMIN_AUTH_FAILED,502 GARMIN_API_ERROR(withlastErrorpersisted),409 GARMIN_BUSY.
File: server/routes/workouts.ts
Get merged timeline of planned and logged workouts.
- Auth: Required
- Query:
planId?(filter by plan),limit?(default capped),offset? - Response:
TimelineEntry[]— merged planned + logged workouts sorted by date
Response example:
[
{
"id": "pd_101",
"date": "2025-03-17",
"type": "planned",
"status": "planned",
"focus": "Sled Push + SkiErg",
"mainWorkout": "4x50m sled push at 100kg, 3x500m SkiErg",
"accessory": "3x15 wall balls",
"notes": null,
"planDayId": "pd_101",
"workoutLogId": null,
"weekNumber": 3,
"dayName": "Monday",
"planName": "Hyrox 8-Week Prep",
"planId": "plan_abc"
},
{
"id": "wl_202",
"date": "2025-03-16",
"type": "logged",
"status": "completed",
"focus": "Easy Run",
"mainWorkout": "5km easy run",
"accessory": null,
"notes": "Felt good, kept HR under 145",
"duration": 30,
"rpe": 4,
"planDayId": null,
"workoutLogId": "wl_202",
"source": "strava",
"exerciseSets": [
{
"id": "es_501",
"workoutLogId": "wl_202",
"exerciseName": "easy_run",
"category": "running",
"setNumber": 1,
"distance": 5000,
"time": 30,
"reps": null,
"weight": null
}
],
"calories": 320,
"distanceMeters": 5000,
"avgHeartrate": 142,
"maxHeartrate": 155
}
]Get historical exercise sets for a specific exercise.
- Auth: Required
- Response: Exercise set history with dates
Export all training data as CSV or JSON.
- Auth: Required
- Rate limit:
exportcategory, 5/min - Query:
format—"csv"(default) or"json" - Response: File download with appropriate Content-Type and Content-Disposition headers
- AI and RAG -- Architecture of the RAG pipeline, embedding strategy, and coaching material processing.
- Authentication -- Clerk JWT setup, middleware configuration, and session management.