Backend¶
How the API in backend/ works and why: endpoints, authentication, backup,
imports, tutorials, MCP, and the container image. For contributors; read the
domain model first for the terms used here.
Stack: FastAPI, async SQLAlchemy, pydantic, Postgres. Code is under
backend/src/dinatos_backend/ (models/, schemas/, services/,
api/routers/).
API surface¶
Except GET /health, /auth/* and the calendar feed, every endpoint needs
Authorization: Bearer <token> and only sees the caller’s own data. The
token is a login session token from /auth/login or an API key (see
Authentication and API keys).
Endpoint |
Notes |
|---|---|
|
See Authentication. |
|
CRUD plus |
|
GIF plus instructions. See Exercise tutorials. |
|
The caller’s all-time best weight and reps. Warm-up sets never count. |
|
The caller’s past sessions of it, newest first, one entry per activity. |
|
Templates, owner-scoped. Nested exercises and sets are created and replaced as a whole ( |
|
Performed sessions, owner-scoped, same nested shape, plus |
|
One row per user ( |
|
Dated body measurements, owner-scoped. |
|
The training calendar. |
|
Manage the calendar feed secret. See Calendar feed. |
|
See API keys. |
|
|
|
Admin only: users, backup, restore. |
|
Per-user data. See Backup and data export. |
|
MCP server. See MCP server. |
Details worth knowing:
Exercises are global, but records and history are per caller. The app derives “last time”, suggestions, personal records and the progress chart from them.
An activity may reference one of the caller’s own routines. Referencing someone else’s is a 404, the same as reading it directly.
Measurements are one flat row per entry with every value nullable (weigh-in totals, segmental analysis, tape circumferences). One weigh-in is one dated entry, and the backup is generic over columns. The grouping is only in the UI.
A planned workout is not an activity (activities feed stats). To start a workout from a plan, the client sends
planned_workout_idwithPOST /activities; the server sets the plan’scompleted_activity_id. An unknown id is ignored rather than failing the save.Routine and activity exercises carry a
superset_group. The client keeps groups coherent, see Frontend.
Retried saves¶
A client on a flaky connection cannot tell “the request never arrived” from
“it was stored but the response was lost”, so it retries. To avoid storing
the workout twice, POST /activities treats a request from the same caller
with the same title, start and end as a replay of the first save. It returns
the existing activity with 200 instead of 201.
No idempotency-key column is needed: a live workout’s start time is a full-precision device timestamp, so different workouts do not collide. Because the title is part of the key, the app freezes the title once an attempt may have gone through, see Frontend.
API documentation¶
FastAPI serves the generated schema at /docs (Swagger UI), /redoc and
/openapi.json. The routers and schemas are the source of truth, so it
cannot drift. main.py only adds a description and openapi_tags.
The app shows the same page (Profile -> “API documentation”). On web it is an
iframe of <server>/docs. Elsewhere, or if the browser refuses the frame, it
shows the URLs with a copy button. A browser refuses the frame when an HTTPS
page frames an HTTP backend (mixed content).
Gotchas:
The app reaches this screen with
context.go, notcontext.push.pushleaves the address bar on#/profile, so the link cannot be copied, bookmarked or reloaded. A router-state unit test does not catch this; only a real browser does.goleaves nothing to pop, hence the screen’s own back button.The app route is
/#/docs(Flutter web uses hash routing). A bare/docsis the backend’s own page. Keep the two apart if you serve both from one origin.Swagger UI loads its JS and CSS from
cdn.jsdelivr.net. Air-gapped, the frame renders empty (console:SwaggerUIBundle is not defined)./openapi.jsonis unaffected. Vendoringswagger-ui-distwould fix it at the cost of a copied bundle and license to keep in sync. See Browsing the API documentation.
Authentication¶
Accounts are multi-user. Anyone can POST /auth/register unless
DINATOS_ALLOW_REGISTRATION=false. The first account on a fresh instance
becomes admin and can always register, so a locked-down instance can still be
bootstrapped. Admins can create accounts with POST /admin/users regardless
of the flag (/admin/* sits behind the require_admin dependency).
GET /auth/config is the public endpoint the login screen reads to decide
whether to show “Register”. POST /auth/login returns a bearer token.
Sessions¶
Each login creates an AuthSession row (a server-side session). The token is
a random high-entropy string (services.auth.generate_session_token). Only
its SHA-256 is stored. Every request hashes the presented token and looks the
session up (services.auth.get_valid_session).
Why not a signed JWT: a stateless token cannot be revoked before it expires,
so POST /auth/logout could not honestly do anything. The app also loads the
User row on every request anyway (so a deleted account stops working at
once), so a signed token saved no database work. Do not reintroduce JWT
machinery (pyjwt, a signing-key setting) without weighing that.
POST /auth/logoutrevokes exactly the session its token names (AuthSession.revoked_at). The account’s other sessions stay open.A revoked, expired or unknown session gives the same
401and message. The response never reveals which.Expiry is checked in SQL (
expires_at > func.now()), not in Python. This avoids timezone-awareness mismatches between the driver and Python.The default lifetime is 30 days (
session_ttl_days). It is long because sessions are revocable: revoking (logout, or deleting theAuthSessionrow of a stolen device) is the real mitigation, and the TTL is a backstop. This avoids forcing a re-login mid-workout.
Password and token hashing¶
Secret |
Hash |
Why |
|---|---|---|
Password |
Argon2 ( |
Passwords are low-entropy. A fast hash would let anyone who steals |
Session token, API key |
Plain SHA-256 |
Already 256 bits of randomness, nothing to brute-force. Argon2 would only add request latency. |
Never swap the two.
API keys¶
A person can create named API keys (POST /api-keys; Settings -> API keys in
the app) so tools, chiefly the MCP server, act as them without
holding a password or login session.
A key is
dnk_plus 32 random bytes. Only its SHA-256 is stored (models/api_key.py,services/api_keys.py).The create response is the only time the key is shown. Listing returns the name, created and last-used times, and the last three characters (
suffix).Deleting a key revokes it at once.
last_used_atis refreshed at most once a minute, so using a key is not a write on every request.Limit: 25 keys per account.
get_current_user (api/deps.py) accepts either credential: the dnk_
prefix selects the key lookup, anything else the session lookup.
get_session_user accepts a login session only. It guards key management
(a leaked key cannot mint more or hide itself), logout, and every /admin/*
route (a key cannot back up or restore the server).
Backup and data export¶
Two features share one idea, a versioned JSON document with format,
format_version, exported_at and app_version (schemas/backup.py). They
differ in everything else.
Full server backup (admin only)¶
GET /admin/backup downloads every table as {"tables": {name: [row, ...]}}.
POST /admin/backup/restore (multipart file plus form field
confirm=true; without it, a 400 and nothing is read) resets the whole
server to that document. Code: services/backup.py.
Generic over table metadata. Every column of every table in
BACKED_UP_TABLESis exported with its original primary key, enums as their value, timestamps as UTC ISO 8601.A new column needs no change here. A column added after a backup was made is filled from its nullable/default on restore.
A new table does:
tests/services/test_backup_coverage.pyfails until the table is inBACKED_UP_TABLESor inEXCLUDED_TABLESwith a reason.Bump
FORMAT_VERSION(schemas/backup.py) only for a change an old reader cannot interpret (a column or table removed or re-meant).
Validate, then replace.
parse_backupchecks the whole document before touching the database: format name and version, exact table set, each column’s presence/type/length/enum, primary-key uniqueness, every foreign key resolving, and at least one admin (a backup that would lock everyone out is refused). Problems give a 422 listing all of them.restore_backupthen deletes every table (children first) and inserts the document (parents first) in one transaction. If the database still rejects something, everything rolls back.Postgres sequences. Inserting explicit ids leaves serial sequences behind, so the next normal insert would collide.
reset_sequencesrunssetval(pg_get_serial_sequence(...), max(id)+1)per autoincrement key, on Postgres only (sqlite derives the next rowid from the table). The SQL is unit-tested and the dialect switch is tested with a stub, but no test runs it against a real Postgres.Secrets. The file contains password hashes and stored WorkoutX API keys in the clear, because a restore must give a working server. Treat it as a credential.
Sessions.
auth_sessionsis excluded: a row is a live login, so backups must not carry them and restores must not resurrect them. After a restore every login is gone, except the calling admin’s own session, kept only if the backup has a user with the same id and the same email (session_keptin the response). Id alone is not enough: on a fresh instance the backup’s user 1 may be a different person. Otherwise the caller gets a 401 on their next request, like everyone else.
A user’s own data (/profile/export, /profile/import)¶
Code: services/user_export.py. Not generic: a hand-shaped, id-free
document (UserExport) of profile settings, the exercises the data uses,
routines, activities and body measurements. It never contains the password
hash, sessions, import records, the calendar token or the WorkoutX API key
(a credential that stays on the server it was typed into; an import ignores
one if a file includes it).
Exercises are global, so “the user’s custom exercises” means those their routines and activities reference. They travel with their definition and are matched on import by exact name: an existing exercise (built-in or custom) is reused and never modified; an unknown name becomes a custom exercise. An exercise the file does not define and the server lacks is a 422.
Ids are remapped. The file contains no database ids. An activity names its routine by
routine_ref, the routine’s 1-based position in the file’sroutines.Merge (default) keeps everything in the account and skips what is already present, so importing twice is harmless. Present means: same routine name; same activity title and start time; same measurement timestamp. Presence is judged against the account as it was before the import, so two same-named routines inside one file both arrive. An activity whose routine was skipped is linked to the existing routine.
Replace (
?mode=replace) first deletes the caller’s routines, activities and measurements. Profile settings are applied in both modes.DELETE /profile/dataclears the account: routines, activities, measurements, import records, and every custom exercise that no remaining routine or activity (anyone’s) references. Exercises are shared, so one another account uses survives. Settings and the user stay.Every row written gets
owner_idset to the caller; nothing else is touched. A file is fully validated (shape by pydantic, then references) before the first write, and applied in one transaction.
When you add a table or column¶
New table: add it to
BACKED_UP_TABLES(the coverage test forces this).New user-owned field: also add it to the
Exported*models inschemas/backup.pyand toservices/user_export.py. No test can know a column is user-portable, so this is on you.Extend
tests/backup_seed.pywith a value for it. The roundtrip tests intests/api/test_user_export_roundtrip.pycompare whole documents.
Calendar feed¶
GET /calendar/{token}.ics serves the caller’s planned workouts as an
iCalendar document (services/calendar.py) that Google, Apple and Outlook
calendars subscribe to by URL.
Calendar apps cannot log in, so this is the one unauthenticated data route.
The unguessable token in the path (UserProfile.calendar_token,
secrets.token_urlsafe(32)) is the credential.
It is stored in the clear, unlike an API key, because it is shown again so the person can re-copy the link. It is therefore excluded from the per-user export but, like every profile column, included in the admin backup.
POST /profile/calendarcreates or rotates it (the old link then 404s).DELETEturns the feed off.The document is generated on every request, so it is never stale. The feed asks subscribers to re-fetch hourly (
REFRESH-INTERVAL,X-PUBLISHED-TTL); Google Calendar polls only every several hours anyway.Events have a stable
UID(planned-<id>@dinatos) and aSEQUENCEfromupdated_at, so an edit updates the event instead of duplicating it, plus aVALARMfrom the reminder lead time. The feed covers the last 90 days onward.The client builds the full URL (
<server>/calendar/<token>.ics) from its own server address, since only it knows how the server is reached.
MCP server¶
MCP (Model Context Protocol) lets an AI client call tools on a server. The
API hosts one at /mcp (streamable HTTP, api/mcp.py), so a client needs
only the server URL and an API key.
Stateless: the key travels with every request, so it works behind any proxy.
Auth:
McpGatewayaccepts onlydnk_API keys and checks them against the database up front, so a bad key fails at connection time.Tools call the REST API in-process with that same key over an ASGI transport. The MCP therefore does exactly what the app can for that person, with the same validation and ownership rules, and no second copy of the business logic.
Lifecycle: the app’s lifespan starts and stops the transport (
McpGateway.running()). A fresh one is built each time so tests can start it per test.Tools: thin wrappers over exercises, routines and activities; planned workouts (
list_planned_workouts,schedule_workout,update_planned_workout, aPATCHwhere only passed arguments change, anddelete_planned_workout); andget_persona_stats.Persona parity:
get_persona_statsrunsservices/personas.py, a Python port of the app’s on-device persona scoring (frontend/lib/features/personas/). Both test suites run a shared fixture,frontend/test/fixtures/persona_parity.json. Change the scoring in one place and the other’s test fails until it matches.
mcp_server/ (package and entry point dinatos-mcp) is a small stdio proxy
to /mcp, for clients that can only launch a local program. It defines no
tools, so it cannot disagree with the server. It is configured only by
DINATOS_MCP_BASE_URL and DINATOS_MCP_API_KEY; there is no password or
session option. It is its own uv project and imports dinatos_backend only
in its tests, which run it against the real app in-process. To point a client
at either, see MCP server.
Hevy import¶
Hevy is a workout-tracking app.
POST /imports/hevy/workouts and /imports/hevy/measurements take its CSV
exports (Settings -> Export in Hevy) as a multipart upload. User-facing flow:
Importing from Hevy.
Parsing. The workout CSV has one row per set. Rows are grouped into activities by
(title, start_time, end_time)and into exercises by watchingset_indexreset to0. Seeservices/hevy_import.py.Timestamps. Hevy writes two formats: Android
1 Jan 2026, 08:00and web/PCJan 1, 2026, 8:00 AM._parse_timestampaccepts both, and tests are parametrized over both.Fixtures.
backend/tests/fixtures/hevy_*.csvare synthetic (warm-ups, dropsets, supersets, bodyweight, cardio) so nobody’s real history is in a public repo.One-shot, not a sync. The file’s SHA-256 (not its name; Hevy names every export the same) is recorded as a
HevyImportRecord. Re-importing identical content gives409 Conflictnaming when and under what filename it was imported.?force=trueimports it anyway. A changed file is not a duplicate.App side. Profile -> “Import from Hevy” (
frontend/lib/features/imports/).HevyImportRepositoryturns the409into a typedHevyImportAlreadyDoneException, shown as a confirm dialog that retries with?force=true.
Guessing metadata for created exercises¶
Hevy’s CSV has only an exercise title. A title matching nothing in the seeded
catalog (services/hevy_exercise_match.py) becomes a custom exercise, and
services/hevy_exercise_infer.py guesses its metadata. It is deterministic
and makes no network or LLM call.
Equipment: from Hevy’s parenthesised suffix (“Row (Dumbbell)”) mapped to the
Equipmentenum. An unknown suffix is ignored. Equipment words in the name (“Dumbbell Row”) are the fallback.Muscles: from the most similar seeded exercises. Names are compared as word sets with equipment and filler words removed (Dice coefficient, threshold 0.6). Up to three neighbours vote, weighted by similarity. If none qualifies, an ordered movement-keyword table is tried (row -> back, curl -> biceps, …). If that also fails, muscles stay empty rather than wrong.
Only seeded exercises are neighbours, never a person’s own or earlier guesses, so a result does not depend on what else was imported.
The workouts response lists each created exercise in
created_exerciseswithequipment_guessedandmuscles_guessedflags. The app shows a “please review these” card linking to each edit screen.Re-importing creates and infers nothing: existing exercises, including ones the person edited, are never re-inferred.
Intervals.icu import¶
Intervals.icu is a training-analytics service. This is a one-time import of chosen activities from it, for someone who also tracks there. Like Hevy it is a one-shot migration, not a sync, but the source is an API, which changes how duplicates are caught. Nothing runs in the background. User-facing flow: Intervals.icu import.
Code: services/intervals_client.py (HTTP), services/intervals_import.py
(mapping, selection, duplicates), api/routers/intervals_imports.py,
models/intervals_import.py.
Two steps, both POST¶
POST /imports/intervals/previewfetches activities in a date window (oldest;newestdefaults to today) and returns them annotated. It writes nothing.POST /imports/intervals/activitiestakes the same window plusactivity_ids(the selection) and imports exactly those.
The import re-fetches the window instead of trusting data sent by the client, so what is written is always what Intervals.icu says. An id missing from the window, or unimportable, is a
422and nothing is written.Both are
POSTbecause the API key travels in the body. It is used for that request and never stored: not in the profile, a backup or an export, and never echoed (a test checks this).The athlete id must match
^(0|i?\d+)$because it becomes a URL path segment. The base URL is a constant, so the endpoint cannot be pointed at another host.A key Intervals.icu rejects returns
400, deliberately not401: the app logs the user out on a 401 from this API, and this is not their Dinatos session. Intervals.icu being down is a502.
Mapping¶
Each activity becomes one
Activity(title from its name, start fromstart_date_local, end from elapsed time) with one exercise named for its sport, holding one set with the moving time and distance.Sport names come from a small table (
Run-> “Running”); otherwise the type is split on capitals. An exercise of that exact name is reused, else a custom one is created tracking duration (and distance if any selected activity had it).There is no per-exercise strength data, so
WeightTrainingis just a timed entry.Timestamps are stored as naive wall-clock time, like Hevy’s.
Strava-sourced activities arrive from Intervals.icu as a stub (an
idand a_note). They are listed as unimportable, not dropped, so the person sees why they are missing.
Duplicate handling: per activity, not per file¶
A Hevy export has no ids, so a whole-file hash is the only check. An
Intervals.icu activity has a stable id, so each import is recorded per
activity in IntervalsImportedActivity: unique on (owner_id, intervals_id),
pointing at the Activity it created. This also catches overlapping date
ranges, which a file hash cannot.
A request including an already-imported activity is rejected whole with
409(nothing written) naming those ids and import times.?force=trueimports them again, creating a secondActivityand repointing the existing record at it.Duplicates are scoped per
owner_id.The selection is written in one transaction. If two requests race, the unique constraint makes the loser fail at commit; it is rolled back and reported as the same
409.A record counts only while its
Activitystill exists (the lookup joins to it; sqlite, which tests use, has no cascade to rely on). Deleting an imported activity makes it importable again withoutforce. For the same reason the records are deleted with activities inreplaceand inDELETE /profile/data.
Overlap with Hevy. Hevy can sync to Intervals.icu, so the same workout may
already be in Dinatos. The preview flags (possible_duplicate_of, the title
found) any activity starting within 30 minutes (DUPLICATE_WINDOW) of an
existing one for the same owner, and the app leaves it unticked. This is
advisory only: the backend never skips a selected activity for it, because
only the person knows if it is the same session.
Backup and app¶
The records table is in
BACKED_UP_TABLES(restores keep theiractivitiesforeign keys) but not in the per-user export, like Hevy’s.App code:
frontend/lib/features/imports/intervals_import_*(Profile -> Import from Intervals.icu). The list ticks only what is importable, not already imported and not a possible duplicate. It remembers the key and window used for the fetch so later form edits do not leak into the import. A409(IntervalsAlreadyImportedException) opens the same “import again anyway?” dialog as Hevy.Tests fake Intervals.icu with an
httpx2.MockTransportinjected via theget_intervals_clientdependency. No test touches the network.
Exercise tutorials¶
GET /exercises/{id}/tutorial returns images or a GIF plus
instructions, muscles and equipment, from the active provider. The response’s
source says which provider answered; the app shows it under the images.
Provider |
When |
Notes |
|---|---|---|
|
Default, no setup |
Vendored public-domain dataset from yuhonas/free-exercise-db ( |
Opt-in per user |
Real animated GIFs. The user saves their own API key in Settings ( |
WorkoutX design decisions:
No instance-wide key. The project never holds a shared key, and one user’s lookups are never served from or billed to another’s account.
Key stored as-is and write-only. It must be sent to WorkoutX on every lookup, so a hash would be useless.
GET /profileonly reportshas_workoutx_api_key.GIFs are proxied. WorkoutX GIF URLs also need the key, and an
<img>or FlutterImage.networkcannot send theX-WorkoutX-Keyheader (nor should a key go in a query string, where it leaks to the client and logs). The provider rewrites each GIF URL to/exercises/media/workoutx/<id>.gif, an authenticated endpoint that fetches the GIF with the caller’s key and streams it back through the app’s normal bearer-token client.Fallback.
FallbackTutorialProvider(services/tutorials/fallback.py) answers from free-exercise-db if WorkoutX fails (bad key, quota, outage) or has no match, so opting in never loses a tutorial. The failure is logged as a server warning.
Providers implement the TutorialProvider protocol
(services/tutorials/base.py), so a new one is a new adapter.
services.tutorials.get_tutorial_provider_for_key picks one per request:
WorkoutX for users with a saved key (one cached provider per distinct key),
the bundled dataset otherwise.
Cache. Every lookup goes through an in-memory cache
(services/tutorials/cache.py) that lives for the process only and is
deliberately never persisted. WorkoutX’s terms prohibit bulk-caching or
scraping, so tutorials are fetched one at a time, only when someone opens
one, never as a catalog sync. A persistent cache would also bring staleness,
reconciliation and licensing questions about storing someone else’s data. A
restart costs one re-fetch per exercise viewed.
Container image¶
The root Dockerfile (build context: the repo root, since it also needs
frontend/) has three stages:
frontend-builderdownloads Flutter’s currentstableand runsflutter build web --release.builderresolves and installs the backend withuv.runtimeis a slim image with the backend venv frombuilderandbuild/webfromfrontend-builder, placed atweb/under/app, whereSettings.web_direxpects it.
It runs as a non-root numeric uid. docker-entrypoint.sh runs
alembic upgrade head and then exec uvicorn ..., so uvicorn is PID 1 and
shuts down cleanly on SIGTERM.
No pinned Flutter version. Nothing else in the repo pins one either (CI and the release workflow use
subosito/flutter-action@v2withchannel: stable), so a pin here would be a second source of truth. The stage asks Google’sreleases_linux.jsonfeed which archivestableis and downloads that.--no-editableis required on the finaluv sync --locked --no-dev. The default editable install points at the builder’s/app/src, which does not exist inruntime, soimport dinatos_backendfails at container start.docker buildstill succeeds; only running the image shows it. SeeAGENTS.md.
docker-compose.yml wires the image to Postgres; see
Deploying with Docker Compose. just docker debug
attaches nicolaka/netshoot (ping,
dig, tcpdump, curl, …) to the running container’s network namespace. It is
not in the Dockerfile on purpose, so the runtime image stays small; it is
pulled on first use.
Serving the bundled web app¶
_mount_web_ui in main.py mounts a StaticFiles subclass (_WebApp) at
/, only if Settings.web_dir (default web, relative to the working
directory) exists. That is true in the Docker image and false everywhere else
(just backend run, all tests), where behaviour is unchanged.
It is mounted last, after every API router and
/health, so API routes always match first._WebApp.get_responsefalls back toindex.htmlon a 404. This rarely matters: Flutter web’s hash routing (/#/routines/1) never sends the client-side route to the server, so browsers only request/.The web build needs no
--dart-define=API_BASE_URL.ApiConfigdefaults a web build to the origin that served it, which is this backend. To build against a separately hosted backend, see Clients.