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.
Fitrace app (iOS / Android)
Expo + React Native. React Query for server data, an on-device store for the workout being logged, Live Activity on iOS.
Clerk
Sign in with Apple, Google or an email code. Issues a 60-second JWT (template api, audience fitrace-api) for each API call.
Route 53 + ACM
api.dev.fitrace.pyutasane.com (dev), api.fitrace.pyutasane.com (prod), TLS certificate validated by DNS.
API Gateway HTTP API
Clerk JWT authorizer on every route (issuer, audience, expiry). Rejects bad tokens before any code runs. Stage throttling.
AWS Lambda × 34
Node 24 on arm64, ESM bundles. Each function has least-privilege access to only what its route needs.
DynamoDB
One table, a partition per user, the shared exercise catalogue, AI usage counters.
S3
Private bucket: catalogue photos, progress photos, custom exercise photos. Signed links only.
Secrets Manager
OpenAI API key, Clerk secret key (for account deletion).
OpenAI Responses API
Called from Lambda only, store: false. Chat and program scanning.
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.
| Service | What Fitrace uses it for | How it's set up |
|---|---|---|
| API Gateway (HTTP API) | The one public API the app calls | Clerk 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 Lambda | All 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 DynamoDB | All app data | Single 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 S3 | Photos: exercise catalogue images and users' own photos | One private media bucket: all public access blocked, S3-managed encryption, HTTPS only; files reach the phone only through signed links that expire |
| AWS Secrets Manager | Third-party keys the server needs | fitrace/<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 Manager | HTTPS for the custom hostnames | DNS-validated certificates for the API domain (regional) and this docs site (us-east-1, for CloudFront) |
| Amazon Route 53 | DNS | Alias records in the shared pyutasane.com zone: api.dev.fitrace, api.fitrace and docs.fitrace |
| Amazon CloudWatch Logs | Function logs | Structured JSON logs (Powertools Logger); kept 1 week in dev, 3 months in prod. Error names only: no tokens, links or user content |
| AWS X-Ray | Tracing | Active tracing on every function |
| AWS IAM | Permissions | A 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 / CDK | Deployments | Stacks Fitrace-Data-<stage> and Fitrace-Api-<stage>, plus Fitrace-Docs for this page |
| Amazon CloudFront | Serves this documentation page | A 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 withStack.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/: theuseApiclient,ApiErrorand 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/expowith 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-highlighterfor 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
- The user signs in with Clerk. The session token is kept in the device's secure storage.
- For each call,
useApi()asks Clerk for a fresh JWT from theapitemplate: audiencefitrace-api, a 60-second lifetime, signed by Clerk. - 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. - The route's Lambda takes the user id only from the verified
subclaim, never from the body or the URL. It validates the body with a strict zod schema (unknown keys such asuserIdare refused). - It reads or writes only that user's partition in DynamoDB (
USER#<sub>), or their own folder in S3 (users/<sub>/). - 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.
| Domain | Routes | Notes |
|---|---|---|
| Account | GET /me · PATCH /me · DELETE /me | Profile, 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 catalogue | GET /exercises · GET /exercises/{id} · GET /exercises/thumbnails | 876 exercises from free-exercise-db, imported by a script; signed photo links |
| Your exercises | GET · POST /custom-exercises · PATCH · DELETE /custom-exercises/{id} · PUT · DELETE /custom-exercises/{id}/photo | Exercises a user makes (e.g. from a scan), private to them, with an optional photo |
| Plans | GET · 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 |
| Workouts | POST /workouts · GET /workouts · GET /workouts/active · GET · PUT · DELETE /workouts/{id} · POST /workouts/{id}/finish | Ids 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 |
| Progress | GET /progress · GET /exercise-history | Reduced history for charts, streaks and muscle breakdown; per-exercise history |
| Body | GET /body · PUT /body/{date} | Body measurements by day |
| Progress photos | GET · POST /progress-photos · DELETE /progress-photos/{id} | JPEG only, private, signed links |
| AI | POST /chat · POST /scan · POST /scan/fix | Training 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).
| Item | PK | SK | Notes |
|---|---|---|---|
| Profile | USER#<sub> | PROFILE | Name, units, onboarding, current plan, default rest |
| Plan | USER#<sub> | PLAN#<uuid> | Days, exercises, sets in one item; when each day was last done |
| Workout | USER#<sub> | WORKOUT#<uuidv7> | Sorted by start time; in progress or completed, with stats |
| Active workout | USER#<sub> | ACTIVE_WORKOUT | Points at the one workout in progress |
| Last performance | USER#<sub> | LAST#<exerciseId> | Last sets and best set, for "Last: 60 kg × 8" and suggestions |
| Body entry | USER#<sub> | BODY#<metric>#<day> | Weight, body fat, waist… |
| Progress photo | USER#<sub> | PHOTO#<uuid> | Details; the image is in S3 |
| Custom exercise | USER#<sub> | CUSTOM_EXERCISE#custom-<uuid> | Up to 300; deleting hides it so history keeps its name |
| AI quota | USER#<sub> | QUOTA#<CHAT|SCAN>#<day> | Atomic daily counter, removed by TTL after two days |
| Catalogue exercise | CATALOG | EXERCISE#<id> | Shared and read-only to the API |
| Catalogue version | CATALOG | META | Which 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 bucket | What | Who 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
subclaim. The app never sends a user id the server trusts. - Every route requires a valid Clerk token with the
fitrace-apiaudience; tokens last 60 seconds. - Strict request schemas refuse unknown fields such as
userIdorplanId.
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).
.envfiles 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
| Stage | API | Stacks | Data safety |
|---|---|---|---|
| dev | https://api.dev.fitrace.pyutasane.com | Fitrace-Data-dev, Fitrace-Api-dev | PITR; can be torn down and rebuilt |
| prod | https://api.fitrace.pyutasane.com | Fitrace-Data-prod, Fitrace-Api-prod | PITR, 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. Thennpm 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
mainafter review and a passing smoke test.