Fitrace · Architecture

How Fitrace is built

A fitness-tracking app for iOS and Android, built with Expo and React Native, talking to a TypeScript serverless backend on AWS. Sign-in is handled by Clerk. AI features (chat and scanning a program from a photo) call OpenAI from the server only.

Expo SDK 57 React Native 0.86 · React 19 Clerk API Gateway HTTP API Lambda · Node 24 · arm64 DynamoDB single table S3 (private) AWS CDK v2 ap-southeast-2 (Sydney)

Overview

The phone does the work a person sees: logging sets, timers, plans, charts. It keeps a workout in progress on the device so logging never waits for the network. Everything that must be trusted or kept (accounts, plans, workout history, photos, AI calls) goes through one HTTPS API, where every request is checked against the signed-in user before anything is read or written.

AWS services

Everything is defined in AWS CDK v2 (TypeScript) in backend/infra; nothing is set up by hand. Region: ap-southeast-2 (Sydney), apart from this documentation site's certificate, which CloudFront needs in us-east-1.

ServiceWhat Fitrace uses it forHow it's set up
API Gateway (HTTP API)The one public API the app callsClerk JWT authorizer as the default on every route (no public routes); CORS; $default stage with throttling (dev 20 req/s, burst 40; prod 50, burst 100); 30-second integration limit
AWS LambdaAll server logic: one function per route (34)Node.js 24, arm64 (Graviton), ESM bundles built by esbuild via NodejsFunction; 512 MB, 10 s by default (AI routes 29 s, uploads 20 s); its own log group each
Amazon DynamoDBAll app dataSingle table Fitrace-<stage>, on-demand billing, PK/SK, TTL on expiresAt, point-in-time recovery (35 days); prod also has deletion protection and is retained if the stack is removed. A stream (old images) is enabled but has no consumer yet
Amazon S3Photos: exercise catalogue images and users' own photosOne private media bucket: all public access blocked, S3-managed encryption, HTTPS only; files reach the phone only through signed links that expire
AWS Secrets ManagerThird-party keys the server needsfitrace/<stage>/openai-api-key and fitrace/<stage>/clerk-secret-key; read at runtime and cached in memory; only the functions that need them can read them
AWS Certificate ManagerHTTPS for the custom hostnamesDNS-validated certificates for the API domain (regional) and this docs site (us-east-1, for CloudFront)
Amazon Route 53DNSAlias records in the shared pyutasane.com zone: api.dev.fitrace, api.fitrace and docs.fitrace
Amazon CloudWatch LogsFunction logsStructured JSON logs (Powertools Logger); kept 1 week in dev, 3 months in prod. Error names only: no tokens, links or user content
AWS X-RayTracingActive tracing on every function
AWS IAMPermissionsA role per function, scoped to the table and to the bucket prefixes it needs (e.g. read catalog/*, or get/put/delete under users/*)
AWS CloudFormation / CDKDeploymentsStacks Fitrace-Data-<stage> and Fitrace-Api-<stage>, plus Fitrace-Docs for this page
Amazon CloudFrontServes this documentation pageA private S3 bucket behind CloudFront (Origin Access Control), HTTPS only, at docs.fitrace.pyutasane.com

Outside AWS: Clerk (sign-in and tokens), OpenAI (AI features), Expo Application Services (app builds and TestFlight submission), Apple and Google (Sign in with Apple, Google sign-in).

Mobile app

Expo SDK 57 development builds (not Expo Go, so native Sign in with Apple and the Live Activity work), TypeScript strict, Expo Router with typed routes, React 19 with the React Compiler.

Structure

  • app/: routes only, kept thin. Groups for signed out (auth), onboarding (onboarding) and the signed-in app (app), guarded with Stack.Protected.
  • src/features/<feature>/: screens, hooks and pure logic with tests (workout, plans, scan, chat, progress, body, exercises, today, settings…).
  • src/components/: shared UI, one component per file.
  • src/theme/: every colour, spacing, radius and text style. Monochrome, light and dark, Geist, tabular numbers.
  • src/lib/api/: the useApi client, ApiError and the response types.

Navigation

  • A floating bottom tab bar: Today · Train · Progress.
  • The FitraceAI chat button sits over every tab; the chat opens as a sheet over the app.
  • Full-screen stacks for the live workout logger, Scan a program, exercise library, plan editing, settings and photos.
  • First-run tour of the tabs and shortcuts, replayable from an info button.

Data on the phone

  • TanStack Query for all server data (plans, history, catalogue, photos). No ad hoc fetches in screens.
  • The workout in progress lives in an on-device store (AsyncStorage). Logging, finishing and discarding work offline; an outbox syncs to the API in order (discards, finished workouts, then the live one) when the network is back.
  • The chat thread is kept on the phone and sent with each message; the server doesn't store it.
  • Exercise photos are cached by exercise (memory and disk) so lists show them at once.

Native features

  • Sign in: @clerk/expo with the session token in the Keychain/Keystore (expo-secure-store); native Sign in with Apple, Google OAuth, email code.
  • Live Activity (iOS): the current exercise and rest timer on the Lock Screen and Dynamic Island (expo-widgets, @expo/ui).
  • Rest timer: local notifications only (expo-notifications); no push server.
  • Photos: camera and library (expo-image-picker), shrunk on the phone to 1,600px JPEG plus a 480px thumbnail (expo-image-manipulator) before upload.
  • Haptics, Reanimated and Gesture Handler for the logger, weight ruler and sheets; react-native-body-highlighter for muscle maps.

No analytics or crash-reporting SDKs. Only EXPO_PUBLIC_* values (the API URL and Clerk's publishable key) are built into the app, and those are public by design.

A request, end to end

  1. The user signs in with Clerk. The session token is kept in the device's secure storage.
  2. For each call, useApi() asks Clerk for a fresh JWT from the api template: audience fitrace-api, a 60-second lifetime, signed by Clerk.
  3. API Gateway's JWT authorizer checks the signature against Clerk's published keys, plus the issuer, audience and expiry. Anything wrong is a 401, and no Lambda runs.
  4. The route's Lambda takes the user id only from the verified sub claim, never from the body or the URL. It validates the body with a strict zod schema (unknown keys such as userId are refused).
  5. It reads or writes only that user's partition in DynamoDB (USER#<sub>), or their own folder in S3 (users/<sub>/).
  6. Errors come back in one shape: { "error": { "code", "message" } }. The app maps them to plain messages, including offline and limit states.

API

34 routes, all behind the Clerk JWT authorizer. Code lives in backend/services/<domain>/ as handlers, commands (writes) and queries (reads); each route has its own Lambda.

DomainRoutesNotes
AccountGET /me · PATCH /me · DELETE /meProfile, onboarding answers and settings (created on first call). Delete removes every item in the user's partition, every file under their folder and their Clerk login
Exercise catalogueGET /exercises · GET /exercises/{id} · GET /exercises/thumbnails876 exercises from free-exercise-db, imported by a script; signed photo links
Your exercisesGET · POST /custom-exercises · PATCH · DELETE /custom-exercises/{id} · PUT · DELETE /custom-exercises/{id}/photoExercises a user makes (e.g. from a scan), private to them, with an optional photo
PlansGET · POST /plans · GET · PUT · DELETE /plans/{id}Up to 20 plans of 1–7 days in rotation; days carry sections, sets, rest, notes and sides
WorkoutsPOST /workouts · GET /workouts · GET /workouts/active · GET · PUT · DELETE /workouts/{id} · POST /workouts/{id}/finishIds made on the phone (UUIDv7) so a workout can start offline; revisions guard against stale saves; finishing records stats and each exercise's last performance
ProgressGET /progress · GET /exercise-historyReduced history for charts, streaks and muscle breakdown; per-exercise history
BodyGET /body · PUT /body/{date}Body measurements by day
Progress photosGET · POST /progress-photos · DELETE /progress-photos/{id}JPEG only, private, signed links
AIPOST /chat · POST /scan · POST /scan/fixTraining chat, and reading a program from a photo, with daily limits

Shared code in backend/services/common: auth (the verified sub), the HTTP wrapper and errors, DynamoDB and S3 clients, key builders, the OpenAI client, AI quotas and secrets.

Data

One DynamoDB table with a generic PK / SK. Everything a user owns sits in their own partition, so a single query reads it and nothing can reach across users by accident. Every key is built in one place (common/keys.ts).

ItemPKSKNotes
ProfileUSER#<sub>PROFILEName, units, onboarding, current plan, default rest
PlanUSER#<sub>PLAN#<uuid>Days, exercises, sets in one item; when each day was last done
WorkoutUSER#<sub>WORKOUT#<uuidv7>Sorted by start time; in progress or completed, with stats
Active workoutUSER#<sub>ACTIVE_WORKOUTPoints at the one workout in progress
Last performanceUSER#<sub>LAST#<exerciseId>Last sets and best set, for "Last: 60 kg × 8" and suggestions
Body entryUSER#<sub>BODY#<metric>#<day>Weight, body fat, waist…
Progress photoUSER#<sub>PHOTO#<uuid>Details; the image is in S3
Custom exerciseUSER#<sub>CUSTOM_EXERCISE#custom-<uuid>Up to 300; deleting hides it so history keeps its name
AI quotaUSER#<sub>QUOTA#<CHAT|SCAN>#<day>Atomic daily counter, removed by TTL after two days
Catalogue exerciseCATALOGEXERCISE#<id>Shared and read-only to the API
Catalogue versionCATALOGMETAWhich import is live

Recovery: point-in-time recovery on both stages. In prod the table also has deletion protection and is kept if its stack is deleted. The catalogue can be rebuilt any time with the import script; user data only exists in the table.

Photos and media

Prefix in the media bucketWhatWho can touch it
catalog/exercises/<id>/Exercise photos as WebP (720px and 240px thumbnails)Written by the import script; API functions can only read
users/<sub>/progress-photos/Progress photos (JPEG full size and thumbnail)Only that user's requests; upload, list and delete functions
users/<sub>/custom-exercises/<id>/Photos of a user's own exercises (a new key for each upload)Only that user's requests; deleted with the exercise
  • The bucket is private. The phone gets signed GET links (about an hour) and refreshes them before they expire. A link without its signature is refused.
  • Photos are shrunk on the phone before upload, so the server never decodes images; it only checks the file is a JPEG.
  • Photos scanned for a program are sent once to read the plan and never stored.
  • Deleting the account deletes everything under users/<sub>/.

AI features

OpenAI is called only from Lambda, through the Responses API with plain fetch and store: false. The key is in Secrets Manager and never reaches the phone. Every paid call first takes one from the user's daily allowance in DynamoDB, atomically.

FitraceAI chat POST /chat

  • A small guard model first decides whether the message is about training; off-topic questions get a short refusal.
  • The answer model gets the user's training context, read from their own partition only: profile, plans, recent workouts and exercise names.
  • 50 messages per user per UTC day. Answers are text only; the chat can't change data.

Scan a program POST /scan · /scan/fix

  • A photo of a board, sheet or screenshot is read by a vision model into a strict JSON routine (days, sections, exercises, targets).
  • The server clamps every number to plan limits, merges repeats, and matches names to the catalogue (name → alias → same words → careful fuzzy match). Anything unmatched becomes the user's own exercise, created only when they save or start.
  • "Ask to fix…" applies typed corrections to the routine. 10 scans a day; fixes count against the chat limit.

Security

Who you are

  • Authorisation always happens on the server, from the verified sub claim. The app never sends a user id the server trusts.
  • Every route requires a valid Clerk token with the fitrace-api audience; tokens last 60 seconds.
  • Strict request schemas refuse unknown fields such as userId or planId.

What a function can do

  • One IAM role per function: read-only routes can only read the table; photo routes only touch users/*, and only the actions they need. Infrastructure tests check this.
  • Custom exercise ids start with custom- and must belong to the caller before a plan or workout can use them.
  • API throttling per stage; daily limits on paid AI calls.

Secrets and logs

  • Keys in Secrets Manager, set once by hand with the CLI (never in code, templates or the app). .env files are never committed.
  • Logs carry error names, never tokens, keys, signed links or user content.

Data at rest and in transit

  • HTTPS everywhere (the bucket refuses plain HTTP).
  • S3 and DynamoDB encrypted at rest; the bucket blocks all public access.
  • Account deletion removes the user's items, files and Clerk login.

Environments and delivery

StageAPIStacksData safety
devhttps://api.dev.fitrace.pyutasane.comFitrace-Data-dev, Fitrace-Api-devPITR; can be torn down and rebuilt
prodhttps://api.fitrace.pyutasane.comFitrace-Data-prod, Fitrace-Api-prodPITR, deletion protection, retained on stack removal; waits for Clerk's production instance

Repository

  • apps/mobile: the Expo app.
  • backend/services: Lambda code by domain.
  • backend/infra: the CDK app (stacks, route constructs, Lambda defaults).
  • docs/: the build spec (the source of truth for screens, copy, routes and data) and this page.
  • scripts/: smoke test, Clerk setup, brand assets.

Checks and releases

  • npm run check: typecheck, lint and tests in all three packages (Jest with jest-expo for the app, ts-jest for backend and infra, CDK assertions).
  • npm run deploy: CDK deploy, only when asked. Then npm run smoke, an end-to-end test against the deployed API with a temporary Clerk test user.
  • App builds and TestFlight submission through Expo Application Services (EAS): development, preview and production profiles.
  • Work happens on branches, merged to main after review and a passing smoke test.